diff --git a/README.md b/README.md index 246b796..ead9bbb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ hardcodes `robertdelanghe.dev`, `bounded.tools`, an account, or an email. ``` integrity/ verify-site · verify (sigstore) · gen-sitemanifest · gen-provenance · structure-audit · http-probe -gates/ sbom (gen + completeness) · shacl-runner · seo-gate · axe-gate (axe-core a11y) · readability-gate · commonmark-runner · semantic (lone) +gates/ sbom (gen + completeness) · shacl-runner · seo-gate · axe-gate (axe-core a11y) · vuln-gate (npm audit) · readability-gate · commonmark-runner · semantic (lone) gates/conformance/ conformance-report — lone's conformance() projection (Node port of jsr:@bounded-systems/lone@0.4) + a generic HTML renderer generators/ gen-cid (IPFS UnixFS) · gen-identity (did:web + VC) · openapi (static-API helper core) emitters/ reprDigest (RFC 9530) · securityTxt (RFC 9116) · webManifest · markdown-sibling headers @@ -67,6 +67,7 @@ in-process verifier). The Deno semantic runner pins its imports in | `shacl-runner.mjs` | `node …/shacl-runner.mjs ` | **The SHACL shapes file stays in the site** (its structured-data contract) + the built-HTML dir. Optional `$SHACL_CONTEXT` (custom offline JSON-LD context; default schema.org). Fails unless every JSON-LD block `conforms: true`. | | `seo-gate.mjs` | `node …/seo-gate.mjs [distDir]` | `$DIST`. Optional `$SEO_ERROR_PAGE`, `$SEO_DEPLOY_SIDECARS`. Enforces canonical/title/description uniqueness + self-consistency, robots.txt (RFC 9309), sitemap, internal links. | | `axe-gate.mjs` | `node …/axe-gate.mjs [distDir]` | `$DIST`. Optional `$AXE_PAGES` (comma list, default: every `*.html` in dist), `$AXE_TAGS` (default `wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa`), `$AXE_IMPACT_THRESHOLD` (`minor`/`moderate`/`serious`/`critical`, default `serious`), `$AXE_RUNNER` (`playwright` (CI, needs `playwright` + `@axe-core/playwright` + `npx playwright install chromium`) \| `tezcatl` (macOS WebKit, local)), `$AXE_REPORT` (write the JSON report). Serves dist over an ephemeral origin (so assets resolve), runs **axe-core** per page, and **fails closed** on any violation at/above the threshold. The emitted report's `axe: { serious, critical }` envelope is exactly what `conformance-report`'s `a11y.axe-serious-critical` criterion consumes — a clean run is what lets a site honestly assert it. | +| `vuln-gate.mjs` | `node …/vuln-gate.mjs [projectDir]` | `$VULN_ROOT` (lockfile lives here, default `.`). Optional `$VULN_OMIT_DEV` (`true`→production deps only, default `true`), `$VULN_THRESHOLD` (highest tolerated known critical/high, default `0`), `$VULN_REPORT` (write the JSON report). Runs **`npm audit`** and **fails closed** when the known critical/high count exceeds the threshold. The report's `vulns: { knownCriticalOrHighVulns }` envelope is what `conformance-report`'s `security.no-critical-vulns` criterion consumes. | | `readability-gate.mjs` | `node …/readability-gate.mjs [--strict]` | **The corpus is an input** the site assembles from its copy: a JSON array of `{id,text}` or an `{id:text}` map. Optional `$READABILITY_THRESHOLDS`, `$READABILITY_MIN_WORDS`, `$READABILITY_KNOWN_ACRONYMS`. WARN-only unless `--strict`. | | `commonmark-runner.mjs` | `node …/commonmark-runner.mjs [fixtures.json]` | **The site's markdown renderer module** (export `renderMarkdown`, or set `$COMMONMARK_RENDER_EXPORT`). Default fixtures pin a safe CommonMark subset + 4 hostile-HTML escapes; a site with a different renderer supplies its own `fixtures.json`. | | `semantic/gate.ts` | `deno run --allow-read --allow-net …/gate.ts` | Built HTML in `$SEMANTIC_DIR` (default `dist/blog`); `$SEMANTIC_SELECTOR` (subject node, default `article`). Imports `jsr:@bounded-systems/lone`; any error-severity finding fails CI. | diff --git a/gates/vuln-gate.mjs b/gates/vuln-gate.mjs new file mode 100644 index 0000000..5ba9c7e --- /dev/null +++ b/gates/vuln-gate.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +// known-vulnerability gate — turns "npm audit looked fine once" into a +// CONTINUOUSLY-ENFORCED member of the conformance contract. It runs `npm audit` +// over a project's lockfile and FAILS CLOSED (exit 1) when the count of known +// critical/high advisories exceeds a configurable threshold (default 0). The +// machine-readable result is exactly the shape lone's conformance() model consumes +// for `security.no-critical-vulns` (`{ knownCriticalOrHighVulns }`), so a clean run +// is what lets a site honestly assert that criterion — and a new advisory turns CI red. +// +// node gates/vuln-gate.mjs [projectDir] # build gate (exit 1 when over threshold) +// +// Everything is config-driven; NOTHING about any one project is hard-coded: +// argv[2] / $VULN_ROOT project dir containing the lockfile (default: ".") +// $VULN_OMIT_DEV "true" → audit production deps only (default: "true") +// A static site SHIPS no runtime deps, so production scope +// == the deployed bytes; the build toolchain's own +// advisories are a separate concern. Set "false" to audit all. +// $VULN_THRESHOLD highest tolerated known critical/high (default: 0) +// $VULN_REPORT path to write the JSON report (default: none) +// +// The pure parse/evaluation functions are exported for unit testing without a network. +import { writeFile, access } from "node:fs/promises"; +import { resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +// ── Pure core (network-free; unit-testable) ────────────────────────────────── + +/** Extract the known critical + high count from an `npm audit --json` payload. + * Tolerates both the v2 `metadata.vulnerabilities` shape and a missing field. */ +export function parseAudit(json) { + const v = (json && json.metadata && json.metadata.vulnerabilities) || {}; + const critical = v.critical || 0; + const high = v.high || 0; + return { critical, high, known: critical + high }; +} + +/** Evaluate a parsed audit against the threshold. Pure: (parsed, threshold) → report. */ +export function evaluateVulns({ critical, high, known }, threshold = 0) { + return { + passed: known <= threshold, + threshold, + critical, + high, + knownCriticalOrHighVulns: known, + // The envelope lone's conformance() consumes for `security.no-critical-vulns`. + vulns: { knownCriticalOrHighVulns: known }, + }; +} + +// ── Impure runner ──────────────────────────────────────────────────────────── + +/** Run `npm audit --json` and return the parsed payload. npm exits non-zero when + * advisories exist, so we capture stdout regardless of exit code. */ +export function runNpmAudit({ root = ".", omitDev = true } = {}) { + const args = ["audit", "--json", ...(omitDev ? ["--omit=dev"] : [])]; + const res = spawnSync("npm", args, { cwd: resolve(root), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (res.error) throw new Error(`cannot run npm audit (${res.error.message})`); + if (!res.stdout) throw new Error(`npm audit produced no JSON (stderr: ${(res.stderr || "").slice(0, 300)})`); + return JSON.parse(res.stdout); +} + +/** Audit + evaluate → report. Exposed for programmatic use and the kit's own test. */ +export function runVulnGate({ root = ".", omitDev = true, threshold = 0 } = {}) { + return evaluateVulns(parseAudit(runNpmAudit({ root, omitDev })), threshold); +} + +// ── CLI ────────────────────────────────────────────────────────────────────── + +async function main() { + const root = resolve(process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : process.env.VULN_ROOT || "."); + const exists = async (p) => { try { await access(p); return true; } catch { return false; } }; + if (!(await exists(resolve(root, "package-lock.json"))) && !(await exists(resolve(root, "npm-shrinkwrap.json")))) { + console.error(`✗ vuln-gate: no package-lock.json under ${root} — nothing to audit.`); + process.exit(2); + } + const omitDev = (process.env.VULN_OMIT_DEV ?? "true").trim() !== "false"; + const threshold = Number.parseInt(process.env.VULN_THRESHOLD ?? "0", 10); + if (!Number.isInteger(threshold) || threshold < 0) { + console.error(`✗ vuln-gate: $VULN_THRESHOLD must be an integer ≥ 0 (got "${process.env.VULN_THRESHOLD}")`); + process.exit(2); + } + + const report = runVulnGate({ root, omitDev, threshold }); + if (process.env.VULN_REPORT) { + await writeFile(resolve(process.env.VULN_REPORT), JSON.stringify(report, null, 2) + "\n"); + } + + const scope = omitDev ? "production deps" : "all deps"; + const line = `vuln-gate: ${report.knownCriticalOrHighVulns} known critical/high in ${scope} (${report.critical} critical, ${report.high} high) · threshold ${threshold}`; + if (!report.passed) { + console.error(`✗ ${line}`); + console.error(` a known critical/high advisory exceeds the threshold — fix it, or (if accepted) raise $VULN_THRESHOLD.`); + process.exit(1); + } + console.log(`✓ ${line}`); +} + +// Only run the CLI when invoked directly (not when imported by a test). +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((e) => { console.error("✗ vuln-gate: error —", e.stack || e.message); process.exit(1); }); +} diff --git a/package.json b/package.json index bf12cdc..ce1900f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "ck-shacl-runner": "./gates/shacl-runner.mjs", "ck-seo-gate": "./gates/seo-gate.mjs", "ck-axe-gate": "./gates/axe-gate.mjs", + "ck-vuln-gate": "./gates/vuln-gate.mjs", "ck-readability-gate": "./gates/readability-gate.mjs", "ck-commonmark-runner": "./gates/commonmark-runner.mjs", "ck-gen-cid": "./generators/gen-cid.mjs", diff --git a/test/run.mjs b/test/run.mjs index ec563cb..21ecbaa 100755 --- a/test/run.mjs +++ b/test/run.mjs @@ -320,6 +320,37 @@ await test("gates/axe-gate: classify + threshold + report, e2e on fixtures", asy } }); +// 14. vuln-gate: pure parse/evaluate logic, then a best-effort e2e via real npm audit. +await test("gates/vuln-gate: parse + evaluate, e2e via npm audit", async () => { + const { parseAudit, evaluateVulns, runVulnGate } = await import(join(KIT, "gates", "vuln-gate.mjs")); + + // (a) pure parse over npm-audit-shaped payloads. + const clean = parseAudit({ metadata: { vulnerabilities: { info: 0, low: 1, moderate: 2, high: 0, critical: 0 } } }); + if (clean.known !== 0 || clean.high !== 0 || clean.critical !== 0) throw new Error("clean audit must total 0 critical/high"); + const dirty = parseAudit({ metadata: { vulnerabilities: { high: 2, critical: 1 } } }); + if (dirty.known !== 3 || dirty.critical !== 1 || dirty.high !== 2) throw new Error(`expected 3 known (1c/2h), got ${dirty.known}`); + if (parseAudit({}).known !== 0) throw new Error("missing metadata must parse to 0"); + + // (b) pure threshold evaluation + the lone evidence envelope. + const cleanEval = evaluateVulns(clean, 0); + if (!cleanEval.passed || cleanEval.vulns.knownCriticalOrHighVulns !== 0) throw new Error("clean must pass with vulns {0}"); + const bad = evaluateVulns(dirty, 0); + if (bad.passed || bad.vulns.knownCriticalOrHighVulns !== 3) throw new Error("3 known at threshold 0 must fail"); + if (!evaluateVulns(dirty, 5).passed) throw new Error("3 known at threshold 5 must pass"); + + // (c) best-effort e2e: real npm audit over the kit's own lockfile. Offline/registry + // failures are a tolerated skip (the pure logic above is the always-on assertion). + try { + const rep = runVulnGate({ root: KIT, omitDev: true, threshold: 0 }); + if (typeof rep.vulns.knownCriticalOrHighVulns !== "number") throw new Error("e2e report missing the vulns envelope"); + ok("gates/vuln-gate: parse + evaluate, e2e via npm audit", + `pure logic asserted · e2e: ${rep.knownCriticalOrHighVulns} known critical/high in prod deps`); + } catch (e) { + if (/must (pass|fail|total)|envelope|expected/.test(e.message)) throw e; + ok("gates/vuln-gate: parse + evaluate, e2e via npm audit", `pure logic asserted · e2e SKIPPED (${e.message.split("\n")[0]})`); + } +}); + await rm(work, { recursive: true, force: true }); console.log(`\n${failed ? "✗" : "✓"} conformance-kit tests: ${passed} passed, ${failed} failed`); process.exit(failed ? 1 : 0);