diff --git a/.github/scripts/vuln-gate.mjs b/.github/scripts/vuln-gate.mjs index 27f67207a..9026e7421 100644 --- a/.github/scripts/vuln-gate.mjs +++ b/.github/scripts/vuln-gate.mjs @@ -18,7 +18,7 @@ // dotnet list package --vulnerable --include-transitive --format json --output-version 1 \ // | node .github/scripts/vuln-gate.mjs --ecosystem nuget // -// Flags: --level (default high — blocks at or above) +// Flags: --level (default high — blocks at or above) // --warn-only (report, always exit 0) // --exceptions (default .github/security-exceptions.json) // --emit-allowlist (print live GHSA ids for dependency-review) @@ -43,6 +43,12 @@ export function severityRank(severity) { return i < 0 ? UNKNOWN_RANK : i; } +export function levelProblem(level) { + return SEVERITIES.includes(String(level ?? "").toLowerCase()) + ? null + : `--level must be one of ${SEVERITIES.join(", ")}`; +} + // Both ecosystems point their advisory URLs at GitHub Security Advisories, so a // GHSA id is the one key that means the same thing on both sides — that is what // an exception is written against. Numeric npm ids / package coordinates are @@ -172,14 +178,24 @@ function isLive(exception, now) { return Number.isFinite(dayStart) && now.getTime() < dayStart + ONE_DAY_MS; } +function scopeOf(exception) { + return String(exception.ecosystem ?? "").toLowerCase(); +} + +function inScope(exception, ecosystem) { + const scope = scopeOf(exception); + return scope === "any" || scope === String(ecosystem).toLowerCase(); +} + function matches(exception, finding, ecosystem) { - const scope = String(exception.ecosystem).toLowerCase(); - if (scope !== "any" && scope !== ecosystem) return false; + if (!inScope(exception, ecosystem)) return false; return canonicalGhsa(exception.id) === finding.id; } export function gate({ findings, exceptions = [], ecosystem, level = "high", now = new Date() }) { - const floor = severityRank(level); + const problem = levelProblem(level); + if (problem) throw new Error(problem); + const floor = SEVERITIES.indexOf(String(level).toLowerCase()); const atOrAbove = findings.filter((f) => severityRank(f.severity) >= floor); const valid = exceptions.filter(isValidException); @@ -188,8 +204,6 @@ export function gate({ findings, exceptions = [], ecosystem, level = "high", now .filter((e) => !isValidException(e)) .map((e) => ({ raw: e, problem: exceptionProblem(e) })); - const inScope = (e) => e.ecosystem === "any" || e.ecosystem.toLowerCase() === ecosystem; - const suppressed = []; const blocking = []; for (const finding of atOrAbove) { @@ -200,7 +214,7 @@ export function gate({ findings, exceptions = [], ecosystem, level = "high", now // Valid, in-scope, but past its window — surfaced so a lapsed entry gets // deleted instead of lingering as dead config. - const staleExceptions = valid.filter((e) => inScope(e) && !isLive(e, now)); + const staleExceptions = valid.filter((e) => inScope(e, ecosystem) && !isLive(e, now)); return { blocking, @@ -295,12 +309,23 @@ async function readStdin() { return Buffer.concat(chunks).toString("utf8"); } -function loadExceptions(path) { +export function loadExceptions(path, warn = console.error) { + let parsed; try { - return JSON.parse(readFileSync(path, "utf8")).exceptions ?? []; - } catch { - return []; // no file → empty allowlist, the normal case + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (err) { + if (err?.code === "ENOENT") return []; // no file → empty allowlist, the normal case + warn(`::warning::could not load security exceptions from ${path}: ${err.message}`); + return []; + } + + const exceptions = parsed?.exceptions; + if (exceptions === undefined || exceptions === null) return []; + if (!Array.isArray(exceptions)) { + warn(`::warning::security exceptions from ${path} must contain an exceptions array`); + return []; } + return exceptions; } async function main() { @@ -319,6 +344,13 @@ async function main() { return; } + const problem = levelProblem(options.level); + if (problem) { + console.error(`usage: vuln-gate.mjs --ecosystem npm|nuget [--level info|low|moderate|high|critical] [--warn-only] — ${problem}`); + process.exitCode = 2; + return; + } + let parsed; try { parsed = extractJson(await readStdin()); diff --git a/.github/scripts/vuln-gate.test.mjs b/.github/scripts/vuln-gate.test.mjs index 13ee93f2c..1f3afe6ad 100644 --- a/.github/scripts/vuln-gate.test.mjs +++ b/.github/scripts/vuln-gate.test.mjs @@ -9,6 +9,11 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { advisoryId, emitAllowlist, @@ -17,6 +22,8 @@ import { gate, isGhsaId, isValidException, + levelProblem, + loadExceptions, parseArgs, parseNpm, parseNuget, @@ -28,6 +35,7 @@ import { const NOW = new Date("2026-07-24T00:00:00Z"); const GHSA_ONE = "GHSA-aaaa-bbbb-cccc"; const GHSA_TWO = "GHSA-dddd-eeee-ffff"; +const SCRIPT = fileURLToPath(new URL("./vuln-gate.mjs", import.meta.url)); const finding = (over = {}) => ({ id: GHSA_ONE, package: "left-pad@1.0.0", severity: "high", title: "", url: "", ...over, @@ -307,3 +315,98 @@ test("parseArgs reads the flags and keeps sensible defaults", () => { }); assert.equal(parseArgs(["--emit-allowlist"]).emitAllowlist, true); }); + +test("levelProblem accepts supported case-insensitive thresholds and rejects bad values", () => { + for (const level of ["info", "LOW", "Moderate", "high", "CRITICAL"]) { + assert.equal(levelProblem(level), null); + } + assert.match(levelProblem("hihg"), /--level/); + assert.match(levelProblem(), /--level/); +}); + +test("gate: rejects an unknown configured threshold instead of lowering the floor", () => { + assert.throws( + () => gate({ findings: [finding({ severity: "critical" })], ecosystem: "npm", level: "hihg", now: NOW }), + /--level/, + ); +}); + +test("CLI: a critical npm advisory blocks by default and a typoed level is a usage error", () => { + const report = JSON.stringify({ + auditReportVersion: 2, + vulnerabilities: { + "left-pad": { + name: "left-pad", + severity: "critical", + via: [{ + source: 1, + name: "left-pad", + severity: "critical", + title: "critical test advisory", + url: `https://github.com/advisories/${GHSA_ONE}`, + }], + }, + }, + }); + const run = (level) => spawnSync( + process.execPath, + [SCRIPT, "--ecosystem", "npm", ...(level ? ["--level", level] : [])], + { cwd: process.cwd(), encoding: "utf8", input: report }, + ); + + assert.equal(run().status, 1); + const invalid = run("hihg"); + assert.equal(invalid.status, 2); + assert.match(invalid.stderr, /--level/); +}); + +test("gate: expired ANY and npm scopes are each reported stale", () => { + for (const ecosystem of ["ANY", "any", "NPM", "npm"]) { + const result = gate({ + findings: [finding()], + exceptions: [exception({ ecosystem, expires: "2026-07-23" })], + ecosystem: "npm", + now: NOW, + }); + assert.equal(result.blocking.length, 1, `${ecosystem} exception must not suppress after expiry`); + assert.equal(result.staleExceptions.length, 1, `${ecosystem} exception must be reported stale`); + } +}); + +test("loadExceptions: only a missing file is quiet; unusable files warn and suppress nothing", () => { + const dir = mkdtempSync(join(tmpdir(), "cluckwork-vuln-gate-")); + const path = join(dir, "exceptions.json"); + const warnings = []; + const warn = (line) => warnings.push(line); + + try { + assert.deepEqual(loadExceptions(path, warn), []); + assert.deepEqual(warnings, []); + + warnings.length = 0; + assert.deepEqual(warnings, []); + assert.deepEqual(loadExceptions(dir, warn), []); + assert.equal(warnings.length, 1); + assert.ok(warnings[0].includes(dir)); + + warnings.length = 0; + writeFileSync(path, "not json", "utf8"); + assert.deepEqual(loadExceptions(path, warn), []); + assert.equal(warnings.length, 1); + assert.ok(warnings[0].includes(path)); + + warnings.length = 0; + writeFileSync(path, JSON.stringify({ exceptions: {} }), "utf8"); + assert.deepEqual(loadExceptions(path, warn), []); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /array/i); + + warnings.length = 0; + const valid = [exception()]; + writeFileSync(path, JSON.stringify({ exceptions: valid }), "utf8"); + assert.deepEqual(loadExceptions(path, warn), valid); + assert.deepEqual(warnings, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});