Безопасность приложений Моделирование угроз: STRIDE, поверхность атаки, разбор на примере
0%

Моделирование угроз: STRIDE, поверхность атаки, разбор на примере

Моделирование угроз: STRIDE, поверхность атаки, разбор на примере

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

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

Статья идёт следом за картой трека и является самой архитектурной: всё остальное — инъекции, аутентификация, авторизация, криптография — отвечает на угрозы, которые вы научитесь находить здесь.

Четыре вопроса, из которых состоит вся дисциплина

Адам Шостак в книге «Threat Modeling: Designing for Security» (Wiley, 2014) свёл методику к четырём вопросам. Их же зафиксировал Threat Modeling Manifesto — документ 2020 года, подписанный полутора десятками практиков отрасли.

Вопрос Что делаем Артефакт
Что мы строим? Рисуем систему: компоненты, потоки данных, границы доверия DFD, список активов и точек входа
Что может пойти не так? Систематически перебираем угрозы по методике: STRIDE, деревья атак, LINDDUN Журнал угроз с привязкой к элементам
Что мы с этим сделаем? По каждой угрозе выбираем один из четырёх ответов Задачи в трекере, ADR, правки дизайна
Достаточно ли хорошо? Проверяем покрытие и оставляем след, который переживёт ротацию команды Тесты, чек-лист, ревизия модели

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

Дисциплина сложилась быстро: в 1999 году Лорен Конфельдер и Прерит Гарг формулируют STRIDE внутри Microsoft, а Брюс Шнайер публикует статью про деревья атак; в 2004-м DFD и STRIDE становятся частью Microsoft SDL; в 2011-м NIST SP 800-30 rev.1 задаёт общий язык оценки риска; в 2014-м Шостак формулирует четыре вопроса; в 2022-м моделирование угроз попадает в NIST SSDF как требование к процессу разработки.

Словарь, без которого разговор превращается в спор

Половина бесплодных дискуссий о безопасности вызвана тем, что «угроза», «уязвимость» и «риск» используются как синонимы. Строгие определения есть в RFC 4949 «Internet Security Glossary, Version 2» и NIST SP 800-30 rev.1:

  • Актив — то, что имеет ценность: персональные данные клиентов, деньги на счёте, доступность сервиса, ключи подписи артефактов.
  • Угроза — потенциальное нежелательное событие: «кто-то читает отчёты чужого арендатора». Существует всегда, независимо от наличия бага. Уязвимость — конкретный дефект, делающий её реализуемой: «в хендлере GET /api/reports/{id} объект ищется по идентификатору без учёта арендатора». Поверхность атаки — множество путей, по которым недоверенные данные добираются до уязвимости.
  • Риск — угроза, взвешенная по вероятности и ущербу. Приоритизируют именно риски; угроз бесконечно много. Контрмера — изменение системы, снижающее вероятность или ущерб.
  • Модель нарушителя — предположения о том, кто против вас: скрипт-кидди со сканером, конкурент, инсайдер с легальным доступом, организованная группа с бюджетом. От неё зависит, какие угрозы вообще стоит рассматривать.

Полезная эвристика: угроза формулируется предложением с подлежащим и сказуемым, а не ярлыком. Не «XSS», а «злоумышленник, приславший комментарий, выполняет скрипт в браузере администратора и угоняет его сессию». Первое непроверяемо, второе превращается в тест.

Поверхность атаки: считаем не сервисы, а пересечения границ

Поверхность атаки — совокупность точек, через которые недоверенные данные или недоверенный вызывающий попадают внутрь. Наивный подсчёт «у нас 12 микросервисов» бесполезен; считать нужно пересечения границ доверия. Граница доверия — линия, при пересечении которой меняются предположения о том, кому и насколько мы верим: интернет ↔ периметр, процесс ↔ процесс, пользовательское пространство ↔ ядро, ваш код ↔ сторонняя библиотека, приложение ↔ база данных, CI ↔ прод.

Зоны доверия и точки входа в типичном SaaS

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

