diff --git a/editorial/production/README.md b/editorial/production/README.md index 70dcbd9..37890df 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 181 из 358 созданных материалов. Остальные 177 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 184 из 358 созданных материалов. Остальные 174 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2023-04-draft.md b/editorial/reviews/2023-04-draft.md new file mode 100644 index 0000000..d9def6b --- /dev/null +++ b/editorial/reviews/2023-04-draft.md @@ -0,0 +1,37 @@ +# П62 · 2023-04 · Безопасность зависимостей — три прохода саморевью + +## Рамка партии + +- Slug: `editorial-2023-04-practice-dependency-security`, `editorial-2023-04-mechanism-dependency-security`, `editorial-2023-04-field-dependency-security`. +- Голос: М6, апрель 2023. Системный практик начинает с наблюдаемой цены update, различает границы артефактов и оставляет короткий проверяемый маршрут без выдуманного incident report или метрик. +- Три материала не повторяют друг друга: практический triage advisory и достижимости; механизм manifest/lockfile/SBOM/runtime; полевой gate безопасного update и rollback. +- Sidecar создаёт ровно пять файлов: этот review, `web/scripts/upgrade-2023-04.mjs` и три SVG с префиксом `dependency-security-2023-`. Скрипт не пишет `articles.json`, registry, README, очередь или Git. +- Единственный fixture input помечен `synthetic-dependency-security-input-v1`. `SYNTHETIC-ADVISORY-001`, demo package names, versions, lockfile, SBOM, path и runtime labels — учебные значения; они не получены из scanner, CVE, registry, production lockfile, SBOM, trace или запуска приложения. + +## Проход 1 — факты и модель + +- Источники сверены 31.07.2026 по первичным или официальным страницам и их историческим датам, все существовали не позднее апреля 2023. Для triage используются [GitHub Advisory Database, 14.11.2019](https://github.blog/changelog/2019-11-14-github-advisory-database/), [NTIA Minimum Elements for an SBOM, 12.07.2021](https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom) и [NIST SP 800-218 SSDF v1.1 Final, 03.02.2022](https://doi.org/10.6028/NIST.SP.800-218). Для модели добавлены [npm Docs v7.24.2 package-lock.json, 22.09.2021](https://docs.npmjs.com/cli/v7/configuring-npm/package-lock-json/), [SPDX Specification 2.3.0, 2022](https://spdx.github.io/spdx-spec/v2.3/conformance/) и [npm Docs v8.19.4 npm ci / package.json, 26.10.2022](https://docs.npmjs.com/cli/v8/commands/npm-ci/). Источники везде названы рамкой или описанием формата, а не подтверждением состояния конкретного сервиса. +- Перепроверена модель утверждений: advisory остаётся внешним сигналом; lockfile — synthetic resolved tree; SBOM — synthetic inventory record; supplied path — hypothesis для review. Ни один слой не назван доказательством CVE, exploitability, фактической загрузки модуля или production impact. +- `triageSyntheticAdvisory()` сопоставляет только заданные package/version strings и возвращает `vulnerabilityStatus: not-determined-by-fixture`; достижимость всегда `not-assessed-by-fixture`. `buildSyntheticInventory()` намеренно оставляет runtime как `not-observed-by-fixture` и deployment inventory как `not-proven-by-fixture`. +- `planSyntheticSafeUpdate()` требует явный synthetic project contract, сравнивает только labels, хранит named gates как synthetic declarations и всегда отдаёт `compatibility` и `safety` в состоянии `not-proven-by-fixture`. `rollbackSyntheticSafeUpdate()` возвращает лишь version label и не заявляет deployment effect. В fixture есть 22 assertions, включая invalid kind, malformed inventory, отсутствующий package/project contract, неполный gate и неверный candidate; отсутствуют scanner, network, installer, test runner, package download и runtime execution. + +## Проход 2 — язык, полнота и объём + +- В первых двух абзацах каждой статьи названы исходная проблема и цена: ложная аварийность или непросмотренный риск для advisory; неверное смешение manifest, lockfile и inventory; риск resolver/runtime regressions при update. После этого текст идёт по порядку «симптом → причина → проверка → действие». +- У каждой статьи есть доступная таблица, воспроизводимый code/fixture, упорядоченный маршрут, SVG с содержательными `alt` и `figcaption`, отдельный раздел ограничения модели и минимум три официальных или первичных источника. Practical article не повторяет механизм, а field article не выдаёт named gates за результаты их исполнения. +- `npm run audit:draft -- scripts/upgrade-2023-04.mjs` измерил основной текст без списка источников: practice — 8 613 знаков, mechanism — 8 592, field — 9 048. Все три значения находятся в заданном диапазоне 8–11 тыс. и в общем стандарте 5–15 тыс. +- Редакционный проход удалил общий язык и анахронизмы: нет неназванных измерений, «универсального» процесса, fabricated scanner result, CVE, runtime PASS или обещания, что обновление автоматически безопасно. М6 использует точные объекты: package@version, commit, artifact identity, gate, environment, evidence и rollback condition. + +## Проход 3 — SVG, безопасность и выпуск + +- Три SVG разделяют роли: `advisory-triage` показывает входной сигнал, lockfile/SBOM и вопрос достижимости; `lockfile-sbom` — четыре границы данных; `safe-update-gate` — evidence gates и отдельный rollback. Подписи и `alt` прямо говорят, что это схемы synthetic процесса, а не scanner report или production trace. +- `xmllint --noout` принял все три SVG. Safety scan не обнаружил `script`, `foreignObject`, inline handlers, внешние `href/src`, `url(http...)` или `data:` assets. Файлы не содержат пользовательского ввода, интерактивного поведения или raster embedded content. +- Sharp отрендерил SVG на ширине 375 px: 375×396, 375×411 и 375×417 px. Ручной просмотр PNG подтвердил читаемость заголовков, шагов, стрелок и нижних подписей; обрезанных labels и перекрытий нет. +- Выпускной проход подтверждает только sidecar: `node --check` — PASS; fixture — PASS 22/22; draft audit — PASS; import-safe export содержит ровно три revision, CLI JSON совпадает с import и нет полей `date`/`author`; XML, safety scan и Sharp-review — PASS. Registry, README, `articles.json`, очередь, документация, Git, commit и push не затрагивались. + +## Интеграционная приёмка + +- Основной редактор добавил явный reject для malformed inventory и отсутствующего project contract: валидный по advisory input больше не может завершиться исключением. Титулы npm-источников уточнены до legacy-версий v7.24.2 (22.09.2021) и v8.19.4 (26.10.2022). После исправлений fixture — PASS 22/22. +- Ревизии добавлены в `web/data/editorial-revisions.mjs` поверх трёх исходных slug. `web/data/articles.json`, даты и авторы не изменялись; в registry 175 уникальных ревизий без дубликатов. +- Строгий аудит `npm run audit:articles -- editorial-2023-04-practice-dependency-security editorial-2023-04-mechanism-dependency-security editorial-2023-04-field-dependency-security` — PASS: у каждой статьи 1 figure, 1 table и 1 code example. +- Production-сборка `cd web && npm run build` — PASS: 374 статические страницы. README обновлён с 181/358 до 184/358 принятых материалов; осталось 174. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 593a764..ffbd6dc 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -56,6 +56,7 @@ import { revisions as october2022Revisions } from '../scripts/upgrade-2022-10.mj import { revisions as november2022Revisions } from '../scripts/upgrade-2022-11.mjs'; import { revisions as december2022Revisions } from '../scripts/upgrade-2022-12.mjs'; import { revisions as january2023Revisions } from '../scripts/upgrade-2023-01.mjs'; +import { revisions as april2023Revisions } from '../scripts/upgrade-2023-04.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -117,4 +118,5 @@ export const editorialRevisions = [ ...november2022Revisions, ...december2022Revisions, ...january2023Revisions, + ...april2023Revisions, ]; diff --git a/web/public/assets/editorial/2023/dependency-security-2023-advisory-triage.svg b/web/public/assets/editorial/2023/dependency-security-2023-advisory-triage.svg new file mode 100644 index 0000000..b9d3659 --- /dev/null +++ b/web/public/assets/editorial/2023/dependency-security-2023-advisory-triage.svg @@ -0,0 +1,43 @@ + + + Разбор advisory зависимости до решения об обновлении + Внешний advisory сопоставляется с lockfile и SBOM, затем формулируется гипотеза достижимости от точки входа. Только после этого возможен project review; схема не делает вывода об уязвимости. + + + Advisory — вход в triage, не вердикт + Все значения на схеме — вопросы для project review + + + 1. External advisory + package + version + source URL + SYNTHETIC + + + + + + 2. Lockfile + Есть exact package@version? + Это resolved tree, не runtime. + + + 3. SBOM + Есть component record? + Нужна связь с build artifact. + + + + + + + 4. Гипотеза достижимости + entry point → import path → named scenario + Не доказывает вызов уязвимой ветки. + + + + + + Project review: evidence, owner, next check + не «safe» + diff --git a/web/public/assets/editorial/2023/dependency-security-2023-lockfile-sbom.svg b/web/public/assets/editorial/2023/dependency-security-2023-lockfile-sbom.svg new file mode 100644 index 0000000..f194713 --- /dev/null +++ b/web/public/assets/editorial/2023/dependency-security-2023-lockfile-sbom.svg @@ -0,0 +1,43 @@ + + + Границы manifest, lockfile, SBOM и runtime evidence + Вертикальная схема различает намерение в manifest, разрешённое дерево в lockfile, инвентарь привязанного артефакта в SBOM и наблюдённое выполнение в runtime. Между слоями показаны обязательные проверки, а не автоматические гарантии. + + + Один package, четыре вопроса + Списки похожи, но владеют разными фактами + + + Manifest + Что проект просит: direct dependency и range + intent + + + + resolver + flags + + + Lockfile + Какое resolved tree получил installer + Проверка: diff и reproducible install + + + + build identity + + + SBOM + Какие components записаны для named artifact + Проверка: provenance, format, source revision + + + + named environment + + + Runtime evidence + Что наблюдалось в конкретном scenario + Не доказывает все paths и configurations + + Стрелка означает «нужна проверка», а не «гарантировано» + diff --git a/web/public/assets/editorial/2023/dependency-security-2023-safe-update-gate.svg b/web/public/assets/editorial/2023/dependency-security-2023-safe-update-gate.svg new file mode 100644 index 0000000..6cf2946 --- /dev/null +++ b/web/public/assets/editorial/2023/dependency-security-2023-safe-update-gate.svg @@ -0,0 +1,44 @@ + + + Порядок проверки безопасного обновления зависимости + Схема показывает gates обновления: определить scope и baseline, проверить lockfile diff, провести чистую установку, tests и runtime smoke в именованном окружении. Только затем принимается release decision; rollback является отдельным планом. + + + Безопасное обновление — цепочка evidence + Названный gate ещё не равен выполненной проверке + + + 1. Scope + baseline + advisory context · package · from/to version · owner + + + + + + 2. Lockfile diff + added / removed / changed records и resolver effects + + + + + + 3. Clean install + project tests + same installer rules · named application contracts + + + + + + 4. Runtime smoke + declared environment · startup · controlled scenario + + + + + + Release decision — только с реальными artifacts + + + + Rollback plan: вернуть baseline, не стереть evidence + diff --git a/web/scripts/upgrade-2023-04.mjs b/web/scripts/upgrade-2023-04.mjs new file mode 100644 index 0000000..35884e7 --- /dev/null +++ b/web/scripts/upgrade-2023-04.mjs @@ -0,0 +1,498 @@ +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +const p = (text) => '

