Тестирование ПО Тестирование безопасности: OWASP Top 10, SAST, DAST, фаззинг
0%

Тестирование безопасности: OWASP Top 10, SAST, DAST, фаззинг

Тестирование безопасности: OWASP Top 10, SAST, DAST, фаззинг

Функциональный тест проверяет, что система делает то, что должна. Тест безопасности проверяет, что система не делает того, чего не должна — и в этом вся разница. Требований «пользователь Боб не может прочитать заказ Алисы через подстановку id» в аналитике обычно нет. Никто не забыл его написать: спецификации описывают разрешённое поведение, а запрещённое поведение бесконечно.

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

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

Как решать, что тестировать: модель угроз вместо списка сканеров

Типичная ошибка первого года — начать с инструментов: «прикрутим SAST, потом ZAP, потом посмотрим». Так получается пайплайн, который генерирует сотни находок о Math.random() в генераторе идентификаторов для UI-теста и молчит о том, что админский эндпоинт доступен по прямой ссылке.

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

Минимальная модель угроз — это два часа с командой, доска и четыре вопроса (формулировка из Threat Modeling Manifesto):

  1. Над чем мы работаем? — рисуем поток данных: акторы, сервисы, хранилища, и главное — границы доверия (browser → API, наш сервис → сторонний платёжный, пользовательский ввод → SQL).
  2. Что может пойти не так? — по каждой границе прогоняем STRIDE.
  3. Что мы с этим сделаем? — контроль, тест, принятие риска.
  4. Хорошо ли мы это сделали? — а это уже наша работа: тест на каждое решение.
