From 43dc3d839f3b05a7144b8eafd29c7dbd89c43721 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sat, 8 Aug 2026 10:02:18 -0500 Subject: [PATCH 1/2] fix(security): GT-657 fix the half of a js-yaml advisory that has a fix, and name the half that has none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes GT-657. GHSA-5p4m-2wfm-xmqj / CVE-2026-59870 (js-yaml, quadratic CPU in `!!omap`) was published between 2026-08-05 and 2026-08-08 and turned `Security Audit` red on every branch. The dating is the attribution evidence: the dependabot PRs of the 5th were green, PR #440 of the 8th was red, and #440 changed 8 documentation and JSON files with ZERO dependency files. Pre-existing branch debt, not a regression. The half that had a fix, and why it was missed ---------------------------------------------- The root overrides already carried `js-yaml: 4.3.0`, added for an EARLIER advisory. The new one is vulnerable through 4.0.0-4.3.0 INCLUSIVE, so the existing pin was exactly one patch short of it. Bumped to 4.3.1; re-resolving also collapsed three nested copies (@commitlint/load, @istanbuljs/load-nyc-config, cosmiconfig) into the single hoisted one. The half that has none, proven rather than assumed -------------------------------------------------- The remaining advisory arrives through @nestjs/swagger, which pins js-yaml EXACTLY, and every published release pins a vulnerable one: 11.4.4 -> 4.1.1 and 11.4.5 -> 4.3.0 (CVE-2026-59870), 11.4.6 -> 5.2.1 (GHSA-pm4m-ph32-ghv5). 11.4.6 is the latest stable; 12.0.0 is alpha only. npm overrides do not reach it, measured four ways with the same tree every time: a top-level override, a scoped @nestjs/swagger override, both repeated with the unrelated nested override objects removed, and --package-lock-only versus a real npm install. That last experiment REFUTES the generalisation GT-636 recorded — that a nested override object anywhere stops the top-level rule cascading. Removing them changed nothing. What actually blocks the override is the consumer's exact pin. Recorded rather than left standing: a wrong lesson inside a closed row is worse than no lesson. Why an acceptance was not enough -------------------------------- Leaving the job red is what GT-622 was just closed to remove — a permanently red check trains reviewers to discount red, and the next genuinely fixable advisory would land in a job nobody reads. `63-validate-npm-audit-gate` keeps the same HIGH threshold and adds one requirement: an advisory with no upstream fix must be NAMED, with the path it arrives by and what was checked upstream. It fails on an undeclared advisory, on a declaration for a different id or path, and — the rule that stops a graveyard — on a declaration whose advisory has DISAPPEARED, so the good news arrives as a red check asking for the entry's removal. The gate's own fixtures run in the job, because a gate whose exception list can swallow anything is a green button. Verified -------- node --test 63-validate-npm-audit-gate.test.mjs 19/19 (including the undeclared advisory red, the wrong-path declaration covering nothing, the stale declaration failing, and an unparseable registry stopping the run). `npm audit` now reports 0 critical / 2 high, both declared, 3 moderate untouched below the unchanged threshold. core-api 160/160 in 30 suites and core-domain 1704/1704 in 146 suites against the bumped tree; eslint 9.39.4 and jest 30.4.1 both boot. Guards 04, 08, 09 --check, 34, 39, 40, 41 (--execute --strict, 114 executed / 113 exit 0, the one non-zero being GT-78's root-cleanliness objecting to an untracked local .DS_Store absent on the runner), 42, 43, 46, 47, 49 and bilingual-terminology-lint all exit 0. Board: 642 / 655 done, 3 in progress, 3 pending, 7 deferred. Co-Authored-By: Claude Opus 5 --- .github/workflows/sdk-cli-ci.yml | 26 +- .harness/config/npm-audit-exceptions.json | 21 ++ .../scripts/ci/63-validate-npm-audit-gate.mjs | 296 ++++++++++++++++++ .../ci/63-validate-npm-audit-gate.test.mjs | 216 +++++++++++++ .harness/scripts/lib/guard-classification.mjs | 12 + package-lock.json | 46 ++- package.json | 5 +- .../evidence/gap-closure-evidence.json | 17 + .../gaps/gap-reference-catalog.es.md | 23 ++ .../gaps/gap-reference-catalog.md | 23 ++ .../control-center/gaps/gap-tracking.es.md | 3 +- .../core/control-center/gaps/gap-tracking.md | 3 +- .../maturity-reports/executive-summary.es.md | 6 +- .../maturity-reports/executive-summary.md | 6 +- .../maturity-reconciliation.json | 6 +- src/apps/core-api/package.json | 2 +- 16 files changed, 684 insertions(+), 27 deletions(-) create mode 100644 .harness/config/npm-audit-exceptions.json create mode 100644 .harness/scripts/ci/63-validate-npm-audit-gate.mjs create mode 100644 .harness/scripts/ci/63-validate-npm-audit-gate.test.mjs diff --git a/.github/workflows/sdk-cli-ci.yml b/.github/workflows/sdk-cli-ci.yml index 00d002c1d..1b46172c6 100644 --- a/.github/workflows/sdk-cli-ci.yml +++ b/.github/workflows/sdk-cli-ci.yml @@ -107,7 +107,11 @@ jobs: run: npm ci - name: Security Vulnerability Audit - working-directory: ${{ env.CLI_DIR }} + # GT-657: runs from the repository root, not ${{ env.CLI_DIR }}. The scope + # is identical — one root lockfile, audited whole — but the gate reads + # .harness/config/npm-audit-exceptions.json, and a guard should be invoked + # from the tree it is reasoning about rather than resolve its way out of a + # workspace directory. # Scope, made explicit (GT-568): there is a single root lockfile, so this # audits the ENTIRE monorepo dependency tree — every workspace's prod and # dev deps — not just the CLI's. That is deliberate: it is stricter than a @@ -117,7 +121,25 @@ jobs: # patched version with a targeted `overrides` entry in the root # package.json (see js-yaml / brace-expansion / protobufjs) rather than # regenerating package-lock.json wholesale. - run: npm audit --audit-level=high + # + # GT-657: that advice has a third case it could not express — an advisory + # a targeted override CANNOT reach, because the consumer pins its + # dependency EXACTLY and every published release of it pins a vulnerable + # one. `npm audit --audit-level=high` then leaves the job permanently red, + # which is the failure mode GT-622 was opened to remove: a check that is + # always red trains reviewers to discount red, and the next real advisory + # arrives into a job nobody reads. The gate below keeps the same threshold + # and adds exactly one thing — such an advisory must be NAMED, with the + # path it arrives by and what was checked upstream, and the guard turns red + # again the day the exception stops matching a real advisory. + run: node .harness/scripts/ci/63-validate-npm-audit-gate.mjs --verbose + + - name: The audit gate's own fixtures + # A gate whose exception list can swallow anything is a green button. These + # fixtures are what say it cannot: an undeclared advisory is red, a + # declaration for a different path or id covers nothing, and a declaration + # whose advisory is gone fails as stale. + run: node --test .harness/scripts/ci/63-validate-npm-audit-gate.test.mjs # ============================================ # JOB 3: Lint and Type Check diff --git a/.harness/config/npm-audit-exceptions.json b/.harness/config/npm-audit-exceptions.json new file mode 100644 index 000000000..2d0c59883 --- /dev/null +++ b/.harness/config/npm-audit-exceptions.json @@ -0,0 +1,21 @@ +{ + "$comment": "GT-657. A high/critical advisory belongs here ONLY when no upstream fix exists and no `overrides` entry can reach it. Both conditions must be measured before an entry is added, and `noUpstreamFix` must record what was actually checked, not what was assumed. 63-validate-npm-audit-gate fails when an entry stops matching a real advisory, so an exception cannot outlive the hole it excuses.", + "exceptions": [ + { + "id": "GHSA-pm4m-ph32-ghv5", + "package": "js-yaml", + "path": "node_modules/@nestjs/swagger/node_modules/js-yaml", + "declaredAt": "2026-08-08", + "noUpstreamFix": "Every published @nestjs/swagger release pins js-yaml to an EXACT version and all three are vulnerable: 11.4.4 -> 4.1.1 and 11.4.5 -> 4.3.0 (CVE-2026-59870, vulnerable 4.0.0-4.3.0), 11.4.6 -> 5.2.1 (this advisory, vulnerable 5.0.0-5.2.1). 11.4.6 is the latest stable; 12.0.0 exists only as alpha. npm overrides do not rewrite that nested exact spec, measured four ways: a top-level `js-yaml` override, a scoped `@nestjs/swagger: { js-yaml }` override, both with the unrelated nested override objects removed to test whether they blocked the cascade (they did not), and through `npm install --package-lock-only` as well as a real `npm install`. Every route produced the same tree.", + "reason": "The advisory is a denial of service in js-yaml's parser, triggered by parsing adversarial YAML. @nestjs/swagger uses js-yaml to SERIALISE the OpenAPI document this service generates from its own decorators; core-api never parses caller-supplied YAML through it. The exposure is a parser this repository never points at untrusted input. This is an acceptance of a specific hole with a known shape, not of the package: the moment @nestjs/swagger ships a patched pin, this entry stops matching and the gate turns red asking for its removal." + }, + { + "id": "via:js-yaml", + "package": "@nestjs/swagger", + "path": "node_modules/@nestjs/swagger", + "declaredAt": "2026-08-08", + "noUpstreamFix": "Not an advisory against @nestjs/swagger itself. npm reports the parent separately as `depends on vulnerable versions of js-yaml`, so it clears exactly when GHSA-pm4m-ph32-ghv5 above clears, and never independently.", + "reason": "The derived half of the row above. Declared separately because npm reports it as its own high-severity row, and an exception that silently swallowed the parent would hide a future advisory that genuinely lands on @nestjs/swagger itself." + } + ] +} diff --git a/.harness/scripts/ci/63-validate-npm-audit-gate.mjs b/.harness/scripts/ci/63-validate-npm-audit-gate.mjs new file mode 100644 index 000000000..53f141472 --- /dev/null +++ b/.harness/scripts/ci/63-validate-npm-audit-gate.mjs @@ -0,0 +1,296 @@ +#!/usr/bin/env node + +/** + * GT-657 — a HIGH advisory with no upstream fix must be named, not tolerated. + * + * ## The defect + * + * `Security Audit` ran `npm audit --audit-level=high` and had exactly two + * outcomes: green, or red until someone bumps something. On 2026-08-08 a third + * situation appeared and the job had no way to express it — a HIGH advisory that + * CANNOT be fixed from this repository: + * + * GHSA-pm4m-ph32-ghv5 (js-yaml, exponential parsing time in flow collections) + * reaches the tree through `@nestjs/swagger`, which pins js-yaml EXACTLY. + * Every published release pins a vulnerable one — 11.4.4 -> 4.1.1, + * 11.4.5 -> 4.3.0, 11.4.6 -> 5.2.1 — and npm `overrides` do not rewrite that + * nested exact spec: measured with a top-level override, with a scoped + * override, with the other nested override objects removed, and through both + * `--package-lock-only` and a real `npm install`. Four routes, same tree. + * + * Leaving the job red is not neutral. It is precisely what + * [GT-622] spent a day removing: a permanently red check trains reviewers to + * discount red checks, and the next REAL advisory would arrive into a job + * everyone had already learned to ignore. + * + * ## What it checks + * + * Every `high`/`critical` advisory `npm audit` reports must either be absent or + * be declared in `.harness/config/npm-audit-exceptions.json` with a reason. The + * declaration names the ADVISORY and the PATH it arrives by, so an exception + * covers one known hole and not a package forever. + * + * Exceptions are themselves checked, in both directions: + * + * - an undeclared high/critical -> FAILS (the whole point) + * - a declared one that is still present -> reported, with its reason + * - a declared one that has DISAPPEARED -> FAILS as stale + * + * That last rule is what stops the file becoming a graveyard: the day + * `@nestjs/swagger` ships a patched pin, this guard turns red and says so, + * instead of silently carrying an exemption nobody re-reads. + * + * ## Anti-vacuous pass + * + * `npm audit --json` that cannot be parsed, or that reports no `metadata`, is a + * hard failure. "The audit did not run" must never read as "the audit found + * nothing" — that is the failure mode this corpus keeps finding. + * + * USAGE + * node .harness/scripts/ci/63-validate-npm-audit-gate.mjs + * node .harness/scripts/ci/63-validate-npm-audit-gate.mjs --verbose + * node .harness/scripts/ci/63-validate-npm-audit-gate.mjs --audit-json + * + * EXIT CODES + * 0 every high/critical advisory is declared, and every declaration is live + * 1 an undeclared advisory, a stale declaration, or an audit that did not run + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const GUARD = '63-validate-npm-audit-gate'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '../../..'); + +export const EXCEPTIONS = '.harness/config/npm-audit-exceptions.json'; +const BLOCKING = new Set(['high', 'critical']); + +// --------------------------------------------------------------------------- +// Pure core +// --------------------------------------------------------------------------- + +/** + * Flatten `npm audit --json` into one row per (advisory, path). + * + * A package appears once per advisory that reaches it, and the PATH is part of + * the identity: the same advisory arriving through a different parent is a + * different hole, and an exception written for one must not silently cover it. + * + * @param {object} report + * @returns {Array<{id: string, package: string, severity: string, title: string, via: string, paths: string[]}>} + */ +export function blockingAdvisories(report) { + const rows = []; + for (const [name, entry] of Object.entries(report?.vulnerabilities ?? {})) { + if (!BLOCKING.has(entry.severity)) continue; + const direct = (entry.via ?? []).filter((v) => typeof v === 'object'); + if (direct.length === 0) { + // Reached only through another vulnerable package; identified by that chain. + rows.push({ + id: `via:${(entry.via ?? []).filter((v) => typeof v === 'string').join('+') || 'unknown'}`, + package: name, + severity: entry.severity, + title: `depends on a vulnerable ${(entry.via ?? []).join(', ')}`, + via: (entry.via ?? []).filter((v) => typeof v === 'string').join(', '), + paths: [...(entry.nodes ?? [])].sort(), + }); + continue; + } + for (const advisory of direct) { + rows.push({ + id: advisoryId(advisory), + package: name, + severity: entry.severity, + title: advisory.title ?? '(no title)', + via: advisory.url ?? '', + paths: [...(entry.nodes ?? [])].sort(), + }); + } + } + return rows.sort((a, b) => `${a.package}${a.id}`.localeCompare(`${b.package}${b.id}`)); +} + +/** GHSA id when npm gives one, falling back to its numeric advisory id. */ +export function advisoryId(advisory) { + const fromUrl = /\/advisories\/(GHSA-[\w-]+)/.exec(advisory?.url ?? ''); + if (fromUrl) return fromUrl[1]; + if (advisory?.source != null) return `npm:${advisory.source}`; + return 'unknown'; +} + +/** + * Match advisories against declarations, in BOTH directions. + * + * @param {Array<{id:string,package:string,paths:string[]}>} advisories + * @param {Array<{id:string,package:string,path:string}>} exceptions + */ +export function reconcile(advisories, exceptions) { + const covered = []; + const undeclared = []; + const usedKeys = new Set(); + + for (const a of advisories) { + const match = exceptions.find( + (e) => e.id === a.id && e.package === a.package && a.paths.includes(e.path), + ); + if (match) { + covered.push({ advisory: a, exception: match }); + usedKeys.add(`${match.id}|${match.package}|${match.path}`); + } else { + undeclared.push(a); + } + } + + const stale = exceptions.filter((e) => !usedKeys.has(`${e.id}|${e.package}|${e.path}`)); + return { covered, undeclared, stale }; +} + +/** Shape check for one declaration; every field carries weight. */ +export function validateException(entry, index) { + const at = `exceptions[${index}]`; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return [`${at} is not an object`]; + const problems = []; + for (const field of ['id', 'package', 'path', 'reason', 'noUpstreamFix']) { + if (typeof entry[field] !== 'string' || entry[field].trim() === '') { + problems.push(`${at}.${field} must be a non-empty string`); + } + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.declaredAt ?? '')) { + problems.push(`${at}.declaredAt must be YYYY-MM-DD`); + } + return problems; +} + +// --------------------------------------------------------------------------- +// I/O edges +// --------------------------------------------------------------------------- + +function fail(lines) { + console.error(`\n✗ ${GUARD}: ${lines[0]}`); + for (const l of lines.slice(1)) console.error(` ${l}`); + process.exit(1); +} + +function readExceptions(root) { + const file = path.join(root, EXCEPTIONS); + if (!fs.existsSync(file)) return []; + + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + fail([ + `${EXCEPTIONS} exists but is not valid JSON: ${error.message}`, + 'An exception registry that cannot be read must stop the run, never be', + 'treated as empty — that would report a pass over a file nobody could check.', + ]); + } + if (!Array.isArray(parsed?.exceptions)) { + fail([`${EXCEPTIONS} has no \`exceptions\` array.`, 'Expected: { "exceptions": [ { id, package, path, declaredAt, noUpstreamFix, reason } ] }']); + } + const problems = parsed.exceptions.flatMap((e, i) => validateException(e, i)); + if (problems.length) { + fail([`${problems.length} malformed exception(s) in ${EXCEPTIONS}:`, ...problems.map((p) => ` • ${p}`)]); + } + return parsed.exceptions; +} + +function runAudit(root) { + // `npm audit` exits non-zero when it FINDS something, which is not an error + // here — the report is the output either way. + let raw; + try { + raw = execFileSync('npm', ['audit', '--json'], { + cwd: root, encoding: 'utf8', maxBuffer: 128 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch (error) { + raw = error.stdout; + } + if (!raw || !raw.trim()) { + fail([ + '`npm audit --json` produced no output, so nothing was checked.', + 'The audit failing to RUN must never read as the audit finding nothing.', + ]); + } + try { + return JSON.parse(raw); + } catch (error) { + fail(['`npm audit --json` output is not valid JSON — the audit did not complete.', error.message]); + } +} + +function main(argv) { + const rootIdx = argv.indexOf('--root'); + const root = rootIdx !== -1 ? path.resolve(process.cwd(), argv[rootIdx + 1]) : REPO_ROOT; + const jsonIdx = argv.indexOf('--audit-json'); + const verbose = argv.includes('--verbose'); + + const report = jsonIdx !== -1 + ? JSON.parse(fs.readFileSync(path.resolve(process.cwd(), argv[jsonIdx + 1]), 'utf8')) + : runAudit(root); + + if (!report?.metadata?.vulnerabilities) { + fail([ + 'the audit report carries no `metadata.vulnerabilities`, so its denominator is unknown.', + 'A report of an unknown shape must not read as a clean tree.', + ]); + } + + const advisories = blockingAdvisories(report); + const exceptions = readExceptions(root); + const { covered, undeclared, stale } = reconcile(advisories, exceptions); + const totals = report.metadata.vulnerabilities; + + console.log(`${GUARD} — every high advisory is fixed or named`); + console.log(` audited ............. ${totals.total} advisory/ies (${totals.critical} critical, ${totals.high} high, ${totals.moderate} moderate, ${totals.low} low)`); + console.log(` blocking rows ....... ${advisories.length}`); + console.log(` declared exceptions . ${exceptions.length} (${covered.length} live, ${stale.length} stale)`); + console.log(` undeclared .......... ${undeclared.length}`); + + // Never silent: an exemption nobody sees is indistinguishable from a hole. + for (const { advisory, exception } of covered) { + console.log(` · ${advisory.id} ${advisory.package} — accepted ${exception.declaredAt}: ${exception.reason}`); + } + if (verbose) { + for (const a of advisories) console.log(` · ${a.severity} ${a.id} ${a.package} @ ${a.paths.join(', ')}`); + } + + if (stale.length > 0) { + fail([ + `${stale.length} declared exception(s) no longer match any advisory:`, + ...stale.map((e) => ` • ${e.id} ${e.package} @ ${e.path} — declared ${e.declaredAt}`), + '', + ' This is the good news arriving as a red check, on purpose: the advisory is', + ' gone or has moved, so the exception must go with it. Delete the entry.', + ' An exception list that outlives what it excused is how a graveyard starts.', + ]); + } + + if (undeclared.length > 0) { + fail([ + `${undeclared.length} high/critical advisory/ies are neither fixed nor declared:`, + ...undeclared.flatMap((a) => [ + ` • ${a.severity} ${a.id} — ${a.package}`, + ` ${a.title}`, + ` at ${a.paths.join(', ')}`, + ]), + '', + ' Fix it if a fix exists — a version bump or a targeted `overrides` entry in', + ' the root package.json. Declare it ONLY when no upstream fix exists, in', + ` ${EXCEPTIONS}, naming the advisory AND the path it arrives by:`, + ' { "exceptions": [ { "id": "GHSA-...", "package": "...", "path": "node_modules/...",', + ' "declaredAt": "YYYY-MM-DD", "noUpstreamFix": "what was checked",', + ' "reason": "why it is acceptable here" } ] }', + ]); + } + + console.log(`\n✓ ${GUARD}: 0 undeclared high/critical advisories; ${covered.length} accepted with a recorded reason, ${stale.length} stale.`); + return 0; +} + +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) process.exit(main(process.argv.slice(2))); diff --git a/.harness/scripts/ci/63-validate-npm-audit-gate.test.mjs b/.harness/scripts/ci/63-validate-npm-audit-gate.test.mjs new file mode 100644 index 000000000..8041ab364 --- /dev/null +++ b/.harness/scripts/ci/63-validate-npm-audit-gate.test.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node + +/** + * GT-657 — fixtures for the npm-audit gate. + * + * The case that matters is the one that makes this guard worth having over a + * plain `npm audit --audit-level=high`: an advisory that is NOT declared must + * still be red. A gate whose exception list can swallow anything is a green + * button, and the situation it was built for — a high advisory with no upstream + * fix — is exactly when someone is tempted to build one. + * + * The audit report is injected with `--audit-json` rather than run for real: + * the guard's job is reconciling a report against declarations, and running npm + * here would test npm. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +import { + blockingAdvisories, + advisoryId, + reconcile, + validateException, + EXCEPTIONS, +} from './63-validate-npm-audit-gate.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const GUARD = resolve(__dirname, '63-validate-npm-audit-gate.mjs'); + +let sandbox; +before(() => { sandbox = mkdtempSync(join(tmpdir(), 'gt657-')); }); +after(() => { if (sandbox) rmSync(sandbox, { recursive: true, force: true }); }); + +const GHSA = 'GHSA-pm4m-ph32-ghv5'; +const NESTED = 'node_modules/@nestjs/swagger/node_modules/js-yaml'; + +const report = (vulns, totals = {}) => ({ + metadata: { vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0, total: 1, ...totals } }, + vulnerabilities: vulns, +}); + +const jsYamlHigh = { + 'js-yaml': { + severity: 'high', + nodes: [NESTED], + via: [{ title: 'Exponential parsing time', url: `https://github.com/advisories/${GHSA}`, severity: 'high' }], + }, +}; + +const exception = (over = {}) => ({ + id: GHSA, + package: 'js-yaml', + path: NESTED, + declaredAt: '2026-08-08', + noUpstreamFix: 'every published release pins a vulnerable version', + reason: 'the parser is never pointed at untrusted input here', + ...over, +}); + +/** A tree carrying an exceptions file and an audit report on disk. */ +const treeWith = (name, exceptions, auditReport) => { + const root = join(sandbox, name); + mkdirSync(join(root, dirname(EXCEPTIONS)), { recursive: true }); + if (exceptions !== undefined) { + writeFileSync(join(root, EXCEPTIONS), typeof exceptions === 'string' ? exceptions : JSON.stringify(exceptions, null, 2)); + } + const json = join(root, 'audit.json'); + writeFileSync(json, JSON.stringify(auditReport)); + return { root, json }; +}; + +const run = ({ root, json }, extra = []) => { + const r = spawnSync(process.execPath, [GUARD, '--root', root, '--audit-json', json, ...extra], { + encoding: 'utf8', timeout: 120000, + }); + return { status: r.status, out: `${r.stdout}\n${r.stderr}` }; +}; + +// --------------------------------------------------------------------------- +// Pure core +// --------------------------------------------------------------------------- + +describe('advisoryId', () => { + it('prefers the GHSA id in the advisory URL', () => { + assert.equal(advisoryId({ url: `https://github.com/advisories/${GHSA}` }), GHSA); + }); + + it('falls back to the npm source id, and says so rather than inventing one', () => { + assert.equal(advisoryId({ source: 1234 }), 'npm:1234'); + assert.equal(advisoryId({}), 'unknown'); + }); +}); + +describe('blockingAdvisories', () => { + it('keeps high and critical, drops the rest', () => { + const rows = blockingAdvisories(report({ + ...jsYamlHigh, + hono: { severity: 'moderate', nodes: ['node_modules/hono'], via: [{ title: 'ReDoS', url: 'x' }] }, + })); + assert.equal(rows.length, 1); + assert.equal(rows[0].package, 'js-yaml'); + }); + + it('keeps a parent reached only through another vulnerable package', () => { + const rows = blockingAdvisories(report({ + '@nestjs/swagger': { severity: 'high', nodes: ['node_modules/@nestjs/swagger'], via: ['js-yaml'] }, + })); + assert.equal(rows.length, 1); + assert.equal(rows[0].id, 'via:js-yaml'); + }); + + it('an empty report yields nothing to block on', () => { + assert.deepEqual(blockingAdvisories(report({}, { high: 0, total: 0 })), []); + }); +}); + +describe('reconcile', () => { + const advisories = blockingAdvisories(report(jsYamlHigh)); + + it('an exact declaration covers its advisory', () => { + const r = reconcile(advisories, [exception()]); + assert.equal(r.covered.length, 1); + assert.deepEqual(r.undeclared, []); + assert.deepEqual(r.stale, []); + }); + + it('THE ABUSE CASE: a declaration for a different PATH covers nothing', () => { + // Otherwise one exception excuses the same advisory wherever it later appears. + const r = reconcile(advisories, [exception({ path: 'node_modules/js-yaml' })]); + assert.equal(r.undeclared.length, 1); + assert.equal(r.stale.length, 1); + }); + + it('a declaration for a different advisory id covers nothing', () => { + const r = reconcile(advisories, [exception({ id: 'GHSA-somethingelse' })]); + assert.equal(r.undeclared.length, 1); + }); + + it('an advisory that has gone away leaves its declaration STALE', () => { + const r = reconcile([], [exception()]); + assert.equal(r.stale.length, 1); + }); +}); + +describe('validateException', () => { + it('accepts a complete declaration', () => { + assert.deepEqual(validateException(exception(), 0), []); + }); + + it('rejects one with no reason, and one with no upstream-fix evidence', () => { + assert.match(validateException(exception({ reason: ' ' }), 0)[0], /reason must be a non-empty string/); + assert.match(validateException(exception({ noUpstreamFix: '' }), 0)[0], /noUpstreamFix must be a non-empty string/); + }); + + it('rejects a missing or malformed date', () => { + assert.match(validateException(exception({ declaredAt: 'soon' }), 0)[0], /declaredAt must be YYYY-MM-DD/); + }); +}); + +// --------------------------------------------------------------------------- +// End to end +// --------------------------------------------------------------------------- + +describe('the gate', () => { + it('THE FIXTURE: an undeclared high advisory is RED', () => { + const { status, out } = run(treeWith('undeclared', { exceptions: [] }, report(jsYamlHigh))); + assert.equal(status, 1, out); + assert.match(out, /neither fixed nor declared/); + assert.match(out, new RegExp(GHSA)); + // It must not merely say "no": it must say what would make it a yes. + assert.match(out, /Declare it ONLY when no upstream fix exists/); + }); + + it('a declared advisory is green, and the reason is printed', () => { + const { status, out } = run(treeWith('declared', { exceptions: [exception()] }, report(jsYamlHigh))); + assert.equal(status, 0, out); + assert.match(out, /0 undeclared high\/critical/); + assert.match(out, /never pointed at untrusted input here/); + }); + + it('THE GOOD NEWS AS A RED CHECK: a declaration whose advisory is gone fails', () => { + const clean = report({}, { high: 0, total: 0 }); + const { status, out } = run(treeWith('stale', { exceptions: [exception()] }, clean)); + assert.equal(status, 1, out); + assert.match(out, /no longer match any advisory/); + assert.match(out, /An exception list that outlives what it excused/); + }); + + it('no exceptions file at all means no exemptions', () => { + const { status } = run(treeWith('absent', undefined, report(jsYamlHigh))); + assert.equal(status, 1); + }); + + it('an unreadable registry stops the run rather than reading as "no exemptions"', () => { + const { status, out } = run(treeWith('broken', '{ not json', report(jsYamlHigh))); + assert.equal(status, 1, out); + assert.match(out, /is not valid JSON/); + }); + + it('a clean tree with no declarations is green', () => { + const { status, out } = run(treeWith('clean', { exceptions: [] }, report({}, { high: 0, total: 0 }))); + assert.equal(status, 0, out); + }); + + it('a report of an unknown shape must not read as a clean tree', () => { + const { status, out } = run(treeWith('shapeless', { exceptions: [] }, { totals: 'moved' })); + assert.equal(status, 1, out); + assert.match(out, /denominator is unknown/); + }); +}); diff --git a/.harness/scripts/lib/guard-classification.mjs b/.harness/scripts/lib/guard-classification.mjs index 242220541..8e8775e4b 100644 --- a/.harness/scripts/lib/guard-classification.mjs +++ b/.harness/scripts/lib/guard-classification.mjs @@ -40,6 +40,18 @@ export const CALLS_COVERAGE = /\b(?:assertScanned|assertScannedPerSource|scanned * deleting the check and leaving the exemption behind. */ export const SELF_GUARDED = [ + { + file: '63-validate-npm-audit-gate.mjs', + proof: /denominator is unknown/, + reason: + 'GT-657 npm-audit gate; its denominator is the audit report itself, so the two ways it ' + + 'could pass over nothing are both hard failures: `npm audit --json` that produces no ' + + 'output or does not parse ("the audit failing to RUN must never read as the audit finding ' + + 'nothing"), and a report carrying no `metadata.vulnerabilities`, whose shape has moved and ' + + 'whose denominator is therefore unknown. It also fails in the OTHER direction, which a ' + + 'scan count cannot express: a declared exception that no longer matches any advisory is ' + + 'stale and red, so the exception list cannot outlive the hole it excuses', + }, { file: '50-validate-gap-claim.mjs', proof: /could not read the open pull requests/, diff --git a/package-lock.json b/package-lock.json index f1794ccd4..3585f0911 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2657,18 +2657,20 @@ } }, "node_modules/@nestjs/swagger": { - "version": "11.4.4", + "version": "11.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.6.tgz", + "integrity": "sha512-Le136h2WC7HGsd70+WyK1qrm+Zq7kFxBLkYC1JgAVqNRCt8kNh7bMF7Qkn65D5j2t/aks0+VbWmUVlYIwPrs3A==", "license": "MIT", "dependencies": { "@microsoft/tsdoc": "0.16.0", "@nestjs/mapped-types": "2.1.1", - "js-yaml": "4.1.1", + "js-yaml": "5.2.1", "lodash": "4.18.1", "path-to-regexp": "8.4.2", - "swagger-ui-dist": "5.32.6" + "swagger-ui-dist": "5.32.8" }, "peerDependencies": { - "@fastify/static": "^8.0.0 || ^9.0.0", + "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "class-transformer": "*", @@ -2687,6 +2689,28 @@ } } }, + "node_modules/@nestjs/swagger/node_modules/js-yaml": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", + "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, "node_modules/@nestjs/terminus": { "version": "11.1.1", "license": "MIT", @@ -7856,6 +7880,8 @@ }, "node_modules/@scarf/scarf": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", "hasInstallScript": true, "license": "Apache-2.0" }, @@ -12910,9 +12936,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", @@ -14947,7 +14973,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.32.6", + "version": "5.32.8", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", + "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -16170,7 +16198,7 @@ "@nestjs/config": "4.0.4", "@nestjs/core": "11.1.27", "@nestjs/platform-express": "11.1.28", - "@nestjs/swagger": "11.4.4", + "@nestjs/swagger": "11.4.6", "@nestjs/terminus": "11.1.1", "@nestjs/throttler": "6.5.0", "@opentelemetry/api": "1.9.1", diff --git a/package.json b/package.json index f55b317e7..61193d373 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ }, "overrides": { "multer": "2.2.0", - "js-yaml": "4.3.0", + "js-yaml": "4.3.1", "brace-expansion": "5.0.9", "protobufjs": "7.6.5", "micromatch": "4.0.8", @@ -40,9 +40,6 @@ "@eslint/eslintrc": { "ajv": "^6.12.6" }, - "@nestjs/swagger": { - "js-yaml": "4.3.0" - }, "ip-address": "10.4.0" } } diff --git a/reference/core/control-center/evidence/gap-closure-evidence.json b/reference/core/control-center/evidence/gap-closure-evidence.json index 84e6e5eb9..ffb3ac268 100644 --- a/reference/core/control-center/evidence/gap-closure-evidence.json +++ b/reference/core/control-center/evidence/gap-closure-evidence.json @@ -9779,6 +9779,23 @@ ], "dependencyDisposition": "accepted-scope", "dependencyRationale": "The mechanism reads the ENGLISH catalog only, because that is the document the guard has always parsed and the id/title pair it protects lives there. A Spanish-only title change is therefore not covered, which is deliberate rather than overlooked: 04-check-bilingual-parity is the control for EN/ES divergence, and duplicating title comparison here would put two guards on one invariant. The declaration for GT-622 states the English titles; the Spanish title was corrected in the same change. The two branch comparisons that prove the retitle lands green (--base origin/main and --base origin/develop) are recorded in the catalog section as prose, NOT here: this registry is EXECUTED by 41-validate-evidence-commands, and a remote ref is not resolvable in the runner checkout — recording them here made the Governance guards job red, which is the correct verdict on an unrunnable command rather than a nuisance." + }, + { + "id": "GT-657", + "closedAt": "2026-08-08", + "closureCommit": "1fc9ea5b", + "evidence": [ + ".harness/scripts/ci/63-validate-npm-audit-gate.mjs", + ".harness/scripts/ci/63-validate-npm-audit-gate.test.mjs", + ".harness/config/npm-audit-exceptions.json", + "package.json" + ], + "validationCommands": [ + "node --test .harness/scripts/ci/63-validate-npm-audit-gate.test.mjs", + "node .harness/scripts/ci/63-validate-npm-audit-gate.mjs --verbose" + ], + "dependencyDisposition": "accepted-scope", + "dependencyRationale": "GHSA-pm4m-ph32-ghv5 in js-yaml, reached through @nestjs/swagger, is accepted rather than fixed because no fix exists to apply: all three published swagger releases pin js-yaml exactly and all three pin a vulnerable version (11.4.4 -> 4.1.1, 11.4.5 -> 4.3.0, 11.4.6 -> 5.2.1), and npm overrides do not rewrite that nested exact spec — measured through a top-level override, a scoped override, both with the unrelated nested override objects removed, and through --package-lock-only as well as a real npm install. The acceptance is recorded in .harness/config/npm-audit-exceptions.json against the advisory AND its path, and 63-validate-npm-audit-gate fails the day it stops matching a real advisory, so it cannot outlive the hole it excuses. The three moderate advisories stay below the unchanged HIGH threshold and are untouched." } ] } diff --git a/reference/core/control-center/gaps/gap-reference-catalog.es.md b/reference/core/control-center/gaps/gap-reference-catalog.es.md index 3ba5934d8..8307e3806 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -8426,3 +8426,26 @@ La lección es la del propio tablero y esta vez la pagó quien medía: un `conta - [x] El gemelo negativo se observa en rojo, no se supone: el mismo retítulo SIN declaración sigue fallando, y una declaración que cita un título que el catálogo nunca llevó no lo blanquea. `node --test` 27/27. - **Primer uso:** el título de GT-622 se corrige en el mismo cambio que construyó el mecanismo —82 → 210, "cada PR" → "los PR contra `develop`"— con la declaración cargando el porqué. Esa es la prueba del propio cierre: el guard sale verde con el retítulo aplicado contra `origin/main` y contra `origin/develop`. - **Estado:** `COMPLETADO` (2026-08-08) + +#### GT-657 + +**Título:** Un advisory de js-yaml sin arreglo aguas arriba, y un job de auditoría sin forma de decirlo + +- **Propósito / Problema:** Cerrar la mitad de un advisory HIGH nuevo que un pin sí puede cerrar, y dar al job de auditoría una forma de expresar la otra mitad —un advisory que este repositorio demostrablemente no puede arreglar— sin dejar un check rojo para siempre. +- **Evidencia:** `GHSA-5p4m-2wfm-xmqj` / CVE-2026-59870 (js-yaml, consumo cuadrático de CPU al resolver `!!omap`) se publicó entre el 2026-08-05 y el 2026-08-08. Las fechas son la evidencia, no el color: los PR de dependabot del día 5 salieron verdes, el PR #440 del día 8 salió rojo, y el #440 cambiaba 8 ficheros de documentación y JSON con **cero** ficheros de dependencias. Es deuda de rama preexistente en `main` y `develop`, no una regresión — la misma distinción que tuvo que hacer [`GT-636`](./gap-reference-catalog.es.md#gt-636). +- **La mitad que tenía arreglo, y por qué se pasó por alto:** los `overrides` de raíz ya llevaban `js-yaml: 4.3.0`, puesto por un advisory ANTERIOR. El nuevo es vulnerable en `4.0.0 - 4.3.0` inclusive, así que el pin existente se quedó exactamente una patch corto. `4.3.1` lo cierra, y re-resolver además colapsó tres copias anidadas (`@commitlint/load`, `@istanbuljs/load-nyc-config`, `cosmiconfig`) en la única hoisted. +- **La mitad que no tiene ninguno.** El advisory restante llega por `@nestjs/swagger`, que fija `js-yaml` **exacto**, y todas sus versiones publicadas fijan una vulnerable: `11.4.4` → `4.1.1` y `11.4.5` → `4.3.0` (ambas dentro de CVE-2026-59870), `11.4.6` → `5.2.1` (dentro de `GHSA-pm4m-ph32-ghv5`, vulnerable `5.0.0 - 5.2.1`). `11.4.6` es la última estable; `12.0.0` solo existe como alpha. No hay versión a la que subir. +- **Los `overrides` de npm no lo alcanzan, y eso se midió en vez de suponerse** — cuatro rutas, el mismo árbol siempre: override de raíz para `js-yaml`; override anidado `@nestjs/swagger: { js-yaml }`; ambos repetidos quitando temporalmente los objetos anidados ajenos (`eslint`/`@eslint/eslintrc` → `ajv`) para comprobar si su presencia bloqueaba la cascada; y `npm install --package-lock-only` frente a un `npm install` real. **Esto REFUTA la generalización que registró [`GT-636`](./gap-reference-catalog.es.md#gt-636)** —que un objeto anidado en cualquier parte impide la cascada de la regla de nivel superior—. Quitarlos no cambió nada; lo que de verdad bloquea el override es el pin EXACTO del consumidor. Se registra aquí en vez de dejarlo en pie, porque una lección equivocada dentro de una fila cerrada es peor que ninguna lección. +- **Por qué no bastaba con aceptarlo.** Dejar `Security Audit` en rojo es exactamente lo que [`GT-622`](./gap-reference-catalog.es.md#gt-622) se acababa de cerrar para quitar: un check permanentemente rojo enseña a los revisores a descontar los rojos, y el siguiente advisory realmente arreglable aterrizaría en un job que todos habrían aprendido a ignorar. El umbral no cambia; lo que se añade es que un advisory inarreglable debe estar NOMBRADO. +- **Componente:** `Security` · **Criticidad:** P2 · **Complejidad:** M +- **Principal:** `M` · **Interés:** `HIGH` · **Base:** `estimate` +- **Procedencia:** Encontrado el 2026-08-08 como el único check rojo que quedaba en el PR #440 después de que GT-622 y GT-656 salieran verdes. Registrado en vez de descartado como ruido, porque "no lo causó este PR" es una afirmación sobre la atribución, no sobre si el advisory es real. +- **Criterios de aceptación:** + - [x] La mitad arreglable se arregla en su origen: override de raíz `js-yaml` `4.3.0` → `4.3.1`, verificado re-resolviendo — un único `4.3.1` hoisted donde había cuatro posiciones, y CVE-2026-59870 fuera del reporte. + - [x] La mitad inarreglable se demuestra inarreglable antes de aceptarse: las tres versiones de `@nestjs/swagger` enumeradas con el `js-yaml` exacto que fija cada una, y cuatro rutas de override medidas. + - [x] `Security Audit` falla ante cualquier advisory high/critical no declarado — el umbral no cambia, y los fixtures lo observan en rojo. + - [x] Una declaración se ata a un advisory Y a la ruta por la que llega, para que no pueda excusar el mismo id apareciendo después en otro sitio. + - [x] Una declaración cuyo advisory ha desaparecido FALLA como caduca, para que la buena noticia llegue como un check rojo pidiendo quitar la entrada, y no como una exención que nadie relee. + - [x] Los fixtures del propio gate corren en el job — un gate cuya lista de excepciones puede tragarse cualquier cosa es un botón verde. `node --test` 19/19. +- **Lo que deliberadamente NO se cubre:** tres advisories `moderate` (`hono`, `@hono/node-server`, `@modelcontextprotocol/sdk`) quedan por debajo del umbral HIGH y siguen intactos, como estaban. Bajar el umbral es otra decisión y esta fila no la toma. +- **Estado:** `COMPLETADO` (2026-08-08) diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index 9f17dcebe..ba18a4d52 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -8521,3 +8521,26 @@ The lesson is the board's own, and this time the measurer paid it: a `contains` - [x] The negative twin is observed red, not assumed: the same retitle WITHOUT a declaration still fails, and a declaration quoting a title the catalog never carried does not launder it. `node --test` 27/27. - **First use:** GT-622's title is corrected in the same change that built the mechanism — 82 → 210, "every PR" → "PRs into `develop`" — with the declaration carrying why. That is the closure's own proof: the guard is green with the retitle applied against both `origin/main` and `origin/develop`. - **Status:** `DONE` (2026-08-08) + +#### GT-657 + +**Title:** A js-yaml advisory with no upstream fix, and an audit job with no way to say so + +- **Purpose / Problem:** Clear the half of a new HIGH advisory that a version pin can clear, and give the audit job a way to express the other half — an advisory this repository provably cannot fix — without leaving a check permanently red. +- **Evidence:** `GHSA-5p4m-2wfm-xmqj` / CVE-2026-59870 (js-yaml, quadratic CPU consumption in `!!omap` resolution) was published between 2026-08-05 and 2026-08-08. The dating is evidence, not colour: the dependabot PRs of the 5th were green, PR #440 of the 8th was red, and #440 changed 8 documentation and JSON files with **zero** dependency files. So it is pre-existing branch debt on `main` and `develop`, not a regression — the same distinction [`GT-636`](./gap-reference-catalog.md#gt-636) had to make. +- **The half that had a fix, and why it was missed:** the root `overrides` already carried `js-yaml: 4.3.0`, added for an EARLIER advisory. The new one is vulnerable through `4.0.0 - 4.3.0` inclusive, so the existing pin was exactly one patch short of it. `4.3.1` clears it, and re-resolving also collapsed three nested copies (`@commitlint/load`, `@istanbuljs/load-nyc-config`, `cosmiconfig`) into the single hoisted one. +- **The half that has none.** The remaining advisory arrives through `@nestjs/swagger`, which pins `js-yaml` **exactly**, and every published release pins a vulnerable version: `11.4.4` → `4.1.1` and `11.4.5` → `4.3.0` (both inside CVE-2026-59870), `11.4.6` → `5.2.1` (inside `GHSA-pm4m-ph32-ghv5`, vulnerable `5.0.0 - 5.2.1`). `11.4.6` is the latest stable; `12.0.0` exists only as alpha. There is no version to bump to. +- **npm `overrides` do not reach it, and that was measured rather than assumed** — four routes, same tree every time: a top-level `js-yaml` override; a scoped `@nestjs/swagger: { js-yaml }` override; both repeated with the unrelated nested override objects (`eslint`/`@eslint/eslintrc` → `ajv`) temporarily removed, to test whether their presence blocked the cascade; and `npm install --package-lock-only` versus a real `npm install`. **This REFUTES the generalisation [`GT-636`](./gap-reference-catalog.md#gt-636) recorded** — that a nested override object anywhere stops the top-level rule cascading. Removing them changed nothing; what actually blocks the override is the consumer's EXACT pin. Recorded here rather than left standing, because a wrong lesson in a closed row is worse than no lesson. +- **Why an acceptance was not enough.** Leaving `Security Audit` red is exactly what [`GT-622`](./gap-reference-catalog.md#gt-622) had just been closed to remove: a permanently red check trains reviewers to discount red checks, and the next genuinely fixable advisory would land in a job everyone had learned to ignore. The threshold is unchanged; what is added is that an unfixable advisory must be NAMED. +- **Component:** `Security` · **Criticality:** P2 · **Complexity:** M +- **Principal:** `M` · **Interest:** `HIGH` · **Basis:** `estimate` +- **Provenance:** Found on 2026-08-08 as the one red check left on PR #440 after GT-622 and GT-656 went green. Registered rather than dismissed as noise, because "not caused by this PR" is a statement about attribution, not about whether the advisory is real. +- **Acceptance criteria:** + - [x] The fixable half is fixed at its source: root override `js-yaml` `4.3.0` → `4.3.1`, verified by re-resolution — one hoisted `4.3.1` where there were four positions, and CVE-2026-59870 gone from the report. + - [x] The unfixable half is proven unfixable before being accepted: all three `@nestjs/swagger` releases enumerated with the exact `js-yaml` each pins, and four override routes measured. + - [x] `Security Audit` fails on any high/critical advisory that is not declared — the threshold is unchanged, the fixtures observe it red. + - [x] A declaration is scoped to an advisory AND the path it arrives by, so it cannot excuse the same id appearing somewhere else later. + - [x] A declaration whose advisory has disappeared FAILS as stale, so the good news arrives as a red check asking for the entry's removal instead of an exemption nobody re-reads. + - [x] The gate's own fixtures run in the job — a gate whose exception list can swallow anything is a green button. `node --test` 19/19. +- **What is deliberately NOT covered:** three `moderate` advisories (`hono`, `@hono/node-server`, `@modelcontextprotocol/sdk`) stay below the HIGH threshold and are untouched, as they were before. Raising the threshold is a separate decision and this row does not take it. +- **Status:** `DONE` (2026-08-08) diff --git a/reference/core/control-center/gaps/gap-tracking.es.md b/reference/core/control-center/gaps/gap-tracking.es.md index 510ed5f40..ba7f0c4c2 100644 --- a/reference/core/control-center/gaps/gap-tracking.es.md +++ b/reference/core/control-center/gaps/gap-tracking.es.md @@ -669,9 +669,10 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-654`](./gap-reference-catalog.es.md#gt-654) | **Tres servicios de un mismo producto responden `/health` en tres formas.** `core-api` devuelve el sobre ADR-0073 (`data.status = "OK"`); `mcp` y `agent-runtime` devuelven objetos planos (`status = "ok"`) — cambia el anidamiento y también la caja. **Qué significa:** quien sondee las tres trata cada una como caso especial, y una sonda escrita contra cualquiera de las formas reporta las otras dos como rotas. **Ejemplo:** el 2026-08-03 una sonda cross-cluster casó `"status":"ok"` literalmente y reportó como inalcanzables dos servicios que estaban sirviendo. **Es una decisión antes que un trabajo:** el sobre es el contrato declarado del Core, pero `/health` es lo que lee una sonda de Kubernetes y hoy están configuradas contra la forma actual. | `Evolith Core` | Cross | P2 | S | `COMPLETADO` | | [`GT-655`](./gap-reference-catalog.es.md#gt-655) | **Cuatro operaciones declaradas en las tres superficies no las ha invocado nunca ninguna prueba.** `satellite-create`, `pattern-list`, `pattern-get` y `pattern-list-by-topology` están `exposed: true` en CLI, MCP y REST, y el arnés de exploración no tiene binding para ninguna — 48 de 73 operaciones lo llevan. **Qué significa:** la matriz de paridad afirma que existen en tres superficies y nada les ha pedido nunca demostrarlo; el arnés las reporta en `uncoveredTriangleOps` en vez de redondearlas, pero reportar no es cubrir. **`satellite-create` es la difícil:** aprovisiona un repo real de GitHub y escribe el registro, así que su binding exige un camino deshacible o de dry-run. | `Evolith Core` | Cross | P2 | M | `COMPLETADO` | | [`GT-656`](./gap-reference-catalog.es.md#gt-656) | **Un guard contra colisiones de id volvió inmutables los títulos de los gaps, así que un tablero cuyo propósito es no mentir acumuló filas cuya primera línea miente.** `49-validate-gap-id-allocation` distingue una colisión de una edición normal comparando el `**Title:**` del catálogo contra la rama base, y cualquier diferencia falla. Eso es correcto para una colisión e incorrecto para un retítulo, y el guard no podía distinguirlos — su propio mensaje lo decía y pedía la distinción "en el commit", que nada lee. **El coste ya estaba pagado, no es hipotético:** [`GT-622`](./gap-reference-catalog.es.md#gt-622) se re-midió dos veces —82 → 201 → 210 análisis, y la rama afectada resultó ser `develop`, no `main`— mientras su titular seguía diciendo "Ochenta y dos ... cada PR", porque corregirlo habría puesto en rojo un check REQUERIDO en el propio PR que traía la corrección. La re-medición del 2026-08-01 sentó el precedente arreglando la evidencia y dejando el título, y este cierre lo encontró a punto de repetirse por tercera vez. **Arreglado convirtiendo en dato el juicio humano que el guard delegaba, no ablandando el check:** un retítulo se declara en `gap-retitles.json` reproduciendo AMBOS títulos exactamente, y solo una coincidencia exacta exime. La exactitud es el diseño — no se puede escribir como un "este id puede retitularse" en bloque, así que una colisión real que caiga después sobre el mismo número sigue fallando. Las declaraciones se clasifican `active` / `spent` / `rot`, con rot fatal y un registro ilegible fatal, porque una lista de exenciones que se pudre en silencio es peor que el defecto que arregla. | `.harness` | Cross | P2 | S | `COMPLETADO` | +| [`GT-657`](./gap-reference-catalog.es.md#gt-657) | **Un advisory nuevo de `js-yaml` puso `Security Audit` en rojo en todas las ramas, y la mitad no se puede arreglar desde este repositorio.** `GHSA-5p4m-2wfm-xmqj` / CVE-2026-59870 se publicó entre el 2026-08-05 y el 2026-08-08 — los PR de dependabot del día 5 salieron verdes, el PR #440 del día 8 no, y no tocaba ningún fichero de dependencias — así que es deuda de rama en `main` y `develop`, no una regresión. **La mitad que tenía arreglo:** el repositorio ya fijaba `js-yaml: 4.3.0` por un advisory ANTERIOR, y el nuevo es vulnerable hasta `4.3.0` exactamente; el pin se quedó una patch corto. Subido a `4.3.1`, que además deduplicó tres copias anidadas en una. **La mitad que no tiene ninguno:** el advisory restante llega por `@nestjs/swagger`, que fija `js-yaml` EXACTO, y todas sus versiones publicadas fijan una vulnerable — `11.4.4` → `4.1.1`, `11.4.5` → `4.3.0`, `11.4.6` → `5.2.1` — con `12.0.0` solo en alpha. **Los `overrides` de npm no lo alcanzan, medido de cuatro formas** en vez de supuesto: override de raíz, override anidado en `@nestjs/swagger`, ambos repetidos quitando los objetos anidados ajenos para comprobar si bloqueaban la cascada (no lo hacían, lo que REFUTA la generalización que registró [`GT-636`](./gap-reference-catalog.es.md#gt-636)), y por `--package-lock-only` además de un `npm install` real. El mismo árbol todas las veces. **Por qué hacía falta más que una aceptación:** dejar el job rojo es justo lo que [`GT-622`](./gap-reference-catalog.es.md#gt-622) se acababa de cerrar para evitar — un check permanentemente rojo enseña a descontar los rojos, y el siguiente advisory de verdad aterrizaría en un job que nadie lee. `63-validate-npm-audit-gate` conserva el mismo umbral HIGH y añade un requisito: un advisory sin arreglo aguas arriba debe estar NOMBRADO, con la ruta por la que llega y qué se comprobó upstream. Falla ante un advisory no declarado, ante una declaración con otro id u otra ruta y —la regla que evita el cementerio— ante una declaración cuyo advisory ha DESAPARECIDO. | `Security` | Cross | P2 | M | `COMPLETADO` | -**Progreso:** 641 / 654 completados · 3 en progreso · 3 pendientes · 7 diferidos +**Progreso:** 642 / 655 completados · 3 en progreso · 3 pendientes · 7 diferidos **Oleada 2026-06-23 (auditoría profunda de Winston III):** Añadidos 14 gaps nuevos `GT-212`…`GT-225` del Winston Audit Playbook que cubren: higiene de estado ADR (GT-212), metadata + presupuestos operativos + corpus de guías por topología (GT-213, GT-217, GT-219), observabilidad + OpenAPI en controladores REST (GT-214, GT-215), paridad de input-schemas OPA + densidad de tests por topología (GT-216, GT-222), plantillas de rollback + on-call de Fase 05 (GT-218), cobertura de ramas CLI + paridad de envelope --format + limpieza de skip-list (GT-220, GT-224, GT-225), audit logging HTTP de MCP (GT-221), y tests e2e de paridad cross-surface (GT-223). diff --git a/reference/core/control-center/gaps/gap-tracking.md b/reference/core/control-center/gaps/gap-tracking.md index 67af78341..d5ea66646 100644 --- a/reference/core/control-center/gaps/gap-tracking.md +++ b/reference/core/control-center/gaps/gap-tracking.md @@ -669,9 +669,10 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-654`](./gap-reference-catalog.md#gt-654) | **Three services of one product answer `/health` in three shapes.** `core-api` returns the ADR-0073 envelope (`data.status = "OK"`); `mcp` and `agent-runtime` return bare objects (`status = "ok"`) — the nesting differs and so does the case. **What it means:** anything probing all three special-cases each one, and a probe written against either shape reports the other two as broken. **Example:** on 2026-08-03 a cross-cluster probe matched `"status":"ok"` literally and reported two healthy services as unreachable while they were serving. **A decision before it is work:** the envelope is the Core's stated contract, but `/health` is what a Kubernetes probe reads and those are configured against today's shape. | `Evolith Core` | Cross | P2 | S | `DONE` | | [`GT-655`](./gap-reference-catalog.md#gt-655) | **Four operations declared on all three surfaces have never been invoked by any test.** `satellite-create`, `pattern-list`, `pattern-get` and `pattern-list-by-topology` are `exposed: true` on CLI, MCP and REST, and the exploration harness has no binding for any of them — 48 of 73 operations carry one. **What it means:** the parity matrix asserts they exist on three surfaces and nothing has ever asked them to prove it; the harness reports them in `uncoveredTriangleOps` rather than rounding them away, but reporting is not covering. **`satellite-create` is the hard one:** it provisions a live GitHub repo and writes the registry, so a binding needs an undoable or dry-run path. | `Evolith Core` | Cross | P2 | M | `DONE` | | [`GT-656`](./gap-reference-catalog.md#gt-656) | **A guard against id collisions made gap titles immutable, so a board whose purpose is not lying accumulated rows whose first line lies.** `49-validate-gap-id-allocation` discriminates a collision from an ordinary edit by comparing the catalog `**Title:**` against the base branch, and any difference fails. That is right for a collision and wrong for a retitle, and the guard could not tell them apart — its own message said so and asked for the distinction "in the commit", which nothing reads. **The cost was already paid, not hypothetical:** [`GT-622`](./gap-reference-catalog.md#gt-622) was re-measured twice — 82 → 201 → 210 analyses, and the branch it affects turned out to be `develop`, not `main` — while its headline went on saying "Eighty-two ... every PR", because correcting it would have turned a REQUIRED check red on the PR carrying the correction. The 2026-08-01 re-measure set the precedent by fixing the evidence and leaving the title, and this closure found it about to be repeated a third time. **Fixed by making the deferred human judgement into data rather than by softening the check:** a retitle is declared in `gap-retitles.json` reproducing BOTH titles exactly, and only an exact match exempts. The exactness is the design — it cannot be written as a blanket "this id may be retitled", so a genuine collision later landing on the same number still fails. Declarations are themselves classified `active` / `spent` / `rot`, with rot fatal and an unparseable registry fatal, because an exemption list that rots silently is worse than the defect it fixes. | `.harness` | Cross | P2 | S | `DONE` | +| [`GT-657`](./gap-reference-catalog.md#gt-657) | **A new `js-yaml` advisory turned `Security Audit` red on every branch, and half of it cannot be fixed from this repository at all.** `GHSA-5p4m-2wfm-xmqj` / CVE-2026-59870 was published between 2026-08-05 and 2026-08-08 — the dependabot PRs of the 5th were green, PR #440 on the 8th was not, and it touched no dependency file — so it is branch debt on `main` and `develop`, not a regression. **The half that had a fix:** the repository already pinned `js-yaml: 4.3.0` for an EARLIER advisory, and the new one is vulnerable through exactly `4.3.0`; the pin was one patch short. Bumped to `4.3.1`, which also deduplicated three nested copies into one. **The half that has none:** the remaining advisory reaches the tree through `@nestjs/swagger`, which pins `js-yaml` EXACTLY, and every published release pins a vulnerable one — `11.4.4` → `4.1.1`, `11.4.5` → `4.3.0`, `11.4.6` → `5.2.1` — with `12.0.0` only in alpha. **npm `overrides` cannot reach it, measured four ways** rather than assumed: a top-level override, a scoped `@nestjs/swagger` override, both re-tested with the unrelated nested override objects removed to check whether they blocked the cascade (they did not, which REFUTES the generalisation [`GT-636`](./gap-reference-catalog.md#gt-636) recorded), and through `--package-lock-only` as well as a real `npm install`. Same tree every time. **Why this needed more than an acceptance:** leaving the job red is what [`GT-622`](./gap-reference-catalog.md#gt-622) had just been closed to stop — a permanently red check trains reviewers to discount red, and the next real advisory would arrive into a job nobody reads. `63-validate-npm-audit-gate` keeps the same HIGH threshold and adds one requirement: an advisory with no upstream fix must be NAMED, with the path it arrives by and what was checked upstream. It fails on an undeclared advisory, on a declaration for a different id or path, and — the rule that stops a graveyard — on a declaration whose advisory has DISAPPEARED. | `Security` | Cross | P2 | M | `DONE` | -**Progress:** 641 / 654 done · 3 in progress · 3 pending · 7 deferred +**Progress:** 642 / 655 done · 3 in progress · 3 pending · 7 deferred **Wave 2026-06-23 (Winston deep audit III):** Added 14 new gaps `GT-212`…`GT-225` from the Winston Audit Playbook covering: ADR status hygiene (GT-212), topology manifest metadata + operational budgets + guidance corpus (GT-213, GT-217, GT-219), REST controller observability + OpenAPI (GT-214, GT-215), OPA input-schema parity + per-topology test density (GT-216, GT-222), SDLC Phase 05 rollback + on-call templates (GT-218), CLI branch coverage + envelope format coverage + skip-list cleanup (GT-220, GT-224, GT-225), MCP HTTP audit logging (GT-221), and cross-surface parity e2e tests (GT-223). diff --git a/reference/core/control-center/maturity-reports/executive-summary.es.md b/reference/core/control-center/maturity-reports/executive-summary.es.md index 50d08c730..56564ee5b 100644 --- a/reference/core/control-center/maturity-reports/executive-summary.es.md +++ b/reference/core/control-center/maturity-reports/executive-summary.es.md @@ -42,14 +42,14 @@ La forma correcta de usar este resumen es simple: si necesitas contexto, abre so | Indicador | Valor | |---|---:| | Fecha canónica del tablero | 2026-08-08 | -| Gaps totales | 654 | -| Gaps cerrados | 641 | +| Gaps totales | 655 | +| Gaps cerrados | 642 | | Gaps pendientes | 13 | | P0 abiertos | 1 | | P1 abiertos | 3 | | P2 abiertos | 6 | | Cierre total | 98% | -| Registros de evidencia de cierre | 623 | +| Registros de evidencia de cierre | 624 | | Readiness registrado | 4 PASS | | Área | Pendientes | P0 | P1 | Primeros IDs | diff --git a/reference/core/control-center/maturity-reports/executive-summary.md b/reference/core/control-center/maturity-reports/executive-summary.md index acae7d2e1..69f92bd2e 100644 --- a/reference/core/control-center/maturity-reports/executive-summary.md +++ b/reference/core/control-center/maturity-reports/executive-summary.md @@ -42,14 +42,14 @@ Use this summary with a simple rule: if you need context, open only the linked I | Indicator | Value | |---|---:| | Canonical board date | 2026-08-08 | -| Total gaps | 654 | -| Closed gaps | 641 | +| Total gaps | 655 | +| Closed gaps | 642 | | Open gaps | 13 | | Open P0 | 1 | | Open P1 | 3 | | Open P2 | 6 | | Total closure | 98% | -| Closure evidence records | 623 | +| Closure evidence records | 624 | | Recorded readiness | 4 PASS | | Area | Open | P0 | P1 | First IDs | diff --git a/reference/core/control-center/maturity-reports/maturity-reconciliation.json b/reference/core/control-center/maturity-reports/maturity-reconciliation.json index 9d952d9ad..667594953 100644 --- a/reference/core/control-center/maturity-reports/maturity-reconciliation.json +++ b/reference/core/control-center/maturity-reports/maturity-reconciliation.json @@ -3,14 +3,14 @@ "scope": "evolith-core", "asOf": "2026-08-08", "gaps": { - "total": 654, - "done": 641, + "total": 655, + "done": 642, "pending": 3, "inProgress": 3, "deferred": 7 }, "evidence": { - "closureRecords": 623, + "closureRecords": 624, "cliPackage": "@beyondnet/evolith-cli@1.2.2", "adrCount": 140, "rulesetCount": 177, diff --git a/src/apps/core-api/package.json b/src/apps/core-api/package.json index 42843377e..ac9142233 100644 --- a/src/apps/core-api/package.json +++ b/src/apps/core-api/package.json @@ -29,7 +29,7 @@ "@nestjs/config": "4.0.4", "@nestjs/core": "11.1.27", "@nestjs/platform-express": "11.1.28", - "@nestjs/swagger": "11.4.4", + "@nestjs/swagger": "11.4.6", "@nestjs/terminus": "11.1.1", "@nestjs/throttler": "6.5.0", "@opentelemetry/api": "1.9.1", From 621c18117992d8ff9f0428ec9f4ef5a66854c3f2 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sat, 8 Aug 2026 10:10:53 -0500 Subject: [PATCH 2/2] docs(security): GT-657 record why Trivy's "new alert" must not be answered by reverting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Trivy` reported "1 new alert including 1 high severity security vulnerability" on PR #441 — js-yaml 5.2.1 at package-lock.json. It reads as though this change introduced a vulnerability. It did not, and the revert was attempted before that was checked, so the check is recorded rather than the conclusion alone. Measured at BOTH versions: @nestjs/swagger 11.4.4 -> js-yaml 4.1.1 -> THREE advisories GHSA-52cp-r559-cp3m HIGH GHSA-5p4m-2wfm-xmqj HIGH GHSA-h67p-54hq-rp68 moderate @nestjs/swagger 11.4.6 -> js-yaml 5.2.1 -> ONE advisory GHSA-pm4m-ph32-ghv5 HIGH The bump removes two high advisories and leaves one. Trivy compares alerts on the changed lines, so an advisory id changing at the same lockfile position is "new" to it — the id changed, no hole opened. Two instruments disagree with each other here and both are being read correctly: npm reports 2 high ROWS for 11.4.6 (js-yaml and its parent) against 1 for 11.4.4, while 11.4.6 carries a third of the advisories. Counting rows says revert; counting holes says do not. No code or dependency change: the tree is byte-identical to the previous commit. What is added is the reasoning, in the exceptions entry as `doNotRevertTheSwaggerBump` and in GT-657's catalog section in both languages, so the next reader who sees the red Trivy check does not undo the fix. Co-Authored-By: Claude Opus 5 --- .harness/config/npm-audit-exceptions.json | 3 ++- reference/core/control-center/gaps/gap-reference-catalog.es.md | 1 + reference/core/control-center/gaps/gap-reference-catalog.md | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.harness/config/npm-audit-exceptions.json b/.harness/config/npm-audit-exceptions.json index 2d0c59883..3aeb68c33 100644 --- a/.harness/config/npm-audit-exceptions.json +++ b/.harness/config/npm-audit-exceptions.json @@ -7,7 +7,8 @@ "path": "node_modules/@nestjs/swagger/node_modules/js-yaml", "declaredAt": "2026-08-08", "noUpstreamFix": "Every published @nestjs/swagger release pins js-yaml to an EXACT version and all three are vulnerable: 11.4.4 -> 4.1.1 and 11.4.5 -> 4.3.0 (CVE-2026-59870, vulnerable 4.0.0-4.3.0), 11.4.6 -> 5.2.1 (this advisory, vulnerable 5.0.0-5.2.1). 11.4.6 is the latest stable; 12.0.0 exists only as alpha. npm overrides do not rewrite that nested exact spec, measured four ways: a top-level `js-yaml` override, a scoped `@nestjs/swagger: { js-yaml }` override, both with the unrelated nested override objects removed to test whether they blocked the cascade (they did not), and through `npm install --package-lock-only` as well as a real `npm install`. Every route produced the same tree.", - "reason": "The advisory is a denial of service in js-yaml's parser, triggered by parsing adversarial YAML. @nestjs/swagger uses js-yaml to SERIALISE the OpenAPI document this service generates from its own decorators; core-api never parses caller-supplied YAML through it. The exposure is a parser this repository never points at untrusted input. This is an acceptance of a specific hole with a known shape, not of the package: the moment @nestjs/swagger ships a patched pin, this entry stops matching and the gate turns red asking for its removal." + "reason": "The advisory is a denial of service in js-yaml's parser, triggered by parsing adversarial YAML. @nestjs/swagger uses js-yaml to SERIALISE the OpenAPI document this service generates from its own decorators; core-api never parses caller-supplied YAML through it. The exposure is a parser this repository never points at untrusted input. This is an acceptance of a specific hole with a known shape, not of the package: the moment @nestjs/swagger ships a patched pin, this entry stops matching and the gate turns red asking for its removal.", + "doNotRevertTheSwaggerBump": "Trivy reports this as a NEW high alert on any PR that changes package-lock.json, because the advisory id at that position changed. It is not a regression and reverting @nestjs/swagger 11.4.6 -> 11.4.4 makes things WORSE, measured: 11.4.4 pins js-yaml 4.1.1, which carries THREE advisories (GHSA-52cp-r559-cp3m HIGH, GHSA-5p4m-2wfm-xmqj HIGH, GHSA-h67p-54hq-rp68 moderate), while 11.4.6 pins 5.2.1, which carries ONE. The bump removes two high advisories and leaves one. This note exists because the revert was attempted during GT-657 on a first reading of Trivy's output and had to be undone; npm's row count and Trivy's per-PR 'new alerts' view can both make the better tree look like the worse one." }, { "id": "via:js-yaml", diff --git a/reference/core/control-center/gaps/gap-reference-catalog.es.md b/reference/core/control-center/gaps/gap-reference-catalog.es.md index 8307e3806..4d63891c9 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -8447,5 +8447,6 @@ La lección es la del propio tablero y esta vez la pagó quien medía: un `conta - [x] Una declaración se ata a un advisory Y a la ruta por la que llega, para que no pueda excusar el mismo id apareciendo después en otro sitio. - [x] Una declaración cuyo advisory ha desaparecido FALLA como caduca, para que la buena noticia llegue como un check rojo pidiendo quitar la entrada, y no como una exención que nadie relee. - [x] Los fixtures del propio gate corren en el job — un gate cuya lista de excepciones puede tragarse cualquier cosa es un botón verde. `node --test` 19/19. +- **El check `Trivy` reporta esto como una alerta HIGH nueva, y no es una regresión — se registra porque la reversión se intentó y hubo que deshacerla.** Trivy compara alertas sobre las líneas cambiadas, así que sustituir un id de advisory por otro en la misma posición del lockfile se lee como "1 new alert including 1 high severity security vulnerability". Medido en ambos sentidos en vez de argumentado: `/swagger.4.4` fija `js-yaml 4.1.1`, que arrastra **tres** advisories —`GHSA-52cp-r559-cp3m` (HIGH), `GHSA-5p4m-2wfm-xmqj` (HIGH) y `GHSA-h67p-54hq-rp68` (moderate)—, mientras que `11.4.6` fija `5.2.1`, que arrastra **uno**. El bump quita dos advisories high y deja uno, así que el árbol que produce es estrictamente mejor y la "alerta nueva" es el id cambiando, no un agujero abriéndose. **El casi-error es la lección:** en una primera lectura de Trivy se revirtió el bump como si él hubiera causado la alerta, y solo enumerar los advisories en AMBAS versiones mostró que la reversión empeoraba las cosas. El conteo de filas de npm dice lo contrario que el conteo de advisories: 11.4.6 produce 2 filas high (js-yaml y su padre) frente a 1 de 11.4.4, llevando un tercio de los advisories. Un campo `doNotRevertTheSwaggerBump` en la entrada de excepciones lo traslada al siguiente lector. - **Lo que deliberadamente NO se cubre:** tres advisories `moderate` (`hono`, `@hono/node-server`, `@modelcontextprotocol/sdk`) quedan por debajo del umbral HIGH y siguen intactos, como estaban. Bajar el umbral es otra decisión y esta fila no la toma. - **Estado:** `COMPLETADO` (2026-08-08) diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index ba18a4d52..f728f2d29 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -8542,5 +8542,6 @@ The lesson is the board's own, and this time the measurer paid it: a `contains` - [x] A declaration is scoped to an advisory AND the path it arrives by, so it cannot excuse the same id appearing somewhere else later. - [x] A declaration whose advisory has disappeared FAILS as stale, so the good news arrives as a red check asking for the entry's removal instead of an exemption nobody re-reads. - [x] The gate's own fixtures run in the job — a gate whose exception list can swallow anything is a green button. `node --test` 19/19. +- **The `Trivy` check reports this as a NEW high alert, and that is not a regression — recorded because the revert was attempted and had to be undone.** Trivy compares alerts on the changed lines, so replacing one advisory id with another at the same lockfile position reads as "1 new alert including 1 high severity security vulnerability". Measured both ways rather than argued: `/swagger.4.4` pins `js-yaml 4.1.1`, which carries **three** advisories — `GHSA-52cp-r559-cp3m` (HIGH), `GHSA-5p4m-2wfm-xmqj` (HIGH) and `GHSA-h67p-54hq-rp68` (moderate) — while `11.4.6` pins `5.2.1`, which carries **one**. The bump removes two high advisories and leaves one, so the tree it produces is strictly better and the "new alert" is the id changing, not a hole opening. **The near-miss is the lesson:** on a first reading of Trivy the bump was reverted as if it had caused the alert, and only enumerating the advisories at BOTH versions showed the revert made things worse. npm's row count says the opposite of the advisory count here — 11.4.6 produces 2 high ROWS (js-yaml and its parent) against 11.4.4's 1, while carrying a third of the advisories. A `doNotRevertTheSwaggerBump` field in the exceptions entry carries this to the next reader. - **What is deliberately NOT covered:** three `moderate` advisories (`hono`, `@hono/node-server`, `@modelcontextprotocol/sdk`) stay below the HIGH threshold and are untouched, as they were before. Raising the threshold is a separate decision and this row does not take it. - **Status:** `DONE` (2026-08-08)