Безопасность приложений Безопасная разработка: практики, ревью, SAST/DAST, security-чемпионы
0%

Безопасная разработка: практики, ревью, SAST/DAST, security-чемпионы

Безопасная разработка: практики, ревью, SAST/DAST, security-чемпионы

Тринадцать предыдущих статей трека — от моделирования угроз до цепочки поставок — отвечали на вопрос «как устроена конкретная уязвимость и как её починить». Этой статьёй мы отвечаем на другой вопрос: как сделать так, чтобы такие уязвимости перестали появляться заново. Разница принципиальная. Знание о том, что параметризованный запрос лечит SQL-инъекцию, живёт в голове одного человека. Процесс, в котором непараметризованный запрос физически не проходит в ветку main, живёт в организации и работает, когда этот человек в отпуске, уволился или просто устал в пятницу вечером.

Безопасность приложения — это свойство процесса разработки, а не результат финальной проверки. Утверждение звучит как лозунг, но у него есть измеримое содержание: если единственный контроль — пентест перед релизом, то стоимость каждой находки включает переделку архитектуры, а количество находок пропорционально объёму кода, написанного с прошлого пентеста. Если контроли распределены по всему циклу, большая часть дефектов гасится там, где стоимость правки — минуты.

Статья написана с позиции защищающейся стороны и легального тестирования. Всё, что здесь описано, применяется к системам, которыми вы владеете, либо при письменном разрешении владельца с зафиксированным объёмом, окном и контактом для эскалации. Сканер, направленный на чужой сервис без договора, — не проверка качества, а инцидент, и в большинстве юрисдикций правонарушение. Об этом дальше есть отдельный раздел.

Опорные документы: NIST SP 800-218 SSDF — практики безопасной разработки; OWASP SAMM и BSIMM — модели зрелости программы; OWASP ASVS — проверяемые требования; OWASP Proactive Controls — что делать, а не чего бояться; OWASP Code Review Guide и OWASP WSTG — методики ревью и тестирования.

Три способа не иметь уязвимости

Прежде чем обсуждать инструменты, полезно разложить проблему по способам её решения. Классов ровно три, и они не взаимозаменяемы.

Первый — сделать ошибку невозможной. Уязвимости данного вида не бывает, потому что язык, фреймворк или ваша собственная библиотека не дают её выразить. Управляемая память убирает переполнение буфера. Шаблонизатор с автоэкранированием по контексту убирает большинство отражённого XSS. ORM с параметризацией по построению убирает классическую SQL-инъекцию. Это самый дешёвый способ: он работает без дисциплины, без ревью и без сканеров.

Второй — сделать ошибку заметной. Ошибку написать можно, но она немедленно всплывает: не компилируется, падает тест, срабатывает правило линтера, ревьюер видит подозрительный вызов. Стоимость — настройка и поддержка контроля плюс постоянная борьба с ложными срабатываниями.

Третий — обнаружить последствия. Ошибка уехала в прод, и мы узнаём о ней от мониторинга, пентестера, исследователя или атакующего. Самый дорогой способ, но полностью отказаться от него нельзя: только он ловит то, что не выразимо в правилах.

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

Экономика: почему важна не находка, а расстояние до неё

Пять вложенных петель обратной связи вокруг строки кода: редактор, pre-commit, PR и CI, ночной прогон, продакшен

Классический тезис «баг дешевле чинить раньше» восходит к работам Барри Боэма 1980-х годов, и приводимые множители (1x в дизайне против 100x в проде) многократно оспаривались как непереносимые на современные практики — разбор есть у Лорана Бошара и в критике оригинальных данных. Но для безопасности порядок соотношения устойчив по другой причине, и она не про сам код.

Правка уязвимости, найденной в редакторе, — это одна строка и ноль координации. Та же правка после релиза — это расследование «сколько данных утекло», решение об уведомлении пользователей и регулятора, экстренный релиз вне окна, откат зависимых сервисов, ротация всех секретов, которые могли быть доступны, и постмортем. Дорожает не изменение кода, а контекст вокруг изменения.

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

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

Что должно быть в коде: безопасность по построению

Безопасные умолчания вместо правил в вики

Разберём типовой случай по схеме «уязвимый код → почему работает → как чинить → как проверить». Начнём с самого коварного — с внутреннего API, который небезопасен по умолчанию.

Как выглядит уязвимый код. Команда написала удобную функцию для отрисовки писем и виджетов:

# api/render.py — «удобная» функция, доступная всему коду
def render_block(title: str, body_html: str) -> str:
    """Собирает HTML-блок. body_html вставляется как есть — так задумано для писем."""
    return f'<div class="block"><h3>{title}</h3>{body_html}</div>'

# вызов где-то далеко, спустя год и три команды
html = render_block(user.display_name, comment.text)

Почему это работает. Сигнатура функции не различает «уже безопасный HTML» и «строка от пользователя»: оба параметра имеют тип str. Автор функции знал про разницу, автор вызова — нет. Ни один из них не сделал ничего явно неправильного, а результат — хранимый XSS (CWE-79, механика разобрана в статье про XSS и CSRF). Это CWE-1173 в широком смысле — небезопасное умолчание: чтобы сделать правильно, надо помнить, а чтобы сделать неправильно, достаточно ничего не знать.

Как чинить правильно. Требуемое свойство — «небезопасное действие должно быть невыразимым без явного акта». Технически это отдельный тип для доверенного значения:

# security/markup.py — единственное место, где рождается доверенный HTML
from dataclasses import dataclass
from html import escape

@dataclass(frozen=True)
class SafeHtml:
    """Строка, про которую доказано, что она безопасна в HTML-контексте."""
    value: str

def esc(text: str) -> SafeHtml:
    """Единственный законный способ превратить произвольный текст в разметку."""
    return SafeHtml(escape(text, quote=True))

def trusted_literal(source: str, *, reason: str) -> SafeHtml:
    """Аварийный люк: только для литералов в коде, требует письменного обоснования.

    Проверяется правилом линтера: аргумент обязан быть строковым литералом,
    а вызов — сопровождаться ссылкой на ревью.
    """
    return SafeHtml(source)

# api/render.py — теперь тип не даёт ошибиться
def render_block(title: str, body: SafeHtml) -> SafeHtml:
    # title экранируется внутри, body уже доказан вызывающей стороной
    return SafeHtml(f'<div class="block"><h3>{escape(title)}</h3>{body.value}</div>')

# вызов: mypy не пропустит передачу голого str
html = render_block(user.display_name, esc(comment.text))

В TypeScript тот же приём выражается брендированным типом (type SafeHtml = string & { readonly __brand: "SafeHtml" }), а в браузере он уже стандартизован как Trusted Types: заголовок CSP require-trusted-types-for 'script' превращает присваивание произвольной строки в innerHTML в ошибку времени выполнения, а единственным легальным путём остаётся именованная политика — тот же «аварийный люк», только на уровне платформы.

Как проверить, что починено. Три независимых проверки, ни одна из которых не требует помнить о правиле:

  1. Компилятор/mypy в CI — вызов render_block(name, comment.text) перестаёт проходить типизацию. Это самая дешёвая петля.
  2. Правило SAST на «аварийный люк» — любой вызов trusted_literal вне списка разрешённых мест падает в PR.
  3. Регрессионный тест — конкретная полезная нагрузка, которая раньше исполнялась, теперь приезжает как текст.
# tests/security/test_markup.py
import pytest
from security.markup import esc
from api.render import render_block

@pytest.mark.parametrize("payload", [
    "<script>alert(1)</script>",
    '"><img src=x onerror=1>',
    "<svg/onload=1>",
])
def test_user_text_never_becomes_markup(payload):
    out = render_block("Заголовок", esc(payload)).value
    # признак успеха: ни одного исполняемого тега, исходный текст виден как текст
    assert "<script" not in out and "onerror" not in out and "onload" not in out
    assert "&lt;" in out

Такие тесты стоит держать отдельной папкой tests/security/ и запускать в основном прогоне. Они дешёвые, быстрые и, в отличие от сканеров, никогда не дают ложных срабатываний. Подробнее о дисциплине таких проверок — в статье Тестирование: безопасность.

Секреты и персональные данные в логах

Как выглядит уязвимый код. Классика, которая переживает любые ревью, потому что выглядит как забота о наблюдаемости:

# handlers/payments.py
logger.info("Запрос на оплату: %s", request.json)   # внутри — карта и CVV
logger.debug("Ответ провайдера: %r", provider_response)  # внутри — токен доступа

Почему это работает. Логи по построению уезжают туда, где меньше контроля доступа, чем у базы: в централизованный сборщик, в облачное хранилище, в тикеты поддержки, в скриншоты. Формально утечки нет — данные «внутри периметра», но круг лиц с доступом к логам обычно на порядок шире круга лиц с доступом к продовой базе. Это CWE-532, и он же — самая частая причина «внезапных» инцидентов с персональными данными.

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

# security/sensitive.py
from typing import Any

