diff --git a/tests/audit-gate.test.mjs b/tests/audit-gate.test.mjs index bfa60e11..8fd192fe 100644 --- a/tests/audit-gate.test.mjs +++ b/tests/audit-gate.test.mjs @@ -185,6 +185,88 @@ test('the allowlist matches the advisories SECURITY.md documents', () => { } }); +// Reads the GHSA ids that have a row in SECURITY.md's "Known unpatched +// dependency advisories" table. +// +// The table is the documented register of suppressions; the prose around it +// also names advisories, but for the opposite reason -- the paragraph on +// GHSA-ch52-4w7c-c8xp is there to record that the entry was *removed* once +// upstream shipped a fix. A substring search over the whole file cannot tell +// those two kinds of mention apart, so the parity checks below read rows. +function documentedAdvisoryIds(securityMd) { + const heading = '## Known unpatched dependency advisories'; + const start = securityMd.indexOf(heading); + assert.notEqual( + start, + -1, + `SECURITY.md no longer has a "${heading}" section; audit-gate.mjs tells ` + + 'contributors to document allowlist entries there', + ); + // The section runs to the next top-level heading; the appended sentinel + // makes "ends at EOF" the same case rather than a branch that only the end + // of the file could ever take. + const rest = `${securityMd.slice(start + heading.length)}\n## `; + const section = rest.slice(0, rest.search(/^## /m)); + return new Set( + [...section.matchAll(/^\|\s*\[?(GHSA(?:-[0-9a-z]{4}){3})\)?\]?/gm)].map( + (match) => match[1], + ), + ); +} + +test('every allowlisted advisory has a row in the SECURITY.md table', () => { + // Stronger than the substring check above, which a retired advisory can + // satisfy from the paragraph that says it is gone: re-adding + // GHSA-ch52-4w7c-c8xp to ALLOWLISTED_ADVISORIES would pass `includes()` + // while leaving the table -- the part a reader audits -- silent about it. + const securityMd = readFileSync(join(repoRoot, 'SECURITY.md'), 'utf8'); + const documented = documentedAdvisoryIds(securityMd); + for (const id of ALLOWLISTED_ADVISORIES) { + assert.ok( + documented.has(id), + `${id} is allowlisted in audit-gate.mjs but has no row in SECURITY.md's ` + + '"Known unpatched dependency advisories" table (a mention elsewhere ' + + 'in the file is not the register the gate points readers at)', + ); + } +}); + +test('every advisory in the SECURITY.md table is still allowlisted', () => { + // The other direction. A row here asserts that the gate is suppressing the + // advisory because nothing upstream fixes it. Once the allowlist entry goes + // away -- the fix lands, or the dependency is dropped -- an orphaned row + // documents a suppression that no longer exists, and the next reader + // auditing the posture is told the gate accepts an advisory it would in + // fact block. unusedAllowlistEntries() forces the allowlist side of that + // removal; nothing forced the table side. + const securityMd = readFileSync(join(repoRoot, 'SECURITY.md'), 'utf8'); + for (const id of documentedAdvisoryIds(securityMd)) { + assert.ok( + ALLOWLISTED_ADVISORIES.has(id), + `${id} has a row in SECURITY.md's "Known unpatched dependency ` + + 'advisories" table but is not in ALLOWLISTED_ADVISORIES in ' + + 'scripts/audit-gate.mjs; drop the row, or restore the entry', + ); + } +}); + +test('the SECURITY.md table is not empty while the allowlist has entries', () => { + // Guards the parser itself. If the table were reformatted into a shape the + // row regex no longer matches, both parity checks above would iterate over + // nothing and pass vacuously, which is exactly the silent-pass failure mode + // audit-gate.mjs refuses for an unreadable npm audit report. + const securityMd = readFileSync(join(repoRoot, 'SECURITY.md'), 'utf8'); + const documented = documentedAdvisoryIds(securityMd); + assert.equal( + documented.size > 0, + ALLOWLISTED_ADVISORIES.size > 0, + 'SECURITY.md\'s "Known unpatched dependency advisories" table parsed to ' + + `${documented.size} advisory row(s) while audit-gate.mjs allowlists ` + + `${ALLOWLISTED_ADVISORIES.size}; if the table was reformatted, update ` + + 'documentedAdvisoryIds() so the parity checks keep reading real rows', + ); +}); + // --- unusedAllowlistEntries --- test('unusedAllowlistEntries reports nothing while the advisory is present', () => {