Files
progcode/web/scripts/upgrade-2026-04.mjs
T
huncode 95e8350657
Build and deploy / deploy (push) Successful in 16s
revise April 2026 modern web security articles
2026-07-31 19:29:26 +03:00

412 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.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, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.values(value).forEach(deepFreeze);
Object.freeze(value);
}
return value;
}
function cloneFixed(value) {
return JSON.parse(JSON.stringify(value));
}
const REFERENCES = deepFreeze({
csp3: {
title: 'W3C Content Security Policy Level 3',
url: 'https://www.w3.org/TR/2026/WD-CSP3-20260421/',
version: 'W3C Working Draft, 21 April 2026, dated immutable snapshot',
},
asvs5: {
title: 'OWASP Application Security Verification Standard 5.0.0',
url: 'https://github.com/OWASP/ASVS/blob/5cf9b032440be53ce345ab3c130fda46ba1ce7a2/5.0/en/0x12-V3-Web-Frontend-Security.md',
version: 'release tag v5.0.0_release, commit 5cf9b032440be53ce345ab3c130fda46ba1ce7a2, 30 May 2025, immutable commit pin',
},
nist8397: {
title: 'NISTIR 8397: Guidelines on Minimum Standards for Developer Verification of Software',
url: 'https://doi.org/10.6028/NIST.IR.8397',
version: 'NIST IR 8397, 6 October 2021, immutable DOI publication',
},
});
function sourceList(entries) {
return '<ul>' + entries.map(({ key, use, boundary }) => {
const reference = REFERENCES[key];
return '<li><a href="' + reference.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(reference.title) + '</a> — версия: ' + escapeHtml(reference.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>';
}).join('') + '</ul>';
}
const FIXED_SYNTHETIC_SECURITY_REVIEWS = deepFreeze({
'mapped-injection-path-v1': {
id: 'mapped-injection-path-v1',
threatModel: {
id: 'fixed-html-injection-path-v1',
asset: 'fixed-browser-rendering-context',
attackPath: ['untrusted-fragment', 'unsafe-render-sink', 'script-capable-document'],
precondition: 'fixed-fragment-reaches-named-sink',
},
control: {
id: 'fixed-csp-nonce-boundary-v1',
kind: 'browser-policy',
intendedInterruption: 'reject-script-without-fixed-nonce',
configuredDirectives: ["object-src 'none'", "base-uri 'none'", "script-src 'nonce-fixed-review' 'strict-dynamic'"],
},
evidence: {
id: 'fixed-policy-and-negative-case-v1',
allowedObservation: ['named-directives-present', 'synthetic-negative-script-is-not-authorized'],
bindsThreatId: 'fixed-html-injection-path-v1',
bindsControlId: 'fixed-csp-nonce-boundary-v1',
limits: 'In-memory teaching object. It does not establish browser behaviour, headers, deployment state, input handling, encoding, authentication, telemetry or incident outcome.',
},
residualRisk: {
id: 'fixed-residual-input-handling-v1',
remainsOpen: ['source-of-untrusted-fragment', 'correctness-of-output-encoding', 'named-sink-not-covered-by-this-evidence'],
ownerQuestion: 'Which separate review traces validation and encoding before this browser-policy boundary?',
},
reviewResult: {
id: 'fixed-security-hand-off-v1',
allowedNextStep: 'hand-off-fixed-synthetic-security-review',
},
boundary: 'Named fixed synthetic literals in memory only; no network, filesystem, clock, telemetry, real browser, API, secret, production system, deployment or security claim.',
},
'control-without-attack-path-v1': {
id: 'control-without-attack-path-v1',
threatModel: { id: '', asset: 'fixed-browser-rendering-context', attackPath: [], precondition: '' },
control: { id: 'fixed-csp-nonce-boundary-v1', kind: 'browser-policy', intendedInterruption: 'reject-script-without-fixed-nonce', configuredDirectives: ["object-src 'none'"] },
evidence: { id: 'fixed-policy-and-negative-case-v1', allowedObservation: ['named-directives-present'], bindsThreatId: '', bindsControlId: 'fixed-csp-nonce-boundary-v1', limits: 'synthetic' },
residualRisk: { id: 'fixed-residual-input-handling-v1', remainsOpen: ['unknown'], ownerQuestion: 'unknown' },
reviewResult: { id: 'fixed-security-hand-off-v1', allowedNextStep: 'hand-off-fixed-synthetic-security-review' },
boundary: 'fixed synthetic literal',
},
'evidence-without-binding-v1': {
id: 'evidence-without-binding-v1',
threatModel: { id: 'fixed-html-injection-path-v1', asset: 'fixed-browser-rendering-context', attackPath: ['untrusted-fragment', 'unsafe-render-sink', 'script-capable-document'], precondition: 'fixed-fragment-reaches-named-sink' },
control: { id: 'fixed-csp-nonce-boundary-v1', kind: 'browser-policy', intendedInterruption: 'reject-script-without-fixed-nonce', configuredDirectives: ["object-src 'none'", "base-uri 'none'"] },
evidence: { id: 'fixed-unbound-observation-v1', allowedObservation: ['named-directives-present'], bindsThreatId: 'other-path', bindsControlId: 'other-control', limits: 'synthetic' },
residualRisk: { id: 'fixed-residual-input-handling-v1', remainsOpen: ['source-of-untrusted-fragment'], ownerQuestion: 'Which review owns input handling?' },
reviewResult: { id: 'fixed-security-hand-off-v1', allowedNextStep: 'hand-off-fixed-synthetic-security-review' },
boundary: 'fixed synthetic literal',
},
'residual-risk-omitted-v1': {
id: 'residual-risk-omitted-v1',
threatModel: { id: 'fixed-html-injection-path-v1', asset: 'fixed-browser-rendering-context', attackPath: ['untrusted-fragment', 'unsafe-render-sink', 'script-capable-document'], precondition: 'fixed-fragment-reaches-named-sink' },
control: { id: 'fixed-csp-nonce-boundary-v1', kind: 'browser-policy', intendedInterruption: 'reject-script-without-fixed-nonce', configuredDirectives: ["object-src 'none'", "base-uri 'none'"] },
evidence: { id: 'fixed-policy-and-negative-case-v1', allowedObservation: ['named-directives-present'], bindsThreatId: 'fixed-html-injection-path-v1', bindsControlId: 'fixed-csp-nonce-boundary-v1', limits: 'synthetic' },
residualRisk: { id: 'fixed-residual-input-handling-v1', remainsOpen: [], ownerQuestion: '' },
reviewResult: { id: 'fixed-security-hand-off-v1', allowedNextStep: 'hand-off-fixed-synthetic-security-review' },
boundary: 'fixed synthetic literal',
},
'unsafe-positive-result-v1': {
id: 'unsafe-positive-result-v1',
threatModel: { id: 'fixed-html-injection-path-v1', asset: 'fixed-browser-rendering-context', attackPath: ['untrusted-fragment', 'unsafe-render-sink', 'script-capable-document'], precondition: 'fixed-fragment-reaches-named-sink' },
control: { id: 'fixed-csp-nonce-boundary-v1', kind: 'browser-policy', intendedInterruption: 'reject-script-without-fixed-nonce', configuredDirectives: ["object-src 'none'", "base-uri 'none'"] },
evidence: { id: 'fixed-policy-and-negative-case-v1', allowedObservation: ['named-directives-present'], bindsThreatId: 'fixed-html-injection-path-v1', bindsControlId: 'fixed-csp-nonce-boundary-v1', limits: 'synthetic' },
residualRisk: { id: 'fixed-residual-input-handling-v1', remainsOpen: ['source-of-untrusted-fragment'], ownerQuestion: 'Which review owns input handling?' },
reviewResult: { id: 'fixed-security-result-v1', allowedNextStep: 'release-fixed-security-change' },
boundary: 'fixed synthetic literal',
},
});
export function createFixedSyntheticSecurityReview(id) {
const review = FIXED_SYNTHETIC_SECURITY_REVIEWS[id];
return review ? deepFreeze(cloneFixed(review)) : undefined;
}
export function mapFixedAttackControlEvidence(review) {
if (!review?.threatModel?.id || review.threatModel.attackPath?.length < 3) {
return deepFreeze({ status: 'stop-unnamed-attack-path', reasons: deepFreeze(['attack-path-is-not-traceable']), nextAction: 'name-asset-precondition-and-ordered-path' });
}
if (!review?.control?.id || !review.control.intendedInterruption) {
return deepFreeze({ status: 'stop-unnamed-control', reasons: deepFreeze(['control-does-not-name-interruption']), nextAction: 'name-control-and-interrupted-path-step' });
}
if (review.evidence?.bindsThreatId !== review.threatModel.id || review.evidence?.bindsControlId !== review.control.id) {
return deepFreeze({ status: 'stop-unbound-evidence', reasons: deepFreeze(['evidence-does-not-bind-threat-and-control']), nextAction: 'bind-allowed-observation-to-named-objects' });
}
return deepFreeze({
status: 'fixed-attack-control-evidence-map',
attackPath: deepFreeze([...review.threatModel.attackPath]),
interruption: review.control.intendedInterruption,
allowedEvidence: deepFreeze([...review.evidence.allowedObservation]),
boundary: review.evidence.limits,
});
}
export function reviewFixedSecurityTraceability(review) {
const map = mapFixedAttackControlEvidence(review);
if (map.status !== 'fixed-attack-control-evidence-map') return map;
if (!review.residualRisk?.remainsOpen?.length || !review.residualRisk.ownerQuestion) {
return deepFreeze({ status: 'stop-unbounded-residual-risk', reasons: deepFreeze(['residual-risk-or-owner-question-missing']), nextAction: 'name-open-path-and-separate-owner-question' });
}
if (review.reviewResult?.allowedNextStep !== 'hand-off-fixed-synthetic-security-review') {
return deepFreeze({ status: 'stop-disallowed-positive-result', reasons: deepFreeze(['positive-result-must-be-synthetic-hand-off-only']), nextAction: 'replace-result-with-bounded-hand-off' });
}
return deepFreeze({
status: 'synthetic-security-review-hand-off',
map,
residualRisk: deepFreeze(cloneFixed(review.residualRisk)),
nextAction: review.reviewResult.allowedNextStep,
productionEffect: 'not-attempted',
});
}
export function createFixedEvidenceRetestCase(id) {
const review = createFixedSyntheticSecurityReview(id);
return review ? deepFreeze({ caseId: 'fixed-evidence-retest-' + id, review, comparisonBoundary: 'Only named synthetic literals are compared; no system is contacted or represented.' }) : undefined;
}
export function reviewFixedEvidenceRetestCase(caseInput) {
if (!caseInput?.review || !caseInput.comparisonBoundary) {
return deepFreeze({ status: 'stop-unknown-fixed-evidence-case', reasons: deepFreeze(['fixed-case-is-missing']), nextAction: 'select-a-named-fixed-case' });
}
const trace = reviewFixedSecurityTraceability(caseInput.review);
if (trace.status !== 'synthetic-security-review-hand-off') return trace;
return deepFreeze({
status: 'synthetic-security-review-hand-off',
caseId: caseInput.caseId,
retestScope: deepFreeze(['named-directives-present', 'synthetic-negative-script-is-not-authorized']),
residualRisk: trace.residualRisk,
nextAction: 'hand-off-fixed-synthetic-security-review',
productionEffect: 'not-attempted',
});
}
export function runFixedSecurityReviewFixture() {
const good = reviewFixedSecurityTraceability(createFixedSyntheticSecurityReview('mapped-injection-path-v1'));
const pathless = reviewFixedSecurityTraceability(createFixedSyntheticSecurityReview('control-without-attack-path-v1'));
const unbound = reviewFixedSecurityTraceability(createFixedSyntheticSecurityReview('evidence-without-binding-v1'));
const riskless = reviewFixedSecurityTraceability(createFixedSyntheticSecurityReview('residual-risk-omitted-v1'));
const unsafe = reviewFixedSecurityTraceability(createFixedSyntheticSecurityReview('unsafe-positive-result-v1'));
const retest = reviewFixedEvidenceRetestCase(createFixedEvidenceRetestCase('mapped-injection-path-v1'));
const missing = reviewFixedEvidenceRetestCase({});
return deepFreeze({ assertions: deepFreeze({
mappedPathHandsOffOnly: good.status === 'synthetic-security-review-hand-off' && good.productionEffect === 'not-attempted',
mappedPathNamesTwoEvidenceItems: good.map.allowedEvidence.length === 2,
pathlessStopsClosed: pathless.status === 'stop-unnamed-attack-path',
pathlessNamesReason: pathless.reasons.includes('attack-path-is-not-traceable'),
unboundStopsClosed: unbound.status === 'stop-unbound-evidence',
unboundNamesReason: unbound.reasons.includes('evidence-does-not-bind-threat-and-control'),
missingResidualRiskStops: riskless.status === 'stop-unbounded-residual-risk',
unsafeResultStops: unsafe.status === 'stop-disallowed-positive-result',
retestHandsOffOnly: retest.status === 'synthetic-security-review-hand-off' && retest.nextAction === 'hand-off-fixed-synthetic-security-review',
missingCaseStops: missing.status === 'stop-unknown-fixed-evidence-case',
literalsAreFrozen: Object.isFrozen(FIXED_SYNTHETIC_SECURITY_REVIEWS),
}) });
}
function revision(meta, parts, sources) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + sourceList(sources);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength);
return deepFreeze({ ...meta, contentHtml, proseLength });
}
const practice = revision({
slug: 'editorial-2026-04-practice-modern-web-security',
title: 'Контроль без маршрута атаки — это инвентарь, а не защита: практическая карта для веба',
categories: ['Безопасность', 'Инженерная практика'],
cover: '/assets/editorial/2026/modern-web-security-2026-attack-control-evidence-map.svg',
excerpt: 'Практика M9: превратить список web-security controls в проверяемую связь между путём атаки, точкой прерывания, разрешённым evidence и открытым остаточным риском.',
readingMinutes: 15,
}, [
p('Проблема списка «CSP включён, cookies настроены, проверки есть» в удобной иллюзии готовности. Он не отвечает, какой именно шаг атаки должен быть прерван и какое наблюдение это подтверждает. Цена — во время разбора команда находит настройки, но не может отличить работающую границу от декоративной строки в конфигурации; время уходит на спор о смысле control вместо локализации открытого пути.'),
p('Практический минимум проще: для одного риска записать asset, precondition и упорядоченный attack path; рядом назвать control, его точку interruption, допустимое evidence и остаток. Не доказывать всё сразу. Действие на текущий review — не выдавать «безопасно», а передать ровно один <code>synthetic-security-review hand-off</code>, когда все четыре связи видимы в фиксированном учебном объекте.'),
h2('Карта начинается с глагола атакующего'),
p('Начинать с control неудобно, зато начинать с действия атакующего ещё полезнее. В fixed example путь имеет три именованные ступени: <code>untrusted-fragment</code>, <code>unsafe-render-sink</code>, <code>script-capable-document</code>. Это не описание конкретного сайта и не отчёт о найденной уязвимости. Это маленькая модель вопроса: какой объект ценен, при каком условии фрагмент доходит до sink и какой следующий шаг должен стать невозможным.'),
p('Такое сужение защищает от бесконечной таксономии. Не нужно в одной карточке перечислять XSS, CSRF, clickjacking, supply chain и все заголовки. Один путь — одна проверяемая гипотеза. Если новый риск использует иной asset или иное условие, ему нужна новая карточка. Иначе одно evidence случайно начинают читать как ответ на несколько атак, а residual risk превращается в строку «остальное учтено».') ,
figure('/assets/editorial/2026/modern-web-security-2026-attack-control-evidence-map.svg', 'Карта из пяти связанных узлов: asset и упорядоченный путь атаки ведут к точке interruption в control; разрешённое evidence подтверждает только эту связь, а отдельная ветка перечисляет оставшийся риск.', 'Карта заставляет читать control как ограниченное прерывание конкретного маршрута, а evidence — как наблюдение с собственной границей.'),
table('Карточка одного пути атаки', ['Слой', 'Что назвать', 'Fixed пример', 'Что это не доказывает'], [
['threat model', 'asset, precondition, ordered steps', 'fragment → sink → document', 'что такой путь есть в реальном продукте'],
['control', 'место и способ interruption', 'reject script without fixed nonce', 'корректность всех источников HTML'],
['evidence', 'разрешённое наблюдение и binding', 'policy plus negative case', 'политику каждого браузера или стенда'],
['residual risk', 'оставшийся шаг и вопрос владельцу', 'encoding before boundary', 'что риск обнулён'],
['result', 'единственно допустимый выход', 'synthetic review hand-off', 'релиз, разрешение или deployment'],
]),
h2('Control должен назвать точку прерывания'),
p('Фраза «используем CSP» слабее, чем <code>reject-script-without-fixed-nonce</code>. Во второй фразе есть объект решения: script без named nonce не получает авторизацию в synthetic модели. W3C CSP3 прямо описывает CSP как defence-in-depth против content injection, а не как замену validation и output encoding. Поэтому здесь browser policy не закрывает происхождение фрагмента и не делает unsafe sink допустимым; она ограничивает один последующий шаг.'),
p('OWASP ASVS 5.0.0 полезен как язык проверки web frontend: он просит документировать ожидаемые browser security features, использовать CSP response header с ограничениями и проверять их присутствие. Но ASVS requirement — не свидетельство, что конкретная атака остановлена. Требование становится полезным в review только после привязки к пути и к наблюдению. Иначе мы получаем аккуратный checklist, который нельзя использовать для принятия следующего инженерного решения.'),
h2('Исполняемая карта, не имитация стенда'),
code("import { createFixedSyntheticSecurityReview, mapFixedAttackControlEvidence } from './upgrade-2026-04.mjs';\n\nconst item = createFixedSyntheticSecurityReview('mapped-injection-path-v1');\nconst map = mapFixedAttackControlEvidence(item);\nconsole.log({ path: map.attackPath, interruption: map.interruption, evidence: map.allowedEvidence });\n// { path: ['untrusted-fragment', 'unsafe-render-sink', 'script-capable-document'], interruption: 'reject-script-without-fixed-nonce', evidence: ['named-directives-present', 'synthetic-negative-script-is-not-authorized'] }"),
p('Пример вызывает public export и читает named fixed literal в памяти. Он не формирует header, не запускает browser, не запрашивает страницу и не сообщает результат наружу. Выход — карта связей, а не измерение. Именно это позволяет выполнить snippet буквально и не выдать упражнение за production evidence: буквальное исполнение проверяет логику модели, но не свойства среды.'),
h2('Evidence не бывает «вообще достаточно»'),
p('В карте разрешены только два наблюдения: named directives присутствуют и synthetic negative script не авторизован. Они связаны одновременно с <code>fixed-html-injection-path-v1</code> и <code>fixed-csp-nonce-boundary-v1</code>. Если evidence знает имя control, но ссылается на другой threat id, его нельзя переносить в эту карточку. Результат должен быть <code>stop-unbound-evidence</code>, а не попытка дописать объяснение после факта.'),
p('Это правило экономит время в ревью. Вместо длинного перечня артефактов reviewer спрашивает четыре коротких вещи: что наблюдали, к какому пути это относится, какой control проверяет наблюдение и что наблюдение не умеет утверждать. NISTIR 8397 рекомендует threat modeling, automated testing, static analysis и другие техники как минимальный набор подходов, но не говорит, что один тип проверки заменяет остальные. Наше evidence намеренно узкое и не присваивает себе чужую работу.'),
h2('Остаток надо писать раньше успешной ветки'),
p('Residual risk — не послесловие к зелёной ячейке. В fixed record остаются source untrusted fragment, correctness output encoding и named sink, который данный evidence не покрывает. Рядом есть owner question: какой отдельный review трассирует validation и encoding до browser-policy boundary? Эта запись сохраняет границу: CSP не должен превращаться в замену контроля данных только потому, что он удобен как заголовок.'),
p('У residual risk должен быть не абстрактный владелец «security», а следующий проверяемый вопрос. Если вопрос пустой или remainsOpen пуст, fixture останавливается на <code>stop-unbounded-residual-risk</code>. Это не утверждение, что не бывает контроля с нулевым риском. Это защита нашей модели от ложного вывода: в учебной карточке нельзя назвать путь закрытым, пока не видно, какие части маршрута специально оставлены за её пределами.'),
h2('Последовательность сборки карты'),
ol([
'<strong>Выбрать один asset.</strong> Не объединять пользовательские данные, сессию и документ в общий «веб».',
'<strong>Записать precondition.</strong> Указать, при каком именованном условии первый шаг пути становится возможен.',
'<strong>Разложить маршрут.</strong> Дать шагам порядок и глаголы; два пути не склеивать ради компактности.',
'<strong>Назвать interruption.</strong> Control обязан указывать, какой шаг он ограничивает и каким решением.',
'<strong>Ограничить evidence.</strong> Разрешить только наблюдения, привязанные к threat id и control id.',
'<strong>Оставить остаток.</strong> Выписать открытый участок и owner question до получения hand-off.',
]),
h2('Почему поле «control: enabled» не подходит'),
p('Статус enabled сообщает лишь, что кто-то считает control включённым. В нём нет scope: на каких представлениях, для какого маршрута и с каким исключением. В нём нет mechanism: что именно должно быть запрещено или разрешено. И в нём нет evidence boundary: наблюдалась строка, отрицательный пример или поведение. Даже если список controls полный, он может покрывать одинаковую часть path трижды и оставить критический переход без владельца.'),
p('М9-голос здесь намеренно сухой: не продавать confidence, а уменьшать неопределённость. Карта может показать, что одного control недостаточно; это полезный результат. Сильнее сказать «из этого evidence нельзя выводить корректность encoding», чем добавить ещё один зелёный бейдж. Тогда следующий инженер получает не настроение предыдущего review, а точную точку, в которой нужен другой метод проверки.'),
h2('Как не потерять карту при изменении control'),
p('Изменение policy не должно переписывать историю одним статусом «обновлено». Сначала нужно спросить, сохраняется ли interruption. Если новый control по-прежнему ограничивает script без named nonce, карта может получить новую версию control id и сравнение старого evidence с новым пределом. Если же меняется сам момент прерывания — например, защита переносится из browser policy в обработку входа, — это уже другой маршрут и чужое evidence нельзя переносить автоматически. Название инструмента здесь не спасает: решающим остаётся шаг, который он ограничивает.'),
p('Это же правило работает с исключениями. Добавленный trusted source, новый render sink или дополнительная форма контента не являются мелкой правкой, пока не ясно, какую ступень path они затрагивают. Карта может честно вернуть незаполненный residual block и попросить отдельный review. Так изменение не превращается в полемику о том, достаточно ли «разумной настройки». У неё появляется узкий технический вопрос: совпадает ли старое evidence с новой парой threat/control, и что теперь осталось вне связи.'),
h2('Границы и следующий шаг'),
p('Этот пакет не моделирует настоящий HTTP response, browser parser, CSP enforcement, заголовки, cookie, пользователя, сеть, repository или секрет. Он не утверждает, что CSP3 или ASVS автоматически защищают какую-либо систему. Источники используются только для узких формулировок: CSP — defence-in-depth; ASVS задаёт проверяемые web-frontend requirements; NIST перечисляет техники verification. Остальное — явно synthetic reasoning over fixed literals.'),
p('Следующий шаг не «включить control». Возьмите один уже существующий список мер и выберите из него одну строку. Для неё добавьте attack path, interruption и evidence boundary. Если связь не получается, верните <code>stop-unnamed-attack-path</code> или <code>stop-unbound-evidence</code>. Такой stop дешевле ложного закрытия: он показывает, какую запись надо сделать до того, как control начнёт жить отдельной легендой.'),
], [
{ key: 'csp3', use: 'CSP3 от 21.04.2026 называет CSP defense-in-depth, уменьшающей вред content injection, но не заменяющей validation и output encoding.', boundary: 'Working Draft не является утверждением о настройке или поведении какого-либо браузера, сайта либо среды.' },
{ key: 'asvs5', use: 'Pinned ASVS 5.0.0 формулирует проверяемые требования для web frontend, включая CSP response header и ожидаемые browser security features.', boundary: 'ASVS requirement не является результатом assessment и не доказывает interruption конкретного attack path.' },
{ key: 'nist8397', use: 'NISTIR 8397 перечисляет threat modeling и несколько техник developer verification как рекомендации минимального уровня.', boundary: 'Документ не задаёт эту synthetic data model и не заменяет evidence для отдельного control.' },
]);
const mechanism = revision({
slug: 'editorial-2026-04-mechanism-modern-web-security',
title: 'Почему перечень controls не доказывает защиту: трассируемость и остаточный риск в веб-ревью',
categories: ['Безопасность', 'Архитектура'],
cover: '/assets/editorial/2026/modern-web-security-2026-residual-risk-matrix.svg',
excerpt: 'Механика M9: контроль имеет смысл лишь в трассе threat model → interruption → bounded evidence → residual risk; список мер не хранит ни путь, ни допустимость вывода.',
readingMinutes: 16,
}, [
p('Проблема длинного security baseline в том, что он всё равно может не дать ответа на один неудобный вопрос: какая атака упирается в какую границу? Когда после инцидента control list читают как доказательство, цена — ложная причинность. Наличие policy, scanner или заголовка задним числом превращается в «значит, путь был закрыт», хотя никто не зафиксировал, какой шаг должен был быть остановлен.'),
p('Механика review должна хранить цепочку, а не набор названий: threat model формулирует путь, control объявляет interruption, evidence разрешает ограниченное наблюдение, residual risk держит незакрытую часть. При разрыве цепи результат — stop. При полной synthetic цепи — только hand-off для следующего review. Это сознательно меньше, чем verdict о защищённости, зато вывод имеет проверяемую область.'),
h2('Перечень теряет отношение между сущностями'),
p('Control list — это таблица существительных: CSP, encoding, review, scanner, token. У него обычно нет направленного ребра между причиной и следствием. Два controls могут претендовать на один и тот же шаг, но не закрывать соседний. Один evidence может быть собран для другой страницы или иной модели угроз. Без идентификаторов мы не замечаем подмену: «есть CSP» начинает выглядеть как «данный input не исполняется», хотя это разные утверждения.'),
p('Traceability возвращает отношения в явную форму. Threat id связывается с ordered path; control id — с intended interruption; evidence обязано связать оба id; residual record указывает то, что не выводится из наблюдения. Такая форма не делает security математически полной. Она делает неполноту видимой и локальной: reviewer видит конкретное отсутствующее ребро, а не получает расплывчатое ощущение недостаточной зрелости.'),
figure('/assets/editorial/2026/modern-web-security-2026-residual-risk-matrix.svg', 'Матрица из четырёх строк: путь атаки, control, evidence и residual risk; в правом столбце показано, какой вывод допустим, а какой запрещён при каждом заполнении.', 'Зелёный статус возможен лишь у полной трассы. Пустая связь или остаток без owner question возвращают конкретный stop.'),
table('Что хранит трассировка вместо списка мер', ['Вопрос', 'Нужный объект', 'Допустимый вывод', 'Запрещённый скачок'], [
['что атакуется?', 'asset и precondition', 'модель имеет границу', 'все угрозы перечислены'],
['как идёт путь?', 'ordered attackPath', 'шаги можно обсуждать', 'путь существует в production'],
['где прерывание?', 'control interruption', 'control относится к шагу', 'control исправляет первопричину'],
['что видели?', 'bound allowed evidence', 'наблюдение относится к паре ids', 'наблюдение доказывает весь security posture'],
['что осталось?', 'residual risk и owner question', 'следующий review назван', 'риск исчез или принят'],
]),
h2('Evidence — это тип разрешённого вывода'),
p('Полезно читать evidence не как файл, скриншот или лог, а как тип разрешённого вывода. В fixed record есть <code>named-directives-present</code> и <code>synthetic-negative-script-is-not-authorized</code>. Первое позволяет говорить о строках модели. Второе — о негативной ветке именно этой модели. Ни одно не позволяет сказать, что unsafe sink отсутствует, input valid, пользователь защищён или реальный browser получил конкретный header.'),
p('Граница evidence должна быть записана рядом с ним, а не в памяти автора. Иначе доказательство расширяется с каждым пересказом. W3C CSP3 особенно полезен именно как ограничитель риторики: его introduction говорит, что CSP снижает вред injection, но не заменяет careful input validation и output encoding. Значит, даже корректно сформулированный policy control не разрешает убрать из residual matrix строку про данные и sink без независимого основания.'),
h2('Исполняемая проверка разрыва трассы'),
code("import { createFixedSyntheticSecurityReview, reviewFixedSecurityTraceability } from './upgrade-2026-04.mjs';\n\nconst record = createFixedSyntheticSecurityReview('evidence-without-binding-v1');\nconst outcome = reviewFixedSecurityTraceability(record);\nconsole.log({ status: outcome.status, reasons: outcome.reasons, next: outcome.nextAction });\n// { status: 'stop-unbound-evidence', reasons: ['evidence-does-not-bind-threat-and-control'], next: 'bind-allowed-observation-to-named-objects' }"),
p('Snippet демонстрирует не «ошибку CSP», а отсутствующую связь в data model. Evidence содержит наблюдение, но ссылается на другие ids, поэтому engine не пытается сопоставить его по имени или похожему тексту. Public function возвращает fail-closed status. Вызов не читает файл, не смотрит время, не пользуется telemetry и не проводит retest; это важно, потому что функция проверяет дисциплину утверждения, а не безопасность окружения.'),
h2('Остаточный риск — обратная сторона mechanism'),
p('Control без residual risk выглядит законченным просто потому, что у карточки больше нет строк. Это структурная ошибка. Если control прерывает late step — например, авторизацию script — то ранние шаги могут остаться открыты: появление untrusted fragment, его преобразование, выбор render sink. Они не становятся безопасными от того, что downstream boundary ограничивает последствия. В хорошем review residual list даёт этим шагам отдельные имена.'),
p('Для каждого остатка нужен owner question, но не owner verdict. В fixed data вопрос звучит: какой отдельный review трассирует validation и encoding до этой browser-policy boundary? Он не назначает команду, не создаёт тикет и не гарантирует ответ. Такая скромность функциональна: model не может знать реальные владельцы, зато может не позволить результату пройти, если вопрос о следующей проверке отсутствует. Пустой residual record получает <code>stop-unbounded-residual-risk</code>.'),
h2('Почему controls иногда конфликтуют без явной ошибки'),
p('Конфликт часто не в том, что один control выключен. Он появляется, когда два controls дают разные обещания для одного участка, а evidence привязан только к одному. Например, policy может ограничивать execution, а sanitization claim — говорить о преобразовании input. Если в карточке остался только policy, reviewer не вправе выдать вывод о transformation. Если оба перечислены без путей, они могут дублировать друг друга и одновременно пропустить иной sink. Нужны два отдельных interruption и два ограниченных evidence либо честное объединение одного механизма.'),
p('ASVS помогает замечать классы таких проверок: его frontend chapter говорит о CSP, safe rendering functions, cookie attributes, CORS и других browser-facing требованиях. Но standard не подставляет relation за конкретную архитектуру. Техническая зрелость М9 состоит не в пересказе большего числа требований, а в умении сказать: «вот этот requirement используется здесь, вот какая связь проверяется, а вот какие conclusions запрещены». Это сильнее checklist, потому что его можно оспорить предметно.'),
h2('Порядок механической проверки'),
ol([
'<strong>Проверить identity.</strong> У threat, control и evidence должны быть отдельные стабильные ids внутри review.',
'<strong>Проверить маршрут.</strong> Attack path должен иметь минимум три ordered step, а не ярлык класса уязвимости.',
'<strong>Проверить interruption.</strong> Control обязан называть решение, не только инструмент или политику.',
'<strong>Проверить binding.</strong> Evidence одновременно указывает threat id и control id; совпадение слов не считается.',
'<strong>Проверить предел.</strong> Limits запрещают выводы о среде, которых named observation не даёт.',
'<strong>Проверить остаток.</strong> Открытые части и owner question обязательны перед positive hand-off.',
]),
h2('Положительная ветка тоже должна быть ограничена'),
p('Самая опасная строка в automation — та, что звучит как итоговое разрешение. В данном overlay даже полностью связанная запись не возвращает approval, release или deployment. Допустимое значение одно: <code>synthetic-security-review-hand-off</code>, а effect равен <code>not-attempted</code>. Это не косметика. Структура не даёт подсунуть безопасному-looking data set действие, для которого отсутствуют полномочия, среда и реальные наблюдения.'),
p('Проверка <code>unsafe-positive-result-v1</code> намеренно подставляет <code>release-fixed-security-change</code> после корректных ids. Она всё равно получает stop. Тем самым fixture охраняет не только негативные случаи, но и семантику зелёной ветки. Если модель разрешает сильный вывод ради удобства, дальнейшая трассировка перестаёт быть доказательной: любой полный объект становится приглашением выполнить необоснованную операцию.'),
h2('Две независимые оси неполноты'),
p('У traceability есть как минимум две разные неполноты. Первая — структурная: нет шага path, нет interruption, ids не связаны или не указан residual question. Её можно обнаружить внутри объекта и вернуть deterministic stop. Вторая — эпистемическая: запись полная, но на вопрос о реальном браузере, фактическом input или охвате остальных sinks у модели нет данных. Её нельзя исправить добавлением ещё одного boolean. Она должна остаться в limits и стать входом отдельного метода.'),
p('Смешивать эти оси вредно. Когда структурный дефект называют «недостатком уверенности», команда может продолжать с поломанной схемой. Когда границу знания пытаются закрыть структурой, synthetic fixture начинает изображать измерительный инструмент. Хорошее механическое правило сначала устраняет разрыв ids, затем сохраняет неизвестное как неизвестное. Только так зелёная ветка означает качество аргумента, а не степень уверенности в окружающем мире.'),
h2('Границы механизма и следующий шаг'),
p('Эта модель не оценивает вероятность, ущерб, exploitability, coverage, latency и соответствие требованиям. Она не заменяет threat modeling, code review, тестирование, сканирование или независимую оценку; NISTIR 8397 перечисляет эти техники именно как разные элементы developer verification. Мы заимствуем дисциплину сравнения, а не объявляем собственный fixture стандартом безопасности.'),
p('Следующий шаг — взять одну существующую «зелёную» строку в security spreadsheet и попытаться провести четыре ребра. Если evidence не может назвать оба ids, не улучшайте формулировку: остановите запись. Если residual risk нельзя назвать без реальных данных, оставьте его неизвестным и вынесите в отдельный review. Трассировка полезна тем, что предпочитает честный пробел убедительному, но неразрешённому выводу.'),
], [
{ key: 'csp3', use: 'CSP3 от 21.04.2026 описывает CSP как дополнительную защиту, не заменяющую validation и output encoding.', boundary: 'Draft не определяет локальную threat model, sufficient evidence или residual-risk owner для этого synthetic review.' },
{ key: 'asvs5', use: 'Pinned ASVS 5.0.0 содержит web-frontend verification requirements для CSP, safe rendering, cookie и cross-origin механизмов.', boundary: 'Наличие requirement не связывает конкретный control с конкретным attack path без отдельной трассировки.' },
{ key: 'nist8397', use: 'NISTIR 8397 рекомендует разнообразные техники developer verification, включая threat modeling, tests и static analysis.', boundary: 'NISTIR не задаёт verdict, при котором один result может быть выдан за approval или release.' },
]);
const field = revision({
slug: 'editorial-2026-04-field-modern-web-security',
title: 'Полевая безопасность веба: авторизованный цикл evidence и ретеста без ложного допуска',
categories: ['Безопасность', 'Практика'],
cover: '/assets/editorial/2026/modern-web-security-2026-evidence-review-loop.svg',
excerpt: 'Полевой цикл M9 для security-review: подготовить named synthetic evidence case, проверить границу ретеста, вернуть понятный stop или ограниченный hand-off без approval и production claims.',
readingMinutes: 15,
}, [
p('Проблема полевого security review часто ломается не на знаниях о controls, а на передаче результата. Один участник принёс policy, другой увидел тест, третий услышал «проверено» — и никто не помнит, на какой путь, среду и следующую операцию это разрешение распространялось. Цена высока: hand-off начинает звучать как допуск, а позднее выясняется, что evidence было для другой границы или retest вообще не был определён.'),
p('Нужен авторизованный, но узкий цикл: назвать fixed case, показать связанный threat-control-evidence record, ограничить retest scope и вернуть либо stop, либо <code>synthetic-security-review hand-off</code>. В этой статье «авторизованный» означает разрешённый форматом модели, а не право на production action. Единственный положительный output не выпускает изменение, не включает настройку и не подтверждает реальную систему.'),
h2('Field review хранит границу передачи'),
p('Review hand-off полезен только тогда, когда получатель видит его площадь. Для fixed case площадь равна двум observations: <code>named-directives-present</code> и <code>synthetic-negative-script-is-not-authorized</code>. Это не универсальный набор доказательств CSP. Это именованный retest scope в учебном объекте. Получатель может продолжить обсуждение этой пары, но не может честно сделать вывод о логировании, security headers, origin policy или опыте настоящего пользователя.'),
p('Именно поэтому case включает comparison boundary: сравниваются только named synthetic literals. Нет browser session, request, response, файлов, времени, метрик, стенда, secret или production service. Такая изоляция кажется слишком строгой, пока не вспомнить обычный дефект передачи: факт из одного окружения переезжает в другую ветку решения без отметки, что контекст исчез. Здесь context не исчезает, потому что он записан в каждом result.'),
figure('/assets/editorial/2026/modern-web-security-2026-evidence-review-loop.svg', 'Кольцевой цикл: выбрать named case, подтвердить traceability, ограничить retest двумя observations, записать residual question и передать synthetic hand-off; красные ответвления ведут к отдельным stop.', 'Узел hand-off не является финальной стрелкой. Он передаёт только область следующего review и никогда не превращается в release decision.'),
table('Полевой цикл evidence/retest', ['Этап', 'Вход', 'Выход', 'Стоп при дефекте'], [
['выбор case', 'named fixed id', 'явная область сравнения', 'unknown fixed case'],
['трассировка', 'threat + control + evidence', 'проверенная связка ids', 'unbound evidence'],
['retest scope', 'allowed observations', 'ровно два synthetic check', 'unnamed attack path'],
['residual boundary', 'remainsOpen + question', 'следующий вопрос видим', 'unbounded residual risk'],
['передача', 'bounded result', 'synthetic hand-off', 'disallowed positive result'],
]),
h2('Retest не должен расширять evidence'),
p('Слово retest опасно, когда его используют как синоним «проверить всё снова». В этом overlay retest не открывает приложение и не пытается повторить атаку. Он повторно применяет тот же validator к тому же fixed record и публикует scope двух разрешённых observations. Такой ретест может проверить, что chain не потеряла binding и positive output не стал сильнее. Он не способен узнать, что произошло где-либо вне памяти процесса.'),
p('Это различие особенно важно после изменения текста или ownership. В ходе передачи людям легко расширить смысл: «negative case был не авторизован» становится «у нас нет XSS». Модель не даёт такого скачка, потому что <code>retestScope</code> сериализован как список наблюдений, а <code>productionEffect</code> всегда <code>not-attempted</code>. Если кому-то нужен реальный retest, надо создавать другой процесс с другими полномочиями и evidence. Его нельзя спрятать внутри данного public export.'),
h2('Исполняемый hand-off с ограниченным ретестом'),
code("import { createFixedEvidenceRetestCase, reviewFixedEvidenceRetestCase } from './upgrade-2026-04.mjs';\n\nconst fixedCase = createFixedEvidenceRetestCase('mapped-injection-path-v1');\nconst handOff = reviewFixedEvidenceRetestCase(fixedCase);\nconsole.log({ status: handOff.status, scope: handOff.retestScope, effect: handOff.productionEffect });\n// { status: 'synthetic-security-review-hand-off', scope: ['named-directives-present', 'synthetic-negative-script-is-not-authorized'], effect: 'not-attempted' }"),
p('Этот вызов использует только exported factory и reviewer. Он не меняет literal, потому что returned object глубоко frozen; он не запрашивает data и не строит сообщение наружу. Положительное значение означает ровно то, что форма synthetic case прошла собственные ограничения. Для неизвестного case или отсутствующей boundary функция вернёт stop, а не будет подбирать похожую запись. Это fail-closed поведение нужно для передачи: неопределённость нельзя конвертировать в удобный зелёный результат.'),
h2('Как читать stop без попытки обойти его'),
p('Stop — это адрес недостающей работы. <code>stop-unknown-fixed-evidence-case</code> просит выбрать named case. <code>stop-unbound-evidence</code> просит связать observation с threat и control. <code>stop-unbounded-residual-risk</code> просит назвать открытый участок и следующий вопрос. Ни один status не просит «попробовать ещё раз» без изменения входа. Такой дизайн защищает review от циклического пересмотра одного и того же неполного пакета под новым названием.'),
p('Практическая дисциплина здесь проста: не редактировать result вручную и не заменять status устным объяснением. Если evidence пришло из другого case, оно остаётся чужим до явной привязки. Если residual question требует реального владельца, synthetic record не делает вид, что знает его. Если нужно действие в системе, hand-off не может быть прокси-разрешением. Это меньше похоже на быстрый процесс, но быстрее настоящего разбора, в котором люди спорят о незафиксированных предположениях.'),
h2('Authorized означает разрешено схемой, не организацией'),
p('Термин authorised здесь намеренно ограничен. Schema authorizes только один переход: complete fixed traceability превращается в bounded hand-off. Она не authorizes publication, release, deployment, настройки заголовков, изменения кода, отправку уведомлений, работу с секретами или запросы к API. Это важная языковая дисциплина для зрелого автора: не называть механизмом доступа то, что всего лишь проверяет структуру аргумента.'),
p('NISTIR 8397 говорит о developer verification techniques, включая threat modeling, automated testing, code review и scanners. Из этого следует полезная организационная мысль, но не полномочие: разные техники производят разные evidence. Полевой loop не склеивает их в один виртуальный сертификат. Он оставляет место следующему reviewer, который применит подходящий метод с реальным scope, если такой метод и право действительно существуют.'),
h2('Последовательность передачи'),
ol([
'<strong>Выбрать case по id.</strong> Не строить новую запись из пересказа и не угадывать аналог.',
'<strong>Зафиксировать boundary.</strong> Сразу перечислить, чего review не читает, не запускает и не утверждает.',
'<strong>Прогнать traceability.</strong> Проверить path, interruption, двойной binding evidence и residual question.',
'<strong>Ограничить retest.</strong> Передать только named observations, не расширяя их в общий security statement.',
'<strong>Сохранить stop.</strong> Отдельно записать причину и exact next action, если цепь неполна.',
'<strong>Передать hand-off.</strong> Положительный результат остаётся synthetic и не имеет production effect.',
]),
h2('Где заканчивается browser-policy review'),
p('CSP3 описывает Content-Security-Policy response header как предпочитаемый механизм доставки policy и подчёркивает его роль defence-in-depth. Это объясняет, почему в модели control расположен у execution boundary. Но document не говорит, что только CSP покрывает каждый вид injection или что Report-Only observation равен enforcement. Поэтому в field loop нет вывода «policy присутствует — review завершён». Есть только ограниченное наблюдение и residual вопрос о том, что происходит до этой границы.'),
p('ASVS 5.0.0 также просит document expected browser security features и поведение при их отсутствии. Для hand-off это напоминание о форме: результат должен явно показывать feature, evidence и limit, а не быть словом «проверено». Однако ASVS не присваивает ownership и не разрешает выбрать риски вместо команды. В synthetic package owner question остаётся вопросом; без него зелёная ветка закрыта. Это честнее, чем заполнить ячейку именем, которого data model не подтверждает.'),
h2('Передача между ролями без растворения контекста'),
p('У reviewer, разработчика и владельца следующего исследования разные задачи, но hand-off не обязан моделировать их как людей. Достаточно передать предмет: case id, два evidence observations, residual list и exact next action. Получатель может проверить эти поля, не угадывая, что предыдущий участник имел в виду под «готово». Если нужно добавить мнение или риск-приоритет, это новый объект поверх данной записи, а не замена её status свободным текстом.'),
p('Такой формат особенно полезен при асинхронной работе. Через неделю вспомнить название control легко, а его исходную boundary — нет. Named fixed case сохраняет минимальную семантику без притворства, что он сохраняет всю историю решений. Поэтому loop не требует журналов, метрик или timestamp: отсутствие этих данных не является дефектом в учебном контуре; дефектом была бы попытка выдать их неявно за evidence.'),
h2('Ограничения и следующий шаг'),
p('Полевой цикл не является эксплуатационным playbook, тестовым стендом или политикой исключений. Он не осуществляет authorised access к чему-либо и не делает claims о security posture. SVG, таблица и code показывают только fixed synthetic data. Даже успешно исполненный fixture говорит о согласованности validator, а не о том, что какой-то browser, endpoint или команда действует так же.'),
p('Следующий шаг — взять один реальный процесс передачи и сначала не автоматизировать его. Выписать на бумаге: какой attack path, какой control interruption, какое evidence разрешено передавать и какой residual question остаётся. Если формулировка просит release вместо hand-off, разделите процесс. Если case не имеет named boundary, верните stop. Когда структура выдержит этот ручной тест, можно обсуждать отдельное, уполномоченное исследование вне данного пакета.'),
], [
{ key: 'csp3', use: 'CSP3 от 21.04.2026 описывает header delivery policy и ограниченную роль CSP как defense-in-depth.', boundary: 'Snapshot не подтверждает header, enforcement или retest какого-либо приложения.' },
{ key: 'asvs5', use: 'Pinned ASVS 5.0.0 требует документировать ожидаемые browser security features и поведение при их отсутствии.', boundary: 'Стандарт не авторизует hand-off, release или действия в системе и не создаёт ownership residual risk.' },
{ key: 'nist8397', use: 'NISTIR 8397 различает наборы developer verification techniques, а не заменяет их единым result.', boundary: 'Документ не делает fixed synthetic fixture доказательством реального retest или security assessment.' },
]);
export const revisions = deepFreeze([practice, mechanism, field]);
if (process.argv.includes('--verify-fixture')) {
const result = runFixedSecurityReviewFixture();
const failed = Object.entries(result.assertions).filter(([, value]) => value !== true).map(([key]) => key);
if (failed.length) {
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
process.exitCode = 1;
} else {
const count = Object.keys(result.assertions).length;
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
}
}
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');