revise January 2023 threat model articles
Build and deploy / deploy (push) Successful in 16s

This commit is contained in:
2026-07-31 14:24:50 +03:00
parent 1fe13dbdbe
commit 31ed7053e7
7 changed files with 383 additions and 1 deletions
+2
View File
@@ -55,6 +55,7 @@ import { revisions as september2022Revisions } from '../scripts/upgrade-2022-09.
import { revisions as october2022Revisions } from '../scripts/upgrade-2022-10.mjs';
import { revisions as november2022Revisions } from '../scripts/upgrade-2022-11.mjs';
import { revisions as december2022Revisions } from '../scripts/upgrade-2022-12.mjs';
import { revisions as january2023Revisions } from '../scripts/upgrade-2023-01.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -115,4 +116,5 @@ export const editorialRevisions = [
...october2022Revisions,
...november2022Revisions,
...december2022Revisions,
...january2023Revisions,
];
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720" role="img" aria-labelledby="title desc">
<title id="title">Контракт threat, control и evidence</title>
<desc id="desc">Показаны обязательные поля учебной модели, принятый план с одной связью control-evidence, ветки отклонения и rollback к snapshot.</desc>
<defs><marker id="a" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0 0 L10 3 L0 6 Z" fill="#17324d"/></marker></defs>
<rect width="1200" height="720" fill="#f8fafc"/>
<text x="70" y="75" font-family="Arial, sans-serif" font-size="34" font-weight="700" fill="#17324d">Контракт: не угадывать пропущенный контекст</text>
<rect x="70" y="140" width="290" height="370" rx="18" fill="#e8f2ff" stroke="#2176ae" stroke-width="3"/>
<text x="100" y="190" font-family="Arial, sans-serif" font-size="25" font-weight="700" fill="#17324d">Вход модели</text>
<text x="100" y="245" font-family="Arial, sans-serif" font-size="21" fill="#17324d">actor</text><text x="100" y="300" font-family="Arial, sans-serif" font-size="21" fill="#17324d">asset</text><text x="100" y="355" font-family="Arial, sans-serif" font-size="21" fill="#17324d">boundary</text><text x="100" y="410" font-family="Arial, sans-serif" font-size="21" fill="#17324d">abuse</text><text x="100" y="465" font-family="Arial, sans-serif" font-size="21" fill="#17324d">control</text>
<line x1="360" y1="325" x2="500" y2="325" stroke="#17324d" stroke-width="5" marker-end="url(#a)"/>
<rect x="500" y="165" width="330" height="320" rx="18" fill="#e3f4e6" stroke="#287744" stroke-width="3"/>
<text x="665" y="215" text-anchor="middle" font-family="Arial, sans-serif" font-size="25" font-weight="700" fill="#185d31">accepted plan</text>
<text x="535" y="270" font-family="Arial, sans-serif" font-size="20" fill="#17324d">threat → unsigned request</text><text x="535" y="325" font-family="Arial, sans-serif" font-size="20" fill="#17324d">control → signed-request</text><text x="535" y="380" font-family="Arial, sans-serif" font-size="20" fill="#17324d">evidence → local rejection</text><text x="535" y="435" font-family="Arial, sans-serif" font-size="20" fill="#17324d">rollback → snapshot</text>
<line x1="830" y1="265" x2="965" y2="265" stroke="#17324d" stroke-width="5" marker-end="url(#a)"/>
<rect x="965" y="185" width="180" height="160" rx="16" fill="#eee8ff" stroke="#6a52a3" stroke-width="3"/>
<text x="1055" y="245" text-anchor="middle" font-family="Arial, sans-serif" font-size="23" font-weight="700" fill="#4b3881">one evidence</text><text x="1055" y="280" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" fill="#17324d">for one control</text>
<path d="M250 510 V595 H950 V485" fill="none" stroke="#6a52a3" stroke-width="4" stroke-dasharray="11 9" marker-end="url(#a)"/>
<text x="600" y="630" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#4b3881">rollback restores the teaching snapshot; it is not a deployment rollback</text>
<rect x="70" y="540" width="235" height="100" rx="12" fill="#ffe4e4" stroke="#c44" stroke-width="2"/>
<text x="92" y="580" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#8e2424">reject</text><text x="92" y="615" font-family="Arial, sans-serif" font-size="17" fill="#3c2424">missing asset / unknown control</text>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720" role="img" aria-labelledby="title desc">
<title id="title">Маршрут диагностики и rollback модели угроз</title>
<desc id="desc">Последовательность вопросов ведёт от контроля без объекта защиты к активу, границе, злоупотреблению и evidence. Принятый учебный план можно вернуть к исходной записи.</desc>
<defs><marker id="a" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0 0 L10 3 L0 6 Z" fill="#17324d"/></marker></defs>
<rect width="1200" height="720" fill="#f8fafc"/>
<text x="70" y="78" font-family="Arial, sans-serif" font-size="34" font-weight="700" fill="#17324d">Маршрут ревью: от готового контроля к проверяемой связи</text>
<g font-family="Arial, sans-serif" fill="#17324d">
<rect x="70" y="175" width="210" height="105" rx="14" fill="#ffe4e4" stroke="#c44" stroke-width="3"/><text x="175" y="220" text-anchor="middle" font-size="21" font-weight="700">control без asset</text><text x="175" y="252" text-anchor="middle" font-size="17">остановить выбор</text>
<rect x="350" y="175" width="210" height="105" rx="14" fill="#e8f2ff" stroke="#2176ae" stroke-width="3"/><text x="455" y="220" text-anchor="middle" font-size="21" font-weight="700">назвать asset</text><text x="455" y="252" text-anchor="middle" font-size="17">что защищаем?</text>
<rect x="630" y="175" width="210" height="105" rx="14" fill="#e8f2ff" stroke="#2176ae" stroke-width="3"/><text x="735" y="220" text-anchor="middle" font-size="21" font-weight="700">назвать boundary</text><text x="735" y="252" text-anchor="middle" font-size="17">где решение?</text>
<rect x="910" y="175" width="210" height="105" rx="14" fill="#e8f2ff" stroke="#2176ae" stroke-width="3"/><text x="1015" y="220" text-anchor="middle" font-size="21" font-weight="700">назвать abuse</text><text x="1015" y="252" text-anchor="middle" font-size="17">что блокируем?</text>
</g>
<g stroke="#17324d" stroke-width="5" marker-end="url(#a)"><line x1="280" y1="227" x2="350" y2="227"/><line x1="560" y1="227" x2="630" y2="227"/><line x1="840" y1="227" x2="910" y2="227"/></g>
<line x1="1015" y1="280" x2="1015" y2="390" stroke="#17324d" stroke-width="5" marker-end="url(#a)"/>
<rect x="815" y="390" width="300" height="120" rx="16" fill="#e3f4e6" stroke="#287744" stroke-width="3"/>
<text x="965" y="438" text-anchor="middle" font-family="Arial, sans-serif" font-size="23" font-weight="700" fill="#185d31">control + evidence</text><text x="965" y="475" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" fill="#17324d">названный отрицательный check</text>
<path d="M815 450 H500 V575" fill="none" stroke="#17324d" stroke-width="5" marker-end="url(#a)"/>
<rect x="325" y="575" width="350" height="95" rx="15" fill="#eee8ff" stroke="#6a52a3" stroke-width="3"/>
<text x="500" y="615" text-anchor="middle" font-family="Arial, sans-serif" font-size="23" font-weight="700" fill="#4b3881">accepted teaching plan</text><text x="500" y="647" text-anchor="middle" font-family="Arial, sans-serif" font-size="17" fill="#17324d">snapshot can be restored</text>
<path d="M325 620 H130 V365" fill="none" stroke="#6a52a3" stroke-width="4" stroke-dasharray="11 9" marker-end="url(#a)"/>
<text x="80" y="350" font-family="Arial, sans-serif" font-size="18" font-weight="700" fill="#4b3881">rollback of model</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720" role="img" aria-labelledby="title desc">
<title id="title">Учебный DFD одного потока и связанный контроль</title>
<desc id="desc">Browser client пересекает границу доверия перед handler. Handler сохраняет change request. Возле границы показаны злоупотребление, контроль и evidence.</desc>
<defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#17324d"/></marker></defs>
<rect width="1200" height="720" fill="#f7fafc"/>
<text x="70" y="80" font-family="Arial, sans-serif" font-size="34" font-weight="700" fill="#17324d">Один учебный поток: сначала актив и граница</text>
<rect x="70" y="250" width="215" height="120" rx="16" fill="#d8ecff" stroke="#2176ae" stroke-width="3"/>
<text x="177" y="302" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="700" fill="#17324d">browser-client</text><text x="177" y="335" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" fill="#17324d">source</text>
<line x1="285" y1="310" x2="475" y2="310" stroke="#17324d" stroke-width="5" marker-end="url(#arrow)"/>
<line x1="390" y1="165" x2="390" y2="480" stroke="#d64545" stroke-width="5" stroke-dasharray="12 10"/>
<text x="390" y="140" text-anchor="middle" font-family="Arial, sans-serif" font-size="19" font-weight="700" fill="#a12c2c">trust boundary</text><text x="390" y="505" text-anchor="middle" font-family="Arial, sans-serif" font-size="17" fill="#a12c2c">public-api-to-handler</text>
<rect x="475" y="250" width="220" height="120" rx="16" fill="#e6f5df" stroke="#3b7a35" stroke-width="3"/>
<text x="585" y="302" text-anchor="middle" font-family="Arial, sans-serif" font-size="25" font-weight="700" fill="#17324d">handler</text><text x="585" y="335" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" fill="#17324d">process</text>
<line x1="695" y1="310" x2="885" y2="310" stroke="#17324d" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M885 250 H1085 L1115 280 V370 H885 Z" fill="#fff4cf" stroke="#9c6d00" stroke-width="3"/>
<text x="1000" y="302" text-anchor="middle" font-family="Arial, sans-serif" font-size="23" font-weight="700" fill="#17324d">change-request</text><text x="1000" y="335" text-anchor="middle" font-family="Arial, sans-serif" font-size="18" fill="#17324d">asset / store</text>
<rect x="78" y="545" width="315" height="105" rx="12" fill="#ffe1e1" stroke="#c44" stroke-width="2"/>
<text x="97" y="582" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#8e2424">Abuse</text><text x="97" y="617" font-family="Arial, sans-serif" font-size="18" fill="#3c2424">request without valid signature</text>
<rect x="440" y="545" width="310" height="105" rx="12" fill="#dff4e4" stroke="#287744" stroke-width="2"/>
<text x="459" y="582" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#185d31">Control</text><text x="459" y="617" font-family="Arial, sans-serif" font-size="18" fill="#17324d">signed-request: reject before handler</text>
<rect x="790" y="545" width="340" height="105" rx="12" fill="#e8e2ff" stroke="#6a52a3" stroke-width="2"/>
<text x="809" y="582" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#4b3881">Evidence</text><text x="809" y="617" font-family="Arial, sans-serif" font-size="18" fill="#17324d">local check: unsigned value rejected</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+283
View File
@@ -0,0 +1,283 @@
function escapeHtml(value) {
return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content.replace(/<[^>]+>/g, ' ').replaceAll('&nbsp;', ' ').replaceAll('&quot;', '"').replaceAll('&#039;', "'").replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&').replace(/\s+/g, ' ').trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const sources = [
{
title: 'NIST SP 800-154, Initial Public Draft: Guide to Data-Centric System Threat Modeling, 14 марта 2016',
url: 'https://csrc.nist.gov/files/pubs/sp/800/154/ipd/docs/sp800_154_draft.pdf',
note: 'официальный датированный initial public draft, доступный к январю 2023. Это не финальная публикация; он задаёт data-centric взгляд на модель угроз, а не аттестует систему.',
},
{
title: 'NIST SP 800-218 SSDF Version 1.1, Final, 3 февраля 2022',
url: 'https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-218.pdf',
note: 'официальный финальный документ, доступный к январю 2023. Он описывает практики безопасной разработки; не доказывает, что конкретный код им соответствует.',
},
{
title: 'NIST SP 800-53 Revision 5, Security and Privacy Controls, September 2020',
url: 'https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-53r5.pdf',
note: 'неизменяемый официальный PDF. Каталог помогает назвать тип контроля и evidence, но сам по себе не выбирает контроль для конкретного потока.',
},
];
function sourceList() {
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function revision(meta, parts) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': основной текст вне диапазона: ' + proseLength);
return { ...meta, contentHtml, proseLength };
}
/**
* Малый учебный DFD-контракт. Он не читает конфигурацию, не ищет уязвимости,
* не анализирует сеть, не запускает криптографию и не доказывает безопасность.
* Значения actor, asset, boundary и abuse вручную заданы для одного потока.
*/
export function planTeachingThreatModel(input) {
const base = { kind: 'teaching-threat-model-v1', assessment: 'not-performed', environment: 'not-observed' };
if (!input || typeof input !== 'object') return Object.freeze({ ...base, accepted: false, reason: 'invalid-input', controls: [], evidence: [] });
const required = ['actor', 'asset', 'boundary', 'abuse'];
for (const key of required) {
if (typeof input[key] !== 'string' || !input[key].trim()) return Object.freeze({ ...base, accepted: false, reason: 'missing-' + key, controls: [], evidence: [] });
}
if (!['signed-request', 'least-privilege', 'input-validation'].includes(input.control)) {
return Object.freeze({ ...base, accepted: false, reason: 'unknown-control', controls: [], evidence: [] });
}
const before = Object.freeze({ actor: input.actor, asset: input.asset, boundary: input.boundary, abuse: input.abuse });
const control = input.control === 'signed-request'
? Object.freeze({ id: 'signed-request', claim: 'принять запрос только после проверяемой подписи', evidence: 'проверка отказа неподписанного учебного запроса' })
: input.control === 'least-privilege'
? Object.freeze({ id: 'least-privilege', claim: 'выдать обработчику только нужное действие', evidence: 'проверка отказа лишнего учебного действия' })
: Object.freeze({ id: 'input-validation', claim: 'отклонить вход вне явного формата', evidence: 'проверка отклонения учебного значения' });
return Object.freeze({
...base,
accepted: true,
reason: 'teaching-model-planned',
dfd: Object.freeze({ source: input.actor, flow: 'request', boundary: input.boundary, process: 'handler', store: input.asset }),
threat: Object.freeze({ abuse: input.abuse, affectedAsset: input.asset }),
controls: Object.freeze([control]),
evidence: Object.freeze([control.evidence]),
rollback: Object.freeze({ action: 'restore-before-control-plan', snapshot: before }),
});
}
export function rollbackTeachingThreatModel(plan) {
if (!plan || !plan.accepted || !plan.rollback?.snapshot) return Object.freeze({ restored: false, reason: 'no-accepted-plan' });
return Object.freeze({ restored: true, reason: 'teaching-snapshot-restored', snapshot: plan.rollback.snapshot, assessment: 'not-performed' });
}
export function runThreatModelFixture() {
const valid = planTeachingThreatModel({ actor: 'browser-client', asset: 'change-request', boundary: 'public-api-to-handler', abuse: 'send request without a valid signature', control: 'signed-request' });
const missingAsset = planTeachingThreatModel({ actor: 'browser-client', asset: '', boundary: 'public-api-to-handler', abuse: 'send request without a valid signature', control: 'signed-request' });
const missingBoundary = planTeachingThreatModel({ actor: 'browser-client', asset: 'change-request', boundary: '', abuse: 'send request without a valid signature', control: 'signed-request' });
const unknownControl = planTeachingThreatModel({ actor: 'browser-client', asset: 'change-request', boundary: 'public-api-to-handler', abuse: 'send request without a valid signature', control: 'encrypt-everything' });
const restored = rollbackTeachingThreatModel(valid);
const rejectedRollback = rollbackTeachingThreatModel(missingAsset);
return Object.freeze({
assertions: Object.freeze({
validPlanAccepted: valid.accepted === true,
dfdNamesBoundary: valid.dfd.boundary === 'public-api-to-handler',
threatNamesAffectedAsset: valid.threat.affectedAsset === 'change-request',
evidenceBoundToControl: valid.evidence[0] === 'проверка отказа неподписанного учебного запроса',
missingAssetRejected: missingAsset.accepted === false && missingAsset.reason === 'missing-asset',
missingBoundaryRejected: missingBoundary.accepted === false && missingBoundary.reason === 'missing-boundary',
unknownControlRejected: unknownControl.accepted === false && unknownControl.reason === 'unknown-control',
modelDoesNotAssessSystem: valid.assessment === 'not-performed',
modelDoesNotObserveEnvironment: valid.environment === 'not-observed',
rollbackRestoresOriginalBoundary: restored.restored === true && restored.snapshot.boundary === 'public-api-to-handler',
rollbackDoesNotClaimSecurity: restored.assessment === 'not-performed' && !Object.hasOwn(restored, 'controls') && !Object.hasOwn(restored, 'evidence'),
rejectedPlanHasNoRollback: rejectedRollback.restored === false,
oneControlHasOneEvidenceItem: valid.controls.length === 1 && valid.evidence.length === 1 && valid.controls[0].evidence === valid.evidence[0],
}),
samples: Object.freeze({ valid, missingAsset, missingBoundary, unknownControl, restored, rejectedRollback }),
});
}
const example = `const plan = planTeachingThreatModel({
actor: 'browser-client',
asset: 'change-request',
boundary: 'public-api-to-handler',
abuse: 'send request without a valid signature',
control: 'signed-request',
});
console.log(plan.accepted); // true
console.log(plan.controls[0].id); // signed-request
console.log(plan.evidence[0]);
// проверка отказа неподписанного учебного запроса
const rollback = rollbackTeachingThreatModel(plan);
console.log(rollback.snapshot.boundary); // public-api-to-handler`;
const practice = revision({
slug: 'editorial-2023-01-practice-threat-model',
title: 'Модель угроз перед чек-листом: назвать поток, актив и границу',
categories: ['Безопасность', 'Разработка'],
cover: '/assets/editorial/2023/threat-model-2023-flow.svg',
excerpt: 'Малый учебный DFD-поток: как связать актив, злоупотребление, контроль и evidence до выбора готового списка мер.',
readingMinutes: 11,
}, [
p('Чек-лист безопасности часто начинается с контроля: включить подпись, шифрование, rate limit или сканер. Проблема появляется позже: никто не может сказать, что именно защищает выбранный пункт и на какой границе он действует. Цена не в том, что список стал короче. Команда получает изменение, которое нельзя проверить по смыслу: отказ в доступе есть, а актив, от которого отказ должен защищать, так и не назван.'),
p('Для начала не нужен большой аудит. Возьмём один учебный поток: browser-client отправляет change-request через границу public-api-to-handler, обработчик кладёт запрос в store с тем же именем. Рядом фиксируем предполагаемое злоупотребление: отправить запрос без корректной подписи. Это не карта реальной системы, не security review и не аттестация. Цель скромнее: не позволить обсуждению перескочить от тревожного слова «атака» сразу к чужому контролю.'),
h2('Четыре имени до выбора контроля'),
p('Актив — не «данные вообще», а конкретный объект, потеря или искажение которого имеет значение для этого потока. В примере это change-request. Граница доверия — не название сервиса и не цвет на диаграмме; это место, где мы меняем предположение о входе. Здесь строка public-api-to-handler означает, что handler не принимает данные клиента как уже проверенные. Злоупотребление — действие, которое нарушает нужное свойство: прислать запрос без valid signature.'),
p('После этих имён появляется место для контроля. В учебной модели signed-request означает только план: принять запрос после проверяемой подписи. Он не раскрывает алгоритм, заголовок, ключ, clock skew или формат ошибки. Такие детали нельзя честно вывести из одной DFD-строки. Зато можно заранее назвать evidence: локальная проверка, что неподписанный учебный запрос отклонён. Контроль без такого evidence остаётся намерением, а evidence без актива не показывает, что именно доказано.'),
table('Минимальная запись одного потока', ['Поле', 'Учебное значение', 'Зачем оно нужно', 'Чего оно не доказывает'], [
['Actor', 'browser-client', 'указывает источник входа', 'личность пользователя или тип устройства'],
['Asset', 'change-request', 'даёт объекту защиты имя', 'классификацию данных и законность обработки'],
['Trust boundary', 'public-api-to-handler', 'ставит проверку на смене предположения', 'топологию настоящей сети'],
['Abuse', 'request without valid signature', 'делает риск проверяемым условием', 'полный перечень атак'],
['Control', 'signed-request', 'связывает действие с условием', 'качество реализации подписи'],
['Evidence', 'rejection in local fixture', 'фиксирует ожидаемую отрицательную ветку', 'результат pentest или production trace'],
]),
h2('Проверяемый пример: контракт, а не сканер'),
p('Пример ниже работает в Node с простыми строками. Он не отправляет HTTP-запрос, не создаёт ключ, не вызывает криптографическую библиотеку и не знает настоящую конфигурацию. Поэтому его результат повторяем, но узок. PASS fixture говорит, что локальная функция требует actor, asset, boundary и abuse, связывает один control с одним evidence и умеет вернуть исходный snapshot. Он не говорит «endpoint безопасен».'),
code(example),
p('Отрицательная ветка здесь важнее красивого happy path. Если asset пустой, модель возвращает missing-asset; если не названа boundary — missing-boundary. Незнакомый control encrypt-everything даёт unknown-control, а не догадку о смысле. Это защищает ревью от трёх привычных ошибок: назвать контроль вместо цели, пропустить место решения и закрыть неясность модным словом. Добавление нового контроля должно потребовать явного правила и нового evidence, а не неявной строки.'),
figure('/assets/editorial/2023/threat-model-2023-flow.svg', 'Учебный поток модели угроз: browser-client пересекает границу public-api-to-handler, handler пишет change-request в store; рядом показано злоупотребление неподписанным запросом, контроль signed-request и evidence отказа.', 'Схема фиксирует проектный DFD-контракт одного потока. Она не отражает реальную сеть, пользователей, ключи, журналирование или результаты security review.'),
h2('Почему контроль не открывает разговор'),
p('NIST SP 800-154 в статусе initial public draft описывает threat modeling как форму risk assessment и отдельно рассматривает data-centric подход. Его полезный для практики вывод не «берите один перечень мер», а сначала моделируйте то, что важно защищать, и стороны атаки и защиты. Документ не завершает работу за команду: он не знает ваш актив, роль, протокол и допустимое доказательство. Поэтому ссылка на него не превращает таблицу выше в сертификационный артефакт.'),
p('NIST SP 800-218 v1.1 говорит о практиках безопасной разработки, которые можно встроить в жизненный цикл. Это хорошее место для следующего шага после модели: договориться, где хранится запись угрозы, кто проверяет evidence и как изменение возвращается назад. Но framework не назначает signed-request правильным ответом для любого потока. Сначала граница, затем риск, затем решение, затем доказательство и владелец проверки.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Найдите один чек-листовый пункт, у которого нет ответа на вопрос «какой актив он защищает». Не расширяйте задачу до всего продукта.',
'<strong>Причина.</strong> Нарисуйте один поток с source, process, store и trust boundary. Если границу нельзя назвать словами, пока нельзя выбрать осмысленную проверку.',
'<strong>Проверка.</strong> Запустите <code>node web/scripts/upgrade-2023-01.mjs --verify-fixture</code>. PASS подтверждает только assertions учебной модели: обязательные поля, связь control/evidence и rollback snapshot.',
'<strong>Действие.</strong> Для одного злоупотребления запишите один control и evidence. Evidence должен быть наблюдаемым: отказ, лог решения, unit test или согласованная ручная процедура — не обещание «всё закрыто».',
'<strong>Ревью.</strong> Попросите другого инженера прочитать запись, закрыв название технологии. Если он не понимает актив и условие отказа, технология была выбрана слишком рано.',
'<strong>Откат.</strong> При спорном новом контроле верните snapshot договора и старую ветку обработки; не используйте rollback как утверждение, что уже развернутая система вернулась в безопасное состояние.',
]),
h2('Граница модели и следующий шаг'),
p('Эта малая DFD не ранжирует угрозы, не считает вероятность, не строит attack tree, не перечисляет trust zones продукта и не проверяет реализацию подписи. В ней нет реальных запросов, дат, пользователей, журналов или эффекта. Если в проекте есть требования к классификации, ключам, персональным данным или независимой оценке, они потребуют других артефактов и владельцев. Подменять их одной функцией было бы опаснее, чем не иметь функции вовсе.'),
p('Следующий шаг — взять один настоящий change и заполнить те же шесть полей на листе ревью. После этого выбрать только тот unit или integration test, который способен дать названный evidence. Так threat model остаётся рабочей записью о решении, а не приложением к чек-листу. Для каждой новой границы повторите путь заново: имя актива, злоупотребление, контроль, evidence, условие отката.'),
h2('Историческая граница января 2023'),
p('Использованы официальные документы, существовавшие к январю 2023: NIST SP 800-154 IPD от 14 марта 2016 года, NIST SP 800-218 v1.1 Final от 3 февраля 2022 года и NIST SP 800-53 Rev. 5 от 2020 года. Первый назван draft, а все три источника задают методические рамки, не вывод о безопасности конкретного приложения.'),
]);
const mechanism = revision({
slug: 'editorial-2023-01-mechanism-threat-model',
title: 'Контроль и evidence в модели угроз: одна связь, которую можно проверить',
categories: ['Безопасность', 'Архитектура'],
cover: '/assets/editorial/2023/threat-model-2023-contract.svg',
excerpt: 'Как отделить threat model от реализации: один актив, одно злоупотребление, контроль, evidence и обратимый учебный контракт.',
readingMinutes: 13,
}, [
p('В обсуждении безопасности легко получить два несвязанных списка: слева угрозы, справа controls. Между ними нет правила, поэтому review принимает любое знакомое слово. Проблема проявляется при изменении: control остаётся в коде, но никто не знает, какая отрицательная ветка должна доказать его работу. Цена — ложное закрытие риска и трудный откат: нельзя понять, какую гарантию потеряем, если удалить изменение.'),
p('Разберём связь на одной учебной модели. Актив change-request приходит от browser-client и пересекает public-api-to-handler. Злоупотребление — передать запрос без valid signature. Control signed-request обещает ровно одно: handler не принимает вход без проверяемой подписи. Evidence — результат локальной проверки отказа для специально заданного неподписанного значения. Модель не реализует подпись и не говорит, что у продукта есть такой endpoint. Она показывает, как записать связь до реализации.'),
h2('DFD здесь нужен как граница предположений'),
p('Data flow diagram часто пытаются превратить в архитектурную картину на все случаи. Для малого review достаточно четырёх сущностей: source, flow, process и store. На стрелке между source и process лежит trust boundary: после неё нельзя считать вход уже доверенным. Store не обязан быть базой данных; в учебной записи это только место, где оказался asset. Такое упрощение полезно, пока оно честно: мы не показываем протокол, изоляцию сети, репликацию или журнал.'),
p('У угрозы тоже должен быть глагол. «Подделка» слишком широка, пока не сказано, что делает актор и какое свойство актива нарушает. Формулировка «send request without a valid signature» даёт отрицательный пример. Проверка может установить входное условие без подписи и ожидать отказ по договору handler. Она не доказывает отсутствие всех способов подделки, не проводит криптоанализ и не заменяет тест границ инфраструктуры.'),
table('Контракт связи threat → control → evidence', ['Слой', 'Запись в учебной модели', 'Вопрос ревьюера', 'Недопустимый вывод'], [
['Asset', 'change-request', 'что потеряет смысл при нарушении?', 'что защищены все данные продукта'],
['Threat', 'unsigned request', 'какое действие пытаемся остановить?', 'что перечислены все злоупотребления'],
['Control', 'signed-request', 'какую точку решения меняем?', 'что выбранный механизм достаточен'],
['Evidence', 'local rejection check', 'какой наблюдаемый отказ ждём?', 'что выполнен security test среды'],
['Rollback', 'restore snapshot', 'что вернётся в учебной записи?', 'что production rollout безопасно отменён'],
]),
h2('Почему один control должен иметь названный evidence'),
p('Контроль иногда действительно требует нескольких проверок, но начинать полезно с минимальной пары. Если для signed-request нельзя назвать хотя бы одну отрицательную ветку, команда ещё не понимает его контракт. Если evidence — только «код прочитан», оно может быть разумным для дизайна, но не должно называться доказательством поведения. Хорошая запись разделяет эти виды: design review проверяет выбор границы, unit test проверяет локальный reject, а внешний security assessment имеет другой объект и критерий.'),
p('NIST SP 800-53 Rev. 5 — каталог controls, а не генератор тестов. Его роль в этой статье ограничена словарём: security control имеет функцию и нуждается в уверенности, что функция работает. Из этого не следует, что любой control стоит тащить в маленький сервис или что имя control заменяет спецификацию. Для выбранного потока достаточно сделать связь видимой, затем спросить владельца домена, нужна ли она вообще.'),
h2('Исполнимый контракт и его отрицательные ветки'),
p('Функция <code>planTeachingThreatModel</code> принимает только заранее названные строки. Пустой asset, отсутствующая boundary и неизвестный control отклоняются. Это намеренная строгость учебной модели: ей запрещено достраивать пропущенный контекст. Объект результата содержит dfd, threat, control и evidence, а также snapshot для rollback. Никаких скрытых глобальных данных нет, поэтому fixture повторяем на любой машине с Node.'),
code(example),
p('Rollback важен не как кнопка «безопасно отменить». Он проверяет другое: при отмене проекта контроля мы возвращаем исходные actor, asset, boundary и abuse, не подставляя новую историю. В production отмена может задеть ключи, миграции, compatibility, очередь, документацию и пользователей. Наша функция ничего из этого не делает. Она не должна даже имитировать deployment, иначе у читателя возникнет ложное чувство, что unit fixture покрывает операционный риск.'),
figure('/assets/editorial/2023/threat-model-2023-contract.svg', 'Контракт учебной модели: asset change-request и threat unsigned request связываются с control signed-request и evidence local rejection check; отсутствующий asset и неизвестный control ведут в reject; rollback возвращает исходный snapshot.', 'Схема показывает отношения объектов модели и отрицательные ветки. Это не диаграмма криптографического протокола, не журнал реального приложения и не отчёт о тестировании.'),
h2('Откуда в этом месте появляется разработка'),
p('SSDF v1.1 полезен тем, что связывает design, implementation и verification в практиках разработки. Но в конкретном PR эти общие слова надо развернуть. Design: назвать boundary и abuse. Implementation: указать место, где handler принимает или отклоняет вход. Verification: выбрать test, который делает отрицательную ветку наблюдаемой. Change management: записать rollback и условие его запуска. Такой порядок не гарантирует отсутствие дефекта, но делает спор предметным.'),
p('Не нужно объявлять every validation security control. Иногда входной фильтр обслуживает только UX или форматирование. Связь с угрозой возникает, когда есть asset, недопустимое действие и ожидаемое защитное решение. И наоборот, не вся угроза обязана закрываться кодом process: часть требует изменения boundary, прав, операционной процедуры или отказа от функции. Модель нужна именно для различения этих случаев.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> В PR есть control, но нет формулировки, какой негативный вход или переход он остановит.',
'<strong>Причина.</strong> Разделите слова на asset, abuse, control и evidence. Если одно поле невозможно заполнить, не компенсируйте это ещё одним middleware.',
'<strong>Проверка модели.</strong> Выполните fixture. Отдельно посмотрите, что assertions о missing asset, missing boundary и unknown control остаются true: они защищают договор от молчаливого угадывания.',
'<strong>Действие в коде.</strong> Выберите точку принятия решения, добавьте отрицательный test именно для неё и назовите evidence в описании изменения.',
'<strong>Проверка границы.</strong> Разберите, кто формирует вход до boundary и кто владеет отказом после неё. Не объявляйте local test проверкой proxy, identity provider или key management.',
'<strong>Откат.</strong> До merge запишите, какой контракт и test уходят вместе с control. Если откат требует другой миграции или ключа, остановитесь и сделайте отдельный план выпуска.',
]),
h2('Граница утверждений'),
p('Учебный контракт не применяет подпись, не обрабатывает временные окна, не хранит секреты, не проверяет права и не показывает настоящий data store. Он также не ранжирует риск и не отвечает, нужен ли signed-request в конкретном дизайне. Его единственный измеримый результат — тринадцать assertions о созданных в памяти объектах. Называть это threat modeling процесса можно только с уточнением «учебная малая модель», а не «мы провели security review». '),
p('Следующий шаг — заменить строки одним реальным контрактом команды: сделать asset и boundary именами из вашей документации, а evidence — ссылкой на разрешённый test или review. Не переносите в статью или ticket секреты, реальные request samples и имена пользователей. Достаточно того, чтобы независимый reviewer мог понять, какой отказ ожидается и какую часть изменения нужно откатить при несоответствии.'),
h2('Историческая граница января 2023'),
p('NIST SP 800-154 IPD датирован 14 марта 2016 года и остаётся именно draft; NIST SP 800-218 v1.1 стал Final 3 февраля 2022 года; NIST SP 800-53 Rev. 5 издан в 2020 году. Все источники были доступны к январю 2023. Они не являются доказательством корректности данной функции или статуса безопасности внешней системы.'),
]);
const field = revision({
slug: 'editorial-2023-01-field-threat-model',
title: 'Полевой маршрут модели угроз: как не принять контроль без объекта защиты',
categories: ['Безопасность', 'Практика'],
cover: '/assets/editorial/2023/threat-model-2023-diagnosis-rollback.svg',
excerpt: 'Короткий маршрут ревью одного потока: симптом, граница, злоупотребление, evidence и честный rollback без выдуманного security review.',
readingMinutes: 12,
}, [
p('Изменение безопасности часто приходит как готовая фраза: «добавим подпись», «поставим ограничение» или «закроем endpoint». Проблема в том, что после первого вопроса ревью — какой актив и какой abuse — разговор останавливается. Цена — не только лишняя работа. Непроверяемый control может заблокировать полезный поток, а команда не сумеет объяснить, какие данные нужны для решения и что произойдёт при откате.'),
p('Ниже — маршрут для одного change, а не процедура оценки организации. Он берёт маленький учебный DFD: browser-client → public-api-to-handler → handler → change-request store. В нём одна граница, один asset и одно злоупотребление. Локальная функция помогает проверить полноту записи, но не общается с браузером, API, ключами или production. Поэтому она не выдаётся за pentest, compliance evidence или результат настоящей модели угроз.'),
h2('Соберите симптом как наблюдаемый пробел'),
p('Начните не с названия технологии, а с пробела в решении. Например: handler сейчас получает request, но contract не говорит, по какому признаку он должен отвергнуть неподтверждённый вход. Это ещё не утверждение, что есть уязвимость. Чтобы сделать утверждение, понадобятся scope, конфигурация, реализация, данные о доступе и независимая проверка. На первом шаге достаточно честно назвать неизвестное: у решения нет связи между активом, злоупотреблением и evidence.'),
p('Затем сузьте поток. Browser-client — источник, change-request — asset, public-api-to-handler — boundary, unsigned request — abuse. У каждого слова есть функция в следующем вопросе. Asset отвечает «что важно». Boundary отвечает «где меняются предположения». Abuse отвечает «какое действие не должно пройти». Если одно слово заменить словом «система» или «безопасность», маршрут сразу теряет место для проверки.'),
table('Диагностика записи до реализации', ['Наблюдение в ревью', 'Вероятная причина', 'Проверяемый вопрос', 'Первое действие'], [
['Есть control, нет asset', 'выбрали привычную меру', 'какой объект теряет свойство?', 'назвать asset одной строкой'],
['Есть threat, нет boundary', 'не указано место решения', 'где вход перестаёт быть доверенным?', 'нарисовать одну стрелку DFD'],
['Есть control, нет evidence', 'нет отрицательной ветки', 'какой вход должен быть отвергнут?', 'добавить локальную проверку договора'],
['В evidence написано «проверено»', 'не назван метод', 'что именно увидит reviewer?', 'указать test, log или ручной шаг'],
['Rollback означает «вернуть всё»', 'смешаны модель и выпуск', 'какие артефакты меняются?', 'разделить snapshot и deployment plan'],
]),
h2('Разложите один поток, не рисуя весь продукт'),
p('На схеме достаточно source, process и store. Стрелка request проходит boundary. Возле неё положите abuse; возле handler — control; рядом с control — evidence. Не добавляйте в рисунок несуществующие базы, firewall, SIEM или сотрудников для солидности. Каждый объект должен поддерживать вопрос, который вы зададите в ревью. Если рисуете ключ, должны быть готовы объяснить его жизненный цикл; если пока не готовы, ключ не нужен в малой модели.'),
p('Контроль signed-request выбран здесь как условный пример, не совет для любого API. Он не равен «шифровать трафик», «проверять пользователя» или «включить JWT». Его узкое обещание записано в модели: без проверяемой подписи handler не принимает request. В реальном решении это может быть не тот механизм, или обязательство может жить на другой границе. Правильность выбора определяет доменная и техническая проверка, которую статья не подменяет.'),
figure('/assets/editorial/2023/threat-model-2023-diagnosis-rollback.svg', 'Маршрут диагностики учебной модели угроз: контроль без asset возвращает к названию объекта; threat без boundary — к DFD-стрелке; control без evidence — к отрицательной проверке; accepted plan сохраняет snapshot, rollback возвращает исходные поля.', 'Рисунок показывает порядок вопросов для ревью и границу rollback учебной записи. Он не является диаграммой инцидента, отчётом сканера или процедурой развёртывания.'),
h2('Прогоните локальный fixture, затем отделите его от продукта'),
p('Команда может запустить fixture до ревью, чтобы исключить механические пропуски: пустой asset, отсутствующую boundary или неизвестный control. Пример не требует зависимостей и возвращает одинаковые objects. Но после PASS нужно сделать второй шаг: показать место в коде или документе, к которому относится выбранная запись. Без этого fixture проверяет только аккуратность учебной формы, а не поведение приложения.'),
code(`node web/scripts/upgrade-2023-01.mjs --verify-fixture
# PASS означает: локальная модель приняла полный набор полей,
# связала signed-request с одним evidence и умеет вернуть snapshot.
# PASS не означает: endpoint проверен, подпись реализована или риск закрыт.`),
p('Полезно разнести evidence по уровням. Для модели — assertion функции. Для реализации — unit test обработчика или review конкретного guard. Для интеграции — согласованный test среды с явными входными данными. Для эксплуатации — отдельная процедура с владельцем и допустимыми логами. Нельзя складывать эти уровни в одну строку «проверено». Такой ярлык скрывает, что именно отсутствует, и затрудняет решение, можно ли выпускать change.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Выпишите control из текущего change и отметьте, какое поле из asset, boundary, abuse, evidence отсутствует.',
'<strong>Причина.</strong> Найдите точку, где система должна принять защитное решение. Не называйте сетевую зону boundary, если не можете объяснить, какое предположение меняется.',
'<strong>Проверка договора.</strong> Выполните fixture и прочитайте сами assertions. Они проверяют модель, не ваше приложение; это должно остаться в описании change.',
'<strong>Действие.</strong> Добавьте один конкретный evidence для одной отрицательной ветки. Сформулируйте его так, чтобы другой человек мог сказать PASS или FAIL без догадки.',
'<strong>Проверка реализации.</strong> Отдельно договоритесь об окружении, данных, владельце и артефакте реальной проверки. Если этого нет, пишите «не проверено», а не «защищено».',
'<strong>Откат.</strong> Для модели восстановите исходную запись. Для production подготовьте отдельный план: совместимость, ключи, миграции и наблюдение не существуют внутри данного fixture.',
]),
h2('Типичные неверные движения'),
p('Первое неверное движение — перечислить STRIDE или controls вместо конкретного злоупотребления. Так терминология прячет отсутствие условия. Второе — назвать модель угроз после одной схемы и посчитать задачу закрытой. NIST SP 800-154 сам называет threat modeling формой risk assessment; маленькая схема может быть входом в неё, но не заменой scope и оценки. Третье — считать reject в unit test доказательством сетевой защиты. У уровня проверки есть граница, и она должна быть написана рядом с результатом.'),
p('Четвёртое движение — вводить rollback после того, как change уже необратимо связан с другими компонентами. В учебной модели rollback безопасен потому, что это возврат четырёх строк из памяти. В настоящей доставке надо заранее понять, какие контракты, данные и права уже зависят от нового контроля. Если неизвестно, лучше остановить изменение, чем объявить «можно быстро выключить» без доказуемой процедуры.'),
h2('Граница и следующий шаг'),
p('Данная партия не содержит реального DFD продукта, списка активов, оценок риска, scans, traces, уязвимостей, пользователей или результатов. Она не заявляет соответствие NIST, не выбирает криптографию и не гарантирует защиту от неподписанного входа. Источники нужны для проверки терминов и порядка работы, а не как заёмный авторитет для модели из нескольких строк.'),
p('Следующий шаг — на ближайшем security-sensitive change завести маленькую запись из таблицы, назначить владельца evidence и вынести операционный rollback в отдельный release plan. Если одна строка не заполняется, это полезный результат: change ещё недостаточно понятен для выбора контроля. Так автор развивает системную практику без притворства, что один пример заменяет зрелую программу безопасности.'),
h2('Историческая граница января 2023'),
p('К январю 2023 были доступны NIST SP 800-154 IPD (14.03.2016), NIST SP 800-218 v1.1 Final (03.02.2022) и NIST SP 800-53 Rev. 5 (2020). Их статусы и даты указаны рядом со ссылками. Они не фиксируют состояние локальной платформы и не дают статье права заявлять проверенный security effect.'),
]);
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
function verifyFixture() {
const report = runThreatModelFixture();
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
if (failed.length) { process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n'); process.exitCode = 1; return; }
process.stdout.write('PASS fixture: ' + Object.keys(report.assertions).length + '/' + Object.keys(report.assertions).length + ' assertions\n');
}
if (process.argv.includes('--verify-fixture')) verifyFixture();
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');