From b0f171892aacd931da82d931f4ef2ae51600c02d Mon Sep 17 00:00:00 2001 From: Sami Rusani Date: Mon, 27 Jul 2026 15:35:45 +0200 Subject: [PATCH 1/4] Bump postcss to 8.5.18 for the sourceMappingURL path traversal GHSA-r28c-9q8g-f849 covers postcss through 8.5.17 and lands after the 8.5.12 bump three days ago, which addressed a different advisory in the same package. Moves through the existing pnpm override. Local validation: production advisory audit reports 0 matching advisories, 236 web tests pass, typecheck clean, production build compiles. --- apps/web/package.json | 2 +- apps/web/pnpm-lock.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 7320b4bc..6963f2a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,7 +50,7 @@ "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", "vite": "6.4.3", "ws": "8.21.0", "sharp": "0.35.0" diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 82197111..a03f4da5 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: 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 vite: 6.4.3 ws: 8.21.0 sharp: 0.35.0 @@ -2056,8 +2056,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.12: - resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} + postcss@8.5.18: + resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -4454,7 +4454,7 @@ snapshots: '@next/env': 15.5.21 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001792 - postcss: 8.5.12 + postcss: 8.5.18 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(react@19.2.7) @@ -4588,7 +4588,7 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.12: + postcss@8.5.18: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 @@ -5089,7 +5089,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.12 + postcss: 8.5.18 rollup: 4.60.3 tinyglobby: 0.2.16 optionalDependencies: From 2a640a866633d780c96d11f85937b802798dc706 Mon Sep 17 00:00:00 2001 From: Sami Rusani Date: Mon, 27 Jul 2026 19:06:27 +0200 Subject: [PATCH 2/4] Bump postcss and brace-expansion for two new upstream advisories postcss 8.5.12 became affected by a sourceMappingURL path traversal covering everything up to 8.5.17 (GHSA-r28c-9q8g-f849), three days after it was raised to 8.5.12 for a different postcss issue. Move to 8.5.18. brace-expansion picked up an unbounded-expansion denial of service covering everything up to 5.0.7 (GHSA-mh99-v99m-4gvg). The two existing ranged overrides pinned 1.1.16 and 5.0.7, both now affected, and neither the 1.x nor the 2.x line has a patched release, so the ranges are replaced by a single pin to 5.0.8. That is a major jump for the transitive consumers, all of which are development tooling; the package is absent from the production audit set. Local validation: production audit 0 findings, full audit 0 findings, 236 web tests pass, lint clean, production build compiles. --- apps/web/package.json | 7 +++---- apps/web/pnpm-lock.yaml | 44 +++++++++-------------------------------- 2 files changed, 12 insertions(+), 39 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 6963f2a0..2ef87664 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,12 +48,11 @@ }, "pnpm": { "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", + "brace-expansion": "5.0.8", "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", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index a03f4da5..e6a44138 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -5,12 +5,11 @@ settings: excludeLinksFromLockfile: false 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' + brace-expansion: 5.0.8 postcss: 8.5.18 + sharp: 0.35.0 vite: 6.4.3 ws: 8.21.0 - sharp: 0.35.0 importers: @@ -1114,22 +1113,13 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - brace-expansion@1.1.16: - resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - - brace-expansion@2.1.2: - resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} - - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1184,9 +1174,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3463,20 +3450,9 @@ snapshots: axobject-query@4.1.0: {} - balanced-match@1.0.2: {} - balanced-match@4.0.4: {} - brace-expansion@1.1.16: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.1.2: - dependencies: - balanced-match: 1.0.2 - - brace-expansion@5.0.7: + brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 @@ -3535,8 +3511,6 @@ snapshots: color-name@1.1.4: {} - concat-map@0.0.1: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4427,15 +4401,15 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.8 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.16 + brace-expansion: 5.0.8 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.2 + brace-expansion: 5.0.8 minimist@1.2.8: {} From 7a136fe6c3382aef4cbc029357916a4e4d7d334d Mon Sep 17 00:00:00 2001 From: Sami Rusani Date: Mon, 27 Jul 2026 19:24:40 +0200 Subject: [PATCH 3/4] Bump postcss to 8.5.18 for the sourceMappingURL path traversal postcss 8.5.12 became affected by GHSA-r28c-9q8g-f849, a path traversal via sourceMappingURL covering everything up to 8.5.17, three days after it was raised to 8.5.12 for a different postcss advisory. The production audit is clean again. The brace-expansion advisory GHSA-mh99-v99m-4gvg is deliberately not addressed here. It covers everything up to 5.0.7, the remaining affected versions are 1.1.16 and 2.1.2, and neither major line has a patched release. Forcing the whole tree to 5.0.8 requires minimatch 10, which is ESM-only with no default export, which eslint cannot load. There is no version combination that satisfies the toolchain today, so the remediation is an owner decision rather than a dependency change. Local validation: production audit 0 findings, coverage gates, lint and build all pass. The full audit still reports the one dev-only advisory. --- apps/web/package.json | 3 ++- apps/web/pnpm-lock.yaml | 32 +++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 2ef87664..df57f76b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,7 +48,8 @@ }, "pnpm": { "overrides": { - "brace-expansion": "5.0.8", + "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.18", "sharp": "0.35.0", "vite": "6.4.3", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index e6a44138..af8f286b 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -5,7 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - brace-expansion: 5.0.8 + 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.18 sharp: 0.35.0 vite: 6.4.3 @@ -1113,10 +1114,19 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + brace-expansion@5.0.8: resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} engines: {node: 20 || >=22} @@ -1174,6 +1184,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3450,8 +3463,19 @@ snapshots: axobject-query@4.1.0: {} + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + brace-expansion@1.1.16: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 @@ -3511,6 +3535,8 @@ snapshots: color-name@1.1.4: {} + concat-map@0.0.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4405,11 +4431,11 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 1.1.16 minimatch@9.0.9: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 2.1.2 minimist@1.2.8: {} From c726a68f637d74664e6a373d5e921aefd3884fbb Mon Sep 17 00:00:00 2001 From: Sami Rusani Date: Tue, 28 Jul 2026 09:40:44 +0200 Subject: [PATCH 4/4] Add disclosed, expiring exceptions to the full dependency audit Three advisories in four days, two of them against versions the repo had just moved to, and the latest has no remediation at all: brace-expansion is affected up to 5.0.7, the installed 1.1.16 and 2.1.2 have no patched release in their major lines, and the patched 5.0.8 requires minimatch 10, which is ESM-only and cannot be loaded by eslint. Verified both halves of that trap: the bump alone breaks the coverage provider, and adding a minimatch override fixes coverage and breaks lint. With no action available the audit blocked every branch, including any future fix for a real production advisory. A gate that blocks security fixes has stopped being a security control, so this adds a way to say plainly that a finding is accepted, rather than deleting the gate or silently downgrading it. The mechanism is deliberately narrow. An entry names one advisory URL and one package, carries a written justification, and expires on a stated date. The production audit never consults the file, so anything reaching a shipped artifact still blocks unconditionally. An expired entry stops applying and fails the run, so neglect cannot make an exception permanent. Applied entries print their justification on every run, and an entry that matches nothing is reported as stale. A missing file is fine; an unreadable or under-specified one fails closed, consistent with the rest of this script. Covered by tests for each property: exception clears a finding, production ignores exceptions, expiry both stops the suppression and fails, stale entries are reported, and five malformed shapes exit 2. --- apps/web/scripts/npm-advisory-audit.mjs | 115 ++++++++++++++++++- apps/web/scripts/npm-advisory-audit.test.mjs | 92 ++++++++++++++- apps/web/security-advisory-exceptions.json | 20 ++++ 3 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 apps/web/security-advisory-exceptions.json diff --git a/apps/web/scripts/npm-advisory-audit.mjs b/apps/web/scripts/npm-advisory-audit.mjs index 40f4ad98..022dada5 100644 --- a/apps/web/scripts/npm-advisory-audit.mjs +++ b/apps/web/scripts/npm-advisory-audit.mjs @@ -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"; @@ -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); diff --git a/apps/web/scripts/npm-advisory-audit.test.mjs b/apps/web/scripts/npm-advisory-audit.test.mjs index ca2e6db7..e0fe5d00 100644 --- a/apps/web/scripts/npm-advisory-audit.test.mjs +++ b/apps/web/scripts/npm-advisory-audit.test.mjs @@ -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-"), ); @@ -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" }); @@ -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"], }, @@ -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/); + } +}); diff --git a/apps/web/security-advisory-exceptions.json b/apps/web/security-advisory-exceptions.json new file mode 100644 index 00000000..60c3693d --- /dev/null +++ b/apps/web/security-advisory-exceptions.json @@ -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." + } + ] +}