diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5cc244..653be1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: - run: npm ci --ignore-scripts - name: Audit JS tooling dependencies - run: npm audit --audit-level=moderate + run: node scripts/audit-js-tooling.mjs - name: Setup Python ${{ matrix.python }} uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 diff --git a/.github/workflows/regenerate-sdk.yml b/.github/workflows/regenerate-sdk.yml index e45d89a..5acd822 100644 --- a/.github/workflows/regenerate-sdk.yml +++ b/.github/workflows/regenerate-sdk.yml @@ -47,7 +47,7 @@ jobs: npm install --package-lock-only --ignore-scripts - name: Audit JS tooling dependencies - run: npm audit --audit-level=moderate + run: node scripts/audit-js-tooling.mjs - name: Setup Python uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21bbabd..f09b406 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: - run: npm ci --ignore-scripts - name: Audit JS tooling dependencies - run: npm audit --audit-level=moderate + run: node scripts/audit-js-tooling.mjs - name: Setup Python uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 diff --git a/package-lock.json b/package-lock.json index 3ef4b60..9ac3384 100644 --- a/package-lock.json +++ b/package-lock.json @@ -979,9 +979,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", - "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", "dev": true, "funding": [ { @@ -2025,9 +2025,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index ff7c8a2..a389572 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ "@stoplight/prism-http": "5.16.0", "@stoplight/prism-http-server": "5.16.0", "@tootallnate/once": "2.0.1", - "fast-uri": "4.1.2", + "fast-uri": "4.1.4", "fast-xml-parser": "5.8.0", "js-yaml": "5.2.3", "lodash": "4.18.1", + "qs": "6.16.0", "uuid": "11.1.1", "ws": "8.21.0" }, diff --git a/scripts/audit-js-tooling.mjs b/scripts/audit-js-tooling.mjs new file mode 100755 index 0000000..e997187 --- /dev/null +++ b/scripts/audit-js-tooling.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +// Fails on any npm advisory at or above `moderate` in the JS tooling tree, +// except advisories listed in ALLOWED below. +// +// `npm audit` has no way to waive a single advisory: the only knob is +// --audit-level, and raising that to clear one high-severity finding would +// also hide the next real one. So we read the JSON report and decide here. +// +// An allowlist entry is a dated, justified exception, not a mute button. The +// script fails when an entry expires, and fails when an entry no longer +// matches anything — a waiver that outlives its advisory is a waiver nobody +// re-read. + +import { execFileSync } from "node:child_process"; + +const ALLOWED = { + "GHSA-qxc2-j82w-r537": { + package: "@faker-js/faker", + expires: "2026-12-31", + reason: + "Arbitrary code execution via faker.helpers.fake on a caller-supplied " + + "template. Reaches us only as postman-collection's hard-pinned " + + "@faker-js/faker 5.5.3, under @stoplight/http-spec, under the Prism " + + "mock server. Not fixable by upgrading: postman-collection 5.3.1 (latest) " + + "still pins 5.5.3 exactly, and overriding faker forward breaks " + + "postman-collection/lib/superstring, which makes every tests/contract " + + "case uncollectable. Not reachable as we run Prism: tests/contract/conftest.py " + + "starts `prism mock ` against a spec we generate, with no " + + "--dynamic flag, so no Postman collection is parsed and faker generates " + + "nothing. Prism is a devDependency and is never part of the published wheel.", + }, +}; + +function auditReport() { + const options = { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + stdio: ["ignore", "pipe", "inherit"], + }; + try { + return execFileSync("npm", ["audit", "--json"], options); + } catch (error) { + // npm exits non-zero whenever it finds anything at all. That is the normal + // path here, and the report is still on stdout; only a missing report is + // a real failure. + if (typeof error.stdout === "string" && error.stdout.trim() !== "") { + return error.stdout; + } + throw error; + } +} + +const report = JSON.parse(auditReport()); + +// A registry failure also exits non-zero, with an {"error": ...} body and no +// vulnerabilities map. Without this, an unreachable registry would look like a +// clean tree whose waiver had gone stale — fail-closed, but for the wrong +// reason and with a message that sends you to delete a live waiver. +if (report.error) { + throw new Error( + `npm audit could not produce a report: ${report.error.summary ?? JSON.stringify(report.error)}`, + ); +} + +const BLOCKING = new Set(["moderate", "high", "critical"]); +const found = new Map(); + +for (const vuln of Object.values(report.vulnerabilities ?? {})) { + for (const via of vuln.via ?? []) { + if (typeof via !== "object" || !via.url) continue; + if (!BLOCKING.has(via.severity)) continue; + const id = via.url.split("/").pop(); + if (!found.has(id)) { + found.set(id, { id, severity: via.severity, package: via.name, title: via.title }); + } + } +} + +const today = new Date().toISOString().slice(0, 10); +const failures = []; + +for (const advisory of found.values()) { + const waiver = ALLOWED[advisory.id]; + if (!waiver) { + failures.push( + `${advisory.severity.toUpperCase()} ${advisory.id} (${advisory.package}) — ${advisory.title}`, + ); + } else if (waiver.expires < today) { + failures.push( + `${advisory.id} (${advisory.package}) — waiver expired ${waiver.expires}; re-review it`, + ); + } +} + +for (const [id, waiver] of Object.entries(ALLOWED)) { + if (!found.has(id)) { + failures.push( + `${id} (${waiver.package}) — waiver no longer matches any advisory; delete it from ALLOWED`, + ); + } +} + +if (failures.length > 0) { + console.error("JS tooling audit failed:\n"); + for (const line of failures) console.error(` - ${line}`); + console.error("\nRun `npm audit` for the full report."); + process.exit(1); +} + +const waived = Object.keys(ALLOWED).join(", "); +console.log( + `JS tooling audit clean at moderate and above${waived ? ` (waived: ${waived})` : ""}.`, +);