328 lines
38 KiB
JavaScript
328 lines
38 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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');
|