morphit/apps/web/scripts/npm-audit-gate-smoke.ts
Morphit Team 0ecced72b7
Some checks failed
morphit-release / Build + publish release tarball (push) Has been cancelled
v1.10.0: pin js-yaml 4.3.1 (release-gate fix)
2026-08-06 14:39:40 -07:00

477 lines
23 KiB
TypeScript

#!/usr/bin/env tsx
/**
* npm-audit-gate-smoke — Part 122 cp16 audit finding DD-13.
*
* Runs `npm audit --json` against the root workspace and fails
* the build if it reports any HIGH or CRITICAL vulnerabilities
* that aren't on the documented allowlist below.
*
* Each entry in the allowlist names a package + the severity we
* accept for it, plus a short rationale. Whoever adds a new
* allowlist entry must also add a rationale — the gate isn't
* "ignore everything," it's "document why each accepted risk
* is below our threat-model bar."
*
* When a vulnerability is fixed upstream OR mitigated by removing
* the dependency, drop the allowlist row. The smoke will then
* pass cleanly.
*
* Note: this smoke runs `npm audit` which talks to the npm
* registry. In offline / restricted-network environments, the
* smoke will WARN and exit 0 rather than fail spuriously —
* a hard fail on transient network issues would mask real
* problems. CI environments with real audit results should
* see a hard fail when something slips through.
*/
import { execSync } from 'node:child_process';
import { join } from 'node:path';
const REPO = join(import.meta.dirname, '..', '..', '..');
/** Vulnerabilities we accept with their rationale. Each entry
* names a package + the severity we accept for it + the EXACT
* CVE titles we've reviewed + a rationale + a last-reviewed
* date. The gate fires when a NEW CVE title appears for an
* allowlisted package — forces a fresh review when the
* supply-chain landscape shifts under us, even on packages
* we've already accepted.
*
* `lastReviewed` is informational — not enforced as an expiry —
* but reviewers should re-check entries older than ~6 months
* to catch quietly-evolved attack surfaces. CI doesn't fail
* on stale dates; humans should. */
interface AllowlistEntry {
readonly package: string;
readonly maxSeverity: 'low' | 'moderate' | 'high' | 'critical';
readonly acceptedTitles: readonly string[];
readonly rationale: string;
/** ISO date (YYYY-MM-DD) of the last human review of this
* entry. Bump when re-evaluating the rationale, NOT when
* unrelated changes touch the file. */
readonly lastReviewed: string;
}
const ALLOWLIST: readonly AllowlistEntry[] = [
{
package: 'request',
maxSeverity: 'critical',
acceptedTitles: ['Server-Side Request Forgery in Request'],
lastReviewed: '2026-07-06',
rationale:
'Deprecated HTTP library brought in transitively by matrix-bot-sdk. ' +
'Carries CRITICAL SSRF (CVE in request) but matrix-bot only makes outbound ' +
'calls to operator-configured Matrix homeserver URLs — no user-controlled ' +
'URLs flow through this library, so the SSRF surface is bounded to operator ' +
'misconfiguration. Re-reviewed cp426 (2026-07-06): matrix-bot-sdk\'s LATEST ' +
'(0.8.0) STILL depends on request@^2.88.2 + request-promise, so an SDK version ' +
'bump does NOT resolve this — only replacing matrix-bot-sdk with a request-free ' +
'client would (the bot\'s usage is a thin MatrixClient facade, so that is ' +
'feasible future work). Overriding form-data/request to a fixed major would ' +
'break request\'s 2.x multipart API, so no safe transitive override exists.'
},
{
package: 'form-data',
maxSeverity: 'critical',
acceptedTitles: [
'form-data uses unsafe random function in form-data for choosing boundary',
'form-data: CRLF injection in form-data via unescaped multipart field names and filenames'
],
lastReviewed: '2026-07-06',
rationale:
'Transitive of `request` (see above), reached only by matrix-bot-sdk. Two ' +
'advisories: (1) unsafe Math.random() boundary generation matters for cross-origin ' +
'request forgery via predictable boundaries; (2) CRLF injection via unescaped ' +
'multipart field names/filenames matters only when an attacker controls those ' +
'names. matrix-bot makes solely outbound, operator-configured Matrix homeserver ' +
'calls and constructs its own multipart field names, so neither the boundary space ' +
'nor the field names are attacker-controlled. Acceptable until matrix-bot-sdk ' +
'upgrades or is replaced. Reviewed cp270.'
},
{
package: 'tough-cookie',
maxSeverity: 'high',
acceptedTitles: ['tough-cookie Prototype Pollution vulnerability'],
lastReviewed: '2026-07-06',
rationale:
'Transitive of `request` (see above). Prototype-pollution via crafted ' +
'cookie names; matrix-bot only receives cookies from operator-configured ' +
'Matrix homeservers, so attacker-controlled cookies are not in scope.'
},
{
package: 'esbuild',
maxSeverity: 'high',
acceptedTitles: [
'esbuild enables any website to send any requests to the development server and read the response',
'esbuild: Missing binary integrity verification in Deno module enables remote code execution via NPM_CONFIG_REGISTRY',
'esbuild allows arbitrary file read when running the development server on Windows'
],
lastReviewed: '2026-06-12',
rationale:
'Dev/build-only dependency (never shipped to operators). All three advisories ' +
'target esbuild usage patterns Morphit does not employ: (1) the permissive-CORS ' +
'dev server and (3) the Windows dev-server arbitrary-file-read both require ' +
"esbuild's `serve` mode — Morphit uses esbuild only as a one-shot Node bundler " +
'(ops-cli build.mjs) and the SvelteKit/Vite dev server is local-development-only, ' +
'never exposed in production (operators serve prebuilt static assets). (2) the ' +
'Deno-module binary-integrity RCE requires running esbuild under Deno with an ' +
'attacker-controlled NPM_CONFIG_REGISTRY — Morphit builds under Node with the ' +
'default registry and has no Deno path installed. No production surface; the ' +
'lockfile is the tested source of truth (no `npm audit fix`). Reviewed cp252. ' +
'Revisit if esbuild ever becomes a runtime/served dependency or if a non-breaking ' +
'patched esbuild is already in range.'
},
{
package: 'vitest',
maxSeverity: 'critical',
acceptedTitles: [
'When Vitest UI server is listening, arbitrary file can be read and executed'
],
lastReviewed: '2026-06-01',
rationale:
'Dev/test-only dependency (never shipped to operators). The vulnerable ' +
'code path is the Vitest UI server: Morphit invokes vitest only as ' +
'`vitest run` / `vitest` (no `--ui`), has NO `@vitest/ui` dependency, and ' +
'never starts the UI server in CI or locally — so the listening-server ' +
'file-read/exec surface is not installed or reachable here. Reviewed cp184. ' +
'Revisit if a vitest 2.1.x patch ships or if `@vitest/ui` is ever added.'
},
{
package: 'vite',
maxSeverity: 'high',
acceptedTitles: [
'Vite Vulnerable to Path Traversal in Optimized Deps `.map` Handling',
'launch-editor: NTLMv2 hash disclosure via UNC path handling on Windows',
'vite: `server.fs.deny` bypass on Windows alternate paths'
],
lastReviewed: '2026-07-06',
rationale:
'Dev/build-only dependency (never shipped to operators — production serves ' +
'prebuilt static assets via the operator web server, with no Vite dev server ' +
'running). All three advisories require the Vite dev server, which Morphit uses ' +
'only for local development: (1) the optimized-deps `.map` path traversal is ' +
'served by the dev server only; (2) launch-editor (transitive via vite) is the ' +
"dev server's click-to-open-in-editor helper and the NTLMv2/UNC hash disclosure " +
'is Windows-only; (3) the `server.fs.deny` bypass is the dev server file-serving ' +
'guard and is also Windows-only. Morphit is developed on Linux and production ' +
'never starts the dev server, so neither the dev-server surface nor the ' +
'Windows-specific paths are reachable. No production surface; the lockfile is ' +
'the tested source of truth (no `npm audit fix`). Reviewed cp270. Revisit if a ' +
'non-breaking patched vite is already in range or if the dev server is ever ' +
'exposed in production.'
},
{
package: 'brace-expansion',
maxSeverity: 'high',
acceptedTitles: [
'brace-expansion: DoS via exponential-time expansion of consecutive non-expanding {} groups',
'brace-expansion: DoS via unbounded expansion length causing an out-of-memory process crash',
'brace-expansion: DoS via unbounded intermediate arrays, bypassing the CVE-2026-14257 mitigation'
],
lastReviewed: '2026-08-03',
rationale:
'Build/dev-only transitive dependency (reached via minimatch/glob under vite, ' +
'rollup, eslint, tailwind, and the test tooling — never shipped to operators; ' +
'production serves prebuilt static assets). Two advisories, same DoS class: ' +
'GHSA-3jxr-9vmj-r5cp (exponential-time ReDoS on many consecutive non-expanding ' +
'`{}` groups) and GHSA-mh99-v99m-4gvg (unbounded expansion length → OOM crash). ' +
'Both require an attacker to control the GLOB/BRACE PATTERN string fed to ' +
'brace-expansion, but every pattern in our stack is developer-authored at build ' +
'time (file globs in configs, test match patterns) — no runtime path lets ' +
'untrusted user input reach a brace-expansion call, so neither DoS is reachable ' +
'in production or in the served app. No safe transitive override exists across ' +
'every consumer without churning the lockfile, and the lockfile is the tested ' +
'source of truth (no `npm audit fix`). Revisit if a patched brace-expansion ' +
'lands non-breakingly in range for all consumers, or if any runtime code ever ' +
'brace-expands user-supplied input. First reviewed cp509 (2026-07-20); ' +
'GHSA-mh99 added + re-reviewed at the v1.8.15 cut (2026-07-24). Third advisory ' +
'(unbounded intermediate arrays bypassing the earlier CVE-2026-14257 length cap) ' +
're-reviewed at the v1.9.20 cut (2026-08-03): same build-time-only DoS class, ' +
'conclusion unchanged.'
},
{
package: 'postcss',
maxSeverity: 'high',
acceptedTitles: [
'PostCSS: Path Traversal in Previous Source Map Auto-Loading (sourceMappingURL) leads to Arbitrary .map File Disclosure',
'PostCSS: incomplete fix of GHSA-6g55-p6wh-862q — attacker-controlled sourceMappingURL reads arbitrary .map files when `from` is unset'
],
lastReviewed: '2026-08-03',
rationale:
'postcss@8.5.14 is reached two ways, neither of which reaches the vulnerable ' +
'path. (1) Web BUILD tooling — autoprefixer, tailwindcss, eslint-plugin-svelte, ' +
'and postcss directly — runs only at build time over OUR OWN authored CSS; ' +
'production serves prebuilt static assets, so postcss is not in the served app ' +
'at all. (2) matrix-bot → matrix-bot-sdk → sanitize-html uses postcss at runtime ' +
'only to parse inline `style` ATTRIBUTE fragments while sanitizing HTML — it does ' +
'not process full stylesheets and does not enable source-map auto-loading. The ' +
'advisory (GHSA-r28c-9q8g-f849) requires postcss to process CSS carrying a ' +
'`sourceMappingURL` comment WITH previous-map resolution enabled so it reads a ' +
'`.map` file off disk via a traversal path; no consumer here feeds untrusted CSS ' +
'through that option (the build CSS is developer-authored with fixed inputs; ' +
'sanitize-html parses attribute-value snippets without map loading), so the ' +
'arbitrary `.map` disclosure is not reachable. No patched postcss is in range for ' +
'every consumer without churning the lockfile (the tested source of truth — no ' +
'`npm audit fix`). Revisit when a fixed postcss lands non-breakingly across all ' +
'consumers, or if any runtime code processes untrusted CSS with previous-source-map ' +
'loading enabled. Second advisory (the earlier sourceMappingURL fix was incomplete — ' +
'still reads arbitrary .map files when `from` is unset) re-reviewed at the v1.9.20 ' +
'cut (2026-08-03): same map-disclosure class, same two non-reaching consumers, ' +
'conclusion unchanged.'
},
{
package: 'fast-uri',
maxSeverity: 'high',
acceptedTitles: [
'fast-uri vulnerable to host confusion via backslash authority introducer'
],
lastReviewed: '2026-08-03',
rationale:
'Transitive dependency of ajv (a JSON Schema validator), already override- ' +
'pinned to ^3.1.4. ajv uses fast-uri to parse the $id/$ref/$schema URIs that ' +
'appear IN a schema. The advisory (host confusion via a backslash authority ' +
'introducer) matters only when fast-uri parses an ATTACKER-controlled URI. ' +
'Every schema Morphit compiles is developer-authored with fixed, compile-time ' +
'$id/$ref values, and ajv resolves those refs WITHIN the schema document, ' +
'never over the network or against a host derived from user input — so no ' +
'attacker-controlled URI reaches fast-uri and the host-confusion parse has no ' +
'security-relevant effect here. The request DATA being validated never flows ' +
'into fast-uri (it parses schema URIs, not the instance). Already at the ' +
'latest 3.x via the override; the lockfile is the tested source of truth (no ' +
'npm audit fix). Revisit if any runtime code parses untrusted URIs through ' +
'fast-uri or a patched fast-uri lands in range. Reviewed at the v1.9.20 cut ' +
'(2026-08-03).'
},
{
package: 'ip-address',
maxSeverity: 'high',
acceptedTitles: [
'ip-address: Address4 decodes leading-zero octets as decimal while resolvers decode them as octal, allowing SSRF and trust-boundary bypass',
'ip-address: a CIDR suffix on the parsed address suppresses special-use classification and can bypass SSRF and trust-boundary checks',
'ip-address: misclassification of IPv4-mapped/NAT64 IPv6 addresses can bypass SSRF and trust-boundary checks'
],
lastReviewed: '2026-08-03',
rationale:
'Transitive dependency reached ONLY by the read-only MCP server (morphit-mcp ' +
'→ @modelcontextprotocol/sdk → express-rate-limit → ip-address). express- ' +
'rate-limit uses ip-address to normalize the CLIENT IP into a rate-limit ' +
'bucket key. All three advisories are IP-misclassification issues the text ' +
'frames as SSRF and trust-boundary bypass, but that framing does not apply to ' +
'THIS consumer: express-rate-limit makes no outbound request from the parsed ' +
'IP and takes no trust decision from it — it only buckets requests for rate ' +
'limiting. The residual risk is therefore narrower than SSRF: a client ' +
'crafting an ambiguous representation (a leading-zero octet decoded as ' +
'decimal, a CIDR suffix suppressing special-use classification, or an ' +
'IPv4-mapped/NAT64 address) could land in a different rate-limit bucket than ' +
'intended — i.e. potential rate-limit EVASION. The MCP surface is a read-only ' +
'orderbook API (no writes, no funds, no personal data), so evasion there is ' +
'low-impact and further bounded by the operator\'s own reverse proxy. Morphit ' +
'does NOT use ip-address for any SSRF or trust-boundary decision of its own — ' +
'its outbound-request SSRF defense is the DNS-pinned undici Agent in ' +
'federationProbe.ts, which does not involve ip-address. Revisit if a patched ' +
'ip-address lands under express-rate-limit or ip-address is ever used for an ' +
'outbound/trust decision. Reviewed at the v1.9.20 cut (2026-08-03).'
},
{
package: 'undici',
maxSeverity: 'high',
acceptedTitles: [
'undici vulnerable to downstream response desynchronization via retry interceptor',
'undici vulnerable to cross-user information disclosure and parse-time crash via degenerate private cache directives',
'undici vulnerable to CRLF Injection via blob-like body \'type\' property',
'undici vulnerable to cross-user information disclosure via whitespace around equals in Cache-Control directives',
'undici vulnerable to cookie attribute injection via unsanitized domain and unparsed setCookie fields'
],
lastReviewed: '2026-08-03',
rationale:
'Runtime dependency (override-pinned to ^7.28.0), used by the indexer and ' +
'relay for OUTBOUND HTTP only — Blurt RPC, FX/price feeds, and federation ' +
'peer probes via Node\'s built-in fetch. Morphit imports from undici ONLY the ' +
'Agent class (federationProbe.ts), used to build a dispatcher that PINS the ' +
'connect-time DNS lookup to a pre-resolved IP — a TOCTOU/SSRF DEFENSE, not a ' +
'vulnerable feature — plus a custom User-Agent header (userAgent.ts). Each of ' +
'the five advisories requires an undici feature Morphit does not use: (1) ' +
'downstream response desynchronization needs the RETRY interceptor / ' +
'RetryAgent — not used (no RetryAgent or interceptors.retry anywhere in ' +
'source); (2) and (4) cross-user disclosure via degenerate / whitespace ' +
'Cache-Control directives need undici\'s CACHE interceptor — not used; (3) ' +
'CRLF injection via a blob-like body type needs sending a Blob-like request ' +
'body — Morphit sends only JSON/string bodies to RPC and read endpoints, ' +
'never a Blob; (5) cookie attribute injection needs undici\'s cookie handling ' +
'(setCookie/getSetCookies) — these RPC/explorer/peer calls are cookieless, ' +
'sending and reading none. So none of the five vulnerable code paths is ' +
'reachable from Morphit\'s fetch-with-DNS-pinning usage. A newer 7.29.0 ' +
'exists but is not yet confirmed to carry all five fixes, and bumping the ' +
'runtime HTTP client is deferred to a dedicated review rather than folded ' +
'into this release; the lockfile is the tested source of truth (no npm audit ' +
'fix). Revisit if Morphit adopts undici\'s retry, cache, or cookie features ' +
'or Blob request bodies, or when a patched undici covering all five lands in ' +
'range. Reviewed at the v1.9.20 cut (2026-08-03).'
}
];
interface NpmAuditOutput {
readonly vulnerabilities?: Record<
string,
{
readonly name: string;
readonly severity: string;
readonly via?: ReadonlyArray<
string | { readonly title?: string; readonly name?: string }
>;
}
>;
readonly metadata?: {
readonly vulnerabilities?: Record<string, number>;
};
}
const SEVERITY_RANK: Record<string, number> = {
info: 0,
low: 1,
moderate: 2,
high: 3,
critical: 4
};
type ViaEntry = string | { readonly title?: string; readonly name?: string };
/** Extract the set of CVE titles `npm audit` reports for one
* vulnerable package. `via` is heterogeneous: each entry is
* either a string (the name of a downstream package that brings
* the vuln in) or an object with `title` + `name` fields (the
* actual CVE). We want only the latter. */
function cveTitles(via: ReadonlyArray<ViaEntry> | undefined): string[] {
if (!Array.isArray(via)) return [];
const out: string[] = [];
for (const entry of via) {
if (typeof entry === 'object' && entry !== null && typeof entry.title === 'string') {
out.push(entry.title);
}
}
return out;
}
interface AllowDecision {
readonly ok: boolean;
/** Titles present in audit output but not yet on the allowlist
* for this package. Non-empty when the smoke should fail
* the package even though it's named on the allowlist —
* forces a fresh review when supply-chain shifts. */
readonly unknownTitles: readonly string[];
}
function isAllowed(name: string, severity: string, titles: readonly string[]): AllowDecision {
const entry = ALLOWLIST.find((e) => e.package === name);
if (!entry) return { ok: false, unknownTitles: titles };
if (SEVERITY_RANK[severity]! > SEVERITY_RANK[entry.maxSeverity]!) {
return { ok: false, unknownTitles: titles };
}
const accepted = new Set(entry.acceptedTitles);
const unknown = titles.filter((t) => !accepted.has(t));
return { ok: unknown.length === 0, unknownTitles: unknown };
}
// Run npm audit. In offline / restricted networks, this fails
// with a non-zero exit but still emits useful JSON; we tolerate
// the exit code and parse what we get.
//
// DEEP-DEEP NOTE (DD-cp16-1): offline-skip exits 0 so that
// transient network issues don't break unrelated CI runs, but
// the output explicitly reports "0 scenarios actually checked"
// rather than misleadingly counting the skip as a pass. An
// attacker who can block the npm registry would see the smoke
// SKIP — not silently green-light a vulnerable build. CI
// reviewers should treat "0 scenarios actually checked" as a
// gate failure for any commit touching dependencies.
let audit: NpmAuditOutput;
try {
const out = execSync('npm audit --json', {
cwd: REPO,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 32 * 1024 * 1024
});
audit = JSON.parse(out);
} catch (err: unknown) {
const stdout = (err as { stdout?: Buffer | string })?.stdout;
if (!stdout) {
console.log('⚠ npm audit unavailable — registry unreachable from this host.');
console.log('⚠ 0 scenarios actually checked. CI must treat this as a gate failure');
console.log('⚠ when the commit touches package.json or package-lock.json.');
console.log('');
console.log('npm-audit-gate smoke: 0 scenarios actually checked (offline-skip)');
process.exit(0);
}
try {
audit = JSON.parse(stdout.toString());
} catch {
console.log('⚠ npm audit produced unparseable output — gate cannot evaluate.');
console.log('⚠ 0 scenarios actually checked. CI must treat this as a gate failure');
console.log('⚠ when the commit touches package.json or package-lock.json.');
console.log('');
console.log('npm-audit-gate smoke: 0 scenarios actually checked (parse-error)');
process.exit(0);
}
}
// Iterate the vulnerabilities map. Each entry's `name` is the
// package; the `severity` field summarizes the worst issue
// affecting that package.
const vulns = audit.vulnerabilities ?? {};
let failed = 0;
let allowedCount = 0;
let totalConsidered = 0;
const failures: string[] = [];
for (const [pkgName, info] of Object.entries(vulns)) {
const severity = info.severity;
if (!severity || !(severity in SEVERITY_RANK)) continue;
if (SEVERITY_RANK[severity]! < SEVERITY_RANK.high) continue;
totalConsidered++;
const titles = cveTitles(info.via);
const decision = isAllowed(pkgName, severity, titles);
if (decision.ok) {
allowedCount++;
continue;
}
failed++;
if (decision.unknownTitles.length > 0) {
failures.push(
`${severity.toUpperCase()}: ${pkgName} — new CVE title(s) not yet reviewed:\n ${decision.unknownTitles.map((t) => `· ${t}`).join('\n ')}`
);
} else {
failures.push(`${severity.toUpperCase()}: ${pkgName}`);
}
}
// Report.
const meta = audit.metadata?.vulnerabilities ?? {};
console.log(
`npm-audit-gate smoke — ${meta.high ?? 0} HIGH + ${meta.critical ?? 0} CRITICAL (registry totals)`
);
console.log(
` allowlisted: ${allowedCount}; new HIGH/CRITICAL not on allowlist: ${failed}`
);
console.log('');
if (failed === 0) {
if (allowedCount > 0) {
console.log(` Allowlisted (rationale documented in this file):`);
for (const entry of ALLOWLIST) {
console.log(
` · ${entry.package} (≤${entry.maxSeverity}, last reviewed ${entry.lastReviewed})`
);
}
console.log('');
}
console.log(`✓ all ${1 + totalConsidered} npm-audit-gate scenarios pass`);
process.exit(0);
} else {
console.error('Newly-introduced HIGH/CRITICAL vulnerabilities (not on allowlist):');
for (const f of failures) console.error(`${f}`);
console.error('');
console.error('Either upgrade/remove the dependency OR add an entry to ALLOWLIST');
console.error(`in apps/web/scripts/npm-audit-gate-smoke.ts with a real rationale.`);
console.error(`${failed} gate violations`);
process.exit(1);
}