Моделирование угроз: 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 ↔ прод.
Каждая точка входа — место, где обязаны сработать одни и те же семь вещей: защищённый канал, аутентификация вызывающего, разбор с лимитами, валидация по схеме, авторизация, квота, аудит. Пропущена хотя бы одна — угроза становится уязвимостью.
Практические правила сокращения поверхности:
- Выключенное не атакуют. Отладочные эндпойнты, профайлер, GraphQL introspection,
/actuator/env, листинг каталогов, дефолтные учётки должны отсутствовать в проде, а не «быть защищёнными». - Один вход лучше пяти. Три способа авторизоваться — три реализации, три набора багов и три места, где забудут добавить MFA. Тот же принцип для типов: эндпойнт, принимающий произвольный JSON, имеет бесконечную поверхность, а принимающий структуру из шести полей с диапазонами — конечную и перечислимую.
- Меньше привилегий — меньше последствий. Поверхность считается не только «снаружи внутрь», но и «изнутри дальше»: что сможет сделать компонент, если его захватят. И главное: внутренняя сеть — не граница доверия. Самое дорогое заблуждение архитектуры «мягкая внутри, твёрдая снаружи»; подробнее — в транспортной безопасности.
Шаг 1. «Что мы строим»: диаграмма потоков данных
DFD (data flow diagram) — нотация из пяти элементов, придуманная задолго до информационной безопасности и оказавшаяся идеальной для неё, потому что угрозы живут в потоках, а не в коробочках.
| Элемент | Обозначение | Что это |
|---|---|---|
| Внешняя сущность | прямоугольник | То, чем вы не управляете: пользователь, партнёрский сервис, платёжная система |
| Процесс | скруглённый блок | Ваш код: сервис, воркер, лямбда, скрипт миграции |
| Хранилище | цилиндр | БД, файл, объектное хранилище, кеш, очередь |
| Поток данных | стрелка | Вызов, сообщение, запись; подписывается протоколом и содержимым |
| Граница доверия | пунктир | Линия, на которой меняются предположения о доверии |
Правила, отличающие полезную DFD от красивой картинки. Уровень 0 (контекст) — система как один блок и все внешние сущности, помещается на салфетку; уровень 1 — основные процессы и хранилища, рабочий уровень для 90 % моделирования; уровень 2 — детализация одного критичного узла. Подписывайте стрелки данными, а не только протоколом: «HTTPS» ничего не даёт, а «идентификатор отчёта + cookie сессии, класс данных — коммерческая тайна» задаёт половину анализа. Каждый поток где-то начинается и заканчивается — висящая стрелка означает незамеченного участника. И диаграмма живёт в репозитории рядом с кодом, а не в презентации.
Система для разбора
Дальше разбирается один пример — мультиарендная «Витрина отчётов». B2B SaaS: клиенты-арендаторы загружают выгрузки продаж, строят отчёты и получают уведомления в свои системы через вебхуки.
браузер и SPA"] P["Партнёрская система
приёмник вебхуков"] end subgraph z1["Зона 1 — периметр"] GW["API Gateway
TLS, лимиты, маршрутизация"] end subgraph z2["Зона 2 — приложение"] AUTH["auth-service"] API["reports-api"] WRK["report-worker"] end subgraph z3["Зона 3 — данные"] PG[("PostgreSQL
reports, tenants, users")] S3[("Объектное хранилище
готовые выгрузки")] RD[("Redis
сессии, очередь, квоты")] end U -->|1. HTTPS, cookie сессии| GW GW -->|2. внутренний токен, tenant_id| API GW -->|3. вход и обновление сессии| AUTH AUTH -->|4. учётные данные| PG API -->|5. SQL с обязательным tenant_id| PG API -->|6. постановка задачи| RD WRK -->|7. курсорное чтение| PG WRK -->|8. запись объекта| S3 WRK -->|9. исходящий POST вебхука| P API -->|10. подписанная ссылка с коротким TTL| U
На рисунке сразу видны вопросы, которых не было в постановке задачи: кто проверяет 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 — в безопасной разработке.
Границы легального: проверять можно только своё
Всё описанное выше — анализ и укрепление собственной системы. Как только речь заходит о практической проверке, действуют жёсткие рамки:
- Письменное разрешение владельца системы — обязательно и до начала работ. Устная договорённость, договорённость с коллегой из соседней команды или «я же просто посмотрел» не считаются; разрешение фиксирует, кто его дал и на каком основании.
- Область (scope) определяется явно — перечень доменов, адресов, учётных записей и сред. Всё, что не перечислено, вне области, даже если «явно тоже их».
- Правила проведения (rules of engagement): временные окна, запрещённые действия (нагрузочные проверки, социальная инженерия, работа с реальными персональными данными), контакт для экстренной остановки, порядок обращения с найденными данными. Матричные тесты доступа гоняются на синтетических арендаторах, а не на аккаунтах живых клиентов.
- Чужая инфраструктура — только через программы ответственного раскрытия. Публичная политика bug bounty или
security.txt(RFC 9116) — форма разрешения, действующая ровно в описанных ею границах.
Инженерный вывод: почти всё, что нужно для проверки контрмер, делается регрессионными тестами на своём стенде — они дешевле, воспроизводимее и не создают юридических вопросов. Методику проверок безопасности в рамках QA разбирает статья тестирование безопасности.
Частые ошибки
- Моделировать после релиза. Тогда единственный доступный ответ — «смягчить», а самый дешёвый, «устранить», уже недоступен.
- Рисовать компоненты вместо потоков. Схема с коробочками не показывает, где пересекаются границы доверия, — нужны стрелки с данными, а не рамки.
- Забывать про «изнутри». Инсайдер, скомпрометированный сервис, сбежавшая сборка в CI — легитимные источники угроз и часто самые дорогие.
- Считать список STRIDE отчётностью. Шесть букв — способ не забыть спросить, а не форма для заполнения; а модель без владельцев и сроков через месяц не помнит никто.
- Игнорировать доступность. Буква D выпадает чаще всех, а отказ в обслуживании — самый частый реальный инцидент у продуктовых команд.
- Смешивать угрозы приватности с угрозами безопасности. Легальный сбор избыточных данных не ломает триаду «конфиденциальность — целостность — доступность», но создаёт риск; для этого есть отдельная методика LINDDUN. И наоборот, одна огромная модель на всю компанию бесполезна: модели должны быть маленькие, по сервисам и фичам, и обновляться вместе с кодом.
Мини-итог
Моделирование угроз — структурированный способ задать четыре вопроса и записать ответы так, чтобы они пережили смену команды. Рабочий минимум: нарисовать DFD с границами доверия, пройти по элементам с STRIDE, оценить риск как вероятность × ущерб, выбрать один из четырёх ответов на каждую значимую угрозу и закрыть каждую контрмеру тестом, который падал до неё. Всё остальное — инструменты, шаблоны и глубина детализации — подстраивается под контекст. Главный признак, что практика прижилась: в описании обычной продуктовой задачи появляется абзац «какие границы доверия она пересекает и что на них проверяется». С этого момента безопасность перестаёт быть отдельным этапом и становится свойством того, как команда думает.
Источники
- Adam Shostack. Threat Modeling: Designing for Security. Wiley, 2014 — базовый учебник дисциплины.
- Threat Modeling Manifesto — ценности и антипаттерны; OWASP Threat Modeling Cheat Sheet и OWASP Threat Modeling Process — рабочие инструкции.
- NIST: SP 800-30 rev.1 — язык оценки риска; SP 800-154 — моделирование, отталкивающееся от данных; SP 800-218 SSDF — практика PW.1, моделирование угроз как требование к процессу.
- CWE, CWE Top 25, CAPEC и MITRE ATT&CK — словари дефектов, шаблонов атак и техник нарушителя.
- CVSS v4.0, EPSS и OWASP Risk Rating Methodology — оценка серьёзности и приоритета.
- RFC: 4949 — терминология, 3552 — образцовый разбор модели угроз протокола, 9116 —
security.txt. - OWASP ASVS — требования, в которые удобно превращать контрмеры; Bruce Schneier, Attack Trees, 1999.
- Инструменты: pytm, OWASP Threat Dragon, Threagile, Microsoft Threat Modeling Tool.
Что дальше
Модель угроз даёт список того, что может пойти не так именно у вас. Следующий шаг — сверить его с отраслевым знанием о том, что чаще всего идёт не так у всех: OWASP Top 10: что это, как читать и что делать с каждым пунктом.