Files
progcode/web/scripts/upgrade-2023-12.mjs
huncode 201dc7bd54
Build and deploy / deploy (push) Successful in 15s
revise December 2023 security audit articles
2026-07-31 15:24:14 +03:00

570 lines
69 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="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const sources = [
{
title: 'NIST SP 800-115: Technical Guide to Information Security Testing and Assessment, сентябрь 2008',
url: 'https://csrc.nist.gov/pubs/sp/800/115/final',
note: 'Первичный документ NIST о планировании, проведении, анализе и mitigation технических проверок. Это обзор 2008 года: он не заменяет актуальный договор об авторизации, не задаёт scope конкретного веб-проекта и не подтверждает результаты этого учебного fixture.',
},
{
title: 'OWASP Web Security Testing Guide v4.2, 3 декабря 2020',
url: 'https://owasp.org/www-project-web-security-testing-guide/v42/',
note: 'Версионированный официальный guide описывает testing framework, mapping архитектуры и WSTG identifiers. Он не даёт права выполнять тест, не является перечнем всех активов и не доказывает coverage или finding в чужой системе.',
},
{
title: 'OWASP Application Security Verification Standard 4.0.3, 28 октября 2021',
url: 'https://owasp.org/www-project-application-security-verification-standard/',
note: 'Официальный ASVS задаёт требования к верификации приложения. Версия 4.0.3 существовала к концу 2023 года, но стандарт не выдает сертификат, не подменяет evidence и не делает учебную запись compliance-результатом.',
},
{
title: 'RFC 9116: security.txt, апрель 2022',
url: 'https://www.rfc-editor.org/rfc/rfc9116.html',
note: 'Информационный RFC описывает канал disclosure и прямо оговаривает отсутствие подразумеваемого разрешения на testing. security.txt относится к домену или IP, из которого он получен; сам по себе он не расширяет scope и не создаёт authorization.',
},
{
title: 'FIRST: Common Vulnerability Scoring System v3.1, июнь 2019',
url: 'https://www.first.org/cvss/v3-1/specification-document',
note: 'Первичная спецификация различает Base, Temporal и Environmental metrics и называет CVSS входом для management process. Она не учитывает автоматически владельца, обратимость изменения, доказательство, разрешённые границы или бизнес-контекст конкретной команды.',
},
];
function sourceList() {
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function revision(meta, parts) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
}
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const MODEL_LIMIT = 'in-memory-fixed-marked-synthetic-audit-records-no-scan-no-brute-force-no-url-access-no-network-no-code-read-no-log-read-no-secret-read-no-security-test-no-vulnerability-no-coverage-no-compliance';
const SYNTHETIC_SCOPE_ID = 'synthetic-web-audit-scope-2023-12-A';
const SYNTHETIC_WEB_OWNER = 'synthetic-web-owner';
const SYNTHETIC_IDENTITY_OWNER = 'synthetic-identity-owner';
const EXPECTED_AUTHORIZATION = Object.freeze({
id: 'synthetic-declared-boundary-record-2023-12-A',
kind: 'synthetic-declared-boundary-v1',
scopeId: SYNTHETIC_SCOPE_ID,
mode: 'synthetic-inventory-and-record-review-only',
realPermission: 'not-granted',
ownerStep: 'synthetic-owner-approval-required-outside-fixture',
});
const EXPECTED_ASSETS = Object.freeze([
Object.freeze({
id: 'synthetic-asset-browser-ui',
kind: 'synthetic-browser-ui',
boundary: 'synthetic-public-browser-entry',
owner: SYNTHETIC_WEB_OWNER,
dataClass: 'synthetic-session-metadata',
reviewQuestion: 'synthetic-map-before-review',
}),
Object.freeze({
id: 'synthetic-asset-web-api',
kind: 'synthetic-web-api',
boundary: 'synthetic-browser-to-api',
owner: SYNTHETIC_WEB_OWNER,
dataClass: 'synthetic-account-operation-data',
reviewQuestion: 'synthetic-confirm-authz-boundary',
}),
Object.freeze({
id: 'synthetic-asset-identity-broker',
kind: 'synthetic-identity-broker',
boundary: 'synthetic-third-party-identity-boundary',
owner: SYNTHETIC_IDENTITY_OWNER,
dataClass: 'synthetic-identity-claims',
reviewQuestion: 'synthetic-confirm-owner-and-contract',
}),
Object.freeze({
id: 'synthetic-asset-upload-delivery',
kind: 'synthetic-upload-delivery',
boundary: 'synthetic-upload-to-delivery-boundary',
owner: SYNTHETIC_WEB_OWNER,
dataClass: 'synthetic-user-upload-metadata',
reviewQuestion: 'synthetic-confirm-storage-and-serving-boundary',
}),
]);
const EXPECTED_EVIDENCE = Object.freeze([
Object.freeze({
id: 'synthetic-evidence-scope-map',
kind: 'synthetic-scope-card',
statement: 'synthetic-asset-map-recorded',
status: 'synthetic-not-externally-verified',
owner: SYNTHETIC_WEB_OWNER,
}),
Object.freeze({
id: 'synthetic-evidence-owner-route',
kind: 'synthetic-owner-route-card',
statement: 'synthetic-identity-owner-recorded',
status: 'synthetic-not-externally-verified',
owner: SYNTHETIC_IDENTITY_OWNER,
}),
Object.freeze({
id: 'synthetic-evidence-permission-limit',
kind: 'synthetic-permission-limit-card',
statement: 'synthetic-record-is-not-test-permission',
status: 'synthetic-not-externally-verified',
owner: SYNTHETIC_WEB_OWNER,
}),
Object.freeze({
id: 'synthetic-evidence-remediation-gate',
kind: 'synthetic-remediation-gate-card',
statement: 'synthetic-owner-review-before-change',
status: 'synthetic-not-externally-verified',
owner: SYNTHETIC_WEB_OWNER,
}),
]);
const EXPECTED_TRIAGE = Object.freeze([
Object.freeze({
id: 'synthetic-triage-authz-boundary',
evidence: 'synthetic-evidence-gap',
exposure: 'synthetic-public-entry',
reversibility: 'synthetic-reversible-mitigation-draft',
action: 'synthetic-owner-review-before-change',
}),
Object.freeze({
id: 'synthetic-triage-identity-contract',
evidence: 'synthetic-owner-confirmation-needed',
exposure: 'synthetic-third-party-boundary',
reversibility: 'synthetic-contract-review-before-change',
action: 'synthetic-confirm-scope-and-owner',
}),
Object.freeze({
id: 'synthetic-triage-upload-serving',
evidence: 'synthetic-test-plan-not-written',
exposure: 'synthetic-user-content-boundary',
reversibility: 'synthetic-rollback-plan-required',
action: 'synthetic-write-reversible-plan',
}),
]);
function hasExactKeys(value, keys) {
return value && typeof value === 'object' && !Array.isArray(value)
&& Object.keys(value).length === keys.length
&& keys.every((key) => Object.hasOwn(value, key));
}
function matchesExpectedRecord(value, expected) {
return hasExactKeys(value, Object.keys(expected))
&& Object.entries(expected).every(([key, expectedValue]) => value[key] === expectedValue);
}
function matchesExpectedRecords(values, expected) {
return Array.isArray(values)
&& values.length === expected.length
&& Object.keys(values).length === expected.length
&& expected.every((record, index) => Object.hasOwn(values, index) && matchesExpectedRecord(values[index], record));
}
function rejectSyntheticAuditPacket(reason) {
return Object.freeze({
kind: 'synthetic-security-audit-fixture-v1',
syntheticOnly: true,
accepted: false,
reason,
modelLimit: MODEL_LIMIT,
});
}
/**
* Учебная функция создаёт и проверяет только заранее заданные marked synthetic
* audit records в памяти Node. Она не сканирует и не перебирает ничего, не
* обращается к URL или сети, не читает код, логи, secrets, environment, часы,
* проект, Git или CI. Она не запускает security test и не утверждает finding,
* vulnerability, coverage, compliance, permission либо результат для реального
* веб-проекта. Название authorization ниже — лишь форма учебной записи; поле
* realPermission намеренно равно not-granted.
*/
export function createSyntheticSecurityAuditPacket(input) {
if (!input || input.synthetic !== true || input.kind !== 'synthetic-security-audit-input-v1') {
return rejectSyntheticAuditPacket('synthetic-input-required');
}
const allowedTopLevel = ['synthetic', 'kind', 'authorization', 'assets', 'evidence', 'triage'];
if (!hasExactKeys(input, allowedTopLevel)) return rejectSyntheticAuditPacket('unexpected-input-field');
if (!matchesExpectedRecord(input.authorization, EXPECTED_AUTHORIZATION)) {
return rejectSyntheticAuditPacket('synthetic-declared-boundary-rejected');
}
if (!matchesExpectedRecords(input.assets, EXPECTED_ASSETS)) {
return rejectSyntheticAuditPacket('synthetic-asset-map-rejected');
}
if (!matchesExpectedRecords(input.evidence, EXPECTED_EVIDENCE)) {
return rejectSyntheticAuditPacket('synthetic-evidence-matrix-rejected');
}
if (!matchesExpectedRecords(input.triage, EXPECTED_TRIAGE)) {
return rejectSyntheticAuditPacket('synthetic-triage-route-rejected');
}
const snapshot = Object.freeze({
scopeId: SYNTHETIC_SCOPE_ID,
declaredBoundaryId: EXPECTED_AUTHORIZATION.id,
assetIds: Object.freeze(input.assets.map((asset) => asset.id)),
evidenceIds: Object.freeze(input.evidence.map((evidence) => evidence.id)),
triageIds: Object.freeze(input.triage.map((card) => card.id)),
});
const remediationRoute = Object.freeze([
Object.freeze({
position: 1,
cardId: 'synthetic-triage-authz-boundary',
gate: 'synthetic-confirm-evidence-and-owner-before-change',
operation: 'not-performed',
}),
Object.freeze({
position: 2,
cardId: 'synthetic-triage-identity-contract',
gate: 'synthetic-confirm-third-party-scope-before-change',
operation: 'not-performed',
}),
Object.freeze({
position: 3,
cardId: 'synthetic-triage-upload-serving',
gate: 'synthetic-write-validation-and-rollback-plan-before-change',
operation: 'not-performed',
}),
]);
return Object.freeze({
kind: 'synthetic-security-audit-fixture-v1',
syntheticOnly: true,
accepted: true,
reason: 'synthetic-audit-packet-assembled',
modelLimit: MODEL_LIMIT,
declaredBoundary: Object.freeze({
id: input.authorization.id,
scopeId: input.authorization.scopeId,
mode: input.authorization.mode,
realPermission: 'not-granted',
testing: 'not-started',
}),
assetMap: Object.freeze(input.assets.map((asset) => Object.freeze({ ...asset }))),
evidenceMatrix: Object.freeze(input.evidence.map((evidence) => Object.freeze({ ...evidence }))),
remediationRoute,
claims: Object.freeze({
vulnerability: 'not-claimed',
coverage: 'not-claimed',
compliance: 'not-claimed',
scan: 'not-performed',
bruteForce: 'not-performed',
securityTest: 'not-run',
}),
limits: Object.freeze({
url: 'not-accessed',
network: 'not-used',
code: 'not-read',
logs: 'not-read',
secrets: 'not-read',
project: 'not-read',
productionEffect: 'not-attempted',
}),
rollback: Object.freeze({ action: 'restore-synthetic-audit-draft', snapshot }),
});
}
export function rollbackSyntheticSecurityAuditPacket(packet) {
if (!packet || packet.accepted !== true || !packet.rollback?.snapshot) {
return Object.freeze({
restored: false,
syntheticOnly: true,
reason: 'no-accepted-synthetic-audit-packet',
});
}
return Object.freeze({
restored: true,
syntheticOnly: true,
reason: 'synthetic-audit-draft-restored',
snapshot: packet.rollback.snapshot,
records: 'not-created-or-deleted-outside-memory',
permission: 'not-granted',
securityTest: 'not-run',
network: 'not-used',
productionEffect: 'not-attempted',
});
}
const validSyntheticInput = Object.freeze({
synthetic: true,
kind: 'synthetic-security-audit-input-v1',
authorization: EXPECTED_AUTHORIZATION,
assets: EXPECTED_ASSETS,
evidence: EXPECTED_EVIDENCE,
triage: EXPECTED_TRIAGE,
});
export function runSecurityAuditFixture() {
const valid = createSyntheticSecurityAuditPacket(validSyntheticInput);
const nonSynthetic = createSyntheticSecurityAuditPacket({ ...validSyntheticInput, synthetic: false });
const extraTopLevel = createSyntheticSecurityAuditPacket({ ...validSyntheticInput, targetHint: 'synthetic-unapproved-target-hint' });
const changedAuthorization = createSyntheticSecurityAuditPacket({
...validSyntheticInput,
authorization: { ...EXPECTED_AUTHORIZATION, realPermission: 'synthetic-granted' },
});
const changedAsset = createSyntheticSecurityAuditPacket({
...validSyntheticInput,
assets: [
{ ...EXPECTED_ASSETS[0], host: 'synthetic-not-allowed' },
...EXPECTED_ASSETS.slice(1),
],
});
const reorderedAsset = createSyntheticSecurityAuditPacket({
...validSyntheticInput,
assets: [EXPECTED_ASSETS[1], EXPECTED_ASSETS[0], ...EXPECTED_ASSETS.slice(2)],
});
const sparseAssets = new Array(EXPECTED_ASSETS.length);
sparseAssets[1] = EXPECTED_ASSETS[1];
sparseAssets[2] = EXPECTED_ASSETS[2];
sparseAssets[3] = EXPECTED_ASSETS[3];
const sparseAssetMap = createSyntheticSecurityAuditPacket({
...validSyntheticInput,
assets: sparseAssets,
});
const rawEvidence = createSyntheticSecurityAuditPacket({
...validSyntheticInput,
evidence: [
{ ...EXPECTED_EVIDENCE[0], rawSecret: 'synthetic-not-allowed' },
...EXPECTED_EVIDENCE.slice(1),
],
});
const changedEvidenceStatus = createSyntheticSecurityAuditPacket({
...validSyntheticInput,
evidence: [
{ ...EXPECTED_EVIDENCE[0], status: 'synthetic-externally-confirmed' },
...EXPECTED_EVIDENCE.slice(1),
],
});
const unsafeTriage = createSyntheticSecurityAuditPacket({
...validSyntheticInput,
triage: [
{ ...EXPECTED_TRIAGE[0], action: 'synthetic-automatic-change' },
...EXPECTED_TRIAGE.slice(1),
],
});
const restored = rollbackSyntheticSecurityAuditPacket(valid);
const rejectedRollback = rollbackSyntheticSecurityAuditPacket(unsafeTriage);
return Object.freeze({
assertions: Object.freeze({
acceptsOnlyFixedSyntheticPacket: valid.accepted === true && valid.reason === 'synthetic-audit-packet-assembled',
keepsDeclaredScopeAndFourAssets: valid.declaredBoundary.scopeId === SYNTHETIC_SCOPE_ID && valid.assetMap.length === 4,
keepsAssetOwnersExplicit: valid.assetMap[0].owner === SYNTHETIC_WEB_OWNER && valid.assetMap[2].owner === SYNTHETIC_IDENTITY_OWNER,
mapsFourDifferentBoundaries: new Set(valid.assetMap.map((asset) => asset.boundary)).size === 4,
makesPermissionBoundaryExplicit: valid.declaredBoundary.realPermission === 'not-granted' && valid.declaredBoundary.testing === 'not-started',
keepsEvidenceAsUnverifiedSyntheticRecords: valid.evidenceMatrix.length === 4 && valid.evidenceMatrix.every((evidence) => evidence.status === 'synthetic-not-externally-verified'),
keepsSafeTriageOrder: valid.remediationRoute.map((item) => item.cardId).join(',') === 'synthetic-triage-authz-boundary,synthetic-triage-identity-contract,synthetic-triage-upload-serving',
keepsEveryTriageOperationManual: valid.remediationRoute.every((item) => item.operation === 'not-performed' && item.gate.includes('before-change')),
doesNotClaimFindingOrCoverage: valid.claims.vulnerability === 'not-claimed' && valid.claims.coverage === 'not-claimed' && valid.claims.compliance === 'not-claimed',
doesNotRunARealSecurityAction: valid.claims.scan === 'not-performed' && valid.claims.bruteForce === 'not-performed' && valid.claims.securityTest === 'not-run',
doesNotAccessProjectData: valid.limits.url === 'not-accessed' && valid.limits.network === 'not-used' && valid.limits.code === 'not-read' && valid.limits.logs === 'not-read' && valid.limits.secrets === 'not-read',
rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-input-required',
rejectsUnexpectedTopLevelInput: extraTopLevel.accepted === false && extraTopLevel.reason === 'unexpected-input-field',
rejectsAClaimedPermission: changedAuthorization.accepted === false && changedAuthorization.reason === 'synthetic-declared-boundary-rejected',
rejectsAssetDataOutsideFixedMap: changedAsset.accepted === false && changedAsset.reason === 'synthetic-asset-map-rejected',
rejectsAssetReorderingWithoutNewContract: reorderedAsset.accepted === false && reorderedAsset.reason === 'synthetic-asset-map-rejected',
rejectsSparseAssetMap: sparseAssetMap.accepted === false && sparseAssetMap.reason === 'synthetic-asset-map-rejected',
rejectsRawEvidencePayload: rawEvidence.accepted === false && rawEvidence.reason === 'synthetic-evidence-matrix-rejected',
rejectsExternallyConfirmedClaim: changedEvidenceStatus.accepted === false && changedEvidenceStatus.reason === 'synthetic-evidence-matrix-rejected',
rejectsAutomaticChangeTriage: unsafeTriage.accepted === false && unsafeTriage.reason === 'synthetic-triage-route-rejected',
rollsBackOnlyTheInMemoryDraft: restored.restored === true && restored.reason === 'synthetic-audit-draft-restored' && restored.securityTest === 'not-run',
neverRollsBackExternalState: restored.records === 'not-created-or-deleted-outside-memory' && restored.network === 'not-used' && restored.productionEffect === 'not-attempted',
rejectsRollbackOfRejectedPacket: rejectedRollback.restored === false && rejectedRollback.reason === 'no-accepted-synthetic-audit-packet',
}),
});
}
const fixtureCommand = 'node web/scripts/upgrade-2023-12.mjs --verify-fixture';
const practice = revision({
slug: 'editorial-2023-12-practice-security-audit',
title: 'Практический аудит веб-проекта: scope и карта активов до первого теста',
categories: ['Безопасность', 'Практика разработки'],
cover: '/assets/editorial/2023/security-audit-2023-scope-map.svg',
excerpt: 'Как начать аудит веб-проекта с понятного scope: собрать карту активов, назвать владельца и границу данных, отделить инвентаризацию от права на проверку и оставить обратимый следующий шаг.',
readingMinutes: 11,
}, [
p('Проблема обычно выглядит безобидно: в задаче написано «проверить веб-проект», а в руках команды только основной домен и длинный чек-лист. Через день один человек изучает форму входа, другой обсуждает CDN, а интеграция с identity provider вообще не попала в разговор. Цена такой экономии — не только пропущенный участок. Можно потратить время на второстепенный экран, принять чужую инфраструктуру за свою или затронуть внешний сервис, для которого команда не получила разрешения. До любого теста нужен не инструмент, а короткий scope: что считаем системой, где граница и кто подтверждает каждый участок.'),
p('Вторая ошибка — путать карту активов с перечнем URL. Адрес показывает точку входа, но не объясняет, где принимается решение об авторизации, кто владеет данными сессии, куда уходит загрузка файла и что считается внешней зависимостью. Для аудита это разные вопросы. Если ответ на них спрятан в голове одного разработчика, итоговый отчёт будет выглядеть аккуратно, но не позволит повторить проверку или безопасно выбрать первое исправление.'),
h2('Scope начинается с действия пользователя и заканчивается владельцем'),
p('В декабре 2023 автор уже не сводит безопасность к одному сканеру. После тем о сессиях, CSRF и threat model естественный следующий шаг — увидеть путь пользователя как несколько границ. Для обычного веб-проекта это минимум browser UI, web API, identity boundary и delivery пользовательского файла. Это не универсальная архитектура и не список обязательных сервисов. Это способ задать первый вопрос: где конкретно меняется состояние и кто может объяснить контракт этого перехода.'),
p('Начинать полезно с одного ценного сценария: вход, смена адреса доставки, оплата или загрузка документа. Затем отмечаем активы, по которым проходит сценарий. Для каждого нужны шесть полей: понятный идентификатор, тип, граница, владелец, класс данных и вопрос аудита. Поле «вопрос» особенно полезно: оно не позволяет записать «проверить API» вместо проверяемого условия вроде «подтвердить границу авторизации между browser и API». Карта не говорит, что на границе есть ошибка. Она делает незнание видимым и назначает, у кого уточнить его до следующего действия.'),
table('Минимальная карточка актива для первого scope', ['Поле', 'Пример учебной записи', 'Зачем нужно', 'Чего не доказывает'], [
['ID', '<code>synthetic-asset-web-api</code>', 'связывает scope, evidence и triage без адреса реальной системы', 'что актив существует в production'],
['Граница', 'browser → API', 'показывает момент смены ответственности', 'что запрос уже проверен на практике'],
['Владелец', 'web owner', 'даёт адрес для уточнения контракта и плана изменения', 'что владелец разрешил любой тест'],
['Класс данных', 'session metadata', 'помогает не смешать интерфейс, учетные данные и файл', 'что данные действительно хранятся или передаются'],
['Вопрос', 'подтвердить authz boundary', 'делает следующий шаг наблюдаемым', 'что найдено нарушение'],
]),
figure('/assets/editorial/2023/security-audit-2023-scope-map.svg', 'Карта четырёх synthetic активов: browser UI, web API, identity boundary и upload delivery. У каждой стрелки отмечены граница, владелец и вопрос для review; схема отдельно указывает, что это не карта реальной сети и не разрешение на тест.', 'Scope map нужен, чтобы договориться о границах до проверки. Схема не содержит адресов, сетевых маршрутов или результатов security testing.'),
h2('Не подменяйте инвентаризацию разрешением'),
p('Самая опасная формулировка в стартовой задаче — «раз доступно из браузера, можно проверять». RFC 9116 описывает формат <code>security.txt</code> для disclosure, а не разрешение на действия. В его security considerations отдельно сказано, что файла недостаточно для подразумеваемого permission to test. Даже документ с contact и policy относится к домену или IP, из которого он получен; он не переносится автоматически на поддомены, подрядчика или соседний продукт. Поэтому рядом с scope нужна отдельная запись: кто утверждает границы, допустимый метод, среду, время и способ остановки. Пока этой записи нет, честный режим — inventory и review, а не активная проверка.'),
p('Это различие не бюрократическое. Scope отвечает «какой объект обсуждаем». Authorization отвечает «что разрешено делать с объектом». Evidence отвечает «на чём основан вывод». Когда три слоя смешаны в одной таблице, любая ссылка на документацию превращается в мнимое разрешение, а любой скриншот — в мнимое доказательство. Разделённые карточки делают разговор короче: сначала владелец подтверждает границу, потом команда фиксирует допустимый способ проверки, затем в отчёт попадают только воспроизводимые сведения.'),
h2('Учебный fixture проверяет форму карты, а не проект'),
p('Ниже запускается маленькая модель этой статьи. В ней четыре заранее заданных объекта с префиксом <code>synthetic-</code>, одна запись declared boundary, четыре evidence cards и три triage cards. У входа нет поля URL, учётных данных, лога, исходного кода или произвольной цели. Любое лишнее поле, перестановка карты, попытка назвать permission granted или добавить raw secret отвергаются. Это полезно для редакторской проверки: короткий пример не должен незаметно стать сканером или источником ложного отчёта.'),
code(fixtureCommand),
p('PASS означает только одно: fixed in-memory contract собран, отрицательные ветки отклонены и rollback вернул учебный draft. Код не открывает browser, не читает репозиторий, не выполняет HTTP, не обращается к URL, не пробует пароль, не перебирает пути и не запускает security test. В нём нет реального актива, finding, coverage, compliance или permission. Такие явные ограничения важнее красивого примера: читатель видит, где заканчивается модель и начинается работа с владельцем системы.'),
h2('Карта активов помогает ставить точный вопрос'),
p('Симптом часто маскируется под техническую деталь: форма авторизует пользователя, а после загрузки файла команда спорит, кто обязан проверять доступ к выдаче. Причина — на карте был отмечен upload, но не был отмечен переход от хранения к delivery. Проверка — не отправить специальный файл и не искать обход, а попросить владельца назвать контракт: кто принимает файл, где его имя становится серверным, кто выдаёт его пользователю, какие роли участвуют и где хранится evidence этого решения. Действие — добавить отсутствующую boundary card, владельца и отдельный plan проверки. После этого можно обсуждать метод, не расширяя scope наугад.'),
p('NIST SP 800-115 полезен именно этой последовательностью: планирование, проведение, анализ findings и mitigation не должны быть одним импульсом. Документ старше современного web stack, поэтому не стоит искать в нём готовую схему OAuth, CDN или SPA. Но его практический принцип остаётся применим: сначала назначить цель, ограничения и evidence, затем выбрать технику. OWASP WSTG v4.2 дополняет это версионированными сценариями и рекомендацией фиксировать идентификатор версии. В отчёте лучше написать <code>WSTG-v42-…</code> с границей применимости, чем «проверили OWASP» без предмета и версии.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> В задаче есть домен и тема аудита, но непонятно, входит ли в него identity provider, upload delivery или административный путь.',
'<strong>Причина.</strong> Команда начала с техники, не зафиксировав активы, владельцев, данные и переходы между ними.',
'<strong>Проверка scope.</strong> Выберите один пользовательский сценарий и заполните карточки активов. Для каждого перехода спросите: кто владелец, какие данные пересекают границу и какой вопрос мы хотим подтвердить.',
'<strong>Проверка permission.</strong> Отдельно получите и запишите допустимый метод, среду, период, точку остановки и контакт. <code>security.txt</code>, публичный экран и ссылка на стандарт не заменяют этот шаг.',
'<strong>Действие.</strong> Уберите из плана всё, у чего нет owner или разрешённой границы. Неизвестный участок пометьте как evidence gap и назначьте следующий разговор, а не как «низкий риск».',
'<strong>Повтор.</strong> Перед каждым новым классом проверки сравните план с той же картой: если появился новый актив или third-party boundary, обновите scope и согласование до действия.',
]),
h2('Rollback должен возвращать договор, а не обещание'),
p('У аудита тоже есть обратимое действие. Если карта оказалась неверной, безопасный rollback — не «продолжаем осторожнее», а возврат к предыдущей версии scope: убрать неподтверждённый актив из плана, отметить evidence gap, восстановить старую формулировку и снова запросить подтверждение владельца. Это не удаляет результаты и не отменяет инцидент: в учебной модели таких объектов вообще нет. В реальной работе нужно отдельно указать, что происходит с уже собранными материалами, доступами и задачами, потому что именно там возникает риск распространить лишние данные.'),
p('Следующий шаг занимает меньше часа: провести review одной карты с владельцем самого ценного пользовательского пути. В результате должна появиться не презентация, а versioned scope record: активы, boundary, owner, класс данных, вопрос проверки, разрешённый метод, запретные действия и способ остановки. Если хотя бы одно поле неизвестно, это и есть результат аудита на сегодня. Не следует имитировать полноту новым чек-листом; сначала надо закрыть пробел в договоре.'),
h2('Историческая граница декабря 2023'),
p('К концу декабря 2023 уже были доступны NIST SP 800-115 (сентябрь 2008), OWASP WSTG v4.2 (3 декабря 2020), ASVS 4.0.3 (28 октября 2021) и RFC 9116 (апрель 2022). В статье используются их определения planning, versioned testing guidance, verification requirements и disclosure boundary. Они не превращают synthetic fixture в аудит, не дают permission и не позволяют говорить о реальном покрытии проекта.'),
]);
const mechanism = revision({
slug: 'editorial-2023-12-mechanism-security-audit',
title: 'Практический аудит веб-проекта: evidence, разрешённые границы и проверяемый вывод',
categories: ['Безопасность', 'Инженерные процессы'],
cover: '/assets/editorial/2023/security-audit-2023-evidence-matrix.svg',
excerpt: 'Почему evidence, hypothesis и permission нельзя хранить в одной строке аудита; как собрать матрицу доказательств, назвать ограничения проверки и не выдать учебный факт за finding или compliance.',
readingMinutes: 12,
}, [
p('Проблема появляется после первого же аудиторского созвона: в документе уже есть «уязвимость», но внизу только ссылка на задачу, пересказ слов коллеги и скриншот без контекста. Никто не может ответить, кто наблюдал факт, в какой разрешённой среде, что именно проверялось и можно ли повторить вывод. Цена высока в обе стороны. Неподтверждённую гипотезу могут срочно отправить в релиз, а важное ограничение доступа — отложить, потому что evidence оформлен хуже, чем громкое предположение.'),
p('У этой ситуации одна частая причина: evidence, scope и authorization складывают в одну колонку «доказательство». Но это разные объекты. Evidence связывает конкретное утверждение с источником и ограничением. Scope определяет, к какому участку системы относится вопрос. Authorization ограничивает допустимые действия. Ни ссылка на стандарт, ни контакт из <code>security.txt</code>, ни положительный результат автоматического инструмента не могут заменить два остальных слоя. Пока они склеены, отчёт невозможно безопасно проверить или оспорить.'),
h2('Evidence — это цепочка, а не убедительная формулировка'),
p('Для каждого утверждения полезно завести короткую evidence card. В ней есть ID scope, объект, наблюдаемый факт или вопрос, происхождение материала, владелец, состояние верификации, ограничение и следующий шаг. Важно различать «видели», «подтверждено владельцем», «воспроизводимо в согласованной среде» и «не проверено». Это не шкала доверия к человеку. Это способ не потерять то, чего в записи пока нет. Если источник — только устный контекст, он может быть полезен для гипотезы, но не должен превращаться в finding при экспорте отчёта.'),
p('ASVS задаёт требования к верификации, а WSTG предоставляет каталог сценариев. Обе рамки помогают назвать предмет проверки, но не производят evidence сами. WSTG identifier без версии может измениться вместе с guide, а ASVS requirement без описания метода не показывает, что было сделано. Поэтому рядом с каждой карточкой нужно фиксировать точную версию источника, локальную границу и тот факт, который действительно подтверждён. Если этого нет, честный статус — <code>evidence gap</code>, а не «контроль пройден». '),
table('Четыре слоя записи, которые нельзя взаимозаменять', ['Слой', 'Вопрос', 'Минимальная запись', 'Опасная подмена'], [
['Scope', 'какая часть системы обсуждается?', 'asset ID, boundary, owner', 'домен выдают за карту всей системы'],
['Permission', 'что разрешено делать?', 'согласованный метод, среда, stop condition, контакт', '<code>security.txt</code> трактуют как carte blanche'],
['Evidence', 'что именно можно утверждать?', 'источник, статус, ограничение, связь со scope', 'скриншот или мнение называют воспроизводимым finding'],
['Decision', 'кто и что делает дальше?', 'owner, reversible step, criterion, rollback', 'оценку severity выдают за автоматическое исправление'],
]),
figure('/assets/editorial/2023/security-audit-2023-evidence-matrix.svg', 'Матрица synthetic evidence связывает scope card, owner route, permission limit и remediation gate. В каждой ячейке отмечено состояние «не внешне подтверждено»; отдельная полоса показывает, что evidence не является разрешением, finding или compliance.', 'Матрица показывает только форму учётной записи. Она не собирает материалы, не читает логи или секреты и не подтверждает безопасность реального продукта.'),
h2('Разрешённая проверка начинается там, где заканчивается догадка'),
p('Иногда команда ищет формальное слово, которое позволит «аккуратно проверить». Его нет. Authorization должна быть конкретной: объект, среда, временное окно, допустимые и запрещённые классы действий, контакт, условие остановки, обращение с данными и ожидаемый артефакт. Эта запись может быть краткой, но она должна существовать до того, как будет изменено состояние, отправлен необычный запрос или затронута внешняя зависимость. Если право не подтверждено, безопасное действие — задать вопрос владельцу, а не расширять эксперимент до фактической проверки.'),
p('RFC 9116 полезен именно как предохранитель от неверного вывода. Документ помогает организации опубликовать contact, policy и срок актуальности disclosure-информации. Он также напоминает, что файл относится к конкретному домену или IP и не создаёт implied permission for testing. Значит, <code>security.txt</code> можно положить в evidence как канал связи, но нельзя записать в поле authorization. Такой разделитель экономит время на споре и снижает риск, что исследование выйдет за неоговорённую границу.'),
h2('Fixture хранит только форму evidence, не доказательство'),
p('Исполнимый пример этой партии намеренно строгий. Он принимает только четыре fixed synthetic evidence cards: scope map, owner route, permission limit и remediation gate. Каждая помечена <code>synthetic-not-externally-verified</code>. Если добавить raw secret, произвольное поле, изменить status на «externally confirmed» или назвать declared boundary реальным permission, contract отклонит вход. Такой тест не заменяет review; он проверяет, что учебная модель не переобещает то, чего у неё нет.'),
code(fixtureCommand),
p('Проверка не читает сайт, URL, исходный код, CI, логи, переменные окружения или секреты. Она не производит сетевой запрос, не запускает browser и не выполняет security test. В ответе намеренно стоят <code>vulnerability=not-claimed</code>, <code>coverage=not-claimed</code> и <code>compliance=not-claimed</code>. PASS означает, что модель сохранила разделение слоёв, а не то, что команда имеет право проверять проект или что в нём нет слабых мест.'),
h2('Как вести отрицательное evidence без самообмана'),
p('Отсутствие материала тоже нужно записывать. Например, для identity provider не удалось получить владельца или договор интеграции. Это не вывод «интеграция небезопасна» и не повод проставить zero risk. Правильная карточка говорит: boundary известна, owner route отсутствует, метод не согласован, утверждение не сделано, следующий шаг — получить подтверждение. Такая запись помогает triage: она поднимает вопрос туда, где его можно решить, но не создаёт фиктивный finding и не заставляет разработчика исправлять неизвестную причину.'),
p('Не следует компенсировать недостаток evidence длинным списком технических терминов. Если неизвестно, как проверяется authz, полезнее написать «требуется подтверждение механизма и тестового пути в разрешённой среде», чем предположить OAuth flow, claim mapping или роль CDN. Автор 2023 года умеет назвать boundary и стоимость пробела, но не притворяется владельцем каждой интеграции. Техническая точность здесь проявляется в том, что вывод ровно соответствует доказательству.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> В отчёте есть громкое утверждение, но нельзя показать scope, источник, статус верификации, владельца и разрешённую среду.',
'<strong>Причина.</strong> Evidence, permission и decision попали в одну строку; предположение получило статус finding только из-за убедительной формулировки.',
'<strong>Проверка записи.</strong> Для каждого утверждения спросите: к какому asset ID оно относится, кто владелец, откуда взят материал, что именно наблюдалось, какое ограничение у источника и что ещё не проверено.',
'<strong>Проверка границы.</strong> До действия подтвердите method, environment, stop condition и канал связи. Публичность ресурса, стандарт или disclosure contact не дают разрешения сами по себе.',
'<strong>Действие.</strong> Переведите неподтверждённый пункт в hypothesis или evidence gap; назначьте owner и короткий запрос на подтверждение. Finding публикуйте только вместе с воспроизводимым и разрешённым evidence.',
'<strong>Повтор.</strong> Перед triage проверьте, что значение severity не скрывает отсутствие доказательства. Нулевая уверенность не становится низким приоритетом без отдельного решения владельца.',
]),
h2('Rollback нужен и для документа'),
p('Если новая информация опровергла карточку, откат должен быть простым: вернуть статус к hypothesis, убрать неподтверждённый вывод из decision record, сохранить ссылку на причину пересмотра и повторно назначить владельца. Не надо стирать след изменения: иначе команда снова будет спорить, почему пункт исчез. Но и нельзя оставлять старую формулировку в списке findings с пометкой «уточняется» — на неё всё равно начнут опираться. В реальном процессе порядок хранения, доступа и удаления материалов должен быть определён отдельной политикой; fixture этого не делает.'),
p('Следующий шаг — взять один пункт из текущего security backlog и переписать его как evidence card. Добавьте scope ID, owner, literal observation или вопрос, источник, version, status, limit, разрешённый способ подтверждения, reversible next step и критерий закрытия. Если карточка не помещается в несколько строк, значит, в ней смешаны несколько границ или решений. Разделите её до того, как разработка начнёт исправление.'),
h2('Историческая граница декабря 2023'),
p('Эта статья опирается на NIST SP 800-115 (сентябрь 2008), OWASP WSTG v4.2 (3 декабря 2020), OWASP ASVS 4.0.3 (28 октября 2021) и RFC 9116 (апрель 2022), все доступные до конца 2023 года. Они объясняют planning, versioned testing guidance, verification requirements и disclosure channel. Они не подтверждают конкретный finding, не устанавливают authorization для этого репозитория и не превращают fixed synthetic records в compliance evidence.'),
]);
const field = revision({
slug: 'editorial-2023-12-field-security-audit',
title: 'Практический аудит веб-проекта: порядок исправлений и безопасный triage',
categories: ['Безопасность', 'Надёжность изменений'],
cover: '/assets/editorial/2023/security-audit-2023-remediation-route.svg',
excerpt: 'Как разбирать список security-гипотез без гонки за самым громким score: отделить evidence от severity, выбрать обратимый шаг, назначить владельца и не превратить triage в неуправляемое изменение.',
readingMinutes: 12,
}, [
p('Проблема после аудита в том, что команда часто получает не ответ, а очередь из разнородных пунктов: где-то есть версия зависимости, где-то вопрос к авторизации, где-то неполная карта загрузки файла. Самый громкий заголовок или высокий score легко забирает весь фокус. Цена — исправить то, что удобно показать в статусе, и оставить без владельца публичную boundary с неполным evidence. Вторая цена не менее реальна: поспешная «security-правка» ломает рабочий путь, а rollback никто не продумал, потому что triage уже назвал её очевидной.'),
p('Причина в том, что severity читают как готовый порядок действий. CVSS v3.1 полезен для описания технических характеристик и прямо допускает Base, Temporal и Environmental groups. Но сама спецификация говорит, что организация учитывает и другие факторы при remediation decision. Score не знает, есть ли разрешённое воспроизведение, кому принадлежит boundary, насколько широко она используется, что изменится при фиксе и можно ли вернуть состояние. Поэтому безопасный triage строится не вокруг одной цифры, а вокруг цепочки evidence → owner → reversible decision → проверка результата.'),
h2('Сначала отделяем гипотезу от решения'),
p('Пусть есть карточка «authz boundary требует review». Она не означает, что в системе найдена vulnerability; это вопрос, который получил высокий порядок потому, что относится к public entry и не имеет полного evidence. Следующая карточка про third-party identity contract может оказаться важнее зависимости с высоким Base score, если у неё нет согласованного owner и scope. Это не спор с CVSS. Это признание границ метода: он описывает свойства уязвимости, а не контекст конкретного изменения и не право действовать с чужими системами.'),
p('Для каждого пункта нужны четыре развилки. Первая — evidence: есть ли достаточно материала для утверждения или пока только gap. Вторая — exposure: где находится boundary относительно пользовательского пути и внешней зависимости. Третья — owner: кто может подтвердить контракт и принять решение. Четвёртая — reversibility: какой шаг можно отменить, если гипотеза не подтвердится. Пока хотя бы одна развилка неизвестна, правильное действие — не автоматическая правка, а уточнение. Это быстрее, чем создать новый риск из хорошего намерения.'),
table('Матрица безопасного triage перед исправлением', ['Поле', 'Вопрос', 'Если ответа нет', 'Безопасный следующий шаг'], [
['Evidence', 'что подтверждено и с каким ограничением?', 'severity опирается на гипотезу', 'перевести в evidence gap и запросить разрешённое подтверждение'],
['Exposure', 'какая boundary и пользовательский путь затронуты?', 'не виден возможный blast radius', 'связать пункт с asset map и ценным сценарием'],
['Owner', 'кто подтверждает контракт и change?', 'исправление становится бесхозным', 'назначить владельца до изменения'],
['Reversibility', 'что вернём и как проверим?', 'rollback звучит как обещание', 'написать validation и rollback plan'],
['Decision', 'почему именно этот пункт идёт первым?', 'очередь строится по громкости', 'зафиксировать evidence-based rationale, а не только score'],
]),
figure('/assets/editorial/2023/security-audit-2023-remediation-route.svg', 'Маршрут synthetic triage: evidence gap на authz boundary ведёт к owner review, third-party contract — к подтверждению scope, upload delivery — к плану validation и rollback. Стрелки оканчиваются gate «до изменения», а не автоматическим исправлением.', 'Схема не оценивает реальный риск, не находит уязвимости и не выполняет remediation. Она показывает порядок вопросов, который сохраняет возможность остановиться.'),
h2('Порядок — это объяснение, а не сортировка по колонке'),
p('Хороший порядок можно пересказать одним предложением. Например: «сначала подтверждаем evidence и владельца на public authz boundary, затем согласуем scope с identity provider, затем пишем validation и rollback для upload delivery». В такой формулировке видно, что пункт №1 не объявлен найденной уязвимостью и не отправлен на автоматический fix. Его подняли потому, что незакрытый вопрос касается ценного входа и есть обратимый шаг — получить доказательство и договориться о change. Если причина порядка не помещается в одно предложение, значит, в очереди смешались разные классы работы.'),
p('Нельзя скрывать decision в шкале «critical/high/medium». Эти labels полезны для сводки, но не отвечают на «кто сделает что утром». К моменту изменения нужны owner, exact boundary, критерий валидации, отказоустойчивый план остановки и правило возврата. Если операция требует неотменяемой миграции, влияния на совместимость или доступа к third-party, её нельзя выдавать за «быстрый security fix». Пункт должен перейти в отдельный review, даже если проблема кажется знакомой.'),
h2('Fixture моделирует только безопасные gates'),
p('В учебной модели три synthetic triage cards уже имеют порядок, но не получают право менять систему. Первая карта требует подтвердить evidence и owner до change. Вторая требует согласовать scope третьей стороны. Третья требует сначала написать validation и rollback plan. Любая подмена на <code>synthetic-automatic-change</code> отвергается. Тест также отказывается принимать claimed permission, externally confirmed evidence, лишние asset fields и raw secret. Это не защита реального проекта; это защита примера от ложного смысла.'),
code(fixtureCommand),
p('После запуска можно увидеть только PASS набора assert-выражений. Никакого score не вычисляется, CVSS vector не создаётся, URL не открывается, сеть не используется, код и логи не читаются. Fixture не запускает scanner, brute force или browser и не заявляет vulnerability, coverage, compliance либо production effect. В этом его ценность: модель показывает, что triage начинается с запрета на действие без evidence и owner, а не с имитации риск-оценки без системы.'),
h2('Исправление должно быть проверяемым и обратимым'),
p('Техническое решение можно назвать только после того, как сформулирован критерий проверки. «Добавим проверку доступа» ещё не план: нужно знать, на какой boundary, для какого действия пользователя, кто проверит ожидаемый и запрещённый исход, где записан результат и как вернуть предыдущую версию. Здесь автор продолжает линию статей о контрактных тестах и e2e stability: хороший gate не обещает, что баг исчез. Он фиксирует, какой наблюдаемый результат отличит рабочее изменение от неверной гипотезы.'),
p('Rollback не обязан быть сложным, но обязан быть реальным для выбранного изменения. Для конфигурации это может быть версия правила и явный owner восстановления. Для UI-flow — возврат к известной реализации и ограничение exposure. Для third-party contract — прекращение rollout до согласования. Нельзя писать «откатим базу» или «выключим интеграцию» без оценки последствий для данных и пользователей. Если безопасного обратного действия нет, triage должен повысить требование к review, а не ускорить выпуск из-за тревожного названия.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Backlog полон security-пунктов, но первые места заняты самым громким score или последним сообщением в чате.',
'<strong>Причина.</strong> Severity подменила evidence, scope, owner и reversibility; очередь стала списком тревог, а не планом изменения.',
'<strong>Проверка evidence.</strong> Для каждого пункта зафиксируйте literal claim, источник, status, границу, ограничение и то, что не подтверждено. При gap не называйте карточку finding.',
'<strong>Проверка change.</strong> Назовите owner, пользовательский путь, expected result, запрещённый результат, валидацию, stop condition и rollback. Если одного элемента нет, решение ещё не готово.',
'<strong>Действие.</strong> Поставьте первым не «самый страшный» пункт, а тот, для которого есть понятный evidence-based question и безопасный следующий gate. Неавторизованные или необратимые действия вынесите из очереди исправлений.',
'<strong>Повтор.</strong> После подтверждения или изменения обновите evidence и rationale. Если причина порядка изменилась, пересортируйте очередь открыто, а не оставляйте старый номер по инерции.',
]),
h2('Ограничения и следующий шаг'),
p('Эта статья не даёт универсальный SLA, пороги severity, срок исправления или готовый порядок для всех команд. CVSS v3.1 — исторически доступный стандарт к декабрю 2023, но он не определяет business impact, permission, owner или rollback конкретного продукта. NIST, OWASP и RFC дают язык для планирования, границ и верификации; они не выводят автоматически, что надо менять в данном коде. Любая реальная проверка требует согласованного scope и допустимого метода вне этого fixture.'),
p('Следующий шаг — выбрать одну карточку из backlog и провести пятнадцатиминутный triage без обсуждения реализации. Запишите: claim, evidence status, asset boundary, owner, exposure, reversible next step, validation, stop condition и rollback. Только после этого решайте, нужна ли разработка, review контракта, запрос к подрядчику или дополнительное доказательство. Такая карточка короче типичного отчёта, но даёт команде возможность действовать без вымышленных гарантий.'),
h2('Историческая граница декабря 2023'),
p('Для порядка исправлений использована версия CVSS 3.1 от июня 2019 года, доступная до декабря 2023, вместе с NIST SP 800-115, OWASP WSTG v4.2, ASVS 4.0.3 и RFC 9116. CVSS здесь назван входом в decision, а не автоматической командой. Учебные числа, findings, exploitation, coverage, compliance и production-результаты не создаются и не заявляются.'),
]);
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
function verifyFixture() {
const report = runSecurityAuditFixture();
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
if (failed.length) {
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
process.exitCode = 1;
return;
}
const count = Object.keys(report.assertions).length;
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
}
if (process.argv.includes('--verify-fixture')) verifyFixture();
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');