STRIDE Что это Нарушает Типичный тест
Spoofing выдать себя за другого аутентичность подделка JWT с alg: none, чужой session id
Tampering изменить данные целостность правка цены в теле запроса, подмена суммы после подписи
Repudiation отрицать действие неотказуемость операция без записи в аудит-лог
Information disclosure утечка конфиденциальность IDOR, стек-трейс в 500, PII в логах
Denial of service отказ доступность zip-бомба, регексп-катастрофа, запрос без лимита страницы
Elevation of privilege повышение прав авторизация обычный пользователь дёргает /admin/*

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

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

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

OWASP Top 10: что это и чем не является

OWASP Top 10 — самый цитируемый документ в прикладной безопасности. Это список категорий рисков, ранжированный по данным о встречаемости и влиянии, обновляемый примерно раз в три-четыре года. Наиболее устоявшаяся редакция — 2021 года; в конце 2025-го публиковалась новая версия, где вверх заметно поднялись риски цепочки поставок, а обработка исключительных ситуаций появилась как отдельная категория. Перепроверяйте актуальную редакцию на сайте проекта — но структура мышления от версии к версии не меняется.

Чем Top 10 не является:

  • Это не чек-лист приёмки. «Мы проверили все десять пунктов» — бессмысленная фраза: внутри A01 умещаются тысячи разных проверок.
  • Это не стандарт. Стандарт — это OWASP ASVS: сотни конкретных проверяемых требований, разбитых по трём уровням строгости. Если нужен документ, по которому пишут тест-кейсы, берите ASVS, а не Top 10.
  • Это не методика. Методика — это OWASP WSTG, где каждой проверке сопоставлена процедура: как именно проверять.

Практическое правило: Top 10 — для приоритизации, ASVS — для требований, WSTG — для процедуры.

Ниже разберём три категории подробно — те, где тестировщик даёт максимальную пользу и где автоматика бессильна.

A01: контроль доступа — то, что не найдёт ни один сканер

Нарушенный контроль доступа стабильно занимает первое место, и причина проста: это единственный класс дефектов, где невозможно отличить уязвимость от фичи без знания предметной области. Сканер видит GET /api/orders/7412200 OK. Правильный ли это ответ, зависит от того, чей это заказ и кто спрашивает. Сканер этого не знает. Вы знаете.

Это IDOR (Insecure Direct Object Reference). Написать на него тест элементарно — проблема в том, что его почти никогда не пишут, потому что в требованиях нет строки «Боб не видит заказ Алисы».

Лечится это одним приёмом: матрица доступа как исполняемая спецификация.

# tests/security/test_authorization_matrix.py
import pytest

# Матрица прав. Заполняется вместе с аналитиком и владельцем продукта,
# а НЕ считывается с реализации: иначе тест зафиксирует существующую дыру.
ACCESS_MATRIX = [
    # (роль,         метод,    путь,                    ожидаемый статус)
    ("anonymous",    "GET",    "/api/orders/{alice}",   401),
    ("alice",        "GET",    "/api/orders/{alice}",   200),
    ("bob",          "GET",    "/api/orders/{alice}",   404),   # намеренно не 403
    ("bob",          "PATCH",  "/api/orders/{alice}",   404),
    ("bob",          "DELETE", "/api/orders/{alice}",   404),
    ("support",      "GET",    "/api/orders/{alice}",   200),
    ("support",      "DELETE", "/api/orders/{alice}",   403),
    ("admin",        "DELETE", "/api/orders/{alice}",   204),
    # горизонтальный доступ на списках — забывают чаще, чем на карточках
    ("bob",          "GET",    "/api/users/{alice}/orders", 403),
]


@pytest.mark.parametrize(
    "role,method,path,expected",
    ACCESS_MATRIX,
    ids=[f"{r}-{m}-{p}" for r, m, p, _ in ACCESS_MATRIX],
)
def test_access_matrix(api_client, tokens, seeded_ids, role, method, path, expected):
    url = path.format(**seeded_ids)
    response = api_client.request(method, url, headers=tokens.auth_header(role))
    assert response.status_code == expected, (
        f"{role} {method} {url}: ожидали {expected}, получили {response.status_code}. "
        f"Тело: {response.text[:300]}"
    )

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

Но матрица бесполезна, если про новый эндпоинт в неё забыли добавить строку. Поэтому к ней прилагается мета-тест:

def test_every_route_is_covered(app):
    """Новый роут без записи в матрице доступа ломает сборку.
    Это единственный способ не забыть — ревью такое пропускает всегда."""
    covered = {(method, path) for _, method, path, _ in ACCESS_MATRIX}
    declared = {
        (method, route.path)
        for route in app.routes
        for method in route.methods
        if method not in {"HEAD", "OPTIONS"}
    }
    missing = declared - covered
    assert not missing, f"эндпоинты без описанных прав доступа: {sorted(missing)}"

Этот приём переносится на любой стек и решает проблему системно: вопрос «кто имеет право?» становится обязательным полем при добавлении API, а не факультативным пунктом ревью.

Что ещё проверять в A01, кроме IDOR:

  • вертикальный доступ: обычный пользователь дёргает административные роуты напрямую;
  • скрытие вместо запрета: кнопки нет в UI, но эндпоинт открыт — классика SPA;
  • параметры вместо прав: ?role=admin, {"is_admin": true} в теле PATCH, массовое присваивание полей модели;
  • JWT: alg: none, подпись не проверяется, exp игнорируется, токен не отзывается после логаута;
  • CORS: Access-Control-Allow-Origin отражает произвольный Origin вместе с Allow-Credentials: true;
  • гонки: два параллельных запроса на списание промокода — проверяется нагрузочным инструментом, о них в нагрузочном тестировании.

A03: инъекции — почему грепом не найти

Инъекция возникает, когда данные попадают в интерпретатор как код: SQL, shell, LDAP, XPath, шаблонизатор, ORM-выражение. Классический тест ' OR 1=1-- в поле поиска до сих пор иногда срабатывает, но полагаться на ручной перебор кавычек в 2026 году бессмысленно — параметров слишком много.

Рабочая связка выглядит так: правило SAST ловит паттерн в коде → тест фиксирует поведение → DAST страхует на стенде. Ни один элемент по отдельности не надёжен.

Своё правило Semgrep пишется за десять минут и знает про ваш код то, чего не знают публичные правила:

# .semgrep/sql.yml — запрет строковой интерполяции в сыром SQL
rules:
  - id: raw-sql-string-interpolation
    languages: [python]
    severity: ERROR
    message: >-
      Сырой SQL со строковой интерполяцией. Передавайте значения параметрами:
      cursor.execute("... WHERE id = %s", [order_id]).
    metadata:
      cwe: "CWE-89: Improper Neutralization of Special Elements used in an SQL Command"
      owasp: "A03:2021 Injection"
      confidence: HIGH
    patterns:
      - pattern-either:
          - pattern: $CUR.execute(f"...", ...)
          - pattern: $CUR.execute("..." % ..., ...)
          - pattern: $CUR.execute("..." + $X, ...)
          - pattern: $MODEL.objects.raw(f"...", ...)
      # исключаем миграции: там нет пользовательского ввода
      - pattern-not-inside: |
          def migrate(...):
              ...

И регрессионный тест на конкретный найденный дефект — обязательная часть починки:

# tests/security/test_search_injection.py
import pytest

PAYLOADS = [
    "' OR '1'='1",
    "'; DROP TABLE orders; --",
    "%' UNION SELECT password_hash, 1, 1 FROM users --",
    "\\' OR 1=1 --",          # экранирование обратным слэшем
    "0x27204f5220313d31",     # hex-обход наивных фильтров
]


@pytest.mark.parametrize("payload", PAYLOADS)
def test_search_is_injection_safe(api_client, tokens, payload):
    r = api_client.get("/api/orders", params={"q": payload},
                       headers=tokens.auth_header("alice"))
    # 1. Не 500: падение означает, что строка дошла до парсера SQL
    assert r.status_code == 200, f"payload сломал запрос: {r.text[:300]}"
    # 2. Не вернулось лишнего: инъекция '1'='1' сняла бы фильтр по владельцу
    assert all(o["owner"] == "alice" for o in r.json()["items"])
    # 3. Не утекла структура БД
    assert "password_hash" not in r.text

Обратите внимание на проверку №2. Наивный тест «нет 500 — значит, безопасно» пропускает самый опасный случай: инъекция сработала штатно и вернула чужие данные с кодом 200. Оракул теста — это не отсутствие ошибки, а соответствие результата правам запрашивающего.

A10 и SSRF: пример на TypeScript

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

// tests/security/ssrf.spec.ts
import { describe, it, expect } from "vitest";
import { fetchUserAvatar } from "../../src/avatar";

const MUST_BE_BLOCKED = [
  "http://169.254.169.254/latest/meta-data/iam/security-credentials/", // метаданные облака
  "http://metadata.google.internal/computeMetadata/v1/",
  "http://127.0.0.1:6379/",                 // локальный redis
  "http://[::1]:8080/admin",                // IPv6-петля
  "http://2130706433/",                     // 127.0.0.1 десятичным числом
  "http://127.1/",                          // сокращённая запись
  "http://internal-billing.svc.cluster.local/", // внутренний DNS
  "file:///etc/passwd",                     // другая схема
  "gopher://127.0.0.1:6379/_SET%20foo%20bar",
];

describe("SSRF-защита загрузчика аватаров", () => {
  it.each(MUST_BE_BLOCKED)("отклоняет %s", async (url) => {
    await expect(fetchUserAvatar(url)).rejects.toThrow(/запрещённый адрес/);
  });

  it("не идёт по редиректу на внутренний адрес", async () => {
    // публичный хост отвечает 302 на 169.254.169.254 — проверка «до запроса» это пропустит
    await expect(fetchUserAvatar("https://redirector.test/to-metadata"))
      .rejects.toThrow(/запрещённый адрес/);
  });
});

Два последних кейса — то, что отличает поверхностный тест от настоящего. Защита обязана резолвить имя в IP и проверять итоговый адрес, а не строку URL, и делать это на каждом шаге редиректа. Проверка «строка не начинается с 127.» — не защита. Полный список обходов — SSRF Prevention Cheat Sheet.

Классы инструментов: кто что видит

Карта инструментов безопасности по фазам разработки

Класс Как работает Ловит хорошо Слеп к Шум
Secrets scanning регулярки + энтропия по коду и git-истории ключи, токены, приватные ключи секрет в переменной окружения CI низкий
SAST анализ кода и потоков данных без запуска инъекции, небезопасные API, криптопримитивы всё, что зависит от конфигурации и прав высокий
SCA сверка зависимостей с базами CVE известные уязвимости пакетов уязвимость вашего кода; неопубликованные CVE средний
DAST атакует работающее приложение снаружи конфигурация, заголовки, отражённые XSS, часть инъекций бизнес-логика, права доступа, неохваченные роуты средний
IAST агент внутри рантайма + трафик инъекции с подтверждением по стеку вызовов требует нагрузки на все пути низкий
Фаззинг миллионы мутированных входов + инструментация падения, порча памяти, парсеры, десериализация всё, что не выражается как падение низкий, но дорогой по CPU
Пентест человек с моделью угроз логика, цепочки, обход прав масштаб и повторяемость нулевой

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

SAST на практике: как не утонуть в шуме

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

1. Дифференциальный режим. Блокируйте только то, что появилось в этом PR. Исторический долг фиксируется как baseline и разбирается отдельным потоком.

# Semgrep сам определяет базовую ревизию в PR-контексте
semgrep ci --config=p/default --config=.semgrep/

# вручную: сравнить с общим предком
semgrep --config=.semgrep/ --baseline-commit=$(git merge-base origin/main HEAD)

2. Точность важнее полноты. Правило, дающее больше ~20% ложных срабатываний, приносит вреда больше, чем пользы: оно обучает команду закрывать глаза. Начинайте с узкого набора правил высокой уверенности и расширяйте его, а не наоборот.

3. Подавление с причиной и сроком. Голый # nosec — способ спрятать проблему.

# nosemgrep: raw-sql-string-interpolation
# Причина: имя таблицы приходит из внутреннего enum, не из запроса пользователя.
# Проверено: MIGRATIONS_TABLES — Literal-тип, значения захардкожены.
# Ревизия: 2026-10-01, тикет SEC-412.
cursor.execute(f"ANALYZE {table}")  # table: Literal[...]

Из бесплатных инструментов рабочие: Semgrep (быстрый, правила пишутся на языке, похожем на сам код), CodeQL (медленнее, но настоящий анализ потоков данных, бесплатен для публичных репозиториев), Bandit для Python, gosec для Go.

SCA и цепочка поставок

Большая часть кода в вашем продукте написана не вами. Атаки на цепочку поставок — event-stream, ua-parser-js, xz/liblzma — показали, что доверенная зависимость это оксюморон.

# Python: аудит по установленному окружению и по локфайлу
pip-audit -r requirements.txt --strict

# Node: только прямые исправимые уязвимости
npm audit --audit-level=high --omit=dev

# Образ целиком: код, ОС-пакеты, конфиги, секреты
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 myapp:${TAG}

# SBOM — опись состава, нужна для быстрого ответа «а у нас есть уязвимый пакет?»
syft myapp:${TAG} -o cyclonedx-json > sbom.json
grype sbom:sbom.json --fail-on high

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

  1. Достижим ли уязвимый код? Уязвимость в парсере XML библиотеки, которой вы пользуетесь только для JSON, не эксплуатируется. Инструменты reachability-анализа (в том числе Semgrep Supply Chain, CodeQL) отсекают большую часть шума.
  2. Есть ли эксплуатация в дикой природе? CISA KEV — список того, что уже используют в атаках. Попадание в KEV означает «чинить сегодня» независимо от CVSS.
  3. Какова вероятность эксплуатации? EPSS даёт оценку вероятности эксплуатации в ближайшие 30 дней. CVSS 9.8 с EPSS 0.001 почти всегда менее срочен, чем CVSS 7.5 с EPSS 0.4. Ориентироваться только на CVSS — распространённая ошибка: CVSS измеряет потенциальную тяжесть, а не вероятность.
  4. Как быстро мы вообще умеем обновляться? Если релиз занимает две недели, вопрос приоритизации вторичен по отношению к вопросу автоматизации обновлений (Dependabot, Renovate) и --ignore-unfixed, чтобы не отвлекаться на то, для чего патча ещё нет.

Отдельная проверка — целостность: включённый npm ci вместо npm install, зафиксированные локфайлы, проверка подписей артефактов (cosign), уровни SLSA для сборочного конвейера. Это A08 в терминах Top 10.

Секреты в репозитории

Отдельный класс с самым высоким отношением ущерба к сложности поиска.

# сканирование рабочей копии и всей истории
gitleaks detect --source . --redact --report-format sarif --report-path gitleaks.sarif

# хук на pre-commit: дешевле не пустить, чем потом переписывать историю
gitleaks protect --staged --redact

Главное, что нужно понимать про найденный секрет: удаление коммита ничего не лечит. Если ключ побывал в истории публичного или даже внутреннего репозитория, он скомпрометирован. Порядок действий строго такой: ротация ключа → проверка логов использования → и только потом чистка истории. Тестировщик, который нашёл секрет и оформил тикет «удалить из репозитория», сделал работу наполовину.

DAST на практике

DAST атакует работающее приложение снаружи, ничего не зная о коде. OWASP ZAP — стандарт де-факто в бесплатном сегменте, Burp Suite — в платном, Nuclei — быстрый шаблонный сканер для известных уязвимостей и мисконфигураций.

# .github/workflows/dast.yml — ночной прогон против стенда
- name: ZAP baseline (пассивный, ~2 минуты)
  uses: zaproxy/action-baseline@v0.12.0
  with:
    target: "https://staging.example.com"
    rules_file_name: ".zap/rules.tsv"   # понижение уровня разобранных ложных срабатываний
    cmd_options: "-a"                    # включить альфа-правила

# .zap/rules.tsv — три колонки: id, действие, комментарий
# 10015	IGNORE	Cache-control на статике: обрабатывается CDN, тикет SEC-207

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

  1. Аутентификация. Незалогиненный скан видит форму входа и лендинг. Все интересные уязвимости за авторизацией. Настройте контекст ZAP с сессией, и обязательно исключите из скана логаут — иначе сканер разлогинится на третьем запросе и остаток прогона будет тестировать страницу входа.
  2. Полнота обхода. Паук не найдёт роуты SPA, которые вызываются из JS. Скармливайте сканеру OpenAPI-спецификацию или HAR-запись E2E-прогона — это лучший способ дать ему реальную карту приложения.
  3. Отдельный стенд. Активный скан отправляет вредоносные payload’ы, создаёт мусорные записи и может уронить приложение. Не по проду, с чистой БД, с уведомлением команды.

Ближайший родственник DAST для API — Schemathesis: он генерирует негативные входы прямо из схемы OpenAPI и проверяет, что сервер не падает и отвечает согласно контракту. Это мостик к тестированию API:

st run https://staging.example.com/openapi.json \
  --checks all \
  --header "Authorization: Bearer ${STAGING_TOKEN}" \
  --hypothesis-max-examples 200 \
  --junit-xml=reports/schemathesis.xml

--checks all включает в том числе проверку not_a_server_error: любая пятисотка на сгенерированном входе — это как минимум необработанное исключение, а как максимум точка входа для инъекции. Отличный дешёвый источник находок.

Фаззинг

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

  • Property-based тестирование (Hypothesis, fast-check, PropEr): генерация по типам и свойствам, тысячи примеров, живёт в обычном тестовом наборе, проверяет логические инварианты. Подробно — в модульных тестах.
  • Coverage-guided фаззинг (libFuzzer, AFL++, Atheris, go test -fuzz): мутация байтов с обратной связью по покрытию, миллионы прогонов, живёт в отдельном длинном процессе, ищет падения и порчу памяти.

Цикл coverage-guided фаззинга

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

Python через Atheris:

# fuzz/fuzz_invoice_parser.py
import sys
import atheris

with atheris.instrument_imports():
    from myapp.invoices import parse_invoice, InvoiceFormatError


def TestOneInput(data: bytes) -> None:
    fdp = atheris.FuzzedDataProvider(data)
    text = fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes())
    try:
        invoice = parse_invoice(text)
    except InvoiceFormatError:
        return  # объявленная часть контракта — не баг
    # Оракул №1: разбор не создаёт отрицательных сумм
    assert invoice.amount >= 0, f"отрицательная сумма из входа: {text!r}"
    # Оракул №2: повторный разбор сериализации даёт то же значение
    assert parse_invoice(invoice.serialize()) == invoice


atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()

Go — фаззинг встроен в стандартный тулчейн (go.dev/doc/security/fuzz):

func FuzzParseInvoice(f *testing.F) {
    // Seed-корпус: реальные валидные примеры и известные граничные случаи
    f.Add("INV-1|100.00|RUB")
    f.Add("INV-0|0.00|USD")
    f.Add("INV-2|-0.01|RUB")

    f.Fuzz(func(t *testing.T, s string) {
        inv, err := ParseInvoice(s)
        if err != nil {
            return // отказ разобрать мусор — корректное поведение
        }
        // Инвариант: сериализация переразбирается в то же значение
        again, err := ParseInvoice(inv.String())
        if err != nil {
            t.Fatalf("свой же вывод не разбирается: %q -> %q", s, inv.String())
        }
        if again != inv {
            t.Fatalf("round-trip нарушен: %#v != %#v", again, inv)
        }
    })
}

Что здесь по-настоящему важно — не код, а оракул. Фаззер даст вам миллионы входов; вопрос в том, как он поймёт, что результат неправильный. Источники оракулов в порядке убывания доступности: падение процесса → санитайзеры (ASAN/UBSAN, а для управляемых языков — необработанное исключение неожиданного типа) → нарушенные инварианты (round-trip, идемпотентность, неотрицательность) → дифференциальное сравнение с эталонной реализацией. Без оракула фаззинг находит только сегфолты, а в Python/Go/Java их обычно нет — и потому пункты 3–4 для прикладного кода главные.

Куда стоит направлять фаззинг: парсеры любых форматов, десериализация, обработка загруженных файлов, регулярные выражения (ReDoS), конвертеры валют и дат, всё, что принимает байты из внешнего мира. Куда не стоит: CRUD-обработчики и бизнес-логика без нетривиального разбора входа — там дешевле property-based тесты и матрица доступа.

Найденный падающий вход обязательно сохраняется в репозиторий как обычный регрессионный тест: testdata/fuzz/FuzzParseInvoice/<хеш> в Go, отдельный файл корпуса в Python. Иначе через полгода тот же баг вернётся.

Всё это в CI: гейты без превращения пайплайна в шум

Главный инженерный вопрос — что блокирует merge, а что просто создаёт тикет. Ответ определяется временем: PR-пайплайн должен укладываться в минуты (подробнее — тесты в CI).

# .github/workflows/security.yml
name: security

on:
  pull_request:
  schedule:
    - cron: "0 2 * * *"   # ночью — всё долгое

permissions:
  contents: read
  security-events: write  # для загрузки SARIF в code scanning

jobs:
  fast:
    name: Быстрый контур (блокирует merge)
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 8
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0            # gitleaks смотрит историю

      - name: Секреты
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: SAST на изменённом коде
        run: semgrep ci --config=p/default --config=.semgrep/ --sarif --output=semgrep.sarif
        env:
          SEMGREP_RULES: p/secrets

      # Единый формат SARIF даёт находки прямо в диффе PR — это резко
      # повышает шанс, что их починят, а не проигнорируют.
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: semgrep.sarif

      - name: Уязвимые зависимости
        run: |
          pip install pip-audit
          pip-audit -r requirements.txt --strict

      - name: Security-тесты (матрица доступа, инъекции, SSRF)
        run: pytest tests/security -q --maxfail=1

  nightly:
    name: Долгий контур (создаёт тикеты)
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    timeout-minutes: 120
    steps:
      - uses: actions/checkout@v4

      - name: Фаззинг парсеров
        run: |
          python fuzz/fuzz_invoice_parser.py corpus/ \
            -max_total_time=1800 -rss_limit_mb=2048 -print_final_stats=1

      - name: Падающие входы — в артефакты и в тикет
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: fuzz-crashes
          path: crash-*

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

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

Честно про боль

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

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

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

DAST хрупок и медленен так же, как E2E. Он ходит по живому UI, зависит от состояния стенда и данных, флачит по таймаутам. Все приёмы борьбы с хрупкостью из статьи об E2E применимы буквально, плюс DAST-прогон нельзя ставить в блокирующий контур.

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

Фаззинг требует бюджета CPU и терпения. Тридцать секунд в PR не находят ничего. Ценность появляется на масштабе часов и дней непрерывной работы — модель OSS-Fuzz, где цели фаззатся постоянно.

Правовая и этическая рамка

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

  • Тестируйте только системы, на которые у вас есть явное разрешение владельца, с зафиксированным периметром (домены, IP, аккаунты) и временным окном.
  • Публичные программы bug bounty — разрешение в рамках опубликованной политики, и только в её рамках. Вышли за периметр — разрешения нет.
  • Никогда не тестируйте на реальных персональных данных; не выгружайте данные «в доказательство» — доказательством служит минимальный факт доступа.
  • Найденную уязвимость в чужом продукте сообщают приватно, по каналу responsible disclosure, а не в публичном issue.

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

  1. Начинать с инструментов, а не с модели угроз. Получается шумный пайплайн, не связанный с реальными рисками продукта.
  2. Считать OWASP Top 10 чек-листом приёмки. Для требований есть ASVS, для процедур — WSTG.
  3. Тест «нет 500 — значит, безопасно». Успешная инъекция часто отвечает 200. Оракул — соответствие результата правам запрашивающего.
  4. Писать матрицу доступа, глядя на реализацию. Так фиксируется существующая дыра как эталон. Матрица — это спецификация, её источник — продукт, а не код.
  5. Удалять секрет коммитом вместо ротации ключа. Секрет, попавший в историю, скомпрометирован навсегда.
  6. Приоритизировать только по CVSS. Добавьте достижимость кода, EPSS и KEV — иначе команда чинит теоретическое и пропускает эксплуатируемое.
  7. Ставить DAST и фаззинг в блокирующий PR-контур. Медленные и шумные проверки в быстром контуре гарантированно отключат весь контур.
  8. Не писать регрессионный тест на исправленную уязвимость. Через два рефакторинга она вернётся — это подтверждено практикой любой достаточно старой кодовой базы.

Мини-итог

  • Тестирование безопасности проверяет запрещённое поведение, требований на которое в спецификации нет. Их порождает модель угроз — с неё начинается работа.
  • OWASP Top 10 — карта приоритетов; ASVS — источник проверяемых требований; WSTG — процедуры проверки.
  • Инструменты дополняют друг друга слепыми зонами: SAST видит код, DAST — работающую систему, SCA — чужой код, фаззинг — обработку неожиданных входов. Ни один не видит контроль доступа и бизнес-логику — это работа человека, и здесь тестировщик незаменим.
  • Матрица доступа как исполняемая спецификация плюс мета-тест на покрытие всех роутов закрывают категорию №1 в Top 10 дешевле любого сканера.
  • В CI: быстрый блокирующий контур (секреты, SAST на диффе, SCA, security-тесты) и долгий информирующий (DAST, фаззинг, полное сканирование образов).
  • Метрика зрелости — не число находок, а время до устранения и доля просроченных по SLA. Число находок растёт и от того, что вы стали лучше искать.

Источники

  • OWASP Top 10 — категории рисков и их обоснование
  • OWASP ASVS — проверяемые требования по уровням
  • OWASP Web Security Testing Guide — процедуры тестирования
  • OWASP Cheat Sheet Series — практические руководства по каждому классу защит
  • CWE Top 25 — наиболее опасные слабости ПО, взгляд со стороны кода
  • PortSwigger Web Security Academy — бесплатные лаборатории с реальными уязвимостями, лучший практикум
  • FIRST: CVSS и EPSS — оценка тяжести и вероятности эксплуатации
  • Google OSS-Fuzz — как устроен непрерывный фаззинг в масштабе
  • Dafydd Stuttard, Marcus Pinto. The Web Application Hacker’s Handbook — фундаментальный разбор веб-атак
  • Tanya Janca. Alice and Bob Learn Application Security — вводная книга, ориентированная на команды разработки
  • Adam Shostack. Threat Modeling: Designing for Security — систематическое построение модели угроз

Что дальше

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

Мобильное, кроссбраузерное тестирование и доступность

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

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

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

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