Я думал, у меня всё чисто. Bandit стоял, правила регулярно обновлялись, pipeline на каждый PR ругался на опасные места. Ну, знаете, как это бывает: где-то что-то подсветит, мы правим, живём дальше, чувствуем себя молодцами, прикрыв тыл статическим анализом.
А потом я решил копнуть глубже. Не для галочки, а руками, с инструментом, который умеет ходить по коду не линейно, а как по графу.
Знаете, сколько нашлось?
57 мест, где код делает небезопасные вызовы. Не "потенциально опасные", а реально стрёмные. Восемь из десяти моих автоматических гипотез об уязвимостях отвалились как ложные. Но четыре — подтвердились. Итоговая оценка безопасности моей собственной кодовой базы (Python, около 40 тысяч методов), которую я считал "нормально закрытой", — 4.6 из 10.
И самое бесячее: Bandit всё это время был в проекте. Он видел код. Он молчал. Либо орал на то, на что орать не надо. Это не история про то, какой Bandit плохой. Это история про то, почему классические SAST-ы слепые, и почему графы свойств кода (Code Property Graph, CPG) видят то, что они прячут.
Как работает традиционный SAST
Классический подход (SonarQube, Semgrep, Fortify, Bandit) состоит из трёх шагов:
Парсинг: исходный код превращается в синтаксическое дерево (AST).
Матчинг: по дереву пробегают правила вида «нашёл вызов
executeсо строковым аргументом — бей тревогу».Отчёт: все совпадения — в список.
Проблема на втором шаге. Инструмент не отвечает на вопросы:
Откуда взялась строка? Это пользовательский ввод или константа?
Прошла ли строка через функцию очистки (sanitizer)?
Достигает ли строка действительно опасной точки (приёмника)?
Классический пример ложного срабатывания Bandit:
# Пример из моего реального кода def execute_query(self, sql: str, params=None): # Bandit видит: execute() + строка -> SQL-инъекция (B608) result = self.conn.execute(sql, params) return result.fetchall()
Bandit кричит. Но если посмотреть на вызов этой функции, окажется, что sql берётся не из HTTP-запроса, а из внутреннего конфига. Пользовательские данные до этого execute() просто не доходят. Это ложное срабатывание.
Что такое Code Property Graph (CPG)
Граф свойств кода — это не замена AST, а его надстройка. Это унифицированная структура, объединяющая четыре представления кода:
AST (синтаксис): что написано.
CFG (поток управления): как выполняется (порядок инструкций).
PDG (программные зависимости): откуда что зависит.
DDG (зависимости по данным): как путешествуют значения.
Если вы работали с Roslyn (.NET), вы знакомы с этой логикой: есть синтаксическое дерево, а есть семантическая модель. CPG делает то же самое, но для многих языков сразу, и позволяет ходить по графу запросами.
Когда эти графы склеены, анализатор может ответить на вопрос, недоступный синтаксическому анализу: «Существует ли путь от источника (пользовательский ввод) до приёмника (SQL-запрос), который не проходит через функцию очистки?» Это называется taint-анализом.
Как CPG-подход нашёл то, что пропустили правила
Покажу на примерах из того же самого кода, где Bandit ошибался или молчал.
Пример 1. Межпроцедурная цепочка (то, что правила пропускают)
Рассмотрим другой метод в моём проекте, который не использовал параметризацию:
# src/api/routers/query.py @router.post("/query") async def execute_query(request: QueryRequest): # Данные пришли от пользователя result = await cpg_service.run_query(request.sql) return result # src/services/cpg/base.py def run_query(self, sql: str): # Опасная точка! Здесь нет params return self.conn.execute(sql)
Что сделает традиционный анализ?
Вариант А: увидит
execute(sql)вbase.py. Но не поймёт, откудаsql. Если правило не умеет в межпроцедурность, он либо промолчит (пропуск), либо будет орать на каждыйexecute(шум).Вариант Б: Semgrep с продвинутыми правилами (pro engine) может что-то найти, но он всё равно ограничен в анализе сложных путей через фреймворки.
Что сделает анализ на графе: построит цепочку request.sql → execute_query() → run_query() → conn.execute(). Путь от источника (пользователь) до приёмника (SQL) существует, очистки (санитайзеров) нет. Это CWE-89 (SQLi). Найдено.
Пример 2. Отсечение ложного срабатывания (то, где правила шумят)
# src/workflow/scenarios/audit_composite.py def _metrics_cte(self): # Формируем SQL sql = f""" SELECT * FROM nodes_method WHERE NOT is_test {self._method_filters} """ return self.cpg.execute(sql)
Традиционный анализ: видит f-string внутри execute(). Бьёт тревогу: SQL-инъекция!
CPG-анализ: смотрит на поток данных. Откуда взялось self._method_filters? Трассировка показывает, что это свойство объекта, которое инициализируется константой из конфигурационного файла (DEFAULT_METHOD_FILTERS). Пользовательский ввод туда не попадает. Срабатывание отсекается как ложное.
Пример 3. Обратные вызовы и фреймворки (слепая зона правил)
# src/api/main.py app = FastAPI() @app.middleware("http") async def add_request_id(request: Request, call_next): # request.headers приходят от пользователя request_id = request.headers.get("X-Request-ID", str(uuid4())) response = await call_next(request) response.headers["X-Request-ID"] = request_id return response
Традиционный анализ: видит функцию add_request_id. В коде проекта никто не вызывает add_request_id() явно. Инструмент думает: «функция есть, но мёртвая, анализировать не буду». Он не знает, что FastAPI вызовет её сам при каждом запросе.
CPG-анализ: парсер знает о декораторах фреймворков. Он помечает add_request_id как is_entry_point. Строится граф потоков от request.headers. Если этот request_id уйдёт без валидации в лог или базу, анализ это зафиксирует.
Дьявол в деталях: семантика стандартной библиотеки
Построить граф внутри своего кода — полдела. Самое сложное — понять, что происходит в вызовах стандартных библиотек. У нас нет исходников strcpy, json.loads или subprocess.Popen. Чтобы не терять след данных, нужно описать семантику этих функций.
Обычно это решается хардкодом на Scala или Python. Альтернативный подход — описание в YAML:
# Описание семантики для функции strcpy function: "strcpy" from_args: [1] # Данные перетекают из 1-го аргумента (источник) to_args: [0] # Данные перетекают в 0-й аргумент (приёмник) is_sink: true # Это опасный приёмник sink_type: "buffer_overflow" global_writes: ["errno"] # Функция имеет побочный эффект (меняет глобал)
Такое описание позволяет межпроцедурному анализу точно моделировать потоки данных, не имея исходников libc. В моём случае я собрал 733 таких отображения для 11 языков.
Побочные эффекты — важная деталь. Например, функция strtol меняет errno. Без отслеживания global_writes анализ достижимых определений (reaching definitions) пропустит факт, что после вызова strtol глобальная переменная errno может содержать новое значимое значение.
Что я нашёл на самом деле
Я применил CPG-анализ к собственной кодовой базе (41 034 метода, 2 170 файлов с учётом тестов). Результаты в сухом остатке:
57 небезопасных вызовов: в основном
subprocessсshell=Trueи пара случаевeval, куда теоретически можно было подмешать данные.1 секрет: пароль в конфигурации нашёлся не по паттерну (типа
password =), а потому что граф показал: значение переменнойdb_passwordберётся из конфига и уходит в коннект к БД, но при этом не помечено как пришедшее из безопасного хранилища секретов (env/vault).5 случаев слабой криптографии: важен контекст. MD5 для генерации ключа кеша — ок. MD5 для хеширования пароля — плохо. Граф это отличает по тому, куда уходит результат хеш-функции.
Из девяти автоматически сгенерированных подозрений на уязвимости (гипотез) подтвердились четыре. Уровень подтверждения 44% — это нормально для поиска неизвестных проблем.
Для сравнения: Bandit выдал 98 предупреждений. Я их отфильтровал (убрал срабатывания на assert в тестах, привязку к 0.0.0.0 в Docker и т.д.). Но главное — Bandit в принципе не мог найти межфайловые цепочки из примера 1 или контекстно-зависимую криптографию.
Это не замена, а эволюция
Сопоставление с шаблоном было адекватно 20 лет назад, когда код помещался в пару файлов. Сегодня уязвимость — это цепочка из 10 вызовов, размазанная по трём файлам, с участием коллбэков фреймворка.
CPG-подход не отменяет статический анализ, а выводит его на новый уровень. Вместо поиска строки strcpy я ищу потоки недоверенных данных.
В следующей заметке расскажу, как тот же граф свойств кода помогает отвечать на вопросы онбординга новых разработчиков: «Откуда берётся эта переменная?», «Кто вызывает этот метод?» и «Почему этот код здесь лежит?».
