Безопасная разработка: практики, ревью, 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-инъекцию. Это самый дешёвый способ: он работает без дисциплины, без ревью и без сканеров.
Второй — сделать ошибку заметной. Ошибку написать можно, но она немедленно всплывает: не компилируется, падает тест, срабатывает правило линтера, ревьюер видит подозрительный вызов. Стоимость — настройка и поддержка контроля плюс постоянная борьба с ложными срабатываниями.
Третий — обнаружить последствия. Ошибка уехала в прод, и мы узнаём о ней от мониторинга, пентестера, исследователя или атакующего. Самый дорогой способ, но полностью отказаться от него нельзя: только он ловит то, что не выразимо в правилах.
сделать невыразимой?"} B -- да --> C["Безопасный примитив:
тип, API, фреймворк"] B -- нет --> D{"Можно ли
описать правилом?"} D -- да --> E["Автоматика:
линтер, SAST, тест-инвариант"] D -- нет --> F{"Виден ли
человеку в диффе?"} F -- да --> G["Ревью по чек-листу
и модель угроз"] F -- нет --> H["Обнаружение постфактум:
DAST, фаззинг, пентест, багбаунти"] C --> I["Цена: один раз при выборе"] E --> J["Цена: настройка и ложные срабатывания"] G --> K["Цена: время людей на каждый PR"] H --> L["Цена: инцидент и переделка"] classDef cheap fill:#3fa66a22,stroke:#3fa66a classDef mid fill:#4a90d922,stroke:#4a90d9 classDef costly fill:#d9534a22,stroke:#d9534a class C,I cheap class E,G,J,K mid class H,L costly
Правило приоритета читается по этой схеме сверху вниз: прежде чем добавлять сканер, спросите, нельзя ли убрать сам класс ошибки. Организации, которые начинают с покупки инструментов, получают тысячи тикетов и ноль изменений в коде. Организации, которые начинают с безопасных умолчаний, получают инструменты, которым почти нечего находить, — и это правильное состояние.
Экономика: почему важна не находка, а расстояние до неё
Классический тезис «баг дешевле чинить раньше» восходит к работам Барри Боэма 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 в ошибку времени выполнения, а единственным легальным путём остаётся именованная политика — тот же «аварийный люк», только на уровне платформы.
Как проверить, что починено. Три независимых проверки, ни одна из которых не требует помнить о правиле:
- Компилятор/
mypyв CI — вызовrender_block(name, comment.text)перестаёт проходить типизацию. Это самая дешёвая петля. - Правило SAST на «аварийный люк» — любой вызов
trusted_literalвне списка разрешённых мест падает в PR. - Регрессионный тест — конкретная полезная нагрузка, которая раньше исполнялась, теперь приезжает как текст.
# 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 "<" 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.
Ревью кода с прицелом на безопасность
Обычное ревью отвечает на вопрос «правильно ли это работает». Ревью с прицелом на безопасность отвечает на вопрос «что произойдёт, если этим воспользуются не так, как задумано». Это разные режимы чтения, и второй требует явного переключения. Об общей культуре ревью — в статье Ревью кода и стандарты; здесь — только специфика.
Правило четырёх вопросов — минимальный набор, который стоит задать любому диффу, не имея времени на большее:
- Откуда пришли данные? Есть ли в изменении новый источник недоверенного ввода: параметр, заголовок, файл, сообщение из очереди, ответ внешнего сервиса, значение из базы, которое туда положил пользователь.
- Куда они уходят? Появился ли новый опасный приёмник: SQL, шелл, шаблон, десериализатор, путь файловой системы, URL исходящего запроса, генератор разметки.
- Кто это может вызвать? Изменились ли требования к субъекту: новый эндпоинт, ослабление проверки, новая роль, новый способ получить токен.
- Что видно в ответе и в логах? Не появилось ли в выдаче полей, которых там быть не должно, и не различает ли ответ существование чужих объектов.
Дальше — то, на что ревью тратить не надо: поиск непараметризованных запросов, хардкод-секретов, устаревших зависимостей, отключённой проверки сертификата. Это работа автоматики, и если она обсуждается людьми, значит, автоматика не настроена. Человеческое внимание — самый дорогой ресурс в процессе, и расходовать его следует на четыре класса задач, где машина бессильна:
| Класс | Пример вопроса на ревью | Почему автоматика не поможет |
|---|---|---|
| Права на конкретный объект | «Проверили, что счёт принадлежит арендатору?» | Отсутствие проверки невыразимо как шаблон |
| Корректность бизнес-правил | «Можно ли применить купон дважды параллельными запросами?» | Требует знания предметной области |
| Границы доверия | «Этот сервис теперь принимает решения на основе заголовка от клиента?» | Требует знания архитектуры |
| Злоупотребление процессом | «Что если отменить заказ после отгрузки, но до списания?» | Не баг в коде, а дыра в модели |
Практический приём, который заметно повышает выход, — security-дельта к модели угроз в описании PR. Автор отвечает на три строки шаблона: какие новые входы появились, какие новые права выдаются, какие данные добавились в хранение или в лог. Если все три пустые — ревьюер читает как обычно. Если нет — включается режим чтения по четырём вопросам. Шаблон живёт в .github/pull_request_template.md и стоит команде минуту на PR.
ревьюер их вообще не видит A->>R: запрос ревью R->>R: четыре вопроса к диффу alt дельта пустая R-->>A: обычное ревью else появились входы, права или данные R->>CH: пометка "нужен взгляд на безопасность" CH->>CH: разбор границ доверия и прав по объектам alt типовой случай CH-->>A: правки и ссылка на безопасный примитив else новый класс риска, крипто, платежи, аутентификация CH->>S: эскалация с конкретным вопросом S-->>A: решение и, если нужно, новое правило в SAST end end A->>CI: правки CI-->>R: зелёный прогон R-->>A: approve
Обратите внимание на порядок: автоматика отрабатывает до человека. Ревьюер, которому приходится вручную указывать на хардкод-пароль, — это не ревьюер, а дорогой линтер.
Инструменты: что каждый реально видит
Аббревиатур в этой области больше, чем смысла, поэтому разложим их по единственному признаку, который имеет значение, — какое представление системы инструмент наблюдает.
| Класс | Что анализирует | Находит хорошо | Не найдёт принципиально |
|---|---|---|---|
| Типы, компилятор, линтер | исходник в момент написания | нарушения инвариантов, выраженных типами | всё, что типами не выражено |
| 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 бесполезен:
- Он должен уметь войти. Без аутентифицированной сессии сканер обходит страницу логина и рапортует «чисто». Настройка входа и признака «сессия жива» — обязательный шаг.
- Он должен знать карту. Современный интерфейс — это не набор ссылок; сканеру нужен OpenAPI, GraphQL-схема или записанный сценарий, иначе он увидит десять процентов поверхности.
- Он должен работать по вашему стенду. Прогон по продакшену портит данные и метрики, прогон по чужой системе — правонарушение.
#!/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 с реальными персональными данными — сам по себе инцидент; про требования к работе с ними — в следующей статье.
Мини-итог
Безопасная разработка — это не набор инструментов и не отдельный этап, а расположение контролей вдоль всего цикла с одним организующим правилом: находки должны переезжать на петлю с меньшим номером.
- Сначала убираем класс ошибки. Безопасные примитивы и типы, которые не дают выразить небезопасное действие, работают без дисциплины и без сканеров — и это самое выгодное вложение.
- Потом автоматизируем то, что выразимо правилом. SAST по диффу с базовой линией, поиск секретов, SCA с политикой по KEV, скан IaC — быстро, в PR, с честной метрикой доли подтверждённых находок.
- Человеческое ревью расходуем только на невыразимое. Права на объект, бизнес-логика, границы доверия, злоупотребление процессом — четыре вопроса к каждому диффу и security-дельта в описании PR.
- Правую сторону не отменяем. DAST, фаззинг, пентест и VDP находят то, что не видно до запуска, — но каждая их находка обязана вернуться налево тестом и правилом.
- Роль важнее инструмента. Security-чемпион с защищённым временем и полномочиями меняет больше, чем очередная подписка, потому что у него есть контекст продукта.
- Всё активное тестирование — только по своим системам или по письменному разрешению.
Источники
- NIST SP 800-218 Secure Software Development Framework — практики, на которые ссылаются требования к поставщикам ПО.
- OWASP SAMM и BSIMM — модели зрелости и данные о том, что делают другие.
- OWASP ASVS — каталог проверяемых требований для чек-листов и приёмки.
- OWASP Proactive Controls и Cheat Sheet Series — что делать, в формате для разработчика.
- OWASP Code Review Guide и Web Security Testing Guide — методики ревью и тестирования.
- OWASP Security Champions Guide — запуск и поддержание программы чемпионов.
- Semgrep rule syntax и CodeQL docs — написание собственных правил анализа.
- ZAP Automation Framework — динамическое сканирование в конвейере.
- OSS-Fuzz, Go fuzzing, Hypothesis — непрерывный фаззинг и тестирование свойств.
- CVSS, EPSS, CISA KEV — приоритизация по риску, а не по баллу.
- RFC 9116 security.txt, ISO/IEC 29147, ISO/IEC 30111 — приём и обработка сообщений об уязвимостях.
- W3C Trusted Types — платформенный способ запретить небезопасную разметку.
- Microsoft Security Development Lifecycle — исторически первая широко описанная промышленная программа.
Что дальше
Приватность и соответствие: персональные данные, минимизация, аудит — заключительная статья трека о том, как инженерно обращаться с персональными данными: что считать минимизацией на уровне схемы и логов, как устроены удаление и срок хранения, что писать в журнал аудита и как не превратить наблюдаемость в собственную утечку.