class Secret:
    """Обёртка над значением, которое нельзя случайно распечатать."""
    __slots__ = ("_value",)

    def __init__(self, value: str) -> None:
        self._value = value

    def reveal(self) -> str:
        """Единственный способ достать значение — явный и грепаемый."""
        return self._value

    def __repr__(self) -> str:  # срабатывает в %r, f-строках, traceback
        return "Secret(***)"

    __str__ = __repr__

REDACT_KEYS = {"password", "token", "authorization", "card", "cvv", "pan", "secret"}

def redact(obj: Any) -> Any:
    """Страховочный фильтр: рекурсивно вычищает известные ключи перед выводом."""
    if isinstance(obj, dict):
        return {k: ("***" if k.lower() in REDACT_KEYS else redact(v)) for k, v in obj.items()}
    if isinstance(obj, (list, tuple)):
        return type(obj)(redact(v) for v in obj)
    return obj

Тип Secret защищает от случайного вывода даже в traceback и в f-строке, а redact вешается как logging.Filter на корневой логгер — тогда он работает и для чужих библиотек, которые про ваши соглашения не знают.

Как проверить, что починено. Тест, который читает то, что реально попало в поток логов:

def test_secrets_never_reach_log_stream(caplog, client):
    client.post("/payments", json={"card": "4111111111111111", "cvv": "737"})
    dumped = "\n".join(r.getMessage() for r in caplog.records)
    assert "4111111111111111" not in dumped
    assert "737" not in dumped

Плюс регулярный поиск по фактическим логам стенда на известные образцы (тестовые номера карт, тестовые токены) — это ловит случаи, когда обошли обёртку. Тема пересекается с приватностью: минимизация того, что вообще попадает в системы наблюдения, разбирается в следующей статье трека.

Проверка прав: то, что не найдёт ни один сканер

Как выглядит уязвимый код. Обработчик, который проверил аутентификацию и забыл про принадлежность объекта:

@router.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user = Depends(current_user)):
    return db.query(Invoice).filter(Invoice.id == invoice_id).one()

Почему это работает. С точки зрения любого статического анализатора здесь всё безупречно: запрос параметризован, аутентификация есть, инъекции нет, типы сходятся. Отсутствует ровно одна вещь — сравнение владельца объекта с текущим субъектом, и «отсутствие» невыразимо как шаблон в коде. Это CWE-639 / OWASP A01:2021 Broken Access Control, механика — в статьях про авторизацию и безопасность API.

Как чинить правильно. Индивидуальная проверка в каждом обработчике не масштабируется: рано или поздно кто-то напишет двадцать первый эндпоинт и забудет. Работает архитектурный приём — сделать доступ к данным невозможным без указания субъекта:

# data/scoped.py — репозиторий, который нельзя вызвать без субъекта
class ScopedRepo:
    def __init__(self, session, principal: Principal) -> None:
        self._s = session
        self._p = principal

    def invoices(self):
        # Фильтр по арендатору и владельцу применяется всегда, а не по желанию вызова
        q = self._s.query(Invoice).filter(Invoice.tenant_id == self._p.tenant_id)
        if not self._p.has_role("accountant"):
            q = q.filter(Invoice.owner_id == self._p.user_id)
        return q

@router.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int, repo: ScopedRepo = Depends(scoped_repo)):
    # 404 вместо 403: не подтверждаем существование чужого объекта
    return repo.invoices().filter(Invoice.id == invoice_id).one_or_404()

Дополнительная страховка — запрет на «голую» сессию: правило линтера, которое разрешает session.query(...) только внутри слоя data/, и тест архитектурных ограничений.

Как проверить, что починено. Здесь работает только тест, знающий бизнес-смысл, — и он должен быть обязательным для каждого эндпоинта, отдающего объект:

# tests/security/test_object_authorization.py
import pytest

# Матрица «кто к чьему объекту»: строится один раз, применяется ко всем ресурсам
@pytest.mark.parametrize("path", ["/api/invoices/{id}", "/api/orders/{id}", "/api/files/{id}"])
def test_foreign_object_is_not_readable(path, client, alice, bob):
    obj_id = create_object_for(alice, path)
    r = client.get(path.format(id=obj_id), headers=auth(bob))
    # 404, а не 403: ответ не должен различать «нет объекта» и «не ваш объект»
    assert r.status_code == 404

def test_owner_still_has_access(path_owner_case, client, alice):
    # Обязательный парный тест: защита не должна ломать легитимный доступ
    assert client.get(path_owner_case, headers=auth(alice)).status_code == 200

