Files
progcode/web/scripts/upgrade-2027-08.mjs
2026-07-31 22:26:56 +03:00

328 lines
38 KiB
JavaScript
Raw Permalink 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="' + 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 deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.values(value).forEach(deepFreeze);
Object.freeze(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>|$)/, ''));
}
const REFERENCES = deepFreeze({
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>';
}).join('') + '</ul>';
}
const FIXED_SECURITY_CARDS = deepFreeze({
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 = 'publicProfile') {
return FIXED_SECURITY_CARDS[id] ? deepFreeze(cloneFixed(FIXED_SECURITY_CARDS[id])) : undefined;
}
export function assessSecurityCapstoneCard(input) {
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({ 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 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 < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength);
return deepFreeze({ ...meta, contentHtml, proseLength });
}
const refs = [
{ key: 'ssdf', use: 'Даёт общий словарь практик безопасной разработки и связывает защиту с жизненным циклом продукта.', boundary: 'Рамка не подтверждает наличие контроля, уязвимости или результата в конкретном приложении.' },
{ key: 'asvs', use: 'Даёт версионируемые требования для проверки технических контролей веб-приложения.', boundary: 'Стандарт не заменяет модель угроз, настройку инфраструктуры и тестирование конкретных данных.' },
{ key: 'controls', use: 'Помогает различить сам контроль и уверенность в его работе.', boundary: 'Каталог не выбирает права вашего ресурса и не является отчётом о соответствии.' },
];
const practice = revision({
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('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'],
]),
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([
'Назвать допустимые домены, схемы и порты в конфигурации, а не принимать их из запроса.',
'Распарсить URL стандартным URL-парсером и проверить hostname после нормализации.',
'Запретить credentials, неожиданные схемы и redirect по умолчанию.',
'Ограничить timeout, размер ответа и число сетевых попыток.',
'Добавить тесты на @, похожий домен, IP, IPv6, другой порт и redirect.',
'Сохранить причину отказа без полного URL с секретными параметрами.',
]),
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: 'Аутентификация не даёт доступ: строим deny-by-default для объекта',
categories: ['Безопасность', 'Архитектура'],
cover: '/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg',
excerpt: 'Разбираем, почему проверка логина без проверки объекта оставляет горизонтальную эскалацию прав.',
readingMinutes: 14,
}, [
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'],
]),
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([
'Назвать субъект, объект и действие для каждого защищаемого endpoint.',
'Определить доверенный источник subjectId, role и ownerId.',
'Сделать deny результатом по умолчанию для неизвестной комбинации.',
'Добавить allow-правила по одному и проверить чужой объект, неизвестную роль и запрещённое действие.',
'Разделить внешний код ответа и внутреннюю безопасную причину отказа.',
'Проверить handler напрямую, без UI, чтобы запрос нельзя было обойти скрытой кнопкой.',
]),
h2('Ограничения и следующий шаг'),
p('Пример использует заголовки вместо реального механизма аутентификации и Map вместо базы. Он показывает объектную проверку, но не решает CSRF, срок токена, tenant isolation или кэширование ответа. В проекте необходимо проверить границу каждого промежуточного слоя и не кэшировать чужой ответ под общим ключом.'),
p('Следующий шаг — взять один endpoint с параметром <code>id</code>, выписать матрицу субъекты × действия × объекты и добавить тест на соседнего пользователя. Если правило нельзя выразить в таблице, его будет трудно проверить и сопровождать.'),
], refs);
const field = revision({
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('Строка «авторизация проверена» не говорит, что именно проверялось. Цена ошибки — закрыть задачу после 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', 'чем подтверждён вывод'],
]),
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([
'Взять одно требование и записать его субъект, действие, объект и ожидаемый ответ.',
'Составить положительный и минимум два отрицательных входа.',
'Закрепить версию требования и имя теста, чтобы изменение стандарта было заметно.',
'Запустить тест без UI и сохранить понятный вывод каждой строки.',
'При падении сравнить фактический input, policy branch и expected result.',
'Проверить, что тест не использует секреты, чужие данные и случайное внешнее состояние.',
]),
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 >= 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('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');