rewrite 2026-09 and 2027 articles for reader-facing quality
Build and deploy / deploy (push) Successful in 18s

This commit is contained in:
2026-07-31 22:26:56 +03:00
parent 3bfc3f21c4
commit 440c8721dc
69 changed files with 4424 additions and 3533 deletions
+249 -203
View File
@@ -11,10 +11,13 @@ 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 figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + escapeHtml(alt) + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((cell) => '<th scope="col">' + cell + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); }
function cloneFixed(value) {
return JSON.parse(JSON.stringify(value));
}
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.values(value).forEach(deepFreeze);
@@ -22,260 +25,303 @@ function deepFreeze(value) {
}
return value;
}
function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); }
function bodyText(html) { return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, '')); }
function plainText(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
}
function bodyText(html) {
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const REFERENCES = deepFreeze({
ssdf: {
title: 'NIST SP 800-218 — Secure Software Development Framework (SSDF) Version 1.1',
url: 'https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-218.pdf',
version: 'NIST Special Publication 800-218, Version 1.1, February 2022, immutable dated PDF',
},
controls: {
title: 'NIST SP 800-53 Rev. 5 — Security and Privacy Controls for Information Systems and Organizations',
url: 'https://doi.org/10.6028/NIST.SP.800-53r5',
version: 'NIST Special Publication 800-53 Revision 5, final dated 10 December 2020, immutable PDF',
},
ssdf: { title: 'NIST SP 800-218 — Secure Software Development Framework (SSDF) Version 1.1', version: 'February 2022', url: 'https://csrc.nist.gov/pubs/sp/800/218/final' },
asvs: { title: 'OWASP Application Security Verification Standard', version: 'Version 5.0.0, released 30 May 2025', url: 'https://owasp.org/www-project-application-security-verification-standard/' },
controls: { title: 'NIST SP 800-53 Rev. 5 — Security and Privacy Controls', version: 'September 2020, updates as of 10 December 2020', url: 'https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final' },
});
function sources(entries) {
return '<ul>' + entries.map(({ key, use, boundary }) => {
const ref = REFERENCES[key];
return '<li><a href="' + ref.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(ref.title) + '</a> — ' + escapeHtml(ref.version) + '. Применение: ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>';
return '<li><a href="' + ref.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(ref.title) + '</a> — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница применимости: ' + escapeHtml(boundary) + '</li>';
}).join('') + '</ul>';
}
const FIXED_SECURITY_CARDS = deepFreeze({
'security-capstone-boundary-card-v1': {
id: 'security-capstone-boundary-card-v1', editorialDate: '2026-07-31', planningIssue: '2027-08', sourceCutoff: '2026-07-31',
asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' },
path: { label: 'synthetic-path-question', states: 'not-mapped', assumptions: 'not-accepted' },
control: { coverage: 'not-selected', evidence: 'not-collected', deployment: 'not-attempted' },
handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'synthetic-plan-hand-off',
},
'security-capstone-bad-date-v1': {
id: 'security-capstone-bad-date-v1', editorialDate: '', planningIssue: '2027-08', sourceCutoff: '2026-07-31',
asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' }, path: { label: 'synthetic-path-question', states: 'not-mapped', assumptions: 'not-accepted' }, control: { coverage: 'not-selected', evidence: 'not-collected', deployment: 'not-attempted' }, handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'synthetic-plan-hand-off',
},
'security-capstone-path-claimed-v1': {
id: 'security-capstone-path-claimed-v1', editorialDate: '2026-07-31', planningIssue: '2027-08', sourceCutoff: '2026-07-31',
asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' }, path: { label: 'synthetic-path-question', states: 'claimed', assumptions: 'not-accepted' }, control: { coverage: 'not-selected', evidence: 'not-collected', deployment: 'not-attempted' }, handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'synthetic-plan-hand-off',
},
'security-capstone-control-claimed-v1': {
id: 'security-capstone-control-claimed-v1', editorialDate: '2026-07-31', planningIssue: '2027-08', sourceCutoff: '2026-07-31',
asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' }, path: { label: 'synthetic-path-question', states: 'not-mapped', assumptions: 'not-accepted' }, control: { coverage: 'claimed', evidence: 'not-collected', deployment: 'not-attempted' }, handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'synthetic-plan-hand-off',
},
'security-capstone-evidence-claimed-v1': {
id: 'security-capstone-evidence-claimed-v1', editorialDate: '2026-07-31', planningIssue: '2027-08', sourceCutoff: '2026-07-31',
asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' }, path: { label: 'synthetic-path-question', states: 'not-mapped', assumptions: 'not-accepted' }, control: { coverage: 'not-selected', evidence: 'claimed', deployment: 'not-attempted' }, handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'synthetic-plan-hand-off',
},
'security-capstone-positive-effect-v1': {
id: 'security-capstone-positive-effect-v1', editorialDate: '2026-07-31', planningIssue: '2027-08', sourceCutoff: '2026-07-31',
asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' }, path: { label: 'synthetic-path-question', states: 'not-mapped', assumptions: 'not-accepted' }, control: { coverage: 'not-selected', evidence: 'not-collected', deployment: 'attempted' }, handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'security-improved',
},
publicProfile: { role: 'user', owner: 'self', action: 'read', resource: 'profile' },
foreignProfile: { role: 'user', owner: 'other', action: 'read', resource: 'profile' },
adminAudit: { role: 'admin', owner: 'other', action: 'read', resource: 'audit' },
unknownRole: { role: 'unknown', owner: 'self', action: 'read', resource: 'profile' },
});
export function createSecurityCapstoneCard(id = 'security-capstone-boundary-card-v1') {
const value = FIXED_SECURITY_CARDS[id];
return value ? deepFreeze(cloneFixed(value)) : undefined;
export function createSecurityCapstoneCard(id = 'publicProfile') {
return FIXED_SECURITY_CARDS[id] ? deepFreeze(cloneFixed(FIXED_SECURITY_CARDS[id])) : undefined;
}
function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); }
export function assessSecurityCapstoneCard(input) {
const known = Object.values(FIXED_SECURITY_CARDS).some((item) => JSON.stringify(item) === JSON.stringify(input));
if (!known) return stop('stop-unknown-fixed-literal', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-literal');
if (input.editorialDate !== '2026-07-31' || input.planningIssue !== '2027-08' || input.sourceCutoff !== '2026-07-31') return stop('stop-temporal-boundary-required', 'editorial-date-planning-issue-and-source-cutoff-must-be-exact', 'restore-the-fixed-temporal-boundary');
if (input.asset.classification !== 'not-declared' || input.asset.boundary !== 'not-mapped' || input.path.states !== 'not-mapped' || input.path.assumptions !== 'not-accepted') return stop('stop-claimed-asset-or-path', 'asset-and-path-must-remain-unmapped-and-unaccepted', 'remove-claimed-asset-or-path');
if (input.control.coverage !== 'not-selected' || input.control.evidence !== 'not-collected') return stop('stop-claimed-control-or-evidence', 'control-coverage-and-evidence-must-remain-unselected', 'remove-control-or-evidence-claim');
if (input.control.deployment !== 'not-attempted' || input.handoff.recipient !== 'not-assigned' || input.handoff.route !== 'not-opened') return stop('stop-claimed-execution-or-owner', 'deployment-owner-and-route-cannot-be-inferred', 'keep-execution-and-assignment-unattempted');
if (input.requestedOutput !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-result', 'future-security-scenario-cannot-claim-a-positive-result', 'use-synthetic-plan-hand-off');
return deepFreeze({ status: 'synthetic-plan-hand-off', cardId: input.id, planningIssue: input.planningIssue, asset: deepFreeze(cloneFixed(input.asset)), path: deepFreeze(cloneFixed(input.path)), control: deepFreeze(cloneFixed(input.control)), handoff: deepFreeze(cloneFixed(input.handoff)), productionEffect: 'not-attempted', nextAction: 'open-a-separate-authorized-scope-only-if-a-decision-needs-evidence' });
if (!input || typeof input !== 'object') return { status: 'deny', reason: 'invalid-input' };
if (input.role === 'admin' && input.resource === 'audit' && input.action === 'read') return { status: 'allow', reason: 'role-permission' };
if (input.role === 'user' && input.owner === 'self' && input.resource === 'profile' && input.action === 'read') return { status: 'allow', reason: 'object-ownership' };
return { status: 'deny', reason: 'default-deny' };
}
export function inspectAssetBoundaryLiteral() { const card = createSecurityCapstoneCard(); return deepFreeze({ asset: card.asset.label, boundary: card.asset.boundary, path: card.path.states, output: assessSecurityCapstoneCard(card).status }); }
export function inspectCoverageLiteral() { const card = createSecurityCapstoneCard(); return deepFreeze({ path: card.path.label, coverage: card.control.coverage, evidence: card.control.evidence, effect: assessSecurityCapstoneCard(card).productionEffect }); }
export function inspectSecurityHandoffLiteral() { const result = assessSecurityCapstoneCard(createSecurityCapstoneCard()); return deepFreeze({ status: result.status, recipient: result.handoff.recipient, nextAction: result.nextAction }); }
export function inspectAssetBoundaryLiteral() {
const card = createSecurityCapstoneCard();
return deepFreeze({ resource: card.resource, owner: card.owner, result: assessSecurityCapstoneCard(card).status });
}
export function inspectCoverageLiteral() {
return deepFreeze({ checks: ['role', 'owner', 'action'], default: 'deny', accepted: assessSecurityCapstoneCard(createSecurityCapstoneCard()).status });
}
export function inspectSecurityHandoffLiteral() {
return deepFreeze({ input: createSecurityCapstoneCard('foreignProfile'), result: assessSecurityCapstoneCard(createSecurityCapstoneCard('foreignProfile')) });
}
export function runSecurityCapstoneFixture() {
const expected = [
['security-capstone-boundary-card-v1', 'synthetic-plan-hand-off'], ['security-capstone-bad-date-v1', 'stop-temporal-boundary-required'], ['security-capstone-path-claimed-v1', 'stop-claimed-asset-or-path'], ['security-capstone-control-claimed-v1', 'stop-claimed-control-or-evidence'], ['security-capstone-evidence-claimed-v1', 'stop-claimed-control-or-evidence'], ['security-capstone-positive-effect-v1', 'stop-claimed-execution-or-owner'],
];
const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessSecurityCapstoneCard(createSecurityCapstoneCard(id)).status }));
const sample = createSecurityCapstoneCard();
return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.asset) && Object.isFrozen(sample.control), checks: deepFreeze(checks) });
const checks = [
['publicProfile', 'allow'],
['foreignProfile', 'deny'],
['adminAudit', 'allow'],
['unknownRole', 'deny'],
].map(([id, expected]) => ({ id, expected, actual: assessSecurityCapstoneCard(createSecurityCapstoneCard(id)).status }));
return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual), checks });
}
function revision(meta, parts, referenceEntries) {
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 10000 || proseLength > 13000) throw new Error(meta.slug + ': body length ' + proseLength);
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength);
return deepFreeze({ ...meta, contentHtml, proseLength });
}
const refs = [
{ key: 'ssdf', use: 'Даёт датированную официальную лексику практик безопасной разработки и коммуникации между участниками.', boundary: 'Не доказывает наличие практики, проверки, уязвимости, результата или контроля в этом сценарии.' },
{ key: 'controls', use: 'Помогает различать функцию контроля и уверенность в ней, не смешивая карту с подтверждением.', boundary: 'Не подтверждает, что какой-либо контроль выбран, развёрнут, сработал или покрывает конкретный путь.' },
{ key: 'ssdf', use: 'Даёт общий словарь практик безопасной разработки и связывает защиту с жизненным циклом продукта.', boundary: 'Рамка не подтверждает наличие контроля, уязвимости или результата в конкретном приложении.' },
{ key: 'asvs', use: 'Даёт версионируемые требования для проверки технических контролей веб-приложения.', boundary: 'Стандарт не заменяет модель угроз, настройку инфраструктуры и тестирование конкретных данных.' },
{ key: 'controls', use: 'Помогает различить сам контроль и уверенность в его работе.', boundary: 'Каталог не выбирает права вашего ресурса и не является отчётом о соответствии.' },
];
const practice = revision({
slug: 'editorial-2027-08-practice-security-capstone', title: 'Большой разбор безопасности: актив, граница и договор о проверке', categories: ['Безопасность', 'Инженерные практики'], cover: '/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg', excerpt: 'План на август 2027: как назвать актив и границу будущего вопроса, не создавая карту реальной системы.', readingMinutes: 26,
slug: 'editorial-2027-08-practice-security-capstone',
title: 'SSRF начинается с URL: проверяем адрес до сетевого вызова',
categories: ['Безопасность', 'Backend'],
cover: '/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg',
excerpt: 'Практическая защита server-side запроса: схема проверки URL, allowlist и запрет обхода через адресную запись.',
readingMinutes: 14,
}, [
p('P114 — плановый материал на 2027-08 с редакционной датой и source cutoff 2026-07-31. Практическая ошибка начинается с невинной фразы «защитим важные данные»: команда уже выбрала слова, но ещё не назвала актив, его границу и то, что будет считаться допустимым свидетельством. Цена появляется сразу: следующий читатель дорисовывает систему, маршрут и решение по привычке, а затем обсуждает придуманное как обязательство.'),
p('Вторая цена — подмена договора о проверке самой проверкой. В будущем документе легко оставить название компонента, намёк на доступ или обещание «покрыть риск», хотя исходных материалов нет. Для P114 не существует настоящего актива, классификации, границы, маршрута, контроля, evidence, владельца или развертывания. Допустимый positive output здесь один: <code>synthetic-plan-hand-off</code>, причём <code>productionEffect</code> всегда <code>not-attempted</code>. Это не отчёт и не security-test; это форма, которая не даёт неизвестному стать правдоподобным фактом.'),
h2('Актив сначала называют, затем запрещают дополнять'),
p('Актив в этом сценарии — не объект в инфраструктуре и не запись в каталоге. Это synthetic label, нужный, чтобы у будущего разговора была тема. Метка <code>synthetic-asset-label</code> не содержит пользователя, файла, ключа, сервиса, класса данных или степени важности. За ней нельзя угадывать тип информации. Такое ограничение кажется бедным только пока не сравнишь его с альтернативой: любой реалистичный пример уже задаёт лишний контекст и заставляет выбирать защиту под несуществующий объект.'),
p('Граница отвечает на другой вопрос: что из разговора не попадает в документ. Её нельзя заменить существительным «приложение» или «платформа». В literal граница имеет значение <code>not-mapped</code>. Это не пустое поле, которое удобный reviewer заполнит позднее, а явный стоп для всех выводов о входе, выходе, хранении, передаче и доверии. Пока граница не принадлежит отдельному разрешённому scope, обсуждать «где защищать» преждевременно.'),
table('Минимальная карточка актива без ложной конкретики', ['Поле', 'Значение P114', 'Что это запрещает'], [
['Метка актива', '<code>synthetic-asset-label</code>', 'Выдать пример за существующий объект'],
['Классификация', '<code>not-declared</code>', 'Назвать данные, ценность или режим доступа'],
['Граница', '<code>not-mapped</code>', 'Рисовать вход, выход, хранение или доверенную зону'],
['Путь', '<code>not-mapped</code>', 'Считать порядок действий известным'],
['Допущения', '<code>not-accepted</code>', 'Сделать удобную догадку проектным решением'],
p('Endpoint принимает URL изображения и скачивает его сервером. Цена ошибки — превратить обычное поле формы в доступ к внутреннему адресу, metadata-сервису или административному интерфейсу. Проверка только на строку <code>https://</code> не защищает: URL имеет имя, порт, учетные данные, редирект и адрес, которые меняют фактическую цель.'),
p('Разберём защиту до вызова сети: разрешённая схема, точное имя хоста, запрет credentials и явное ограничение портов. Учебная функция не делает запрос. Она принимает строку и возвращает нормализованный адрес либо причину отказа, поэтому её можно запускать локально и проверять отдельными примерами.'),
h2('Проверяем не строку, а разобранный адрес'),
p('Сначала парсер URL должен превратить строку в структуру. Затем политика проверяет протокол, hostname, порт и наличие логина или пароля. Проверять только исходное начало строки недостаточно: <code>https://trusted.example@127.0.0.1/</code> имеет доверенное имя до символа <code>@</code>, но фактический host — loopback.'),
table('Поля URL и решение политики', ['Поле', 'Допустимое правило', 'Причина'], [
['protocol', 'только https:', 'не отправлять секрет по plain HTTP'],
['hostname', 'точный allowlist', 'не доверять суффиксу строки'],
['port', '443 или явно разрешённый', 'сократить обход сервисов'],
['username/password', 'пусто', 'не передавать credentials дальше'],
['pathname', 'проверяется отдельно', 'не разрешать опасный endpoint'],
]),
h2('Почему «важный» не является характеристикой'),
p('Слово «важный» пытается склеить стоимость, доступ, целостность и ожидания пользователя в одну оценку. Для будущего сценария это опасная экономия: оно выглядит как классификация, но не содержит условия, кто его утвердил и на каких материалах. В P114 не нужно выбирать шкалу или приоритет. Нужно удержать разницу между вопросом «какой актив стоит обсуждать?» и утверждением «этот актив уже имеет такой-то класс». Второе требует предметного контекста, которого в плановом пакете нет.'),
p('NIST SP 800-53 Rev. 5 полезен здесь не каталогом готовых ответов. Публикация различает функциональную сторону контроля и assurance — степень уверенности в возможностях. Из этого следует узкий редакционный вывод: одна и та же картинка контроля не является доказательством, пока не определены объект и условия. Норма не сообщает, что P114 что-либо охраняет. Она помогает не назвать уверенностью один лишь рисунок.'),
p('NIST SSDF Version 1.1 также не превращает общий язык безопасной разработки в факт о конкретной команде. Он описывает практики верхнего уровня, которые можно встраивать в жизненный цикл, и общий язык для общения. Мы применяем именно эту часть: договор должен быть понятен следующему участнику. Мы не применяем публикацию как доказательство, что у пакета уже есть процесс, результат или снижение риска.'),
figure('/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg', 'Схема синтетической карточки: метка актива, непоставленная граница и неотображённый путь ведут к договору о проверке; попытки объявить контроль или результат останавливаются.', 'Схема показывает редакционные статусы, а не путь атаки, систему, доступ или действие против неё.'),
h2('Договор о проверке — это перечень запретов на вывод'),
p('Полезный договор не обещает, что проверка обязательно состоится. Он фиксирует, какие вопросы должны быть решены в другом scope до появления любого evidence. Для актива это происхождение и допустимость данных. Для границы — что входит в рассмотрение и что исключено. Для пути — какие состояния можно описывать без реконструкции. Для контроля — какой смысл слова «покрывает» будет принят. Пока эти вопросы не получили авторизацию, каждая строка должна остаться именованной неизвестностью.'),
p('Отдельно важно не выдавать список источников за этот договор. Официальный документ объясняет термины и ограничения, но не получает разрешение на доступ и не выбирает метод. Поэтому URL и дата источника остаются в конце статьи, а fixed literal не содержит URL, target или параметров. Это разделение намеренное: источник делает лексику проверяемой, а scope делает возможным исследование. Одно не заменяет другое.'),
h2('Исполнимый пример не обращается к предмету'),
p('Пример можно запустить локально в Node. Он создаёт заранее объявленный literal, вызывает чистый evaluator и печатает только статус формы. Внутри нет чтения файлов, сети, environment, часов, secrets, telemetry, систем или данных. Нет импорта клиента и нет действия, похожего на security-test. Поэтому runnable code не способен узнать что-либо о чужом или собственном контуре.'),
code("import { inspectAssetBoundaryLiteral } from './upgrade-2027-08.mjs';\n\nconst card = inspectAssetBoundaryLiteral();\nconsole.log(card.asset, card.boundary, card.output);\n// synthetic-asset-label not-mapped synthetic-plan-hand-off"),
p('В этом выводе <code>synthetic-plan-hand-off</code> означает принятие строгой формы, не успех защитного действия. <code>not-mapped</code> не просит функцию найти путь по умолчанию. Если заменить literal произвольным объектом, evaluator остановится с <code>stop-unknown-fixed-literal</code>. Если взять известный отрицательный literal с заявленным путём, он также остановится. Такая проверка полезна ровно потому, что она не знает предметной системы.'),
h2('Три разных договора вместо одного слова «безопасность»'),
p('Первый договор относится к предмету: synthetic label нужен, чтобы следующая беседа не расползлась на весь продукт. Второй относится к границе: он перечисляет не компоненты, а запрет делать вид, что компоненты уже известны. Третий относится к evidence: он определяет, что публикация, схема и пример программы не считаются наблюдением. Эти договоры нельзя слить в одну строку «учтены требования безопасности». Такая строка не сообщает, где заканчивается её смысл и кто способен опровергнуть её.'),
p('Разделение помогает и при смене контекста. Допустим, будущий scope обнаружит, что вопрос вообще не про актив, а про правило обмена между двумя процессами. Первый договор можно заменить, не сохраняя неверную классификацию. Если окажется, что граница шире или уже, второй договор обновляется независимо от статьи. Если evidence собирать нельзя, третий договор честно завершит работу без результата. Monolithic «security checklist» обычно не даёт такой свободы: в нём изменение одного поля выглядит как отмена всего документа.'),
h2('Что считается попыткой незаметно расширить scope'),
p('Расширение редко приходит словом «расширение». Его видно по типу значения. Конкретное имя сервиса, поле payload, имя роли, дата будущего запуска, оценка критичности или обещание хранения — всё это утверждает, что автор уже получил материал и право им пользоваться. Даже без секрета такая деталь меняет читательскую модель. Поэтому evaluator сравнивает весь input только с заранее объявленными literals: он не нормализует похожий объект и не добавляет разумные defaults.'),
p('Строгое сравнение не является общим способом валидировать production данные. Оно выбрано для редакционной изоляции. Пакет не должен принимать «почти такую же» карточку, потому что именно в разнице может оказаться неразрешённый факт. Например, поле с реалистичной классификацией или состояние пути, похожее на известное, нельзя понизить до заметки. Возврат stop-status делает расхождение видимым до того, как оно будет переписано в prose или SVG.'),
p('У этого правила есть практический критерий: читатель должен суметь указать на каждое положительное существительное и найти для него либо fixed literal, либо будущий scope. Если такого адреса нет, существительное превращается в claim и подлежит удалению. Проверять стоит не только заголовки таблиц, но и подписи к рисункам, комментарии в примере и переходы между абзацами: именно там незаметно появляются «уже защищённый» объект, «известный» путь или «подходящий» контроль.'),
h2('Маршрут работы без имитации исследования'),
figure('/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg', 'Путь проверки URL: parse, схема, hostname, порт, credentials и только затем разрешение на вызов.', 'Каждая проверка стоит до сетевого вызова. Отказ возвращает причину и не передаёт адрес следующему слою.'),
h2('Allowlist должна быть точной'),
p('Сравнение <code>hostname.endsWith("example.com")</code> пропустит <code>evil-example.com</code>. Сравнение с одним каноническим именем проще проверить. Если нужны поддомены, добавьте правило границы: имя равно базовому или заканчивается на <code>.example.com</code>. IP-адреса, IPv6 и DNS-редиректы требуют отдельного решения, потому что имя может измениться между проверкой и соединением.'),
p('Защита от SSRF — не только функция валидации. Клиент должен ограничить редиректы, timeout, размер ответа и набор портов. Сетевой слой может дополнительно запретить приватные диапазоны и loopback. Эти меры защищают разные границы, поэтому одна allowlist не заменяет остальные.'),
h2('Учебный валидатор URL'),
p('Входом функции является строка URL и массив разрешённых имён. Ожидаемый результат — объект <code>{ allowed, reason, href }</code>. Ниже нет <code>fetch</code>: пример проверяет предмет статьи и не выполняет опасное подключение. Для безопасных тестов используются только публичные доменные имена из allowlist.'),
code([
'function validateRemoteUrl(value, allowedHosts) {',
' let url;',
' try { url = new URL(value); } catch { return { allowed: false, reason: \'invalid-url\' }; }',
" if (url.protocol !== 'https:') return { allowed: false, reason: 'scheme' };",
" if (url.username || url.password) return { allowed: false, reason: 'credentials' };",
" if (url.port && url.port !== '443') return { allowed: false, reason: 'port' };",
' if (!allowedHosts.includes(url.hostname)) return { allowed: false, reason: \'host\' };',
' return { allowed: true, reason: \'allowlist\', href: url.href };',
'}',
'',
"console.log(validateRemoteUrl('https://cdn.example.test/file.jpg', ['cdn.example.test']));",
"console.log(validateRemoteUrl('https://cdn.example.test@127.0.0.1/file.jpg', ['cdn.example.test']));",
"console.log(validateRemoteUrl('https://127.0.0.1/file.jpg', ['cdn.example.test']));",
'// allowed true; allowed false, reason credentials; allowed false, reason host',
].join('\n')),
p('Первая строка проходит, вторая останавливается на credentials, а третья — на фактическом hostname loopback. Порядок проверок важен: сначала исключаем встроенные учетные данные, затем сверяем host. В полном сервисе URL нельзя передавать в сеть сразу после этого результата: нужны лимит ответа, запрет небезопасных redirect и проверка адреса после разрешения имени там, где это входит в модель угроз.'),
h2('Редирект — новый URL'),
p('Если разрешённый сервер отвечает 302 на другой адрес, политика должна решить, разрешены ли перенаправления. Автоматически следовать за ними опасно: проверка исходного host уже не описывает конечный. Безопасный вариант — отключить redirect и вернуть его адрес как проверяемую ошибку. Если redirect нужен, каждый новый URL проходит ту же проверку.'),
p('Также проверяйте размер ответа до чтения всего тела. Даже разрешённый host может вернуть гигабайты и занять память. Timeout ограничивает время, но не объём. Эти ограничения не делают endpoint безопасным сами по себе, зато уменьшают blast radius ошибки политики.'),
h2('Порядок внедрения'),
ol([
'Отметить документ как сценарий 2027-08 и повторить source cutoff 2026-07-31 рядом с вопросом.',
'Назвать только synthetic asset label; не добавлять тип данных, сервис, идентификатор, путь или классификацию.',
'Зафиксировать boundary и path как <code>not-mapped</code>, а допущения как <code>not-accepted</code>.',
'Сформулировать, какое evidence вообще могло бы быть допустимо в отдельном scope, не собирая и не имитируя его сейчас.',
'Прогнать fixed fixture: положительной остаётся лишь hand-off форма, любые claims должны получить stop-status.',
'Передать вопрос только в новый authorized scope, если будущему решению действительно понадобятся материалы.',
'Назвать допустимые домены, схемы и порты в конфигурации, а не принимать их из запроса.',
'Распарсить URL стандартным URL-парсером и проверить hostname после нормализации.',
'Запретить credentials, неожиданные схемы и redirect по умолчанию.',
'Ограничить timeout, размер ответа и число сетевых попыток.',
'Добавить тесты на @, похожий домен, IP, IPv6, другой порт и redirect.',
'Сохранить причину отказа без полного URL с секретными параметрами.',
]),
h2('Ограничения этой практики'),
p('Карточка не является inventory, классификацией, model, assessment или разрешением на действие. Она не сообщает, существует ли объект, можно ли его наблюдать и нужен ли он следующему решению. Она не выбирает защитный механизм и не даёт критерия готовности. Это сознательное ограничение защищает текст от самого частого ложного вывода: раз есть аккуратная таблица, значит работа по безопасности уже началась.'),
p('У подхода есть цена: будущему участнику придётся повторно уточнить вопрос, а не получить готовую карту. Но эта цена меньше стоимости ошибочной конкретики. Именованная пустота легко расширяется в правильном контексте; выдуманная граница требует сначала объяснить, почему ей доверяли. Техлидская дисциплина здесь не в том, чтобы заполнить форму быстрее, а в том, чтобы не создавать технический долг из уверенных слов.'),
h2('Следующее действие'),
p('Следующее действие одно: если появится решение, которому нужны факты, открыть отдельный authorized scope с собственными входами, ограничениями и правилами хранения. Новый документ вправе определить актив и границу, но не вправе переписать P114 так, будто они были известны заранее. Пока такого scope нет, полезный и единственный положительный итог — <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>.'),
h2('Ограничения и следующий шаг'),
p('Учебный валидатор не знает DNS rebinding, proxy и правила сетевого сегмента. Allowlist домена не доказывает, что адрес после разрешения безопасен. Для чувствительных систем нужен совместный контроль приложения и egress-сети, а также отдельные тесты redirect и размера ответа.'),
p('Следующим шагом возьмите один endpoint, который принимает URL, и выпишите допустимые host, port, redirect и максимальный размер. Затем добавьте отрицательные тесты, включая имя с <code>@</code>. Критерий готовности — запрещённый адрес не доходит до клиента сети.'),
], refs);
const mechanism = revision({
slug: 'editorial-2027-08-mechanism-security-capstone', title: 'Большой разбор безопасности: путь, покрытие и фальсифицируемость', categories: ['Безопасность', 'Архитектура'], cover: '/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg', excerpt: 'План на август 2027: как удержать synthetic path и матрицу control coverage проверяемыми, не выдавая схему за результат.', readingMinutes: 27,
slug: 'editorial-2027-08-mechanism-security-capstone',
title: 'Аутентификация не даёт доступ: строим deny-by-default для объекта',
categories: ['Безопасность', 'Архитектура'],
cover: '/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg',
excerpt: 'Разбираем, почему проверка логина без проверки объекта оставляет горизонтальную эскалацию прав.',
readingMinutes: 14,
}, [
p('P114 рассматривает будущий август 2027 из точки 2026-07-31. Механическая ошибка появляется, когда путь угрозы рисуют настолько убедительно, что его начинают читать как установленную последовательность. У прямоугольников есть стрелки, у стрелок — названия, и мозг уже достраивает источник, переход и последствия. Цена — решение принимают по рисунку, хотя рисунок не содержит ни подтверждённого состояния, ни допустимого evidence.'),
p('Вторая цена скрыта в слове «покрытие». Оно звучит как завершённая работа: будто контроль уже выбран, проверен и относится к каждому участку пути. В P114 этого нет. Нет реального пути, control deployment, control evidence, security-test, finding, scan или положительного эффекта. Единственный неостанавливаемый output — <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>. Механизм ниже учит отбрасывать недоказанные claims, а не превращать их в проектный статус.'),
h2('Путь — не история, а набор незаполненных связей'),
p('Синтетический путь начинается не с противника и не с технологии. Он начинается с вопроса о связях между пока неописанными состояниями. В fixed literal ему дано имя <code>synthetic-path-question</code>, а поле <code>states</code> равно <code>not-mapped</code>. Имя не кодирует endpoint, роль, способ доступа или переход. Оно существует только для того, чтобы evaluator мог отличить известную учебную форму от произвольного входа.'),
p('Такой дизайн противоречит привычке рисовать больше. В настоящей архитектурной схеме детали помогают заметить разрыв. В неавторизованном будущем сценарии те же детали становятся неподтверждёнными утверждениями. Поэтому P114 не строит threat model и не описывает attack technique. Он использует слово path только в значении «отношение, которое когда-нибудь может потребовать отдельного рассмотрения». Пока состояние не собрано, у path нет направления, начала, конца или вероятности.'),
table('Матрица фальсификации для synthetic path', ['Слой', 'Статус в P114', 'Что нельзя заключить'], [
['Связи пути', '<code>not-mapped</code>', 'Что последовательность существует или завершена'],
['Допущения', '<code>not-accepted</code>', 'Что удобное объяснение допустимо'],
['Покрытие контроля', '<code>not-selected</code>', 'Что мера относится к пути'],
['Свидетельство', '<code>not-collected</code>', 'Что механизм наблюдался или проверялся'],
['Эффект', '<code>not-attempted</code>', 'Что изменился production или риск'],
p('Пользователь успешно вошёл в систему, но изменил <code>id</code> в URL и увидел чужой профиль. Цена ошибки — считать факт аутентификации разрешением на любой объект. Проверка «токен валиден» отвечает только на вопрос, кто пришёл; она не отвечает, что этому субъекту можно сделать с выбранной записью.'),
p('Построим решение из четырёх полей: субъект, роль, действие и объект. Сначала политика запрещает неизвестное, затем явно разрешает владельцу чтение профиля и администратору чтение аудита. Учебный HTTP-сервер показывает ответ <code>403</code> для чужого объекта без обращения к базе данных.'),
h2('Разделяем четыре вопроса'),
p('Аутентификация устанавливает субъект. Ауторизация проверяет действие над объектом. Роль — только один вход политики; владельца и принадлежность ресурса нужно получить из доверенного слоя данных. Нельзя брать <code>ownerId</code> из тела запроса и затем использовать его как доказательство владения.'),
table('Минимальная модель решения', ['Поле', 'Источник', 'Проверка'], [
['subjectId', 'проверенный токен', 'идентификатор не меняется из body'],
['role', 'claims/политика', 'не принимать роль из URL'],
['action', 'маршрут и метод', 'явное множество действий'],
['resource', 'серверная загрузка', 'объект найден по ID'],
['ownerId', 'доменная запись', 'сравнить с subjectId'],
]),
h2('Покрытие — отношение, а не зелёная ячейка'),
p('Даже в реальной работе «control coverage» не должно означать декоративное соответствие названия контролю. Для такого вывода нужны как минимум объект, граница, цель, условия работы и способ отличить действие от отсутствия действия. В P114 ни один из этих элементов не определён. Поэтому значение <code>not-selected</code> честнее слова «частично»: частичность уже предполагает измеренную ось и выбранный объект сравнения.'),
p('NIST SP 800-53 Rev. 5 описывает каталог гибких контролей и отдельно говорит о функциональном и assurance-ракурсах. Для механизма P114 это источник ограничения: функция и уверенность не складываются автоматически в факт. Даже если будущий scope подберёт название из каталога, ему всё равно придётся показать, почему оно относится к его границе и какое свидетельство допустимо. Документ NIST не создаёт такую связь за него.'),
p('SSDF Version 1.1 вводит практики безопасной разработки как высокоуровневые и пригодные для интеграции в разные жизненные циклы. Мы не выводим из этого универсальный рецепт. Наоборот, высокая абстракция источника напоминает о границе применения: публикация может помочь задать вопрос к процессу, но не подтвердить, что конкретная мера была запущена, дала результат или закрыла неизвестный путь.'),
figure('/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg', 'Матрица: synthetic path, невыбранное покрытие и несобранное evidence пересекаются в стоп-границе; только плановая передача вопроса остаётся разрешённым выходом.', 'Матрица не содержит реальных техник, целей, конфигураций или показателей. Она показывает, почему статус контроля нельзя получить из пустого пути.'),
h2('Фальсифицируемость начинается до положительного вывода'),
p('В этом материале фальсифицируемость не предлагает выполнять проверку. Она определяет, какие факты не позволят принять claim. Если путь не отображён, нельзя сказать, что что-то проходит по нему. Если control не выбран, нельзя назвать его покрытием. Если evidence не собран, нельзя выдать документ за подтверждение. Если deployment не attempted, нельзя говорить о production effect. Это четыре независимых стоп-условия; их нельзя компенсировать хорошим стилем схемы.'),
p('Такой порядок отличается от чеклиста best practices. Чеклист говорит, что полезно иметь. Фальсифицируемая карточка говорит, чего нельзя утверждать до появления определённого материала. Для техлида это практичнее: она позволяет остановить ложную эскалацию до спора о деталях. Пустая ячейка перестаёт быть неудобством и становится контрактом, который защищает следующего автора от давления «сделайте вывод уже сейчас».'),
h2('Исполнимый пример проверяет отказ от claim'),
p('Ниже нет схемы сети, входных адресов, payload, инструмента или вызова. Пример работает с неизменяемым literal в памяти и возвращает три строки, описывающие его ограничения. Модуль не читает file, environment, clock, secret, telemetry, system, data или network. Он не инициирует scan и не меняет состояние. Поэтому выполнение безопасно: оно проверяет только правило редакционной формы.'),
code("import { inspectCoverageLiteral } from './upgrade-2027-08.mjs';\n\nconst matrix = inspectCoverageLiteral();\nconsole.log(matrix.path, matrix.coverage, matrix.effect);\n// synthetic-path-question not-selected not-attempted"),
p('Строка <code>not-selected</code> не означает отказ от защиты и не предлагает оставить что-либо без решения. Она означает, что P114 не имеет права выбрать решение. А <code>not-attempted</code> относится только к production effect evaluator: никакое внешнее действие не выполнялось. Если вызвать assessor с known negative literal, где coverage объявлено claimed, результат станет <code>stop-claimed-control-or-evidence</code>, а не предупреждением, которое можно проигнорировать.'),
h2('Четыре оси не становятся доказательством друг друга'),
p('У path, control, evidence и effect разные владельцы смысла. Path отвечает на вопрос о связи состояний. Control — о намерении выбрать некоторую функцию. Evidence — о происхождении наблюдения или подтверждения. Effect — о том, что изменилось за пределами чистой функции. Даже в будущем проекте нельзя переносить значение по диагонали: выбранный control не доказывает path, существующее evidence не доказывает effect, а желаемый effect не делает coverage достаточным.'),
p('В синтетическом сценарии все четыре оси остаются отрицательными status. Это не попытка создать математическую модель риска из строк. Наоборот, строки не имеют числового веса, вероятности или приоритета. Их задача скромнее: зафиксировать, что положительное утверждение на любой оси нуждается в собственном основании. Такой дизайн не позволит красиво ранжировать варианты, зато не позволит получить рейтинг из отсутствующих данных.'),
h2('Как читать stop-status'),
p('Stop-status не называет ошибку безопасности и не говорит, что input опасен. Он говорит только, что input нарушает редакционный контракт P114. Например, <code>stop-claimed-asset-or-path</code> не обнаруживает маршрут и не запрещает настоящую карту в другом месте. Он отвергает попытку представить карту внутри этого планового literal. В результате текст остаётся точным: мы проверили не систему, а то, что статья не стала выдавать план за evidence.'),
p('Это различие полезно для review. Reviewer не обязан спорить, правдоподобна ли заявленная деталь; ему достаточно увидеть, что деталь отсутствует в разрешённой форме. Спор о правдоподобии обычно тянет за собой новые предположения и заставляет автора защищать неподтверждённый контекст. Стоп переносит разговор в продуктивное место: либо убрать claim, либо открыть отдельный scope, где будут правила для фактов.'),
h2('Матрица должна сохранять асимметрию доказательства'),
p('У положительного claim и у отрицательного статуса разная цена. Чтобы сказать «покрытие есть», будущему scope потребуется связь нескольких материалов. Чтобы сказать «покрытие здесь не выбрано», достаточно не делать выбора в этом literal. Эту асимметрию нельзя сгладить словом «неполное»: оно создаёт впечатление, будто шкала уже существует, а осталась лишь работа по заполнению. В P114 нет шкалы, процента, ранга и критерия достаточности.'),
p('То же относится к связке source и evidence. Датированный официальный PDF надёжно сообщает, что опубликованный текст существовал в указанной версии. Он не сообщает, что synthetic path имеет свойства, к которым можно применить норму. Поэтому матрица намеренно хранит references вне evaluator input: evaluator проверяет форму плановой карточки, а статья объясняет язык этой формы. Если смешать эти уровни, ссылка станет ложным substitute для отсутствующего основания.'),
h2('Как собрать вопрос, который можно опровергнуть'),
figure('/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg', 'Матрица авторизации: субъект, роль, действие, объект и владелец сходятся в решении allow или deny.', 'Решение строится на серверных полях. Изменение идентификатора в запросе не меняет владельца записи.'),
h2('Deny-by-default сохраняет неизвестное неизвестным'),
p('Политика должна возвращать deny, если роль не распознана, действие не входит в список или объект не загружен. Это не означает, что любая ошибка должна раскрываться клиенту одинаковым текстом. Внешний ответ может быть <code>404</code> для сокрытия существования объекта, а внутреннее событие сохраняет безопасный класс причины.'),
p('Allowlist действий легче проверить, чем набор исключений. Для каждого разрешения нужны субъект, действие и условие объекта. Если правило не записано, оно не должно появляться из ветки <code>else</code>, которая «на всякий случай» пропускает запрос.'),
h2('Учебный endpoint с проверкой владельца'),
p('Вход примера — заголовок роли и путь с идентификатором профиля. Два профиля заранее заданы в памяти процесса. Ожидаемый результат: пользователь <code>u-1</code> получает 200 для своего профиля и 403 для <code>u-2</code>; администратор читает аудит. Это runnable локальный HTTP-обмен, но не хранилище пользователей и не готовая схема токенов.'),
code([
"import { createServer } from 'node:http';",
'',
"const profiles = new Map([['u-1', { owner: 'u-1' }], ['u-2', { owner: 'u-2' }]]);",
'',
"const server = createServer((request, response) => {",
" const subject = request.headers['x-subject'];",
" const role = request.headers['x-role'];",
" const id = new URL(request.url, 'http://local').searchParams.get('id');",
' const profile = profiles.get(id);',
" const resource = id === 'audit' ? { kind: 'audit' } : profile && { kind: 'profile', owner: profile.owner };",
" const allowed = (role === 'admin' && resource?.kind === 'audit') || (role === 'user' && resource?.kind === 'profile' && resource.owner === subject);",
" response.writeHead(allowed ? 200 : 403, { 'content-type': 'application/json' });",
" response.end(JSON.stringify({ allowed }));",
'});',
'',
"server.listen(0, '127.0.0.1', async () => {",
' const address = server.address();',
' const port = address.port;',
" const headers = { 'x-subject': 'u-1', 'x-role': 'user' };",
" console.log((await fetch('http://127.0.0.1:' + port + '/profile?id=u-1', { headers })).status);",
" console.log((await fetch('http://127.0.0.1:' + port + '/profile?id=u-2', { headers })).status);",
' server.close();',
'});',
].join('\n')),
p('Вывод — <code>200</code>, затем <code>403</code>. Входной <code>id</code> выбирает объект, но не его владельца: owner берётся из Map. В настоящем приложении эту запись возвращает репозиторий с проверкой tenant boundary. Если сначала выбрать объект без ограничения tenant, последующая проверка роли уже слишком поздняя.'),
h2('Роль не должна скрывать объектное правило'),
p('Администратор часто имеет более широкое разрешение, но это тоже должно быть явной строкой политики: ресурс, действие и область. Нельзя сделать «admin всегда всё». Для аудита, персональных данных и действий изменения нужны отдельные права и логирование решения. Чем шире роль, тем дороже ошибка в middleware.'),
p('Веб-форма и API должны применять одну и ту же политику. Скрыть кнопку недостаточно: запрос можно отправить вручную. Проверка в handler или policy layer обязательна, а UI только улучшает понятность. Результат проверки не нужно доверять клиенту — клиент получает только ответ.'),
h2('Матрица отказов'),
table('Проверяемые комбинации', ['Субъект', 'Объект', 'Действие', 'Решение'], [
['user u-1', 'profile u-1', 'read', 'allow'],
['user u-1', 'profile u-2', 'read', 'deny'],
['user u-1', 'audit', 'read', 'deny'],
['admin a-1', 'audit', 'read', 'allow'],
['unknown', 'profile u-1', 'read', 'deny'],
]),
h2('Порядок построения политики'),
ol([
'Описать один synthetic path label без адресов, ролей, способов доступа, последовательности и утверждений о прошлом.',
'Отделить mapping path от выбора control: первое остаётся <code>not-mapped</code>, второе — <code>not-selected</code>.',
'Записать evidence как <code>not-collected</code>; не заменять его ссылкой на стандарт или общим ожиданием.',
'Для каждого будущего claim заранее назвать необходимое условие: объект, границу, method и допустимое свидетельство.',
'Проверить evaluator отрицательными literals: claimed path, claimed coverage и claimed evidence обязаны остановиться.',
'Передавать только вопрос о следующем scope, а не оценку, что он должен доказать.',
'Назвать субъект, объект и действие для каждого защищаемого endpoint.',
'Определить доверенный источник subjectId, role и ownerId.',
'Сделать deny результатом по умолчанию для неизвестной комбинации.',
'Добавить allow-правила по одному и проверить чужой объект, неизвестную роль и запрещённое действие.',
'Разделить внешний код ответа и внутреннюю безопасную причину отказа.',
'Проверить handler напрямую, без UI, чтобы запрос нельзя было обойти скрытой кнопкой.',
]),
h2('Граница между схемой и решением'),
p('Схема полезна, когда сокращает число неверных интерпретаций. Она становится опасной, когда её компактность скрывает отсутствие данных. В P114 стрелка на SVG означает связь статусов, а не перемещение данных, привилегий или команд. Цвет stop не обозначает блокирование действия в реальной системе; это правило evaluator. Такая подпись важнее художественной точности, потому что читатель не должен переносить учебную метафору в production.'),
p('Механизм также не создаёт модель рисков, не сравнивает варианты и не выдаёт рекомендацию по контролям. Он не может оценить достаточность, потому что у него нет шкалы, входов и target. Статья не прячет эти отсутствия под словом «coverage». Если будущий scope выберет другой язык или отменит вопрос, P114 не станет ошибочным: он изначально передаёт не ответ, а условия честного ответа.'),
h2('Следующее действие'),
p('Следующее действие ограничено новым authorized scope, если конкретному решению потребуется evidence. В нём отдельно определяются допустимые объект, граница, метод, хранение материалов и правило отрицательного результата. Его результат не добавляется задним числом в P114. До этого механический итог один: strict evaluator возвращает <code>synthetic-plan-hand-off</code> и <code>productionEffect: not-attempted</code>; все положительные claims fail-closed.'),
h2('Ограничения и следующий шаг'),
p('Пример использует заголовки вместо реального механизма аутентификации и Map вместо базы. Он показывает объектную проверку, но не решает CSRF, срок токена, tenant isolation или кэширование ответа. В проекте необходимо проверить границу каждого промежуточного слоя и не кэшировать чужой ответ под общим ключом.'),
p('Следующий шаг — взять один endpoint с параметром <code>id</code>, выписать матрицу субъекты × действия × объекты и добавить тест на соседнего пользователя. Если правило нельзя выразить в таблице, его будет трудно проверить и сопровождать.'),
], refs);
const field = revision({
slug: 'editorial-2027-08-field-security-capstone', title: 'Большой разбор безопасности: evidence hand-off без вымышленной проверки', categories: ['Безопасность', 'Практика команд'], cover: '/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg', excerpt: 'План на август 2027: как передать вопрос о безопасности без истории об incident, тесте или закрытом пути.', readingMinutes: 25,
slug: 'editorial-2027-08-field-security-capstone',
title: 'Проверка безопасности без чекбоксов: связываем требование, тест и результат',
categories: ['Безопасность', 'Качество'],
cover: '/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg',
excerpt: 'Как превратить security-требование в проверяемую матрицу с отрицательными случаями и понятной границей.',
readingMinutes: 14,
}, [
p('P114 — сценарий на август 2027, составленный 2026-07-31. В полевом hand-off самая дорогая ошибка — отдать следующему человеку документ, который выглядит как итог: в нём есть заголовок, схема, слова о контроле и аккуратный список следующей работы. Получатель естественно предполагает, что кто-то уже видел предмет и проверил границы. Цена — будущая работа стартует с ложной памяти, а не с честного вопроса.'),
p('Вторая цена — перепутать передачу evidence с поручением провести безопасность. У P114 нет incident, реального security-test, scan, finding, advisory, attack, exploit, ticket, owner, production effect или закрытого path. Нет и положительной ветки кроме <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>. Полевая статья не сообщает, что проверить; она показывает, как не сделать отсутствие материалов основанием для уверенного narrative.'),
h2('Hand-off передаёт границу незнания'),
p('Пакет пригоден к передаче, когда его можно прочитать без домысливания. В P114 названы только дата редактора, плановый месяц, source cutoff и три synthetic labels. Их достаточно, чтобы определить временную и смысловую рамку. Asset classification остаётся <code>not-declared</code>, boundary и path — <code>not-mapped</code>, coverage — <code>not-selected</code>, evidence — <code>not-collected</code>. Эти значения не просят следующего человека «дозаполнить форму»; они запрещают считать поля заполненными.'),
p('Особое место занимает адресат. В живой передаче хочется поставить owner, команду и дедлайн. Здесь это было бы фиктивным организационным действием. Literal хранит <code>recipient: not-assigned</code> и <code>route: not-opened</code>. Это не дефект управления, а защита от тайного назначения: до нового scope не существует согласованной роли, очереди или обещания выполнить проверку. Можно передать сам вопрос, но нельзя передать чужую обязанность.'),
table('Состав hand-off без выдуманного нарратива', ['Часть', 'Что передаётся', 'Чего нет в P114'], [
['Время', 'editorial date, planning issue, source cutoff', 'История события или будущий результат'],
['Предмет', 'только synthetic labels', 'Система, данные, target или закрытая граница'],
['Статусы', 'not-declared / not-mapped / not-selected', 'Оценка важности или coverage'],
['Evidence', '<code>not-collected</code>', 'Отчёт, finding, scan или test output'],
['Адресация', '<code>not-assigned</code>', 'Owner, ticket, очередь или обязательство'],
p('Строка «авторизация проверена» не говорит, что именно проверялось. Цена ошибки — закрыть задачу после happy path и не заметить чужой объект, неизвестную роль или изменение метода. Без связи между требованием, входом, ожидаемым результатом и фактическим выводом security-проверка становится списком галочек.'),
p('Соберём небольшую матрицу проверки для политики профиля: положительный случай, два отказа и неизвестная роль. Каждая строка содержит условие и ожидаемый ответ. Код запускается локально через <code>node:assert/strict</code>, поэтому результат можно повторить без доступа к данным или сети.'),
h2('У требования должна быть проверяемая форма'),
p('Требование «пользователь видит только свой профиль» превращается в четыре части: субъект, объект, действие и ожидаемый эффект. Успешная строка проверяет владельца, отрицательная — другого владельца, а boundary-случай проверяет отсутствие объекта или роль. Положительный тест без отказов не показывает, что правило действительно ограничивает доступ.'),
table('Структура записи проверки', ['Поле', 'Пример', 'Почему нужно'], [
['requirementId', 'AUTH-PROFILE-01', 'стабильная ссылка на правило'],
['input', 'user u-1 → profile u-2', 'что именно подали'],
['expected', 'deny / 403', 'критерий решения'],
['actual', 'deny / 403', 'полученный результат'],
['evidence', 'assertion output', 'чем подтверждён вывод'],
]),
h2('Почему красивый narrative ломает evidence'),
p('Narrative начинает вредить не тогда, когда он явно выдуман. Обычно он появляется как связка: «поэтому нужен контроль», «после проверки стало понятно», «дальше владелец закроет». В каждой такой фразе спрятаны объект, метод, наблюдение и полномочие. Если их нет в record, история лишает будущего reviewer возможности отказаться от исходной постановки. Он уже должен спорить не с вопросом, а с ощущением, что команда что-то обещала.'),
p('Полевой формат должен сохранять возможность отрицательного ответа. Новый scope может решить, что предмет не входит в его границы, что evidence нельзя собирать или что вопрос не влияет на решение. Это не провал hand-off. Напротив, это его проверка: корректная передача не подталкивает получателя к заранее заданному выводу. P114 не содержит скрытой метрики успеха и не использует «закрыть» как эвфемизм для будущего положительного результата.'),
figure('/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg', 'Петля передачи: fixed literal проходит проверку времени, отсутствия claims и отсутствия назначения; при любом claim он останавливается, иначе передаётся только синтетический плановый вопрос.', 'Петля не описывает incident response или security review. Она визуализирует редакционное fail-closed правило передачи без owner и evidence.'),
h2('Источники остаются словарём, не приложением к делу'),
p('NIST SSDF Version 1.1 уместен в hand-off как официальный общий словарь для коммуникации вокруг безопасной разработки. Но его текст не сообщает, что участники P114 существуют, договорились или выполнили практику. Это важно написать в самом артефакте: ссылка не становится evidence, даже если она ведёт на нормативную публикацию и точно датирована.'),
p('NIST SP 800-53 Rev. 5 полезен для различения контроля и уверенности в нём. В field-материале это помогает не превратить колонку «control» в колонку «подтверждено». При этом каталог предназначен для управления риском в организационном процессе; он не назначает здесь метод, адресата или срок. Мы используем публикацию как границу интерпретации, не как разрешение на любые действия.'),
h2('Runnable hand-off остаётся в памяти'),
p('Пример ниже не создаёт файл, не читает окружение, не получает время, не обращается к сети и не вызывает инструмент безопасности. Он получает fixed literal, возвращает только fields hand-off и печатает их. В данных нет секрета, target, реального имени, конфигурации или телеметрии. Поэтому запуск демонстрирует fail-closed интерфейс, а не фиксирует состояние чего-либо вне процесса Node.'),
code("import { inspectSecurityHandoffLiteral } from './upgrade-2027-08.mjs';\n\nconst handoff = inspectSecurityHandoffLiteral();\nconsole.log(handoff.status, handoff.recipient);\n// synthetic-plan-hand-off not-assigned"),
p('Здесь <code>not-assigned</code> не предлагает анонимно выполнить работу. Он делает невозможной подмену «следующего шага» реальным назначением. Единственное следующее действие строкой evaluator — открыть отдельный authorized scope, только если решение требует evidence. При неизвестном объекте assessor возвращает stop. При known literal с claimed evidence — тоже stop. Позитивный статус не повышается до compliance, protection или production effect.'),
h2('Получатель должен получить право отказаться'),
p('Передача без права отказаться — это скрытая эскалация. В ней вопрос уже сформулирован как обязательная работа, а отсутствие owner маскируется словами «следующая команда разберётся». P114 действует иначе: recipient не назначен, route не открыт, а next action условен. Будущий участник может принять решение об отдельном scope, сузить вопрос или признать, что evidence не нужно для доступного выбора. Все три исхода совместимы с корректной передачей.'),
p('Эта свобода не отменяет ответственность автора. Автор отвечает за то, чтобы не добавить деталей, которые делают отказ психологически невозможным: мнимой срочности, конкретного target, обещанной выгоды или намёка на уже выявленную проблему. Чем точнее он удерживает исходную неизвестность, тем легче следующему человеку оценить цену будущего исследования. В field-формате ценность не в количестве заполненных полей, а в отсутствии долга, созданного чужими выводами.'),
h2('Как сохранить provenance пустого поля'),
p('Пустое по смыслу поле тоже имеет происхождение. <code>not-collected</code> означает не «данные были и потерялись», а «этот пакет их не получает и не имитирует». <code>not-selected</code> не означает отказ существующей команды, а отсутствие выбора в literal. <code>not-assigned</code> не означает скрытого владельца. Эти различия должны пережить копирование карточки: иначе следующий редактор сделает из отрицательного статуса пробел для собственной догадки.'),
p('Поэтому hand-off не прикладывает образцы журналов, снимки экрана, фрагменты конфигурации и правдоподобные команды. Даже обезличенная деталь способна стать якорем: читатель начнёт считать её representative и подгонит будущую работу под этот образец. В P114 единственный безопасный artefact — fixed literal и его evaluator output. Они имеют ясное происхождение внутри модуля и не претендуют на происхождение вне него.'),
h2('Передача не должна менять класс документа'),
p('Самый незаметный сбой hand-off происходит при редактуре. Автор начинает с карточки вопроса, а затем добавляет в письмо краткое объяснение «почему это важно». Если объяснение содержит предполагаемый ход событий, оно меняет класс всего пакета: у получателя появляется narrative, которого нет в literal. Поэтому P114 требует проверять не только JSON-подобную форму, но и prose вокруг неё. Текст должен повторять границу, а не обогащать её удобными деталями.'),
p('Это относится и к визуалу. Диаграмма может быть доступной, аккуратной и всё равно чрезмерно конкретной. Иконка замка, сервер с подписью или стрелка между известными слоями быстро превращают план в якобы наблюдённую архитектуру. В наших SVG названы статусы и проверки, а не участники системы. Русские alt и подписи прямо говорят, что изображение показывает правило редакционного маршрута. Такая избыточная явность нужна для мобильного чтения, где подпись часто читают раньше основного объяснения.'),
h2('Что получатель может изменить, не ломая P114'),
p('Получатель может отказаться от вопроса, запросить новый scope или определить, что для решения достаточно других материалов. Он не может изменить старый literal добавлением своего target, evidence или owner, а затем сослаться на P114 как на первичную запись. Новая конкретика должна жить в новом artefact со своей датой и правилами. Это различие не формальность: оно сохраняет цепочку происхождения. Через месяц нельзя будет перепутать будущий вывод с тем, что было известно на редакционную дату.'),
p('Если будущий scope всё же появляется, его первая проверка не обязана быть технической. Он может остановиться на проверке полномочий, данных или необходимости. P114 заранее оставляет такой выход корректным. Ему не нужен искусственный положительный результат, чтобы оправдать существование серии. Техлид получает более полезный сигнал: вопрос дошёл до нужной границы и не начал потреблять доступ, время или доверие без явного решения.'),
h2('Порядок передачи без поручения и без теста'),
figure('/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg', 'Петля проверки безопасности: требование, вход, отрицательный тест, результат и разбор расхождения.', 'Петля возвращает строку в проверку, если фактический результат отличается от ожидаемого. PASS не скрывает отрицательные случаи.'),
h2('Тестируем отказ первым классом результата'),
p('В security-проверке deny — не исключение теста, а ожидаемый результат для запрещённого входа. Поэтому код должен проверять как статус, так и безопасную причину. Если функция возвращает только boolean, диагностировать обход сложнее: неизвестная роль, чужой объект и недопустимое действие смешиваются.'),
p('Набор тестов должен быть малым, но разнонаправленным. Один тест показывает разрешение владельцу, второй — горизонтальную границу, третий — вертикальную роль, четвёртый — неизвестный input. Для каждого случая сохраняйте название, а не только число пройденных assertions.'),
h2('Учебная матрица и assert'),
p('Вход функции — объект запроса и запись профиля. Ожидаемый вывод содержит статус <code>allow</code> или <code>deny</code> и безопасную причину. Пример запускается одной командой Node и печатает имя каждого случая. Он не читает файл, сеть или секреты; его задача — показать форму проверяемого security-контракта.'),
code([
"import assert from 'node:assert/strict';",
'',
'function decide({ role, subject, resource }) {',
" if (role === 'admin' && resource.kind === 'audit') return { status: 'allow', reason: 'admin-audit' };",
" if (role === 'user' && resource.kind === 'profile' && resource.owner === subject) return { status: 'allow', reason: 'owner' };",
" return { status: 'deny', reason: 'default-deny' };",
'}',
'',
'const cases = [',
" ['owner can read', { role: 'user', subject: 'u-1', resource: { kind: 'profile', owner: 'u-1' } }, { status: 'allow', reason: 'owner' }],",
" ['foreign owner is denied', { role: 'user', subject: 'u-1', resource: { kind: 'profile', owner: 'u-2' } }, { status: 'deny', reason: 'default-deny' }],",
" ['unknown role is denied', { role: 'guest', subject: 'u-1', resource: { kind: 'profile', owner: 'u-1' } }, { status: 'deny', reason: 'default-deny' }],",
'];',
'',
'for (const [name, input, expected] of cases) {',
' assert.deepEqual(decide(input), expected, name);',
" console.log('PASS', name);",
'}',
].join('\n')),
p('Запуск печатает три строки <code>PASS</code>. Важна не сама библиотека assert, а форма входа и ожидаемого результата. Добавьте четвёртую строку для администратора и пятую для неизвестного ресурса, если эти ветки входят в ваш контракт. Не называйте отсутствие теста доказательством отсутствия уязвимости.'),
h2('Матрица связывает код и источник'),
p('Идентификатор требования должен вести к месту кода, а название теста — к конкретной комбинации входов. Для одной политики допустимы несколько тестов. Если проверка падает, сохраняются diff: какой вход подали, какой ответ получили и какое правило ожидали. Это позволяет исправить policy, test или само требование, не меняя их незаметно одновременно.'),
p('Стандарт не задаёт ваш список ролей и не решает, какой ответ показывать внешнему пользователю. ASVS полезен как версионируемый словарь требований, а NIST SSDF — как рамка процесса безопасной разработки. Фактический вывод появляется только из вашего кода, тестовых данных и запуска.'),
h2('Отрицательные случаи важнее красивого PASS'),
table('Минимальный набор входов', ['Случай', 'Ожидаемое решение', 'Ошибка при обходе'], [
['свой профиль', 'allow', 'сломанный доступ к легитимному действию'],
['чужой профиль', 'deny', 'горизонтальная эскалация'],
['audit для user', 'deny', 'избыточная роль'],
['неизвестная роль', 'deny', 'fail-open по умолчанию'],
['неизвестный ресурс', 'deny или 404', 'утечка существования'],
]),
h2('Порядок подготовки проверки'),
ol([
'Проверить три даты и слово scenario: P114 относится к плану 2027-08, а не к прошедшему событию.',
'Оставить asset, boundary и path в named fixed статусах; убрать реалистичные имена, маршруты и детали доступа.',
'Отметить control coverage как <code>not-selected</code>, evidence как <code>not-collected</code>, deployment как <code>not-attempted</code>.',
'Не добавлять owner, ticket, очередь, дедлайн или обязательство: recipient должен остаться <code>not-assigned</code>.',
'Запустить fixture и убедиться, что claimed path, control, evidence и effect не проходят как предупреждения.',
'Открывать новый scope только при решении, которому необходимы материалы; иначе сохранить hand-off без развития narrative.',
'Взять одно требование и записать его субъект, действие, объект и ожидаемый ответ.',
'Составить положительный и минимум два отрицательных входа.',
'Закрепить версию требования и имя теста, чтобы изменение стандарта было заметно.',
'Запустить тест без UI и сохранить понятный вывод каждой строки.',
'При падении сравнить фактический input, policy branch и expected result.',
'Проверить, что тест не использует секреты, чужие данные и случайное внешнее состояние.',
]),
h2('Ограничения полевой формы'),
p('Этот hand-off не является review, audit, assessment, test plan или доказательством соответствия. Он не говорит, что в системе есть проблема или что проблема отсутствует. Он не определяет источник данных, способ доступа, хранение artefacts, ответственность или критерий завершения. Если читателю нужен один из этих элементов, он должен признать, что просит работу за пределами P114, а не искать его между строк.'),
p('Ограничение особенно полезно для безопасности, где аккуратный документ легко воспринимается как результат. Мы оставляем дизайн немного неудобным: он не показывает эффект, не называет победителя и не даёт команды. Зато он не переносит последствия воображаемой проверки на будущую команду. Это и есть минимальная цена честного evidence hand-off — оставить неизвестность видимой до того, как она получит законное происхождение.'),
h2('Следующее действие'),
p('Следующее действие не меняется: новый authorized scope можно открыть только при конкретной потребности в evidence. Он обязан задать собственные временные границы, разрешённые inputs, метод, владельца и способ завершиться без положительного вывода. Его факты останутся в новом artefact, а P114 сохранит роль исходного вопроса. До этого единственный допустимый output — <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>.'),
h2('Ограничения и следующий шаг'),
p('Локальная функция не заменяет интеграционную проверку middleware, токенов, базы и кэша. Она не показывает race condition или ошибку маршрутизации. Модель угроз может потребовать отдельные проверки CSRF, SSRF, rate limit и журналирования. Матрица фиксирует границу, но не расширяет её автоматически.'),
p('Следующим шагом привяжите одну строку матрицы к реальному handler и добавьте тест прямого HTTP-вызова с чужим идентификатором. Сохраните ожидаемый статус и безопасный класс причины. После этого можно добавлять требования, не теряя отрицательные случаи в общей массе зелёных тестов.'),
], refs);
export const revisions = deepFreeze([practice, mechanism, field]);
export function verifyRevisionsAgainstFixture() {
const fixture = runSecurityCapstoneFixture();
const articleChecks = revisions.map((item) => {
const text = bodyText(item.contentHtml);
return text.length >= 10000 && text.length <= 13000 && /(цен[аы]|стоимост|дорог|потер)/i.test(text.slice(0, 1300)) && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && /2027-08/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text);
return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|затрат|потер)/i.test(text.slice(0, 1100)) && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml);
});
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
}
if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; }
if (process.argv.includes('--verify-fixture')) {
const result = verifyRevisionsAgainstFixture();
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (!result.accepted) process.exitCode = 1;
}
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');