pqcheck 0.5.0 → 0.7.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/bin/pqcheck.js +710 -7
- 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.0";
|
|
11
11
|
|
|
12
12
|
const ANSI = {
|
|
13
13
|
reset: "\x1b[0m",
|
|
@@ -34,15 +34,45 @@ async function main() {
|
|
|
34
34
|
process.exit(0);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// Subcommand dispatch
|
|
38
|
-
// Anything else is treated as the default scan command.
|
|
37
|
+
// Subcommand dispatch.
|
|
39
38
|
if (args[0] === "lock") {
|
|
40
39
|
return runLockCommand(args.slice(1));
|
|
41
40
|
}
|
|
41
|
+
if (args[0] === "deps") {
|
|
42
|
+
return runDepsCommand(args.slice(1));
|
|
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
|
+
}
|
|
42
73
|
|
|
43
|
-
// Multi-domain support: any non-flag positional arg is a domain
|
|
44
74
|
const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
|
|
45
|
-
const domains = positional
|
|
75
|
+
const domains = [...positional, ...fileDomains]
|
|
46
76
|
.map((a) => normalizeDomain(a))
|
|
47
77
|
.filter((d) => !!d);
|
|
48
78
|
|
|
@@ -138,6 +168,10 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi
|
|
|
138
168
|
printCsvRow(report);
|
|
139
169
|
} else if (format === "markdown") {
|
|
140
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);
|
|
141
175
|
} else {
|
|
142
176
|
if (multi) console.log(color("dim", `\n──── ${domain} ────`));
|
|
143
177
|
printReport(report);
|
|
@@ -226,15 +260,18 @@ function isFlagValue(args, val) {
|
|
|
226
260
|
const idx = args.indexOf(val);
|
|
227
261
|
if (idx <= 0) return false;
|
|
228
262
|
const prev = args[idx - 1];
|
|
229
|
-
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";
|
|
230
264
|
}
|
|
231
265
|
|
|
232
266
|
function parseFormat(args) {
|
|
233
267
|
if (args.includes("--json")) return "json"; // back-compat alias
|
|
268
|
+
if (args.includes("--gh-action")) return "gh-action"; // GitHub Actions annotation format
|
|
234
269
|
const i = args.indexOf("--format");
|
|
235
270
|
if (i === -1) return "text";
|
|
236
271
|
const v = (args[i + 1] || "").toLowerCase();
|
|
237
|
-
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
|
+
}
|
|
238
275
|
return "text";
|
|
239
276
|
}
|
|
240
277
|
|
|
@@ -464,6 +501,7 @@ Public Surface Blast Radius — quantum-decryption risk for any domain.
|
|
|
464
501
|
${color("bold", "Usage:")}
|
|
465
502
|
npx pqcheck <domain> Scan + print human-readable report
|
|
466
503
|
npx pqcheck lock <domain> Generate quantapact.lock (QXM) for repo commit
|
|
504
|
+
npx pqcheck deps <domain> Scan third-party origins (supply-chain HNDL); --lock for committable manifest
|
|
467
505
|
npx pqcheck a.com b.com c.com Multi-domain scan
|
|
468
506
|
npx pqcheck <domain> --format json Raw JSON
|
|
469
507
|
npx pqcheck <domain> --format markdown GitHub-issue / Slack-ready Markdown
|
|
@@ -739,6 +777,671 @@ function renderQxmMarkdown(m) {
|
|
|
739
777
|
return lines.join("\n");
|
|
740
778
|
}
|
|
741
779
|
|
|
780
|
+
// =============================================================================
|
|
781
|
+
// `pqcheck deps` — supply-chain HNDL scan for a target domain
|
|
782
|
+
// =============================================================================
|
|
783
|
+
// Fetches the public HTML of the target domain, extracts third-party origins
|
|
784
|
+
// referenced via <script src>, <iframe src>, <link href>, <img src>, then runs
|
|
785
|
+
// /api/scan against each unique third party. Outputs a sorted summary + an
|
|
786
|
+
// optional committable lockfile (quantapact-deps.lock).
|
|
787
|
+
//
|
|
788
|
+
// Parallel to the browser extension's Dependencies tab, exposed as a CLI for
|
|
789
|
+
// CI integration: gate PR builds on third-party crypto posture.
|
|
790
|
+
//
|
|
791
|
+
// Usage:
|
|
792
|
+
// npx pqcheck deps <domain> Scan + print summary table
|
|
793
|
+
// npx pqcheck deps <domain> --json JSON output (pipe to jq, etc.)
|
|
794
|
+
// npx pqcheck deps <domain> --lock Also write quantapact-deps.lock + .md
|
|
795
|
+
// npx pqcheck deps <domain> -o dir/ Output directory for --lock files
|
|
796
|
+
// npx pqcheck deps <domain> --max=20 Cap on third parties scanned (default 20)
|
|
797
|
+
// =============================================================================
|
|
798
|
+
|
|
799
|
+
async function runDepsCommand(args) {
|
|
800
|
+
const fs = await import("node:fs/promises");
|
|
801
|
+
const path = await import("node:path");
|
|
802
|
+
|
|
803
|
+
const json = args.includes("--json");
|
|
804
|
+
const lock = args.includes("--lock");
|
|
805
|
+
const outIdx = args.indexOf("-o");
|
|
806
|
+
const outDir = outIdx >= 0 ? args[outIdx + 1] : ".";
|
|
807
|
+
const maxArg = args.find((a) => a.startsWith("--max="));
|
|
808
|
+
const maxThirdParties = maxArg ? Math.max(1, parseInt(maxArg.slice(6), 10) || 20) : 20;
|
|
809
|
+
|
|
810
|
+
// Allowlist support: --allowlist <path> reads newline-separated host patterns.
|
|
811
|
+
// If a third-party host is NOT in the allowlist, the scan exits non-zero with code 3.
|
|
812
|
+
// Useful as a CI gate for vendor-risk teams: "fail PR if any unapproved third-party appears."
|
|
813
|
+
const allowlistIdx = args.indexOf("--allowlist");
|
|
814
|
+
let allowlist = null;
|
|
815
|
+
if (allowlistIdx >= 0) {
|
|
816
|
+
const allowlistPath = args[allowlistIdx + 1];
|
|
817
|
+
try {
|
|
818
|
+
const fs2 = await import("node:fs/promises");
|
|
819
|
+
const raw = await fs2.readFile(allowlistPath, "utf8");
|
|
820
|
+
allowlist = new Set(raw.split(/\r?\n/).map(l => l.trim().toLowerCase()).filter(l => l && !l.startsWith("#")));
|
|
821
|
+
} catch (err) {
|
|
822
|
+
console.error(color("red", `error reading --allowlist: ${err.message}`));
|
|
823
|
+
process.exit(1);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const positional = args.filter((a) => !a.startsWith("-") && a !== outDir);
|
|
828
|
+
const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
|
|
829
|
+
if (!domain || !isValidDomain(domain)) {
|
|
830
|
+
console.error(color("red", "error: pqcheck deps requires a valid domain"));
|
|
831
|
+
console.error(color("dim", "Usage: npx pqcheck deps <domain> [--json|--lock] [-o dir/] [--max=N]"));
|
|
832
|
+
process.exit(1);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (!json) process.stderr.write(color("dim", `Fetching ${domain} HTML...`));
|
|
836
|
+
const html = await fetchPageHTML(domain);
|
|
837
|
+
if (!json) process.stderr.write("\r\x1b[K");
|
|
838
|
+
if (!html) {
|
|
839
|
+
console.error(color("red", `error: could not fetch https://${domain}/`));
|
|
840
|
+
process.exit(1);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const refs = extractThirdPartyRefs(html, domain);
|
|
844
|
+
if (refs.length === 0) {
|
|
845
|
+
if (json) {
|
|
846
|
+
console.log(JSON.stringify({ domain, scannedAt: new Date().toISOString(), thirdParties: [], summary: { uniqueOrigins: 0, totalReferences: 0 } }, null, 2));
|
|
847
|
+
} else {
|
|
848
|
+
console.log("");
|
|
849
|
+
console.log(` ${color("violet", domain)} ${color("dim", "·")} ${color("bold", "no third-party origins detected")}`);
|
|
850
|
+
console.log(color("dim", " (page is fully first-party, or HTML didn't load script/iframe/link refs)"));
|
|
851
|
+
console.log("");
|
|
852
|
+
}
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Group by host, dedupe
|
|
857
|
+
const byHost = new Map();
|
|
858
|
+
for (const r of refs) {
|
|
859
|
+
if (!byHost.has(r.host)) byHost.set(r.host, { host: r.host, types: new Set(), occurrences: 0 });
|
|
860
|
+
const e = byHost.get(r.host);
|
|
861
|
+
e.types.add(r.type);
|
|
862
|
+
e.occurrences += 1;
|
|
863
|
+
}
|
|
864
|
+
const uniqueHosts = Array.from(byHost.values()).slice(0, maxThirdParties);
|
|
865
|
+
|
|
866
|
+
if (!json) process.stderr.write(color("dim", `Scanning ${uniqueHosts.length} third-party origins...`));
|
|
867
|
+
|
|
868
|
+
// Scan each host (parallel batches of 4 to avoid hammering the API)
|
|
869
|
+
const BATCH = 4;
|
|
870
|
+
const results = [];
|
|
871
|
+
for (let i = 0; i < uniqueHosts.length; i += BATCH) {
|
|
872
|
+
const batch = uniqueHosts.slice(i, i + BATCH);
|
|
873
|
+
const batchResults = await Promise.all(
|
|
874
|
+
batch.map(async (h) => {
|
|
875
|
+
try {
|
|
876
|
+
const r = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(h.host)}&source=cli-deps`, {
|
|
877
|
+
headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (deps)` },
|
|
878
|
+
});
|
|
879
|
+
if (!r.ok) return { ...h, types: Array.from(h.types), scan: null, error: `${r.status}` };
|
|
880
|
+
const body = await r.json();
|
|
881
|
+
return {
|
|
882
|
+
...h,
|
|
883
|
+
types: Array.from(h.types),
|
|
884
|
+
scan: {
|
|
885
|
+
grade: body.grade,
|
|
886
|
+
score: body.score,
|
|
887
|
+
reachable: body.reachable,
|
|
888
|
+
hybridPQC: body.publicSurface?.hybridPQC ?? false,
|
|
889
|
+
},
|
|
890
|
+
};
|
|
891
|
+
} catch (e) {
|
|
892
|
+
return { ...h, types: Array.from(h.types), scan: null, error: e.message };
|
|
893
|
+
}
|
|
894
|
+
})
|
|
895
|
+
);
|
|
896
|
+
results.push(...batchResults);
|
|
897
|
+
}
|
|
898
|
+
if (!json) process.stderr.write("\r\x1b[K");
|
|
899
|
+
|
|
900
|
+
// Sort: F first (worst), then D, C, B, A; unreachable/error to bottom
|
|
901
|
+
const gradeRank = { F: 5, D: 4, C: 3, B: 2, A: 1 };
|
|
902
|
+
results.sort((a, b) => {
|
|
903
|
+
const ar = a.scan?.grade ? gradeRank[a.scan.grade] || 0 : -1;
|
|
904
|
+
const br = b.scan?.grade ? gradeRank[b.scan.grade] || 0 : -1;
|
|
905
|
+
return br - ar;
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
const summary = buildDepsSummary(results);
|
|
909
|
+
|
|
910
|
+
// Build manifest
|
|
911
|
+
const manifest = {
|
|
912
|
+
$schema: "https://quantapact.com/schemas/deps/v1",
|
|
913
|
+
schemaVersion: "1.0",
|
|
914
|
+
domain,
|
|
915
|
+
scannedAt: new Date().toISOString(),
|
|
916
|
+
tool: "pqcheck-cli",
|
|
917
|
+
toolVersion: VERSION,
|
|
918
|
+
summary,
|
|
919
|
+
thirdParties: results.map((r) => ({
|
|
920
|
+
host: r.host,
|
|
921
|
+
types: r.types,
|
|
922
|
+
occurrences: r.occurrences,
|
|
923
|
+
scan: r.scan,
|
|
924
|
+
error: r.error,
|
|
925
|
+
})),
|
|
926
|
+
evidence: {
|
|
927
|
+
methodology: `${API_BASE}/methodology/browser-extension`,
|
|
928
|
+
reportLink: `${API_BASE}/r/${domain}`,
|
|
929
|
+
},
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
if (json) {
|
|
933
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Pretty terminal table
|
|
938
|
+
console.log("");
|
|
939
|
+
console.log(` ${color("bold", "Supply-chain HNDL exposure")} for ${color("violet", domain)}`);
|
|
940
|
+
console.log(` ${color("dim", `${summary.uniqueOrigins} unique third-party origins · ${summary.totalReferences} references · weakest: ${summary.weakestLink?.host ?? "—"} (${summary.weakestLink?.grade ?? "—"})`)}`);
|
|
941
|
+
console.log("");
|
|
942
|
+
console.log(` ${color("dim", "GRADE HOST PQC TYPES")}`);
|
|
943
|
+
console.log(` ${color("dim", "───── ───────────────────────────────────────── ─── ─────")}`);
|
|
944
|
+
for (const r of results) {
|
|
945
|
+
const gradeStr = r.scan?.grade ?? "?";
|
|
946
|
+
const gradeColored = gradeStr === "A" ? color("green", gradeStr) : gradeStr === "F" || gradeStr === "D" ? color("red", gradeStr) : color("yellow", gradeStr);
|
|
947
|
+
const host = r.host.length > 41 ? r.host.slice(0, 40) + "…" : r.host.padEnd(41, " ");
|
|
948
|
+
const pqc = r.scan?.hybridPQC ? color("green", "yes") : color("dim", "no ");
|
|
949
|
+
const types = r.types.join(",");
|
|
950
|
+
console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${pqc} ${color("dim", types)}`);
|
|
951
|
+
}
|
|
952
|
+
console.log("");
|
|
953
|
+
console.log(` ${color("dim", "Each row scanned via")} ${color("violet", "/api/scan")}${color("dim", " · /methodology/browser-extension explains scoring")}`);
|
|
954
|
+
console.log("");
|
|
955
|
+
|
|
956
|
+
if (lock) {
|
|
957
|
+
const lockPath = path.join(outDir, "quantapact-deps.lock");
|
|
958
|
+
const mdPath = path.join(outDir, "quantapact-deps-report.md");
|
|
959
|
+
try {
|
|
960
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
961
|
+
await fs.writeFile(lockPath, JSON.stringify(manifest, null, 2));
|
|
962
|
+
await fs.writeFile(mdPath, depsManifestToMarkdown(manifest));
|
|
963
|
+
console.log(` ${color("bold", "Wrote")} ${color("violet", lockPath)} ${color("dim", "and")} ${color("violet", mdPath)}`);
|
|
964
|
+
console.log(` ${color("dim", "Commit these to track third-party crypto posture changes in PR diffs.")}`);
|
|
965
|
+
console.log("");
|
|
966
|
+
} catch (err) {
|
|
967
|
+
console.error(color("red", `error writing lockfile: ${err.message}`));
|
|
968
|
+
process.exit(1);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Allowlist gate: exit non-zero if any third-party host isn't in the allowlist.
|
|
973
|
+
if (allowlist) {
|
|
974
|
+
const violations = results.filter(r => !allowlist.has(r.host));
|
|
975
|
+
if (violations.length > 0) {
|
|
976
|
+
if (!json) {
|
|
977
|
+
console.error("");
|
|
978
|
+
console.error(color("red", ` ✗ Allowlist violation: ${violations.length} third-party origin(s) not in allowlist:`));
|
|
979
|
+
for (const v of violations) console.error(` - ${v.host}`);
|
|
980
|
+
console.error("");
|
|
981
|
+
}
|
|
982
|
+
process.exit(3);
|
|
983
|
+
} else if (!json) {
|
|
984
|
+
console.log(` ${color("green", "✓")} ${color("dim", "All third-party origins on allowlist.")}`);
|
|
985
|
+
console.log("");
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
async function fetchPageHTML(domain) {
|
|
991
|
+
try {
|
|
992
|
+
const ctrl = new AbortController();
|
|
993
|
+
const t = setTimeout(() => ctrl.abort(), 8000);
|
|
994
|
+
const resp = await fetch(`https://${domain}/`, {
|
|
995
|
+
method: "GET",
|
|
996
|
+
redirect: "follow",
|
|
997
|
+
signal: ctrl.signal,
|
|
998
|
+
headers: { "User-Agent": `pqcheck-cli/${VERSION} (deps; +https://quantapact.com)` },
|
|
999
|
+
});
|
|
1000
|
+
clearTimeout(t);
|
|
1001
|
+
if (!resp.ok) return null;
|
|
1002
|
+
return await resp.text();
|
|
1003
|
+
} catch {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function extractThirdPartyRefs(html, targetDomain) {
|
|
1009
|
+
const out = [];
|
|
1010
|
+
// Patterns: <tag ... attr="..."> — non-greedy, single or double quoted
|
|
1011
|
+
const patterns = [
|
|
1012
|
+
{ type: "script", re: /<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
|
|
1013
|
+
{ type: "iframe", re: /<iframe\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
|
|
1014
|
+
{ type: "link", re: /<link\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi },
|
|
1015
|
+
{ type: "img", re: /<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi },
|
|
1016
|
+
];
|
|
1017
|
+
const targetRoot = registeredDomain(targetDomain);
|
|
1018
|
+
for (const { type, re } of patterns) {
|
|
1019
|
+
let m;
|
|
1020
|
+
while ((m = re.exec(html)) !== null) {
|
|
1021
|
+
try {
|
|
1022
|
+
const u = new URL(m[1], `https://${targetDomain}`);
|
|
1023
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") continue;
|
|
1024
|
+
const host = u.hostname.toLowerCase();
|
|
1025
|
+
if (!host || host === targetDomain || registeredDomain(host) === targetRoot) continue;
|
|
1026
|
+
out.push({ host, type });
|
|
1027
|
+
} catch { /* relative URL or malformed */ }
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return out;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Cheap registered-domain helper — covers common 2-label TLDs (co.uk, com.au, etc.)
|
|
1034
|
+
function registeredDomain(host) {
|
|
1035
|
+
const parts = host.split(".");
|
|
1036
|
+
if (parts.length <= 2) return host;
|
|
1037
|
+
const last2 = parts.slice(-2).join(".");
|
|
1038
|
+
const doubleTLDs = new Set([
|
|
1039
|
+
"co.uk", "co.jp", "co.nz", "co.za", "com.au", "com.br", "com.cn", "com.mx",
|
|
1040
|
+
"com.tr", "ne.jp", "ac.uk", "gov.uk", "org.uk", "edu.au", "gov.au",
|
|
1041
|
+
]);
|
|
1042
|
+
if (doubleTLDs.has(last2)) return parts.slice(-3).join(".");
|
|
1043
|
+
return last2;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function buildDepsSummary(results) {
|
|
1047
|
+
const byGrade = { A: 0, B: 0, C: 0, D: 0, F: 0, "?": 0 };
|
|
1048
|
+
let totalRefs = 0;
|
|
1049
|
+
let scoreSum = 0;
|
|
1050
|
+
let scoreN = 0;
|
|
1051
|
+
let pqcCount = 0;
|
|
1052
|
+
let weakest = null;
|
|
1053
|
+
for (const r of results) {
|
|
1054
|
+
totalRefs += r.occurrences;
|
|
1055
|
+
const g = r.scan?.grade ?? "?";
|
|
1056
|
+
byGrade[g] = (byGrade[g] || 0) + 1;
|
|
1057
|
+
if (typeof r.scan?.score === "number") {
|
|
1058
|
+
scoreSum += r.scan.score;
|
|
1059
|
+
scoreN += 1;
|
|
1060
|
+
if (!weakest || r.scan.score > weakest.score) {
|
|
1061
|
+
weakest = { host: r.host, grade: r.scan.grade, score: r.scan.score };
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (r.scan?.hybridPQC) pqcCount += 1;
|
|
1065
|
+
}
|
|
1066
|
+
return {
|
|
1067
|
+
uniqueOrigins: results.length,
|
|
1068
|
+
totalReferences: totalRefs,
|
|
1069
|
+
byGrade,
|
|
1070
|
+
averageScore: scoreN > 0 ? Math.round((scoreSum / scoreN) * 10) / 10 : null,
|
|
1071
|
+
hybridPQCCount: pqcCount,
|
|
1072
|
+
weakestLink: weakest,
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function depsManifestToMarkdown(m) {
|
|
1077
|
+
const lines = [];
|
|
1078
|
+
lines.push(`# Supply-chain HNDL exposure: ${m.domain}`);
|
|
1079
|
+
lines.push("");
|
|
1080
|
+
lines.push(`Scanned at \`${m.scannedAt}\` by \`${m.tool}@${m.toolVersion}\`.`);
|
|
1081
|
+
lines.push("");
|
|
1082
|
+
lines.push("## Summary");
|
|
1083
|
+
lines.push("");
|
|
1084
|
+
lines.push(`- **Unique third-party origins:** ${m.summary.uniqueOrigins}`);
|
|
1085
|
+
lines.push(`- **Total references:** ${m.summary.totalReferences}`);
|
|
1086
|
+
lines.push(`- **Average HNDL score:** ${m.summary.averageScore ?? "—"} / 10`);
|
|
1087
|
+
lines.push(`- **Hybrid-PQC origins:** ${m.summary.hybridPQCCount} / ${m.summary.uniqueOrigins}`);
|
|
1088
|
+
if (m.summary.weakestLink) {
|
|
1089
|
+
lines.push(`- **Weakest link:** \`${m.summary.weakestLink.host}\` — grade ${m.summary.weakestLink.grade}, score ${m.summary.weakestLink.score}`);
|
|
1090
|
+
}
|
|
1091
|
+
lines.push("");
|
|
1092
|
+
lines.push("## Grade distribution");
|
|
1093
|
+
lines.push("");
|
|
1094
|
+
lines.push("| Grade | Count |");
|
|
1095
|
+
lines.push("|---|---|");
|
|
1096
|
+
for (const g of ["A", "B", "C", "D", "F", "?"]) {
|
|
1097
|
+
if ((m.summary.byGrade[g] || 0) > 0) lines.push(`| ${g} | ${m.summary.byGrade[g]} |`);
|
|
1098
|
+
}
|
|
1099
|
+
lines.push("");
|
|
1100
|
+
lines.push("## Third parties");
|
|
1101
|
+
lines.push("");
|
|
1102
|
+
lines.push("| Grade | Host | PQC | Types | Occurrences |");
|
|
1103
|
+
lines.push("|---|---|---|---|---|");
|
|
1104
|
+
for (const tp of m.thirdParties) {
|
|
1105
|
+
const grade = tp.scan?.grade ?? "?";
|
|
1106
|
+
const pqc = tp.scan?.hybridPQC ? "yes" : "no";
|
|
1107
|
+
lines.push(`| ${grade} | \`${tp.host}\` | ${pqc} | ${tp.types.join(", ")} | ${tp.occurrences} |`);
|
|
1108
|
+
}
|
|
1109
|
+
lines.push("");
|
|
1110
|
+
lines.push("---");
|
|
1111
|
+
lines.push("");
|
|
1112
|
+
lines.push("*Methodology: [/methodology/browser-extension](" + m.evidence.methodology + "). Re-run `npx pqcheck deps " + m.domain + " --lock` to refresh; commit the lockfile to track changes in pull requests.*");
|
|
1113
|
+
lines.push("");
|
|
1114
|
+
return lines.join("\n");
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// =============================================================================
|
|
1118
|
+
// SARIF + GitHub Action output formats
|
|
1119
|
+
// =============================================================================
|
|
1120
|
+
|
|
1121
|
+
function reportToSarif(report) {
|
|
1122
|
+
// SARIF 2.1.0 minimal schema for security findings.
|
|
1123
|
+
// Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
|
1124
|
+
const findings = Array.isArray(report.findings) ? report.findings : [];
|
|
1125
|
+
const sevMap = { critical: "error", high: "error", medium: "warning", low: "note" };
|
|
1126
|
+
return {
|
|
1127
|
+
$schema: "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json",
|
|
1128
|
+
version: "2.1.0",
|
|
1129
|
+
runs: [{
|
|
1130
|
+
tool: {
|
|
1131
|
+
driver: {
|
|
1132
|
+
name: "pqcheck",
|
|
1133
|
+
version: VERSION,
|
|
1134
|
+
informationUri: "https://quantapact.com",
|
|
1135
|
+
rules: findings.map((f, i) => ({
|
|
1136
|
+
id: `pqcheck-${i + 1}`,
|
|
1137
|
+
name: (f.title || "finding").replace(/[^A-Za-z0-9]/g, "_"),
|
|
1138
|
+
shortDescription: { text: f.title || "finding" },
|
|
1139
|
+
fullDescription: { text: f.detail || f.title || "finding" },
|
|
1140
|
+
defaultConfiguration: { level: sevMap[f.severity] || "note" },
|
|
1141
|
+
})),
|
|
1142
|
+
},
|
|
1143
|
+
},
|
|
1144
|
+
results: findings.map((f, i) => ({
|
|
1145
|
+
ruleId: `pqcheck-${i + 1}`,
|
|
1146
|
+
level: sevMap[f.severity] || "note",
|
|
1147
|
+
message: { text: `${f.title || "finding"}${f.detail ? ` — ${f.detail}` : ""}` },
|
|
1148
|
+
locations: [{
|
|
1149
|
+
physicalLocation: {
|
|
1150
|
+
artifactLocation: { uri: `https://${report.domain || ""}` },
|
|
1151
|
+
},
|
|
1152
|
+
}],
|
|
1153
|
+
properties: {
|
|
1154
|
+
domain: report.domain,
|
|
1155
|
+
score: report.score,
|
|
1156
|
+
grade: report.grade,
|
|
1157
|
+
severity: f.severity,
|
|
1158
|
+
},
|
|
1159
|
+
})),
|
|
1160
|
+
properties: {
|
|
1161
|
+
score: report.score,
|
|
1162
|
+
grade: report.grade,
|
|
1163
|
+
domain: report.domain,
|
|
1164
|
+
},
|
|
1165
|
+
}],
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function printGitHubActionAnnotations(report) {
|
|
1170
|
+
// GitHub Actions workflow command syntax: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
|
|
1171
|
+
const findings = Array.isArray(report.findings) ? report.findings : [];
|
|
1172
|
+
const sevMap = { critical: "error", high: "error", medium: "warning", low: "notice" };
|
|
1173
|
+
// Top-line score/grade as a notice
|
|
1174
|
+
console.log(`::notice title=Quantapact: ${report.domain}::Grade ${report.grade || "?"} · score ${report.score ?? "?"} / 10`);
|
|
1175
|
+
for (const f of findings) {
|
|
1176
|
+
const cmd = sevMap[f.severity] || "notice";
|
|
1177
|
+
const title = (f.title || "finding").replace(/[\r\n]/g, " ");
|
|
1178
|
+
const msg = (f.detail || f.title || "").replace(/[\r\n]/g, " ").replace(/::/g, ":");
|
|
1179
|
+
console.log(`::${cmd} title=${title}::${msg}`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// =============================================================================
|
|
1184
|
+
// `pqcheck history` — show recent score history for a domain
|
|
1185
|
+
// =============================================================================
|
|
1186
|
+
|
|
1187
|
+
async function runHistoryCommand(args) {
|
|
1188
|
+
const json = args.includes("--json");
|
|
1189
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
1190
|
+
const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
|
|
1191
|
+
if (!domain || !isValidDomain(domain)) {
|
|
1192
|
+
console.error(color("red", "error: pqcheck history requires a valid domain"));
|
|
1193
|
+
console.error(color("dim", "Usage: npx pqcheck history <domain> [--json]"));
|
|
1194
|
+
process.exit(1);
|
|
1195
|
+
}
|
|
1196
|
+
const days = (() => {
|
|
1197
|
+
const i = args.indexOf("--days");
|
|
1198
|
+
if (i === -1) return 90;
|
|
1199
|
+
const n = parseInt(args[i + 1] || "90", 10);
|
|
1200
|
+
return Number.isFinite(n) && n > 0 && n <= 365 ? n : 90;
|
|
1201
|
+
})();
|
|
1202
|
+
|
|
1203
|
+
let h;
|
|
1204
|
+
try {
|
|
1205
|
+
const r = await fetch(`${API_BASE}/api/history?domain=${encodeURIComponent(domain)}&days=${days}`, {
|
|
1206
|
+
headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (history)` },
|
|
1207
|
+
});
|
|
1208
|
+
if (!r.ok) {
|
|
1209
|
+
console.error(color("red", `error: ${r.status} ${r.statusText}`));
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
h = await r.json();
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
console.error(color("red", `error: ${err.message}`));
|
|
1215
|
+
process.exit(1);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (json) {
|
|
1219
|
+
console.log(JSON.stringify(h, null, 2));
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const points = Array.isArray(h?.points) ? h.points : [];
|
|
1224
|
+
if (points.length === 0) {
|
|
1225
|
+
console.log(` ${color("violet", domain)} ${color("dim", "·")} no history found`);
|
|
1226
|
+
console.log(color("dim", " (need at least one previous scan; try `npx pqcheck " + domain + "` first)"));
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
const sorted = points.slice().reverse(); // oldest → newest
|
|
1230
|
+
const first = sorted[0].score;
|
|
1231
|
+
const last = sorted[sorted.length - 1].score;
|
|
1232
|
+
const delta = last - first;
|
|
1233
|
+
const min = Math.min(...sorted.map(p => p.score));
|
|
1234
|
+
const max = Math.max(...sorted.map(p => p.score));
|
|
1235
|
+
const trend = Math.abs(delta) < 0.1 ? "→ flat" : delta > 0 ? `↑ +${delta.toFixed(1)} (worsened)` : `↓ ${delta.toFixed(1)} (improved)`;
|
|
1236
|
+
|
|
1237
|
+
console.log("");
|
|
1238
|
+
console.log(` ${color("bold", domain)} ${color("dim", "·")} score history (${days}d, ${sorted.length} samples)`);
|
|
1239
|
+
console.log(` ${color("dim", `range ${min.toFixed(1)} – ${max.toFixed(1)} · trend ${trend}`)}`);
|
|
1240
|
+
console.log("");
|
|
1241
|
+
// Compact ASCII sparkline
|
|
1242
|
+
const range = Math.max(0.1, max - min);
|
|
1243
|
+
const ramp = "▁▂▃▄▅▆▇█";
|
|
1244
|
+
let bar = "";
|
|
1245
|
+
for (const p of sorted) {
|
|
1246
|
+
const idx = Math.min(ramp.length - 1, Math.floor(((p.score - min) / range) * (ramp.length - 1)));
|
|
1247
|
+
bar += ramp[idx];
|
|
1248
|
+
}
|
|
1249
|
+
console.log(` ${color("violet", bar)}`);
|
|
1250
|
+
console.log("");
|
|
1251
|
+
// Tail of recent samples
|
|
1252
|
+
console.log(` ${color("dim", "Recent samples (most-recent first):")}`);
|
|
1253
|
+
for (const p of points.slice(0, 8)) {
|
|
1254
|
+
const date = (p.scannedAt || p.date || "").slice(0, 10);
|
|
1255
|
+
console.log(` ${color("dim", date)} score ${color("bold", p.score?.toFixed(1) ?? "?")} grade ${p.grade ?? "?"}`);
|
|
1256
|
+
}
|
|
1257
|
+
console.log("");
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// =============================================================================
|
|
1261
|
+
// `pqcheck diff` — diff two QXM lockfiles
|
|
1262
|
+
// =============================================================================
|
|
1263
|
+
|
|
1264
|
+
async function runDiffCommand(args) {
|
|
1265
|
+
const fs = await import("node:fs/promises");
|
|
1266
|
+
const json = args.includes("--json");
|
|
1267
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
1268
|
+
if (positional.length !== 2) {
|
|
1269
|
+
console.error(color("red", "error: pqcheck diff requires two lockfile paths"));
|
|
1270
|
+
console.error(color("dim", "Usage: npx pqcheck diff old.lock new.lock [--json]"));
|
|
1271
|
+
process.exit(1);
|
|
1272
|
+
}
|
|
1273
|
+
let oldLock, newLock;
|
|
1274
|
+
try {
|
|
1275
|
+
oldLock = JSON.parse(await fs.readFile(positional[0], "utf8"));
|
|
1276
|
+
newLock = JSON.parse(await fs.readFile(positional[1], "utf8"));
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
console.error(color("red", `error reading lockfile: ${err.message}`));
|
|
1279
|
+
process.exit(1);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const diff = computeLockDiff(oldLock, newLock);
|
|
1283
|
+
if (json) {
|
|
1284
|
+
console.log(JSON.stringify(diff, null, 2));
|
|
1285
|
+
process.exit(diff.regressed ? 2 : 0);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
console.log("");
|
|
1289
|
+
console.log(` ${color("bold", "Quantapact lockfile diff")}`);
|
|
1290
|
+
console.log(` ${color("dim", `${positional[0]} → ${positional[1]}`)}`);
|
|
1291
|
+
console.log("");
|
|
1292
|
+
if (diff.scoreChange !== null) {
|
|
1293
|
+
const arrow = diff.scoreChange > 0 ? color("red", "↑") : diff.scoreChange < 0 ? color("green", "↓") : color("dim", "→");
|
|
1294
|
+
const direction = diff.scoreChange > 0 ? "worsened" : diff.scoreChange < 0 ? "improved" : "unchanged";
|
|
1295
|
+
console.log(` Score: ${color("bold", diff.oldScore?.toFixed(1) ?? "?")} → ${color("bold", diff.newScore?.toFixed(1) ?? "?")} ${arrow} ${diff.scoreChange.toFixed(1)} (${direction})`);
|
|
1296
|
+
}
|
|
1297
|
+
if (diff.gradeChange) {
|
|
1298
|
+
const colored = diff.regressed ? color("red", diff.newGrade) : color("green", diff.newGrade);
|
|
1299
|
+
console.log(` Grade: ${diff.oldGrade ?? "?"} → ${colored}`);
|
|
1300
|
+
}
|
|
1301
|
+
if (diff.componentChanges?.length > 0) {
|
|
1302
|
+
console.log("");
|
|
1303
|
+
console.log(` ${color("dim", "Component changes:")}`);
|
|
1304
|
+
for (const c of diff.componentChanges) {
|
|
1305
|
+
const arrow = c.change > 0 ? color("red", "↑") : color("green", "↓");
|
|
1306
|
+
console.log(` ${c.name.padEnd(20, " ")} ${c.before?.toFixed(2) ?? "?"} → ${c.after?.toFixed(2) ?? "?"} ${arrow} ${Math.abs(c.change).toFixed(2)}`);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
if (diff.findingsAdded?.length > 0) {
|
|
1310
|
+
console.log("");
|
|
1311
|
+
console.log(` ${color("red", "+ New findings:")}`);
|
|
1312
|
+
for (const f of diff.findingsAdded) console.log(` [${f.severity}] ${f.title}`);
|
|
1313
|
+
}
|
|
1314
|
+
if (diff.findingsResolved?.length > 0) {
|
|
1315
|
+
console.log("");
|
|
1316
|
+
console.log(` ${color("green", "- Resolved findings:")}`);
|
|
1317
|
+
for (const f of diff.findingsResolved) console.log(` [${f.severity}] ${f.title}`);
|
|
1318
|
+
}
|
|
1319
|
+
console.log("");
|
|
1320
|
+
process.exit(diff.regressed ? 2 : 0);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function computeLockDiff(oldLock, newLock) {
|
|
1324
|
+
const oldScore = typeof oldLock?.score === "number" ? oldLock.score : oldLock?.summary?.score;
|
|
1325
|
+
const newScore = typeof newLock?.score === "number" ? newLock.score : newLock?.summary?.score;
|
|
1326
|
+
const oldGrade = oldLock?.grade || oldLock?.summary?.grade;
|
|
1327
|
+
const newGrade = newLock?.grade || newLock?.summary?.grade;
|
|
1328
|
+
const scoreChange = (typeof oldScore === "number" && typeof newScore === "number") ? Math.round((newScore - oldScore) * 100) / 100 : null;
|
|
1329
|
+
const componentChanges = [];
|
|
1330
|
+
const oldComp = oldLock?.components || {};
|
|
1331
|
+
const newComp = newLock?.components || {};
|
|
1332
|
+
for (const k of Object.keys({ ...oldComp, ...newComp })) {
|
|
1333
|
+
const before = typeof oldComp[k]?.contribution === "number" ? oldComp[k].contribution : null;
|
|
1334
|
+
const after = typeof newComp[k]?.contribution === "number" ? newComp[k].contribution : null;
|
|
1335
|
+
if (before !== null && after !== null && Math.abs(after - before) >= 0.05) {
|
|
1336
|
+
componentChanges.push({ name: k, before, after, change: Math.round((after - before) * 100) / 100 });
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
// Findings comparison by title
|
|
1340
|
+
const oldFindings = Array.isArray(oldLock?.findings) ? oldLock.findings : [];
|
|
1341
|
+
const newFindings = Array.isArray(newLock?.findings) ? newLock.findings : [];
|
|
1342
|
+
const oldTitles = new Set(oldFindings.map(f => f.title));
|
|
1343
|
+
const newTitles = new Set(newFindings.map(f => f.title));
|
|
1344
|
+
const findingsAdded = newFindings.filter(f => !oldTitles.has(f.title));
|
|
1345
|
+
const findingsResolved = oldFindings.filter(f => !newTitles.has(f.title));
|
|
1346
|
+
const regressed = (typeof scoreChange === "number" && scoreChange > 0.1) || findingsAdded.some(f => f.severity === "high" || f.severity === "critical");
|
|
1347
|
+
return {
|
|
1348
|
+
oldScore, newScore, oldGrade, newGrade, scoreChange,
|
|
1349
|
+
gradeChange: oldGrade !== newGrade,
|
|
1350
|
+
componentChanges,
|
|
1351
|
+
findingsAdded, findingsResolved,
|
|
1352
|
+
regressed,
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// =============================================================================
|
|
1357
|
+
// `pqcheck cert <pem-file>` — analyze a local cert file (offline)
|
|
1358
|
+
// =============================================================================
|
|
1359
|
+
|
|
1360
|
+
async function runCertCommand(args) {
|
|
1361
|
+
const fs = await import("node:fs/promises");
|
|
1362
|
+
const crypto = await import("node:crypto");
|
|
1363
|
+
const json = args.includes("--json");
|
|
1364
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
1365
|
+
if (positional.length === 0) {
|
|
1366
|
+
console.error(color("red", "error: pqcheck cert requires a cert file path"));
|
|
1367
|
+
console.error(color("dim", "Usage: npx pqcheck cert <path-to-pem-or-crt> [--json]"));
|
|
1368
|
+
process.exit(1);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
let pemData;
|
|
1372
|
+
try {
|
|
1373
|
+
pemData = await fs.readFile(positional[0], "utf8");
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
console.error(color("red", `error reading cert file: ${err.message}`));
|
|
1376
|
+
process.exit(1);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
let cert;
|
|
1380
|
+
try {
|
|
1381
|
+
cert = new crypto.X509Certificate(pemData);
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
console.error(color("red", `error parsing cert: ${err.message}`));
|
|
1384
|
+
console.error(color("dim", "Expected a PEM-encoded X.509 cert (.pem, .crt, .cer)."));
|
|
1385
|
+
process.exit(1);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const validFrom = new Date(cert.validFrom);
|
|
1389
|
+
const validTo = new Date(cert.validTo);
|
|
1390
|
+
const daysLeft = Math.round((validTo.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
|
1391
|
+
const sigAlg = (cert.publicKey?.asymmetricKeyType || "unknown").toUpperCase();
|
|
1392
|
+
const keyBits = cert.publicKey?.asymmetricKeyDetails?.modulusLength || cert.publicKey?.asymmetricKeyDetails?.namedCurve || "?";
|
|
1393
|
+
const sans = (cert.subjectAltName || "").split(",").map(s => s.trim()).filter(Boolean);
|
|
1394
|
+
const isWildcard = sans.some(s => s.includes("*."));
|
|
1395
|
+
|
|
1396
|
+
// Quantum exposure assessment
|
|
1397
|
+
let quantumNote;
|
|
1398
|
+
if (sigAlg === "RSA" && Number(keyBits) >= 2048) {
|
|
1399
|
+
quantumNote = "RSA-" + keyBits + " — broken by Shor's algorithm once a CRQC exists";
|
|
1400
|
+
} else if (sigAlg === "EC" || sigAlg === "ECDSA") {
|
|
1401
|
+
quantumNote = "ECDSA (" + keyBits + ") — broken by Shor's algorithm once a CRQC exists";
|
|
1402
|
+
} else if (sigAlg === "ED25519") {
|
|
1403
|
+
quantumNote = "Ed25519 — broken by Shor's algorithm once a CRQC exists";
|
|
1404
|
+
} else {
|
|
1405
|
+
quantumNote = sigAlg + " — quantum exposure unknown";
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const result = {
|
|
1409
|
+
file: positional[0],
|
|
1410
|
+
subject: cert.subject,
|
|
1411
|
+
issuer: cert.issuer,
|
|
1412
|
+
serialNumber: cert.serialNumber,
|
|
1413
|
+
validFrom: validFrom.toISOString(),
|
|
1414
|
+
validTo: validTo.toISOString(),
|
|
1415
|
+
daysUntilExpiry: daysLeft,
|
|
1416
|
+
keyAlgorithm: sigAlg,
|
|
1417
|
+
keyBits,
|
|
1418
|
+
sans,
|
|
1419
|
+
isWildcard,
|
|
1420
|
+
isCA: cert.ca,
|
|
1421
|
+
quantumExposure: quantumNote,
|
|
1422
|
+
};
|
|
1423
|
+
|
|
1424
|
+
if (json) {
|
|
1425
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
console.log("");
|
|
1430
|
+
console.log(` ${color("bold", "Cert analysis")}: ${color("violet", positional[0])}`);
|
|
1431
|
+
console.log("");
|
|
1432
|
+
console.log(` Subject: ${cert.subject}`);
|
|
1433
|
+
console.log(` Issuer: ${cert.issuer}`);
|
|
1434
|
+
console.log(` Valid: ${validFrom.toISOString().slice(0, 10)} → ${validTo.toISOString().slice(0, 10)} (${daysLeft} days remaining)`);
|
|
1435
|
+
console.log(` Serial: ${cert.serialNumber}`);
|
|
1436
|
+
console.log(` Key: ${sigAlg}-${keyBits}`);
|
|
1437
|
+
console.log(` SANs (${sans.length}): ${sans.slice(0, 4).join(", ")}${sans.length > 4 ? ", ..." : ""}`);
|
|
1438
|
+
console.log(` Wildcard: ${isWildcard ? "yes" : "no"}`);
|
|
1439
|
+
console.log(` CA cert: ${cert.ca ? "yes" : "no"}`);
|
|
1440
|
+
console.log("");
|
|
1441
|
+
console.log(` ${color("yellow", "Quantum exposure:")} ${quantumNote}`);
|
|
1442
|
+
console.log("");
|
|
1443
|
+
}
|
|
1444
|
+
|
|
742
1445
|
main().catch((err) => {
|
|
743
1446
|
console.error(color("red", `fatal: ${err.message}`));
|
|
744
1447
|
process.exit(2);
|