diff --git a/changelog.d/fixed-520-npm-audit-gate-scoped-exceptions.md b/changelog.d/fixed-520-npm-audit-gate-scoped-exceptions.md new file mode 100644 index 00000000..4f5e44fd --- /dev/null +++ b/changelog.d/fixed-520-npm-audit-gate-scoped-exceptions.md @@ -0,0 +1,2 @@ +- Honour every package-scoped npm audit exception when one GHSA is listed for + several packages instead of keeping only the last entry. diff --git a/scripts/npm-audit-gate.test.ts b/scripts/npm-audit-gate.test.ts index 0cf6272b..6871937e 100644 --- a/scripts/npm-audit-gate.test.ts +++ b/scripts/npm-audit-gate.test.ts @@ -195,6 +195,50 @@ describe("evaluateAudit", () => { expect(r.unused).toHaveLength(1); }); + it("honours one scoped exception per package for the same GHSA", () => { + const report = { + vulnerabilities: { + "pkg-a": { + via: [{ name: "pkg-a", url: BRACES_URL, severity: "high" }], + }, + "pkg-b": { + via: [{ name: "pkg-b", url: BRACES_URL, severity: "high" }], + }, + }, + }; + const r = evaluateAudit( + report, + [exception({ package: "pkg-a" }), exception({ package: "pkg-b" })], + "high", + NOW + ); + expect(r.excepted.map(a => a.package)).toEqual(["pkg-a", "pkg-b"]); + expect(r.blocking).toEqual([]); + expect(r.unused).toEqual([]); + }); + + it("lets a scoped exception and an unscoped one for the same GHSA coexist", () => { + const report = { + vulnerabilities: { + "pkg-a": { + via: [{ name: "pkg-a", url: BRACES_URL, severity: "high" }], + }, + "pkg-b": { + via: [{ name: "pkg-b", url: BRACES_URL, severity: "high" }], + }, + }, + }; + const r = evaluateAudit( + report, + [exception({ package: "pkg-a" }), exception({ package: undefined })], + "high", + NOW + ); + expect(r.excepted.map(a => a.package)).toEqual(["pkg-a", "pkg-b"]); + expect(r.blocking).toEqual([]); + expect(r.unused).toEqual([]); + }); + it.each(["moderate", "high"])( "keeps the second package blocking with a %s first package and scoped exception", severity => { diff --git a/scripts/npm-audit-gate.ts b/scripts/npm-audit-gate.ts index 3caf98f1..93e5740a 100644 --- a/scripts/npm-audit-gate.ts +++ b/scripts/npm-audit-gate.ts @@ -123,6 +123,10 @@ export interface GateResult { invalid: string[]; } +function exceptionKey(id: string, pkg?: string): string { + return `${id.toUpperCase()}:${pkg ?? "*"}`; +} + export function evaluateAudit( report: AuditReport, exceptions: AuditException[], @@ -151,7 +155,7 @@ export function evaluateAudit( expired.push(ex); continue; } - active.set(ex.id.toUpperCase(), ex); + active.set(exceptionKey(ex.id, ex.package), ex); } const blocking: Advisory[] = []; @@ -163,17 +167,19 @@ export function evaluateAudit( belowLevel.push(adv); continue; } - const ex = active.get(adv.id); - if (ex && (!ex.package || ex.package === adv.package)) { - used.add(adv.id); + const key = active.has(exceptionKey(adv.id, adv.package)) + ? exceptionKey(adv.id, adv.package) + : exceptionKey(adv.id); + if (active.has(key)) { + used.add(key); excepted.push(adv); } else { blocking.push(adv); } } - const unused = [...active.values()].filter( - ex => !used.has(ex.id.toUpperCase()) - ); + const unused = [...active.entries()] + .filter(([key]) => !used.has(key)) + .map(([, ex]) => ex); return { blocking, excepted, belowLevel, expired, unused, invalid }; }