Практические правила сокращения поверхности:

  1. Выключенное не атакуют. Отладочные эндпойнты, профайлер, GraphQL introspection, /actuator/env, листинг каталогов, дефолтные учётки должны отсутствовать в проде, а не «быть защищёнными».
  2. Один вход лучше пяти. Три способа авторизоваться — три реализации, три набора багов и три места, где забудут добавить MFA. Тот же принцип для типов: эндпойнт, принимающий произвольный JSON, имеет бесконечную поверхность, а принимающий структуру из шести полей с диапазонами — конечную и перечислимую.
  3. Меньше привилегий — меньше последствий. Поверхность считается не только «снаружи внутрь», но и «изнутри дальше»: что сможет сделать компонент, если его захватят. И главное: внутренняя сеть — не граница доверия. Самое дорогое заблуждение архитектуры «мягкая внутри, твёрдая снаружи»; подробнее — в транспортной безопасности.

Шаг 1. «Что мы строим»: диаграмма потоков данных

DFD (data flow diagram) — нотация из пяти элементов, придуманная задолго до информационной безопасности и оказавшаяся идеальной для неё, потому что угрозы живут в потоках, а не в коробочках.

Элемент Обозначение Что это
Внешняя сущность прямоугольник То, чем вы не управляете: пользователь, партнёрский сервис, платёжная система
Процесс скруглённый блок Ваш код: сервис, воркер, лямбда, скрипт миграции
Хранилище цилиндр БД, файл, объектное хранилище, кеш, очередь
Поток данных стрелка Вызов, сообщение, запись; подписывается протоколом и содержимым
Граница доверия пунктир Линия, на которой меняются предположения о доверии

Правила, отличающие полезную DFD от красивой картинки. Уровень 0 (контекст) — система как один блок и все внешние сущности, помещается на салфетку; уровень 1 — основные процессы и хранилища, рабочий уровень для 90 % моделирования; уровень 2 — детализация одного критичного узла. Подписывайте стрелки данными, а не только протоколом: «HTTPS» ничего не даёт, а «идентификатор отчёта + cookie сессии, класс данных — коммерческая тайна» задаёт половину анализа. Каждый поток где-то начинается и заканчивается — висящая стрелка означает незамеченного участника. И диаграмма живёт в репозитории рядом с кодом, а не в презентации.

Система для разбора

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

На рисунке сразу видны вопросы, которых не было в постановке задачи: кто проверяет tenant_id в потоке 5, что мешает воркеру в потоке 9 постучаться на внутренний адрес, кто прочитает объект из потока 8, если ссылка утечёт. Это и есть эффект DFD — она превращает неявные допущения в стрелки, на которые можно показать пальцем.

Шаг 2. «Что может пойти не так»: STRIDE

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

Буква Угроза Отрицаемое свойство Типичные CWE
S Spoofing — выдать себя за другого Аутентичность CWE-287, CWE-290, CWE-384, CWE-807
T Tampering — изменить данные или код Целостность CWE-89, CWE-345, CWE-915, CWE-494
R Repudiation — отрицать совершённое действие Неотказуемость CWE-778, CWE-117, CWE-223
I Information disclosure — раскрыть лишнее Конфиденциальность CWE-200, CWE-639, CWE-532, CWE-209
D Denial of service — лишить доступности Доступность CWE-400, CWE-770, CWE-1333
E Elevation of privilege — получить чужие права Авторизация CWE-269, CWE-862, CWE-863, CWE-502

Идентификаторы CWE — не украшение. CWE даёт общий словарь дефектов: по номеру находится описание, последствия, проверенные способы исправления и связанные шаблоны атак в CAPEC. Ставьте CWE в журнал угроз и в описание задачи — это экономит часы обсуждений и делает возможной статистику «какие классы дефектов мы порождаем чаще».

Типичные проявления, чтобы буквы не оставались абстракцией. S: доверие заголовку от прокси, кража или фиксация сессии, подмена сервиса в кластере. T: инъекция в запрос к интерпретатору, массовое присвоение полей, подмена зависимости при сборке. R: действия нет в журнале, журнал переписываем, время берётся у клиента. I: чужой объект по прямому идентификатору, секреты в логах и трассировках, публичный бакет с выгрузками. D: дорогой запрос без квоты, разрастание распакованного архива, катастрофический бэктрекинг regexp. E: проверка прав только на клиенте, обход по прямому URL админки, небезопасная десериализация.

STRIDE-per-element: как не перебирать 6 × N вслепую

Шостак предложил приём, экономящий половину времени: не все буквы применимы ко всем элементам DFD — хранилище не может «выдать себя за другого» (у него нет намерений), поток данных не повышает привилегии.

