pqcheck 0.2.0 → 0.4.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/README.md +16 -2
- package/bin/pqcheck.js +302 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,12 +14,17 @@ That's it. No install. Works from any terminal with Node 18+.
|
|
|
14
14
|
|
|
15
15
|
`pqcheck` scans any HTTPS domain and computes its **Decryption Blast Radius Score** — the first continuous metric for harvest-now-decrypt-later (HNDL) risk. Every other TLS scanner answers "is post-quantum cryptography enabled?" with a yes/no. `pqcheck` answers the question that actually matters: *if an adversary harvests this traffic today and decrypts it in 2035, how much past + future data unlocks?*
|
|
16
16
|
|
|
17
|
-
The score combines:
|
|
17
|
+
The score combines (Quantum / cert findings — our differentiator):
|
|
18
|
+
- **Public-key reuse across rotations** — detects when the same private key has been live across multiple cert renewals (often 4+ years at large enterprises). **★ Unique to pqcheck — no other ASM/TLS scanner surfaces this.**
|
|
18
19
|
- **Cipher-class probing** — does the server accept RSA fallback even if it prefers ECDHE?
|
|
19
20
|
- **Certificate chain analysis** — including the intermediate cert (the chain's actual quantum failure point)
|
|
20
|
-
- **Public-key reuse across rotations** — detects when the same private key has been live across multiple cert renewals (often 4+ years at large enterprises)
|
|
21
21
|
- **Subject scale** — wildcard certs and subdomain count multiplying the blast radius
|
|
22
22
|
|
|
23
|
+
Plus a full ASM check suite for credibility (so the report doesn't feel narrow):
|
|
24
|
+
- **Email security** — SPF, DMARC, DKIM (15 selectors probed), BIMI
|
|
25
|
+
- **HTTP header security** — HSTS (with preload + max-age), CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, COOP, CORP
|
|
26
|
+
- **Subdomain takeover detection** — fingerprint-based scan against AWS S3, GitHub Pages, Heroku, Shopify, Fastly, and 5+ other commonly-orphaned services
|
|
27
|
+
|
|
23
28
|
## Example
|
|
24
29
|
|
|
25
30
|
```
|
|
@@ -83,6 +88,15 @@ The `--threshold` flag turns `pqcheck` into a quantum-risk gate for any pipeline
|
|
|
83
88
|
|
|
84
89
|
If the score is 7.0 or higher, the step fails and the PR can't merge.
|
|
85
90
|
|
|
91
|
+
For GitHub Actions specifically, there's also a [first-class action](https://github.com/mzon7/quantapact/tree/main/action) with `score` / `grade` / `report-url` outputs:
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
- uses: mzon7/quantapact/action@main
|
|
95
|
+
with:
|
|
96
|
+
domain: mycompany.com
|
|
97
|
+
threshold: '7'
|
|
98
|
+
```
|
|
99
|
+
|
|
86
100
|
For finer control, combine `--quiet` with shell logic:
|
|
87
101
|
|
|
88
102
|
```bash
|
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.4.0";
|
|
11
11
|
|
|
12
12
|
const ANSI = {
|
|
13
13
|
reset: "\x1b[0m",
|
|
@@ -34,8 +34,13 @@ async function main() {
|
|
|
34
34
|
process.exit(0);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// Multi-domain support: any non-flag positional arg is a domain
|
|
38
|
+
const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
|
|
39
|
+
const domains = positional
|
|
40
|
+
.map((a) => normalizeDomain(a))
|
|
41
|
+
.filter((d) => !!d);
|
|
42
|
+
|
|
43
|
+
if (domains.length === 0) {
|
|
39
44
|
console.error(color("red", "error: no domain provided"));
|
|
40
45
|
printUsage();
|
|
41
46
|
process.exit(1);
|
|
@@ -44,49 +49,178 @@ async function main() {
|
|
|
44
49
|
const quiet = args.includes("--quiet") || args.includes("-q");
|
|
45
50
|
const format = parseFormat(args);
|
|
46
51
|
const threshold = parseThreshold(args);
|
|
52
|
+
const watchInterval = parseWatch(args);
|
|
53
|
+
const webhookUrl = parseWebhook(args);
|
|
54
|
+
|
|
47
55
|
if (threshold === "invalid") {
|
|
48
56
|
console.error(color("red", "error: --threshold requires a number 0-10"));
|
|
49
57
|
process.exit(1);
|
|
50
58
|
}
|
|
59
|
+
if (watchInterval === "invalid") {
|
|
60
|
+
console.error(color("red", "error: --watch requires a positive number of seconds (default 300 if no value given)"));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (webhookUrl === "invalid") {
|
|
64
|
+
console.error(color("red", "error: --webhook requires a URL starting with http:// or https://"));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Watch mode loop: scan, diff, optionally webhook, repeat.
|
|
69
|
+
if (watchInterval !== null) {
|
|
70
|
+
await runWatch({ domains, format, quiet, threshold, webhookUrl, intervalSec: watchInterval });
|
|
71
|
+
return; // runWatch handles its own exit; in practice it runs until killed.
|
|
72
|
+
}
|
|
51
73
|
|
|
52
|
-
|
|
74
|
+
// One-shot scan(s)
|
|
75
|
+
let worstExit = 0;
|
|
76
|
+
for (const domain of domains) {
|
|
77
|
+
const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1 });
|
|
78
|
+
if (exit > worstExit) worstExit = exit;
|
|
79
|
+
}
|
|
80
|
+
process.exit(worstExit);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi }) {
|
|
84
|
+
if (!quiet && format === "text") process.stderr.write(color("dim", `Scanning ${domain} ...`));
|
|
53
85
|
let report;
|
|
54
86
|
try {
|
|
55
87
|
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
|
|
56
88
|
method: "GET",
|
|
57
89
|
headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION}` },
|
|
58
90
|
});
|
|
59
|
-
if (!quiet) process.stderr.write("\r\x1b[K");
|
|
91
|
+
if (!quiet && format === "text") process.stderr.write("\r\x1b[K");
|
|
60
92
|
if (!resp.ok) {
|
|
61
93
|
const errBody = await safeJSON(resp);
|
|
62
|
-
console.error(color("red", `error: ${resp.status} ${errBody?.error || resp.statusText}`));
|
|
94
|
+
console.error(color("red", `error scanning ${domain}: ${resp.status} ${errBody?.error || resp.statusText}`));
|
|
63
95
|
if (errBody?.detail) console.error(color("dim", errBody.detail));
|
|
64
|
-
|
|
96
|
+
return 1;
|
|
65
97
|
}
|
|
66
98
|
report = await resp.json();
|
|
67
99
|
} catch (err) {
|
|
68
|
-
if (!quiet) process.stderr.write("\r\x1b[K");
|
|
69
|
-
console.error(color("red", `error: ${err.message}`));
|
|
70
|
-
|
|
100
|
+
if (!quiet && format === "text") process.stderr.write("\r\x1b[K");
|
|
101
|
+
console.error(color("red", `error scanning ${domain}: ${err.message}`));
|
|
102
|
+
return 1;
|
|
71
103
|
}
|
|
72
104
|
|
|
105
|
+
// Webhook delivery — fire-and-forget POST with JSON body
|
|
106
|
+
if (webhookUrl) {
|
|
107
|
+
fetch(webhookUrl, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "content-type": "application/json", "user-agent": `pqcheck-cli/${VERSION}` },
|
|
110
|
+
body: JSON.stringify({ domain, report, source: "pqcheck-cli", at: new Date().toISOString() }),
|
|
111
|
+
}).catch(() => { /* best-effort — never fail the scan on webhook delivery */ });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Output dispatch
|
|
73
115
|
if (quiet) {
|
|
74
|
-
|
|
75
|
-
|
|
116
|
+
if (multi) {
|
|
117
|
+
console.log(`${domain}\t${typeof report.score === "number" ? report.score : ""}`);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(typeof report.score === "number" ? report.score : "");
|
|
120
|
+
}
|
|
76
121
|
} else if (format === "json") {
|
|
77
|
-
|
|
122
|
+
if (multi) {
|
|
123
|
+
// Per-line NDJSON when scanning multiple domains so output is pipe-friendly
|
|
124
|
+
console.log(JSON.stringify(report));
|
|
125
|
+
} else {
|
|
126
|
+
console.log(JSON.stringify(report, null, 2));
|
|
127
|
+
}
|
|
128
|
+
} else if (format === "csv") {
|
|
129
|
+
if (multi && this && !this.csvHeaderPrinted) {
|
|
130
|
+
// Header only once, before first row
|
|
131
|
+
}
|
|
132
|
+
printCsvRow(report);
|
|
133
|
+
} else if (format === "markdown") {
|
|
134
|
+
printMarkdown(report, multi);
|
|
78
135
|
} else {
|
|
136
|
+
if (multi) console.log(color("dim", `\n──── ${domain} ────`));
|
|
79
137
|
printReport(report);
|
|
80
138
|
}
|
|
81
139
|
|
|
82
|
-
// Threshold gating: exit 2 if score >= threshold (distinct from exit 1 = error)
|
|
83
140
|
if (threshold !== null && typeof report.score === "number" && report.score >= threshold) {
|
|
84
|
-
if (!quiet) {
|
|
85
|
-
console.error(color("red", `threshold breach: score ${report.score} >= ${threshold}`));
|
|
141
|
+
if (!quiet && format === "text") {
|
|
142
|
+
console.error(color("red", `threshold breach: ${domain} score ${report.score} >= ${threshold}`));
|
|
143
|
+
}
|
|
144
|
+
return 2;
|
|
145
|
+
}
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function runWatch({ domains, format, quiet, threshold, webhookUrl, intervalSec }) {
|
|
150
|
+
const previous = new Map(); // domain → previous score
|
|
151
|
+
if (!quiet && format === "text") {
|
|
152
|
+
console.error(color("dim", `Watching ${domains.length} domain(s), polling every ${intervalSec}s. Ctrl-C to stop.`));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Print CSV header once if csv mode
|
|
156
|
+
if (format === "csv") printCsvHeader();
|
|
157
|
+
|
|
158
|
+
// Trap SIGINT so we exit cleanly
|
|
159
|
+
let stopped = false;
|
|
160
|
+
process.on("SIGINT", () => {
|
|
161
|
+
stopped = true;
|
|
162
|
+
if (!quiet && format === "text") console.error(color("dim", "\nStopped."));
|
|
163
|
+
process.exit(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
while (!stopped) {
|
|
167
|
+
for (const domain of domains) {
|
|
168
|
+
try {
|
|
169
|
+
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
|
|
170
|
+
method: "GET",
|
|
171
|
+
headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION}` },
|
|
172
|
+
});
|
|
173
|
+
if (!resp.ok) continue;
|
|
174
|
+
const report = await resp.json();
|
|
175
|
+
const prev = previous.get(domain);
|
|
176
|
+
const changed = prev !== undefined && typeof report.score === "number" && report.score !== prev;
|
|
177
|
+
previous.set(domain, report.score);
|
|
178
|
+
|
|
179
|
+
if (changed && webhookUrl) {
|
|
180
|
+
fetch(webhookUrl, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "content-type": "application/json", "user-agent": `pqcheck-cli/${VERSION}` },
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
type: "score_changed",
|
|
185
|
+
domain,
|
|
186
|
+
previousScore: prev,
|
|
187
|
+
newScore: report.score,
|
|
188
|
+
report,
|
|
189
|
+
at: new Date().toISOString(),
|
|
190
|
+
}),
|
|
191
|
+
}).catch(() => {});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (format === "csv") {
|
|
195
|
+
printCsvRow(report);
|
|
196
|
+
} else if (format === "json") {
|
|
197
|
+
console.log(JSON.stringify({ ...report, _watchPreviousScore: prev ?? null, _watchChanged: changed }));
|
|
198
|
+
} else if (format === "markdown") {
|
|
199
|
+
printMarkdown(report, true);
|
|
200
|
+
} else {
|
|
201
|
+
const stamp = new Date().toISOString().slice(11, 19);
|
|
202
|
+
if (changed) {
|
|
203
|
+
console.log(color("yellow", `[${stamp}] ${domain}: ${prev} → ${report.score} (${report.scoreLabel}) ${color("yellow", "★ changed")}`));
|
|
204
|
+
} else if (!quiet) {
|
|
205
|
+
console.log(color("dim", `[${stamp}] ${domain}: ${report.score} (${report.scoreLabel})`));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (!quiet && format === "text") console.error(color("red", `[watch] ${domain}: ${err.message}`));
|
|
210
|
+
}
|
|
86
211
|
}
|
|
87
|
-
|
|
212
|
+
await sleep(intervalSec * 1000);
|
|
88
213
|
}
|
|
89
|
-
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
217
|
+
|
|
218
|
+
function isFlagValue(args, val) {
|
|
219
|
+
// True when this arg is the value following a flag like --threshold 7 or --format json
|
|
220
|
+
const idx = args.indexOf(val);
|
|
221
|
+
if (idx <= 0) return false;
|
|
222
|
+
const prev = args[idx - 1];
|
|
223
|
+
return prev === "--threshold" || prev === "--format" || prev === "--watch" || prev === "--webhook";
|
|
90
224
|
}
|
|
91
225
|
|
|
92
226
|
function parseFormat(args) {
|
|
@@ -94,7 +228,8 @@ function parseFormat(args) {
|
|
|
94
228
|
const i = args.indexOf("--format");
|
|
95
229
|
if (i === -1) return "text";
|
|
96
230
|
const v = (args[i + 1] || "").toLowerCase();
|
|
97
|
-
|
|
231
|
+
if (v === "json" || v === "csv" || v === "markdown" || v === "md") return v === "md" ? "markdown" : v;
|
|
232
|
+
return "text";
|
|
98
233
|
}
|
|
99
234
|
|
|
100
235
|
function parseThreshold(args) {
|
|
@@ -106,6 +241,94 @@ function parseThreshold(args) {
|
|
|
106
241
|
return n;
|
|
107
242
|
}
|
|
108
243
|
|
|
244
|
+
function parseWatch(args) {
|
|
245
|
+
const i = args.indexOf("--watch");
|
|
246
|
+
if (i === -1) return null;
|
|
247
|
+
const raw = args[i + 1];
|
|
248
|
+
// --watch with no value defaults to 300s (5 min)
|
|
249
|
+
if (raw === undefined || raw.startsWith("-")) return 300;
|
|
250
|
+
const n = parseInt(raw, 10);
|
|
251
|
+
if (!Number.isFinite(n) || n < 10) return "invalid";
|
|
252
|
+
return n;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseWebhook(args) {
|
|
256
|
+
const i = args.indexOf("--webhook");
|
|
257
|
+
if (i === -1) return null;
|
|
258
|
+
const raw = args[i + 1];
|
|
259
|
+
if (!raw || !/^https?:\/\//.test(raw)) return "invalid";
|
|
260
|
+
return raw;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------- format renderers (CSV + markdown) ----------
|
|
264
|
+
|
|
265
|
+
let _csvHeaderPrinted = false;
|
|
266
|
+
function printCsvHeader() {
|
|
267
|
+
console.log("domain,score,grade,score_label,reachable,tls_version,hybrid_pqc,days_until_cert_expiry,subdomains,wildcard_cert,findings_high,findings_medium,findings_low");
|
|
268
|
+
_csvHeaderPrinted = true;
|
|
269
|
+
}
|
|
270
|
+
function printCsvRow(r) {
|
|
271
|
+
if (!_csvHeaderPrinted) printCsvHeader();
|
|
272
|
+
const ps = r.publicSurface || {};
|
|
273
|
+
const sevCount = (sev) => (r.findings || []).filter((f) => f.severity === sev).length;
|
|
274
|
+
const cells = [
|
|
275
|
+
csvEscape(r.domain),
|
|
276
|
+
r.score ?? "",
|
|
277
|
+
r.grade ?? "",
|
|
278
|
+
r.scoreLabel ?? "",
|
|
279
|
+
r.reachable ? "true" : "false",
|
|
280
|
+
csvEscape(ps.tlsVersion ?? ""),
|
|
281
|
+
ps.hybridPQC ? "true" : "false",
|
|
282
|
+
ps.daysUntilCertExpiry ?? "",
|
|
283
|
+
ps.subdomainCount ?? 0,
|
|
284
|
+
ps.wildcardCert ? "true" : "false",
|
|
285
|
+
sevCount("high") + sevCount("critical"),
|
|
286
|
+
sevCount("medium"),
|
|
287
|
+
sevCount("low"),
|
|
288
|
+
];
|
|
289
|
+
console.log(cells.join(","));
|
|
290
|
+
}
|
|
291
|
+
function csvEscape(s) {
|
|
292
|
+
const v = String(s ?? "");
|
|
293
|
+
if (v.includes(",") || v.includes('"') || v.includes("\n")) {
|
|
294
|
+
return '"' + v.replace(/"/g, '""') + '"';
|
|
295
|
+
}
|
|
296
|
+
return v;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function printMarkdown(r, multi) {
|
|
300
|
+
if (!r.reachable) {
|
|
301
|
+
console.log(`### ${r.domain} — unreachable\n${r.errorMessage ? `> ${r.errorMessage}` : ""}\n`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const ps = r.publicSurface || {};
|
|
305
|
+
const lines = [];
|
|
306
|
+
lines.push(`### ${r.domain} — Decryption Blast Radius **${r.score} / 10** (${r.scoreLabel}, grade ${r.grade ?? "—"})`);
|
|
307
|
+
lines.push("");
|
|
308
|
+
lines.push("| Signal | Value |");
|
|
309
|
+
lines.push("|---|---|");
|
|
310
|
+
lines.push(`| TLS | ${ps.tlsVersion ?? "?"}${ps.cipher ? ` (${ps.cipher})` : ""} |`);
|
|
311
|
+
lines.push(`| Hybrid PQC | ${ps.hybridPQC ? "yes" : "no"} |`);
|
|
312
|
+
lines.push(`| Cert expires | ${ps.daysUntilCertExpiry !== null && ps.daysUntilCertExpiry !== undefined ? `in ${ps.daysUntilCertExpiry} days` : "?"} |`);
|
|
313
|
+
lines.push(`| HSTS | ${ps.hsts ? "enabled" : "not detected"} |`);
|
|
314
|
+
lines.push(`| Subdomains | ${ps.subdomainCount ?? 0}${ps.wildcardCert ? " (wildcard cert)" : ""} |`);
|
|
315
|
+
lines.push("");
|
|
316
|
+
if (r.findings && r.findings.length) {
|
|
317
|
+
lines.push("**Findings:**");
|
|
318
|
+
lines.push("");
|
|
319
|
+
for (const f of r.findings) {
|
|
320
|
+
lines.push(`- **[${f.severity.toUpperCase()}]** ${f.title}`);
|
|
321
|
+
lines.push(` ${f.detail}`);
|
|
322
|
+
}
|
|
323
|
+
lines.push("");
|
|
324
|
+
}
|
|
325
|
+
lines.push(`> ⚠ Public surface only. Internal Blast Radius is typically 12–40× this score.`);
|
|
326
|
+
lines.push("");
|
|
327
|
+
lines.push(`Full report: ${API_BASE}/?check=${encodeURIComponent(r.domain)} · Share: ${API_BASE}/r/${encodeURIComponent(r.domain)}`);
|
|
328
|
+
if (multi) lines.push("\n---\n");
|
|
329
|
+
console.log(lines.join("\n"));
|
|
330
|
+
}
|
|
331
|
+
|
|
109
332
|
function printReport(r) {
|
|
110
333
|
if (!r.reachable) {
|
|
111
334
|
console.log(color("red", `\n ${r.domain} — unreachable`));
|
|
@@ -131,16 +354,47 @@ function printReport(r) {
|
|
|
131
354
|
console.log(` • HSTS: ${r.publicSurface.hsts ? color("green", "enabled") : color("dim", "not detected")}`);
|
|
132
355
|
console.log(` • Subdomains: ${r.publicSurface.subdomainCount}${r.publicSurface.wildcardCert ? color("yellow", " (wildcard cert)") : ""}`);
|
|
133
356
|
console.log("");
|
|
357
|
+
// Coverage line — communicates breadth of checks
|
|
358
|
+
const coverage = ["TLS", "Cert chain", "Cipher class", color("violet", "★ Key reuse (CT-log)"), "Subdomains"];
|
|
359
|
+
if (r.emailSecurity) coverage.push("SPF", "DMARC", "DKIM", "BIMI");
|
|
360
|
+
if (r.httpHeaders && r.httpHeaders.reachable) coverage.push("HSTS", "CSP", "X-Frame", "Referrer-Policy");
|
|
361
|
+
if (r.subdomainTakeover) coverage.push("Takeover");
|
|
362
|
+
console.log(color("dim", " Checked: ") + coverage.join(color("dim", " · ")));
|
|
363
|
+
console.log("");
|
|
364
|
+
|
|
134
365
|
if (r.findings && r.findings.length) {
|
|
135
|
-
|
|
366
|
+
// Group findings by category. Quantum/cert findings come first since
|
|
367
|
+
// they're the differentiator; ASM-completeness findings come after.
|
|
368
|
+
const groups = { quantum: [], takeover: [], email: [], headers: [], other: [] };
|
|
136
369
|
for (const f of r.findings) {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
370
|
+
const t = (f.title || "").toLowerCase();
|
|
371
|
+
if (/spf|dmarc|dkim|bimi/.test(t)) groups.email.push(f);
|
|
372
|
+
else if (/hsts|csp|x-frame|content-?type|referrer|clickjacking|server version|permissions-policy/.test(t)) groups.headers.push(f);
|
|
373
|
+
else if (/takeover/.test(t)) groups.takeover.push(f);
|
|
374
|
+
else if (/key reused?|key persist|reused for|cert rotation|chain weakest|rsa fallback|ecdhe|quantum/.test(t)) groups.quantum.push(f);
|
|
375
|
+
else groups.other.push(f);
|
|
376
|
+
}
|
|
377
|
+
const sections = [
|
|
378
|
+
["quantum", "Quantum & cert exposure (our differentiator):", groups.quantum],
|
|
379
|
+
["takeover", "Subdomain takeover:", groups.takeover],
|
|
380
|
+
["email", "Email security (SPF / DMARC / DKIM / BIMI):", groups.email],
|
|
381
|
+
["headers", "HTTP header security:", groups.headers],
|
|
382
|
+
["other", "Other:", groups.other],
|
|
383
|
+
];
|
|
384
|
+
for (const [key, label, arr] of sections) {
|
|
385
|
+
if (!arr || arr.length === 0) continue;
|
|
386
|
+
console.log(color("violet", ` ${label}`));
|
|
387
|
+
for (const f of arr) {
|
|
388
|
+
const sev = f.severity.toUpperCase();
|
|
389
|
+
const sevColor =
|
|
390
|
+
f.severity === "critical" ? "red" :
|
|
391
|
+
f.severity === "high" ? "yellow" :
|
|
392
|
+
f.severity === "medium" ? "yellow" : "dim";
|
|
393
|
+
const isUnique = key === "quantum" && /key reused?|reused for|key persist/.test((f.title || "").toLowerCase());
|
|
394
|
+
const star = isUnique ? color("violet", " ★ UNIQUE TO PQCHECK") : "";
|
|
395
|
+
console.log(` ${color(sevColor, `[${sev}]`)} ${f.title}${star}`);
|
|
396
|
+
console.log(color("dim", ` ${f.detail}`));
|
|
397
|
+
}
|
|
144
398
|
}
|
|
145
399
|
console.log("");
|
|
146
400
|
}
|
|
@@ -197,18 +451,25 @@ ${color("bold", "pqcheck")} ${color("dim", `v${VERSION}`)}
|
|
|
197
451
|
Public Surface Blast Radius — quantum-decryption risk for any domain.
|
|
198
452
|
|
|
199
453
|
${color("bold", "Usage:")}
|
|
200
|
-
npx pqcheck <domain>
|
|
201
|
-
npx pqcheck
|
|
202
|
-
npx pqcheck <domain> --
|
|
203
|
-
npx pqcheck <domain> --
|
|
454
|
+
npx pqcheck <domain> Scan + print human-readable report
|
|
455
|
+
npx pqcheck a.com b.com c.com Multi-domain scan
|
|
456
|
+
npx pqcheck <domain> --format json Raw JSON
|
|
457
|
+
npx pqcheck <domain> --format markdown GitHub-issue / Slack-ready Markdown
|
|
458
|
+
npx pqcheck <domain> --format csv Spreadsheet-friendly CSV row
|
|
459
|
+
npx pqcheck <domain> --threshold 7 Exit 2 if score ≥ 7 (CI gate)
|
|
460
|
+
npx pqcheck <domain> --quiet Print just the score number
|
|
461
|
+
npx pqcheck <domain> --watch [seconds] Continuously poll and report changes
|
|
462
|
+
npx pqcheck <domain> --webhook <url> POST report JSON to URL on each scan
|
|
204
463
|
|
|
205
464
|
${color("bold", "Options:")}
|
|
206
|
-
-h, --help
|
|
207
|
-
-v, --version
|
|
208
|
-
--format <text|json>
|
|
209
|
-
--json
|
|
210
|
-
--threshold <0-10>
|
|
211
|
-
-q, --quiet
|
|
465
|
+
-h, --help Show this help
|
|
466
|
+
-v, --version Show version
|
|
467
|
+
--format <text|json|markdown|csv> Output format (default: text)
|
|
468
|
+
--json Alias for --format json
|
|
469
|
+
--threshold <0-10> Exit 2 if score meets or exceeds this
|
|
470
|
+
-q, --quiet Print only the numeric score
|
|
471
|
+
--watch [seconds] Poll every N seconds (default 300) and report changes
|
|
472
|
+
--webhook <url> POST scan results to a URL (one-shot or each watch tick)
|
|
212
473
|
|
|
213
474
|
${color("bold", "Exit codes:")}
|
|
214
475
|
0 success
|
|
@@ -217,8 +478,10 @@ ${color("bold", "Exit codes:")}
|
|
|
217
478
|
|
|
218
479
|
${color("bold", "Examples:")}
|
|
219
480
|
npx pqcheck chase.com
|
|
220
|
-
npx pqcheck
|
|
221
|
-
npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if
|
|
481
|
+
npx pqcheck mycompany.com mythirdparty.com --format csv > posture.csv
|
|
482
|
+
npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if score ≥ 7")}
|
|
483
|
+
npx pqcheck mybank.com --format markdown >> issue.md
|
|
484
|
+
npx pqcheck mybank.com --watch 600 --webhook https://hooks.slack.com/services/...
|
|
222
485
|
echo "score: $(npx pqcheck mybank.com -q)"
|
|
223
486
|
|
|
224
487
|
Backed by the patented Decryption Blast Radius methodology.
|