Проверка репозитория до запуска: как устроить безопасный статический контроль

Разделяем проверяющий инструмент и недоверенный проект, вводим правила ревью и проверяем их ограничения.

После нескольких разборов хочется автоматизировать проверку: искать автозадачи, установочные хуки и динамическое исполнение. Но возникает неприятная ловушка. Команда «сначала запустим линтер» сама может выполнить код из проверяемого проекта.

Поэтому задача этой статьи — не собрать максимальный список запретов. Нужно устроить процесс так, чтобы проверяемые файлы оставались данными, а решения о запуске принимались отдельно.

Кто управляет проверкой?

У ESLint есть конфигурации eslint.config.js, .mjs, .cjs и другие поддерживаемые варианты. Конфигурация может импортировать модули и плагины. Значит, запуск линтера с настройками неизвестного проекта — это не просто чтение текста: он включает загрузку исполняемой конфигурации. [1]

Сначала проверяющий инструмент, его правила и зависимости должны находиться в доверенном окружении. Подозрительный репозиторий передаётся туда только как материал. Это относится и к скрипту под названием security-check, и к предложенному README вызову npx, и к конфигурации CI из самого репозитория.

Особенно опасно переносить непроверенный код на постоянный CI-исполнитель с секретами или доступом во внутреннюю сеть. Документация GitHub прямо предупреждает, что self-hosted runners могут быть скомпрометированы недоверенным кодом и сохранить изменения между запусками. [2]

Три слоя проверки

Первый слой — состав материала. Получаем перечень файлов, размеры, ожидаемые форматы и контрольные суммы. Для архивов заранее задаём пределы количества и суммарного размера распакованных данных, проверяем пути, ссылки и повторяющиеся имена. Открытие архива не равно исполнению его скриптов, но само распаковывание тоже требует аккуратности. [3]

Второй слой — признаки исполнения. Читаем .vscode, package.json, .npmrc, точки входа и конфигурации инструментов. Не ограничиваемся расширениями .js: предыдущая статья о поддельном шрифте как раз показывает, почему такой фильтр недостаточен.

Третий слой — связная проверка находки. Для подозрительной конструкции устанавливаем источник данных и достижимость вызова. Этот этап нельзя полностью заменить поиском слова eval.

Все три слоя выполняются без установки зависимостей исследуемого проекта. Если затем нужна динамическая проверка, это уже другая стадия с отдельной изоляцией и решением о допустимой сети.

Простой поиск: полезный, но ограниченный

В небольшой, уже безопасно подготовленной копии можно начать с доверенного rg:

1
2
3
4
rg --no-config --hidden --no-ignore --text --files-with-matches \
  --glob '!.git' --max-filesize 2M \
  'Function\s*\.\s*constructor|new\s+Function\s*\(|eval\s*\(|"runOn"\s*:\s*"folderOpen"|"(preinstall|install|postinstall|prepare)"\s*:' \
  -- /analysis/input

Путь /analysis/input здесь обозначает копию материала в подготовленной среде. Команда показывает имена файлов с совпадениями, а не запускает их. Она включает скрытые файлы и ищет текст независимо от расширения. Шаблон !.git исключает каталоги и файлы с этим именем, в том числе при передаче абсолютного пути. Символические ссылки по умолчанию не обходятся; их наличие проверяют при подготовке копии. Поведение параметров описано в документации ripgrep. [4]

Предел размера — осознанное ограничение примера, не способ объявить большие файлы безопасными. Их нужно учесть отдельно. Как и ошибки чтения, неподдержанные кодировки, многослойную обфускацию и конструкции, записанные иначе. У rg код 0 означает найденные совпадения, 1 — отсутствие совпадений, 2 — ошибку. Ошибку нельзя выдавать за чистый результат; отсутствие совпадений тоже не превращает поиск в антивирус.

Что делать с результатом

Полезно разделить два типа правил.

Политика процесса: «В принимаемом тестовом задании запрещён автозапуск при открытии». Нарушение может блокировать приём, даже если задача безобидна. Причина блокировки — несоответствие политике, не доказанная малварь.

Техническая находка: «Ответ внешнего сервера передаётся в исполняемое тело функции и вызывается из импортируемого модуля». Здесь требуется разбор конкретной цепочки.

Исключения должны утверждаться вне проверяемого изменения. Комментарий eslint-disable или nosemgrep, добавленный автором подозрительного кода, не может сам себе разрешить опасное поведение. Для доверенных проектов линтеры с правилами no-eval и no-new-func полезны как часть обычной разработки. Для недоверенного материала сначала устанавливается граница между данными и инструментом. [1][5]

Semgrep позволяет задавать правила анализа структуры кода и текстовые шаблоны. Но готовое правило нужно проверять на своих примерах, включая переименованные файлы и заведомо легитимный код. Наличие названия инструмента в CI ещё ничего не говорит о покрытии. [6]

Задача: проверьте проверяющего

Предложены три способа:

1
2
3
А. Запустить npm run security-check из присланного проекта.
Б. Передать копию файлов локальному сканеру вне проекта, с доверенными правилами.
В. Собрать проект на постоянном CI-сервере, а потом просканировать результат.

Для отдельной проверки поискового шаблона возьмите literal-positive.txt, literal-negative.txt и flow.js.txt из практикума. Предскажите, какие файлы совпадут с шаблоном, и проверьте себя поиском. Для каждого совпадения укажите, откуда берётся тело функции и при каких условиях оно могло бы исполниться. Читайте примеры как текст.

Показать разбор

Разбор. В А порядок доверия перевёрнут: репозиторий сам определяет программу своей проверки. В Б появляется нужная граница, но ещё надо проверить изоляцию инструмента, лимиты и отсутствие исполнения конфигурации проекта. Здесь не предлагается загружать исходники в сторонний сервис: в них могут быть секреты и закрытая информация. В В недоверенный код уже выполняется до оценки, причём на потенциально ценной машине.

Шаблон совпадает в literal-positive.txt и flow.js.txt. В первом тело функции задано строковым литералом, а сама функция в этом фрагменте не вызывается. Во втором тело берётся из данных ответа. До вызова программа должна получить и разобрать ответ, прочитать нужное поле и успешно создать функцию. Поиск показывает место для проверки. Из текста можно вывести условия вызова, но для утверждения о состоявшемся исполнении понадобятся наблюдения за запуском. В literal-negative.txt есть только присваивание числа.

Что стоит запомнить

Автоматизация полезна тогда, когда экономит внимание и сохраняет проверяемость. Хороший результат статического контроля — список находок с причинами, ограничениями покрытия и решениями о допуске. Формулировка «угроз не найдено» без описания того, что проверялось, слишком мало сообщает следующему человеку.

Источники

[1] ESLint: Configuration Files.

[2] GitHub Actions: Secure use reference — self-hosted runners.

[3] Python: zipfile — предупреждения о распаковке и ограничениях.

[4] ripgrep: руководство по параметрам.

[5] ESLint: no-new-func.

[6] Semgrep: Rule syntax.

Создано при помощи Hugo
Тема Stack, дизайн Jimmy