Элемент DFD S T R I D E
Внешняя сущность
Процесс
Хранилище
Поток данных

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

Шаг 3. Прогон по примеру: журнал угроз

Сокращённый результат часового прохода по DFD «Витрины отчётов» (в реальном журнале для системы такого размера 30–60 строк):

ID Элемент STRIDE Формулировка угрозы CWE Вер. Ущерб Ответ
T-01 Поток 2, GW → API S Любой, кто оказался в зоне 2, вызывает reports-api напрямую и объявляет себя чужим пользователем через заголовок идентичности CWE-290, CWE-807 выс. крит. Смягчить: подписанный внутренний токен + mTLS
T-02 Процесс reports-api I Отчёт выдаётся по идентификатору без проверки арендатора — клиент видит чужие данные CWE-639 выс. крит. Смягчить: скоуп по арендатору + RLS
T-03 Процесс reports-api D Построение отчёта не ограничено по окну и объёму; параллельные запросы исчерпывают пул и память CWE-770 сред. выс. Смягчить: схема с границами, очередь, квоты
T-04 Поток 9, worker → партнёр I/E Арендатор указывает адрес вебхука, ведущий во внутреннюю сеть или к сервису метаданных облака CWE-918 сред. крит. Смягчить: проверка адреса на dial + egress-прокси
T-05 Поток 10, подписанная ссылка I Ссылка с длинным TTL пересылается в мессенджере и остаётся рабочей неделями CWE-200 выс. сред. Смягчить: TTL 5 минут, привязка к субъекту
T-06 Хранилище S3 I Бакет открыт на чтение из-за ошибки в политике CWE-732 низ. крит. Смягчить: deny по умолчанию + автотест политики
T-07 Процесс auth-service S Подбор пароля без ограничений по скорости и без блокировки CWE-307 выс. выс. Смягчить: лимиты, задержки, MFA
T-08 Процесс auth-service R В журнале нет входов и смен пароля, спор с клиентом неразрешим CWE-778 сред. сред. Смягчить: аудит-лог append-only
T-10 Внешняя сущность «Партнёр» R Партнёр утверждает, что вебхук не приходил сред. низ. Смягчить: журнал доставок + подпись тела запроса
T-12 Поток 1, клиент → GW T Клиент подменяет цену в теле запроса, сервер берёт её как есть CWE-915 сред. выс. Устранить: цена не приходит от клиента

Обратите внимание на последний ответ. Устранить сильнее, чем смягчить: если серверу поле от клиента не нужно, лучше не принимать его, чем валидировать.

Шаг 4. Приоритизация: риск, а не страх

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

Что работает. OWASP Risk Rating Methodology раскладывает вероятность на восемь факторов (навык нарушителя, мотив, доступность, обнаружимость, сложность эксплуатации…), а ущерб — на технический и бизнесовый; формализм неидеален, но заставляет обосновывать оценку. Ещё полезнее оценка «в деньгах и часах»: сколько записей утечёт, сколько стоит простой, каков штраф — диалог с продуктом ведётся в этих единицах, а не в «критично/некритично». CVSS v4.0 годится для уже найденной уязвимости в конкретном компоненте, но базовый вектор не учитывает ваш контекст: «критичная» CVE в библиотеке, код которой у вас не вызывается, ниже по приоритету, чем «средняя» в аутентификации. Для контекста есть средовые метрики и EPSS — вероятность эксплуатации в ближайшие 30 дней.

Что не работает — DREAD в исходном виде: Microsoft отказался от него ещё в середине 2000-х, потому что пять субъективных шкал, заполняемых разными людьми по-разному, дают ложную точность. Нужна лёгкая схема — берите матрицу 3 × 3 и обсуждайте пограничные случаи вслух.

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

Деревья атак: когда шести букв мало

STRIDE отлично работает поэлементно, но плохо описывает цепочки: настоящая компрометация редко состоит из одного шага. Здесь помогают деревья атак, предложенные Брюсом Шнайером (статья 1999 года). В корне — цель нарушителя, ниже — способы её достичь, соединённые через ИЛИ (достаточно любого) и И (нужны все).

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

Шаг 5. От угрозы к коду

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

Порядок проверок на границе доверия

T-01, буква S: сервис верит заголовку от шлюза