Этот тест — образец того, что называют abuse case: сценарий не «как система используется», а «как ею злоупотребят». Для каждой истории в бэклоге полезно иметь хотя бы один такой сценарий; в моделировании угроз они получаются прямо из разбора по STRIDE.

Ревью кода с прицелом на безопасность

Обычное ревью отвечает на вопрос «правильно ли это работает». Ревью с прицелом на безопасность отвечает на вопрос «что произойдёт, если этим воспользуются не так, как задумано». Это разные режимы чтения, и второй требует явного переключения. Об общей культуре ревью — в статье Ревью кода и стандарты; здесь — только специфика.

Правило четырёх вопросов — минимальный набор, который стоит задать любому диффу, не имея времени на большее:

  1. Откуда пришли данные? Есть ли в изменении новый источник недоверенного ввода: параметр, заголовок, файл, сообщение из очереди, ответ внешнего сервиса, значение из базы, которое туда положил пользователь.
  2. Куда они уходят? Появился ли новый опасный приёмник: SQL, шелл, шаблон, десериализатор, путь файловой системы, URL исходящего запроса, генератор разметки.
  3. Кто это может вызвать? Изменились ли требования к субъекту: новый эндпоинт, ослабление проверки, новая роль, новый способ получить токен.
  4. Что видно в ответе и в логах? Не появилось ли в выдаче полей, которых там быть не должно, и не различает ли ответ существование чужих объектов.

Дальше — то, на что ревью тратить не надо: поиск непараметризованных запросов, хардкод-секретов, устаревших зависимостей, отключённой проверки сертификата. Это работа автоматики, и если она обсуждается людьми, значит, автоматика не настроена. Человеческое внимание — самый дорогой ресурс в процессе, и расходовать его следует на четыре класса задач, где машина бессильна:

Класс Пример вопроса на ревью Почему автоматика не поможет
Права на конкретный объект «Проверили, что счёт принадлежит арендатору?» Отсутствие проверки невыразимо как шаблон
Корректность бизнес-правил «Можно ли применить купон дважды параллельными запросами?» Требует знания предметной области
Границы доверия «Этот сервис теперь принимает решения на основе заголовка от клиента?» Требует знания архитектуры
Злоупотребление процессом «Что если отменить заказ после отгрузки, но до списания?» Не баг в коде, а дыра в модели

Практический приём, который заметно повышает выход, — security-дельта к модели угроз в описании PR. Автор отвечает на три строки шаблона: какие новые входы появились, какие новые права выдаются, какие данные добавились в хранение или в лог. Если все три пустые — ревьюер читает как обычно. Если нет — включается режим чтения по четырём вопросам. Шаблон живёт в .github/pull_request_template.md и стоит команде минуту на PR.

Обратите внимание на порядок: автоматика отрабатывает до человека. Ревьюер, которому приходится вручную указывать на хардкод-пароль, — это не ревьюер, а дорогой линтер.

Инструменты: что каждый реально видит

Аббревиатур в этой области больше, чем смысла, поэтому разложим их по единственному признаку, который имеет значение, — какое представление системы инструмент наблюдает.

Карта видимости инструментов: что именно способен наблюдать каждый класс средств от исходного кода до продакшена

Класс Что анализирует Находит хорошо Не найдёт принципиально
Типы, компилятор, линтер исходник в момент написания нарушения инвариантов, выраженных типами всё, что типами не выражено
SAST исходник и его граф потоков путь от источника до опасного вызова, опасные конструкции ошибки прав, логику, конфигурацию среды
Secret scanning текст файлов и история git ключи, токены, приватные ключи секрет, собранный из частей
SCA манифесты и lock-файлы известные CVE в зависимостях уязвимость без CVE, свой код
IaC/config scan манифесты, terraform, k8s открытые наружу порты, широкие права, публичные бакеты что реально применено в облаке
Скан образов слои и пакеты в артефакте уязвимые пакеты ОС, лишние компоненты ваш прикладной код
DAST работающее приложение снаружи отражённые дефекты, заголовки, ошибки конфигурации всё за аутентификацией, если не научили входить
IAST приложение изнутри при выполнении тестов точную трассу от запроса до строки то, что не покрыто тестами
Фаззинг код при подаче мусорных входов падения, зависания, необработанные состояния смысловые ошибки
WAF/RASP трафик и вызовы в проде известные шаблоны атак не чинит дефект, а маскирует

