Files
msd-core/tests/npm-integrity-gate.test.cjs
Tom Boucher 114dfcb739 fix(#4196): npm-audit gate blocks only NEW advisories, not pre-existing ones (#4214)
* fix(#4196): npm-audit gate blocks only NEW advisories, not pre-existing ones

The #3588 gate failed on ANY advisory in the production tree, regardless
of whether the PR/push actually introduced it. Because npm's advisory
database updates continuously and independently of repo state, a commit
could pass this gate at merge time and fail it minutes later on the
identical tree -- proven on PR #4188/dce40eeb6, which passed on all 3
OSes at 15:57-16:27 and failed the same assertion at 16:19-16:30 on the
unchanged commit, purely because GHSA-jqff-g426-hqxp was disclosed for
fast-uri in the interim.

scripts/npm-audit-baseline.cjs diffs the head tree's vulnerable-package
set against a resolved baseline (the PR's target branch, or the prior
commit on a direct push) and blocks only newly-introduced advisories.
When no baseline can be resolved, falls back to the original
zero-tolerance behavior -- fail-closed, never silently weaker.

* fix(#4196): pin the npm-audit baseline instead of using a drift-prone ref

Two orthogonal reviews found the same class of bug this repo already
fixed once for a different gate (see GSD_EMITTED_BASE's own incident
comment in test.yml): origin/<branch> is live under fetch-depth: 0 and
can advance mid-run, so resolveBaselineRef()'s fallback to
origin/${GITHUB_BASE_REF} could silently disagree with the tree
ci-rebase-check.cjs actually merged. Wire AUDIT_BASELINE_REF from the
workflow to github.event.pull_request.base.sha / github.event.before,
the same pinned values GSD_EMITTED_BASE already relies on.

Also: HEAD~1 assumed exactly one commit per push, which this repo's
allow_rebase_merge:true setting can violate (a rebase-merged PR lands
as several discrete commits in one push) -- github.event.before is
git's own record of the correct pre-push state, not an assumed offset.
HEAD~1 remains as a documented last-resort fallback for out-of-band
invocations (e.g. gsd-test) that don't set any of the above, alongside
a new local-branch fallback for gsd-test's local `next` (not
origin/next) sandbox shape.

---------

Co-authored-by: sim <sim@local>
2026-09-02 22:38:37 -04:00

316 lines
13 KiB
JavaScript

'use strict';
/**
* Regression test for #114 — npm dependency integrity gate.
*
* Verifies that scripts/check-npm-integrity.cjs correctly detects:
* 1. Clean install — exits 0, no stderr findings
* 2. Version drift — exits 1, stderr names the offending package + both versions
* (reproduces the ws 8.20.1 declared vs 8.20.0 installed incident)
* 3. Extraneous — exits 1 without --ignore-extraneous; exits 0 with it
* 4. Missing — exits 1 regardless of flags
*
* Each fixture lives under tests/fixtures/npm-integrity/<name>/.
* The test spawns the script as a subprocess — no require/import of internals.
*
* Sources:
* - npm CLI docs: https://docs.npmjs.com/cli/v10/commands/npm-ls
* - NIST SSDF PW.4.1: https://csrc.nist.gov/publications/detail/sp/800-218/final
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs');
const ROOT = path.resolve(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'check-npm-integrity.cjs');
const FIXTURES = path.join(__dirname, 'fixtures', 'npm-integrity');
/**
* Run the integrity gate script against a fixture directory.
*
* @param {string} fixtureName - subdirectory under tests/fixtures/npm-integrity/
* @param {string[]} [extraArgs] - additional CLI args passed to the script
* @returns {{ status: number, stdout: string, stderr: string }}
*/
function runGate(fixtureName, extraArgs = []) {
const fixtureDir = path.join(FIXTURES, fixtureName);
const r = runNode([SCRIPT, ...extraArgs], { cwd: fixtureDir, timeoutMs: 30_000 });
return {
status: r.exitCode ?? 1,
stdout: r.stdout,
stderr: r.stderr,
};
}
// ─── Scenario 1: Clean ───────────────────────────────────────────────────────
describe('#114: npm integrity gate — clean fixture', () => {
test('exits 0 when install matches lockfile', () => {
const { status } = runGate('clean');
assert.strictEqual(status, 0, 'expected exit 0 for clean install');
});
test('emits no integrity findings to stderr on clean install', () => {
const { stderr } = runGate('clean');
// No "FAIL:" lines expected
assert.ok(
!stderr.includes('FAIL:'),
`expected no FAIL: lines in stderr; got:\n${stderr}`
);
});
});
// ─── Scenario 2: Drift (declared vs installed mismatch) ─────────────────────
// Reproduces: ws 8.20.1 declared in lockfile, 8.20.0 installed in node_modules
// Fixture uses: stable-dep@8.20.1 (declared) vs stable-dep@8.20.0 (installed)
describe('#114: npm integrity gate — drift fixture (declared vs installed mismatch)', () => {
test('exits 1 on version drift', () => {
const { status } = runGate('drift');
assert.strictEqual(status, 1, 'expected exit 1 for version drift');
});
test('stderr names the offending package', () => {
const { stderr } = runGate('drift');
assert.ok(
stderr.includes('stable-dep'),
`expected stderr to name "stable-dep"; got:\n${stderr}`
);
});
test('stderr includes both the declared and installed versions', () => {
const { stderr } = runGate('drift');
assert.ok(
stderr.includes('8.20.0'),
`expected stderr to include installed version "8.20.0"; got:\n${stderr}`
);
assert.ok(
stderr.includes('8.20.1'),
`expected stderr to include declared version "8.20.1"; got:\n${stderr}`
);
});
});
// ─── Scenario 3: Extraneous ──────────────────────────────────────────────────
describe('#114: npm integrity gate — extraneous fixture', () => {
test('exits 1 when extraneous package present (default behavior)', () => {
const { status } = runGate('extraneous');
assert.strictEqual(status, 1, 'expected exit 1 for extraneous package without --ignore-extraneous');
});
test('stderr names the extraneous package', () => {
const { stderr } = runGate('extraneous');
assert.ok(
stderr.includes('ghost-pkg'),
`expected stderr to name "ghost-pkg"; got:\n${stderr}`
);
});
test('exits 0 with --ignore-extraneous flag', () => {
const { status } = runGate('extraneous', ['--ignore-extraneous']);
assert.strictEqual(status, 0, 'expected exit 0 for extraneous package with --ignore-extraneous');
});
});
// ─── Scenario 4: Missing ─────────────────────────────────────────────────────
describe('#114: npm integrity gate — missing fixture', () => {
test('exits 1 when required package is missing from node_modules', () => {
const { status } = runGate('missing');
assert.strictEqual(status, 1, 'expected exit 1 for missing package');
});
test('stderr names the missing package', () => {
const { stderr } = runGate('missing');
assert.ok(
stderr.includes('absent-dep'),
`expected stderr to name "absent-dep"; got:\n${stderr}`
);
});
test('exits 1 even with --ignore-extraneous (missing is not extraneous)', () => {
const { status } = runGate('missing', ['--ignore-extraneous']);
assert.strictEqual(status, 1, 'expected exit 1 for missing package even with --ignore-extraneous');
});
});
// ─── Smoke test: --help ───────────────────────────────────────────────────────
describe('#114: npm integrity gate — --help output', () => {
test('exits 0 with --help flag', () => {
const result = spawnSync(process.execPath, [SCRIPT, '--help'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: 10_000,
});
assert.strictEqual(result.status, 0, '--help should exit 0');
});
test('--help output mentions --ignore-extraneous', () => {
const result = spawnSync(process.execPath, [SCRIPT, '--help'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: 10_000,
});
// The .cjs script writes --help to stdout.
const helpText = (result.stdout ?? '') + (result.stderr ?? '');
assert.ok(
helpText.includes('--ignore-extraneous'),
`expected --help output to document --ignore-extraneous; got:\n${helpText}`
);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3588-npm-audit-clean.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3588-npm-audit-clean (consolidation epic #1969 B6 #1975)", () => {
'use strict';
/**
* Regression test for #3588 — production dependency tree must not carry
* high or moderate npm-audit advisories.
*
* Strategy: run `npm audit --omit=dev --json` against both the root
* workspace and the embedded SDK package, then diff the resulting
* vulnerable-package set against a baseline tree (see #4196 and
* scripts/npm-audit-baseline.cjs) so the gate only fails on advisories
* this PR/push actually introduces — not on pre-existing advisories in
* an untouched transitive dependency. When no baseline can be resolved,
* falls back to the original zero-tolerance check across
* info/low/moderate/high/critical.
*
* If a future advisory lands without an upstream patch on a package this
* PR touches, either bump the patched transitive (preferred), or annotate
* the acceptance below with a justification AND a link to the upstream
* tracker.
*
* Skips automatically when `node_modules/` is absent (a fresh checkout
* before `npm install`) so the test does not falsely report on developer
* machines mid-setup.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const { execFileSync } = require('node:child_process');
const {
evaluateAuditDiff,
runPackageLockAudit,
extractBaselineTree,
resolveBaselineRef,
} = require('../scripts/npm-audit-baseline.cjs');
const { cleanup } = require('./helpers.cjs');
const ROOT = path.resolve(__dirname, '..');
const SDK = path.join(ROOT, 'sdk');
const AUDIT_TIMEOUT_MS = 180_000;
const TEST_TIMEOUT_MS = AUDIT_TIMEOUT_MS + 30_000;
function auditProductionVulns(cwd) {
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
return null; // signal "skip" to caller
}
if (!fs.existsSync(path.join(cwd, 'node_modules'))) {
return null; // signal "skip" to caller
}
const isWindows = process.platform === 'win32';
const npmCandidates = isWindows ? ['npm.cmd', 'npm'] : ['npm'];
const args = ['audit', '--omit=dev', '--json'];
let out;
let lastErr = null;
for (const npmCmd of npmCandidates) {
try {
out = execFileSync(
npmCmd,
args,
{
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: AUDIT_TIMEOUT_MS,
shell: isWindows,
}
);
lastErr = null;
break;
} catch (e) {
// `npm audit` exits non-zero when advisories are present; the JSON is
// still on stdout in that case. Recover and let the assertion classify.
if (e && typeof e.stdout !== 'undefined' && e.stdout !== undefined && e.stdout !== null) {
out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
lastErr = null;
break;
}
lastErr = e;
}
}
if (lastErr) throw lastErr;
const parsed = JSON.parse(out);
// `null` is reserved for the "node_modules missing → skip" signal above.
// Any other unexpected JSON shape is a real failure of the audit harness
// (npm changed its output format, audit aborted before metadata, etc.) —
// throw so the test fails loudly instead of skipping silently.
if (parsed && parsed.metadata && parsed.metadata.vulnerabilities) {
return parsed;
}
throw new Error(`Unexpected npm audit JSON shape in ${cwd}: missing metadata.vulnerabilities`);
}
describe('#3588: npm audit --omit=dev introduces no NEW advisories vs baseline (#4196)', () => {
// #4196: a pre-existing advisory in an untouched transitive dependency
// must not block this PR/push -- only an advisory THIS change actually
// introduces should fail the gate. When no baseline can be resolved
// (e.g. a bare local run with no git history), fall back to the
// original #3588 zero-tolerance behavior rather than silently skipping.
function checkTreeAgainstBaseline(t, cwd, subdir, skipMessage) {
const audit = auditProductionVulns(cwd);
if (audit === null) {
t.skip(skipMessage);
return;
}
const baselineRef = resolveBaselineRef(ROOT);
const baselineDir = baselineRef ? extractBaselineTree(baselineRef, ROOT, subdir) : null;
if (baselineDir === null) {
const vulns = audit.metadata.vulnerabilities;
assert.strictEqual(vulns.critical, 0, `no baseline available; falling back to zero-tolerance -- expected 0 critical; got ${vulns.critical}`);
assert.strictEqual(vulns.high, 0, `no baseline available; falling back to zero-tolerance -- expected 0 high; got ${vulns.high}`);
assert.strictEqual(vulns.moderate, 0, `no baseline available; falling back to zero-tolerance -- expected 0 moderate; got ${vulns.moderate}`);
assert.strictEqual(vulns.low, 0, `no baseline available; falling back to zero-tolerance -- expected 0 low; got ${vulns.low}`);
return;
}
t.after(() => cleanup(baselineDir));
const baselineAudit = runPackageLockAudit(baselineDir);
const baselineVulns = (baselineAudit && baselineAudit.vulnerabilities) || {};
const result = evaluateAuditDiff({
baselineVulnerabilities: baselineVulns,
headVulnerabilities: audit.vulnerabilities || {},
});
assert.strictEqual(
result.ok,
true,
result.ok
? ''
: `new advisory introduced vs baseline (${baselineRef}): ${result.newlyIntroduced.join(', ')}. Pre-existing advisories are tracked separately (see #4196) and do not block this change.`,
);
}
test('root workspace production tree introduces no new advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
checkTreeAgainstBaseline(t, ROOT, '', 'auditable npm package not present or node_modules/ missing');
});
test('sdk/ production tree introduces no new advisories', { timeout: TEST_TIMEOUT_MS }, (t) => {
checkTreeAgainstBaseline(t, SDK, 'sdk', 'sdk/ is not an auditable npm package or sdk/node_modules/ is missing');
});
});
});
}