Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -67,6 +67,7 @@ in-process verifier). The Deno semantic runner pins its imports in
| `shacl-runner.mjs` | `node …/shacl-runner.mjs <shapes.ttl> <htmlDir>` | **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 <corpus.json> [--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 <renderer.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. |
Expand Down
101 changes: 101 additions & 0 deletions gates/vuln-gate.mjs
Original file line number Diff line number Diff line change
@@ -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); });
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Loading