diff --git a/.github/actions/ci-wechat-runtime-audit/audit.sh b/.github/actions/ci-wechat-runtime-audit/audit.sh index db2fe4ad814..783e316184a 100755 --- a/.github/actions/ci-wechat-runtime-audit/audit.sh +++ b/.github/actions/ci-wechat-runtime-audit/audit.sh @@ -138,12 +138,116 @@ for package_spec in "$wechat_spec" "qrcode-terminal@0.12.0" "zod@4.4.3"; do done audit_status=0 +audit_started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" npm --prefix "$runtime_dir" audit \ --userconfig "$trusted_npmrc" \ --registry "$npm_registry" \ --omit=dev \ --audit-level=low \ --json >"$report_dir/npm-audit.json" || audit_status=$? +audit_finished_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# Record scanner/database provenance next to the raw report so a later reader +# can establish exactly which registry endpoint served this audit (#7338). +# Mirrors the *.provenance.json sidecars scripts/audit-reviewed-npm-graph.mts +# writes for the reviewed graphs. Keep the endpoint derivation and GHSA id +# extraction below in sync with deriveAuditEndpoints/extractAdvisoryIds in +# that script; a shared implementation would need a Node module boundary this +# shell action does not have. +REPORT_PATH="$report_dir/npm-audit.json" \ + PROVENANCE_PATH="$report_dir/npm-audit.provenance.json" \ + CONFIGURED_REGISTRY="$npm_registry" \ + PACKAGE_SPEC="$wechat_spec" \ + STARTED_AT="$audit_started_at" \ + FINISHED_AT="$audit_finished_at" \ + AUDIT_STATUS="$audit_status" \ + NPM_VERSION="$(npm --version)" \ + node <<'NODE' +const fs = require("node:fs"); +let report = {}; +let failure; +try { + report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, "utf8")); +} catch { + // A transport failure can leave a non-JSON report; the audit status check + // below still fails the run, and the sidecar records the attempt. + failure = "npm audit did not produce a parseable JSON report"; +} +if (typeof report !== "object" || report === null || Array.isArray(report)) { + // JSON.parse also accepts null, arrays, and bare strings, which real npm + // never emits; normalize so the checks below cannot crash and the sidecar + // still records the attempt as failed. + report = {}; + if (failure === undefined) { + failure = "npm audit did not produce a JSON object report"; + } +} +// npm's dominant failure mode writes PARSEABLE error JSON (`{"error": ...}`) +// and exits nonzero; mirror parseAuditReport in +// scripts/audit-reviewed-npm-graph.mts so such a run is never mistaken for a +// clean scan: report.error, exit status above 1, or a nonzero exit with zero +// findings all mark the attempt as failed. +const auditStatus = Number(process.env.AUDIT_STATUS); +const severities = ["info", "low", "moderate", "high", "critical"]; +const severityCounts = report?.metadata?.vulnerabilities; +const hasCompleteSeverityCounts = + severityCounts && + typeof severityCounts === "object" && + !Array.isArray(severityCounts) && + severities.every((severity) => { + const count = severityCounts[severity]; + return typeof count === "number" && Number.isSafeInteger(count) && count >= 0; + }); +const findingCount = hasCompleteSeverityCounts + ? severities.reduce((total, severity) => total + severityCounts[severity], 0) + : 0; +if (failure === undefined && report.error !== undefined) { + failure = `npm audit returned an error report: ${JSON.stringify(report.error)}`; +} else if (failure === undefined && !hasCompleteSeverityCounts) { + failure = "npm audit did not produce a complete vulnerability finding report"; +} else if ( + failure === undefined && + (!Number.isSafeInteger(auditStatus) || + auditStatus > 1 || + (auditStatus !== 0 && findingCount === 0)) +) { + failure = `npm audit exited ${process.env.AUDIT_STATUS} without vulnerability findings`; +} +const advisoryIds = new Set(); +const findings = report && typeof report.vulnerabilities === "object" ? report.vulnerabilities : {}; +for (const finding of Object.values(findings ?? {})) { + const via = Array.isArray(finding?.via) ? finding.via : []; + for (const cause of via) { + const url = typeof cause === "object" && cause !== null ? cause.url : undefined; + if (typeof url !== "string") continue; + for (const match of url.match(/GHSA(?:-[23456789cfghjmpqrvwx]{4}){3}/gi) ?? []) { + advisoryIds.add(`GHSA${match.slice(4).toLowerCase()}`); + } + } +} +const registryBase = process.env.CONFIGURED_REGISTRY.replace(/\/+$/, ""); +const provenance = { + schemaVersion: 1, + scanner: { + name: "npm audit", + npmVersion: process.env.NPM_VERSION, + nodeVersion: process.version, + }, + registry: { + configuredRegistry: process.env.CONFIGURED_REGISTRY, + bulkAdvisoryEndpoint: `${registryBase}/-/npm/v1/security/advisories/bulk`, + note: "npm audit posts the dependency graph to the bulk advisory endpoint of the configured registry; on request failure npm reports no advisory data.", + }, + run: { startedAt: process.env.STARTED_AT, finishedAt: process.env.FINISHED_AT }, + graph: { label: "WeChat locked runtime graph", packageSpecs: [process.env.PACKAGE_SPEC] }, + // rawReportPath is relative to the directory containing the sidecar. + rawReportPath: "npm-audit.json", + advisoryIds: [...advisoryIds].sort(), + ...(failure === undefined ? {} : { failure }), +}; +fs.writeFileSync(process.env.PROVENANCE_PATH, `${JSON.stringify(provenance, null, 2)}\n`); +if (failure !== undefined) process.exitCode = 1; +NODE signature_status=0 npm --prefix "$runtime_dir" audit signatures \ diff --git a/docs/security/advisory-early-warning.md b/docs/security/advisory-early-warning.md new file mode 100644 index 00000000000..22cb2ff3779 --- /dev/null +++ b/docs/security/advisory-early-warning.md @@ -0,0 +1,101 @@ + + + +# Advisory Early Warning and Audit Provenance + +Status: correlation module, scan CLI, and audit provenance implemented. +Scheduled operation and the response policy are a separate follow-up, gated on +product/security-owner sign-off recorded on issue #7338 (evidence from #7276). + +Public upstream GitHub Security Advisories are often published weeks before the +global reviewed ecosystem record that `npm audit` enforces. For +`fast-uri` (GHSA-4c8g-83qw-93j6) the upstream repository advisory appeared on +June 29 while the reviewed record propagated on July 21, so the same vulnerable +version audited clean at 18:46 UTC and reported High at 20:09 UTC. This page +documents the early-warning correlation that narrows that gap and the +provenance every audit now records so such timelines are provable from retained +artifacts. + +The correlation draws on all three types of the global advisory database, which +contribute differently: + +- reviewed records are the corpus `npm audit` enforces — a match here means + package-level enforcement is imminent or already active, and the signal + confirms the reviewed gate will catch it; +- unreviewed records are NVD-sourced and often appear before curation reaches + the reviewed feed — they usually lack a verified npm mapping, so they flow + through the ambiguous, informational-only path and provide the earlier + heads-up; +- malware records name npm packages published as malware — a match against the + reviewed inventory correlates like any other record and is equally + non-blocking. + +Polling upstream *repository* advisories directly (the earliest public signal, +e.g. `fastify/fast-uri`'s own advisory) needs a package-to-repository map and +is the planned extension; the correlation module already accepts that record +shape unchanged. + +## How the early-warning correlation works + +- `scripts/lib/advisory-early-warning.mts` correlates GitHub Security Advisory + JSON (repository-level and global records share the shape) with the reviewed + npm inventory and emits structured signals: + `{advisoryId, package, vulnerableRange, matchedVersions, source, confidence, action}`. +- The inventory is derived from `ci/reviewed-npm-audit.json`: every committed + archive package spec plus the installed packages of each locked graph's + `package-lock.json`. +- Confidence is encoded, never guessed: only an exact npm ecosystem + + package-name + parseable semver-range match yields `confidence: "exact"` and + `action: "investigate"`. Name collisions from non-npm (CPE-derived) records + and unparseable ranges yield `confidence: "ambiguous"` and + `action: "informational"`. Ambiguous matches never block or mutate a release. +- The reviewed npm audit gate (`scripts/audit-reviewed-npm-graph.mts`, enforced + in CI) remains enabled and authoritative for exact npm package/version-range + decisions. The early-warning path only triggers investigation and rescanning. + +`scripts/advisory-early-warning-scan.mts` is the CLI over the module. +It reads only local files and exits 0 whether or not signals are found. +It does not modify input files or external state. +With `--output`, it writes the requested local signals file: + +```sh +# List inventory package names (one per line), the input for advisory queries. +node --experimental-strip-types scripts/advisory-early-warning-scan.mts \ + --list-packages + +# Correlate fetched advisory records with the inventory. +node --experimental-strip-types scripts/advisory-early-warning-scan.mts \ + --advisories advisories.json --output signals.json +``` + +Advisory records come from the GitHub `/advisories` API — all three types, +paginated, filtered by `affects=` batches of the inventory package names. + +Running this correlation on a schedule and routing signals to an alert +destination is deliberately not wired up yet: #7338 requires product/security +owners to define the supported historical-image scope, rescan ownership, alert +destination, and response expectations first. A follow-up adds the scheduled +workflow once that sign-off is recorded on the issue. + +## Provenance recorded per audit + +Each reviewed npm audit report now has a `*.provenance.json` sidecar +(`coverage/reviewed-npm-audit/` artifacts, and `npm-audit.provenance.json` for +the WeChat locked runtime graph audit) recording: + +- scanner identity: `npm audit`, npm version, Node.js version; +- the configured registry, with URL credentials removed, plus the derived bulk + advisory endpoint npm posts the dependency graph to (npm >= 7 has no + quick-audit fallback: on request failure npm reports no advisory data, and + the note records this); +- run start and finish timestamps (ISO 8601); +- the audited graph label and committed package specs; +- the raw machine-readable report path (`rawReportPath`, by convention + relative to the directory containing the sidecar); +- the GHSA advisory ids extracted from the report; and +- a `failure` marker when the audit attempt itself failed, so the sidecar + still records the attempt. + +Comparing the `advisoryIds` of consecutive retained runs identifies the last +comparable non-detection and the first detection of a newly surfaced advisory, +even when an unrelated finding failed the earlier run. diff --git a/scripts/advisory-early-warning-scan.mts b/scripts/advisory-early-warning-scan.mts new file mode 100755 index 00000000000..b91b9bfebc1 --- /dev/null +++ b/scripts/advisory-early-warning-scan.mts @@ -0,0 +1,106 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// CLI entry for the advisory early-warning path (#7338). Correlates public +// GitHub Security Advisory JSON with the reviewed npm inventory derived from +// ci/reviewed-npm-audit.json (committed package specs plus the locked-graph +// package-locks) and prints structured, NON-blocking signals. Signals never +// fail the process: enforcement stays with the reviewed npm audit gate. +// +// Usage: +// advisory-early-warning-scan.mts --list-packages +// advisory-early-warning-scan.mts --advisories [--output ] + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + type AdvisorySignal, + correlateAdvisories, + type InventoryEntry, + parseInventoryFromAuditConfig, + parseInventoryFromPackageLock, +} from "./lib/advisory-early-warning.mts"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const CONFIG_RELATIVE_PATH = path.join("ci", "reviewed-npm-audit.json"); + +function loadReviewedInventory(): InventoryEntry[] { + const config = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, CONFIG_RELATIVE_PATH), "utf-8"), + ) as Record; + const inventory = parseInventoryFromAuditConfig(config, CONFIG_RELATIVE_PATH); + const lockedGraphs = Array.isArray(config.lockedGraphs) ? config.lockedGraphs : []; + for (const graph of lockedGraphs) { + const directory = (graph as Record | null)?.directory; + if (typeof directory !== "string" || directory.length === 0) continue; + const lockRelativePath = path.join(directory, "package-lock.json"); + const lockPath = path.join(REPO_ROOT, lockRelativePath); + if (!fs.existsSync(lockPath)) continue; + const lock = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as unknown; + inventory.push(...parseInventoryFromPackageLock(lock, lockRelativePath)); + } + return inventory; +} + +function loadAdvisories(advisoriesPath: string): unknown[] { + const parsed = JSON.parse(fs.readFileSync(advisoriesPath, "utf-8")) as unknown; + return Array.isArray(parsed) ? parsed : [parsed]; +} + +function describeSignal(signal: AdvisorySignal): string { + return `${signal.advisoryId} ${signal.package} ${signal.vulnerableRange || "(no range)"} -> ${signal.action} (${signal.confidence}, matched ${signal.matchedVersions.join(", ")})`; +} + +function readFlagValue(argv: readonly string[], flag: string): string | undefined { + const index = argv.indexOf(flag); + if (index < 0) return undefined; + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +function main(argv: readonly string[]): void { + const inventory = loadReviewedInventory(); + if (argv.includes("--list-packages")) { + const names = [...new Set(inventory.map((entry) => entry.name))].sort(); + for (const name of names) console.log(name); + return; + } + const advisoriesPath = readFlagValue(argv, "--advisories"); + if (!advisoriesPath) { + throw new Error( + "usage: advisory-early-warning-scan.mts --list-packages | --advisories [--output ]", + ); + } + const advisories = loadAdvisories(advisoriesPath); + const signals = correlateAdvisories(advisories, inventory); + const outputPath = readFlagValue(argv, "--output"); + if (outputPath) { + fs.writeFileSync(outputPath, `${JSON.stringify(signals, null, 2)}\n`); + } + console.log( + `advisory early warning: ${advisories.length} advisories, ${inventory.length} inventory entries, ${signals.length} signals`, + ); + for (const signal of signals) console.log(describeSignal(signal)); + // Signals are intentionally non-blocking: the process exits 0 either way, + // and the caller routes signals to a tracking issue for investigation. +} + +function isMainModule(): boolean { + return process.argv[1] + ? import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href + : false; +} + +if (isMainModule()) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index a0ac895f877..3d4bd80e329 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -153,6 +153,7 @@ function main(): void { ); fs.rmSync(artifactDirectory, { recursive: true, force: true }); fs.mkdirSync(artifactDirectory, { recursive: true }); + const npmVersion = run("npm", ["--version"], REPO_ROOT).stdout.trim(); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-reviewed-npm-audit-")); try { const reports = [ @@ -162,6 +163,12 @@ function main(): void { directory: materializeArchiveGraph(config.archivePackages, tempRoot), exceptionFile, graph: config.archiveGraphId, + provenance: { + label: "reviewed archive graph", + nodeVersion: process.version, + npmVersion, + packageSpecs: config.archivePackages.map((reviewed) => reviewed.packageSpec), + }, reportFile: path.join(artifactDirectory, "reviewed-archive-graph.json"), resultFile: path.join(artifactDirectory, "reviewed-archive-graph-policy.json"), threshold: config.severityThreshold, @@ -174,6 +181,12 @@ function main(): void { directory: materializeLockedGraph(graph, tempRoot), exceptionFile, graph: graph.id, + provenance: { + label: graph.label, + nodeVersion: process.version, + npmVersion, + packageSpecs: [graph.packageSpec], + }, reportFile: path.join(artifactDirectory, `locked-graph-${index + 1}.json`), resultFile: path.join(artifactDirectory, `locked-graph-${index + 1}-policy.json`), threshold: config.severityThreshold, diff --git a/scripts/lib/advisory-early-warning.mts b/scripts/lib/advisory-early-warning.mts new file mode 100644 index 00000000000..bd27064c246 --- /dev/null +++ b/scripts/lib/advisory-early-warning.mts @@ -0,0 +1,325 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Early-warning correlation between public upstream GitHub Security Advisories +// and the reviewed npm package inventory (#7338). Upstream repository advisories +// are often published weeks before the global reviewed ecosystem record that +// `npm audit` enforces, so this module turns the earlier signal into a traceable, +// NON-blocking investigation prompt. It never replaces the reviewed npm audit +// gate: only exact npm package-name plus semver-range matches are marked +// "investigate", and ambiguous CPE-to-npm matches stay "informational". + +export type AdvisoryConfidence = "exact" | "ambiguous"; +export type AdvisoryAction = "investigate" | "informational"; + +export type AdvisorySignal = Readonly<{ + advisoryId: string; + package: string; + vulnerableRange: string; + matchedVersions: readonly string[]; + source: "upstream-ghsa"; + confidence: AdvisoryConfidence; + action: AdvisoryAction; +}>; + +export type InventoryEntry = Readonly<{ + name: string; + version: string; + origin: string; +}>; + +export type ParsedAdvisoryVulnerability = Readonly<{ + ecosystem: string; + packageName: string; + vulnerableRange: string; +}>; + +export type ParsedAdvisory = Readonly<{ + advisoryId: string; + vulnerabilities: readonly ParsedAdvisoryVulnerability[]; +}>; + +const GHSA_ID_PATTERN = /^GHSA(?:-[23456789cfghjmpqrvwx]{4}){3}$/i; +const RELEASE_VERSION_PATTERN = + /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/; +const RANGE_COMPARATOR_PATTERN = /^(<=|>=|<|>|=)?\s*(\S+)$/; + +type ParsedVersion = Readonly<{ + release: readonly [number, number, number]; + prerelease: readonly string[]; +}>; + +function parseVersion(version: string): ParsedVersion | null { + const match = RELEASE_VERSION_PATTERN.exec(version.trim()); + if (!match) return null; + const release = [Number(match[1]), Number(match[2]), Number(match[3])] as const; + if (release.some((part) => !Number.isSafeInteger(part))) return null; + return { release, prerelease: match[4] ? match[4].split(".") : [] }; +} + +function comparePrereleaseIdentifiers(left: string, right: string): number { + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + if (leftNumeric && rightNumeric) return Math.sign(Number(left) - Number(right)); + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return left < right ? -1 : left > right ? 1 : 0; +} + +function compareParsedVersions(left: ParsedVersion, right: ParsedVersion): number { + for (let index = 0; index < 3; index += 1) { + const difference = Math.sign(left.release[index] - right.release[index]); + if (difference !== 0) return difference; + } + if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0; + if (left.prerelease.length === 0) return 1; + if (right.prerelease.length === 0) return -1; + const shared = Math.min(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < shared; index += 1) { + const difference = comparePrereleaseIdentifiers( + left.prerelease[index], + right.prerelease[index], + ); + if (difference !== 0) return difference; + } + return Math.sign(left.prerelease.length - right.prerelease.length); +} + +/** Compare two versions; null when either is not an exact semver version. */ +export function compareSemver(left: string, right: string): number | null { + const parsedLeft = parseVersion(left); + const parsedRight = parseVersion(right); + if (!parsedLeft || !parsedRight) return null; + return compareParsedVersions(parsedLeft, parsedRight); +} + +/** + * Evaluate the comma-separated comparator subset GitHub Security Advisories use + * for `vulnerable_version_range` (e.g. ">= 3.0.0, < 3.1.3"). Comparators are + * AND-ed: any parseable comparator that evaluates false proves the version is + * outside the range even when a sibling comparator is unparseable. Returns null + * only when the version does not parse or no parseable comparator can decide; + * callers must treat null as ambiguous, never as a confirmed match. + */ +export function satisfiesVulnerableRange(version: string, range: string): boolean | null { + const parsedVersion = parseVersion(version); + if (!parsedVersion) return null; + const comparators = range + .split(",") + .map((comparator) => comparator.trim()) + .filter((comparator) => comparator.length > 0); + if (comparators.length === 0) return null; + let anyUnparseable = false; + for (const comparator of comparators) { + const match = RANGE_COMPARATOR_PATTERN.exec(comparator); + const bound = match ? parseVersion(match[2]) : null; + if (!match || !bound) { + anyUnparseable = true; + continue; + } + const difference = compareParsedVersions(parsedVersion, bound); + const operator = match[1] ?? "="; + const comparatorSatisfied = + (operator === "<" && difference < 0) || + (operator === "<=" && difference <= 0) || + (operator === ">" && difference > 0) || + (operator === ">=" && difference >= 0) || + (operator === "=" && difference === 0); + if (!comparatorSatisfied) return false; + } + return anyUnparseable ? null : true; +} + +/** + * Extract the correlation-relevant fields from one GitHub Security Advisory + * object (repository-level `/repos/{owner}/{repo}/security-advisories` and + * global `/advisories` records share this shape). Malformed input yields null + * instead of throwing so one bad upstream record cannot break a scan. + */ +export function parseAdvisory(input: unknown): ParsedAdvisory | null { + if (typeof input !== "object" || input === null || Array.isArray(input)) return null; + const record = input as Record; + const advisoryId = record.ghsa_id; + if (typeof advisoryId !== "string" || !GHSA_ID_PATTERN.test(advisoryId)) return null; + const rawVulnerabilities = Array.isArray(record.vulnerabilities) ? record.vulnerabilities : []; + const vulnerabilities: ParsedAdvisoryVulnerability[] = []; + for (const entry of rawVulnerabilities) { + if (typeof entry !== "object" || entry === null) continue; + const vulnerability = entry as Record; + const affected = vulnerability.package; + if (typeof affected !== "object" || affected === null) continue; + const packageName = (affected as Record).name; + if (typeof packageName !== "string" || packageName.length === 0) continue; + const ecosystem = (affected as Record).ecosystem; + const vulnerableRange = vulnerability.vulnerable_version_range; + vulnerabilities.push({ + ecosystem: typeof ecosystem === "string" ? ecosystem : "", + packageName, + vulnerableRange: typeof vulnerableRange === "string" ? vulnerableRange : "", + }); + } + return { advisoryId, vulnerabilities }; +} + +/** + * Build the reviewed package inventory from ci/reviewed-npm-audit.json: + * every committed archive package and locked graph package spec. + */ +export function parseInventoryFromAuditConfig(config: unknown, origin: string): InventoryEntry[] { + if (typeof config !== "object" || config === null) return []; + const record = config as Record; + const inventory: InventoryEntry[] = []; + for (const key of ["archivePackages", "lockedGraphs"]) { + const entries = record[key]; + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (typeof entry !== "object" || entry === null) continue; + const packageSpec = (entry as Record).packageSpec; + if (typeof packageSpec !== "string") continue; + const separator = packageSpec.lastIndexOf("@"); + if (separator <= 0) continue; + const name = packageSpec.slice(0, separator); + const version = packageSpec.slice(separator + 1); + if (!parseVersion(version)) continue; + inventory.push({ name, version, origin }); + } + } + return inventory; +} + +/** + * Build an installed-package inventory from a lockfile-version-3 package-lock + * subset: every `node_modules/...` entry that records an installed version. + */ +export function parseInventoryFromPackageLock(lock: unknown, origin: string): InventoryEntry[] { + if (typeof lock !== "object" || lock === null) return []; + const packages = (lock as Record).packages; + if (typeof packages !== "object" || packages === null || Array.isArray(packages)) return []; + const inventory: InventoryEntry[] = []; + for (const [location, entry] of Object.entries(packages)) { + const marker = location.lastIndexOf("node_modules/"); + if (marker < 0) continue; + const pathName = location.slice(marker + "node_modules/".length); + if (pathName.length === 0) continue; + if (typeof entry !== "object" || entry === null) continue; + const version = (entry as Record).version; + if (typeof version !== "string" || version.length === 0) continue; + // Aliased installs (`npm install alias@npm:real-name`) live under the alias + // path but record the real package name in `name`; advisories name the + // real package, so prefer it when present. + const recordedName = (entry as Record).name; + const name = + typeof recordedName === "string" && recordedName.length > 0 ? recordedName : pathName; + inventory.push({ name, version, origin }); + } + return inventory; +} + +type SignalEvidence = { + ranges: string[]; + versions: Set; +}; + +type SignalDraft = { + advisoryId: string; + package: string; + exact: SignalEvidence; + ambiguous: SignalEvidence; +}; + +function addEvidence(evidence: SignalEvidence, range: string, versions: Iterable): void { + if (!evidence.ranges.includes(range)) evidence.ranges.push(range); + for (const version of versions) evidence.versions.add(version); +} + +/** + * Correlate upstream advisories with the reviewed inventory. + * + * - exact npm ecosystem + package-name + parseable-range matches emit + * confidence "exact" / action "investigate"; + * - name collisions from non-npm ecosystems (CPE-style records) and + * unparseable ranges emit confidence "ambiguous" / action "informational"; + * - packages absent from the inventory, versions proven outside the range, + * and malformed advisory objects emit nothing. + * + * Exact and ambiguous evidence for the same advisory and package are tracked + * separately: an exact signal carries only the proving range(s) and verified + * matched versions, and ambiguous evidence never upgrades into it. + * + * No output of this function may block or mutate a release; the reviewed + * npm audit gate remains the authoritative package-level enforcement source. + */ +export function correlateAdvisories( + advisories: readonly unknown[], + inventory: readonly InventoryEntry[], +): AdvisorySignal[] { + const versionsByName = new Map>(); + for (const entry of inventory) { + const versions = versionsByName.get(entry.name) ?? new Set(); + versions.add(entry.version); + versionsByName.set(entry.name, versions); + } + const drafts = new Map(); + for (const input of advisories) { + const advisory = parseAdvisory(input); + if (!advisory) continue; + for (const vulnerability of advisory.vulnerabilities) { + const versions = versionsByName.get(vulnerability.packageName); + if (!versions || versions.size === 0) continue; + let confidence: AdvisoryConfidence | null = null; + const matchedVersions = new Set(); + if (vulnerability.ecosystem.toLowerCase() === "npm") { + const unverifiable = new Set(); + for (const version of versions) { + const satisfied = satisfiesVulnerableRange(version, vulnerability.vulnerableRange); + if (satisfied === true) matchedVersions.add(version); + if (satisfied === null) unverifiable.add(version); + } + if (matchedVersions.size > 0) { + confidence = "exact"; + } else if (unverifiable.size > 0) { + confidence = "ambiguous"; + for (const version of unverifiable) matchedVersions.add(version); + } + } else { + // A non-npm (for example CPE-derived) record naming an npm package is + // never a verified npm mapping; surface it for awareness only. + confidence = "ambiguous"; + for (const version of versions) matchedVersions.add(version); + } + if (confidence === null) continue; + const key = `${advisory.advisoryId} ${vulnerability.packageName}`; + const draft = drafts.get(key) ?? { + advisoryId: advisory.advisoryId, + package: vulnerability.packageName, + exact: { ranges: [], versions: new Set() }, + ambiguous: { ranges: [], versions: new Set() }, + }; + addEvidence( + confidence === "exact" ? draft.exact : draft.ambiguous, + vulnerability.vulnerableRange, + matchedVersions, + ); + drafts.set(key, draft); + } + } + return [...drafts.values()] + .map((draft) => { + const confidence: AdvisoryConfidence = draft.exact.versions.size > 0 ? "exact" : "ambiguous"; + const evidence = confidence === "exact" ? draft.exact : draft.ambiguous; + return { + advisoryId: draft.advisoryId, + package: draft.package, + vulnerableRange: evidence.ranges.join("; "), + matchedVersions: [...evidence.versions].sort(), + source: "upstream-ghsa" as const, + confidence, + action: confidence === "exact" ? ("investigate" as const) : ("informational" as const), + }; + }) + .sort((left, right) => + left.advisoryId === right.advisoryId + ? left.package.localeCompare(right.package) + : left.advisoryId.localeCompare(right.advisoryId), + ); +} diff --git a/scripts/lib/reviewed-npm-audit.mts b/scripts/lib/reviewed-npm-audit.mts index 5d308109139..f980d34ae80 100755 --- a/scripts/lib/reviewed-npm-audit.mts +++ b/scripts/lib/reviewed-npm-audit.mts @@ -48,6 +48,30 @@ export type AuditPolicyResult = Readonly<{ unacceptedBlockingAdvisories: readonly DirectFinding[]; }>; +export type AuditEndpoints = Readonly<{ + configuredRegistry: string | null; + bulkAdvisoryEndpoint: string | null; + note: string; +}>; + +export type AuditProvenance = Readonly<{ + schemaVersion: 1; + scanner: Readonly<{ name: "npm audit"; npmVersion: string; nodeVersion: string }>; + registry: AuditEndpoints; + run: Readonly<{ startedAt: string; finishedAt: string }>; + graph: Readonly<{ label: string; packageSpecs: readonly string[] }>; + rawReportPath: string; + advisoryIds: readonly string[]; + failure?: string; +}>; + +export type AuditProvenanceContext = Readonly<{ + label: string; + nodeVersion: string; + npmVersion: string; + packageSpecs: readonly string[]; +}>; + const EXCEPTION_KEYS = new Set([ "advisory", "compensatingControls", @@ -66,6 +90,7 @@ const ADVISORY_ID = /^(?:GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|CVE-\d{4}-\d+| const GRAPH_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/u; const MAX_EXCEPTION_LIFETIME_DAYS = 30; +const GHSA_ID_IN_URL = /GHSA(?:-[23456789cfghjmpqrvwx]{4}){3}/gi; function asRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -270,6 +295,101 @@ export function exceedsAuditThreshold( ); } +export function deriveAuditEndpoints(configuredRegistry: string): AuditEndpoints { + const candidate = configuredRegistry.trim(); + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return { + configuredRegistry: null, + bulkAdvisoryEndpoint: null, + note: "the configured registry could not be safely recorded for this run, so the audit endpoint is unknown.", + }; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return { + configuredRegistry: null, + bulkAdvisoryEndpoint: null, + note: "the configured registry could not be safely recorded for this run, so the audit endpoint is unknown.", + }; + } + parsed.username = ""; + parsed.password = ""; + const safeRegistry = parsed.toString(); + const base = safeRegistry.replace(/\/+$/, ""); + return { + configuredRegistry: safeRegistry, + bulkAdvisoryEndpoint: `${base}/-/npm/v1/security/advisories/bulk`, + note: "npm audit posts the dependency graph to the bulk advisory endpoint of the configured registry; on request failure npm reports no advisory data.", + }; +} + +export function extractAdvisoryIds(report: Record): readonly string[] { + const ids = new Set(); + const vulnerabilities = report.vulnerabilities; + const findings = + typeof vulnerabilities === "object" && + vulnerabilities !== null && + !Array.isArray(vulnerabilities) + ? Object.values(vulnerabilities) + : []; + for (const finding of findings) { + const via = (finding as Record | null)?.via; + if (!Array.isArray(via)) continue; + for (const cause of via) { + if (typeof cause !== "object" || cause === null) continue; + const url = (cause as Record).url; + if (typeof url !== "string") continue; + for (const match of url.match(GHSA_ID_IN_URL) ?? []) { + ids.add(`GHSA${match.slice(4).toLowerCase()}`); + } + } + } + return [...ids].sort(); +} + +export function buildAuditProvenance( + input: Readonly<{ + failure?: string; + finishedAt: string; + label: string; + nodeVersion: string; + npmVersion: string; + packageSpecs: readonly string[]; + rawReportPath: string; + registry: string; + report: Record; + startedAt: string; + }>, +): AuditProvenance { + return { + schemaVersion: 1, + scanner: { name: "npm audit", npmVersion: input.npmVersion, nodeVersion: input.nodeVersion }, + registry: deriveAuditEndpoints(input.registry), + run: { startedAt: input.startedAt, finishedAt: input.finishedAt }, + graph: { label: input.label, packageSpecs: input.packageSpecs }, + rawReportPath: input.rawReportPath, + advisoryIds: extractAdvisoryIds(input.report), + ...(input.failure === undefined ? {} : { failure: input.failure }), + }; +} + +export function provenanceSidecarPath(reportPath: string): string { + return `${reportPath.replace(/\.json$/, "")}.provenance.json`; +} + +function configuredNpmRegistry(directory: string): string { + const result = spawnSync("npm", ["config", "get", "registry"], { + cwd: directory, + encoding: "utf-8", + env: { ...process.env, NPM_CONFIG_UPDATE_NOTIFIER: "false" }, + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + return result.error || result.status !== 0 ? "" : result.stdout.trim(); +} + function advisoryId(value: Readonly>): string { const url = nonEmptyString(value.url, "npm audit advisory URL"); const match = url.match(/\/advisories\/([^/]+)$/u); @@ -445,13 +565,18 @@ export function runReviewedNpmAudit( directory: string; exceptionFile: string; graph: string; + provenance?: AuditProvenanceContext; reportFile?: string; resultFile?: string; threshold: Severity; throwOnBlock?: boolean; }>, ): AuditPolicyResult { + if (options.provenance && !options.reportFile) { + throw new Error("reviewed npm audit provenance requires a report file"); + } const exceptionRegistry = readAuditExceptionRegistry(options.exceptionFile); + const startedAt = new Date().toISOString(); const result = spawnSync("npm", ["audit", "--omit=dev", "--json"], { cwd: options.directory, encoding: "utf-8", @@ -459,9 +584,35 @@ export function runReviewedNpmAudit( maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }); + const finishedAt = new Date().toISOString(); if (result.error) throw result.error; if (options.reportFile) fs.writeFileSync(options.reportFile, result.stdout); - const report = parseAuditReport(result); + let report: Record = {}; + let auditFailure: Error | undefined; + try { + report = parseAuditReport(result); + } catch (error) { + auditFailure = error instanceof Error ? error : new Error(String(error)); + } + if (options.provenance && options.reportFile) { + const provenance = buildAuditProvenance({ + failure: auditFailure?.message, + finishedAt, + label: options.provenance.label, + nodeVersion: options.provenance.nodeVersion, + npmVersion: options.provenance.npmVersion, + packageSpecs: options.provenance.packageSpecs, + rawReportPath: path.basename(options.reportFile), + registry: configuredNpmRegistry(options.directory), + report, + startedAt, + }); + fs.writeFileSync( + provenanceSidecarPath(options.reportFile), + `${JSON.stringify(provenance, null, 2)}\n`, + ); + } + if (auditFailure) throw auditFailure; const policyResult = evaluateAuditPolicy({ directory: options.directory, exceptionPolicy: exceptionRegistry.policy, diff --git a/test/advisory-early-warning.test.ts b/test/advisory-early-warning.test.ts new file mode 100644 index 00000000000..2bd74b0b9ae --- /dev/null +++ b/test/advisory-early-warning.test.ts @@ -0,0 +1,332 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + compareSemver, + correlateAdvisories, + parseAdvisory, + parseInventoryFromAuditConfig, + parseInventoryFromPackageLock, + satisfiesVulnerableRange, +} from "../scripts/lib/advisory-early-warning.mts"; + +// Upstream repository security advisory (GET /repos/{owner}/{repo}/security-advisories) +// modeled on GHSA-4c8g-83qw-93j6: published upstream on June 29, weeks before its +// global reviewed ecosystem record appeared on July 21 and npm audit began +// reporting it (#7338). This is the "earlier upstream signal" fixture. +const upstreamFastUriAdvisory = { + ghsa_id: "GHSA-4c8g-83qw-93j6", + cve_id: "CVE-2026-13676", + url: "https://api.github.com/repos/fastify/fast-uri/security-advisories/GHSA-4c8g-83qw-93j6", + html_url: "https://github.com/fastify/fast-uri/security/advisories/GHSA-4c8g-83qw-93j6", + summary: "fast-uri URI parsing divergence enables authority spoofing", + severity: "high", + state: "published", + published_at: "2026-06-29T15:02:11Z", + updated_at: "2026-06-29T15:02:11Z", + identifiers: [ + { type: "GHSA", value: "GHSA-4c8g-83qw-93j6" }, + { type: "CVE", value: "CVE-2026-13676" }, + ], + vulnerabilities: [ + { + package: { ecosystem: "npm", name: "fast-uri" }, + vulnerable_version_range: ">= 3.0.0, < 3.1.3", + patched_versions: "3.1.3", + vulnerable_functions: [], + }, + ], +} as const; + +// NVD/CPE-derived record whose product name collides with an npm package but whose +// ecosystem mapping is not a verified npm mapping (GHSA-45rx-2jwx-cxfr shape). +const cpeDerivedAdvisory = { + ghsa_id: "GHSA-45rx-2jwx-cxfr", + cve_id: "CVE-2026-59892", + summary: "Jaeger propagator baggage injection", + severity: "high", + published_at: "2026-07-03T09:00:00Z", + identifiers: [{ type: "CVE", value: "CVE-2026-59892" }], + vulnerabilities: [ + { + package: { ecosystem: "other", name: "fast-uri" }, + vulnerable_version_range: "cpe:2.3:a:fastify:fast-uri:*:*", + }, + ], +} as const; + +const inventory = [ + { + name: "fast-uri", + version: "3.1.2", + origin: "agents/openclaw/mcporter-runtime/package-lock.json", + }, + { name: "tar", version: "7.5.20", origin: "agents/openclaw/mcporter-runtime/package-lock.json" }, + { name: "openclaw", version: "2026.6.10", origin: "ci/reviewed-npm-audit.json" }, +] as const; + +describe("advisory early warning correlation", () => { + it("correlates an upstream advisory with an affected inventory entry", () => { + const signals = correlateAdvisories([upstreamFastUriAdvisory], inventory); + expect(signals).toEqual([ + { + advisoryId: "GHSA-4c8g-83qw-93j6", + package: "fast-uri", + vulnerableRange: ">= 3.0.0, < 3.1.3", + matchedVersions: ["3.1.2"], + source: "upstream-ghsa", + confidence: "exact", + action: "investigate", + }, + ]); + }); + + it("keeps ambiguous CPE-to-npm matches informational instead of blocking", () => { + const signals = correlateAdvisories([cpeDerivedAdvisory], inventory); + expect(signals).toEqual([ + { + advisoryId: "GHSA-45rx-2jwx-cxfr", + package: "fast-uri", + vulnerableRange: "cpe:2.3:a:fastify:fast-uri:*:*", + matchedVersions: ["3.1.2"], + source: "upstream-ghsa", + confidence: "ambiguous", + action: "informational", + }, + ]); + }); + + it("treats an unparseable npm range as ambiguous rather than blocking", () => { + const advisory = { + ghsa_id: "GHSA-23hp-3jrh-7fpw", + vulnerabilities: [ + { + package: { ecosystem: "npm", name: "tar" }, + vulnerable_version_range: "all versions before the July rewrite", + }, + ], + }; + const signals = correlateAdvisories([advisory], inventory); + expect(signals).toEqual([ + { + advisoryId: "GHSA-23hp-3jrh-7fpw", + package: "tar", + vulnerableRange: "all versions before the July rewrite", + matchedVersions: ["7.5.20"], + source: "upstream-ghsa", + confidence: "ambiguous", + action: "informational", + }, + ]); + }); + + it("emits nothing when the advisory package is absent from the inventory", () => { + const advisory = { + ghsa_id: "GHSA-8988-4f7v-96qf", + vulnerabilities: [ + { + package: { ecosystem: "npm", name: "@opentelemetry/core" }, + vulnerable_version_range: "< 1.30.0", + }, + ], + }; + expect(correlateAdvisories([advisory], inventory)).toEqual([]); + }); + + it("emits nothing when the inventory version is outside the vulnerable range", () => { + const advisory = { + ghsa_id: "GHSA-23hp-3jrh-7fpw", + vulnerabilities: [ + { + package: { ecosystem: "npm", name: "tar" }, + vulnerable_version_range: "< 7.5.16", + }, + ], + }; + expect(correlateAdvisories([advisory], inventory)).toEqual([]); + }); + + it("merges duplicate matches for one advisory and package into a single signal", () => { + const duplicatedInventory = [ + ...inventory, + { name: "fast-uri", version: "3.0.1", origin: "ci/reviewed-npm-audit.json" }, + { name: "fast-uri", version: "3.1.2", origin: "another-lock.json" }, + ]; + const signals = correlateAdvisories([upstreamFastUriAdvisory], duplicatedInventory); + expect(signals).toHaveLength(1); + expect(signals[0]?.matchedVersions).toEqual(["3.0.1", "3.1.2"]); + }); + + const garbageRangeVulnerability = { + package: { ecosystem: "npm", name: "tar" }, + vulnerable_version_range: "all versions before the July rewrite", + }; + const exactRangeVulnerability = { + package: { ecosystem: "npm", name: "tar" }, + vulnerable_version_range: "< 9.0.0", + }; + + it.each([ + ["garbage range first", [garbageRangeVulnerability, exactRangeVulnerability]], + ["exact range first", [exactRangeVulnerability, garbageRangeVulnerability]], + ])("keeps an exact signal free of ambiguous evidence for the same package (%s)", (_label, vulnerabilities) => { + const advisory = { ghsa_id: "GHSA-23hp-3jrh-7fpw", vulnerabilities }; + expect(correlateAdvisories([advisory], inventory)).toEqual([ + { + advisoryId: "GHSA-23hp-3jrh-7fpw", + package: "tar", + vulnerableRange: "< 9.0.0", + matchedVersions: ["7.5.20"], + source: "upstream-ghsa", + confidence: "exact", + action: "investigate", + }, + ]); + }); + + it.each([ + ["null", null], + ["a number", 42], + ["an empty object", {}], + ["a missing ghsa id", { vulnerabilities: [] }], + ["a non-array vulnerabilities field", { ghsa_id: "GHSA-4c8g-83qw-93j6", vulnerabilities: 7 }], + [ + "a vulnerability entry without a package", + { ghsa_id: "GHSA-4c8g-83qw-93j6", vulnerabilities: [{ vulnerable_version_range: "< 1" }] }, + ], + ])("does not throw on malformed advisory input: %s", (_label, advisory) => { + expect(() => correlateAdvisories([advisory], inventory)).not.toThrow(); + expect(correlateAdvisories([advisory], inventory)).toEqual([]); + }); + + it("parses a well-formed advisory into its correlation-relevant fields", () => { + expect(parseAdvisory(upstreamFastUriAdvisory)).toEqual({ + advisoryId: "GHSA-4c8g-83qw-93j6", + vulnerabilities: [ + { + ecosystem: "npm", + packageName: "fast-uri", + vulnerableRange: ">= 3.0.0, < 3.1.3", + }, + ], + }); + }); + + it("rejects advisories whose GHSA id does not look like a GHSA id", () => { + expect(parseAdvisory({ ghsa_id: "not-an-id", vulnerabilities: [] })).toBeNull(); + }); +}); + +describe("advisory early warning inventory parsing", () => { + it("parses package specs from the reviewed npm audit config", () => { + const config = { + archivePackages: [ + { packageSpec: "openclaw@2026.6.10" }, + { packageSpec: "@openclaw/slack@2026.6.10" }, + ], + lockedGraphs: [{ packageSpec: "mcporter@0.7.3" }], + }; + expect(parseInventoryFromAuditConfig(config, "ci/reviewed-npm-audit.json")).toEqual([ + { name: "openclaw", version: "2026.6.10", origin: "ci/reviewed-npm-audit.json" }, + { name: "@openclaw/slack", version: "2026.6.10", origin: "ci/reviewed-npm-audit.json" }, + { name: "mcporter", version: "0.7.3", origin: "ci/reviewed-npm-audit.json" }, + ]); + }); + + it("skips malformed audit config entries without throwing", () => { + const config = { + archivePackages: [{ packageSpec: "no-version" }, { packageSpec: 12 }, null], + lockedGraphs: "nope", + }; + expect(parseInventoryFromAuditConfig(config, "ci/reviewed-npm-audit.json")).toEqual([]); + expect(parseInventoryFromAuditConfig(null, "ci/reviewed-npm-audit.json")).toEqual([]); + }); + + it("parses installed packages from a package-lock subset", () => { + const lock = { + lockfileVersion: 3, + packages: { + "": { name: "root" }, + "node_modules/fast-uri": { version: "3.1.2" }, + "node_modules/@scope/pkg": { version: "1.0.0" }, + "node_modules/outer/node_modules/tar": { version: "7.5.20" }, + "node_modules/linked": { link: true }, + }, + }; + expect(parseInventoryFromPackageLock(lock, "fixture-lock.json")).toEqual([ + { name: "fast-uri", version: "3.1.2", origin: "fixture-lock.json" }, + { name: "@scope/pkg", version: "1.0.0", origin: "fixture-lock.json" }, + { name: "tar", version: "7.5.20", origin: "fixture-lock.json" }, + ]); + }); + + it("inventories aliased lock entries under their real package name", () => { + const lock = { + lockfileVersion: 3, + packages: { + "node_modules/my-alias": { name: "fast-uri", version: "3.1.2" }, + }, + }; + expect(parseInventoryFromPackageLock(lock, "fixture-lock.json")).toEqual([ + { name: "fast-uri", version: "3.1.2", origin: "fixture-lock.json" }, + ]); + }); + + it("returns an empty inventory for malformed package-lock input", () => { + expect(parseInventoryFromPackageLock(null, "fixture-lock.json")).toEqual([]); + expect(parseInventoryFromPackageLock({ packages: [] }, "fixture-lock.json")).toEqual([]); + }); +}); + +describe("advisory early warning semver subset", () => { + it.each([ + ["3.1.2", ">= 3.0.0, < 3.1.3", true], + ["3.1.3", ">= 3.0.0, < 3.1.3", false], + ["2.9.9", ">= 3.0.0, < 3.1.3", false], + ["7.5.11", "<= 7.5.15", true], + ["7.5.15", "<= 7.5.15", true], + ["3.0.0", ">= 3.0.0", true], + ["2.9.9", ">= 3.0.0", false], + ["3.0.1", "> 3.0.0", true], + ["3.0.0", "> 3.0.0", false], + ["1.2.3", "1.2.3", true], + ["1.2.3", "= 1.2.4", false], + ["3.1.3-rc.1", "< 3.1.3", true], + // A provably-false parseable comparator decides the AND even when another + // comparator is unparseable. + ["2.0.0", ">= 3.0.0, < 3.1.x", false], + ])("satisfiesVulnerableRange(%s, %s) -> %s", (version, range, expected) => { + expect(satisfiesVulnerableRange(version, range)).toBe(expected); + }); + + it.each([ + ["not-a-version", "< 1.0.0"], + ["1.0.0", "cpe:2.3:a:fastify:fast-uri:*:*"], + ["1.0.0", ""], + ["1.0.0", "^1.0.0 || >= 2"], + ["3.0.5", ">= 3.0.0, < 3.1.x"], + ])("reports unparseable input as null for (%s, %s)", (version, range) => { + expect(satisfiesVulnerableRange(version, range)).toBeNull(); + }); + + it.each([ + ["1.2.3", "1.2.3", 0], + ["1.2.3", "1.2.4", -1], + ["1.10.0", "1.9.0", 1], + ["1.0.0-alpha", "1.0.0", -1], + ["1.0.0-alpha.2", "1.0.0-alpha.10", -1], + ["1.0.0-beta", "1.0.0-alpha", 1], + ["v1.2.3", "1.2.3", 0], + ["1.2.3+build.5", "1.2.3", 0], + ])("compareSemver(%s, %s) -> %i", (left, right, expected) => { + expect(Math.sign(compareSemver(left, right) ?? Number.NaN)).toBe(expected); + }); + + it.each([ + ["1.2", "1.2.3"], + ["1.2.3", "1.2"], + ])("compareSemver(%s, %s) is null for non-semver input", (left, right) => { + expect(compareSemver(left, right)).toBeNull(); + }); +}); diff --git a/test/reviewed-npm-audit.test.ts b/test/reviewed-npm-audit.test.ts index 9a6a9fc9e8b..08380110d85 100644 --- a/test/reviewed-npm-audit.test.ts +++ b/test/reviewed-npm-audit.test.ts @@ -8,11 +8,16 @@ import { describe, expect, it } from "vitest"; import { type AuditExceptionRegistry, assertExceptionGraphs, + buildAuditProvenance, + deriveAuditEndpoints, evaluateAuditPolicy, exceedsAuditThreshold, + extractAdvisoryIds, parseAuditExceptionRegistry, parseAuditReport, + provenanceSidecarPath, readAuditExceptionRegistry, + runReviewedNpmAudit, vulnerabilityCounts, } from "../scripts/lib/reviewed-npm-audit.mts"; @@ -265,3 +270,186 @@ describe("reviewed npm audit gate", () => { ); }); }); + +describe("reviewed npm audit provenance", () => { + const detectionReport = { + metadata: { + vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0 }, + }, + vulnerabilities: { + "fast-uri": { + via: [ + { + source: 1104001, + name: "fast-uri", + url: "https://github.com/advisories/GHSA-4c8g-83qw-93j6", + }, + "ajv", + ], + }, + ajv: { via: ["fast-uri"] }, + tar: { + via: [ + { url: "https://github.com/advisories/GHSA-23hp-3jrh-7fpw" }, + { url: "https://github.com/advisories/GHSA-4c8g-83qw-93j6" }, + ], + }, + }, + }; + + it("extracts sorted unique GHSA ids from a report", () => { + expect(extractAdvisoryIds(detectionReport)).toEqual([ + "GHSA-23hp-3jrh-7fpw", + "GHSA-4c8g-83qw-93j6", + ]); + }); + + it.each([ + ["a clean report", { metadata: { vulnerabilities: {} } }], + ["a report without vulnerabilities", {}], + ["string-only via chains", { vulnerabilities: { ajv: { via: ["fast-uri"] } } }], + ["a malformed vulnerabilities value", { vulnerabilities: [1, 2] }], + ])("extracts no advisory ids from %s", (_label, report) => { + expect(extractAdvisoryIds(report as Record)).toEqual([]); + }); + + it.each([ + "https://registry.npmjs.org/", + "https://registry.npmjs.org", + ])("derives the bulk advisory endpoint npm audit uses from %s", (registry) => { + const endpoints = deriveAuditEndpoints(registry); + expect(endpoints).toEqual({ + configuredRegistry: "https://registry.npmjs.org/", + bulkAdvisoryEndpoint: "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", + note: expect.stringMatching(/bulk advisory endpoint.*no advisory data/s), + }); + }); + + it("redacts registry URL credentials from retained provenance", () => { + expect(deriveAuditEndpoints("https://audit-user:audit-token@registry.npmjs.org/")).toEqual({ + configuredRegistry: "https://registry.npmjs.org/", + bulkAdvisoryEndpoint: "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", + note: expect.stringMatching(/bulk advisory endpoint.*no advisory data/s), + }); + }); + + it("places the provenance sidecar next to its raw report", () => { + expect(provenanceSidecarPath("/tmp/artifacts/reviewed-archive-graph.json")).toBe( + "/tmp/artifacts/reviewed-archive-graph.provenance.json", + ); + }); + + it("builds a complete provenance record for one audited graph", () => { + const provenance = buildAuditProvenance({ + finishedAt: "2026-07-21T20:09:41.000Z", + label: "reviewed archive graph", + nodeVersion: "v22.22.2", + npmVersion: "10.9.7", + packageSpecs: ["openclaw@2026.6.10", "@openclaw/slack@2026.6.10"], + rawReportPath: "reviewed-archive-graph.json", + registry: "https://registry.npmjs.org/", + report: detectionReport, + startedAt: "2026-07-21T20:09:12.000Z", + }); + expect(provenance).toEqual({ + schemaVersion: 1, + scanner: { name: "npm audit", npmVersion: "10.9.7", nodeVersion: "v22.22.2" }, + registry: deriveAuditEndpoints("https://registry.npmjs.org/"), + run: { startedAt: "2026-07-21T20:09:12.000Z", finishedAt: "2026-07-21T20:09:41.000Z" }, + graph: { + label: "reviewed archive graph", + packageSpecs: ["openclaw@2026.6.10", "@openclaw/slack@2026.6.10"], + }, + rawReportPath: "reviewed-archive-graph.json", + advisoryIds: ["GHSA-23hp-3jrh-7fpw", "GHSA-4c8g-83qw-93j6"], + }); + expect(provenance).not.toHaveProperty("failure"); + }); + + it("records a failure marker so a failed audit attempt still leaves provenance", () => { + const provenance = buildAuditProvenance({ + failure: "npm audit failed without vulnerability findings: ECONNREFUSED", + finishedAt: "2026-07-21T20:09:41.000Z", + label: "reviewed archive graph", + nodeVersion: "v22.22.2", + npmVersion: "10.9.7", + packageSpecs: ["openclaw@2026.6.10"], + rawReportPath: "reviewed-archive-graph.json", + registry: "https://registry.npmjs.org/", + report: {}, + startedAt: "2026-07-21T20:09:12.000Z", + }); + expect(provenance.failure).toBe( + "npm audit failed without vulnerability findings: ECONNREFUSED", + ); + expect(provenance.advisoryIds).toEqual([]); + }); + + it.each([ + "", + " ", + ])("records an unknown registry explicitly instead of deriving a nonsense endpoint (%j)", (registry) => { + expect(deriveAuditEndpoints(registry)).toEqual({ + configuredRegistry: null, + bulkAdvisoryEndpoint: null, + note: expect.stringMatching(/registry could not be safely recorded/), + }); + }); + + it("writes the failure sidecar before rethrowing when npm audit hard-fails", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-provenance-")); + const originalPath = process.env.PATH; + try { + const fakeBin = path.join(tempRoot, "bin"); + const exceptionFile = path.join(tempRoot, "exceptions.json"); + fs.mkdirSync(fakeBin); + fs.writeFileSync(exceptionFile, `${JSON.stringify({ schemaVersion: 1, exceptions: [] })}\n`); + // Fake npm: `npm audit` emits npm's parseable transport-error JSON and + // exits 1; every other subcommand (registry introspection) fails hard. + fs.writeFileSync( + path.join(fakeBin, "npm"), + [ + "#!/bin/sh", + 'test "$1" = "audit" && {', + ' echo \'{"error":{"code":"ECONNREFUSED","summary":"registry unreachable"}}\'', + " exit 1", + "}", + "exit 7", + "", + ].join("\n"), + { mode: 0o755 }, + ); + process.env.PATH = `${fakeBin}${path.delimiter}${originalPath}`; + const reportPath = path.join(tempRoot, "graph.json"); + expect(() => + runReviewedNpmAudit({ + directory: tempRoot, + exceptionFile, + graph: "fixture-graph", + provenance: { + label: "fixture graph", + nodeVersion: "v22.22.2", + npmVersion: "10.9.7", + packageSpecs: ["fixture@1.0.0"], + }, + reportFile: reportPath, + threshold: "high", + }), + ).toThrow(/ECONNREFUSED/); + const sidecar = JSON.parse( + fs.readFileSync(path.join(tempRoot, "graph.provenance.json"), "utf-8"), + ) as Record; + expect(sidecar.failure).toMatch(/ECONNREFUSED/); + expect(sidecar.advisoryIds).toEqual([]); + expect(sidecar.rawReportPath).toBe("graph.json"); + expect(sidecar.registry).toEqual({ + configuredRegistry: null, + bulkAdvisoryEndpoint: null, + note: expect.stringMatching(/registry could not be safely recorded/), + }); + } finally { + process.env.PATH = originalPath; + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/test/wechat-runtime-audit-workflow.test.ts b/test/wechat-runtime-audit-workflow.test.ts index 837856ab01c..0db36cb7ee8 100644 --- a/test/wechat-runtime-audit-workflow.test.ts +++ b/test/wechat-runtime-audit-workflow.test.ts @@ -52,7 +52,7 @@ function runAuditValidation( try { mutate({ targetRoot, runtimeDir }); - return spawnSync("bash", [auditScript], { + const result = spawnSync("bash", [auditScript], { cwd: targetRoot, encoding: "utf8", env: { @@ -62,11 +62,55 @@ function runAuditValidation( PATH: `${path.join(targetRoot, "bin")}${path.delimiter}${process.env.PATH ?? ""}`, }, }); + const provenancePath = path.join( + targetRoot, + "artifacts", + "wechat-runtime-audit", + "npm-audit.provenance.json", + ); + return { + ...result, + provenance: fs.existsSync(provenancePath) + ? (JSON.parse(fs.readFileSync(provenancePath, "utf8")) as Record) + : undefined, + }; } finally { fs.rmSync(targetRoot, { force: true, recursive: true }); } } +function installFakeAuditNpm( + targetRoot: string, + auditOutput: Record | string, + auditStatus: number, +): void { + const binDir = path.join(targetRoot, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + const npm = path.join(binDir, "npm"); + fs.writeFileSync( + npm, + [ + "#!/usr/bin/env node", + `const auditReport = ${JSON.stringify( + typeof auditOutput === "string" ? auditOutput : JSON.stringify(auditOutput), + )};`, + `const auditStatus = ${auditStatus};`, + "const args = process.argv.slice(2);", + 'if (args[0] === "--version") {', + ' console.log("10.9.4");', + " process.exit(0);", + "}", + 'if (args.includes("audit")) {', + " console.log(auditReport);", + " process.exit(auditStatus);", + "}", + "process.exit(0);", + "", + ].join("\n"), + { mode: 0o755 }, + ); +} + function requiredStep(job: WorkflowJob, name: string): WorkflowStep { const step = job.steps?.find((candidate) => candidate.name === name); expect(step, `Missing workflow step: ${name}`).toBeDefined(); @@ -210,6 +254,37 @@ describe("WeChat runtime audit and install-cache gates (#5896)", () => { expect(result.stderr).not.toContain("npm-should-not-run"); }); + it.each([ + ["malformed npm output", "{not-json", 1, /parseable JSON report/], + [ + "parseable npm error JSON", + { error: { code: "ECONNREFUSED", summary: "registry unreachable" } }, + 1, + /ECONNREFUSED/, + ], + ["missing vulnerability metadata", {}, 0, /complete vulnerability finding report/], + [ + "an incomplete severity matrix", + { + metadata: { + vulnerabilities: { info: 0, low: 0, moderate: 0, high: 0 }, + }, + }, + 0, + /complete vulnerability finding report/, + ], + ])("records provenance and fails closed for %s", (_label, auditReport, auditStatus, expectedFailure) => { + const result = runAuditValidation(({ targetRoot }) => { + installFakeAuditNpm(targetRoot, auditReport, auditStatus); + }); + + expect(result.status).not.toBe(0); + expect(result.provenance).toMatchObject({ + failure: expect.stringMatching(expectedFailure), + rawReportPath: "npm-audit.json", + }); + }); + it("keeps the image cache trusted and deletes the sandbox-writable copy", () => { const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); for (const fragment of [