# УЯЗВИМО: сервис отчётов принимает идентичность как данные
@app.get("/api/reports/{report_id}")
async def get_report(report_id: str, request: Request):
    tenant = request.headers["X-Tenant-Id"]    # кто это сказал?
    return await reports.load(report_id, tenant=tenant)

Почему это работает. Заголовок — обычная строка в запросе. Утверждение «его ставит шлюз» держится на сетевой топологии, а не на криптографии, и рушится при первом изменении инфраструктуры: соседний под с уязвимостью SSRF, отладочный port-forward, ошибка в NetworkPolicy, сервис-меш без mTLS. Хуже того, шлюзы часто не вычищают входящие заголовки идентичности — тогда достаточно прислать их снаружи. Это CWE-807 «Reliance on Untrusted Inputs in a Security Decision» и CWE-290 «Authentication Bypass by Spoofing».

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

# ПОЧИНЕНО: идентичность приходит с доказательством
import jwt  # PyJWT

def caller(request: Request) -> Principal:
    raw = request.headers.get("Authorization", "")
    if not raw.startswith("Bearer "):
        raise HTTPException(401, "no token")
    token = raw[7:]
    try:
        claims = jwt.decode(
            token,
            key=JWKS.key_for(token),          # ключ по kid из JWKS шлюза
            algorithms=["EdDSA"],             # алгоритм фиксируем, не берём из заголовка токена
            audience="reports-service",       # токен для другого сервиса не подойдёт
            issuer="https://gateway.internal",
            leeway=30,                        # допуск на расхождение часов
            options={"require": ["exp", "iat", "aud", "iss", "sub"]},  # ничего не опционально
        )
    except jwt.PyJWTError as exc:
        raise HTTPException(401, "bad token") from exc
    return Principal(user_id=claims["sub"], tenant=claims["tenant"], scopes=claims["scp"])

@app.get("/api/reports/{report_id}")
async def get_report(report_id: str, who: Principal = Depends(caller)):
    return await reports.load(report_id, tenant=who.tenant)

Тонкости, проваливающие половину внедрений, разобраны в статье про JWT и токены: фиксация алгоритма (иначе подсовывают alg: none или подменяют RS256 на HS256), обязательная проверка aud и iss, короткий exp, ротация ключей через JWKS.

Как проверить регрессионными тестами на своём стенде:

async def test_forged_identity_header_ignored(client):
    r = await client.get("/api/reports/r-1",
                         headers={"X-User-Id": "u-admin", "X-Tenant-Id": "acme"})
    assert r.status_code == 401          # заголовок больше не значит ничего

Аналогично проверяются токен, выписанный для другого aud, и токен с alg: none — оба обязаны давать 401. Плюс e2e-проверка, что шлюз вырезает клиентские заголовки идентичности, и тест сетевой политики: под из чужого namespace не открывает порт сервиса.

T-02, буква I: чужой отчёт по прямому идентификатору

# УЯЗВИМО: объект ищется по идентификатору, владелец не спрашивается
row = await db.fetchrow("SELECT * FROM reports WHERE id = $1", report_id)
if row is None:
    raise NotFound()
return row

Почему это работает. Идентификаторы утекают всегда — из логов прокси, истории браузера, реферера, переписки с поддержкой, — а последовательные целочисленные просто перебираются. Единственное, что стоит между клиентом и чужими данными, — это WHERE. Класс называется IDOR / Broken Object Level Authorization, CWE-639, и годами держится первым пунктом в OWASP API Security Top 10. Вариант «проверим владельца после загрузки» чуть лучше: в системе из тридцати хендлеров кто-нибудь обязательно забудет.

Как чинить. Арендатор — не пост-условие, а часть ключа доступа, продублированная вторым рубежом в самой БД. Непредсказуемые идентификаторы (UUIDv4 или ULID) убирают перебор, но не заменяют проверку — это меры разного уровня; полная картина моделей доступа в статье про авторизацию.

-- второй рубеж: политика внутри PostgreSQL, а не только в коде приложения
ALTER TABLE reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE reports FORCE ROW LEVEL SECURITY;   -- действует и на владельца таблицы

CREATE POLICY reports_tenant_isolation ON reports
    USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
# ПОЧИНЕНО: скоуп по арендатору обязателен и продублирован на уровне БД
async with db.transaction():
    # true = настройка живёт только внутри транзакции и не протекает в пул соединений
    await db.execute("SELECT set_config('app.tenant_id', $1, true)", str(who.tenant))
    row = await db.fetchrow(
        "SELECT id, name, storage_key FROM reports WHERE id = $1 AND tenant_id = $2",
        report_id, who.tenant,
    )
