Skip to content
Closed
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
41 changes: 16 additions & 25 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 8 additions & 10 deletions scripts/audit-gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 36 additions & 10 deletions tests/audit-gate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
Expand Down Expand Up @@ -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',
Expand All @@ -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({
Expand Down Expand Up @@ -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.
Expand All @@ -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 });
Expand Down Expand Up @@ -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/);
Expand All @@ -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/);
});
Expand All @@ -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`));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading