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
6 changes: 3 additions & 3 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@
"overrides": {
"brace-expansion@>=1.0.0 <1.1.16": ">=1.1.16 <2.0.0",
"brace-expansion@>=3.0.0 <5.0.7": ">=5.0.7",
"postcss": "8.5.12",
"postcss": "8.5.18",
"sharp": "0.35.0",
"vite": "6.4.3",
"ws": "8.21.0",
"sharp": "0.35.0"
"ws": "8.21.0"
},
"onlyBuiltDependencies": [
"esbuild",
Expand Down
24 changes: 12 additions & 12 deletions apps/web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

115 changes: 110 additions & 5 deletions apps/web/scripts/npm-advisory-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,62 @@
// configured severity threshold. Endpoint or parsing failures exit nonzero:
// an audit that cannot run must never pass silently.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { validateBulkAdvisoryResponse } from "./npm-advisory-response.mjs";

const require = createRequire(import.meta.url);
const semver = require("semver");

// Disclosed, expiring exceptions for advisories with no available remediation.
// Deliberately narrow: an entry names one advisory URL and one package, never
// applies to the production audit, and stops applying the moment it expires so
// it cannot quietly become permanent. Every applied entry prints on every run.
const EXCEPTIONS_PATH =
process.env.NPM_ADVISORY_EXCEPTIONS_PATH ??
path.join(
path.dirname(path.dirname(fileURLToPath(import.meta.url))),
"security-advisory-exceptions.json",
);

function loadExceptions(now) {
let raw;
try {
raw = readFileSync(EXCEPTIONS_PATH, "utf8");
} catch (error) {
if (error.code === "ENOENT") return [];
throw new Error(`exception file unreadable: ${error.message}`);
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`exception file is not valid JSON: ${error.message}`);
}
const entries = parsed?.exceptions;
if (!Array.isArray(entries)) {
throw new Error("exception file must contain an `exceptions` array");
}
return entries.map((entry, index) => {
const where = `exception[${index}]`;
for (const field of ["advisory_url", "package", "expires", "justification"]) {
if (typeof entry?.[field] !== "string" || entry[field].trim() === "") {
throw new Error(`${where} is missing a non-empty ${field}`);
}
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.expires)) {
throw new Error(`${where} expires must be YYYY-MM-DD, got ${entry.expires}`);
}
const expires = new Date(`${entry.expires}T23:59:59Z`);
if (Number.isNaN(expires.getTime())) {
throw new Error(`${where} expires is not a real date: ${entry.expires}`);
}
return { ...entry, expiresAt: expires, expired: expires < now };
});
}

const BULK_ADVISORY_URL =
process.env.NPM_BULK_ADVISORY_URL ??
"https://registry.npmjs.org/-/npm/v1/security/advisories/bulk";
Expand Down Expand Up @@ -116,15 +166,70 @@ for (const [name, entries] of Object.entries(advisories)) {
}

const scope = prodOnly ? "production" : "full";
const blocking = findings.filter((f) => f.rank >= threshold);

// The production audit never consults exceptions. Anything reaching shipped
// artifacts blocks unconditionally.
let exceptions = [];
if (!prodOnly) {
try {
exceptions = loadExceptions(new Date());
} catch (error) {
console.error(`audit failed to run: ${error.message}`);
process.exit(2);
}
for (const entry of exceptions.filter((e) => e.expired)) {
console.error(
`[EXPIRED] exception for ${entry.package} ${entry.advisory_url} lapsed on ` +
`${entry.expires} and no longer applies; re-check the advisory and either ` +
`remediate or renew it deliberately`,
);
}
}

const liveExceptions = exceptions.filter((e) => !e.expired);
const exceptionFor = (finding) =>
liveExceptions.find(
(e) => e.advisory_url === finding.url && e.package === finding.name,
);

let excepted = 0;
const blocking = [];
for (const f of findings) {
const marker = f.rank >= threshold ? "BLOCKING" : "info";
if (f.rank < threshold) {
console.log(`[info] ${f.severity} ${f.name}@${f.affected.join(",")} (${f.range}) ${f.title} ${f.url}`);
continue;
}
const entry = exceptionFor(f);
if (entry) {
excepted += 1;
console.log(
`[EXCEPTED until ${entry.expires}] ${f.severity} ${f.name}@${f.affected.join(",")} ` +
`(${f.range}) ${f.title} ${f.url}\n justification: ${entry.justification}`,
);
continue;
}
blocking.push(f);
console.log(
`[${marker}] ${f.severity} ${f.name}@${f.affected.join(",")} (${f.range}) ${f.title} ${f.url}`,
`[BLOCKING] ${f.severity} ${f.name}@${f.affected.join(",")} (${f.range}) ${f.title} ${f.url}`,
);
}

// An exception that stops matching is stale scaffolding; say so rather than
// leaving a permanent entry nobody revisits.
for (const entry of liveExceptions) {
if (!findings.some((f) => f.url === entry.advisory_url && f.name === entry.package)) {
console.log(
`[STALE] exception for ${entry.package} ${entry.advisory_url} matched nothing; ` +
`it can be removed`,
);
}
}

const expiredCount = exceptions.filter((e) => e.expired).length;
console.log(
`${scope} audit: ${versions.size} packages checked, ` +
`${findings.length} matching advisories, ${blocking.length} at or above ${auditLevel}`,
`${findings.length} matching advisories, ${blocking.length} at or above ${auditLevel}` +
(excepted > 0 ? `, ${excepted} excepted` : "") +
(expiredCount > 0 ? `, ${expiredCount} expired exception(s)` : ""),
);
process.exit(blocking.length > 0 ? 1 : 0);
process.exit(blocking.length > 0 || expiredCount > 0 ? 1 : 0);
92 changes: 90 additions & 2 deletions apps/web/scripts/npm-advisory-audit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const WEB_DIR = path.dirname(SCRIPT_DIR);
const AUDIT_SCRIPT = path.join(SCRIPT_DIR, "npm-advisory-audit.mjs");

async function runAudit(responsePayload) {
async function runAudit(responsePayload, options = {}) {
const temporaryDirectory = await mkdtemp(
path.join(tmpdir(), "alice-npm-advisory-test-"),
);
Expand All @@ -26,6 +26,18 @@ async function runAudit(responsePayload) {
);
await chmod(fakePnpm, 0o755);

if (options.exceptions !== undefined) {
const exceptionsFile = path.join(temporaryDirectory, "exceptions.json");
await writeFile(
exceptionsFile,
typeof options.exceptions === "string"
? options.exceptions
: JSON.stringify(options.exceptions),
"utf8",
);
options.exceptionsFile = exceptionsFile;
}

const server = createServer((request, response) => {
request.resume();
response.writeHead(200, { "content-type": "application/json" });
Expand All @@ -42,13 +54,15 @@ async function runAudit(responsePayload) {
return await new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[AUDIT_SCRIPT, "--audit-level=high"],
[AUDIT_SCRIPT, "--audit-level=high", ...(options.extraArgs ?? [])],
{
cwd: WEB_DIR,
env: {
...process.env,
PATH: `${temporaryDirectory}${path.delimiter}${process.env.PATH ?? ""}`,
NPM_BULK_ADVISORY_URL: `http://127.0.0.1:${address.port}/bulk`,
NPM_ADVISORY_EXCEPTIONS_PATH:
options.exceptionsFile ?? path.join(temporaryDirectory, "absent.json"),
},
stdio: ["ignore", "pipe", "pipe"],
},
Expand Down Expand Up @@ -145,3 +159,77 @@ test("audit CLI preserves blocking-advisory exit 1", async () => {
assert.match(result.stdout, /1 matching advisories, 1 at or above high/);
assert.equal(result.stderr, "");
});

const BLOCKING_PAYLOAD = {
demo: [
{
vulnerable_versions: ">=1.0.0 <2.0.0",
severity: "high",
title: "Demo advisory",
url: "https://example.invalid/advisory",
},
],
};

const liveException = (overrides = {}) => ({
exceptions: [
{
advisory_url: "https://example.invalid/advisory",
package: "demo",
expires: "2999-01-01",
justification: "no remediation exists; development tooling only",
...overrides,
},
],
});

test("a live exception clears a blocking advisory and prints its justification", async () => {
const result = await runAudit(BLOCKING_PAYLOAD, { exceptions: liveException() });
assert.equal(result.code, 0);
assert.match(result.stdout, /\[EXCEPTED until 2999-01-01\]/);
assert.match(result.stdout, /justification: no remediation exists/);
assert.match(result.stdout, /1 excepted/);
});

test("the production audit ignores exceptions entirely", async () => {
const result = await runAudit(BLOCKING_PAYLOAD, {
exceptions: liveException(),
extraArgs: ["--prod"],
});
assert.equal(result.code, 1);
assert.match(result.stdout, /\[BLOCKING\]/);
assert.doesNotMatch(result.stdout, /EXCEPTED/);
});

test("an expired exception stops applying and fails the audit", async () => {
const result = await runAudit(BLOCKING_PAYLOAD, {
exceptions: liveException({ expires: "2000-01-01" }),
});
assert.equal(result.code, 1);
assert.match(result.stderr, /\[EXPIRED\]/);
assert.match(result.stdout, /\[BLOCKING\]/);
assert.match(result.stdout, /1 expired exception/);
});

test("an exception matching nothing is reported as stale", async () => {
const result = await runAudit(
{},
{ exceptions: liveException() },
);
assert.equal(result.code, 0);
assert.match(result.stdout, /\[STALE\]/);
});

test("a malformed or under-specified exception file fails closed", async () => {
for (const malformed of [
"{ not json",
JSON.stringify({}),
JSON.stringify({ exceptions: {} }),
JSON.stringify({ exceptions: [{ advisory_url: "u", package: "demo", expires: "2999-01-01" }] }),
JSON.stringify({ exceptions: [{ advisory_url: "u", package: "demo", expires: "soon", justification: "j" }] }),
]) {
const result = await runAudit(BLOCKING_PAYLOAD, { exceptions: malformed });
assert.equal(result.code, 2, `expected exit 2 for ${malformed}`);
assert.match(result.stderr, /audit failed to run/);
}
});
20 changes: 20 additions & 0 deletions apps/web/security-advisory-exceptions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$comment": [
"Disclosed exceptions to the full dependency audit. Each entry names ONE",
"advisory URL and ONE package, must carry a written justification, and",
"expires on a stated date. Expired entries stop applying and fail the audit,",
"so an exception cannot become permanent by neglect. The production audit",
"never consults this file: anything reaching a shipped artifact blocks",
"unconditionally. Add an entry only when there is no available remediation,",
"and prefer fixing the dependency."
],
"exceptions": [
{
"advisory_url": "https://github.com/advisories/GHSA-mh99-v99m-4gvg",
"package": "brace-expansion",
"expires": "2026-09-25",
"scope": "development tooling only",
"justification": "Denial of service via unbounded brace expansion, reachable only through a maliciously crafted glob pattern. No remediation is available: the advisory covers everything up to 5.0.7, the installed 1.1.16 and 2.1.2 have no patched release in their major lines, and forcing the tree to the patched 5.0.8 requires minimatch 10, which is ESM-only with no default export and cannot be loaded by eslint. Verified 2026-07-27: brace-expansion 5.0.8 alone breaks the vitest coverage provider through test-exclude and glob, and adding a minimatch 10 override fixes coverage but breaks lint. The package is absent from the production audit set and is reached only by build and test tooling whose glob patterns come from committed configuration, never from untrusted input. Revisit when eslint and glob have migrated to brace-expansion 5."
}
]
}
Loading