' + text + '

'; +const h2 = (text) => '

' + text + '

'; +const code = (text) => '
' + escapeHtml(text) + '
'; +const ol = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +const figure = (src, alt, caption) => '
' + alt + '
' + caption + '
'; +const table = (caption, headers, rows) => '
' + headers.map((item) => '').join('') + '' + rows.map((row) => '' + row.map((item) => '').join('') + '').join('') + '
' + caption + '
' + item + '
' + item + '
'; + +function plainText(content) { + return content + .replace(/<[^>]+>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function bodyText(content) { + return plainText(content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); +} + +const references = { + advisoryDatabase: { + title: 'GitHub Changelog: GitHub Advisory Database, 14 ноября 2019', + url: 'https://github.blog/changelog/2019-11-14-github-advisory-database/', + note: 'первичное сообщение GitHub о базе advisory, сопоставленных с пакетами dependency graph. Оно описывает источник данных, но не подтверждает применимость advisory к конкретному приложению.', + }, + ssdf: { + title: 'NIST SP 800-218 SSDF Version 1.1, Final, 3 февраля 2022', + url: 'https://doi.org/10.6028/NIST.SP.800-218', + note: 'официальный финальный документ, доступный до апреля 2023. Он задаёт практики безопасной разработки и работы с компонентами, но не аттестует отдельный выпуск.', + }, + ntiaSbom: { + title: 'NTIA: The Minimum Elements for a Software Bill of Materials, 12 июля 2021', + url: 'https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom', + note: 'официальный первичный документ: SBOM назван формальной записью о компонентах и связях цепочки поставки. Формат записи не доказывает, что компонент загружен в runtime.', + }, + spdx23: { + title: 'SPDX Specification 2.3.0, Conformance, 2022', + url: 'https://spdx.github.io/spdx-spec/v2.3/conformance/', + note: 'официальная спецификация SPDX 2.3, опубликованная в 2022 году. Она помогает именовать формат и версию инвентаря, но не генерирует его из данного fixture.', + }, + npmLock: { + title: 'npm Docs v7.24.2 (Legacy): package-lock.json, 22 сентября 2021', + url: 'https://docs.npmjs.com/cli/v7/configuring-npm/package-lock-json/', + note: 'официальная legacy-документация npm 7, доступная до апреля 2023: lockfile описывает точное дерево, созданное установщиком, и предназначен для коммита. Это не снимок уже запущенного процесса.', + }, + npmCi: { + title: 'npm Docs v8.19.4 (Legacy): npm ci, 26 октября 2022', + url: 'https://docs.npmjs.com/cli/v8/commands/npm-ci/', + note: 'официальная legacy-документация npm 8, доступная до апреля 2023: команда требует lockfile и не переписывает его. Успешный запуск всё равно надо зафиксировать отдельно от учебного контракта.', + }, + npmPackage: { + title: 'npm Docs v8.19.4 (Legacy): package.json и поле engines, 26 октября 2022', + url: 'https://docs.npmjs.com/cli/v8/configuring-npm/package-json/', + note: 'официальная legacy-документация npm 8, доступная до апреля 2023: engines задаёт заявленный диапазон runtime и без engine-strict обычно даёт предупреждение. Поле не заменяет запуск приложения.', + }, +}; + +function sourceList(items) { + return ''; +} + +function revision(meta, parts, sourceItems) { + const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList(sourceItems); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 8000 || proseLength > 11000) { + throw new Error(meta.slug + ': основной текст должен занимать 8 000–11 000 знаков, сейчас ' + proseLength); + } + return { ...meta, contentHtml, proseLength }; +} + +const SYNTHETIC_KIND = 'synthetic-dependency-security-input-v1'; + +/** + * Учебные входы ниже не получены из scanner, registry, lockfile, SBOM, + * runtime или репозитория. Имена, версии, идентификатор advisory и ветка + * достижимости синтетические. Функции ничего не устанавливают, не читают + * файловую систему и не делают вывод о наличии CVE или безопасности релиза. + */ +export const syntheticDependencyInput = Object.freeze({ + kind: SYNTHETIC_KIND, + advisory: Object.freeze({ + id: 'SYNTHETIC-ADVISORY-001', + packageName: 'demo-parser', + affectedVersion: '1.0.0', + source: 'synthetic-input', + }), + project: Object.freeze({ + name: 'demo-service', + declaredRuntime: 'node-18-demo', + entryPoint: 'demo-http-handler', + }), + lockfile: Object.freeze({ + format: 'synthetic-lockfile-v1', + packages: Object.freeze([ + Object.freeze({ name: 'demo-service', version: '1.0.0', dependsOn: Object.freeze(['demo-shell']) }), + Object.freeze({ name: 'demo-shell', version: '1.0.0', dependsOn: Object.freeze(['demo-parser']) }), + Object.freeze({ name: 'demo-parser', version: '1.0.0', dependsOn: Object.freeze([]) }), + ]), + }), + sbom: Object.freeze({ + format: 'synthetic-sbom-v1', + components: Object.freeze([ + Object.freeze({ bomRef: 'pkg:demo/demo-shell@1.0.0', name: 'demo-shell', version: '1.0.0', suppliedBy: 'fixture' }), + Object.freeze({ bomRef: 'pkg:demo/demo-parser@1.0.0', name: 'demo-parser', version: '1.0.0', suppliedBy: 'fixture' }), + ]), + }), + reachability: Object.freeze({ + suppliedPath: Object.freeze(['demo-http-handler', 'demo-shell', 'demo-parser']), + claim: 'synthetic-path-for-review-only', + }), + candidate: Object.freeze({ + packageName: 'demo-parser', + fromVersion: '1.0.0', + toVersion: '1.0.1', + declaredRuntime: 'node-18-demo', + }), + checks: Object.freeze({ + lockfileDiffReviewed: true, + cleanInstallDeclared: true, + projectTestsDeclared: true, + runtimeSmokeDeclared: true, + }), +}); + +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function findPackage(packages, name, version) { + return packages.find((item) => item.name === name && item.version === version); +} + +function hasComponent(components, name, version) { + return components.some((item) => item.name === name && item.version === version); +} + +export function triageSyntheticAdvisory(input) { + const base = { kind: 'synthetic-advisory-triage-v1', observation: 'not-performed-on-a-real-project' }; + if (!input || input.kind !== SYNTHETIC_KIND) { + return Object.freeze({ ...base, accepted: false, reason: 'synthetic-kind-required' }); + } + const advisory = input.advisory; + const packages = input.lockfile?.packages; + const components = input.sbom?.components; + if (!advisory || !hasText(advisory.id) || !hasText(advisory.packageName) || !hasText(advisory.affectedVersion)) { + return Object.freeze({ ...base, accepted: false, reason: 'synthetic-advisory-fields-required' }); + } + if (!hasText(input.lockfile?.format) || !hasText(input.sbom?.format) || !Array.isArray(packages) || !Array.isArray(components)) { + return Object.freeze({ ...base, accepted: false, reason: 'synthetic-inventory-required' }); + } + const locked = findPackage(packages, advisory.packageName, advisory.affectedVersion); + const recorded = hasComponent(components, advisory.packageName, advisory.affectedVersion); + const suppliedPath = input.reachability?.suppliedPath; + const pathEndsAtPackage = Array.isArray(suppliedPath) && suppliedPath.at(-1) === advisory.packageName; + return Object.freeze({ + ...base, + accepted: true, + advisory: Object.freeze({ id: advisory.id, source: advisory.source, packageName: advisory.packageName, affectedVersion: advisory.affectedVersion }), + lockfile: Object.freeze({ status: locked ? 'synthetic-entry-matched' : 'synthetic-entry-not-found', format: input.lockfile.format }), + sbom: Object.freeze({ status: recorded ? 'synthetic-component-matched' : 'synthetic-component-not-found', format: input.sbom.format }), + reachability: Object.freeze({ + suppliedPathStatus: pathEndsAtPackage ? 'synthetic-path-supplied' : 'synthetic-path-missing', + conclusion: 'not-assessed-by-fixture', + }), + decision: 'project-review-required', + vulnerabilityStatus: 'not-determined-by-fixture', + }); +} + +export function buildSyntheticInventory(input) { + if (!input || input.kind !== SYNTHETIC_KIND) { + return Object.freeze({ accepted: false, reason: 'synthetic-kind-required' }); + } + const packages = input.lockfile?.packages; + const components = input.sbom?.components; + if (!hasText(input.lockfile?.format) || !hasText(input.sbom?.format) || !Array.isArray(packages) || !Array.isArray(components)) { + return Object.freeze({ accepted: false, reason: 'synthetic-inventory-required' }); + } + const rows = packages.map((entry) => Object.freeze({ + package: entry.name, + version: entry.version, + lockfile: 'synthetic-listed', + sbom: hasComponent(components, entry.name, entry.version) ? 'synthetic-listed' : 'synthetic-not-listed', + runtime: 'not-observed-by-fixture', + })); + return Object.freeze({ + accepted: true, + source: 'synthetic-input-only', + lockfileFormat: input.lockfile.format, + sbomFormat: input.sbom.format, + rows: Object.freeze(rows), + deploymentInventory: 'not-proven-by-fixture', + }); +} + +export function planSyntheticSafeUpdate(input) { + const triage = triageSyntheticAdvisory(input); + if (!triage.accepted) return Object.freeze({ accepted: false, reason: triage.reason }); + const project = input.project; + if (!project || !hasText(project.name) || !hasText(project.entryPoint) || !hasText(project.declaredRuntime)) { + return Object.freeze({ accepted: false, reason: 'synthetic-project-fields-required' }); + } + const candidate = input.candidate; + if (!candidate || !hasText(candidate.packageName) || !hasText(candidate.fromVersion) || !hasText(candidate.toVersion) || !hasText(candidate.declaredRuntime)) { + return Object.freeze({ accepted: false, reason: 'synthetic-candidate-fields-required' }); + } + if (candidate.packageName !== input.advisory.packageName || candidate.fromVersion !== input.advisory.affectedVersion) { + return Object.freeze({ accepted: false, reason: 'candidate-must-start-at-synthetic-advisory-entry' }); + } + const checks = input.checks || {}; + const gates = Object.freeze([ + Object.freeze({ id: 'lockfile-diff', declared: checks.lockfileDiffReviewed === true, execution: 'not-run-by-fixture' }), + Object.freeze({ id: 'clean-install', declared: checks.cleanInstallDeclared === true, execution: 'not-run-by-fixture' }), + Object.freeze({ id: 'project-tests', declared: checks.projectTestsDeclared === true, execution: 'not-run-by-fixture' }), + Object.freeze({ id: 'runtime-smoke', declared: checks.runtimeSmokeDeclared === true, execution: 'not-run-by-fixture' }), + ]); + const allGatesNamed = gates.every((gate) => gate.declared); + return Object.freeze({ + accepted: true, + source: 'synthetic-input-only', + candidate: Object.freeze({ packageName: candidate.packageName, fromVersion: candidate.fromVersion, toVersion: candidate.toVersion }), + runtime: Object.freeze({ + declaredProjectRuntime: project.declaredRuntime, + declaredCandidateRuntime: candidate.declaredRuntime, + labelMatches: project.declaredRuntime === candidate.declaredRuntime, + execution: 'not-run-by-fixture', + compatibility: 'not-proven-by-fixture', + }), + gates, + gateNaming: allGatesNamed ? 'all-synthetic-gates-named' : 'synthetic-gates-incomplete', + safety: 'not-proven-by-fixture', + nextAction: 'run-project-specific-install-tests-and-runtime-checks', + rollback: Object.freeze({ action: 'restore-synthetic-version-label', version: candidate.fromVersion, deploymentEffect: 'not-assessed-by-fixture' }), + }); +} + +export function rollbackSyntheticSafeUpdate(plan) { + if (!plan || !plan.accepted || !plan.rollback) { + return Object.freeze({ restored: false, reason: 'accepted-synthetic-plan-required' }); + } + return Object.freeze({ + restored: true, + versionLabel: plan.rollback.version, + deploymentEffect: 'not-assessed-by-fixture', + safety: 'not-proven-by-fixture', + }); +} + +export function runDependencySecurityFixture() { + const triage = triageSyntheticAdvisory(syntheticDependencyInput); + const inventory = buildSyntheticInventory(syntheticDependencyInput); + const plan = planSyntheticSafeUpdate(syntheticDependencyInput); + const rollback = rollbackSyntheticSafeUpdate(plan); + const invalidKind = triageSyntheticAdvisory({ kind: 'real-scanner-output' }); + const malformedInput = { ...syntheticDependencyInput, lockfile: { packages: syntheticDependencyInput.lockfile.packages } }; + const malformedTriage = triageSyntheticAdvisory(malformedInput); + const malformedInventory = buildSyntheticInventory(malformedInput); + const absentPackage = triageSyntheticAdvisory({ + ...syntheticDependencyInput, + advisory: { ...syntheticDependencyInput.advisory, packageName: 'demo-absent' }, + }); + const incompletePlan = planSyntheticSafeUpdate({ + ...syntheticDependencyInput, + checks: { ...syntheticDependencyInput.checks, runtimeSmokeDeclared: false }, + }); + const mismatchedCandidate = planSyntheticSafeUpdate({ + ...syntheticDependencyInput, + candidate: { ...syntheticDependencyInput.candidate, fromVersion: '9.9.9' }, + }); + const missingProject = planSyntheticSafeUpdate({ ...syntheticDependencyInput, project: undefined }); + return Object.freeze({ + assertions: Object.freeze({ + inputIsExplicitlySynthetic: syntheticDependencyInput.kind === SYNTHETIC_KIND && syntheticDependencyInput.advisory.source === 'synthetic-input', + triageAcceptsCompleteSyntheticInput: triage.accepted === true, + triagePreservesSyntheticAdvisoryId: triage.advisory.id === 'SYNTHETIC-ADVISORY-001', + triageMatchesSyntheticLockEntry: triage.lockfile.status === 'synthetic-entry-matched', + triageMatchesSyntheticSbomEntry: triage.sbom.status === 'synthetic-component-matched', + triageDoesNotAssessReachability: triage.reachability.conclusion === 'not-assessed-by-fixture', + triageDoesNotClaimVulnerability: triage.vulnerabilityStatus === 'not-determined-by-fixture', + inventoryHasNoRuntimeObservation: inventory.rows.every((row) => row.runtime === 'not-observed-by-fixture'), + inventoryDoesNotClaimDeployment: inventory.deploymentInventory === 'not-proven-by-fixture', + planNamesCandidateOnly: plan.candidate.toVersion === '1.0.1', + runtimeLabelComparisonIsNotExecution: plan.runtime.labelMatches === true && plan.runtime.execution === 'not-run-by-fixture', + planDoesNotClaimRuntimeCompatibility: plan.runtime.compatibility === 'not-proven-by-fixture', + namedGatesAreNotEvidence: plan.gateNaming === 'all-synthetic-gates-named' && plan.gates.every((gate) => gate.execution === 'not-run-by-fixture'), + planDoesNotClaimSafeUpdate: plan.safety === 'not-proven-by-fixture', + invalidKindRejected: invalidKind.accepted === false && invalidKind.reason === 'synthetic-kind-required', + malformedInventoryRejected: malformedTriage.accepted === false && malformedTriage.reason === 'synthetic-inventory-required' && malformedInventory.accepted === false && malformedInventory.reason === 'synthetic-inventory-required', + absentPackageStaysAReviewQuestion: absentPackage.lockfile.status === 'synthetic-entry-not-found' && absentPackage.vulnerabilityStatus === 'not-determined-by-fixture', + incompleteGateIsVisible: incompletePlan.accepted === true && incompletePlan.gateNaming === 'synthetic-gates-incomplete', + mismatchedCandidateRejected: mismatchedCandidate.accepted === false && mismatchedCandidate.reason === 'candidate-must-start-at-synthetic-advisory-entry', + missingProjectRejected: missingProject.accepted === false && missingProject.reason === 'synthetic-project-fields-required', + rollbackRestoresOnlyLabel: rollback.restored === true && rollback.versionLabel === '1.0.0', + rollbackDoesNotClaimDeploymentEffect: rollback.deploymentEffect === 'not-assessed-by-fixture' && rollback.safety === 'not-proven-by-fixture', + }), + samples: Object.freeze({ triage, inventory, plan, rollback, invalidKind, malformedTriage, malformedInventory, absentPackage, incompletePlan, mismatchedCandidate, missingProject }), + }); +} + +const fixtureCommand = `node web/scripts/upgrade-2023-04.mjs --verify-fixture + +# Все значения входа synthetic: это не вывод scanner и не CVE. +# PASS проверяет только контракт in-memory: запись в lockfile/SBOM, +# явно ненаблюдаемую достижимость, named gates и предел rollback. +# PASS не означает, что пакет уязвим, достижим, совместим или безопасно обновлён.`; + +const practice = revision({ + slug: 'editorial-2023-04-practice-dependency-security', + title: 'Advisory зависимости: сначала зафиксировать факт, потом проверять путь', + categories: ['Безопасность', 'Разработка'], + cover: '/assets/editorial/2023/dependency-security-2023-advisory-triage.svg', + excerpt: 'Как разобрать advisory без выдуманной CVE-диагностики: lockfile, SBOM, заявленный путь до entry point и границы fixture.', + readingMinutes: 12, +}, [ + p('Уведомление об advisory легко превратить в аварийную задачу одним словом «уязвимость». Пакет совпал по имени и версии, значит его надо немедленно заменить. Проблема начинается, когда после быстрого обновления меняется всё дерево, ломается сборка или остаётся непонятно, был ли компонент вообще в поставленном артефакте. Цена ошибки двойная: можно остановить полезный релиз из-за неподтверждённого сигнала или оставить без владельца компонент, который действительно требует разбора.'), + p('Для первой реакции не нужен ни придуманный CVE, ни число из сканера. Нужна короткая запись о том, откуда пришёл advisory, какой exact package и version он называет, где этот package виден в lockfile и где он записан в инвентаре компонентов. Затем отдельно формулируется вопрос о достижимости: может ли путь от конкретного entry point дойти до этого кода в нужной конфигурации. Это четыре разных факта. Их смешивание и делает alert похожим на готовый вердикт.'), + h2('Advisory задаёт вопрос, а не результат проверки'), + p('База advisory хранит сведения о пакетах и затронутых версиях. GitHub в сообщении о своей Advisory Database в 2019 году описывал связь curated advisory с пакетами dependency graph. Из этого не следует, что конкретный сервис загрузил пакет, вызвал уязвимую ветку или выпустил её в production. Advisory полезен как вход в triage: он даёт идентификатор источника и условие для сопоставления. Вердикт о приложении появляется только после проверки его собственных артефактов и поведения.'), + p('В обычном Node-проекте manifest отвечает на другой вопрос: какую зависимость автор хотел получить. Lockfile фиксирует дерево, которое разрешил package manager для конкретного состояния. SBOM, Software Bill of Materials, фиксирует инвентарь компонентов и их связи для выбранного build-артефакта. NTIA определяет SBOM как формальную запись о деталях и supply-chain relationships компонентов. Эта запись полезна, когда нужно показать provenance, но сама по себе не наблюдает загруженный модуль в работающем процессе.'), + table('Что считать отдельным фактом при первом triage', ['Наблюдение', 'Что оно подтверждает', 'Чего оно не подтверждает', 'Следующее действие'], [ + ['Advisory называет package и version', 'есть внешний вход для разбора', 'наличие CVE в вашем коде или выпуске', 'сохранить URL, дату и scope advisory'], + ['Lockfile содержит exact package@version', 'resolver когда-то выбрал эту запись', 'наличие компонента в deployed artifact', 'найти путь, по которому запись попала в дерево'], + ['SBOM содержит component record', 'в инвентаре есть названный компонент', 'факт загрузки в runtime', 'сверить build identity и источник SBOM'], + ['Есть путь от entry point до пакета', 'есть гипотеза достижимости для названной конфигурации', 'вызов уязвимой функции или exploitability', 'проверить импорт, feature path и входные условия'], + ['Обновление подготовлено', 'есть candidate change', 'совместимость и безопасность выпуска', 'отдельно пройти install, tests и runtime gate'], + ]), + h2('Учебный fixture: только контракт synthetic input'), + p('Ниже используется детерминированный input с именами demo-service и demo-parser. Идентификатор SYNTHETIC-ADVISORY-001 не является CVE, package names и versions не получены из registry, а suppliedPath не взят из trace. Функция не открывает файл, не вызывает scanner, не запускает npm и не импортирует приложение. Поэтому её удобно выполнить на чистой машине, но её PASS означает только то, что учебные поля не перепутаны.'), + code(fixtureCommand), + p('Функция triageSyntheticAdvisory сопоставляет synthetic package и version с двумя заранее заданными списками: lockfile packages и SBOM components. Если запись есть в обоих, она возвращает synthetic-entry-matched и synthetic-component-matched. Она намеренно оставляет vulnerabilityStatus равным not-determined-by-fixture. Даже suppliedPath заканчивается на demo-parser только как договор для review; поле reachability.conclusion остаётся not-assessed-by-fixture. Так пример не делает подмену: совпадение строки не становится доказательством эксплуатации.'), + figure('/assets/editorial/2023/dependency-security-2023-advisory-triage.svg', 'Схема triage advisory: внешний advisory сопоставляется отдельно с lockfile и SBOM; заявленный путь от entry point ведёт к вопросу о достижимости, а не к выводу об уязвимости; затем нужен project review.', 'Схема показывает порядок ручного разбора synthetic advisory. Она не содержит данные сканера, CVE, production trace, сведения о реальном пакете или результат запуска приложения.'), + h2('Достижимость не равна наличию в дереве'), + p('Транзитивная зависимость часто остаётся в lockfile после того, как прямой импорт исчез из исходника. Обратная ситуация тоже возможна: компонент нужен только в development, optional branch, build-time tool или в отключённой функции. Поэтому вопрос «есть ли package?» полезен, но узок. Для достижения кода важны точка входа, mode, loaded configuration, dynamic import, plugin registration и условие, при котором управление доходит до нужной функции. Нельзя честно получить этот набор условий из одной строки lockfile.'), + p('Здесь полезно записать путь в форме hypothesis, а не finding: demo-http-handler → demo-shell → demo-parser. Затем рядом назвать, чем он должен быть проверен в настоящем проекте: статическим import graph, unit или integration test, trace разрешённого окружения, ручным запуском с безопасными test data. Результат каждого способа имеет свою границу. Статический граф покажет возможные связи, тест покажет выбранный сценарий, trace покажет наблюдённый запуск. Ни один из них автоматически не перечисляет все конфигурации и входы.'), + h2('Маршрут: симптом → причина → проверка → действие'), + ol([ + 'Симптом. В alert есть package и version, но в задаче нет ссылки на lockfile, SBOM, build или entry point. Не пишите «уязвимость в production» до этих данных.', + 'Причина. Advisory, resolved dependency, inventory и runtime path были записаны как один факт. Из-за этого reviewer не видит, какое утверждение ещё не доказано.', + 'Проверка. Найдите exact запись в commit lockfile и component record в SBOM, если он привязан к нужному build. Затем опишите один возможный путь от entry point и способ проверить именно его.', + 'Действие. Создайте небольшой triage record: source advisory, package@version, lockfile location, SBOM provenance, reachability hypothesis, owner и deadline следующей проверки.', + 'Проверка модели. Запустите node web/scripts/upgrade-2023-04.mjs --verify-fixture. PASS подтверждает исключительно assertions synthetic контракта и должен оставить vulnerabilityStatus неопределённым.', + 'Решение. Если факты не связываются, оставьте статус «требует project review», а не «ложное срабатывание». Отсутствие доказательства достижимости не становится доказательством отсутствия риска.', + ]), + h2('Как сделать запись пригодной для следующего человека'), + p('Минимальная запись удобнее огромного отчёта, если в ней можно отделить наблюдаемое от предполагаемого. В поле source кладут immutable advisory URL или идентификатор базы. В поле resolved component — package, version и путь в lockfile именно того commit, который проверяется. В provenance SBOM — build ID или другой неизменяемый указатель на артефакт. В reachability — не слово «используется», а точка входа, конфигурация и выбранный метод проверки. В outcome — один из честных статусов: подтверждено в границах метода, не найдено в артефакте или ещё не проверено.'), + p('NIST SP 800-218 v1.1 говорит о безопасных практиках разработки и о необходимости работать с рисками в жизненном цикле. Для этой заметки важна не попытка выдать NIST за scanner, а порядок ответственности: входной сигнал надо связать с конкретным компонентом, решением и evidence. Если пакет обновляется, новая запись получает свой lockfile diff, новый inventory и отдельный test plan. Старый alert нельзя закрывать лишь ссылкой на pull request с изменённым диапазоном versions.'), + h2('Ограничение модели и следующий шаг'), + p('Этот fixture не знает реальную advisory database, schema конкретного SBOM, package manager, private registry, исходный код, feature flags, зависимости ОС или runtime. Он не вычисляет vulnerable range, не строит call graph, не запускает приложение и не выносит вердикт «безопасно». Его полезная граница уже видна в output: всё названо synthetic, достижимость не assessed, а решение всегда требует review в настоящем проекте.'), + p('Следующий рабочий шаг — выбрать один новый advisory и заполнить описанную запись без чувствительных данных. Если SBOM отсутствует, не рисуйте его задним числом: сначала назовите артефакт, который надо инвентаризировать, и владельца генерации. Если lockfile и SBOM расходятся, не угадывайте, какой прав. Сначала найдите commit и build, из которых каждый получен. После этого уже можно обсуждать обновление как контролируемое изменение, а не как реакцию на страшное слово.'), +], [references.advisoryDatabase, references.ntiaSbom, references.ssdf]); + +const mechanism = revision({ + slug: 'editorial-2023-04-mechanism-dependency-security', + title: 'Lockfile и SBOM: какие факты о зависимости они фиксируют', + categories: ['Безопасность', 'Архитектура'], + cover: '/assets/editorial/2023/dependency-security-2023-lockfile-sbom.svg', + excerpt: 'Граница между manifest, resolved tree и SBOM: почему три похожих списка не дают один и тот же ответ о поставленном компоненте.', + readingMinutes: 13, +}, [ + p('После advisory команда часто открывает package.json, меняет видимую версию и считает работу законченной. Цена такой правки проявляется позже: lockfile разрешил другое транзитивное дерево, SBOM относится к старому build, а reviewer не может восстановить, что именно было поставлено. Ошибка не в существовании трёх файлов. Ошибка в ожидании, что manifest, lockfile и инвентарь отвечают на один вопрос.'), + p('Полезнее разделить их роли до изменения. Manifest выражает намерение автора: direct dependency и допустимый range. Lockfile фиксирует resolved tree, которое выбрал конкретный installer при известных ему правилах. SBOM описывает компоненты и supply-chain relationships выбранного software artifact. Runtime отвечает ещё на четвёртый вопрос: что реально было загружено в конкретном процессе. Эти слои связаны, но не взаимозаменяемы; у каждого есть свой владелец и свой способ проверки.'), + h2('Один package — четыре разные границы'), + p('Документация npm 7 описывает package-lock.json как автоматически созданное представление dependency tree, которое помогает последующим installs получить то же дерево. Это сильнее, чем range в manifest, но не превращает файл в журнал уже запущенного сервиса. Installer flags, platform, optional dependencies, lifecycle scripts и выбор workspace могут менять условия, в которых дерево превращается в конкретный build. Поэтому проверить lockfile — обязательный шаг, но назвать его production inventory можно только при явной связи с артефактом.'), + p('NTIA называет SBOM формальной записью о деталях и отношениях компонентов. В отличие от lockfile, SBOM может жить рядом с поставкой и быть полезен вне конкретного package manager. Но полезность требует provenance: кто и когда сгенерировал документ, из какого commit или image, в каком format, где хранится неизменяемая ссылка. SPDX 2.3 описывает спецификацию формата и versioned fields; он не создаёт честную связь с binary автоматически. Файл без указанного artifact identity остаётся списком, происхождение которого невозможно проверить.'), + table('Граница ответственности артефактов', ['Артефакт', 'Главный вопрос', 'Владелец состояния', 'Проверяемый сигнал', 'Чего не обещает'], [ + ['package.json', 'какую direct dependency просит проект?', 'автор change', 'manifest diff и review range', 'точно resolved tree'], + ['package-lock.json', 'какое дерево выбрал installer?', 'resolver и committed lockfile', 'lockfile diff, clean install с теми же правилами', 'что модуль загрузился в process'], + ['SBOM', 'какие компоненты заявлены для named artifact?', 'build pipeline и владелец inventory', 'artifact identity, format, component records', 'что путь вызова достижим'], + ['Runtime evidence', 'что произошло в named scenario?', 'запуск и наблюдение среды', 'test, trace или controlled smoke', 'все возможные configuration paths'], + ['Advisory', 'какой внешний risk signal надо разобрать?', 'источник advisory и triage owner', 'ID, URL, package/version scope', 'факт воздействия на этот сервис'], + ]), + h2('Минимальный graph, который можно прочитать'), + p('Учебный fixture строит три lockfile rows: demo-service зависит от demo-shell, а demo-shell зависит от demo-parser. SBOM намеренно перечисляет только два component records, потому что пример показывает не полноту инвентаря, а необходимость сравнения. buildSyntheticInventory выдаёт строку для каждого package и отдельно помечает, есть ли synthetic запись в SBOM. Статус synthetic-not-listed означает пропуск в специально заданном массиве, не проблему стороннего генератора и не evidence о настоящей поставке.'), + code(`import { + buildSyntheticInventory, + syntheticDependencyInput, +} from './web/scripts/upgrade-2023-04.mjs'; + +const inventory = buildSyntheticInventory(syntheticDependencyInput); +console.log(inventory.rows); +// demo-parser: lockfile = synthetic-listed +// demo-parser: sbom = synthetic-listed +// runtime = not-observed-by-fixture + +console.log(inventory.deploymentInventory); +// not-proven-by-fixture`), + p('Этот пример воспроизводим, потому что все строки определены рядом с функцией. Он честно неполон: не читает package-lock.json, не формирует SPDX или CycloneDX, не строит container image и не знает, какая команда release взяла артефакт. Важно не расширять его обещание. Строка sbom = synthetic-listed подтверждает лишь пересечение двух arrays в памяти. Она не даёт право написать в ticket, что production SBOM обновлён или что transitive dependency больше не существует.'), + figure('/assets/editorial/2023/dependency-security-2023-lockfile-sbom.svg', 'Схема границ: manifest выражает намерение, lockfile фиксирует resolved tree, SBOM связывается с named build artifact, а runtime evidence требует отдельного запуска; стрелки показывают необходимые проверки, а не автоматические гарантии.', 'Схема объясняет владение данными о synthetic dependency. Она не является SBOM, lockfile реального проекта, контейнерной манифестацией или записью выполненного запуска.'), + h2('Почему совпадение списков не заканчивает работу'), + p('Совпадение name и version между lockfile и SBOM — хороший consistency check, но оно оставляет важные вопросы. Какой graph branch выбрал resolver? Не попал ли optional component в другой platform? Совпадает ли current commit с build, для которого получен SBOM? Был ли SBOM regenerated после overrides? Есть ли bundle, native addon или generated source, чей состав не виден в простом списке npm packages? Ответы зависят от build pipeline, а не от красивого JSON-формата.'), + p('Особенно опасно обходить это distinction после patch update. Direct dependency может выглядеть как единственная изменённая строка, но новый resolution меняет transitive children. Поэтому в review полезно смотреть не только manifest, но и lockfile diff: added, removed, version-shifted, integrity или source changes. Затем SBOM generation запускают для candidate artifact и сопоставляют его с тем же commit. Если процесс не умеет такого связывания, результат не «SBOM не нужен», а явный пробел в цепочке evidence, который надо закрыть до уверенного выпуска.'), + h2('Маршрут: симптом → причина → проверка → действие'), + ol([ + 'Симптом. В pull request изменился package.json, но lockfile не изменился, изменился неожиданно широко или рядом лежит SBOM без указания build.', + 'Причина. Намерение автора, resolved tree и inventory artifact смешаны в один этап. Поэтому нельзя понять, какой файл устарел и кто должен его обновить.', + 'Проверка. Назовите commit, installer command и relevant flags для lockfile; затем назовите artifact identity, generator и format для SBOM. Сверяйте exact name/version, но сохраняйте различие уровней.', + 'Действие. Сделайте таблицу соответствий для candidate release: manifest request → lockfile resolution → SBOM component → build artifact. Отсутствующее звено пишите как unknown, не как совпадение.', + 'Проверка контракта. Запустите fixture. Он должен оставить deploymentInventory равным not-proven-by-fixture и не превратить synthetic arrays в настоящий inventory.', + 'Фиксация. В release record храните ссылки на lockfile diff, generated SBOM и artifact ID. Это даёт следующему reviewer точку сравнения для следующего advisory.', + ]), + h2('Как выбирать минимально достаточный SBOM'), + p('Не надо начинать с обещания «соберём идеальный список всего». Для одного pipeline достаточно установить contract: какой artifact считается release unit, кто запускает generator, какой format принимается, где лежит document, как он связывается с immutable build identity и кто проверяет failure. Минимальные элементы NTIA помогают не забыть о data fields, automation support и practices/processes. Но они не диктуют команду package manager и не решают, какие части monorepo являются отдельными поставками.'), + p('Внутри команды полезно хранить не только generated file, но и criterion freshness. Например, SBOM считается актуальным, если он был generated из того же source revision и относится к тому же artifact digest, что release candidate. Это criterion, а не факт в этой статье: его надо реализовать и проверить своим pipeline. Если такой связи нет, честнее назвать документ inventory candidate и не закрывать им вопрос о deployed package. Слова «lockfile» и «SBOM» здесь не являются печатями качества.'), + h2('Ограничение модели и следующий шаг'), + p('Функция buildSyntheticInventory не выполняет install, не парсит реальный lockfile, не создаёт SPDX, не проверяет signature, digest, license или provenance. Она не видит environment variables, bundles, generated assets и runtime imports. Её единственное назначение — показать, что lockfile listing, SBOM listing и runtime observation имеют разные статусы даже у одного demo-parser. Из-за этого PASS fixture не может быть приложен как доказательство состава production image.'), + p('Следующий шаг — взять один существующий build pipeline и нарисовать его по таблице: где появляется manifest diff, где resolver создаёт lockfile, где generator получает artifact, где сохраняется SBOM и какой review видит оба указателя. Если ваш инструментарий не поддерживает часть цепочки, это нормальный результат диагностики. Сначала зафиксируйте precise gap и владельца, затем выберите инструмент. Так update перестаёт быть заменой строки и становится проверяемым изменением supply chain.'), +], [references.npmLock, references.ntiaSbom, references.spdx23, references.ssdf]); + +const field = revision({ + slug: 'editorial-2023-04-field-dependency-security', + title: 'Безопасное обновление зависимости: runtime-совместимость и доказательства выпуска', + categories: ['Безопасность', 'Практика'], + cover: '/assets/editorial/2023/dependency-security-2023-safe-update-gate.svg', + excerpt: 'Полевой маршрут обновления без ложного PASS: diff дерева, чистая установка, тесты, runtime smoke и честный rollback.', + readingMinutes: 13, +}, [ + p('Проблема обновления зависимости в том, что одна version в diff часто выглядит безопасной без доказательств. Цена скрыта за этой простотой: новая версия может сменить transitive tree, peer range, optional branch, lifecycle behavior или предположение о Node runtime. Если merge делают только потому, что advisory страшный, риск переносится в следующий запуск: service не стартует, feature ведёт себя иначе, а откат не подготовлен. Значит, вопрос не «обновили ли пакет», а «какие доказательства нужны, чтобы выпустить именно это изменение».'), + p('Нельзя ответить на него фразой «engines подходят». Поле engines в package.json сообщает заявленный диапазон runtime; в документации npm 8 указано, что без engine-strict оно обычно advisory и даёт warning. Даже строгая проверка range не исполняет application path, не проверяет native addon, browser bundle, peer dependency или внешний protocol. Runtime compatibility — отдельная проверка в названном окружении. Версия в metadata может быть входом для gate, но не доказательством его результата.'), + h2('Соберите candidate change до запуска команд'), + p('Перед npm install надо назвать границы change. Какой package меняется: direct или transitive? Какая fromVersion зафиксирована в baseline lockfile? Какой toVersion предлагается? Что может измениться рядом: другие records, integrity, source URL, peer resolution, scripts, generated output? Какой runtime поддерживает сервис и где это проверяется? У этих вопросов нет одного универсального ответа, но без них невозможно отличить small patch от resolver rewrite. Задача update должна содержать не только target version, но и expected evidence.'), + p('Учебный input в этом sidecar задаёт demo-parser 1.0.0 → 1.0.1 и одинаковую метку node-18-demo у проекта и candidate. Функция planSyntheticSafeUpdate возвращает labelMatches: true, но сразу ставит compatibility и safety в not-proven-by-fixture. Это ключевое свойство примера. Совпадение двух строк проверяемо в памяти; совместимость настоящего процесса — нет. Если убрать это различие, fixture начнёт имитировать результат, которого не получал.'), + table('Gate обновления и его честный результат', ['Gate', 'Что проверяется', 'Артефакт PASS/FAIL', 'Чего недостаточно'], [ + ['Scope и baseline', 'package, from/to version, owner, advisory context', 'reviewable change record', 'что resolver выбрал ожидаемое дерево'], + ['Lockfile diff', 'добавленные, удалённые и changed records', 'diff конкретного commit', 'что install и app tests прошли'], + ['Clean install', 'воспроизводимость tree при зафиксированных правилах', 'CI log и exit status', 'что runtime path совместим'], + ['Project tests', 'выбранные application contracts', 'test report с environment', 'все modes и integrations'], + ['Runtime smoke', 'старт и named scenario в declared environment', 'наблюдаемый log/trace/result', 'отсутствие всех уязвимостей'], + ['Rollback', 'как вернуться к baseline change', 'план и проверяемая процедура', 'что уже применённые внешние эффекты исчезли'], + ]), + h2('Fixture называет gate, но не исполняет его'), + p('Ниже можно увидеть предел модели. Все четыре gates в synthetic input помечены как declared. Это значит только, что автор учебной записи не забыл назвать lockfile diff, clean install, project tests и runtime smoke. У каждого gate поле execution равно not-run-by-fixture. Даже когда все поля true, plan.safety остаётся not-proven-by-fixture. Такой контракт полезен для review: он запрещает переносить булево поле из task template в утверждение о реальной проверке.'), + code(`import { + planSyntheticSafeUpdate, + syntheticDependencyInput, +} from './web/scripts/upgrade-2023-04.mjs'; + +const plan = planSyntheticSafeUpdate(syntheticDependencyInput); +console.log(plan.runtime.labelMatches); // true +console.log(plan.runtime.compatibility); // not-proven-by-fixture +console.log(plan.gates[3].execution); // not-run-by-fixture +console.log(plan.safety); // not-proven-by-fixture + +const rollback = plan.rollback; +console.log(rollback.version); // 1.0.0, только synthetic label`), + p('Запуск node web/scripts/upgrade-2023-04.mjs --verify-fixture проверяет двадцать две assertions о synthetic object. Среди них есть отрицательная ветка с incomplete runtime gate, candidate, который не начинается от advisory version, и отсутствующим project contract. Нет assertion, который запускает npm ci, downloader, test runner или Node process приложения. Это специально: иначе учебный файл зависел бы от сети, локального cache и чужой конфигурации, а его результат пришлось бы выдавать за evidence, которого он не способен собрать.'), + figure('/assets/editorial/2023/dependency-security-2023-safe-update-gate.svg', 'Схема безопасного обновления: scope и advisory ведут к lockfile diff, затем к clean install, project tests и runtime smoke; только доказательства конкретного проекта могут открыть release decision, а rollback возвращает baseline version как отдельный план.', 'Схема показывает последовательность gates и разделяет named checks от результата их выполнения. Она не сообщает, что какой-либо package обновлялся, runtime запускался или release признан безопасным.'), + h2('Runtime-совместимость проверяется в контексте'), + p('Первый контекст — version policy: declared Node range, package manager version, OS, CPU, libc и доступные build tools. Второй — dependency semantics: peer dependencies, optional dependencies, postinstall, native code, exports и module format. Третий — приложение: startup path, configuration, migration, network contract и feature, ради которой package подключён. У одного update могут быть разные владельцы этих частей. Хороший gate называет, кто даёт environment и какой scenario должен завершиться, а не прячет весь риск за названием команды.'), + p('Документация npm 8 для npm ci полезна здесь именно ограничением: команда требует существующий lockfile, не переписывает его и может завершиться ошибкой, если manifest и lockfile не соответствуют друг другу. Это помогает воспроизводимо проверить install contract для определённого репозитория. Но успех npm ci не доказывает startup сервиса или использование конкретной функции. Поэтому clean install идёт до project tests, а runtime smoke — после них. Если проект не умеет безопасно выполнить smoke, это не повод объявлять range совместимым; это повод уменьшить change, подготовить environment или добавить наблюдаемую проверку.'), + h2('Маршрут: симптом → причина → проверка → действие'), + ol([ + 'Симптом. В задаче есть только «обновить package до версии X» и ссылка на advisory. Нет baseline, diff дерева, runtime или rollback.', + 'Причина. Версия воспринимается как локальная строка, хотя update меняет resolution и может пересечь границы installer, build и приложения.', + 'Проверка. Зафиксируйте baseline commit, candidate manifest и lockfile diff. Отдельно назовите package manager, flags, supported runtime и scenario, который обязан пройти.', + 'Действие. На CI выполните clean install с теми же правилами, затем targeted tests и controlled runtime smoke. Сохраните для каждого gate ссылку на log или report, а не итоговую фразу «всё зелёное».', + 'Проверка модели. Запустите fixture: он должен показать, что named gate не равен execution, а matching runtime labels не равны compatibility. PASS не разрешает merge.', + 'Откат. До выпуска запишите baseline lockfile и условия возврата. Если update меняет data format, migration или внешний protocol, одного version rollback недостаточно — остановите задачу и добавьте отдельный release plan.', + ]), + h2('Выберите размер доказательства, а не размер страха'), + p('Не каждый advisory требует одинакового rollout. Но масштаб проверки выбирают по scope изменения и известной границе воздействия, а не по уверенности формулировки. Если update затрагивает только development tool и не попадает в release artifact, proof может ограничиться воспроизводимой установкой и checks pipeline. Если component участвует в server startup или обрабатывает внешние данные, нужен сценарий этой границы. Если затронуты native bindings или protocol, заранее договоритесь о compatible environments и способе убрать candidate. При недостатке информации корректный статус — pending evidence, а не «безопасный patch».'), + p('NIST SP 800-218 рекомендует встроить практики безопасной разработки в жизненный цикл, а не ждать отдельного большого аудита после каждой строки. В таком подходе dependency update оставляет след: source advisory, owner, diff, named checks, реальные результаты, decision и rollback. Форма может быть короткой, но ключевые переходы должны быть наблюдаемы. Нельзя заменять evidence чужим badge, statement package manager или результатом учебной функции. Эти вещи полезны только на своём уровне.'), + h2('Ограничение модели и следующий шаг'), + p('planSyntheticSafeUpdate сравнивает строки demo runtime и хранит четыре declared gates. Он не читает engines, не решает semver range, не вызывает npm ci, не собирает bundle, не устанавливает package, не запускает test и не открывает network. rollbackSyntheticSafeUpdate возвращает только version label внутри object; он не меняет deployment и честно ставит deploymentEffect в not-assessed-by-fixture. Поэтому модель не умеет признать update совместимым или безопасным даже при двадцати зелёных assertions.'), + p('Следующий шаг — применить тот же gate к одному настоящему update, не перенося synthetic values. В task создайте baseline, candidate, expected lockfile diff, environment, test scenario, owner и rollback condition. После каждого фактического запуска приложите реальный artifact результата и подпишите его границу: install, test или runtime. Тогда advisory закрывается не из-за смены строки, а потому что команда может показать, что именно проверила и чего пока не проверяла.'), +], [references.npmPackage, references.npmCi, references.ssdf, references.advisoryDatabase]); + +export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item); + +function verifyFixture() { + const report = runDependencySecurityFixture(); + const failed = Object.entries(report.assertions) + .filter(([, value]) => value !== true) + .map(([key]) => key); + if (failed.length > 0) { + 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');