if row is None:
    raise HTTPException(404)   # 404, а не 403: не подтверждаем существование чужого объекта

Как проверить. Матричный тест «вызывающий × владелец» для каждого типа объекта:

@pytest.mark.parametrize("caller,owner,expected", [
    ("acme", "acme", 200), ("acme", "globex", 404), ("globex", "acme", 404),
])
async def test_cross_tenant_access(client, caller, owner, expected):
    report = await seed_report(tenant=owner)
    r = await client.get(f"/api/reports/{report.id}", headers=auth_for(tenant=caller))
    assert r.status_code == expected

И архитектурный тест: ни один SQL-запрос к арендаторским таблицам не идёт мимо репозитория, требующего tenant. Такую проверку удобно делать статически — обходом AST или правилом линтера, — она ловит новые хендлеры до ревью. О месте таких проверок в конвейере: тесты в CI и безопасность в пайплайне.

T-03, буква D: запрос, который стоит вам минуты, а нарушителю — миллисекунды

# УЯЗВИМО: один HTTP-запрос порождает неограниченную работу
@app.post("/api/reports")
async def create_report(spec: dict):            # принимаем что угодно
    rows = await db.fetch(build_query(spec))    # период не ограничен
    return {"csv": to_csv(rows)}                # всё в память, синхронно

Почему это работает. Denial of service в STRIDE — прежде всего асимметрия стоимости, а не флуд пакетами. Запрос в 200 байт запускает полное сканирование таблицы, материализацию в память и синхронное удержание соединения из пула. Десяток параллельных запросов исчерпывает пул и вызывает OOM-kill, после чего страдают все арендаторы: «шумный сосед» становится оружием. Это CWE-770 «Allocation of Resources Without Limits or Throttling».

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

class ReportSpec(BaseModel):
    model_config = ConfigDict(extra="forbid")   # лишние поля — ошибка, а не тихое игнорирование
    date_from: date
    date_to: date
    columns: conlist(Literal["date", "sku", "qty", "amount"], min_length=1, max_length=8)

    @model_validator(mode="after")
    def check_window(self):
        if not 0 <= (self.date_to - self.date_from).days <= 366:
            raise ValueError("окно отчёта — от одного дня до года")
        return self

@app.post("/api/reports", status_code=202)
async def create_report(spec: ReportSpec, who: Principal = Depends(caller),
                        idem: str = Header(alias="Idempotency-Key")):
    await quota.consume(who.tenant, cost=estimate_cost(spec))   # бюджет арендатора
    task_id = await queue.enqueue("build_report", tenant=who.tenant, spec=spec, key=idem)
    return {"task_id": task_id}                                 # 202 и ссылка на статус

В воркере — SET LOCAL statement_timeout = '30s', курсорное чтение вместо fetchall, жёсткий лимит строк, потоковая запись в объектное хранилище и выдача результата по подписанной ссылке с TTL в минуты. extra="forbid" заодно закрывает массовое присвоение (CWE-915) — подробнее в безопасности API.

Как проверить. Окно в 400 дней и неизвестное поле в теле → 422. Одиннадцатый запрос за минуту → 429 с Retry-After. Воркер прерывает задачу, превысившую лимит строк или времени. И нагрузочный сценарий на своём стенде: под самым «дорогим» разрешённым запросом p99 остальных арендаторов не деградирует — методика в статье про нагрузочное тестирование.

T-04, буквы I и E: вебхук как окно во внутреннюю сеть

// УЯЗВИМО: адрес приходит от арендатора и используется как есть
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(body))
resp, err := http.DefaultClient.Do(req) // любые адреса, любые редиректы, без тайм-аута

Почему это работает. Воркер стоит в зоне 2 и ходит туда, куда внешний клиент попасть не может: сервис метаданных облака, внутренние админки без аутентификации, служебные API. Указав адрес вебхука, арендатор получает возможность отправлять запросы от имени вашего сервиса — это SSRF, CWE-918. Наивная защита «проверим hostname через net.LookupHost перед отправкой» не работает: между проверкой и соединением DNS может ответить иначе (DNS rebinding), а редирект уводит куда угодно.

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

