@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,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// integrity · structure-audit — a deterministic, whole-page structure gate.
|
|
3
|
+
//
|
|
4
|
+
// node integrity/structure-audit/audit.mjs <distDir> [--check]
|
|
5
|
+
//
|
|
6
|
+
// Validates the document STRUCTURE + reader survivability + the internal link
|
|
7
|
+
// graph, and extracts a content-addressed `structure.json` so the page skeleton is
|
|
8
|
+
// a pure function of source (drift fails CI under --check).
|
|
9
|
+
//
|
|
10
|
+
// Checks (errors fail the gate):
|
|
11
|
+
// 1. reader survivability (articles) — run Mozilla Readability (what Firefox
|
|
12
|
+
// Reader runs); it must extract an article that still contains the <h1> and
|
|
13
|
+
// isn't mostly-empty. The free test of "do the semantics survive the CSS being
|
|
14
|
+
// stripped." Scoped to articles; list/error pages aren't reader targets.
|
|
15
|
+
// 2. outline — exactly one <h1>, no skipped heading levels (all pages).
|
|
16
|
+
// 3. landmarks — at most one <main>; a content page with none is a warning.
|
|
17
|
+
// 4. internal link-graph — every internal href resolves to a served file, an
|
|
18
|
+
// in-page anchor, or a known deploy-time sidecar; dead links error, and a
|
|
19
|
+
// served page reachable from nothing is an orphan (warn).
|
|
20
|
+
//
|
|
21
|
+
// Deterministic: same dist → byte-identical structure.json (sorted, hashed).
|
|
22
|
+
//
|
|
23
|
+
// Site-agnostic injection points (all optional, neutral defaults):
|
|
24
|
+
// $STRUCTURE_AUDIT_SIDECARS comma-separated deploy-time paths to treat as live
|
|
25
|
+
// (e.g. /resume.pdf), beyond the built-in set.
|
|
26
|
+
// $STRUCTURE_ARTICLE_PREFIX dir prefix whose pages are "articles" for the reader
|
|
27
|
+
// check (default "blog/"); its own index is excluded.
|
|
28
|
+
// $STRUCTURE_ERROR_PAGE the error page exempt from the <main>/orphan rules
|
|
29
|
+
// (default "404.html").
|
|
30
|
+
// $STRUCTURE_BASELINE path to the committed structure.json baseline
|
|
31
|
+
// (default: structure.json next to this script — so a
|
|
32
|
+
// vendored, hash-pinned copy keeps the baseline in the
|
|
33
|
+
// CONSUMER, not in the kit file).
|
|
34
|
+
import { readdir, readFile, writeFile, access } from "node:fs/promises";
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
36
|
+
import { join, relative, dirname, resolve } from "node:path";
|
|
37
|
+
import { fileURLToPath } from "node:url";
|
|
38
|
+
import { parseHTML } from "linkedom";
|
|
39
|
+
import { Readability } from "@mozilla/readability";
|
|
40
|
+
|
|
41
|
+
const dist = resolve(process.argv[2] || process.env.DIST || "dist");
|
|
42
|
+
const CHECK = process.argv.includes("--check");
|
|
43
|
+
const exists = async (p) => { try { await access(p); return true; } catch { return false; } };
|
|
44
|
+
|
|
45
|
+
const ARTICLE_PREFIX = process.env.STRUCTURE_ARTICLE_PREFIX || "blog/";
|
|
46
|
+
const ERROR_PAGE = process.env.STRUCTURE_ERROR_PAGE || "404.html";
|
|
47
|
+
|
|
48
|
+
// Generated at deploy, so absent from a local build — treat as resolvable rather
|
|
49
|
+
// than dead. (The post-deploy edge check verifies they serve.) Consumers add their
|
|
50
|
+
// own deploy-time assets via STRUCTURE_AUDIT_SIDECARS (comma-separated), e.g.
|
|
51
|
+
// bd-site's /resume.pdf, which is print-to-pdf'd during deploy.
|
|
52
|
+
const DEPLOY_SIDECARS = ["/rekor", "/provenance.json", "/site.sha256",
|
|
53
|
+
...(process.env.STRUCTURE_AUDIT_SIDECARS || "").split(",").map((s) => s.trim()).filter(Boolean)];
|
|
54
|
+
|
|
55
|
+
async function walk(dir, ext) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
58
|
+
const abs = join(dir, e.name);
|
|
59
|
+
if (e.isDirectory()) out.push(...await walk(abs, ext));
|
|
60
|
+
else if (e.name.endsWith(ext)) out.push(abs);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Normalise a served path to a canonical key (drop index.html / .html / trailing /).
|
|
66
|
+
const canon = (p) => {
|
|
67
|
+
let s = p.replace(/\\/g, "/");
|
|
68
|
+
if (!s.startsWith("/")) s = "/" + s;
|
|
69
|
+
s = s.replace(/\/index\.html$/, "/").replace(/\.html$/, "");
|
|
70
|
+
if (s.length > 1) s = s.replace(/\/$/, "");
|
|
71
|
+
return s || "/";
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const pages = (await walk(dist, ".html")).sort();
|
|
75
|
+
const servedCanon = new Set(pages.map((p) => canon("/" + relative(dist, p))));
|
|
76
|
+
let errors = 0, warns = 0;
|
|
77
|
+
const err = (m) => { console.error(` ✗ ${m}`); errors++; };
|
|
78
|
+
const warn = (m) => { console.error(` ⚠ ${m}`); warns++; };
|
|
79
|
+
|
|
80
|
+
// Resolve an internal href to a canonical served key, or "ok"/"dead".
|
|
81
|
+
async function resolveHref(pageAbs, href) {
|
|
82
|
+
if (!href || /^(https?:|mailto:|tel:|#|data:)/i.test(href)) return { kind: "skip" };
|
|
83
|
+
const clean = href.split("#")[0].split("?")[0];
|
|
84
|
+
if (!clean) return { kind: "skip" };
|
|
85
|
+
if (DEPLOY_SIDECARS.some((s) => clean === s || clean.startsWith(s + "/"))) return { kind: "ok", key: canon(clean) };
|
|
86
|
+
const base = clean.startsWith("/") ? join(dist, clean) : resolve(dirname(pageAbs), clean);
|
|
87
|
+
for (const cand of [base, base + ".html", join(base, "index.html")]) {
|
|
88
|
+
if (await exists(cand)) return { kind: "ok", key: canon("/" + relative(dist, cand)) };
|
|
89
|
+
}
|
|
90
|
+
return { kind: "dead" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const structure = {};
|
|
94
|
+
const reachable = new Set();
|
|
95
|
+
|
|
96
|
+
for (const pageAbs of pages) {
|
|
97
|
+
const rel = relative(dist, pageAbs).replace(/\\/g, "/");
|
|
98
|
+
const html = await readFile(pageAbs, "utf8");
|
|
99
|
+
const { document } = parseHTML(html);
|
|
100
|
+
|
|
101
|
+
const hs = [...document.querySelectorAll("h1,h2,h3,h4,h5,h6")].map((h) => +h.tagName[1]);
|
|
102
|
+
const h1s = hs.filter((l) => l === 1).length;
|
|
103
|
+
if (h1s !== 1) err(`${rel}: ${h1s} <h1> (want exactly 1)`);
|
|
104
|
+
for (let i = 1; i < hs.length; i++) if (hs[i] - hs[i - 1] > 1) { err(`${rel}: heading jump h${hs[i - 1]}→h${hs[i]} (skipped level)`); break; }
|
|
105
|
+
|
|
106
|
+
const mains = document.querySelectorAll("main").length;
|
|
107
|
+
if (mains > 1) err(`${rel}: ${mains} <main> (want at most 1)`);
|
|
108
|
+
else if (mains === 0 && rel !== ERROR_PAGE) warn(`${rel}: no <main> landmark`);
|
|
109
|
+
|
|
110
|
+
// reader survivability — articles only
|
|
111
|
+
const isArticle = rel.startsWith(ARTICLE_PREFIX) && rel !== `${ARTICLE_PREFIX}index.html`;
|
|
112
|
+
let readerOk = null;
|
|
113
|
+
if (isArticle) {
|
|
114
|
+
const h1text = (document.querySelector("h1")?.textContent || "").trim();
|
|
115
|
+
try {
|
|
116
|
+
const article = new Readability(parseHTML(html).document).parse();
|
|
117
|
+
const txt = (article?.textContent || "").replace(/\s+/g, " ").trim();
|
|
118
|
+
readerOk = !!article && txt.length > 200 && (!h1text || article.title?.includes(h1text.slice(0, 24)) || txt.includes(h1text.slice(0, 24)));
|
|
119
|
+
if (!readerOk) err(`${rel}: reader view didn't extract the article + its <h1> (semantics may be CSS-only)`);
|
|
120
|
+
} catch (e) { err(`${rel}: Readability threw (${e.message})`); readerOk = false; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const links = [];
|
|
124
|
+
for (const a of document.querySelectorAll("a[href]")) {
|
|
125
|
+
const href = a.getAttribute("href");
|
|
126
|
+
const r = await resolveHref(pageAbs, href);
|
|
127
|
+
if (r.kind === "dead") err(`${rel}: dead internal link → ${href}`);
|
|
128
|
+
if (r.kind === "ok") reachable.add(r.key);
|
|
129
|
+
if (href && href.startsWith("/")) links.push(href);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
structure[rel] = { h1: (document.querySelector("h1")?.textContent || "").trim().slice(0, 80), outline: hs.join(""), mains, readerOk, internalLinks: links.sort() };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// orphans — served pages reachable from nothing (home + error page are legit roots)
|
|
136
|
+
const errorKey = canon("/" + ERROR_PAGE);
|
|
137
|
+
for (const key of servedCanon) {
|
|
138
|
+
if (key === "/" || key === errorKey) continue;
|
|
139
|
+
if (!reachable.has(key)) warn(`orphan: ${key} is not linked from any page`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const json = JSON.stringify(Object.fromEntries(Object.keys(structure).sort().map((k) => [k, structure[k]])), null, 2) + "\n";
|
|
143
|
+
const digest = createHash("sha256").update(json).digest("hex").slice(0, 12);
|
|
144
|
+
// Baseline lives in the CONSUMER (default: next to this script), so a vendored,
|
|
145
|
+
// hash-pinned copy of the kit isn't mutated by --check. Override with $STRUCTURE_BASELINE.
|
|
146
|
+
const outPath = process.env.STRUCTURE_BASELINE
|
|
147
|
+
? resolve(process.env.STRUCTURE_BASELINE)
|
|
148
|
+
: join(dirname(fileURLToPath(import.meta.url)), "structure.json");
|
|
149
|
+
|
|
150
|
+
if (CHECK) {
|
|
151
|
+
const current = (await exists(outPath)) ? await readFile(outPath, "utf8") : "";
|
|
152
|
+
if (current !== json) { console.error("✗ structure.json is stale — regenerate and commit."); errors++; }
|
|
153
|
+
} else {
|
|
154
|
+
await writeFile(outPath, json);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.log(`structure-audit: ${pages.length} pages · sha256:${digest} · ${errors} error(s) · ${warns} warn(s)`);
|
|
158
|
+
if (errors) { console.error(`✗ structure-audit failed (${errors})`); process.exit(1); }
|
|
159
|
+
console.log("✓ structure-audit passed");
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bounded-systems/structure-audit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deterministic whole-page structure gate: reader survivability + outline + landmarks + internal link-graph.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "structure-audit": "./audit.mjs" },
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@mozilla/readability": "^0.5.0",
|
|
9
|
+
"linkedom": "^0.18.0"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT"
|
|
12
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @bounded-systems/verify
|
|
2
|
+
|
|
3
|
+
Standalone, out-of-page verifier. Given a deployed site URL (or a local `dist/`
|
|
4
|
+
directory) carrying a published Sigstore **bundle**, it proves **out of band** that
|
|
5
|
+
the served bytes are exactly what an allowed identity built and logged — offline,
|
|
6
|
+
no trust in the page itself.
|
|
7
|
+
|
|
8
|
+
What it checks, in-process via [`sigstore-js`](https://github.com/sigstore/sigstore-js):
|
|
9
|
+
|
|
10
|
+
- signature over the whole-site manifest;
|
|
11
|
+
- certificate chain to the Fulcio root (bundled trusted root — **no network**);
|
|
12
|
+
- Rekor inclusion proof (offline; not the deprecated Rekor query API);
|
|
13
|
+
- issuer + certificate SAN matched against the site's declared builder identity;
|
|
14
|
+
- then re-hashes every served file against the signed manifest, tolerating known,
|
|
15
|
+
named CDN edge transforms (the signed body must still be intact underneath).
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
# against a deployed site
|
|
21
|
+
deno run -A jsr:@bounded-systems/verify https://bounded.tools
|
|
22
|
+
|
|
23
|
+
# against a local build directory
|
|
24
|
+
deno run -A jsr:@bounded-systems/verify ./dist
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Inputs are read from the target: `provenance.json` (the builder identity — nothing
|
|
28
|
+
is hardcoded), the whole-site `site.sha256` manifest, and the `.sigstore.json`
|
|
29
|
+
bundle. Exit code `0` on success, `1` on any verification failure.
|
|
30
|
+
|
|
31
|
+
## Provenance
|
|
32
|
+
|
|
33
|
+
This is a **vendored** copy of the Sigstore verifier, kept here so sites can pull it
|
|
34
|
+
into a hermetic build. The **canonical, published** source of the
|
|
35
|
+
[`@bounded-systems/verify`](https://jsr.io/@bounded-systems/verify) JSR package now
|
|
36
|
+
lives in its own repo, [`bounded-systems/verify`](https://github.com/bounded-systems/verify)
|
|
37
|
+
— that repo owns the package manifest and the keyless GitHub Actions OIDC release.
|
|
38
|
+
The copy here is kept byte-for-byte in sync with it; cut releases there, not here.
|
|
39
|
+
|
|
40
|
+
MIT
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// integrity · verify — the standalone, out-of-page verifier (the "real" one from
|
|
3
|
+
// integrity/verifier-decision.md). Takes a URL (or local dist) and proves, OUT OF
|
|
4
|
+
// BAND, that the served site is exactly what an allowed identity built and logged.
|
|
5
|
+
//
|
|
6
|
+
// node integrity/verify/verify.mjs https://bounded.tools
|
|
7
|
+
// node integrity/verify/verify.mjs ./dist
|
|
8
|
+
//
|
|
9
|
+
// Unlike the zero-dep verify-site.mjs (which shells out to cosign and SKIPS the
|
|
10
|
+
// signature step when cosign is absent), this verifies the published Sigstore
|
|
11
|
+
// BUNDLE cryptographically IN-PROCESS via sigstore-js:
|
|
12
|
+
// - signature over the whole-site manifest
|
|
13
|
+
// - certificate chain to the Fulcio root (bundled trusted root — no network)
|
|
14
|
+
// - Rekor inclusion proof (offline; NOT the deprecated Rekor query API)
|
|
15
|
+
// - issuer enforced by sigstore-js; the cert SAN regex-matched here (cosign-style)
|
|
16
|
+
// then re-hashes every served file against the signed manifest (tolerating known,
|
|
17
|
+
// named CDN edge transforms — the signed body must still be intact underneath).
|
|
18
|
+
//
|
|
19
|
+
// Why a bundle, not a Rekor query: Rekor v2 removed get-by-index/leaf-hash, so the
|
|
20
|
+
// query path is a dead end. The bundle we publish carries its own inclusion proof,
|
|
21
|
+
// so verification is offline and survives the v2 transition. SRI-pinnable and
|
|
22
|
+
// npm-publishable (with its own Sigstore provenance) — the same core a browser
|
|
23
|
+
// extension or CI policy would consume.
|
|
24
|
+
import { readFile } from "node:fs/promises";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { verify as sigstoreVerify } from "sigstore";
|
|
28
|
+
|
|
29
|
+
const target = process.argv[2];
|
|
30
|
+
if (!target) { console.error("usage: verify <https://site | ./dist>"); process.exit(2); }
|
|
31
|
+
const isUrl = /^https?:\/\//.test(target);
|
|
32
|
+
const base = isUrl ? target.replace(/\/$/, "") : target;
|
|
33
|
+
const ISSUER = "https://token.actions.githubusercontent.com";
|
|
34
|
+
const sha256hex = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
35
|
+
|
|
36
|
+
async function load(path) {
|
|
37
|
+
if (isUrl) {
|
|
38
|
+
const res = await fetch(`${base}/${path}`, { cache: "no-store" });
|
|
39
|
+
if (!res.ok) throw new Error(`GET /${path} → ${res.status}`);
|
|
40
|
+
return Buffer.from(await res.arrayBuffer());
|
|
41
|
+
}
|
|
42
|
+
return readFile(join(base, path));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Known, named CDN edge transforms (see verify-site.mjs): a legitimate edge may
|
|
46
|
+
// rewrite a response; if stripping a NAMED transform restores the signed hash, the
|
|
47
|
+
// body is intact and only the edge added markup. Anything else is a real mismatch.
|
|
48
|
+
const KNOWN_EDGE_INJECTIONS = [
|
|
49
|
+
{ name: "cloudflare-js-detections", applies: (p) => /\.html$/.test(p),
|
|
50
|
+
re: /<script\b[^>]*>(?:(?!<\/script>)[\s\S])*?(?:__CF\$cv\$params|cdn-cgi\/challenge-platform)(?:(?!<\/script>)[\s\S])*?<\/script>/g },
|
|
51
|
+
{ name: "cloudflare-managed-robots", applies: (p) => /(^|\/)robots\.txt$/.test(p),
|
|
52
|
+
re: /^[\s\S]*?# END Cloudflare Managed Content\n+/ },
|
|
53
|
+
];
|
|
54
|
+
const stripKnownEdge = (buf, path) => {
|
|
55
|
+
let s = buf.toString("utf8"); const hit = [];
|
|
56
|
+
for (const r of KNOWN_EDGE_INJECTIONS) { if (!r.applies(path)) continue; const n = s.replace(r.re, ""); if (n !== s) { hit.push(r.name); s = n; } }
|
|
57
|
+
return { stripped: Buffer.from(s, "utf8"), hit };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let failures = 0;
|
|
61
|
+
const log = (ok, msg) => { console.log(`${ok ? "✓" : "✗"} ${msg}`); if (!ok) failures++; };
|
|
62
|
+
|
|
63
|
+
// load provenance + the signed manifest + its bundle
|
|
64
|
+
const provenance = JSON.parse((await load("provenance.json")).toString("utf8"));
|
|
65
|
+
const repo = provenance?.builder?.repository || "";
|
|
66
|
+
const identityRe = `^https://github.com/${repo}/`;
|
|
67
|
+
const manifest = await load("site.sha256");
|
|
68
|
+
const bundle = JSON.parse((await load("site.sha256.sigstore.json")).toString("utf8"));
|
|
69
|
+
|
|
70
|
+
console.log(`· site: ${base}`);
|
|
71
|
+
console.log(`· builder: ${repo} @ ${(provenance?.builder?.commit || "").slice(0, 7)} · rekor#${provenance?.siteManifest?.rekorLogIndex ?? "?"}`);
|
|
72
|
+
if (provenance?.builtAt) {
|
|
73
|
+
const ms = Date.now() - Date.parse(provenance.builtAt);
|
|
74
|
+
const age = Number.isFinite(ms) ? (ms < 36e5 ? `${Math.round(ms / 6e4)}m` : ms < 864e5 ? `${Math.round(ms / 36e5)}h` : `${Math.round(ms / 864e5)}d`) : "?";
|
|
75
|
+
console.log(`· built: ${provenance.builtAt} (${age} ago)`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 1: cryptographic bundle verification, in-process, offline
|
|
79
|
+
try {
|
|
80
|
+
const signer = await sigstoreVerify(bundle, manifest, { certificateIssuer: ISSUER });
|
|
81
|
+
const san = signer?.identity?.subjectAlternativeName || "";
|
|
82
|
+
if (!new RegExp(identityRe).test(san)) throw new Error(`cert identity ${san} !~ ${identityRe}`);
|
|
83
|
+
log(true, `bundle verified — signature + Fulcio cert + Rekor inclusion (offline), identity ${san}`);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
log(false, `bundle verification FAILED: ${e.message}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2: byte-for-byte integrity of every served file (edge-transform tolerant)
|
|
89
|
+
const entries = manifest.toString("utf8").trim().split("\n").filter(Boolean).map((l) => {
|
|
90
|
+
const i = l.indexOf(" "); return { hash: l.slice(0, i), path: l.slice(i + 2) };
|
|
91
|
+
});
|
|
92
|
+
let mismatches = 0, edged = 0; const edgeNames = new Set();
|
|
93
|
+
for (const { hash, path } of entries) {
|
|
94
|
+
try {
|
|
95
|
+
const bytes = await load(path);
|
|
96
|
+
if (sha256hex(bytes) === hash) continue;
|
|
97
|
+
if (isUrl) {
|
|
98
|
+
const { stripped, hit } = stripKnownEdge(bytes, path);
|
|
99
|
+
if (hit.length && sha256hex(stripped) === hash) { edged++; hit.forEach((n) => edgeNames.add(n)); continue; }
|
|
100
|
+
}
|
|
101
|
+
mismatches++; console.log(` ✗ ${path}: ${sha256hex(bytes).slice(0, 12)}… ≠ ${hash.slice(0, 12)}…`);
|
|
102
|
+
} catch (e) { mismatches++; console.log(` ✗ ${path}: ${e.message}`); }
|
|
103
|
+
}
|
|
104
|
+
log(mismatches === 0, `${entries.length} served files match the signed manifest${mismatches ? ` (${mismatches} mismatch)` : edged ? ` (${edged} after stripping known edge injections: ${[...edgeNames].join(", ")})` : ""}`);
|
|
105
|
+
|
|
106
|
+
console.log(failures ? `\n✗ verification FAILED (${failures})` : `\n✓ verified: ${base} is exactly what ${repo} built and logged`);
|
|
107
|
+
process.exit(failures ? 1 : 0);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// integrity · verify-site — independently verify a deployed site against its own
|
|
3
|
+
// signed, whole-site provenance. The honest counterpart to the in-page badge:
|
|
4
|
+
// run this from OUTSIDE the page (your shell, CI, an extension) so trust doesn't
|
|
5
|
+
// come from anything the page itself computes.
|
|
6
|
+
//
|
|
7
|
+
// node integrity/verify-site.mjs https://bounded.tools
|
|
8
|
+
// node integrity/verify-site.mjs ./dist # a local build dir
|
|
9
|
+
//
|
|
10
|
+
// What it does:
|
|
11
|
+
// 1. loads /provenance.json (identity, Rekor index, OCI ref)
|
|
12
|
+
// 2. loads /site.sha256 + /site.sha256.sigstore.json (the signed manifest)
|
|
13
|
+
// 3. if `cosign` is on PATH: `cosign verify-blob` the manifest against the
|
|
14
|
+
// builder's OIDC identity + Rekor (the real cryptographic check). Otherwise
|
|
15
|
+
// prints the exact recipe and marks the signature step SKIPPED.
|
|
16
|
+
// 4. re-hashes every file the manifest lists (fetched live, or read locally) and
|
|
17
|
+
// checks it byte-for-byte — integrity of the actual served bytes. In live-URL
|
|
18
|
+
// mode it tolerates ONE known, benign CDN transform (Cloudflare's JS-detection
|
|
19
|
+
// beacon injected into HTML): if stripping it makes the file match, that's a
|
|
20
|
+
// pass-with-note, since the signed body is intact and only the edge added a
|
|
21
|
+
// named script. The edge-independent ground truth is the signed OCI artifact.
|
|
22
|
+
// Exit 0 iff every checked file matches (directly or after a known edge strip) AND
|
|
23
|
+
// (cosign verified OR was skipped with a printed recipe). Exit 1 on any real
|
|
24
|
+
// mismatch or a failed cosign verify.
|
|
25
|
+
//
|
|
26
|
+
// Dependency-free: node:crypto + fetch + cosign (optional, shelled out). Designed
|
|
27
|
+
// to later publish to npm with Sigstore provenance and be consumed by a CLI, a
|
|
28
|
+
// browser extension, or CI policy.
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { spawnSync } from "node:child_process";
|
|
31
|
+
import { readFile, mkdtemp, writeFile, access } from "node:fs/promises";
|
|
32
|
+
import { tmpdir } from "node:os";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
|
|
35
|
+
const target = process.argv[2];
|
|
36
|
+
if (!target) {
|
|
37
|
+
console.error("usage: verify-site <https://site | ./dist>");
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
const isUrl = /^https?:\/\//.test(target);
|
|
41
|
+
const base = isUrl ? target.replace(/\/$/, "") : target;
|
|
42
|
+
const sha256hex = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
43
|
+
|
|
44
|
+
async function load(path) {
|
|
45
|
+
if (isUrl) {
|
|
46
|
+
const res = await fetch(`${base}/${path}`);
|
|
47
|
+
if (!res.ok) throw new Error(`GET /${path} → ${res.status}`);
|
|
48
|
+
return Buffer.from(await res.arrayBuffer());
|
|
49
|
+
}
|
|
50
|
+
return readFile(join(base, path));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let failures = 0;
|
|
54
|
+
const log = (ok, msg) => { console.log(`${ok ? "✓" : "✗"} ${msg}`); if (!ok) failures++; };
|
|
55
|
+
|
|
56
|
+
// 1 + 2: provenance + signed manifest
|
|
57
|
+
const provenance = JSON.parse((await load("provenance.json")).toString("utf8"));
|
|
58
|
+
const repo = provenance?.builder?.repository || "";
|
|
59
|
+
const manifest = (await load("site.sha256")).toString("utf8");
|
|
60
|
+
const bundle = (await load("site.sha256.sigstore.json")).toString("utf8");
|
|
61
|
+
console.log(`· site: ${base}`);
|
|
62
|
+
console.log(`· builder: ${repo} @ ${(provenance?.builder?.commit || "").slice(0, 7)} · rekor#${provenance?.siteManifest?.rekorLogIndex ?? "?"}`);
|
|
63
|
+
if (provenance?.builtAt) {
|
|
64
|
+
const ms = Date.now() - Date.parse(provenance.builtAt);
|
|
65
|
+
const age = Number.isFinite(ms)
|
|
66
|
+
? (ms < 36e5 ? `${Math.round(ms / 6e4)}m` : ms < 864e5 ? `${Math.round(ms / 36e5)}h` : `${Math.round(ms / 864e5)}d`)
|
|
67
|
+
: "?";
|
|
68
|
+
console.log(`· built: ${provenance.builtAt} (${age} ago) — authoritative time is the Rekor entry's integratedTime at /rekor`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3: signature (cosign if available)
|
|
72
|
+
const cosign = spawnSync("cosign", ["version"], { stdio: "ignore" });
|
|
73
|
+
if (cosign.status === 0) {
|
|
74
|
+
const dir = await mkdtemp(join(tmpdir(), "verify-site-"));
|
|
75
|
+
await writeFile(join(dir, "site.sha256"), manifest);
|
|
76
|
+
await writeFile(join(dir, "site.sha256.sigstore.json"), bundle);
|
|
77
|
+
const r = spawnSync("cosign", [
|
|
78
|
+
"verify-blob",
|
|
79
|
+
"--bundle", join(dir, "site.sha256.sigstore.json"),
|
|
80
|
+
"--certificate-identity-regexp", `^https://github.com/${repo}/`,
|
|
81
|
+
"--certificate-oidc-issuer", "https://token.actions.githubusercontent.com",
|
|
82
|
+
join(dir, "site.sha256"),
|
|
83
|
+
], { encoding: "utf8" });
|
|
84
|
+
log(r.status === 0, `cosign verify-blob (identity ^github.com/${repo}/ + Rekor)`);
|
|
85
|
+
if (r.status !== 0) console.error((r.stderr || "").trim());
|
|
86
|
+
} else {
|
|
87
|
+
console.log("· cosign not found — signature check SKIPPED. Verify it out-of-band:");
|
|
88
|
+
console.log(` ${(provenance?.siteManifest?.verify || "").split("\n").join("\n ")}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 4: byte-for-byte integrity of every listed file.
|
|
92
|
+
//
|
|
93
|
+
// Honesty caveat for live-URL mode: a CDN may inject markup into HTML responses,
|
|
94
|
+
// so served HTML can differ from the signed bytes on a perfectly legitimate deploy.
|
|
95
|
+
// Cloudflare's "JavaScript Detections" adds a bot beacon (`__CF$cv$params` /
|
|
96
|
+
// `cdn-cgi/challenge-platform`) before </body>. We detect that ONE known, benign
|
|
97
|
+
// transform, strip it, and re-hash: if it then matches, the signed body is intact
|
|
98
|
+
// and only the edge added a named script — reported as a PASS WITH NOTE, not a
|
|
99
|
+
// silent pass and not a false alarm. Anything else is a real mismatch. The
|
|
100
|
+
// edge-independent ground truth is the signed OCI artifact (see provenance.json
|
|
101
|
+
// `ociArtifact.verify`), which no CDN sits in front of.
|
|
102
|
+
const KNOWN_EDGE_INJECTIONS = [
|
|
103
|
+
{
|
|
104
|
+
// Cloudflare "JavaScript Detections": one <script> naming the challenge beacon,
|
|
105
|
+
// injected before </body> on HTML responses.
|
|
106
|
+
name: "cloudflare-js-detections",
|
|
107
|
+
applies: (p) => /\.html$/.test(p),
|
|
108
|
+
re: /<script\b[^>]*>(?:(?!<\/script>)[\s\S])*?(?:__CF\$cv\$params|cdn-cgi\/challenge-platform)(?:(?!<\/script>)[\s\S])*?<\/script>/g,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
// Cloudflare "Managed robots.txt" / AI-crawler control: a managed block prepended
|
|
112
|
+
// to robots.txt; our signed file survives intact as the tail after the END marker.
|
|
113
|
+
name: "cloudflare-managed-robots",
|
|
114
|
+
applies: (p) => /(^|\/)robots\.txt$/.test(p),
|
|
115
|
+
re: /^[\s\S]*?# END Cloudflare Managed Content\n+/,
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
const stripKnownEdge = (buf, path) => {
|
|
119
|
+
let s = buf.toString("utf8");
|
|
120
|
+
const hit = [];
|
|
121
|
+
for (const rule of KNOWN_EDGE_INJECTIONS) {
|
|
122
|
+
if (!rule.applies(path)) continue;
|
|
123
|
+
const next = s.replace(rule.re, "");
|
|
124
|
+
if (next !== s) { hit.push(rule.name); s = next; }
|
|
125
|
+
}
|
|
126
|
+
return { stripped: Buffer.from(s, "utf8"), hit };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const entries = manifest.trim().split("\n").filter(Boolean).map((l) => {
|
|
130
|
+
const [hash, ...rest] = l.split(" ");
|
|
131
|
+
return { hash, path: rest.join(" ") };
|
|
132
|
+
});
|
|
133
|
+
let mismatches = 0;
|
|
134
|
+
let edgeAdjusted = 0;
|
|
135
|
+
const edgeNames = new Set();
|
|
136
|
+
for (const { hash, path } of entries) {
|
|
137
|
+
try {
|
|
138
|
+
const bytes = await load(path);
|
|
139
|
+
if (sha256hex(bytes) === hash) continue;
|
|
140
|
+
// mismatch — in live mode, try removing known, named edge injections before failing
|
|
141
|
+
if (isUrl) {
|
|
142
|
+
const { stripped, hit } = stripKnownEdge(bytes, path);
|
|
143
|
+
if (hit.length && sha256hex(stripped) === hash) {
|
|
144
|
+
edgeAdjusted++; hit.forEach((n) => edgeNames.add(n));
|
|
145
|
+
console.log(` ~ ${path}: matches after removing edge injection (${hit.join(", ")})`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
mismatches++;
|
|
150
|
+
console.log(` ✗ ${path}: ${sha256hex(bytes).slice(0, 12)}… ≠ ${hash.slice(0, 12)}…`);
|
|
151
|
+
} catch (e) { mismatches++; console.log(` ✗ ${path}: ${e.message}`); }
|
|
152
|
+
}
|
|
153
|
+
const note = edgeAdjusted ? ` (${edgeAdjusted} matched only after stripping a known edge injection: ${[...edgeNames].join(", ")})` : "";
|
|
154
|
+
log(mismatches === 0, `${entries.length} files match the signed manifest${mismatches ? ` (${mismatches} mismatch)` : note}`);
|
|
155
|
+
if (edgeAdjusted && mismatches === 0) {
|
|
156
|
+
console.log(` · the signed body is intact; your CDN rewrites these files on serve. For byte-exact serving, disable it, or verify the signed OCI artifact (edge-independent).`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log(failures ? `\n✗ verification FAILED (${failures})` : `\n✓ verified: served bytes match this build's signed provenance`);
|
|
160
|
+
process.exit(failures ? 1 : 0);
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// lib/config — tiny, dependency-free helpers for injecting site values into the
|
|
2
|
+
// kit's tools via env vars / CLI args. NOTHING here is site-specific; every value
|
|
3
|
+
// has a neutral default and is overridden by the consumer.
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Read an env var with a fallback. Empty string counts as "set" only if keepEmpty.
|
|
7
|
+
export const env = (name, fallback = undefined, { keepEmpty = false } = {}) => {
|
|
8
|
+
const v = process.env[name];
|
|
9
|
+
if (v == null) return fallback;
|
|
10
|
+
if (v === "" && !keepEmpty) return fallback;
|
|
11
|
+
return v;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Parse a comma-separated env var into a trimmed, non-empty string[].
|
|
15
|
+
export const envList = (name, fallback = []) => {
|
|
16
|
+
const v = process.env[name];
|
|
17
|
+
if (v == null || v === "") return fallback;
|
|
18
|
+
return v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Parse a numeric env var; fall back if missing/NaN.
|
|
22
|
+
export const envNum = (name, fallback) => {
|
|
23
|
+
const v = process.env[name];
|
|
24
|
+
if (v == null || v === "") return fallback;
|
|
25
|
+
const n = Number(v);
|
|
26
|
+
return Number.isFinite(n) ? n : fallback;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Resolve a "dist" / build-output dir from (argv slot, then $DIST, then default),
|
|
30
|
+
// absolute against the current working directory.
|
|
31
|
+
export const resolveDist = ({ arg, envName = "DIST", fallback = "dist" } = {}) =>
|
|
32
|
+
resolve(arg || process.env[envName] || fallback);
|
|
33
|
+
|
|
34
|
+
// Positional argv (after `node script.mjs`), excluding --flags.
|
|
35
|
+
export const positionals = () => process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
36
|
+
export const hasFlag = (flag) => process.argv.includes(flag);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Minimal, dependency-free JSON Schema validator — enough for the keywords the
|
|
2
|
+
// kit's contracts use: type, properties, items, required, enum, pattern,
|
|
3
|
+
// additionalProperties (bool/schema), and $ref into #/definitions. `format` is
|
|
4
|
+
// accepted but not enforced (advisory). Returns an array of error strings (empty
|
|
5
|
+
// = valid). No-dependency, hand-rolled — usable in hermetic CI.
|
|
6
|
+
//
|
|
7
|
+
// Site-agnostic: a pure function (schema, data) → string[]. Extracted verbatim
|
|
8
|
+
// from bdelanghe/site/schema-validate.mjs.
|
|
9
|
+
|
|
10
|
+
const typeOk = (t, v) => {
|
|
11
|
+
switch (t) {
|
|
12
|
+
case "string": return typeof v === "string";
|
|
13
|
+
case "number": return typeof v === "number";
|
|
14
|
+
case "integer": return Number.isInteger(v);
|
|
15
|
+
case "boolean": return typeof v === "boolean";
|
|
16
|
+
case "object": return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
17
|
+
case "array": return Array.isArray(v);
|
|
18
|
+
case "null": return v === null;
|
|
19
|
+
default: return true;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const kindOf = (v) => Array.isArray(v) ? "array" : v === null ? "null" : typeof v;
|
|
23
|
+
|
|
24
|
+
export function validateSchema(rootSchema, data) {
|
|
25
|
+
const errors = [];
|
|
26
|
+
const resolve = (schema) => {
|
|
27
|
+
let s = schema, guard = 0;
|
|
28
|
+
while (s && s.$ref && guard++ < 16) {
|
|
29
|
+
const path = s.$ref.replace(/^#\//, "").split("/");
|
|
30
|
+
s = path.reduce((node, p) => node?.[p], rootSchema) ?? {};
|
|
31
|
+
}
|
|
32
|
+
return s;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const walk = (schema, v, path) => {
|
|
36
|
+
schema = resolve(schema);
|
|
37
|
+
if (!schema || typeof schema !== "object") return;
|
|
38
|
+
const at = path || "/";
|
|
39
|
+
|
|
40
|
+
if (schema.type) {
|
|
41
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
42
|
+
if (!types.some((t) => typeOk(t, v))) {
|
|
43
|
+
errors.push(`${at}: expected ${types.join("|")}, got ${kindOf(v)}`);
|
|
44
|
+
return; // type wrong — deeper checks would be noise
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (schema.enum && !schema.enum.some((e) => e === v)) errors.push(`${at}: ${JSON.stringify(v)} not in enum`);
|
|
48
|
+
if (typeof v === "string" && schema.pattern && !new RegExp(schema.pattern).test(v)) {
|
|
49
|
+
errors.push(`${at}: "${v}" does not match pattern ${schema.pattern}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeOk("object", v)) {
|
|
53
|
+
const props = schema.properties || {};
|
|
54
|
+
for (const req of schema.required || []) if (!(req in v)) errors.push(`${at}: missing required "${req}"`);
|
|
55
|
+
for (const [k, val] of Object.entries(v)) {
|
|
56
|
+
if (props[k]) walk(props[k], val, `${at === "/" ? "" : at}/${k}`);
|
|
57
|
+
else if (schema.additionalProperties === false) errors.push(`${at}: unexpected property "${k}"`);
|
|
58
|
+
else if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
59
|
+
walk(schema.additionalProperties, val, `${at === "/" ? "" : at}/${k}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (typeOk("array", v) && schema.items) v.forEach((item, i) => walk(schema.items, item, `${at === "/" ? "" : at}[${i}]`));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
walk(rootSchema, data, "");
|
|
67
|
+
return errors;
|
|
68
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bounded-systems/conformance-kit",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Standalone, site-agnostic web-conformance toolkit: integrity tooling + build gates + provenance generators, all parameterized so a site vendors one kit instead of duplicating scripts.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/bounded-systems/conformance-kit.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"ck-gen-sitemanifest": "./integrity/gen-sitemanifest.mjs",
|
|
13
|
+
"ck-gen-provenance": "./integrity/gen-provenance.mjs",
|
|
14
|
+
"ck-verify-site": "./integrity/verify-site.mjs",
|
|
15
|
+
"ck-http-probe": "./integrity/http-probe.mjs",
|
|
16
|
+
"ck-structure-audit": "./integrity/structure-audit/audit.mjs",
|
|
17
|
+
"ck-gen-sbom": "./gates/sbom/gen-sbom.mjs",
|
|
18
|
+
"ck-check-sbom": "./gates/sbom/check-sbom.mjs",
|
|
19
|
+
"ck-shacl-runner": "./gates/shacl-runner.mjs",
|
|
20
|
+
"ck-seo-gate": "./gates/seo-gate.mjs",
|
|
21
|
+
"ck-axe-gate": "./gates/axe-gate.mjs",
|
|
22
|
+
"ck-vuln-gate": "./gates/vuln-gate.mjs",
|
|
23
|
+
"ck-html-validator-gate": "./gates/html-validator-gate.mjs",
|
|
24
|
+
"ck-baseline-gate": "./gates/baseline-gate.mjs",
|
|
25
|
+
"ck-readability-gate": "./gates/readability-gate.mjs",
|
|
26
|
+
"ck-commonmark-runner": "./gates/commonmark-runner.mjs",
|
|
27
|
+
"ck-gen-cid": "./generators/gen-cid.mjs",
|
|
28
|
+
"ck-gen-identity": "./generators/gen-identity.mjs",
|
|
29
|
+
"ck-gen-snapshots": "./generators/gen-snapshots.mjs"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node test/run.mjs"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"gates",
|
|
36
|
+
"generators",
|
|
37
|
+
"integrity",
|
|
38
|
+
"emitters",
|
|
39
|
+
"lib",
|
|
40
|
+
"provenance.json",
|
|
41
|
+
"vendor.example.json",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"exports": {
|
|
46
|
+
"./package.json": "./package.json",
|
|
47
|
+
"./gates/*": "./gates/*",
|
|
48
|
+
"./gates/conformance/*": "./gates/conformance/*",
|
|
49
|
+
"./generators/*": "./generators/*",
|
|
50
|
+
"./integrity/*": "./integrity/*",
|
|
51
|
+
"./emitters/*": "./emitters/*",
|
|
52
|
+
"./lib/*": "./lib/*"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@mozilla/readability": "^0.5.0",
|
|
59
|
+
"@zazuko/env-node": "^2.1.5",
|
|
60
|
+
"axe-core": "^4.10.0",
|
|
61
|
+
"jsonld": "^9.0.0",
|
|
62
|
+
"linkedom": "^0.18.0",
|
|
63
|
+
"n3": "^1.17.3",
|
|
64
|
+
"rdf-validate-shacl": "^0.5.10",
|
|
65
|
+
"sigstore": "^5.0.0",
|
|
66
|
+
"stylelint": "^17.14.0",
|
|
67
|
+
"stylelint-plugin-use-baseline": "^1.4.4",
|
|
68
|
+
"turndown": "^7.2.4",
|
|
69
|
+
"vnu-jar": "^26.6.24"
|
|
70
|
+
}
|
|
71
|
+
}
|