Я думал, у меня всё чисто. Bandit стоял, правила регулярно обновлялись, pipeline на каждый PR ругался на опасные места. Ну, знаете, как это бывает: где-то что-то подсветит, мы правим, живём дальше, чувствуем себя молодцами, прикрыв тыл статическим анализом.

А потом я решил копнуть глубже. Не для галочки, а руками, с инструментом, который умеет ходить по коду не линейно, а как по графу.

Знаете, сколько нашлось?

57 мест, где код делает небезопасные вызовы. Не "потенциально опасные", а реально стрёмные. Восемь из десяти моих автоматических гипотез об уязвимостях отвалились как ложные. Но четыре — подтвердились. Итоговая оценка безопасности моей собственной кодовой базы (Python, около 40 тысяч методов), которую я считал "нормально закрытой", — 4.6 из 10.

И самое бесячее: Bandit всё это время был в проекте. Он видел код. Он молчал. Либо орал на то, на что орать не надо. Это не история про то, какой Bandit плохой. Это история про то, почему классические SAST-ы слепые, и почему графы свойств кода (Code Property Graph, CPG) видят то, что они прячут.

Как работает традиционный SAST

Классический подход (SonarQube, Semgrep, Fortify, Bandit) состоит из трёх шагов:

  1. Парсинг: исходный код превращается в синтаксическое дерево (AST).

  2. Матчинг: по дереву пробегают правила вида «нашёл вызов execute со строковым аргументом — бей тревогу».

  3. Отчёт: все совпадения — в список.

Проблема на втором шаге. Инструмент не отвечает на вопросы:

  • Откуда взялась строка? Это пользовательский ввод или константа?

  • Прошла ли строка через функцию очистки (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, а его надстройка. Это унифицированная структура, объединяющая четыре представления кода:

  1. AST (синтаксис): что написано.

  2. CFG (поток управления): как выполняется (порядок инструкций).

  3. PDG (программные зависимости): откуда что зависит.

  4. 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.sqlexecute_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 я ищу потоки недоверенных данных.

В следующей заметке расскажу, как тот же граф свойств кода помогает отвечать на вопросы онбординга новых разработчиков: «Откуда берётся эта переменная?», «Кто вызывает этот метод?» и «Почему этот код здесь лежит?».