Из таблицы следуют два вывода, которые экономят бюджет. Первый: инструменты не заменяют друг друга — они смотрят на разные представления, и покупка «самого лучшего SAST» не закрывает то, что видит только SCA или только DAST. Второй: нижняя строка не входит в число средств разработки. WAF полезен как временная мера, пока едет исправление, и как источник телеметрии; принимать его как решение проблемы — способ иметь уязвимость постоянно.

SAST: как он думает и почему ошибается

Статический анализатор безопасности делает четыре шага: разбирает код в AST, строит граф вызовов и граф потока управления, выполняет анализ помеченных данных (taint analysis) и применяет правила. Модель проста: есть источники (request.args, os.environ, тело сообщения), есть приёмники (cursor.execute, subprocess.run, eval, innerHTML), есть санитайзеры (параметризация, экранирование, валидация по списку). Находка — это путь от источника к приёмнику, на котором нет санитайзера.

Из модели прямо следуют оба типа ошибок инструмента. Ложное срабатывание (false positive) возникает, когда санитайзер есть, но анализатор его не знает: ваша собственная функция esc() для него — обычный вызов. Ложный пропуск (false negative) возникает, когда поток данных уходит за границу анализа: через базу, очередь, рефлексию, динамический импорт или чужой бинарь. Отсюда практическое следствие: SAST надо учить вашему коду, иначе он анализирует чужой.

# .semgrep/rules/markup.yaml — правила, описывающие инварианты именно нашего кода
rules:
  - id: raw-html-without-safehtml
    languages: [python]
    severity: ERROR
    message: >-
      Разметка собирается из строки, не прошедшей esc(). Используйте esc() или
      объясните исключение через trusted_literal с ссылкой на ревью.
    patterns:
      - pattern-either:
          - pattern: api.render.render_block($T, $BODY)
      - pattern-not: api.render.render_block($T, security.markup.esc(...))
      - pattern-not: api.render.render_block($T, security.markup.trusted_literal(...))

  - id: db-session-outside-data-layer
    languages: [python]
    severity: ERROR
    message: "Прямой доступ к сессии вне слоя data/: обойдена фильтрация по арендатору."
    paths:
      exclude: ["data/**", "tests/**"]
    pattern: $S.query(...)

Правила такого рода, написанные под ваши примитивы, дают больше пользы, чем несколько сотен универсальных: они не ошибаются и учат команду пользоваться безопасным API. Документация синтаксиса — Semgrep rule syntax; из свободных инструментов той же ниши — CodeQL с полноценным языком запросов по коду, Bandit для Python, gosec и встроенный go vet для Go, ESLint с плагинами безопасности для JavaScript.

Как встраивать без разрушения процесса. Ключевой приём — режим по диффу с базовой линией. Полный прогон по легаси-репозиторию даёт тысячи находок, команда объявляет их шумом, и на этом программа заканчивается. Правильная последовательность: зафиксировать текущее состояние как принятый долг, блокировать только новое, разгребать долг отдельным планом.

# Блокируем только то, что добавлено в этом PR, а старое — отдельным бэклогом
semgrep ci --config .semgrep/rules --baseline-commit "$(git merge-base origin/main HEAD)"

# Разовая фиксация базовой линии для легаси
semgrep --config .semgrep/rules --json --output baseline.json .

Метрика качества настройки — доля подтверждённых находок. Если из десяти сработавших правил девять закрываются как «не проблема», правило надо чинить или выключать: инструмент, которому не верят, хуже отсутствующего, потому что создаёт иллюзию контроля.

DAST: снаружи, по работающей системе, только на своих стендах

Динамический анализ проверяет запущенное приложение чёрным ящиком: отправляет запросы, смотрит на ответы, коды, заголовки, тайминги. Он видит ровно то, что видит внешний клиент, — и в этом его ценность и его границы. DAST находит то, чего нет в коде: забытый заголовок, отладочный эндпоинт, оставшийся на стенде, разницу между конфигурацией окружений, ошибочный CORS, отсутствующий SameSite у cookie.

Три условия, без которых DAST в CI бесполезен:

  1. Он должен уметь войти. Без аутентифицированной сессии сканер обходит страницу логина и рапортует «чисто». Настройка входа и признака «сессия жива» — обязательный шаг.
  2. Он должен знать карту. Современный интерфейс — это не набор ссылок; сканеру нужен OpenAPI, GraphQL-схема или записанный сценарий, иначе он увидит десять процентов поверхности.
  3. Он должен работать по вашему стенду. Прогон по продакшену портит данные и метрики, прогон по чужой системе — правонарушение.
#!/usr/bin/env bash
# Ночной прогон DAST по эфемерному стенду, который поднимаем сами.
# Никогда не по продакшену и никогда не по чужой системе.
set -euo pipefail

