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
54 changes: 43 additions & 11 deletions .github/scripts/vuln-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <low|moderate|high|critical> (default high — blocks at or above)
// Flags: --level <info|low|moderate|high|critical> (default high — blocks at or above)
// --warn-only (report, always exit 0)
// --exceptions <path> (default .github/security-exceptions.json)
// --emit-allowlist (print live GHSA ids for dependency-review)
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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() {
Expand All @@ -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());
Expand Down
103 changes: 103 additions & 0 deletions .github/scripts/vuln-gate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +22,8 @@ import {
gate,
isGhsaId,
isValidException,
levelProblem,
loadExceptions,
parseArgs,
parseNpm,
parseNuget,
Expand All @@ -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,
Expand Down Expand Up @@ -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 });
}
});
Loading