// ПОЧИНЕНО: Control вызывается после резолва и до connect —
// адрес здесь уже настоящий, поэтому DNS rebinding не помогает
func controlAddr(network, address string, _ syscall.RawConn) error {
    host, _, err := net.SplitHostPort(address)
    if err != nil {
        return err
    }
    ip := net.ParseIP(host)
    if ip == nil || !isPublicUnicast(ip) {
        return fmt.Errorf("ssrf guard: адрес %s запрещён", host)
    }
    return nil
}

// отсекаем loopback, приватные, link-local, multicast, unspecified, 0.0.0.0/8 и CGNAT 100.64/10;
// для IPv6 те же проверки покрывают ULA fc00::/7 и mapped-адреса вида ::ffff:127.0.0.1
func isPublicUnicast(ip net.IP) bool {
    if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
        ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() {
        return false
    }
    if v4 := ip.To4(); v4 != nil {
        return v4[0] != 0 && !(v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127)
    }
    return true
}

Функция вешается на net.Dialer{Control: controlAddr}, а сам http.Client получает Timeout: 5 * time.Second и CheckRedirect, возвращающий http.ErrUseLastResponse — редиректы не следуем вообще. Схему допускаем только https, порт — только 443, тело ответа читаем через io.LimitReader. Тело исходящего запроса подписываем HMAC с секретом арендатора — это заодно закрывает T-10 и позволяет партнёру отличить наш вызов от чужого.

Как проверить. Табличный юнит-тест isPublicUnicast на всех особых диапазонах; интеграционный тест с локальным сервером, отвечающим 302 на приватный адрес; тест с подменённым резолвером, возвращающим приватный адрес, — соединение обязано отклоняться на этапе dial; в проде — алерт на исходящие соединения воркера в приватные диапазоны (второй рубеж должен молчать, а если сработал — сломан первый).

T-08, буква R: события, которые нельзя оспорить

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

Минимальный работающий аудит: отдельное append-only хранилище, куда приложение может писать, но не может изменять и удалять; серверное время из синхронизированного источника; в каждой записи — субъект (идентификатор, а не имя), действие, объект, результат, trace_id, версия схемы. Записывать факт, а не тело запроса: пароли, токены и персональные данные в журнале превращают средство защиты в источник утечки (CWE-532). Что именно нельзя логировать и как это соотносится с требованиями к персональным данным — в статье о приватности и соответствии. Проверка: тест «после смены пароля в аудите ровно одна запись с ожидаемыми полями» и тест «UPDATE/DELETE в таблице аудита от роли приложения завершается ошибкой прав».

Шаг 6. «Достаточно ли хорошо»: жизненный цикл угрозы

Угроза — не задача, которую «сделали и закрыли». У неё есть состояние, владелец и условия возврата в работу.

Критерий «достаточно» проверяется по чек-листу:

  • Каждый элемент DFD пройден по применимым буквам STRIDE, «не применимо» обосновано письменно, и у каждой угрозы выше порога есть один из четырёх ответов, а не «обсудили».
  • У каждой контрмеры есть тест, который падал до её появления — главный критерий, отличающий решённую проблему от обсуждённой.
  • Принятые риски имеют владельца, обоснование и дату пересмотра, а диаграмма и журнал лежат в репозитории и обновляются в тех же PR, что и код.

Модель угроз как код

Артефакт в вики умирает за квартал. Артефакт в репозитории живёт, потому что ломает сборку, когда расходится с реальностью. pytm описывает модель на Python и генерирует из неё DFD, отчёт и список угроз по встроенной базе правил:

from pytm import TM, Actor, Server, Datastore, Dataflow, Boundary, Classification

tm = TM("Витрина отчётов")
internet, appzone, datazone = Boundary("Интернет"), Boundary("Приложение"), Boundary("Данные")

user = Actor("Пользователь арендатора"); user.inBoundary = internet

api = Server("reports-api")
api.inBoundary = appzone
api.sanitizesInput, api.authenticatesSource = True, True   # свойства — входные данные для правил

pg = Datastore("PostgreSQL")
pg.inBoundary = datazone
pg.storesSensitiveData, pg.isEncrypted = True, True

req = Dataflow(user, api, "запрос отчёта")
req.protocol, req.dstPort = "HTTPS", 443
req.classification = Classification.RESTRICTED

if __name__ == "__main__":
    tm.process()