docker compose -f deploy/compose.dast.yml up -d --wait   # изолированный стенд, синтетические данные

docker run --network host -v "$PWD:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:stable \
  zap-api-scan.py \
    -t http://localhost:8080/openapi.json -f openapi \
    -z "-config replacer.full_list(0).replacement=Bearer ${DAST_TEST_TOKEN}" \
    -c zap/rules.conf \
    -r zap-report.html -w zap-report.md

Файл zap/rules.conf — это ваш список принятых исключений с обоснованиями: он делает отчёт читаемым и превращает «сто предупреждений» в «три новых». Документация: ZAP Automation, OWASP WSTG как методика ручной проверки того же периметра.

Отдельно про IAST — агент внутри приложения, наблюдающий выполнение во время обычных функциональных тестов. Он совмещает точность SAST (видит строку кода) с достоверностью DAST (видит реальный поток) и почти не даёт ложных срабатываний, но покрывает ровно то, что покрывают ваши тесты. Хорошая проверка зрелости: если покрытие интеграционными тестами низкое, инвестировать надо в них, а не в агента.

Фаззинг: дешёвый способ найти то, о чём не подумали

Фаззинг подаёт на вход поток изменяемых данных и ищет падения, зависания и нарушения заданных инвариантов. Он окупается везде, где есть разбор недоверенного формата: парсеры, десериализация, обработка загруженных файлов, сетевые протоколы, всё, что принимает байты. Современный фаззинг покрыт стандартными средствами языков и не требует отдельной инфраструктуры.

// parser/fuzz_test.go — go test -fuzz=FuzzParseInvoice
func FuzzParseInvoice(f *testing.F) {
    f.Add([]byte(`{"id":1,"amount":"10.00"}`)) // затравка из реальных примеров
    f.Fuzz(func(t *testing.T, data []byte) {
        inv, err := ParseInvoice(data)
        if err != nil {
            return // ошибка разбора — нормальный исход, паника — нет
        }
        // Инвариант: успешно разобранный документ сериализуется и разбирается снова
        again, err := ParseInvoice(inv.Marshal())
        if err != nil || again.ID != inv.ID {
            t.Fatalf("нарушен цикл разбор-сериализация: %v", err)
        }
    })
}

В Python ту же роль играют Hypothesis для свойств и Atheris для покрытия. Найденный вход обязан попасть в корпус затравок и в обычный набор тестов — иначе регрессия вернётся. Для проектов с открытым кодом бесплатную непрерывную инфраструктуру даёт OSS-Fuzz.

Гейты в конвейере: что блокирует, а что просто сообщает

Главная ошибка внедрения — сделать всё блокирующим. Через две недели команда добавляет || true, и программа мертва. Вторая ошибка — не блокировать ничего: тогда находки копятся в дашборде, который никто не открывает. Работающий принцип: блокирует то, что автор может починить прямо сейчас и без сомнений.

Обратные пунктирные стрелки — самая важная часть схемы. Каждая находка с правой стороны обязана порождать артефакт на левой: регрессионный тест, правило SAST, изменение в безопасном примитиве. Без этого организация чинит одни и те же ошибки бесконечно. Формулировка для командного соглашения: «чиним не случай, а класс».

Политика блокировки по критичности должна опираться не только на CVSS. Базовый вектор CVSS 3.1/4.0 описывает потенциал, а не вероятность; для приоритизации к нему добавляют CISA KEV (эксплуатируется в реальности прямо сейчас) и EPSS (вероятность эксплуатации в ближайшие 30 дней). Разумная стартовая политика:

Условие Действие в PR Срок исправления
Уязвимость в списке KEV, компонент доступен снаружи блокировать 24 часа
Critical или High, есть исправленная версия блокировать 7 дней
High без исправления, есть обходной путь не блокировать, тикет 30 дней
Medium не блокировать, тикет 90 дней
Low, недостижимый код не блокировать, в отчёт по возможности

