* 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>
316 lines
13 KiB
JavaScript
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');
|
|
});
|
|
});
|
|
});
|
|
}
|