From e460c5e33e2f6655c1564d838a03aed9e3d79a2d Mon Sep 17 00:00:00 2001 From: ci-maintainer <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:42:53 -0400 Subject: [PATCH] fix: retire the withdrawn braces advisory from the audit allowlist GHSA-vfj7-8cjw-p6xm (braces <= 3.0.3, via micromatch in @docusaurus/utils) was withdrawn upstream on 2026-10-23, so npm audit no longer reports it and the audit gate now fails closed on its own stale allowlist entry, reddening Validate repository on every PR. Remove the entry from ALLOWLISTED_ADVISORIES, drop the SECURITY.md row (the history paragraph records both retirements), and decouple the gate's tests from the shipped set: the three classifyAudit tests pass an explicit allowlist, and the CLI harness gains an allowlist option that pins an entry into the copied script, so arms that only exist while an advisory is allowlisted no longer depend on the shipped set being non-empty. Signed-off-by: ci-maintainer <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- SECURITY.md | 41 ++++++++++++++-------------------- scripts/audit-gate.mjs | 18 +++++++-------- tests/audit-gate.test.mjs | 46 ++++++++++++++++++++++++++++++--------- 3 files changed, 60 insertions(+), 45 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 5ecefbca..4fc1e8ba 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -69,31 +69,22 @@ the import gates. ## Known unpatched dependency advisories -`npm audit` currently reports 28 high-severity packages. They all resolve to the -single advisory below, and every other entry is transitive bubbling from it. The -package is already at its newest published version, so there is no upgrade to -apply and Dependabot has nothing to offer: - -| Advisory | Package | Installed | Reached through | -| ------------------------------------------------------------------------ | -------- | --------- | ---------------------------------------------- | -| [GHSA-vfj7-8cjw-p6xm](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm) | `braces` | 3.0.3 | `@docusaurus/utils` globbing, via `micromatch` | - -It is build-time only: `braces` glob patterns come from Docusaurus rather than -from submitted content, and the package is not bundled into the static output, -so the advisory is not reachable by a visitor to the published site. - -**Do not run `npm audit fix --force` against this.** No patched version exists — -`3.0.3` is the newest release and the advisory's vulnerable range is `<= 3.0.3` -— so the command can only try to force-resolve the `@docusaurus/*` tree onto -incompatible versions, breaking the build without removing the advisory. Remove -this section once upstream publishes a fix and the dependency tree picks it up. - -A second advisory, GHSA-ch52-4w7c-c8xp against `http-cache-semantics`, was -listed here until upstream published `4.3.0` — outside that advisory's -`<= 4.2.0` vulnerable range. The lockfile now resolves `4.3.0` and the advisory -no longer appears, which is why the count above dropped from 34 to 28. When an -entry here is said to have no fix, re-check the registry before trusting it: "no -patched version" is only true as of the day it was written. +`npm audit` currently reports no high-severity advisory without a fix, so the +table is empty and `ALLOWLISTED_ADVISORIES` in `scripts/audit-gate.mjs` holds +no entries: + +| Advisory | Package | Installed | Reached through | +| -------- | ------- | --------- | --------------- | + +Two advisories previously listed here were retired once their premise expired. +[GHSA-vfj7-8cjw-p6xm](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm) +(`braces <= 3.0.3`, reached through `@docusaurus/utils` globbing via +`micromatch`, build-time only) was withdrawn upstream on 2026-10-23, so +`npm audit` no longer reports it. GHSA-ch52-4w7c-c8xp against +`http-cache-semantics` was listed until upstream published `4.3.0` — outside +that advisory's `<= 4.2.0` vulnerable range. When an entry here is said to +have no fix, re-check the registry before trusting it: "no patched version" +is only true as of the day it was written. `npm run check:audit` now re-checks it for you on every run, in both of the ways such a claim can expire. Once an allowlisted advisory stops appearing in the lockfile, the gate fails and names the entry to remove from both diff --git a/scripts/audit-gate.mjs b/scripts/audit-gate.mjs index 48899071..6753d349 100644 --- a/scripts/audit-gate.mjs +++ b/scripts/audit-gate.mjs @@ -22,17 +22,15 @@ import { spawnSync } from 'node:child_process'; // Advisories npm cannot fix because no patched release exists. Every entry // must correspond to a row in SECURITY.md's "Known unpatched dependency -// advisories" table; remove the entry the moment upstream publishes a fix -// (for braces: when `npm view braces versions` lists a release above 3.0.3), -// so the gate starts enforcing it. unusedAllowlistEntries() below fails the -// gate once an entry stops matching anything in the lockfile, and -// fixableAllowlistedAdvisories() fails it once npm reports a fix while the -// advisory is still present, so that removal is prompted rather than left to -// be noticed. +// advisories" table; remove the entry the moment upstream publishes a fix or +// withdraws the advisory, so the gate starts enforcing it. +// unusedAllowlistEntries() below fails the gate once an entry stops matching +// anything in the lockfile, and fixableAllowlistedAdvisories() fails it once +// npm reports a fix while the advisory is still present, so that removal is +// prompted rather than left to be noticed. export const ALLOWLISTED_ADVISORIES = new Set([ - // braces <= 3.0.3 via micromatch in @docusaurus/utils globbing; build-time - // only, never shipped to the published site. - 'GHSA-vfj7-8cjw-p6xm', + // Empty at present: GHSA-vfj7-8cjw-p6xm (braces <= 3.0.3, via micromatch in + // @docusaurus/utils globbing) was withdrawn upstream on 2026-10-23. ]); // `npm audit --audit-level=high` semantics: moderate and low findings are diff --git a/tests/audit-gate.test.mjs b/tests/audit-gate.test.mjs index d06ad69f..a71f5de5 100644 --- a/tests/audit-gate.test.mjs +++ b/tests/audit-gate.test.mjs @@ -73,7 +73,7 @@ test('an entry whose only advisory is allowlisted is not blocked', () => { const report = { vulnerabilities: { braces: entry('high', [advisory(BRACES)]) }, }; - const { blocked, allowlisted } = classifyAudit(report); + const { blocked, allowlisted } = classifyAudit(report, new Set([BRACES])); assert.deepEqual(blocked, []); assert.deepEqual(allowlisted, ['braces']); }); @@ -110,7 +110,7 @@ test('an allowlisted advisory clears its whole bubbled chain', () => { '@docusaurus/utils': entry('high', ['micromatch']), }, }; - const { blocked, allowlisted } = classifyAudit(report); + const { blocked, allowlisted } = classifyAudit(report, new Set([BRACES])); assert.deepEqual(blocked, []); assert.deepEqual(allowlisted.sort(), [ '@docusaurus/utils', @@ -134,12 +134,15 @@ test('moderate and low findings never block, even unallowlisted', () => { test('a via cycle terminates and is judged by its advisories', () => { // npm emits mutual references when two packages each bubble the other. // With only an allowlisted advisory in the cycle, both clear... - const clean = classifyAudit({ - vulnerabilities: { - a: entry('high', [advisory(BRACES), 'b']), - b: entry('high', ['a']), + const clean = classifyAudit( + { + vulnerabilities: { + a: entry('high', [advisory(BRACES), 'b']), + b: entry('high', ['a']), + }, }, - }); + new Set([BRACES]), + ); assert.deepEqual(clean.blocked, []); // ...and with a non-allowlisted one, both block. const dirty = classifyAudit({ @@ -545,6 +548,11 @@ syncBuiltinESMExports(); * @param {boolean} [options.withNpm] install the stub at all; false makes * spawnSync fail with ENOENT to exercise the spawn-error arm * @param {number} [options.timeouts] deadline errors before npm can run + * @param {string[]} [options.allowlist] ids to pin into the copied script's + * ALLOWLISTED_ADVISORIES. The shipped set is empty, so behaviour that only + * exists while an advisory is allowlisted (stale-entry, suppression, + * fix-available arms) pins one here instead of depending on whatever the + * shipped set happens to hold. * @returns {{status: number, stdout: string, stderr: string, * attempts: number, argv: string[], spawns: object[]}} `argv` holds the * invocations that reached npm; `spawns` includes timed-out attempts. @@ -555,12 +563,27 @@ function runGate({ responses, withNpm = true, timeouts = 0, + allowlist, } = {}) { const work = mkdtempSync(join(tmpdir(), 'endusers-audit-')); try { const script = join(work, 'scripts', 'audit-gate.mjs'); mkdirSync(dirname(script), { recursive: true }); cpSync(join(repoRoot, 'scripts', 'audit-gate.mjs'), script); + if (allowlist) { + const source = readFileSync(script, 'utf8'); + const pinned = `export const ALLOWLISTED_ADVISORIES = new Set(${JSON.stringify(allowlist)});`; + const patched = source.replace( + /export const ALLOWLISTED_ADVISORIES = new Set\(\[[\s\S]*?\]\);/, + pinned, + ); + assert.notEqual( + patched, + source, + 'runGate could not find ALLOWLISTED_ADVISORIES in the copied script', + ); + writeFileSync(script, patched); + } const binDir = join(work, '.bin'); mkdirSync(binDir, { recursive: true }); @@ -620,6 +643,7 @@ test('cli: exits 1 on a clean report, because the allowlist is then stale', () = // path, which is what a clean *and* empty allowlist would also produce. const result = runGate({ stdout: JSON.stringify({ vulnerabilities: {} }), + allowlist: [BRACES], }); assert.equal(result.status, 1); assert.match(result.stderr, /no longer present in the lockfile/); @@ -634,7 +658,7 @@ test('cli: exits 0 when every finding is allowlisted, despite npm exiting 1', () micromatch: entry('high', ['braces']), }, }; - const result = runGate({ stdout: JSON.stringify(report), status: '1' }); + const result = runGate({ stdout: JSON.stringify(report), status: '1', allowlist: [BRACES] }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /2 high\/critical finding/); }); @@ -653,7 +677,7 @@ test('cli: exits 1 when npm reports a fix for an allowlisted advisory', () => { }, }, }; - const result = runGate({ stdout: JSON.stringify(report), status: '1' }); + const result = runGate({ stdout: JSON.stringify(report), status: '1', allowlist: [BRACES] }); assert.equal(result.status, 1); assert.match(result.stderr, /now reports as fixable/); assert.match(result.stderr, new RegExp(`${BRACES} via braces`)); @@ -742,6 +766,7 @@ test('cli: a transient no-report answer is retried and the next report decides', { stdout: QUICK_ENDPOINT_FAILURE, status: 1 }, { stdout: ALLOWLISTED_REPORT, status: 1 }, ], + allowlist: [BRACES], }); assert.equal(result.status, 0); assert.equal(result.attempts, 2); @@ -787,6 +812,7 @@ test('cli: a clean run asks npm exactly once', () => { // The retry must not cost a second registry round trip on the happy path. const result = runGate({ responses: [{ stdout: ALLOWLISTED_REPORT, status: 1 }], + allowlist: [BRACES], }); assert.equal(result.status, 0); assert.equal(result.attempts, 1); @@ -809,7 +835,7 @@ test('cli: every attempt bounds the subprocess as well as each request', () => { }); test('cli: a timed-out partial report is retried before accepting a report', () => { - const result = runGate({ timeouts: 1, stdout: ALLOWLISTED_REPORT }); + const result = runGate({ timeouts: 1, stdout: ALLOWLISTED_REPORT, allowlist: [BRACES] }); assert.equal(result.status, 0, result.stderr); assert.equal(result.attempts, 2); assert.equal(result.argv.length, 1);