Подавления (# nosemgrep, .trivyignore, VEX) — нормальный инструмент, но только с обязательными полями: причина, автор, дата истечения. Просроченное подавление автоматически снова становится блокирующим. Без срока истечения список исключений через год превращается в способ не иметь проверок вовсе. Подробнее о встраивании проверок в конвейер — в статье Безопасность в пайплайне и Тесты в CI.

Жизненный цикл находки

Находка без владельца и срока — это не находка, а запись в журнале. Полезно описать её состояния явно и завести на них автоматику.

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

«Принятый риск» — легитимное состояние, но у него обязаны быть три атрибута: письменное обоснование, владелец риска на уровне, который вправе его принимать (не разработчик, а владелец продукта или руководитель), и дата пересмотра. Без этих полей формулировка «мы приняли риск» означает «мы про это забыли».

Security-чемпионы: почему одна роль сильнее десяти инструментов

В любой организации соотношение специалистов по безопасности к разработчикам — от 1:50 до 1:200. Централизованная команда физически не может читать все PR и участвовать во всех проектных обсуждениях: она либо становится узким местом, либо превращается в аудитора, который приходит в конце и говорит «нельзя». Модель security-чемпионов решает это переносом компетенции внутрь продуктовых команд.

Чемпион — это разработчик из команды, который выделяет часть времени на безопасность: примерно 10–20% и не меньше. Он не становится специалистом по безопасности; он становится человеком, который знает достаточно, чтобы правильно задавать вопросы и вовремя эскалировать. Ключевое свойство роли — контекст: чемпион понимает предметную область своего продукта, а именно там живут ошибки бизнес-логики и прав, невидимые для внешнего аудита.

Практика запуска, которая работает: начать с добровольцев из двух-трёх команд, дать им конкретную ценность на первой же неделе (например, право самим менять правила общего SAST и первыми получать разбор реальных инцидентов), сделать регулярную встречу раз в две недели с разбором реального кода, а не с презентациями. Программа, которая начинается с обязательного назначения и годового плана обучения, умирает на третьем месяце.

Централизованная команда безопасности при такой модели меняет функцию: вместо ревьюера всех изменений она становится поставщиком платформы. Её продукт — безопасные примитивы (библиотека криптографии, слой авторизации, обёртки для секретов), правила анализа, шаблоны конвейеров, обучение и разбор сложных случаев. Метрика её работы — не количество найденного, а доля команд, которым не пришлось решать задачу безопасности самостоятельно.

Обучение: точно в момент, а не раз в год

Ежегодный обязательный курс по безопасности — ритуал соответствия, а не инженерная практика: к моменту, когда знание понадобится, оно забыто. Работает обучение точно в момент: ссылка на конкретный внутренний документ в тексте сообщения от линтера, разбор реальной находки из вашего же репозитория на встрече команды, короткие практикумы на кодовой базе продукта. Полезные источники для программы: OWASP Cheat Sheet Series как справочник на каждый день, OWASP ASVS как источник проверяемых требований для чек-листов, Secure Code Warrior и подобные платформы — как тренажёр, если бюджет позволяет.

Метрики программы, которые не врут

Плохие метрики: количество найденных уязвимостей (растёт при покупке нового сканера), количество закрытых тикетов (растёт при массовом закрытии как «не проблема»), процент прохождения обучения (не коррелирует ни с чем).

Хорошие метрики измеряют свойства процесса:

  • Доля дефектов, найденных до слияния — прямое измерение сдвига влево.
  • Количество «сбежавших» дефектов — сколько нашли снаружи: пентест, багбаунти, инцидент. Единственная метрика, которую трудно накрутить.
  • Время до исправления по критичности (медиана и 90-й процентиль) — способность организации реагировать, а не находить.
  • Доля повторов одного класса — работает ли принцип «чиним класс, а не случай». Растёт — значит, обратная связь разорвана.
  • Доля подтверждённых находок инструмента — качество настройки автоматики.
  • Покрытие продуктов моделью угроз и владельцами — знаете ли вы вообще, что защищаете.

Для приоритизации инициатив полезна простая двумерная раскладка: усилие против снижения риска.

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

Из раскладки следует и порядок внедрения. Сначала — то, что не требует менять поведение людей: поиск секретов и SCA с политикой по KEV включаются за пару недель и сразу дают результат. Затем — безопасные примитивы, потому что все последующие правила будут опираться именно на них. Затем — свои правила SAST по диффу и обязательные tests/security для новых эндпоинтов. И только после этого — дорогие обнаруживающие практики: ночной DAST, фаззинг, VDP и пентест. Пентест в начале программы даёт отчёт, который нечем исправлять; пентест после структурных мер — проверку того, что они сработали.

Легальность: пентест, VDP и багбаунти

Любое активное тестирование безопасности — это действия, которые вне контекста разрешения выглядят как атака. Поэтому граница проходит не по намерению, а по документу.

Пентест проводится по договору, в котором зафиксированы: перечень систем и адресов в объёме работ, явно исключённые системы, окно проведения, допустимые и недопустимые техники (обычно исключаются отказ в обслуживании, социальная инженерия против сотрудников без отдельного согласия, работа с реальными персональными данными), контакт для экстренной остановки и порядок обращения с найденными данными. Отдельно фиксируется разрешение от владельца инфраструктуры: облачные провайдеры имеют собственные правила тестирования, и договор с вами их не отменяет.

Программа раскрытия уязвимостей (VDP) — это ваше публичное обещание не преследовать исследователя, который действовал в рамках правил. Технический минимум — файл /.well-known/security.txt по RFC 9116:

Contact: mailto:security@example.com
Expires: 2027-01-01T00:00:00.000Z
Encryption: https://example.com/pgp-key.txt
Preferred-Languages: ru, en
Policy: https://example.com/security-policy
Acknowledgments: https://example.com/hall-of-fame

Процессная сторона описана стандартами ISO/IEC 29147 (приём сообщений об уязвимостях) и ISO/IEC 30111 (внутренняя обработка). Практический минимум: подтверждение получения за 1–3 рабочих дня, оценка за 5–10, согласованный срок публикации, отсутствие юридических угроз в адрес добросовестного исследователя. Организация, которая отвечает на первый отчёт молчанием или претензией, гарантированно узнаёт о второй уязвимости не от исследователя.

Багбаунти — платная надстройка над VDP, и запускать её имеет смысл, только когда внутренний процесс уже переваривает поток находок: иначе программа превращается в очередь неотвеченных отчётов и репутационный ущерб.

И повторим ограничение, с которого начиналась статья: сканеры, фаззеры и любые «проверки» запускаются исключительно по своим системам или при письменном разрешении владельца. Прогон DAST по чужому сайту «просто посмотреть» — не обучение и не исследование.

Типичные ошибки

  • Купили инструмент вместо построения процесса. Сканер без владельца находок, без SLA и без обратной связи в код — это подписка на дашборд.
  • Всё блокирующее с первого дня. Команда обходит проверки, доверие к программе теряется навсегда; восстановить его дороже, чем построить с нуля.
  • Ничего не блокирующее. Тикеты копятся, средний возраст находки растёт, метрика «исправлено» не двигается.
  • Ревью тратится на то, что умеет линтер. Дорогое внимание уходит на пробелы и хардкоды вместо прав и логики.
  • Чинят случай, а не класс. Одна и та же ошибка возвращается в другом эндпоинте через квартал.
  • Подавления без срока истечения. Через год список исключений покрывает половину кода.
  • Безопасность как отдельный этап перед релизом. Находки приходят тогда, когда менять архитектуру уже нельзя, и превращаются в «принятый риск» по умолчанию.
  • WAF как решение. Уязвимость остаётся, а маскировка снимается первым же обходом фильтра.
  • Чемпион без времени. Роль, добавленная к полной нагрузке, тихо становится формальностью.
  • Метрики находок вместо метрик процесса. Награждается активность инструментов, а не снижение риска.
  • Тестовые данные из продакшена. Стенд для DAST с реальными персональными данными — сам по себе инцидент; про требования к работе с ними — в следующей статье.

Мини-итог

Безопасная разработка — это не набор инструментов и не отдельный этап, а расположение контролей вдоль всего цикла с одним организующим правилом: находки должны переезжать на петлю с меньшим номером.

  1. Сначала убираем класс ошибки. Безопасные примитивы и типы, которые не дают выразить небезопасное действие, работают без дисциплины и без сканеров — и это самое выгодное вложение.
  2. Потом автоматизируем то, что выразимо правилом. SAST по диффу с базовой линией, поиск секретов, SCA с политикой по KEV, скан IaC — быстро, в PR, с честной метрикой доли подтверждённых находок.
  3. Человеческое ревью расходуем только на невыразимое. Права на объект, бизнес-логика, границы доверия, злоупотребление процессом — четыре вопроса к каждому диффу и security-дельта в описании PR.
  4. Правую сторону не отменяем. DAST, фаззинг, пентест и VDP находят то, что не видно до запуска, — но каждая их находка обязана вернуться налево тестом и правилом.
  5. Роль важнее инструмента. Security-чемпион с защищённым временем и полномочиями меняет больше, чем очередная подписка, потому что у него есть контекст продукта.
  6. Всё активное тестирование — только по своим системам или по письменному разрешению.

Источники

Что дальше

Приватность и соответствие: персональные данные, минимизация, аудит — заключительная статья трека о том, как инженерно обращаться с персональными данными: что считать минимизацией на уровне схемы и логов, как устроены удаление и срок хранения, что писать в журнал аудита и как не превратить наблюдаемость в собственную утечку.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов