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.
- package/bin/pqcheck.js +452 -26
- 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.
|
|
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"
|
|
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", "
|
|
501
|
+
${color("bold", "Commands:")}
|
|
467
502
|
npx pqcheck <domain> Scan + print human-readable report
|
|
468
|
-
npx pqcheck lock <domain> Generate quantapact.lock (QXM)
|
|
469
|
-
npx pqcheck deps <domain> Scan third-party origins (supply-chain HNDL)
|
|
470
|
-
npx pqcheck
|
|
471
|
-
npx pqcheck <domain>
|
|
472
|
-
npx pqcheck <
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
npx pqcheck
|
|
476
|
-
npx pqcheck
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
--
|
|
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
|
|
499
|
-
npx pqcheck
|
|
500
|
-
|
|
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);
|