@bounded-systems/conformance-kit 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/emitters/index.mjs +68 -0
- package/gates/axe-gate.mjs +325 -0
- package/gates/baseline-gate.mjs +112 -0
- package/gates/commonmark-runner.mjs +98 -0
- package/gates/conformance/conformance.mjs +389 -0
- package/gates/conformance/web-build.mjs +568 -0
- package/gates/conformance-report.mjs +128 -0
- package/gates/html-validator-gate.mjs +120 -0
- package/gates/readability-gate.mjs +134 -0
- package/gates/sbom/check-sbom.mjs +112 -0
- package/gates/sbom/gen-sbom.mjs +167 -0
- package/gates/semantic/deno.json +7 -0
- package/gates/semantic/gate.ts +34 -0
- package/gates/seo-gate.mjs +208 -0
- package/gates/shacl-runner.mjs +160 -0
- package/gates/vuln-gate.mjs +101 -0
- package/generators/gen-cid.mjs +144 -0
- package/generators/gen-identity.mjs +120 -0
- package/generators/gen-snapshots.mjs +108 -0
- package/generators/openapi.mjs +61 -0
- package/integrity/gen-provenance.mjs +137 -0
- package/integrity/gen-sitemanifest.mjs +66 -0
- package/integrity/http-probe.mjs +131 -0
- package/integrity/structure-audit/audit.mjs +159 -0
- package/integrity/structure-audit/package.json +12 -0
- package/integrity/verify/README.md +40 -0
- package/integrity/verify/verify.mjs +107 -0
- package/integrity/verify-site.mjs +160 -0
- package/lib/config.mjs +36 -0
- package/lib/schema-validate.mjs +68 -0
- package/package.json +71 -0
- package/provenance.json +41 -0
- package/vendor.example.json +25 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// gates/conformance-report.mjs
|
|
2
|
+
//
|
|
3
|
+
// The generic conformance-projection helper. Two site-agnostic pieces:
|
|
4
|
+
//
|
|
5
|
+
// buildConformanceReport({ loneFindings, evidence })
|
|
6
|
+
// Gather the standard evidence — lone's DOM findings + an external-evidence
|
|
7
|
+
// envelope whose fields are INJECTED by the consumer (SHACL conforms, SBOM
|
|
8
|
+
// present/valid/complete/signed, Repr-Digest headers present, in-toto
|
|
9
|
+
// attestation present/signed/verified, …) — call lone's `conformance()`
|
|
10
|
+
// model, and return the typed report. Anything the consumer does NOT supply
|
|
11
|
+
// (manual WCAG audit, axe scan, OWASP ASVS, field Core Web Vitals, Baseline,
|
|
12
|
+
// …) is reported `not-assessed`, never silently `met` — so automation can
|
|
13
|
+
// never print "WCAG 2.2 AA" or "ASVS conformant" on its own.
|
|
14
|
+
//
|
|
15
|
+
// renderConformanceReport(report, { evidenceHref, headingLevel, idPrefix })
|
|
16
|
+
// Render a report to semantic, class-based HTML (per-area summaries, each
|
|
17
|
+
// criterion's status + an evidence link, the honest headline claim). NO
|
|
18
|
+
// hardcoded site values, brand tokens, or inline styles — the consumer wraps
|
|
19
|
+
// this fragment in its own template/stylesheet and supplies `evidenceHref`.
|
|
20
|
+
//
|
|
21
|
+
// Nothing here hardcodes a site URL, account, or brand. The conformance MODEL lives
|
|
22
|
+
// in ./conformance/ (a zero-dep Node port of lone@0.4); this file is the reusable
|
|
23
|
+
// glue + presenter on top of it.
|
|
24
|
+
|
|
25
|
+
import { conformance } from "./conformance/conformance.mjs";
|
|
26
|
+
import { COMPACT_CLAIM, CRITERIA, STANDARD_NAME, STANDARD_VERSION } from "./conformance/web-build.mjs";
|
|
27
|
+
|
|
28
|
+
export { conformance, COMPACT_CLAIM, CRITERIA, STANDARD_NAME, STANDARD_VERSION };
|
|
29
|
+
|
|
30
|
+
// lone's own sentinel for "could not read the subtree". Used when the consumer ran
|
|
31
|
+
// the report in a context where lone did NOT bless a DOM (e.g. a pure, headless
|
|
32
|
+
// build that has no document): the lone-measurable criteria come back `not-assessed`
|
|
33
|
+
// rather than being called `met` on an absence of findings (which would be overclaim).
|
|
34
|
+
const DOM_NOT_ASSESSED_FINDINGS = [{
|
|
35
|
+
code: "LONE_ENGINE_INVALID_SUBJECT",
|
|
36
|
+
severity: "error",
|
|
37
|
+
path: "",
|
|
38
|
+
message: "no DOM subject was blessed by lone in this build context",
|
|
39
|
+
}];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Gather evidence + compute the conformance report.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @param {Array<{code:string,severity:string}>|null} [opts.loneFindings] lone's DOM
|
|
46
|
+
* findings (from the semantic gate). Pass `null`/omit when no DOM was blessed in
|
|
47
|
+
* this context → the lone criteria report `not-assessed`. Pass `[]` only when lone
|
|
48
|
+
* actually ran and found nothing.
|
|
49
|
+
* @param {object} [opts.evidence] The external-evidence envelope (lone's shape).
|
|
50
|
+
* Fields with value `undefined`/`null` are pruned so they read as `not-assessed`.
|
|
51
|
+
* @returns {ConformanceReport}
|
|
52
|
+
*/
|
|
53
|
+
export function buildConformanceReport({ loneFindings = null, evidence = {} } = {}) {
|
|
54
|
+
const findings = Array.isArray(loneFindings) ? loneFindings : DOM_NOT_ASSESSED_FINDINGS;
|
|
55
|
+
// Prune absent fields so "not supplied" reads as not-assessed (lone's contract).
|
|
56
|
+
const ev = {};
|
|
57
|
+
for (const [k, v] of Object.entries(evidence ?? {})) {
|
|
58
|
+
if (v !== undefined && v !== null) ev[k] = v;
|
|
59
|
+
}
|
|
60
|
+
return conformance({ findings }, ev);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── HTML renderer ────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const ESC = { "&": "&", "<": "<", ">": ">", '"': """ };
|
|
66
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ESC[c]);
|
|
67
|
+
|
|
68
|
+
const STATUS_LABEL = { met: "met", unmet: "unmet", "not-assessed": "not assessed" };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Render a conformance report to a semantic HTML fragment (class-based, no inline
|
|
72
|
+
* styles, no brand). The consumer styles `.ck-conformance`, `.ck-area`,
|
|
73
|
+
* `.ck-criterion`, and the `.ck-status--{met,unmet,not-assessed}` modifiers, and
|
|
74
|
+
* wraps the fragment in its own page template.
|
|
75
|
+
*
|
|
76
|
+
* @param {ConformanceReport} report
|
|
77
|
+
* @param {object} [opts]
|
|
78
|
+
* @param {(criterion:object)=>(string|undefined)} [opts.evidenceHref] Maps a
|
|
79
|
+
* criterion-result to the URL of its evidence; omit/return falsy → no link.
|
|
80
|
+
* @param {number} [opts.headingLevel] Heading level for the `ck-conformance`
|
|
81
|
+
* section title (default 2). Per-area titles render one level below it.
|
|
82
|
+
* @param {string} [opts.idPrefix] Prefix for per-criterion element ids (default "ck").
|
|
83
|
+
* @returns {string} HTML fragment
|
|
84
|
+
*/
|
|
85
|
+
export function renderConformanceReport(report, opts = {}) {
|
|
86
|
+
const { evidenceHref, headingLevel = 2, idPrefix = "ck" } = opts;
|
|
87
|
+
// The `ck-conformance` section heading sits at `headingLevel`; per-area
|
|
88
|
+
// sub-sections nest one level below it (a valid document outline, and it gives
|
|
89
|
+
// the outer <section> the heading vnu's `--Werror` requires).
|
|
90
|
+
const hSection = Math.min(Math.max(headingLevel | 0, 2), 6);
|
|
91
|
+
const hArea = Math.min(hSection + 1, 6);
|
|
92
|
+
const s = report.summary;
|
|
93
|
+
|
|
94
|
+
const areaBlocks = report.areaSummaries.map((a) => {
|
|
95
|
+
const inArea = report.results.filter((r) => r.area === a.area);
|
|
96
|
+
const items = inArea.map((r) => renderCriterion(r, { evidenceHref, idPrefix })).join("\n");
|
|
97
|
+
return ` <section class="ck-area" data-area="${esc(a.area)}">
|
|
98
|
+
<h${hArea} class="ck-area__title">${esc(a.area)} <span class="ck-area__count">${a.met}/${a.total} met</span></h${hArea}>
|
|
99
|
+
<p class="ck-area__summary">${esc(a.summary)}</p>
|
|
100
|
+
<ul class="ck-criteria">
|
|
101
|
+
${items}
|
|
102
|
+
</ul>
|
|
103
|
+
</section>`;
|
|
104
|
+
}).join("\n");
|
|
105
|
+
|
|
106
|
+
return `<section class="ck-conformance" data-conformant="${report.conformant ? "true" : "false"}">
|
|
107
|
+
<h${hSection} class="ck-conformance__heading">Conformance</h${hSection}>
|
|
108
|
+
<p class="ck-conformance__claim" data-conformant="${report.conformant ? "true" : "false"}">${esc(report.claim)}</p>
|
|
109
|
+
<p class="ck-conformance__summary">${s.met}/${s.total} criteria met · ${s.unmet} unmet · ${s.notAssessed} not assessed · <span class="ck-conformance__standard">${esc(report.standard)} v${esc(report.version)}</span></p>
|
|
110
|
+
<div class="ck-conformance__areas">
|
|
111
|
+
${areaBlocks}
|
|
112
|
+
</div>
|
|
113
|
+
</section>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderCriterion(r, { evidenceHref, idPrefix }) {
|
|
117
|
+
const href = typeof evidenceHref === "function" ? evidenceHref(r) : undefined;
|
|
118
|
+
const evidenceLink = href
|
|
119
|
+
? ` <a class="ck-criterion__evidence" href="${esc(href)}">evidence ↗</a>`
|
|
120
|
+
: "";
|
|
121
|
+
const tier = r.tier ?? 1;
|
|
122
|
+
return ` <li class="ck-criterion" id="${esc(idPrefix)}-${esc(r.id)}" data-status="${esc(r.status)}" data-area="${esc(r.area)}" data-tier="${esc(String(tier))}">
|
|
123
|
+
<span class="ck-criterion__status ck-status--${esc(r.status)}">${esc(STATUS_LABEL[r.status] ?? r.status)}</span>
|
|
124
|
+
<span class="ck-criterion__label">${esc(r.label)}</span>
|
|
125
|
+
<span class="ck-criterion__standard">${esc(r.standard)} · ${esc(r.level)}</span>
|
|
126
|
+
<span class="ck-criterion__detail">${esc(r.detail)}</span>${evidenceLink}
|
|
127
|
+
</li>`;
|
|
128
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// HTML-validity gate — turns "the Nu HTML Checker passed once" into a
|
|
3
|
+
// CONTINUOUSLY-ENFORCED member of the conformance contract. It runs vnu (the Nu
|
|
4
|
+
// Html Checker, the reference HTML conformance checker, as a self-contained Java
|
|
5
|
+
// jar — headless, no browser, no network) over a project's BUILT pages and FAILS
|
|
6
|
+
// CLOSED (exit 1) when the error count exceeds a configurable threshold (default 0).
|
|
7
|
+
// The machine-readable result is exactly the shape lone's conformance() model
|
|
8
|
+
// consumes for `html.validator-clean` (`{ errors }`), so a clean run lets a site
|
|
9
|
+
// honestly assert that criterion — and a regression turns CI red.
|
|
10
|
+
//
|
|
11
|
+
// node gates/html-validator-gate.mjs [distDir] # build gate (exit 1 over threshold)
|
|
12
|
+
//
|
|
13
|
+
// Everything is config-driven; NOTHING about any one site is hard-coded:
|
|
14
|
+
// argv[2] / $HTML_DIST built output dir (default: "dist")
|
|
15
|
+
// $HTML_PAGES comma list of page paths under dist (default: every *.html)
|
|
16
|
+
// $HTML_THRESHOLD highest tolerated error count (default: 0)
|
|
17
|
+
// $HTML_REPORT path to write the JSON report (default: none)
|
|
18
|
+
//
|
|
19
|
+
// Requires a JRE on PATH (CI: actions/setup-java; the jar ships with `vnu-jar`).
|
|
20
|
+
// The pure parse/evaluation functions are exported for unit testing without Java.
|
|
21
|
+
import { writeFile, access, readdir } from "node:fs/promises";
|
|
22
|
+
import { resolve, join } from "node:path";
|
|
23
|
+
import { createRequire } from "node:module";
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
|
|
26
|
+
// ── Pure core (Java-free; unit-testable) ─────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** Extract error-type messages from a vnu `--format json` payload (string or object). */
|
|
29
|
+
export function parseVnu(payload) {
|
|
30
|
+
const json = typeof payload === "string" ? JSON.parse(payload || '{"messages":[]}') : (payload || {});
|
|
31
|
+
const messages = Array.isArray(json.messages) ? json.messages : [];
|
|
32
|
+
return messages.filter((m) => m && m.type === "error");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Evaluate parsed errors against the threshold. Pure: (errors[], threshold) → report. */
|
|
36
|
+
export function evaluateHtml(errors, threshold = 0) {
|
|
37
|
+
const count = errors.length;
|
|
38
|
+
return {
|
|
39
|
+
passed: count <= threshold,
|
|
40
|
+
threshold,
|
|
41
|
+
errors: count,
|
|
42
|
+
// The envelope lone's conformance() consumes for `html.validator-clean`.
|
|
43
|
+
htmlValidator: { errors: count },
|
|
44
|
+
detail: errors.slice(0, 20).map((e) => ({
|
|
45
|
+
page: (e.url || "").replace(/^file:/, ""),
|
|
46
|
+
line: e.lastLine,
|
|
47
|
+
message: e.message,
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Impure runner ────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const require = createRequire(import.meta.url);
|
|
55
|
+
|
|
56
|
+
async function walkHtml(dir, base = dir) {
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
59
|
+
const p = join(dir, e.name);
|
|
60
|
+
if (e.isDirectory()) out.push(...await walkHtml(p, base));
|
|
61
|
+
else if (e.name.endsWith(".html")) out.push(p);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Run vnu over the given files; returns the error-type messages. vnu writes its
|
|
67
|
+
* JSON report to stderr and exits non-zero when errors exist, so we read stderr
|
|
68
|
+
* regardless of exit code. */
|
|
69
|
+
export function runVnu(files) {
|
|
70
|
+
const jar = String(require("vnu-jar"));
|
|
71
|
+
const res = spawnSync("java", ["-jar", jar, "--errors-only", "--format", "json", ...files], {
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
74
|
+
});
|
|
75
|
+
if (res.error) throw new Error(`cannot run vnu (${res.error.message}). Is a JRE on PATH?`);
|
|
76
|
+
return parseVnu(res.stderr || '{"messages":[]}');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Walk → vnu → evaluate → report. Exposed for programmatic use and the kit's test. */
|
|
80
|
+
export async function runHtmlGate({ dist, pages, threshold = 0 }) {
|
|
81
|
+
const files = pages && pages.length
|
|
82
|
+
? pages.map((p) => resolve(dist, p))
|
|
83
|
+
: (await walkHtml(resolve(dist))).sort();
|
|
84
|
+
const report = evaluateHtml(runVnu(files), threshold);
|
|
85
|
+
report.pages = files.length;
|
|
86
|
+
return report;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
async function main() {
|
|
92
|
+
const dist = resolve(process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : process.env.HTML_DIST || "dist");
|
|
93
|
+
const exists = async (p) => { try { await access(p); return true; } catch { return false; } };
|
|
94
|
+
if (!(await exists(dist))) { console.error(`✗ html-validator-gate: ${dist} not found — build first.`); process.exit(2); }
|
|
95
|
+
|
|
96
|
+
const threshold = Number.parseInt(process.env.HTML_THRESHOLD ?? "0", 10);
|
|
97
|
+
if (!Number.isInteger(threshold) || threshold < 0) {
|
|
98
|
+
console.error(`✗ html-validator-gate: $HTML_THRESHOLD must be an integer ≥ 0 (got "${process.env.HTML_THRESHOLD}")`);
|
|
99
|
+
process.exit(2);
|
|
100
|
+
}
|
|
101
|
+
const pages = (process.env.HTML_PAGES || "").split(",").map((s) => s.trim().replace(/^\//, "")).filter(Boolean);
|
|
102
|
+
|
|
103
|
+
const report = await runHtmlGate({ dist, pages, threshold });
|
|
104
|
+
if (process.env.HTML_REPORT) {
|
|
105
|
+
await writeFile(resolve(process.env.HTML_REPORT), JSON.stringify(report, null, 2) + "\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const line = `html-validator-gate: ${report.errors} Nu HTML Checker error(s) over ${report.pages} built page(s) · threshold ${threshold}`;
|
|
109
|
+
if (!report.passed) {
|
|
110
|
+
console.error(`✗ ${line}`);
|
|
111
|
+
for (const d of report.detail) console.error(` ${d.page} L${d.line}: ${d.message}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
console.log(`✓ ${line}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Only run the CLI when invoked directly (not when imported by a test).
|
|
118
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
119
|
+
main().catch((e) => { console.error("✗ html-validator-gate: error —", e.stack || e.message); process.exit(1); });
|
|
120
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Readability SIGNAL gate — a zero-dep readability report over a corpus of prose.
|
|
3
|
+
//
|
|
4
|
+
// node gates/readability-gate.mjs <corpus.json> # report (WARN-only, exit 0)
|
|
5
|
+
// node gates/readability-gate.mjs <corpus.json> --strict # escalate WARNs (exit 1)
|
|
6
|
+
//
|
|
7
|
+
// HONEST FRAMING: this is a READABILITY SIGNAL, not a "cognitive-load score".
|
|
8
|
+
// Flesch-Kincaid / Gunning Fog estimate a US reading grade from surface features
|
|
9
|
+
// (sentence length, syllables-per-word). They do NOT measure how hard an idea is to
|
|
10
|
+
// think about. The gate is WARN-by-default: it reports the signal and flags long
|
|
11
|
+
// sentences, long paragraphs, passive voice, and unexplained acronyms — but it only
|
|
12
|
+
// fails the build on EGREGIOUS thresholds, or when run with --strict.
|
|
13
|
+
//
|
|
14
|
+
// The CORPUS IS AN INPUT (each site curates its own copy): supply a JSON file that
|
|
15
|
+
// is EITHER an array of { "id": "...", "text": "..." } OR an object map { id: text }.
|
|
16
|
+
// Markup/HTML in text is stripped; atoms under the word floor are skipped — a
|
|
17
|
+
// reading-grade formula is meaningless on a two-word button.
|
|
18
|
+
//
|
|
19
|
+
// Site-agnostic injection (all optional, neutral defaults):
|
|
20
|
+
// argv[2] / $READABILITY_CORPUS path to the corpus JSON (required).
|
|
21
|
+
// $READABILITY_THRESHOLDS JSON {gradeWarn,gradeBlock,sentWarn,sentBlock,paraWarn}.
|
|
22
|
+
// $READABILITY_MIN_WORDS per-atom word floor (default 6).
|
|
23
|
+
// $READABILITY_KNOWN_ACRONYMS comma list of acronyms NOT to warn on.
|
|
24
|
+
import { readFile } from "node:fs/promises";
|
|
25
|
+
import { resolve } from "node:path";
|
|
26
|
+
|
|
27
|
+
const corpusPath = process.argv[2] || process.env.READABILITY_CORPUS;
|
|
28
|
+
const strict = process.argv.includes("--strict");
|
|
29
|
+
if (!corpusPath) { console.error("usage: readability-gate <corpus.json> [--strict]"); process.exit(2); }
|
|
30
|
+
|
|
31
|
+
// Thresholds (documented, deliberately generous for terse technical copy):
|
|
32
|
+
// reading grade WARN > 14 (college) · EGREGIOUS (block) > 22
|
|
33
|
+
// sentence length WARN > 30 words · EGREGIOUS (block) > 60 words
|
|
34
|
+
// paragraph length WARN > 90 words
|
|
35
|
+
const T = {
|
|
36
|
+
gradeWarn: 14, gradeBlock: 22, sentWarn: 30, sentBlock: 60, paraWarn: 90,
|
|
37
|
+
...(process.env.READABILITY_THRESHOLDS ? JSON.parse(process.env.READABILITY_THRESHOLDS) : {}),
|
|
38
|
+
};
|
|
39
|
+
const MIN_WORDS = Number(process.env.READABILITY_MIN_WORDS || 6);
|
|
40
|
+
|
|
41
|
+
// A small default of common/explained acronyms; the consumer extends via env.
|
|
42
|
+
const KNOWN_ACRONYMS = new Set([
|
|
43
|
+
"AI", "CLI", "PR", "PRS", "CI", "AWS", "DOM", "HTML", "CSS", "JSON", "RDF", "URL", "RSS",
|
|
44
|
+
"SLSA", "PDF", "US", "OCI", "GHCR", "OIDC", "API", "SBOM", "CID", "IPFS",
|
|
45
|
+
"SPDX", "DNS", "MCP", "VC", "TS", "SHA", "TDD", "SHACL", "RFC", "SEO", "ID",
|
|
46
|
+
...(process.env.READABILITY_KNOWN_ACRONYMS || "").split(",").map((s) => s.trim().toUpperCase()).filter(Boolean),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
// ---- text utilities (zero-dep) --------------------------------------------------
|
|
50
|
+
const stripMarkup = (s) =>
|
|
51
|
+
String(s)
|
|
52
|
+
.replace(/<[^>]+>/g, " ")
|
|
53
|
+
.replace(/·| /g, " ")
|
|
54
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
55
|
+
.replace(/\s+/g, " ")
|
|
56
|
+
.trim();
|
|
57
|
+
|
|
58
|
+
const words = (s) => (s.match(/[A-Za-z][A-Za-z'’-]*/g) || []);
|
|
59
|
+
const sentences = (s) => s.split(/(?<=[.!?])\s+(?=[A-Z(])/).map((x) => x.trim()).filter(Boolean);
|
|
60
|
+
|
|
61
|
+
const syllables = (w) => {
|
|
62
|
+
w = w.toLowerCase().replace(/[^a-z]/g, "");
|
|
63
|
+
if (!w) return 0;
|
|
64
|
+
let groups = (w.match(/[aeiouy]+/g) || []).length;
|
|
65
|
+
if (/e$/.test(w) && !/[aeiouy]e$/.test(w) && groups > 1) groups--; // silent final e
|
|
66
|
+
return Math.max(1, groups);
|
|
67
|
+
};
|
|
68
|
+
const complex = (w) => syllables(w) >= 3; // Gunning Fog "complex word"
|
|
69
|
+
|
|
70
|
+
// ---- load + normalise the corpus -------------------------------------------------
|
|
71
|
+
const raw = JSON.parse(await readFile(resolve(corpusPath), "utf8"));
|
|
72
|
+
const entries = Array.isArray(raw)
|
|
73
|
+
? raw.map((e) => [e.id, e.text])
|
|
74
|
+
: Object.entries(raw).filter(([id, v]) => !id.startsWith("_") && typeof v === "string");
|
|
75
|
+
|
|
76
|
+
const corpus = []; // { id, text }
|
|
77
|
+
for (const [id, text] of entries) {
|
|
78
|
+
const t = stripMarkup(text);
|
|
79
|
+
if (t && words(t).length >= MIN_WORDS) corpus.push({ id, text: t });
|
|
80
|
+
}
|
|
81
|
+
if (corpus.length === 0) { console.error(`✗ readability-gate: no prose atoms (≥ ${MIN_WORDS} words) in ${corpusPath}`); process.exit(2); }
|
|
82
|
+
|
|
83
|
+
// ---- score ----------------------------------------------------------------------
|
|
84
|
+
let warns = 0, blocks = 0;
|
|
85
|
+
const warn = (m) => { console.log(` ⚠ ${m}`); warns++; };
|
|
86
|
+
const block = (m) => { console.error(` ✗ ${m}`); blocks++; };
|
|
87
|
+
|
|
88
|
+
const PASSIVE = /\b(?:is|are|was|were|be|been|being|am)\b\s+(?:[a-z]+ly\s+)?(?:[a-z]+ed|written|built|made|done|shown|given|held|kept|driven|known|seen|taken|drawn|met|run|set|read|put|sent|brought|caught)\b/gi;
|
|
89
|
+
|
|
90
|
+
let totW = 0, totS = 0, totSyl = 0, totComplex = 0;
|
|
91
|
+
for (const { id, text } of corpus) {
|
|
92
|
+
const ws = words(text);
|
|
93
|
+
const ss = sentences(text);
|
|
94
|
+
const syl = ws.reduce((a, w) => a + syllables(w), 0);
|
|
95
|
+
const cx = ws.filter(complex).length;
|
|
96
|
+
totW += ws.length; totS += ss.length; totSyl += syl; totComplex += cx;
|
|
97
|
+
|
|
98
|
+
for (const s of ss) {
|
|
99
|
+
const n = words(s).length;
|
|
100
|
+
if (n > T.sentBlock) block(`${id}: sentence of ${n} words exceeds egregious cap (${T.sentBlock}) — "${s.slice(0, 70)}…"`);
|
|
101
|
+
else if (n > T.sentWarn) warn(`${id}: long sentence (${n} words) — "${s.slice(0, 70)}…"`);
|
|
102
|
+
}
|
|
103
|
+
if (ws.length > T.paraWarn) warn(`${id}: long paragraph (${ws.length} words)`);
|
|
104
|
+
for (const m of text.match(PASSIVE) || []) warn(`${id}: possible passive voice — "${m.trim()}"`);
|
|
105
|
+
for (const tok of text.match(/\b[A-Z][A-Z0-9]{1,6}s?\b/g) || []) {
|
|
106
|
+
const base = tok.replace(/s$/, "").toUpperCase();
|
|
107
|
+
if (!KNOWN_ACRONYMS.has(tok.toUpperCase()) && !KNOWN_ACRONYMS.has(base)) warn(`${id}: unexplained acronym "${tok}"`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fk = 0.39 * (totW / totS) + 11.8 * (totSyl / totW) - 15.59;
|
|
112
|
+
const fog = 0.4 * ((totW / totS) + 100 * (totComplex / totW));
|
|
113
|
+
const grade = (fk + fog) / 2;
|
|
114
|
+
const g = (x) => x.toFixed(1);
|
|
115
|
+
|
|
116
|
+
console.log("");
|
|
117
|
+
console.log(`readability signal (${corpus.length} prose atoms, ${totW} words, ${totS} sentences):`);
|
|
118
|
+
console.log(` Flesch-Kincaid grade ${g(fk)} · Gunning Fog ${g(fog)} · mean ${g(grade)}`);
|
|
119
|
+
console.log(` (a US reading-grade SIGNAL from sentence length + syllables — NOT a cognitive-load score)`);
|
|
120
|
+
console.log("");
|
|
121
|
+
|
|
122
|
+
if (grade > T.gradeBlock) block(`mean reading grade ${g(grade)} exceeds egregious cap (${T.gradeBlock})`);
|
|
123
|
+
else if (grade > T.gradeWarn) warn(`mean reading grade ${g(grade)} above college level (${T.gradeWarn})`);
|
|
124
|
+
|
|
125
|
+
console.log("");
|
|
126
|
+
if (blocks) {
|
|
127
|
+
console.error(`✗ readability-gate: ${blocks} egregious finding(s), ${warns} warning(s).`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
if (strict && warns) {
|
|
131
|
+
console.error(`✗ readability-gate (--strict): ${warns} warning(s) escalated to errors.`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
console.log(`✓ readability-gate: signal reported — ${warns} warning(s), 0 egregious. (WARN-only; pass --strict to block on warnings.)`);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-sbom — the fail-closed completeness gate tying the SPDX SBOM to the
|
|
3
|
+
// flake.lock pinned set (+ an optional in-toto/SLSA statement). A violation refuses
|
|
4
|
+
// the build (process.exit(1)) rather than shipping an incomplete bill.
|
|
5
|
+
//
|
|
6
|
+
// It enforces three things over $DIST/sbom.spdx.json (+ flake.lock, + an optional
|
|
7
|
+
// $DIST/attestation.intoto.json), all decidable / order-free:
|
|
8
|
+
// 1. SPDX 2.3 well-formedness — the document + every package carry their required
|
|
9
|
+
// fields (spdxVersion SPDX-2.3, SPDXID, dataLicense, namespace, creationInfo;
|
|
10
|
+
// per package: name, SPDXID, downloadLocation).
|
|
11
|
+
// 2. Pinned-set ⊆ SBOM — every Nix flake input pinned in flake.lock appears as an
|
|
12
|
+
// SPDX package at the SAME rev, and any package-reference (pkg:…) the
|
|
13
|
+
// attestation enumerates as a resolvedDependency appears in the SBOM at the
|
|
14
|
+
// SAME rev. (File-path materials are content inputs, not redistributable
|
|
15
|
+
// packages, so they are intentionally out of SBOM scope.)
|
|
16
|
+
// 3. SBOM ⊆ pinned-set (vice-versa) — every Nix-sourced SPDX package (pkg:github)
|
|
17
|
+
// traces back to a real flake.lock rev: no orphan Nix entries the build can't pin.
|
|
18
|
+
//
|
|
19
|
+
// Site-agnostic injection (all optional, neutral defaults):
|
|
20
|
+
// $ROOT repo root with flake.lock (default: cwd).
|
|
21
|
+
// $DIST dir holding the SBOM + optional attestation (default: $ROOT/dist).
|
|
22
|
+
// $SBOM_OUT SBOM filename under $DIST (default: "sbom.spdx.json").
|
|
23
|
+
import { readFile, access } from "node:fs/promises";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
// $ROOT / $DIST may be absolute or relative-to-cwd (resolve handles both).
|
|
27
|
+
const root = resolve(process.cwd(), process.env.ROOT || ".");
|
|
28
|
+
const dist = process.env.DIST ? resolve(process.cwd(), process.env.DIST) : join(root, "dist");
|
|
29
|
+
const outName = process.env.SBOM_OUT || "sbom.spdx.json";
|
|
30
|
+
|
|
31
|
+
const exists = async (p) => { try { await access(p); return true; } catch { return false; } };
|
|
32
|
+
const readJson = async (p) => JSON.parse(await readFile(p, "utf8"));
|
|
33
|
+
|
|
34
|
+
const errors = [];
|
|
35
|
+
const fail = (msg) => errors.push(msg);
|
|
36
|
+
|
|
37
|
+
const sbomPath = join(dist, outName);
|
|
38
|
+
const attPath = join(dist, "attestation.intoto.json");
|
|
39
|
+
if (!(await exists(sbomPath))) { console.error(`✗ check:sbom: ${outName} missing — run gen-sbom.mjs first`); process.exit(1); }
|
|
40
|
+
|
|
41
|
+
const sbom = await readJson(sbomPath);
|
|
42
|
+
const flakeLock = (await exists(join(root, "flake.lock"))) ? await readJson(join(root, "flake.lock")) : { nodes: {} };
|
|
43
|
+
|
|
44
|
+
// ---- 1. SPDX 2.3 well-formedness --------------------------------------------
|
|
45
|
+
if (sbom.spdxVersion !== "SPDX-2.3") fail(`spdxVersion is "${sbom.spdxVersion}", expected "SPDX-2.3"`);
|
|
46
|
+
if (sbom.SPDXID !== "SPDXRef-DOCUMENT") fail(`document SPDXID is "${sbom.SPDXID}", expected "SPDXRef-DOCUMENT"`);
|
|
47
|
+
if (!sbom.dataLicense) fail("document missing dataLicense");
|
|
48
|
+
if (!sbom.name) fail("document missing name");
|
|
49
|
+
if (!sbom.documentNamespace) fail("document missing documentNamespace");
|
|
50
|
+
if (!sbom.creationInfo?.created) fail("document missing creationInfo.created");
|
|
51
|
+
if (!Array.isArray(sbom.creationInfo?.creators) || sbom.creationInfo.creators.length === 0) fail("document missing creationInfo.creators");
|
|
52
|
+
if (!Array.isArray(sbom.packages) || sbom.packages.length === 0) fail("document has no packages");
|
|
53
|
+
const seenIds = new Set();
|
|
54
|
+
for (const p of sbom.packages || []) {
|
|
55
|
+
const tag = p.name || p.SPDXID || "(unnamed)";
|
|
56
|
+
if (!p.name) fail(`package ${tag} missing name`);
|
|
57
|
+
if (!p.SPDXID) fail(`package ${tag} missing SPDXID`);
|
|
58
|
+
if (!/^SPDXRef-[a-zA-Z0-9.-]+$/.test(p.SPDXID || "")) fail(`package ${tag} has malformed SPDXID "${p.SPDXID}"`);
|
|
59
|
+
if (p.SPDXID && seenIds.has(p.SPDXID)) fail(`duplicate SPDXID ${p.SPDXID}`);
|
|
60
|
+
seenIds.add(p.SPDXID);
|
|
61
|
+
if (!p.downloadLocation) fail(`package ${tag} missing downloadLocation`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// rev → package index (Nix packages record the locked commit as versionInfo)
|
|
65
|
+
const revToPkg = new Map();
|
|
66
|
+
const nixPkgs = [];
|
|
67
|
+
for (const p of sbom.packages || []) {
|
|
68
|
+
const purl = (p.externalRefs || []).find((r) => r.referenceType === "purl")?.referenceLocator || "";
|
|
69
|
+
if (purl.startsWith("pkg:github/")) {
|
|
70
|
+
nixPkgs.push({ pkg: p, purl });
|
|
71
|
+
if (p.versionInfo) revToPkg.set(p.versionInfo, p);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- 2. Pinned-set ⊆ SBOM ----------------------------------------------------
|
|
76
|
+
// 2a. every flake.lock input is in the SBOM at its locked rev
|
|
77
|
+
const flakeRevs = new Set();
|
|
78
|
+
for (const [nodeName, node] of Object.entries(flakeLock.nodes || {})) {
|
|
79
|
+
if (nodeName === "root") continue;
|
|
80
|
+
const rev = node.locked?.rev;
|
|
81
|
+
if (!rev) continue;
|
|
82
|
+
flakeRevs.add(rev);
|
|
83
|
+
if (!revToPkg.has(rev)) fail(`flake.lock input "${nodeName}" (rev ${rev.slice(0, 12)}…) is not an SPDX package`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2b. every package-reference material in the attestation is in the SBOM
|
|
87
|
+
if (await exists(attPath)) {
|
|
88
|
+
const att = await readJson(attPath);
|
|
89
|
+
const deps = att.predicate?.buildDefinition?.resolvedDependencies || [];
|
|
90
|
+
for (const d of deps) {
|
|
91
|
+
const isPkgRef = (typeof d.uri === "string" && d.uri.startsWith("pkg:")) || d.digest?.gitCommit;
|
|
92
|
+
if (!isPkgRef) continue; // file-path / source materials are out of SBOM scope
|
|
93
|
+
const rev = d.digest?.gitCommit;
|
|
94
|
+
if (rev && !revToPkg.has(rev)) fail(`attestation material "${d.uri}" (gitCommit ${rev.slice(0, 12)}…) is not an SPDX package`);
|
|
95
|
+
if (!rev) fail(`attestation package-ref "${d.uri}" has no gitCommit to reconcile against the SBOM`);
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
console.warn("… check:sbom: attestation.intoto.json not present — skipping attestation cross-check (run the full build to enforce it)");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- 3. SBOM ⊆ pinned-set (vice-versa) --------------------------------------
|
|
102
|
+
for (const { pkg, purl } of nixPkgs) {
|
|
103
|
+
if (!pkg.versionInfo || !flakeRevs.has(pkg.versionInfo))
|
|
104
|
+
fail(`Nix-sourced SPDX package "${pkg.name}" (${purl}) has no matching flake.lock rev — orphan entry`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (errors.length) {
|
|
108
|
+
console.error(`✗ check:sbom: ${errors.length} completeness violation(s) — refusing to ship an incomplete SBOM:`);
|
|
109
|
+
for (const e of errors) console.error(` ${e}`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
console.log(`✓ check:sbom: SPDX-2.3 well-formed · ${sbom.packages.length} packages · pinned set (${flakeRevs.size} flake inputs) ⊆ SBOM ⊆ pinned set.`);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gen-sbom — emit a deterministic SPDX 2.3 SBOM for the WHOLE supply chain a build
|
|
3
|
+
// pulls from: the npm lockfiles + (optionally) the Nix flake.lock inputs.
|
|
4
|
+
//
|
|
5
|
+
// It reads the committed lockfiles (the single source of truth) and emits one
|
|
6
|
+
// SPDX-2.3 JSON. Each package carries a versionInfo, a downloadLocation, and a
|
|
7
|
+
// checksum + purl externalRef:
|
|
8
|
+
// • npm packages — from package-lock.json (+ any extra lockfiles); integrity hash
|
|
9
|
+
// (base64 SRI) decoded to a hex SPDX checksum, downloadLocation =
|
|
10
|
+
// the resolved registry tarball.
|
|
11
|
+
// • Nix inputs — from flake.lock (if present); narHash (sha256 SRI) decoded to a
|
|
12
|
+
// hex SPDX SHA256 checksum, rev pinned via a pkg:github purl + a
|
|
13
|
+
// git+https downloadLocation.
|
|
14
|
+
//
|
|
15
|
+
// Pure + deterministic: a function of the lockfiles only (no network, no clock — the
|
|
16
|
+
// creation timestamp is derived from flake.lock's newest lastModified when present,
|
|
17
|
+
// else epoch 0; output is sorted; the namespace is content-derived). Zero deps.
|
|
18
|
+
//
|
|
19
|
+
// Site-agnostic injection (all optional, neutral defaults):
|
|
20
|
+
// $ROOT repo root containing the lockfiles (default: cwd).
|
|
21
|
+
// $DIST output dir (default: $ROOT/dist).
|
|
22
|
+
// $SBOM_LOCKFILES comma list of npm lockfile paths, relative to $ROOT
|
|
23
|
+
// (default: "package-lock.json").
|
|
24
|
+
// $SBOM_NAME SPDX document name (default: "<basename(ROOT)>-sbom").
|
|
25
|
+
// $SBOM_NAMESPACE_BASE documentNamespace prefix; the content fingerprint is
|
|
26
|
+
// appended (default: "https://spdx.invalid/sbom").
|
|
27
|
+
// $SBOM_CREATORS comma list of SPDX creators
|
|
28
|
+
// (default: "Tool: gen-sbom.mjs").
|
|
29
|
+
// $SBOM_OUT output filename under $DIST (default: "sbom.spdx.json").
|
|
30
|
+
import { readFile, writeFile, access } from "node:fs/promises";
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import { join, basename, resolve } from "node:path";
|
|
33
|
+
|
|
34
|
+
// $ROOT / $DIST may be absolute or relative-to-cwd (resolve handles both).
|
|
35
|
+
const root = resolve(process.cwd(), process.env.ROOT || ".");
|
|
36
|
+
const dist = process.env.DIST ? resolve(process.cwd(), process.env.DIST) : join(root, "dist");
|
|
37
|
+
const lockfiles = (process.env.SBOM_LOCKFILES || "package-lock.json").split(",").map((s) => s.trim()).filter(Boolean);
|
|
38
|
+
const docName = process.env.SBOM_NAME || `${basename(root)}-sbom`;
|
|
39
|
+
const nsBase = (process.env.SBOM_NAMESPACE_BASE || "https://spdx.invalid/sbom").replace(/\/$/, "");
|
|
40
|
+
const creators = (process.env.SBOM_CREATORS || "Tool: gen-sbom.mjs").split(",").map((s) => s.trim()).filter(Boolean);
|
|
41
|
+
const outName = process.env.SBOM_OUT || "sbom.spdx.json";
|
|
42
|
+
|
|
43
|
+
const exists = async (p) => { try { await access(p); return true; } catch { return false; } };
|
|
44
|
+
const readJson = async (p) => JSON.parse(await readFile(p, "utf8"));
|
|
45
|
+
|
|
46
|
+
// SPDXID must be [a-zA-Z0-9.-]; map everything else to '-' so @scope/name@1.2.3
|
|
47
|
+
// becomes a legal, collision-resistant element id.
|
|
48
|
+
const spdxId = (s) => "SPDXRef-Package-" + s.replace(/[^a-zA-Z0-9.-]/g, "-");
|
|
49
|
+
const SRI_ALG = { sha512: "SHA512", sha384: "SHA384", sha256: "SHA256", sha1: "SHA1" };
|
|
50
|
+
// Decode an SRI hash (alg-<base64>) → { algorithm, checksumValue } in lowercase hex,
|
|
51
|
+
// the only checksum form SPDX accepts. Returns null for anything unrecognised.
|
|
52
|
+
const sriToChecksum = (sri) => {
|
|
53
|
+
if (typeof sri !== "string" || !sri.includes("-")) return null;
|
|
54
|
+
const [alg, b64] = [sri.slice(0, sri.indexOf("-")), sri.slice(sri.indexOf("-") + 1)];
|
|
55
|
+
const algorithm = SRI_ALG[alg];
|
|
56
|
+
if (!algorithm || !b64) return null;
|
|
57
|
+
return { algorithm, checksumValue: Buffer.from(b64, "base64").toString("hex") };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Collect every resolved npm package across the given lockfiles, keyed name@version
|
|
61
|
+
// (deduped). lockfileVersion 3: packages[<path>] with version/resolved/integrity.
|
|
62
|
+
async function collectNpm(lockPaths) {
|
|
63
|
+
const pkgs = new Map();
|
|
64
|
+
for (const lp of lockPaths) {
|
|
65
|
+
if (!(await exists(join(root, lp)))) continue;
|
|
66
|
+
const lock = await readJson(join(root, lp));
|
|
67
|
+
for (const [key, p] of Object.entries(lock.packages || {})) {
|
|
68
|
+
if (!key.startsWith("node_modules/")) continue; // skip the project root ("")
|
|
69
|
+
if (!p.version || !p.resolved) continue; // skip links/workspaces
|
|
70
|
+
const name = key.slice(key.lastIndexOf("node_modules/") + "node_modules/".length);
|
|
71
|
+
const id = `${name}@${p.version}`;
|
|
72
|
+
if (pkgs.has(id)) continue;
|
|
73
|
+
pkgs.set(id, {
|
|
74
|
+
kind: "npm",
|
|
75
|
+
name,
|
|
76
|
+
versionInfo: p.version,
|
|
77
|
+
downloadLocation: p.resolved,
|
|
78
|
+
purl: `pkg:npm/${name}@${p.version}`,
|
|
79
|
+
checksum: sriToChecksum(p.integrity),
|
|
80
|
+
license: typeof p.license === "string" ? p.license : null,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return [...pkgs.values()];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Collect the Nix flake inputs. Each is pinned by a commit rev + narHash; narHash is
|
|
88
|
+
// an sha256 SRI we decode to a hex SPDX checksum.
|
|
89
|
+
function collectNix(flakeLock) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const [nodeName, node] of Object.entries(flakeLock.nodes || {})) {
|
|
92
|
+
if (nodeName === "root") continue;
|
|
93
|
+
const lk = node.locked;
|
|
94
|
+
if (!lk || !lk.rev) continue;
|
|
95
|
+
const name = lk.repo ? `${lk.owner}/${lk.repo}` : nodeName;
|
|
96
|
+
const downloadLocation = lk.type === "github"
|
|
97
|
+
? `git+https://github.com/${lk.owner}/${lk.repo}@${lk.rev}`
|
|
98
|
+
: `git+https://${lk.owner || ""}/${lk.repo || nodeName}@${lk.rev}`;
|
|
99
|
+
out.push({
|
|
100
|
+
kind: "nix",
|
|
101
|
+
node: nodeName,
|
|
102
|
+
name,
|
|
103
|
+
versionInfo: lk.rev,
|
|
104
|
+
downloadLocation,
|
|
105
|
+
purl: `pkg:github/${lk.owner}/${lk.repo}@${lk.rev}`,
|
|
106
|
+
checksum: sriToChecksum(lk.narHash),
|
|
107
|
+
rev: lk.rev,
|
|
108
|
+
lastModified: lk.lastModified || 0,
|
|
109
|
+
license: null,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const flakeLock = (await exists(join(root, "flake.lock"))) ? await readJson(join(root, "flake.lock")) : { nodes: {} };
|
|
116
|
+
const npm = await collectNpm(lockfiles);
|
|
117
|
+
const nix = collectNix(flakeLock);
|
|
118
|
+
|
|
119
|
+
// Deterministic order: kind (nix before npm) then name then version.
|
|
120
|
+
const all = [...nix, ...npm].sort((a, b) =>
|
|
121
|
+
(a.kind === b.kind ? 0 : a.kind === "nix" ? -1 : 1) ||
|
|
122
|
+
a.name.localeCompare(b.name) || a.versionInfo.localeCompare(b.versionInfo));
|
|
123
|
+
|
|
124
|
+
const packages = all.map((p) => {
|
|
125
|
+
const externalRefs = [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: p.purl }];
|
|
126
|
+
return {
|
|
127
|
+
name: p.name,
|
|
128
|
+
SPDXID: spdxId(`${p.name}@${p.versionInfo}`),
|
|
129
|
+
versionInfo: p.versionInfo,
|
|
130
|
+
downloadLocation: p.downloadLocation,
|
|
131
|
+
filesAnalyzed: false,
|
|
132
|
+
licenseConcluded: "NOASSERTION",
|
|
133
|
+
licenseDeclared: p.license || "NOASSERTION",
|
|
134
|
+
copyrightText: "NOASSERTION",
|
|
135
|
+
...(p.checksum ? { checksums: [p.checksum] } : {}),
|
|
136
|
+
externalRefs,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Deterministic, content-derived bits: no wall clock. The creation date is the
|
|
141
|
+
// newest flake.lock lastModified (a pure function of the pinned inputs; epoch 0 when
|
|
142
|
+
// no flake); the namespace is a digest of the package set so identical lockfiles →
|
|
143
|
+
// identical document, byte-for-byte.
|
|
144
|
+
const newest = Math.max(0, ...nix.map((p) => p.lastModified));
|
|
145
|
+
const created = new Date(newest * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
146
|
+
const fingerprint = createHash("sha256").update(JSON.stringify(packages)).digest("hex");
|
|
147
|
+
|
|
148
|
+
const doc = {
|
|
149
|
+
spdxVersion: "SPDX-2.3",
|
|
150
|
+
dataLicense: "CC0-1.0",
|
|
151
|
+
SPDXID: "SPDXRef-DOCUMENT",
|
|
152
|
+
name: docName,
|
|
153
|
+
documentNamespace: `${nsBase}/${fingerprint}`,
|
|
154
|
+
creationInfo: {
|
|
155
|
+
created,
|
|
156
|
+
creators,
|
|
157
|
+
},
|
|
158
|
+
packages,
|
|
159
|
+
relationships: packages.map((p) => ({
|
|
160
|
+
spdxElementId: "SPDXRef-DOCUMENT",
|
|
161
|
+
relationshipType: "DESCRIBES",
|
|
162
|
+
relatedSpdxElement: p.SPDXID,
|
|
163
|
+
})),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
await writeFile(join(dist, outName), JSON.stringify(doc, null, 2) + "\n");
|
|
167
|
+
console.log(`✓ SBOM: ${packages.length} packages (${nix.length} Nix + ${npm.length} npm) → ${process.env.DIST || "dist"}/${outName} (SPDX-2.3)`);
|