From b6b6243198a08d3da1cbf3ba671ecfb938aaafaf Mon Sep 17 00:00:00 2001 From: Rob Masson Date: Mon, 14 Sep 2026 19:16:30 -0700 Subject: [PATCH 1/2] fix(ci): unblock the JS tooling audit gate without lowering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm audit --audit-level=moderate runs before any Python step in both ci.yml and release.yml, so advisories published after the last green run (2026-08-19) turned every PR red at step four and left release.yml unable to reach its bump-and-publish steps. Nothing in the repo changed; the audit reads the committed lockfile against the live advisory database, so the result moved with the calendar. Bump two overrides that fix cleanly. fast-uri was already pinned to 4.1.2 as an earlier remediation and the new advisory range is 4.0.0 - 4.1.2, so the old fix had become the vulnerability; qs goes to 6.16.0. The remaining six findings are one chain: @faker-js/faker 5.5.3, hard-pinned by postman-collection, under @stoplight/http-spec, under Prism. There is no version to upgrade into — postman-collection 5.3.1 still pins 5.5.3 exactly, prism-cli 5.16.0 is the newest published, and overriding faker forward breaks postman-collection/lib/superstring and makes all 2616 contract tests uncollectable. npm audit cannot waive a single advisory; the only knob is --audit-level, and raising that to clear one high would hide the next real one too. So read the JSON report and decide in scripts/audit-js-tooling.mjs, which fails on anything at moderate or above that is not explicitly waived. A waiver carries a reason and an expiry, and the script fails both when an entry expires and when an entry stops matching any advisory, so a waiver cannot quietly outlive its justification. Refs #45 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMtJ4fwgPjiG8Zcv6bEkBi --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- package-lock.json | 12 ++-- package.json | 5 +- scripts/audit-js-tooling.mjs | 103 ++++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 10 deletions(-) create mode 100755 scripts/audit-js-tooling.mjs 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/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..5d9c0d0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "archastro-python-tooling", "version": "0.0.0", "private": true, - "description": "JS tooling deps for the ArchAstro Python SDK — the channel-harness subprocess that backs channel contract tests, and the Prism mock server used by REST contract tests.", + "description": "JS tooling deps for the ArchAstro Python SDK \u2014 the channel-harness subprocess that backs channel contract tests, and the Prism mock server used by REST contract tests.", "scripts": { "regenerate": "bash scripts/regenerate_sdk.sh" }, @@ -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..b0a4447 --- /dev/null +++ b/scripts/audit-js-tooling.mjs @@ -0,0 +1,103 @@ +#!/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()); + +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})` : ""}.`, +); From d9a99ff07c8119ab44bd037a21c1e666f7f4f69a Mon Sep 17 00:00:00 2001 From: Rob Masson Date: Mon, 14 Sep 2026 19:51:22 -0700 Subject: [PATCH 2/2] fix(ci): route the third workflow through the gate and keep its messages honest Review found regenerate-sdk.yml still running the raw npm audit, so the next manual regenerate dispatch would have failed on the same faker advisory this branch waives. All three workflows now call the script. Two smaller corrections from the same review: A registry failure exits non-zero with an {"error": ...} body and no vulnerabilities map, which the script read as a clean tree whose waiver had gone stale - fail-closed, but pointing at a live waiver as the thing to delete. Refuse an errored report explicitly instead. Restore the em dash in the package.json description. Rewriting the file through json.dump had replaced it with its ASCII escape sequence, a cosmetic change that does not belong here; the diff should be the two overrides and nothing else. Refs #45 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMtJ4fwgPjiG8Zcv6bEkBi --- .github/workflows/regenerate-sdk.yml | 2 +- package.json | 2 +- scripts/audit-js-tooling.mjs | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) 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/package.json b/package.json index 5d9c0d0..a389572 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "archastro-python-tooling", "version": "0.0.0", "private": true, - "description": "JS tooling deps for the ArchAstro Python SDK \u2014 the channel-harness subprocess that backs channel contract tests, and the Prism mock server used by REST contract tests.", + "description": "JS tooling deps for the ArchAstro Python SDK — the channel-harness subprocess that backs channel contract tests, and the Prism mock server used by REST contract tests.", "scripts": { "regenerate": "bash scripts/regenerate_sdk.sh" }, diff --git a/scripts/audit-js-tooling.mjs b/scripts/audit-js-tooling.mjs index b0a4447..e997187 100755 --- a/scripts/audit-js-tooling.mjs +++ b/scripts/audit-js-tooling.mjs @@ -52,6 +52,16 @@ function auditReport() { 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();