Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions .github/workflows/sdk-cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions .harness/config/npm-audit-exceptions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"$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.",
"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",
"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."
}
]
}
296 changes: 296 additions & 0 deletions .harness/scripts/ci/63-validate-npm-audit-gate.mjs
Original file line number Diff line number Diff line change
@@ -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 <file>
*
* 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)));
Loading
Loading