pqcheck 0.6.0 → 0.7.2

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.
Files changed (2) hide show
  1. package/bin/pqcheck.js +452 -26
  2. package/package.json +1 -1
package/bin/pqcheck.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // =============================================================================
8
8
 
9
9
  const API_BASE = process.env.PQCHECK_API_BASE || "https://quantapact.com";
10
- const VERSION = "0.6.0";
10
+ const VERSION = "0.7.2";
11
11
 
12
12
  const ANSI = {
13
13
  reset: "\x1b[0m",
@@ -41,10 +41,38 @@ async function main() {
41
41
  if (args[0] === "deps") {
42
42
  return runDepsCommand(args.slice(1));
43
43
  }
44
+ if (args[0] === "diff") {
45
+ return runDiffCommand(args.slice(1));
46
+ }
47
+ if (args[0] === "history") {
48
+ return runHistoryCommand(args.slice(1));
49
+ }
50
+ if (args[0] === "cert") {
51
+ return runCertCommand(args.slice(1));
52
+ }
53
+
54
+ // Multi-domain support: positional args are domains.
55
+ // --file reads additional domains from a newline-delimited file.
56
+ const fileFlagIdx = args.indexOf("--file");
57
+ let fileDomains = [];
58
+ if (fileFlagIdx >= 0) {
59
+ const filePath = args[fileFlagIdx + 1];
60
+ if (!filePath) {
61
+ console.error(color("red", "error: --file requires a path argument"));
62
+ process.exit(1);
63
+ }
64
+ try {
65
+ const fs = await import("node:fs/promises");
66
+ const raw = await fs.readFile(filePath, "utf8");
67
+ fileDomains = raw.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
68
+ } catch (err) {
69
+ console.error(color("red", `error reading --file ${filePath}: ${err.message}`));
70
+ process.exit(1);
71
+ }
72
+ }
44
73
 
45
- // Multi-domain support: any non-flag positional arg is a domain
46
74
  const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
47
- const domains = positional
75
+ const domains = [...positional, ...fileDomains]
48
76
  .map((a) => normalizeDomain(a))
49
77
  .filter((d) => !!d);
50
78
 
@@ -140,6 +168,10 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi
140
168
  printCsvRow(report);
141
169
  } else if (format === "markdown") {
142
170
  printMarkdown(report, multi);
171
+ } else if (format === "sarif") {
172
+ console.log(JSON.stringify(reportToSarif(report), null, 2));
173
+ } else if (format === "gh-action") {
174
+ printGitHubActionAnnotations(report);
143
175
  } else {
144
176
  if (multi) console.log(color("dim", `\n──── ${domain} ────`));
145
177
  printReport(report);
@@ -228,15 +260,18 @@ function isFlagValue(args, val) {
228
260
  const idx = args.indexOf(val);
229
261
  if (idx <= 0) return false;
230
262
  const prev = args[idx - 1];
231
- return prev === "--threshold" || prev === "--format" || prev === "--watch" || prev === "--webhook";
263
+ return prev === "--threshold" || prev === "--format" || prev === "--watch" || prev === "--webhook" || prev === "--file" || prev === "-o" || prev === "--allowlist";
232
264
  }
233
265
 
234
266
  function parseFormat(args) {
235
267
  if (args.includes("--json")) return "json"; // back-compat alias
268
+ if (args.includes("--gh-action")) return "gh-action"; // GitHub Actions annotation format
236
269
  const i = args.indexOf("--format");
237
270
  if (i === -1) return "text";
238
271
  const v = (args[i + 1] || "").toLowerCase();
239
- if (v === "json" || v === "csv" || v === "markdown" || v === "md") return v === "md" ? "markdown" : v;
272
+ if (v === "json" || v === "csv" || v === "markdown" || v === "md" || v === "sarif" || v === "gh-action") {
273
+ return v === "md" ? "markdown" : v;
274
+ }
240
275
  return "text";
241
276
  }
242
277
 
@@ -463,41 +498,64 @@ ${color("bold", "pqcheck")} ${color("dim", `v${VERSION}`)}
463
498
 
464
499
  Public Surface Blast Radius — quantum-decryption risk for any domain.
465
500
 
466
- ${color("bold", "Usage:")}
501
+ ${color("bold", "Commands:")}
467
502
  npx pqcheck <domain> Scan + print human-readable report
468
- npx pqcheck lock <domain> Generate quantapact.lock (QXM) for repo commit
469
- npx pqcheck deps <domain> Scan third-party origins (supply-chain HNDL); --lock for committable manifest
470
- npx pqcheck a.com b.com c.com Multi-domain scan
471
- npx pqcheck <domain> --format json Raw JSON
472
- npx pqcheck <domain> --format markdown GitHub-issue / Slack-ready Markdown
473
- npx pqcheck <domain> --format csv Spreadsheet-friendly CSV row
474
- npx pqcheck <domain> --threshold 7 Exit 2 if score ≥ 7 (CI gate)
475
- npx pqcheck <domain> --quiet Print just the score number
476
- npx pqcheck <domain> --watch [seconds] Continuously poll and report changes
477
- npx pqcheck <domain> --webhook <url> POST report JSON to URL on each scan
478
-
479
- ${color("bold", "Options:")}
503
+ npx pqcheck lock <domain> Generate quantapact.lock (QXM) committable manifest
504
+ npx pqcheck deps <domain> Scan all third-party origins on the page (supply-chain HNDL)
505
+ npx pqcheck diff <old.lock> <new.lock> Compare two QXM lockfiles; exit 2 on regression
506
+ npx pqcheck history <domain> Show 90-day score history (sparkline + samples)
507
+ npx pqcheck cert <file.pem> Analyze a local PEM/CRT cert file (offline, no network)
508
+
509
+ ${color("bold", "Multi-domain:")}
510
+ npx pqcheck a.com b.com c.com Multi-domain scan (positional)
511
+ npx pqcheck --file domains.txt Bulk scan from a newline-separated file (# comments allowed)
512
+
513
+ ${color("bold", "Output formats:")}
514
+ --format text Human-readable (default)
515
+ --format json (or --json) Raw JSON / NDJSON for multi
516
+ --format markdown GitHub-issue / Slack-ready Markdown
517
+ --format csv Spreadsheet-friendly CSV row
518
+ --format sarif SARIF 2.1.0 for GitHub Code Scanning upload
519
+ --gh-action GitHub Actions ::notice/::warning/::error annotations
520
+
521
+ ${color("bold", "Common flags:")}
480
522
  -h, --help Show this help
481
523
  -v, --version Show version
482
- --format <text|json|markdown|csv> Output format (default: text)
483
- --json Alias for --format json
484
- --threshold <0-10> Exit 2 if score meets or exceeds this
524
+ --threshold <0-10> Exit 2 if score meets or exceeds this (CI gate)
485
525
  -q, --quiet Print only the numeric score
486
526
  --watch [seconds] Poll every N seconds (default 300) and report changes
487
527
  --webhook <url> POST scan results to a URL (one-shot or each watch tick)
488
528
 
529
+ ${color("bold", "Subcommand-specific:")}
530
+ pqcheck deps:
531
+ --lock Also write quantapact-deps.lock + .md
532
+ -o <dir> Output directory for --lock files
533
+ --max=<N> Max third parties to scan (default 20)
534
+ --allowlist <file> Exit 3 if any third-party not in allowlist (CI gate)
535
+ pqcheck lock:
536
+ -o <dir> Output directory
537
+ --stdout Print JSON to stdout instead of writing files
538
+ pqcheck history:
539
+ --days <N> History window (default 90)
540
+ --json Raw JSON
541
+
489
542
  ${color("bold", "Exit codes:")}
490
543
  0 success
491
544
  1 usage / network / scan error
492
- 2 score met or exceeded --threshold
545
+ 2 score met or exceeded --threshold (or diff regression)
546
+ 3 allowlist violation (deps --allowlist)
493
547
 
494
548
  ${color("bold", "Examples:")}
495
549
  npx pqcheck chase.com
496
- npx pqcheck mycompany.com mythirdparty.com --format csv > posture.csv
497
550
  npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if score ≥ 7")}
498
- npx pqcheck mybank.com --format markdown >> issue.md
499
- npx pqcheck mybank.com --watch 600 --webhook https://hooks.slack.com/services/...
500
- echo "score: $(npx pqcheck mybank.com -q)"
551
+ npx pqcheck deps stripe.com --lock
552
+ npx pqcheck deps acme.com --allowlist allowed-vendors.txt ${color("dim", "# CI vendor-risk gate")}
553
+ npx pqcheck diff main.lock pr.lock ${color("dim", "# regression detection in PR")}
554
+ npx pqcheck history quantapact.com
555
+ npx pqcheck cert ./mycert.pem ${color("dim", "# offline cert analysis")}
556
+ npx pqcheck --file domains.txt --format json > scans.ndjson
557
+ npx pqcheck mybank.com --format sarif > pqcheck.sarif ${color("dim", "# upload to Code Scanning")}
558
+ npx pqcheck mybank.com --gh-action ${color("dim", "# inline PR annotations")}
501
559
 
502
560
  Backed by the patented Decryption Blast Radius methodology.
503
561
  ${color("violet", "https://quantapact.com")}
@@ -772,6 +830,23 @@ async function runDepsCommand(args) {
772
830
  const maxArg = args.find((a) => a.startsWith("--max="));
773
831
  const maxThirdParties = maxArg ? Math.max(1, parseInt(maxArg.slice(6), 10) || 20) : 20;
774
832
 
833
+ // Allowlist support: --allowlist <path> reads newline-separated host patterns.
834
+ // If a third-party host is NOT in the allowlist, the scan exits non-zero with code 3.
835
+ // Useful as a CI gate for vendor-risk teams: "fail PR if any unapproved third-party appears."
836
+ const allowlistIdx = args.indexOf("--allowlist");
837
+ let allowlist = null;
838
+ if (allowlistIdx >= 0) {
839
+ const allowlistPath = args[allowlistIdx + 1];
840
+ try {
841
+ const fs2 = await import("node:fs/promises");
842
+ const raw = await fs2.readFile(allowlistPath, "utf8");
843
+ allowlist = new Set(raw.split(/\r?\n/).map(l => l.trim().toLowerCase()).filter(l => l && !l.startsWith("#")));
844
+ } catch (err) {
845
+ console.error(color("red", `error reading --allowlist: ${err.message}`));
846
+ process.exit(1);
847
+ }
848
+ }
849
+
775
850
  const positional = args.filter((a) => !a.startsWith("-") && a !== outDir);
776
851
  const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
777
852
  if (!domain || !isValidDomain(domain)) {
@@ -916,6 +991,23 @@ async function runDepsCommand(args) {
916
991
  process.exit(1);
917
992
  }
918
993
  }
994
+
995
+ // Allowlist gate: exit non-zero if any third-party host isn't in the allowlist.
996
+ if (allowlist) {
997
+ const violations = results.filter(r => !allowlist.has(r.host));
998
+ if (violations.length > 0) {
999
+ if (!json) {
1000
+ console.error("");
1001
+ console.error(color("red", ` ✗ Allowlist violation: ${violations.length} third-party origin(s) not in allowlist:`));
1002
+ for (const v of violations) console.error(` - ${v.host}`);
1003
+ console.error("");
1004
+ }
1005
+ process.exit(3);
1006
+ } else if (!json) {
1007
+ console.log(` ${color("green", "✓")} ${color("dim", "All third-party origins on allowlist.")}`);
1008
+ console.log("");
1009
+ }
1010
+ }
919
1011
  }
920
1012
 
921
1013
  async function fetchPageHTML(domain) {
@@ -1045,6 +1137,340 @@ function depsManifestToMarkdown(m) {
1045
1137
  return lines.join("\n");
1046
1138
  }
1047
1139
 
1140
+ // =============================================================================
1141
+ // SARIF + GitHub Action output formats
1142
+ // =============================================================================
1143
+
1144
+ function reportToSarif(report) {
1145
+ // SARIF 2.1.0 minimal schema for security findings.
1146
+ // Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
1147
+ const findings = Array.isArray(report.findings) ? report.findings : [];
1148
+ const sevMap = { critical: "error", high: "error", medium: "warning", low: "note" };
1149
+ return {
1150
+ $schema: "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
1151
+ version: "2.1.0",
1152
+ runs: [{
1153
+ tool: {
1154
+ driver: {
1155
+ name: "pqcheck",
1156
+ version: VERSION,
1157
+ informationUri: "https://quantapact.com",
1158
+ rules: findings.map((f, i) => ({
1159
+ id: `pqcheck-${i + 1}`,
1160
+ name: (f.title || "finding").replace(/[^A-Za-z0-9]/g, "_"),
1161
+ shortDescription: { text: f.title || "finding" },
1162
+ fullDescription: { text: f.detail || f.title || "finding" },
1163
+ defaultConfiguration: { level: sevMap[f.severity] || "note" },
1164
+ })),
1165
+ },
1166
+ },
1167
+ results: findings.map((f, i) => ({
1168
+ ruleId: `pqcheck-${i + 1}`,
1169
+ level: sevMap[f.severity] || "note",
1170
+ message: { text: `${f.title || "finding"}${f.detail ? ` — ${f.detail}` : ""}` },
1171
+ locations: [{
1172
+ physicalLocation: {
1173
+ artifactLocation: { uri: `https://${report.domain || ""}` },
1174
+ },
1175
+ }],
1176
+ properties: {
1177
+ domain: report.domain,
1178
+ score: report.score,
1179
+ grade: report.grade,
1180
+ severity: f.severity,
1181
+ },
1182
+ })),
1183
+ properties: {
1184
+ score: report.score,
1185
+ grade: report.grade,
1186
+ domain: report.domain,
1187
+ },
1188
+ }],
1189
+ };
1190
+ }
1191
+
1192
+ function printGitHubActionAnnotations(report) {
1193
+ // GitHub Actions workflow command syntax: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
1194
+ const findings = Array.isArray(report.findings) ? report.findings : [];
1195
+ const sevMap = { critical: "error", high: "error", medium: "warning", low: "notice" };
1196
+ // Top-line score/grade as a notice
1197
+ console.log(`::notice title=Quantapact: ${report.domain}::Grade ${report.grade || "?"} · score ${report.score ?? "?"} / 10`);
1198
+ for (const f of findings) {
1199
+ const cmd = sevMap[f.severity] || "notice";
1200
+ const title = (f.title || "finding").replace(/[\r\n]/g, " ");
1201
+ const msg = (f.detail || f.title || "").replace(/[\r\n]/g, " ").replace(/::/g, ":");
1202
+ console.log(`::${cmd} title=${title}::${msg}`);
1203
+ }
1204
+ }
1205
+
1206
+ // =============================================================================
1207
+ // `pqcheck history` — show recent score history for a domain
1208
+ // =============================================================================
1209
+
1210
+ async function runHistoryCommand(args) {
1211
+ const json = args.includes("--json");
1212
+ const positional = args.filter((a) => !a.startsWith("-"));
1213
+ const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
1214
+ if (!domain || !isValidDomain(domain)) {
1215
+ console.error(color("red", "error: pqcheck history requires a valid domain"));
1216
+ console.error(color("dim", "Usage: npx pqcheck history <domain> [--json]"));
1217
+ process.exit(1);
1218
+ }
1219
+ const days = (() => {
1220
+ const i = args.indexOf("--days");
1221
+ if (i === -1) return 90;
1222
+ const n = parseInt(args[i + 1] || "90", 10);
1223
+ return Number.isFinite(n) && n > 0 && n <= 365 ? n : 90;
1224
+ })();
1225
+
1226
+ let h;
1227
+ try {
1228
+ const r = await fetch(`${API_BASE}/api/history?domain=${encodeURIComponent(domain)}&days=${days}`, {
1229
+ headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (history)` },
1230
+ });
1231
+ if (!r.ok) {
1232
+ console.error(color("red", `error: ${r.status} ${r.statusText}`));
1233
+ process.exit(1);
1234
+ }
1235
+ h = await r.json();
1236
+ } catch (err) {
1237
+ console.error(color("red", `error: ${err.message}`));
1238
+ process.exit(1);
1239
+ }
1240
+
1241
+ if (json) {
1242
+ console.log(JSON.stringify(h, null, 2));
1243
+ return;
1244
+ }
1245
+
1246
+ const points = Array.isArray(h?.points) ? h.points : [];
1247
+ if (points.length === 0) {
1248
+ console.log(` ${color("violet", domain)} ${color("dim", "·")} no history found`);
1249
+ console.log(color("dim", " (need at least one previous scan; try `npx pqcheck " + domain + "` first)"));
1250
+ return;
1251
+ }
1252
+ const sorted = points.slice().reverse(); // oldest → newest
1253
+ const first = sorted[0].score;
1254
+ const last = sorted[sorted.length - 1].score;
1255
+ const delta = last - first;
1256
+ const min = Math.min(...sorted.map(p => p.score));
1257
+ const max = Math.max(...sorted.map(p => p.score));
1258
+ const trend = Math.abs(delta) < 0.1 ? "→ flat" : delta > 0 ? `↑ +${delta.toFixed(1)} (worsened)` : `↓ ${delta.toFixed(1)} (improved)`;
1259
+
1260
+ console.log("");
1261
+ console.log(` ${color("bold", domain)} ${color("dim", "·")} score history (${days}d, ${sorted.length} samples)`);
1262
+ console.log(` ${color("dim", `range ${min.toFixed(1)} – ${max.toFixed(1)} · trend ${trend}`)}`);
1263
+ console.log("");
1264
+ // Compact ASCII sparkline (flat → centered dots; varying → ramp blocks)
1265
+ const isFlat = Math.abs(max - min) < 0.05;
1266
+ let bar = "";
1267
+ if (isFlat) {
1268
+ bar = "·".repeat(sorted.length);
1269
+ } else {
1270
+ const range = max - min;
1271
+ const ramp = "▁▂▃▄▅▆▇█";
1272
+ for (const p of sorted) {
1273
+ const idx = Math.min(ramp.length - 1, Math.floor(((p.score - min) / range) * (ramp.length - 1)));
1274
+ bar += ramp[idx];
1275
+ }
1276
+ }
1277
+ console.log(` ${color("violet", bar)}`);
1278
+ console.log("");
1279
+ // Tail of recent samples — accept either recordedAt or scannedAt or date
1280
+ console.log(` ${color("dim", "Recent samples (most-recent first):")}`);
1281
+ for (const p of points.slice(0, 8)) {
1282
+ const dateRaw = p.recordedAt || p.scannedAt || p.date || "";
1283
+ const date = String(dateRaw).slice(0, 10) || "—".padEnd(10, " ");
1284
+ console.log(` ${color("dim", date)} score ${color("bold", p.score?.toFixed(1) ?? "?")} grade ${p.grade ?? "?"}`);
1285
+ }
1286
+ console.log("");
1287
+ }
1288
+
1289
+ // =============================================================================
1290
+ // `pqcheck diff` — diff two QXM lockfiles
1291
+ // =============================================================================
1292
+
1293
+ async function runDiffCommand(args) {
1294
+ const fs = await import("node:fs/promises");
1295
+ const json = args.includes("--json");
1296
+ const positional = args.filter((a) => !a.startsWith("-"));
1297
+ if (positional.length !== 2) {
1298
+ console.error(color("red", "error: pqcheck diff requires two lockfile paths"));
1299
+ console.error(color("dim", "Usage: npx pqcheck diff old.lock new.lock [--json]"));
1300
+ process.exit(1);
1301
+ }
1302
+ let oldLock, newLock;
1303
+ try {
1304
+ oldLock = JSON.parse(await fs.readFile(positional[0], "utf8"));
1305
+ newLock = JSON.parse(await fs.readFile(positional[1], "utf8"));
1306
+ } catch (err) {
1307
+ console.error(color("red", `error reading lockfile: ${err.message}`));
1308
+ process.exit(1);
1309
+ }
1310
+
1311
+ const diff = computeLockDiff(oldLock, newLock);
1312
+ if (json) {
1313
+ console.log(JSON.stringify(diff, null, 2));
1314
+ process.exit(diff.regressed ? 2 : 0);
1315
+ }
1316
+
1317
+ console.log("");
1318
+ console.log(` ${color("bold", "Quantapact lockfile diff")}`);
1319
+ console.log(` ${color("dim", `${positional[0]} → ${positional[1]}`)}`);
1320
+ console.log("");
1321
+ if (diff.scoreChange !== null) {
1322
+ const arrow = diff.scoreChange > 0 ? color("red", "↑") : diff.scoreChange < 0 ? color("green", "↓") : color("dim", "→");
1323
+ const direction = diff.scoreChange > 0 ? "worsened" : diff.scoreChange < 0 ? "improved" : "unchanged";
1324
+ console.log(` Score: ${color("bold", diff.oldScore?.toFixed(1) ?? "?")} → ${color("bold", diff.newScore?.toFixed(1) ?? "?")} ${arrow} ${diff.scoreChange.toFixed(1)} (${direction})`);
1325
+ }
1326
+ if (diff.gradeChange) {
1327
+ const colored = diff.regressed ? color("red", diff.newGrade) : color("green", diff.newGrade);
1328
+ console.log(` Grade: ${diff.oldGrade ?? "?"} → ${colored}`);
1329
+ }
1330
+ if (diff.componentChanges?.length > 0) {
1331
+ console.log("");
1332
+ console.log(` ${color("dim", "Component changes:")}`);
1333
+ for (const c of diff.componentChanges) {
1334
+ const arrow = c.change > 0 ? color("red", "↑") : color("green", "↓");
1335
+ console.log(` ${c.name.padEnd(20, " ")} ${c.before?.toFixed(2) ?? "?"} → ${c.after?.toFixed(2) ?? "?"} ${arrow} ${Math.abs(c.change).toFixed(2)}`);
1336
+ }
1337
+ }
1338
+ if (diff.findingsAdded?.length > 0) {
1339
+ console.log("");
1340
+ console.log(` ${color("red", "+ New findings:")}`);
1341
+ for (const f of diff.findingsAdded) console.log(` [${f.severity}] ${f.title}`);
1342
+ }
1343
+ if (diff.findingsResolved?.length > 0) {
1344
+ console.log("");
1345
+ console.log(` ${color("green", "- Resolved findings:")}`);
1346
+ for (const f of diff.findingsResolved) console.log(` [${f.severity}] ${f.title}`);
1347
+ }
1348
+ console.log("");
1349
+ process.exit(diff.regressed ? 2 : 0);
1350
+ }
1351
+
1352
+ function computeLockDiff(oldLock, newLock) {
1353
+ const oldScore = typeof oldLock?.score === "number" ? oldLock.score : oldLock?.summary?.score;
1354
+ const newScore = typeof newLock?.score === "number" ? newLock.score : newLock?.summary?.score;
1355
+ const oldGrade = oldLock?.grade || oldLock?.summary?.grade;
1356
+ const newGrade = newLock?.grade || newLock?.summary?.grade;
1357
+ const scoreChange = (typeof oldScore === "number" && typeof newScore === "number") ? Math.round((newScore - oldScore) * 100) / 100 : null;
1358
+ const componentChanges = [];
1359
+ const oldComp = oldLock?.components || {};
1360
+ const newComp = newLock?.components || {};
1361
+ for (const k of Object.keys({ ...oldComp, ...newComp })) {
1362
+ const before = typeof oldComp[k]?.contribution === "number" ? oldComp[k].contribution : null;
1363
+ const after = typeof newComp[k]?.contribution === "number" ? newComp[k].contribution : null;
1364
+ if (before !== null && after !== null && Math.abs(after - before) >= 0.05) {
1365
+ componentChanges.push({ name: k, before, after, change: Math.round((after - before) * 100) / 100 });
1366
+ }
1367
+ }
1368
+ // Findings comparison by title
1369
+ const oldFindings = Array.isArray(oldLock?.findings) ? oldLock.findings : [];
1370
+ const newFindings = Array.isArray(newLock?.findings) ? newLock.findings : [];
1371
+ const oldTitles = new Set(oldFindings.map(f => f.title));
1372
+ const newTitles = new Set(newFindings.map(f => f.title));
1373
+ const findingsAdded = newFindings.filter(f => !oldTitles.has(f.title));
1374
+ const findingsResolved = oldFindings.filter(f => !newTitles.has(f.title));
1375
+ const regressed = (typeof scoreChange === "number" && scoreChange > 0.1) || findingsAdded.some(f => f.severity === "high" || f.severity === "critical");
1376
+ return {
1377
+ oldScore, newScore, oldGrade, newGrade, scoreChange,
1378
+ gradeChange: oldGrade !== newGrade,
1379
+ componentChanges,
1380
+ findingsAdded, findingsResolved,
1381
+ regressed,
1382
+ };
1383
+ }
1384
+
1385
+ // =============================================================================
1386
+ // `pqcheck cert <pem-file>` — analyze a local cert file (offline)
1387
+ // =============================================================================
1388
+
1389
+ async function runCertCommand(args) {
1390
+ const fs = await import("node:fs/promises");
1391
+ const crypto = await import("node:crypto");
1392
+ const json = args.includes("--json");
1393
+ const positional = args.filter((a) => !a.startsWith("-"));
1394
+ if (positional.length === 0) {
1395
+ console.error(color("red", "error: pqcheck cert requires a cert file path"));
1396
+ console.error(color("dim", "Usage: npx pqcheck cert <path-to-pem-or-crt> [--json]"));
1397
+ process.exit(1);
1398
+ }
1399
+
1400
+ let pemData;
1401
+ try {
1402
+ pemData = await fs.readFile(positional[0], "utf8");
1403
+ } catch (err) {
1404
+ console.error(color("red", `error reading cert file: ${err.message}`));
1405
+ process.exit(1);
1406
+ }
1407
+
1408
+ let cert;
1409
+ try {
1410
+ cert = new crypto.X509Certificate(pemData);
1411
+ } catch (err) {
1412
+ console.error(color("red", `error parsing cert: ${err.message}`));
1413
+ console.error(color("dim", "Expected a PEM-encoded X.509 cert (.pem, .crt, .cer)."));
1414
+ process.exit(1);
1415
+ }
1416
+
1417
+ const validFrom = new Date(cert.validFrom);
1418
+ const validTo = new Date(cert.validTo);
1419
+ const daysLeft = Math.round((validTo.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
1420
+ const sigAlg = (cert.publicKey?.asymmetricKeyType || "unknown").toUpperCase();
1421
+ const keyBits = cert.publicKey?.asymmetricKeyDetails?.modulusLength || cert.publicKey?.asymmetricKeyDetails?.namedCurve || "?";
1422
+ const sans = (cert.subjectAltName || "").split(",").map(s => s.trim()).filter(Boolean);
1423
+ const isWildcard = sans.some(s => s.includes("*."));
1424
+
1425
+ // Quantum exposure assessment
1426
+ let quantumNote;
1427
+ if (sigAlg === "RSA" && Number(keyBits) >= 2048) {
1428
+ quantumNote = "RSA-" + keyBits + " — broken by Shor's algorithm once a CRQC exists";
1429
+ } else if (sigAlg === "EC" || sigAlg === "ECDSA") {
1430
+ quantumNote = "ECDSA (" + keyBits + ") — broken by Shor's algorithm once a CRQC exists";
1431
+ } else if (sigAlg === "ED25519") {
1432
+ quantumNote = "Ed25519 — broken by Shor's algorithm once a CRQC exists";
1433
+ } else {
1434
+ quantumNote = sigAlg + " — quantum exposure unknown";
1435
+ }
1436
+
1437
+ const result = {
1438
+ file: positional[0],
1439
+ subject: cert.subject,
1440
+ issuer: cert.issuer,
1441
+ serialNumber: cert.serialNumber,
1442
+ validFrom: validFrom.toISOString(),
1443
+ validTo: validTo.toISOString(),
1444
+ daysUntilExpiry: daysLeft,
1445
+ keyAlgorithm: sigAlg,
1446
+ keyBits,
1447
+ sans,
1448
+ isWildcard,
1449
+ isCA: cert.ca,
1450
+ quantumExposure: quantumNote,
1451
+ };
1452
+
1453
+ if (json) {
1454
+ console.log(JSON.stringify(result, null, 2));
1455
+ return;
1456
+ }
1457
+
1458
+ console.log("");
1459
+ console.log(` ${color("bold", "Cert analysis")}: ${color("violet", positional[0])}`);
1460
+ console.log("");
1461
+ console.log(` Subject: ${cert.subject}`);
1462
+ console.log(` Issuer: ${cert.issuer}`);
1463
+ console.log(` Valid: ${validFrom.toISOString().slice(0, 10)} → ${validTo.toISOString().slice(0, 10)} (${daysLeft} days remaining)`);
1464
+ console.log(` Serial: ${cert.serialNumber}`);
1465
+ console.log(` Key: ${sigAlg}-${keyBits}`);
1466
+ console.log(` SANs (${sans.length}): ${sans.slice(0, 4).join(", ")}${sans.length > 4 ? ", ..." : ""}`);
1467
+ console.log(` Wildcard: ${isWildcard ? "yes" : "no"}`);
1468
+ console.log(` CA cert: ${cert.ca ? "yes" : "no"}`);
1469
+ console.log("");
1470
+ console.log(` ${color("yellow", "Quantum exposure:")} ${quantumNote}`);
1471
+ console.log("");
1472
+ }
1473
+
1048
1474
  main().catch((err) => {
1049
1475
  console.error(color("red", `fatal: ${err.message}`));
1050
1476
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.6.0",
3
+ "version": "0.7.2",
4
4
  "description": "Decryption Blast Radius scanner — find out how much of your data unlocks when quantum decryption arrives.",
5
5
  "keywords": [
6
6
  "post-quantum",