python tm.py --dfd | dot -Tsvg > dfd.svg рисует диаграмму, --report собирает отчёт, --json даёт машиночитаемый список угроз. В CI это превращается в гейт: новый список сравнивается с зафиксированным baseline, сборка падает при появлении угрозы без ответа. Альтернативы: OWASP Threat Dragon — визуальный редактор с хранением модели в JSON рядом с кодом, удобен для воркшопов с непрограммистами; Threagile — модель в YAML с примерно сорока встроенными правилами. Инструмент вторичен: модель на mermaid прямо в docs/threat-model.md плюс таблица угроз в том же файле дают 80 % эффекта, потому что попадают в code review.

Как встроить это в работу команды

Когда моделировать — по триггерам, а не «раз в год»: новая внешняя интеграция или способ аутентификации; новая граница доверия (вынесли сервис, добавили очередь, открыли партнёрский API); новый класс данных (платёжные реквизиты, персональные данные); изменение модели нарушителя (сервис стал публичным, появился self-service); после инцидента — обязательно, включая проверку «а где ещё такой же дефект».

Кто и сколько. Двое-трое разработчиков, знающих систему, плюс тот, кто держит дисциплину процесса; специалист по безопасности полезен, но его отсутствие — не повод не моделировать. Формат близок к event storming: доска, стикеры, таймбокс 60–90 минут, один поток данных за раз. Первый проход по системе среднего размера — половина дня, инкрементальный при добавлении фичи — 15–30 минут, часто прямо в описании задачи.

Куда идут результаты. Угрозы выше порога — в бэклог как обычные задачи с CWE в описании и критерием готовности «есть падающий тест». Архитектурные решения — в ADR, диаграмма — в репозиторий; ничего в отдельный «документ по безопасности», который никто не откроет. Найденные угрозы дальше отрабатываются в статьях про инъекции, XSS и CSRF, аутентификацию, секреты и цепочку поставок; практики ревью и инструменты SAST/DAST — в безопасной разработке.

Границы легального: проверять можно только своё

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

  1. Письменное разрешение владельца системы — обязательно и до начала работ. Устная договорённость, договорённость с коллегой из соседней команды или «я же просто посмотрел» не считаются; разрешение фиксирует, кто его дал и на каком основании.
  2. Область (scope) определяется явно — перечень доменов, адресов, учётных записей и сред. Всё, что не перечислено, вне области, даже если «явно тоже их».
  3. Правила проведения (rules of engagement): временные окна, запрещённые действия (нагрузочные проверки, социальная инженерия, работа с реальными персональными данными), контакт для экстренной остановки, порядок обращения с найденными данными. Матричные тесты доступа гоняются на синтетических арендаторах, а не на аккаунтах живых клиентов.
  4. Чужая инфраструктура — только через программы ответственного раскрытия. Публичная политика bug bounty или security.txt (RFC 9116) — форма разрешения, действующая ровно в описанных ею границах.

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

Частые ошибки

  • Моделировать после релиза. Тогда единственный доступный ответ — «смягчить», а самый дешёвый, «устранить», уже недоступен.
  • Рисовать компоненты вместо потоков. Схема с коробочками не показывает, где пересекаются границы доверия, — нужны стрелки с данными, а не рамки.
  • Забывать про «изнутри». Инсайдер, скомпрометированный сервис, сбежавшая сборка в CI — легитимные источники угроз и часто самые дорогие.
  • Считать список STRIDE отчётностью. Шесть букв — способ не забыть спросить, а не форма для заполнения; а модель без владельцев и сроков через месяц не помнит никто.
  • Игнорировать доступность. Буква D выпадает чаще всех, а отказ в обслуживании — самый частый реальный инцидент у продуктовых команд.
  • Смешивать угрозы приватности с угрозами безопасности. Легальный сбор избыточных данных не ломает триаду «конфиденциальность — целостность — доступность», но создаёт риск; для этого есть отдельная методика LINDDUN. И наоборот, одна огромная модель на всю компанию бесполезна: модели должны быть маленькие, по сервисам и фичам, и обновляться вместе с кодом.

Мини-итог

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

Источники

Что дальше

Модель угроз даёт список того, что может пойти не так именно у вас. Следующий шаг — сверить его с отраслевым знанием о том, что чаще всего идёт не так у всех: OWASP Top 10: что это, как читать и что делать с каждым пунктом.

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

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

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

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