@wcag-checkr/ci 1.0.0-rc.13
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/README.md +135 -0
- package/dist/assets/ErrorBoundary-BPz4qckm.js +524 -0
- package/dist/assets/_commonjsHelpers-Cpj98o6Y.js +1 -0
- package/dist/assets/ai-usage-log-DFkwAfmW.js +1 -0
- package/dist/assets/content-script.ts-D7yXcBUr.js +181 -0
- package/dist/assets/content-script.ts-loader-Cn8Y9Xod.js +13 -0
- package/dist/assets/crash-reporter-wxu43qbG.js +4 -0
- package/dist/assets/devtools-panel-D2fL4guz.js +1 -0
- package/dist/assets/devtools.html-DQBohI9U.js +1 -0
- package/dist/assets/diff-D4sCAdXf.js +1 -0
- package/dist/assets/forensic-log-B3iX62mE.js +129 -0
- package/dist/assets/main-CqDdt0Iq.js +6 -0
- package/dist/assets/main-DyQfCbPM.js +1 -0
- package/dist/assets/modulepreload-polyfill-B5Qt9EMX.js +1 -0
- package/dist/assets/options.html-jfjpxZBp.js +1 -0
- package/dist/assets/preload-helper-D7HrI6pR.js +1 -0
- package/dist/assets/reflow-analyzer-DNgBX8N_.js +1 -0
- package/dist/assets/service-worker.ts-DaHvU8nE.js +715 -0
- package/dist/assets/side-panel.html-DW1tssqQ.js +1 -0
- package/dist/assets/site-report-renderer-JH44v2hK.js +147 -0
- package/dist/assets/state-DnzwwNxZ.js +1 -0
- package/dist/assets/styles-DP9v_aMy.css +1 -0
- package/dist/assets/styles-kHMb1Lda.js +84 -0
- package/dist/devtools/devtools.html +11 -0
- package/dist/devtools/panel.html +20 -0
- package/dist/fonts/mona-sans-variable.woff2 +0 -0
- package/dist/icons/icon-128.png +0 -0
- package/dist/icons/icon-16.png +0 -0
- package/dist/icons/icon-32.png +0 -0
- package/dist/icons/icon-48.png +0 -0
- package/dist/manifest.json +70 -0
- package/dist/options/options.html +19 -0
- package/dist/service-worker-loader.js +1 -0
- package/dist/side-panel/App.tsx +174 -0
- package/dist/side-panel/README.md +57 -0
- package/dist/side-panel/audit-launcher.test.ts +56 -0
- package/dist/side-panel/audit-launcher.ts +65 -0
- package/dist/side-panel/format-component-id.test.ts +89 -0
- package/dist/side-panel/format-component-id.ts +40 -0
- package/dist/side-panel/github-issue.test.ts +102 -0
- package/dist/side-panel/github-issue.ts +66 -0
- package/dist/side-panel/jira-issue.ts +64 -0
- package/dist/side-panel/main.tsx +19 -0
- package/dist/side-panel/side-panel.html +21 -0
- package/dist/side-panel/store.ts +264 -0
- package/dist/side-panel/styles.css +16 -0
- package/dist/side-panel/wire-messaging.test.ts +202 -0
- package/dist/side-panel/wire-messaging.ts +285 -0
- package/package.json +39 -0
- package/wcagcheckr-ci.mjs +559 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// wcagcheckr CI runner — drives the wcagcheckr Chrome extension via Playwright
|
|
3
|
+
// to run full-page accessibility audits in a headless-ish CI environment.
|
|
4
|
+
//
|
|
5
|
+
// Why this exists: competing tools (axe DevTools Pro, Siteimprove) ship CI
|
|
6
|
+
// runners as paid features. We ship one for free. Reuses the same audit
|
|
7
|
+
// engine + state matrix the extension uses interactively, so the CI run
|
|
8
|
+
// matches what a developer sees in the side panel.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// wcagcheckr-ci audit <url> [options]
|
|
12
|
+
//
|
|
13
|
+
// Options:
|
|
14
|
+
// --format <json|sarif|junit> Output format (default: json)
|
|
15
|
+
// --output <file> Write output to file (default: stdout)
|
|
16
|
+
// --threshold <none|critical|serious|moderate|minor>
|
|
17
|
+
// Exit non-zero if any violations at or above
|
|
18
|
+
// this severity (default: serious)
|
|
19
|
+
// --extension-dir <path> Path to a built dist/ (default: bundled)
|
|
20
|
+
// --timeout <ms> Audit timeout (default: 120000)
|
|
21
|
+
// --quiet Suppress progress output to stderr
|
|
22
|
+
//
|
|
23
|
+
// Exit codes:
|
|
24
|
+
// 0 — success, threshold not exceeded
|
|
25
|
+
// 1 — threshold exceeded (CI failure signal)
|
|
26
|
+
// 2 — runtime error (extension didn't load, target unreachable, etc.)
|
|
27
|
+
|
|
28
|
+
import { chromium } from 'playwright';
|
|
29
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { resolve, dirname, join } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
33
|
+
|
|
34
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
35
|
+
// Published package layout: cli/dist/ is bundled inside the package root
|
|
36
|
+
// (see scripts/prepare-cli-publish.mjs). Dev layout: dist/ is at the repo
|
|
37
|
+
// root, one directory up from this script. Prefer the bundled copy so
|
|
38
|
+
// `npx @wcag-checkr/ci` works without a separate dist install.
|
|
39
|
+
const BUNDLED_DIST = resolve(ROOT, 'dist');
|
|
40
|
+
const DEV_DIST = resolve(ROOT, '..', 'dist');
|
|
41
|
+
const DEFAULT_EXT_DIR = existsSync(BUNDLED_DIST) ? BUNDLED_DIST : DEV_DIST;
|
|
42
|
+
|
|
43
|
+
const SEVERITY_RANK = { critical: 0, serious: 1, moderate: 2, minor: 3 };
|
|
44
|
+
|
|
45
|
+
function parseArgs(argv) {
|
|
46
|
+
const args = {
|
|
47
|
+
command: null,
|
|
48
|
+
url: null,
|
|
49
|
+
inputFile: null,
|
|
50
|
+
format: 'json',
|
|
51
|
+
threshold: 'serious',
|
|
52
|
+
timeout: 120_000,
|
|
53
|
+
quiet: false,
|
|
54
|
+
license: null,
|
|
55
|
+
publicKeyUrl: 'https://api.wcagcheckr.com/v1/products/wcagcheckr/forensic/public-key',
|
|
56
|
+
};
|
|
57
|
+
for (let i = 2; i < argv.length; i++) {
|
|
58
|
+
const a = argv[i];
|
|
59
|
+
if (!args.command && a !== '--help' && !a.startsWith('--')) {
|
|
60
|
+
args.command = a;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (args.command === 'audit' && !args.url && !a.startsWith('--')) {
|
|
64
|
+
args.url = a;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (args.command === 'verify' && !args.inputFile && !a.startsWith('--')) {
|
|
68
|
+
args.inputFile = a;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (a === '--format') args.format = argv[++i];
|
|
72
|
+
else if (a === '--output') args.output = argv[++i];
|
|
73
|
+
else if (a === '--threshold') args.threshold = argv[++i];
|
|
74
|
+
else if (a === '--extension-dir') args.extensionDir = argv[++i];
|
|
75
|
+
else if (a === '--timeout') args.timeout = parseInt(argv[++i], 10);
|
|
76
|
+
else if (a === '--quiet') args.quiet = true;
|
|
77
|
+
else if (a === '--license') args.license = argv[++i];
|
|
78
|
+
else if (a === '--public-key-url') args.publicKeyUrl = argv[++i];
|
|
79
|
+
else if (a === '--help' || a === '-h') args.help = true;
|
|
80
|
+
}
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function printUsage() {
|
|
85
|
+
console.log(`wcagcheckr-ci — headless WCAG audit runner
|
|
86
|
+
|
|
87
|
+
Usage:
|
|
88
|
+
wcagcheckr-ci audit <url> [options]
|
|
89
|
+
wcagcheckr-ci verify <forensic-log.json> [options]
|
|
90
|
+
|
|
91
|
+
audit options:
|
|
92
|
+
--format <json|sarif|junit> Output format (default: json)
|
|
93
|
+
--output <file> Write output to file (default: stdout)
|
|
94
|
+
--threshold <none|critical|serious|moderate|minor>
|
|
95
|
+
Severity below which exit code stays 0 (default: serious)
|
|
96
|
+
--extension-dir <path> Path to a built dist/ (default: bundled)
|
|
97
|
+
--timeout <ms> Audit timeout (default: 120000)
|
|
98
|
+
--license <token> Activate this license token before auditing
|
|
99
|
+
(gates paid features like forensic anchoring)
|
|
100
|
+
--quiet Suppress progress output
|
|
101
|
+
|
|
102
|
+
verify options:
|
|
103
|
+
--public-key-url <url> Override the public-key endpoint (default:
|
|
104
|
+
https://api.wcagcheckr.com/.../forensic/public-key)
|
|
105
|
+
--quiet Only print per-entry FAIL lines + final summary
|
|
106
|
+
|
|
107
|
+
Exit codes:
|
|
108
|
+
0 ok 1 threshold exceeded / verification failed 2 runtime error`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const args = parseArgs(process.argv);
|
|
112
|
+
|
|
113
|
+
if (args.help || !args.command) {
|
|
114
|
+
printUsage();
|
|
115
|
+
process.exit(args.help ? 0 : 2);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const log = (...m) => {
|
|
119
|
+
if (!args.quiet) console.error('→', ...m);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// ─── verify subcommand ──────────────────────────────────────────────────────
|
|
123
|
+
//
|
|
124
|
+
// Reads a forensic-log JSON export from the extension's Forensic tab and
|
|
125
|
+
// validates each entry's identity hash + ed25519 server signature offline.
|
|
126
|
+
// RFC 3161 timestamp tokens are surfaced (with a verification command hint)
|
|
127
|
+
// rather than parsed here — full ASN.1 parsing is out-of-scope for the v0
|
|
128
|
+
// verifier; users run `openssl ts -verify` against FreeTSA's chain.
|
|
129
|
+
|
|
130
|
+
if (args.command === 'verify') {
|
|
131
|
+
if (!args.inputFile) {
|
|
132
|
+
console.error('✗ verify: missing input file path.\n');
|
|
133
|
+
printUsage();
|
|
134
|
+
process.exit(2);
|
|
135
|
+
}
|
|
136
|
+
await runVerify(args);
|
|
137
|
+
// runVerify exits the process itself.
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (args.command !== 'audit' || !args.url) {
|
|
141
|
+
console.error(`✗ Unknown command or missing URL.\n`);
|
|
142
|
+
printUsage();
|
|
143
|
+
process.exit(2);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const EXT_DIR = args.extensionDir ?? DEFAULT_EXT_DIR;
|
|
147
|
+
if (!existsSync(EXT_DIR)) {
|
|
148
|
+
console.error(`✗ Extension directory not found: ${EXT_DIR}`);
|
|
149
|
+
console.error(` Either run from a checkout with dist/ built, or pass --extension-dir.`);
|
|
150
|
+
process.exit(2);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!['json', 'sarif', 'junit'].includes(args.format)) {
|
|
154
|
+
console.error(`✗ Unknown format: ${args.format}. Must be json, sarif, or junit.`);
|
|
155
|
+
process.exit(2);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!['none', 'critical', 'serious', 'moderate', 'minor'].includes(args.threshold)) {
|
|
159
|
+
console.error(`✗ Unknown threshold: ${args.threshold}.`);
|
|
160
|
+
process.exit(2);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
log(`Loading extension from ${EXT_DIR}`);
|
|
164
|
+
log(`Auditing ${args.url} (threshold: ${args.threshold}, format: ${args.format})`);
|
|
165
|
+
|
|
166
|
+
// Chrome extensions cannot load in legacy `headless: true` mode, so we
|
|
167
|
+
// pass `--headless=new` as a Chromium arg (the modern headless mode that
|
|
168
|
+
// DOES support extensions). Playwright's `headless` option needs a real
|
|
169
|
+
// boolean — earlier code passed the string 'new' which crashed launch
|
|
170
|
+
// outright with "headless: expected boolean, got string".
|
|
171
|
+
const context = await chromium.launchPersistentContext('', {
|
|
172
|
+
headless: false,
|
|
173
|
+
viewport: { width: 1280, height: 800 },
|
|
174
|
+
args: [
|
|
175
|
+
`--disable-extensions-except=${EXT_DIR}`,
|
|
176
|
+
`--load-extension=${EXT_DIR}`,
|
|
177
|
+
'--headless=new',
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
let exitCode = 0;
|
|
182
|
+
try {
|
|
183
|
+
const [targetPage] = context.pages();
|
|
184
|
+
await targetPage.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
185
|
+
log('target page loaded');
|
|
186
|
+
|
|
187
|
+
let extId;
|
|
188
|
+
for (let i = 0; i < 40 && !extId; i++) {
|
|
189
|
+
const m = context.serviceWorkers()[0]?.url().match(/^chrome-extension:\/\/([^/]+)\//);
|
|
190
|
+
if (m) extId = m[1];
|
|
191
|
+
if (!extId) await new Promise((r) => setTimeout(r, 250));
|
|
192
|
+
}
|
|
193
|
+
if (!extId) throw new Error('extension SW did not register within 10s');
|
|
194
|
+
log(`extension loaded (id: ${extId.slice(0, 8)}…)`);
|
|
195
|
+
|
|
196
|
+
const sidePanel = await context.newPage();
|
|
197
|
+
await sidePanel.goto(`chrome-extension://${extId}/side-panel/side-panel.html`);
|
|
198
|
+
|
|
199
|
+
// Headless persona — pick dev mode if wizard appears.
|
|
200
|
+
const devButton = sidePanel.getByRole('button', { name: /developer or agency/i });
|
|
201
|
+
if (await devButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
|
202
|
+
await devButton.click();
|
|
203
|
+
await sidePanel.waitForTimeout(500);
|
|
204
|
+
}
|
|
205
|
+
await sidePanel.waitForSelector('h1:has-text("wcagcheckr")', { timeout: 10_000 });
|
|
206
|
+
|
|
207
|
+
// Activate license token, if provided. Gated paid features (forensic
|
|
208
|
+
// anchoring, AI-summary, etc.) need an active license at audit time.
|
|
209
|
+
if (args.license) {
|
|
210
|
+
log('activating license token');
|
|
211
|
+
const activated = await sidePanel.evaluate(
|
|
212
|
+
async (token) =>
|
|
213
|
+
await new Promise((resolve) => {
|
|
214
|
+
chrome.runtime.sendMessage(
|
|
215
|
+
{ type: 'LICENSE_SET_REQUEST', token },
|
|
216
|
+
(response) => resolve(response ?? null),
|
|
217
|
+
);
|
|
218
|
+
}),
|
|
219
|
+
args.license,
|
|
220
|
+
);
|
|
221
|
+
if (!activated || activated.tier === 'free') {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`license activation did not yield a paid tier — got ${JSON.stringify(activated)}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
log(`license active — tier: ${activated.tier}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Skip onboarding tour
|
|
230
|
+
for (let i = 0; i < 5; i++) {
|
|
231
|
+
const gotIt = sidePanel.getByRole('button', { name: 'Got it' });
|
|
232
|
+
const next = sidePanel.getByRole('button', { name: 'Next' });
|
|
233
|
+
if (await gotIt.isVisible({ timeout: 500 }).catch(() => false)) {
|
|
234
|
+
await gotIt.click();
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
if (await next.isVisible({ timeout: 500 }).catch(() => false)) {
|
|
238
|
+
await next.click();
|
|
239
|
+
await sidePanel.waitForTimeout(150);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await sidePanel.getByLabel('Audit mode').selectOption('full-page');
|
|
246
|
+
await targetPage.bringToFront();
|
|
247
|
+
await targetPage.waitForTimeout(500);
|
|
248
|
+
await sidePanel.bringToFront();
|
|
249
|
+
await sidePanel.waitForTimeout(300);
|
|
250
|
+
await sidePanel.getByRole('button', { name: /Scan page|Auditing/ }).click();
|
|
251
|
+
log('audit started');
|
|
252
|
+
|
|
253
|
+
await sidePanel
|
|
254
|
+
.getByText(/\d+\s+states?\s+·\s+\d+\s+unique\s+violations?/, { exact: false })
|
|
255
|
+
.waitFor({ timeout: args.timeout });
|
|
256
|
+
log('audit completed');
|
|
257
|
+
|
|
258
|
+
// Trigger the existing export pipeline. The SW handles EXPORT_REQUEST and
|
|
259
|
+
// returns the formatted content for the requested format.
|
|
260
|
+
const formatMap = { json: 'json', sarif: 'sarif', junit: 'junit' };
|
|
261
|
+
const exportFormat = formatMap[args.format];
|
|
262
|
+
|
|
263
|
+
const result = await sidePanel.evaluate(async (fmt) => {
|
|
264
|
+
// Read the just-completed audit from chrome.storage.local. The side-
|
|
265
|
+
// panel persists results there in wire-messaging.ts when AUDIT_COMPLETE_
|
|
266
|
+
// EVENT fires, so by the time the matrix-summary text is visible (which
|
|
267
|
+
// we waited on above), the data is in storage. Earlier code tried to
|
|
268
|
+
// read from `window.__wcagcheckrStore` — that global is never set, so
|
|
269
|
+
// every CLI audit silently returned 0 violations.
|
|
270
|
+
const stored = await new Promise((resolve) => {
|
|
271
|
+
chrome.storage.local.get('sidePanel:lastAudit', (r) =>
|
|
272
|
+
resolve(r['sidePanel:lastAudit'] ?? null),
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
const results = stored?.results ?? [];
|
|
276
|
+
const delta = stored?.delta ?? null;
|
|
277
|
+
return await new Promise((resolve, reject) => {
|
|
278
|
+
const timeoutId = setTimeout(() => reject(new Error('export timeout')), 30_000);
|
|
279
|
+
chrome.runtime.sendMessage(
|
|
280
|
+
{ type: 'EXPORT_REQUEST', format: fmt, results, delta: delta ?? undefined },
|
|
281
|
+
(response) => {
|
|
282
|
+
clearTimeout(timeoutId);
|
|
283
|
+
if (chrome.runtime.lastError) {
|
|
284
|
+
reject(new Error(chrome.runtime.lastError.message));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
resolve(response);
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
}, exportFormat);
|
|
292
|
+
|
|
293
|
+
if (!result || !result.content) {
|
|
294
|
+
throw new Error('export returned no content');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Severity counts for threshold check — same chrome.storage.local source
|
|
298
|
+
// as the export above (window.__wcagcheckrStore is never defined).
|
|
299
|
+
const counts = await sidePanel.evaluate(async () => {
|
|
300
|
+
const stored = await new Promise((resolve) => {
|
|
301
|
+
chrome.storage.local.get('sidePanel:lastAudit', (r) =>
|
|
302
|
+
resolve(r['sidePanel:lastAudit'] ?? null),
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
const results = stored?.results ?? [];
|
|
306
|
+
const totals = { critical: 0, serious: 0, moderate: 0, minor: 0 };
|
|
307
|
+
const seen = new Set();
|
|
308
|
+
for (const r of results) {
|
|
309
|
+
for (const v of r.violations ?? []) {
|
|
310
|
+
const key = (v.matchKey ?? '') + ':' + (v.target?.selector ?? '');
|
|
311
|
+
if (seen.has(key)) continue;
|
|
312
|
+
seen.add(key);
|
|
313
|
+
if (v.impact in totals) totals[v.impact]++;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return totals;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
log(`unique violations: critical=${counts.critical}, serious=${counts.serious}, moderate=${counts.moderate}, minor=${counts.minor}`);
|
|
320
|
+
|
|
321
|
+
if (args.output) {
|
|
322
|
+
writeFileSync(args.output, result.content, 'utf8');
|
|
323
|
+
log(`output written to ${args.output} (${result.content.length} bytes)`);
|
|
324
|
+
} else {
|
|
325
|
+
process.stdout.write(result.content);
|
|
326
|
+
if (!result.content.endsWith('\n')) process.stdout.write('\n');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (args.threshold !== 'none') {
|
|
330
|
+
const cap = SEVERITY_RANK[args.threshold];
|
|
331
|
+
const exceeds =
|
|
332
|
+
(cap >= SEVERITY_RANK.critical && counts.critical > 0) ||
|
|
333
|
+
(cap >= SEVERITY_RANK.serious && counts.serious > 0) ||
|
|
334
|
+
(cap >= SEVERITY_RANK.moderate && counts.moderate > 0) ||
|
|
335
|
+
(cap >= SEVERITY_RANK.minor && counts.minor > 0);
|
|
336
|
+
if (exceeds) {
|
|
337
|
+
log(`✗ threshold exceeded (--threshold=${args.threshold})`);
|
|
338
|
+
exitCode = 1;
|
|
339
|
+
} else {
|
|
340
|
+
log(`✓ threshold not exceeded (--threshold=${args.threshold})`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} catch (err) {
|
|
344
|
+
console.error(`✗ ${err.message}`);
|
|
345
|
+
if (err.stack && !args.quiet) console.error(err.stack);
|
|
346
|
+
exitCode = 2;
|
|
347
|
+
} finally {
|
|
348
|
+
await context.close();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
process.exit(exitCode);
|
|
352
|
+
|
|
353
|
+
// ─── verify subcommand impl ────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
/** Deterministic canonical JSON — keys sorted alphabetically, no whitespace.
|
|
356
|
+
* Same algorithm as forensic-log.ts in the extension. Critical: the hash
|
|
357
|
+
* and signature payloads must serialize byte-for-byte identically across
|
|
358
|
+
* extension, server, and CLI, or the verification fails. */
|
|
359
|
+
function canonicalJson(value) {
|
|
360
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
361
|
+
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']';
|
|
362
|
+
const keys = Object.keys(value).sort();
|
|
363
|
+
return (
|
|
364
|
+
'{' +
|
|
365
|
+
keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') +
|
|
366
|
+
'}'
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function sha256Hex(text) {
|
|
371
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function fetchPublicKey(url, quiet) {
|
|
375
|
+
if (!quiet) console.error('→', 'fetching server public key from', url);
|
|
376
|
+
const res = await fetch(url);
|
|
377
|
+
if (!res.ok) throw new Error(`public-key fetch HTTP ${res.status}`);
|
|
378
|
+
const json = await res.json();
|
|
379
|
+
if (!json.publicKeyBase64 || !json.fingerprint) {
|
|
380
|
+
throw new Error('public-key response missing publicKeyBase64 / fingerprint');
|
|
381
|
+
}
|
|
382
|
+
return { publicKeyBase64: json.publicKeyBase64, fingerprint: json.fingerprint };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function verifyEd25519(messageBytes, signatureBytes, spkiBuffer) {
|
|
386
|
+
// Node's createPublicKey accepts an SPKI DER buffer when format='der',
|
|
387
|
+
// type='spki'. ed25519 verify() takes (algorithm=null, data, key, signature).
|
|
388
|
+
const key = createPublicKey({ key: spkiBuffer, format: 'der', type: 'spki' });
|
|
389
|
+
return cryptoVerify(null, messageBytes, key, signatureBytes);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function runVerify(args) {
|
|
393
|
+
const path = resolve(args.inputFile);
|
|
394
|
+
if (!existsSync(path)) {
|
|
395
|
+
console.error(`✗ verify: file not found: ${path}`);
|
|
396
|
+
process.exit(2);
|
|
397
|
+
}
|
|
398
|
+
let payload;
|
|
399
|
+
try {
|
|
400
|
+
payload = JSON.parse(readFileSync(path, 'utf8'));
|
|
401
|
+
} catch (err) {
|
|
402
|
+
console.error(`✗ verify: failed to parse JSON: ${err.message}`);
|
|
403
|
+
process.exit(2);
|
|
404
|
+
}
|
|
405
|
+
if (!Array.isArray(payload.entries)) {
|
|
406
|
+
console.error('✗ verify: input is not a wcagcheckr forensic-log export (entries[] missing).');
|
|
407
|
+
process.exit(2);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const entries = payload.entries;
|
|
411
|
+
if (!args.quiet) {
|
|
412
|
+
console.error('→', `loaded ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} from ${path}`);
|
|
413
|
+
console.error('→', `tool: ${payload.tool?.name ?? '?'} ${payload.tool?.version ?? ''}`);
|
|
414
|
+
console.error('→', `exported: ${payload.exportedAt ?? 'n/a'}`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Lazy-fetch the public key only if at least one entry has a receipt.
|
|
418
|
+
let cachedKey = null;
|
|
419
|
+
async function getKey() {
|
|
420
|
+
if (cachedKey) return cachedKey;
|
|
421
|
+
cachedKey = await fetchPublicKey(args.publicKeyUrl, args.quiet);
|
|
422
|
+
cachedKey.spkiBuffer = Buffer.from(cachedKey.publicKeyBase64, 'base64');
|
|
423
|
+
return cachedKey;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let hashOk = 0;
|
|
427
|
+
let hashFail = 0;
|
|
428
|
+
let sigOk = 0;
|
|
429
|
+
let sigFail = 0;
|
|
430
|
+
let sigSkipped = 0;
|
|
431
|
+
let chainOk = 0;
|
|
432
|
+
let chainFail = 0;
|
|
433
|
+
let chainUnverifiable = 0;
|
|
434
|
+
let chainSkipped = 0;
|
|
435
|
+
|
|
436
|
+
// The server's anchor chain is per-product, not per-component. We can only
|
|
437
|
+
// hard-verify a link when the claimed predecessor hash exists in THIS
|
|
438
|
+
// export. Otherwise the chain is unverifiable from the file alone (the
|
|
439
|
+
// user would need to export the full history to verify deep chain links).
|
|
440
|
+
const hashesInExport = new Set(entries.map((e) => e.hash));
|
|
441
|
+
|
|
442
|
+
// Sort chronologically for stable per-entry output.
|
|
443
|
+
const sorted = [...entries].sort((a, b) => a.capturedAt.localeCompare(b.capturedAt));
|
|
444
|
+
|
|
445
|
+
for (const e of sorted) {
|
|
446
|
+
const tag = `${e.componentId}@${e.capturedAt}`;
|
|
447
|
+
|
|
448
|
+
// 1. Recompute the identity hash.
|
|
449
|
+
const hashInput = {
|
|
450
|
+
componentId: e.componentId,
|
|
451
|
+
pageUrl: e.pageUrl,
|
|
452
|
+
scope: e.scope,
|
|
453
|
+
grade: e.grade,
|
|
454
|
+
totals: e.totals,
|
|
455
|
+
axeVersion: e.axeVersion,
|
|
456
|
+
capturedAt: e.capturedAt,
|
|
457
|
+
statesAudited: e.statesAudited,
|
|
458
|
+
};
|
|
459
|
+
const recomputed = sha256Hex(canonicalJson(hashInput));
|
|
460
|
+
if (recomputed === e.hash) {
|
|
461
|
+
hashOk++;
|
|
462
|
+
if (!args.quiet) console.log(` HASH OK ${tag} ${e.hash.slice(0, 12)}…`);
|
|
463
|
+
} else {
|
|
464
|
+
hashFail++;
|
|
465
|
+
console.log(` HASH FAIL ${tag}`);
|
|
466
|
+
console.log(` stored=${e.hash}`);
|
|
467
|
+
console.log(` recomp=${recomputed}`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 2. Verify the server signature (if a receipt exists).
|
|
471
|
+
if (e.receipt) {
|
|
472
|
+
try {
|
|
473
|
+
const key = await getKey();
|
|
474
|
+
if (key.fingerprint !== e.receipt.serverKeyFingerprint) {
|
|
475
|
+
// Different key — caller may want to pass --public-key-url for a
|
|
476
|
+
// historical key. We don't fail outright; surface it.
|
|
477
|
+
console.log(
|
|
478
|
+
` SIG FAIL ${tag} key fingerprint mismatch (entry=${e.receipt.serverKeyFingerprint.slice(0, 12)}…, server=${key.fingerprint.slice(0, 12)}…)`,
|
|
479
|
+
);
|
|
480
|
+
sigFail++;
|
|
481
|
+
} else {
|
|
482
|
+
const payloadObj = {
|
|
483
|
+
hash: e.hash,
|
|
484
|
+
anchoredAt: e.receipt.anchoredAt,
|
|
485
|
+
tsaName: e.receipt.tsaName,
|
|
486
|
+
productSlug: payload.tool?.name ?? 'wcagcheckr',
|
|
487
|
+
prevAuditHash: e.receipt.prevAuditHash ?? null,
|
|
488
|
+
};
|
|
489
|
+
if (e.receipt.schemaVersion === 1 || !e.receipt.prevAuditHash) {
|
|
490
|
+
delete payloadObj.prevAuditHash;
|
|
491
|
+
}
|
|
492
|
+
const canonical = canonicalJson(payloadObj);
|
|
493
|
+
const bytes = Buffer.from(canonical, 'utf8');
|
|
494
|
+
const sig = Buffer.from(e.receipt.serverSignatureBase64, 'base64');
|
|
495
|
+
const ok = verifyEd25519(bytes, sig, key.spkiBuffer);
|
|
496
|
+
if (ok) {
|
|
497
|
+
sigOk++;
|
|
498
|
+
if (!args.quiet) console.log(` SIG OK ${tag} ed25519 (${e.receipt.tsaName})`);
|
|
499
|
+
} else {
|
|
500
|
+
sigFail++;
|
|
501
|
+
console.log(` SIG FAIL ${tag} ed25519 verification failed`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} catch (err) {
|
|
505
|
+
sigFail++;
|
|
506
|
+
console.log(` SIG ERR ${tag} ${err.message}`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// 3. Chain check — v2 receipts include prevAuditHash linking to the
|
|
510
|
+
// previous anchor for the product. We can hard-verify a link only
|
|
511
|
+
// when the predecessor hash is present in THIS export AND its
|
|
512
|
+
// signature already verified above. Otherwise the chain link is
|
|
513
|
+
// unverifiable from the file alone (deeper history not exported).
|
|
514
|
+
if (e.receipt.schemaVersion === 2 && typeof e.receipt.prevAuditHash === 'string') {
|
|
515
|
+
const prev = e.receipt.prevAuditHash;
|
|
516
|
+
const genesisHash = '0'.repeat(64);
|
|
517
|
+
if (prev === genesisHash) {
|
|
518
|
+
// Sentinel — this anchor claims to be the first ever for the product.
|
|
519
|
+
chainOk++;
|
|
520
|
+
if (!args.quiet) console.log(` CHAIN OK ${tag} product-genesis anchor`);
|
|
521
|
+
} else if (hashesInExport.has(prev)) {
|
|
522
|
+
// The predecessor is in this export — and since we verified above
|
|
523
|
+
// that its hash matched its identifying fields, the chain link is
|
|
524
|
+
// sound: the receipt's signed bytes commit to that specific hash.
|
|
525
|
+
chainOk++;
|
|
526
|
+
if (!args.quiet) console.log(` CHAIN OK ${tag} predecessor ${prev.slice(0, 12)}… present`);
|
|
527
|
+
} else {
|
|
528
|
+
chainUnverifiable++;
|
|
529
|
+
if (!args.quiet) {
|
|
530
|
+
console.log(
|
|
531
|
+
` CHAIN ?? ${tag} predecessor ${prev.slice(0, 12)}… not in this export — re-export full history to hard-verify`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
chainSkipped++;
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
sigSkipped++;
|
|
540
|
+
if (!args.quiet) console.log(` SIG SKIP ${tag} local-only entry (no receipt)`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Summary.
|
|
545
|
+
console.error('');
|
|
546
|
+
console.error(`Hash: ${hashOk} ok · ${hashFail} fail`);
|
|
547
|
+
console.error(`Signature: ${sigOk} ok · ${sigFail} fail · ${sigSkipped} skipped (local-only)`);
|
|
548
|
+
console.error(`Chain: ${chainOk} ok · ${chainFail} fail · ${chainUnverifiable} unverifiable (predecessor not in export) · ${chainSkipped} skipped (v1 / local-only)`);
|
|
549
|
+
if (sigOk > 0) {
|
|
550
|
+
console.error('');
|
|
551
|
+
console.error('Note: RFC 3161 TimeStampToken bytes are present in each anchored entry but');
|
|
552
|
+
console.error(' this verifier only checks the ed25519 server signature. To fully verify');
|
|
553
|
+
console.error(' the TSA timestamp, save the rfc3161TokenBase64 to a .tsr file and run:');
|
|
554
|
+
console.error(' openssl ts -verify -data <audit-bytes> -in token.tsr -CAfile freetsa.pem');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const failed = hashFail + sigFail + chainFail;
|
|
558
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
559
|
+
}
|