pqcheck 0.3.0 → 0.5.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 +28 -0
- package/bin/pqcheck.js +513 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,13 +61,41 @@ $ npx pqcheck chase.com
|
|
|
61
61
|
|
|
62
62
|
```
|
|
63
63
|
npx pqcheck <domain> Scan and print human-readable report
|
|
64
|
+
npx pqcheck lock <domain> Generate quantapact.lock (QXM artifact for repos)
|
|
64
65
|
npx pqcheck <domain> --format json Output raw JSON for piping / scripting
|
|
66
|
+
npx pqcheck <domain> --format markdown Output GitHub-issue / Slack-ready Markdown
|
|
65
67
|
npx pqcheck <domain> --threshold 7 Exit 2 if score ≥ 7 (CI gate)
|
|
66
68
|
npx pqcheck <domain> --quiet Print only the numeric score
|
|
69
|
+
npx pqcheck <domain> --watch [seconds] Continuously poll and report changes
|
|
70
|
+
npx pqcheck <domain> --webhook <url> POST results to a URL on each scan
|
|
67
71
|
npx pqcheck --help Show all options
|
|
68
72
|
npx pqcheck --version Show version
|
|
69
73
|
```
|
|
70
74
|
|
|
75
|
+
### QXM — Quantum Exposure Manifest (commit-able to your repo)
|
|
76
|
+
|
|
77
|
+
Like SBOM, `package-lock.json`, or `cargo audit` outputs — track quantum exposure as a versioned artifact in your repo. Diffs surface real changes in pull requests.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx pqcheck lock yourcompany.com
|
|
81
|
+
# Writes:
|
|
82
|
+
# quantapact.lock — stable JSON manifest
|
|
83
|
+
# quantapact-report.md — human-readable summary (renders on GitHub)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Commit both files. Re-run `npx pqcheck lock` whenever you want to refresh; the diff in the next PR shows what changed (score, findings, key-reuse window).
|
|
87
|
+
|
|
88
|
+
**In CI:**
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
- name: Refresh QXM lockfile
|
|
92
|
+
run: npx pqcheck@latest lock yourcompany.com
|
|
93
|
+
- name: Fail if score regressed
|
|
94
|
+
run: npx pqcheck@latest yourcompany.com --threshold 7
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The lockfile schema is documented at [quantapact.com/schemas/qxm/v1](https://quantapact.com/methodology) and is intended to be stable across CLI versions.
|
|
98
|
+
|
|
71
99
|
### Exit codes
|
|
72
100
|
|
|
73
101
|
| Code | Meaning |
|
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.5.0";
|
|
11
11
|
|
|
12
12
|
const ANSI = {
|
|
13
13
|
reset: "\x1b[0m",
|
|
@@ -34,8 +34,19 @@ async function main() {
|
|
|
34
34
|
process.exit(0);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// Subcommand dispatch — currently only "lock" (QXM artifact generator).
|
|
38
|
+
// Anything else is treated as the default scan command.
|
|
39
|
+
if (args[0] === "lock") {
|
|
40
|
+
return runLockCommand(args.slice(1));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Multi-domain support: any non-flag positional arg is a domain
|
|
44
|
+
const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
|
|
45
|
+
const domains = positional
|
|
46
|
+
.map((a) => normalizeDomain(a))
|
|
47
|
+
.filter((d) => !!d);
|
|
48
|
+
|
|
49
|
+
if (domains.length === 0) {
|
|
39
50
|
console.error(color("red", "error: no domain provided"));
|
|
40
51
|
printUsage();
|
|
41
52
|
process.exit(1);
|
|
@@ -44,49 +55,178 @@ async function main() {
|
|
|
44
55
|
const quiet = args.includes("--quiet") || args.includes("-q");
|
|
45
56
|
const format = parseFormat(args);
|
|
46
57
|
const threshold = parseThreshold(args);
|
|
58
|
+
const watchInterval = parseWatch(args);
|
|
59
|
+
const webhookUrl = parseWebhook(args);
|
|
60
|
+
|
|
47
61
|
if (threshold === "invalid") {
|
|
48
62
|
console.error(color("red", "error: --threshold requires a number 0-10"));
|
|
49
63
|
process.exit(1);
|
|
50
64
|
}
|
|
65
|
+
if (watchInterval === "invalid") {
|
|
66
|
+
console.error(color("red", "error: --watch requires a positive number of seconds (default 300 if no value given)"));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
if (webhookUrl === "invalid") {
|
|
70
|
+
console.error(color("red", "error: --webhook requires a URL starting with http:// or https://"));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
51
73
|
|
|
52
|
-
|
|
74
|
+
// Watch mode loop: scan, diff, optionally webhook, repeat.
|
|
75
|
+
if (watchInterval !== null) {
|
|
76
|
+
await runWatch({ domains, format, quiet, threshold, webhookUrl, intervalSec: watchInterval });
|
|
77
|
+
return; // runWatch handles its own exit; in practice it runs until killed.
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// One-shot scan(s)
|
|
81
|
+
let worstExit = 0;
|
|
82
|
+
for (const domain of domains) {
|
|
83
|
+
const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1 });
|
|
84
|
+
if (exit > worstExit) worstExit = exit;
|
|
85
|
+
}
|
|
86
|
+
process.exit(worstExit);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi }) {
|
|
90
|
+
if (!quiet && format === "text") process.stderr.write(color("dim", `Scanning ${domain} ...`));
|
|
53
91
|
let report;
|
|
54
92
|
try {
|
|
55
93
|
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
|
|
56
94
|
method: "GET",
|
|
57
95
|
headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION}` },
|
|
58
96
|
});
|
|
59
|
-
if (!quiet) process.stderr.write("\r\x1b[K");
|
|
97
|
+
if (!quiet && format === "text") process.stderr.write("\r\x1b[K");
|
|
60
98
|
if (!resp.ok) {
|
|
61
99
|
const errBody = await safeJSON(resp);
|
|
62
|
-
console.error(color("red", `error: ${resp.status} ${errBody?.error || resp.statusText}`));
|
|
100
|
+
console.error(color("red", `error scanning ${domain}: ${resp.status} ${errBody?.error || resp.statusText}`));
|
|
63
101
|
if (errBody?.detail) console.error(color("dim", errBody.detail));
|
|
64
|
-
|
|
102
|
+
return 1;
|
|
65
103
|
}
|
|
66
104
|
report = await resp.json();
|
|
67
105
|
} catch (err) {
|
|
68
|
-
if (!quiet) process.stderr.write("\r\x1b[K");
|
|
69
|
-
console.error(color("red", `error: ${err.message}`));
|
|
70
|
-
|
|
106
|
+
if (!quiet && format === "text") process.stderr.write("\r\x1b[K");
|
|
107
|
+
console.error(color("red", `error scanning ${domain}: ${err.message}`));
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Webhook delivery — fire-and-forget POST with JSON body
|
|
112
|
+
if (webhookUrl) {
|
|
113
|
+
fetch(webhookUrl, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "content-type": "application/json", "user-agent": `pqcheck-cli/${VERSION}` },
|
|
116
|
+
body: JSON.stringify({ domain, report, source: "pqcheck-cli", at: new Date().toISOString() }),
|
|
117
|
+
}).catch(() => { /* best-effort — never fail the scan on webhook delivery */ });
|
|
71
118
|
}
|
|
72
119
|
|
|
120
|
+
// Output dispatch
|
|
73
121
|
if (quiet) {
|
|
74
|
-
|
|
75
|
-
|
|
122
|
+
if (multi) {
|
|
123
|
+
console.log(`${domain}\t${typeof report.score === "number" ? report.score : ""}`);
|
|
124
|
+
} else {
|
|
125
|
+
console.log(typeof report.score === "number" ? report.score : "");
|
|
126
|
+
}
|
|
76
127
|
} else if (format === "json") {
|
|
77
|
-
|
|
128
|
+
if (multi) {
|
|
129
|
+
// Per-line NDJSON when scanning multiple domains so output is pipe-friendly
|
|
130
|
+
console.log(JSON.stringify(report));
|
|
131
|
+
} else {
|
|
132
|
+
console.log(JSON.stringify(report, null, 2));
|
|
133
|
+
}
|
|
134
|
+
} else if (format === "csv") {
|
|
135
|
+
if (multi && this && !this.csvHeaderPrinted) {
|
|
136
|
+
// Header only once, before first row
|
|
137
|
+
}
|
|
138
|
+
printCsvRow(report);
|
|
139
|
+
} else if (format === "markdown") {
|
|
140
|
+
printMarkdown(report, multi);
|
|
78
141
|
} else {
|
|
142
|
+
if (multi) console.log(color("dim", `\n──── ${domain} ────`));
|
|
79
143
|
printReport(report);
|
|
80
144
|
}
|
|
81
145
|
|
|
82
|
-
// Threshold gating: exit 2 if score >= threshold (distinct from exit 1 = error)
|
|
83
146
|
if (threshold !== null && typeof report.score === "number" && report.score >= threshold) {
|
|
84
|
-
if (!quiet) {
|
|
85
|
-
console.error(color("red", `threshold breach: score ${report.score} >= ${threshold}`));
|
|
147
|
+
if (!quiet && format === "text") {
|
|
148
|
+
console.error(color("red", `threshold breach: ${domain} score ${report.score} >= ${threshold}`));
|
|
86
149
|
}
|
|
87
|
-
|
|
150
|
+
return 2;
|
|
88
151
|
}
|
|
89
|
-
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function runWatch({ domains, format, quiet, threshold, webhookUrl, intervalSec }) {
|
|
156
|
+
const previous = new Map(); // domain → previous score
|
|
157
|
+
if (!quiet && format === "text") {
|
|
158
|
+
console.error(color("dim", `Watching ${domains.length} domain(s), polling every ${intervalSec}s. Ctrl-C to stop.`));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Print CSV header once if csv mode
|
|
162
|
+
if (format === "csv") printCsvHeader();
|
|
163
|
+
|
|
164
|
+
// Trap SIGINT so we exit cleanly
|
|
165
|
+
let stopped = false;
|
|
166
|
+
process.on("SIGINT", () => {
|
|
167
|
+
stopped = true;
|
|
168
|
+
if (!quiet && format === "text") console.error(color("dim", "\nStopped."));
|
|
169
|
+
process.exit(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
while (!stopped) {
|
|
173
|
+
for (const domain of domains) {
|
|
174
|
+
try {
|
|
175
|
+
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
|
|
176
|
+
method: "GET",
|
|
177
|
+
headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION}` },
|
|
178
|
+
});
|
|
179
|
+
if (!resp.ok) continue;
|
|
180
|
+
const report = await resp.json();
|
|
181
|
+
const prev = previous.get(domain);
|
|
182
|
+
const changed = prev !== undefined && typeof report.score === "number" && report.score !== prev;
|
|
183
|
+
previous.set(domain, report.score);
|
|
184
|
+
|
|
185
|
+
if (changed && webhookUrl) {
|
|
186
|
+
fetch(webhookUrl, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { "content-type": "application/json", "user-agent": `pqcheck-cli/${VERSION}` },
|
|
189
|
+
body: JSON.stringify({
|
|
190
|
+
type: "score_changed",
|
|
191
|
+
domain,
|
|
192
|
+
previousScore: prev,
|
|
193
|
+
newScore: report.score,
|
|
194
|
+
report,
|
|
195
|
+
at: new Date().toISOString(),
|
|
196
|
+
}),
|
|
197
|
+
}).catch(() => {});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (format === "csv") {
|
|
201
|
+
printCsvRow(report);
|
|
202
|
+
} else if (format === "json") {
|
|
203
|
+
console.log(JSON.stringify({ ...report, _watchPreviousScore: prev ?? null, _watchChanged: changed }));
|
|
204
|
+
} else if (format === "markdown") {
|
|
205
|
+
printMarkdown(report, true);
|
|
206
|
+
} else {
|
|
207
|
+
const stamp = new Date().toISOString().slice(11, 19);
|
|
208
|
+
if (changed) {
|
|
209
|
+
console.log(color("yellow", `[${stamp}] ${domain}: ${prev} → ${report.score} (${report.scoreLabel}) ${color("yellow", "★ changed")}`));
|
|
210
|
+
} else if (!quiet) {
|
|
211
|
+
console.log(color("dim", `[${stamp}] ${domain}: ${report.score} (${report.scoreLabel})`));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (!quiet && format === "text") console.error(color("red", `[watch] ${domain}: ${err.message}`));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
await sleep(intervalSec * 1000);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
223
|
+
|
|
224
|
+
function isFlagValue(args, val) {
|
|
225
|
+
// True when this arg is the value following a flag like --threshold 7 or --format json
|
|
226
|
+
const idx = args.indexOf(val);
|
|
227
|
+
if (idx <= 0) return false;
|
|
228
|
+
const prev = args[idx - 1];
|
|
229
|
+
return prev === "--threshold" || prev === "--format" || prev === "--watch" || prev === "--webhook";
|
|
90
230
|
}
|
|
91
231
|
|
|
92
232
|
function parseFormat(args) {
|
|
@@ -94,7 +234,8 @@ function parseFormat(args) {
|
|
|
94
234
|
const i = args.indexOf("--format");
|
|
95
235
|
if (i === -1) return "text";
|
|
96
236
|
const v = (args[i + 1] || "").toLowerCase();
|
|
97
|
-
|
|
237
|
+
if (v === "json" || v === "csv" || v === "markdown" || v === "md") return v === "md" ? "markdown" : v;
|
|
238
|
+
return "text";
|
|
98
239
|
}
|
|
99
240
|
|
|
100
241
|
function parseThreshold(args) {
|
|
@@ -106,6 +247,94 @@ function parseThreshold(args) {
|
|
|
106
247
|
return n;
|
|
107
248
|
}
|
|
108
249
|
|
|
250
|
+
function parseWatch(args) {
|
|
251
|
+
const i = args.indexOf("--watch");
|
|
252
|
+
if (i === -1) return null;
|
|
253
|
+
const raw = args[i + 1];
|
|
254
|
+
// --watch with no value defaults to 300s (5 min)
|
|
255
|
+
if (raw === undefined || raw.startsWith("-")) return 300;
|
|
256
|
+
const n = parseInt(raw, 10);
|
|
257
|
+
if (!Number.isFinite(n) || n < 10) return "invalid";
|
|
258
|
+
return n;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseWebhook(args) {
|
|
262
|
+
const i = args.indexOf("--webhook");
|
|
263
|
+
if (i === -1) return null;
|
|
264
|
+
const raw = args[i + 1];
|
|
265
|
+
if (!raw || !/^https?:\/\//.test(raw)) return "invalid";
|
|
266
|
+
return raw;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------- format renderers (CSV + markdown) ----------
|
|
270
|
+
|
|
271
|
+
let _csvHeaderPrinted = false;
|
|
272
|
+
function printCsvHeader() {
|
|
273
|
+
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");
|
|
274
|
+
_csvHeaderPrinted = true;
|
|
275
|
+
}
|
|
276
|
+
function printCsvRow(r) {
|
|
277
|
+
if (!_csvHeaderPrinted) printCsvHeader();
|
|
278
|
+
const ps = r.publicSurface || {};
|
|
279
|
+
const sevCount = (sev) => (r.findings || []).filter((f) => f.severity === sev).length;
|
|
280
|
+
const cells = [
|
|
281
|
+
csvEscape(r.domain),
|
|
282
|
+
r.score ?? "",
|
|
283
|
+
r.grade ?? "",
|
|
284
|
+
r.scoreLabel ?? "",
|
|
285
|
+
r.reachable ? "true" : "false",
|
|
286
|
+
csvEscape(ps.tlsVersion ?? ""),
|
|
287
|
+
ps.hybridPQC ? "true" : "false",
|
|
288
|
+
ps.daysUntilCertExpiry ?? "",
|
|
289
|
+
ps.subdomainCount ?? 0,
|
|
290
|
+
ps.wildcardCert ? "true" : "false",
|
|
291
|
+
sevCount("high") + sevCount("critical"),
|
|
292
|
+
sevCount("medium"),
|
|
293
|
+
sevCount("low"),
|
|
294
|
+
];
|
|
295
|
+
console.log(cells.join(","));
|
|
296
|
+
}
|
|
297
|
+
function csvEscape(s) {
|
|
298
|
+
const v = String(s ?? "");
|
|
299
|
+
if (v.includes(",") || v.includes('"') || v.includes("\n")) {
|
|
300
|
+
return '"' + v.replace(/"/g, '""') + '"';
|
|
301
|
+
}
|
|
302
|
+
return v;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function printMarkdown(r, multi) {
|
|
306
|
+
if (!r.reachable) {
|
|
307
|
+
console.log(`### ${r.domain} — unreachable\n${r.errorMessage ? `> ${r.errorMessage}` : ""}\n`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const ps = r.publicSurface || {};
|
|
311
|
+
const lines = [];
|
|
312
|
+
lines.push(`### ${r.domain} — Decryption Blast Radius **${r.score} / 10** (${r.scoreLabel}, grade ${r.grade ?? "—"})`);
|
|
313
|
+
lines.push("");
|
|
314
|
+
lines.push("| Signal | Value |");
|
|
315
|
+
lines.push("|---|---|");
|
|
316
|
+
lines.push(`| TLS | ${ps.tlsVersion ?? "?"}${ps.cipher ? ` (${ps.cipher})` : ""} |`);
|
|
317
|
+
lines.push(`| Hybrid PQC | ${ps.hybridPQC ? "yes" : "no"} |`);
|
|
318
|
+
lines.push(`| Cert expires | ${ps.daysUntilCertExpiry !== null && ps.daysUntilCertExpiry !== undefined ? `in ${ps.daysUntilCertExpiry} days` : "?"} |`);
|
|
319
|
+
lines.push(`| HSTS | ${ps.hsts ? "enabled" : "not detected"} |`);
|
|
320
|
+
lines.push(`| Subdomains | ${ps.subdomainCount ?? 0}${ps.wildcardCert ? " (wildcard cert)" : ""} |`);
|
|
321
|
+
lines.push("");
|
|
322
|
+
if (r.findings && r.findings.length) {
|
|
323
|
+
lines.push("**Findings:**");
|
|
324
|
+
lines.push("");
|
|
325
|
+
for (const f of r.findings) {
|
|
326
|
+
lines.push(`- **[${f.severity.toUpperCase()}]** ${f.title}`);
|
|
327
|
+
lines.push(` ${f.detail}`);
|
|
328
|
+
}
|
|
329
|
+
lines.push("");
|
|
330
|
+
}
|
|
331
|
+
lines.push(`> ⚠ Public surface only. Internal Blast Radius is typically 12–40× this score.`);
|
|
332
|
+
lines.push("");
|
|
333
|
+
lines.push(`Full report: ${API_BASE}/?check=${encodeURIComponent(r.domain)} · Share: ${API_BASE}/r/${encodeURIComponent(r.domain)}`);
|
|
334
|
+
if (multi) lines.push("\n---\n");
|
|
335
|
+
console.log(lines.join("\n"));
|
|
336
|
+
}
|
|
337
|
+
|
|
109
338
|
function printReport(r) {
|
|
110
339
|
if (!r.reachable) {
|
|
111
340
|
console.log(color("red", `\n ${r.domain} — unreachable`));
|
|
@@ -217,6 +446,11 @@ function normalizeDomain(raw) {
|
|
|
217
446
|
.split(":")[0];
|
|
218
447
|
}
|
|
219
448
|
|
|
449
|
+
function isValidDomain(d) {
|
|
450
|
+
if (!d || d.length < 4 || d.length > 253) return false;
|
|
451
|
+
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(d);
|
|
452
|
+
}
|
|
453
|
+
|
|
220
454
|
async function safeJSON(resp) {
|
|
221
455
|
try { return await resp.json(); } catch { return null; }
|
|
222
456
|
}
|
|
@@ -228,18 +462,26 @@ ${color("bold", "pqcheck")} ${color("dim", `v${VERSION}`)}
|
|
|
228
462
|
Public Surface Blast Radius — quantum-decryption risk for any domain.
|
|
229
463
|
|
|
230
464
|
${color("bold", "Usage:")}
|
|
231
|
-
npx pqcheck <domain>
|
|
232
|
-
npx pqcheck <domain>
|
|
233
|
-
npx pqcheck
|
|
234
|
-
npx pqcheck <domain> --
|
|
465
|
+
npx pqcheck <domain> Scan + print human-readable report
|
|
466
|
+
npx pqcheck lock <domain> Generate quantapact.lock (QXM) for repo commit
|
|
467
|
+
npx pqcheck a.com b.com c.com Multi-domain scan
|
|
468
|
+
npx pqcheck <domain> --format json Raw JSON
|
|
469
|
+
npx pqcheck <domain> --format markdown GitHub-issue / Slack-ready Markdown
|
|
470
|
+
npx pqcheck <domain> --format csv Spreadsheet-friendly CSV row
|
|
471
|
+
npx pqcheck <domain> --threshold 7 Exit 2 if score ≥ 7 (CI gate)
|
|
472
|
+
npx pqcheck <domain> --quiet Print just the score number
|
|
473
|
+
npx pqcheck <domain> --watch [seconds] Continuously poll and report changes
|
|
474
|
+
npx pqcheck <domain> --webhook <url> POST report JSON to URL on each scan
|
|
235
475
|
|
|
236
476
|
${color("bold", "Options:")}
|
|
237
|
-
-h, --help
|
|
238
|
-
-v, --version
|
|
239
|
-
--format <text|json>
|
|
240
|
-
--json
|
|
241
|
-
--threshold <0-10>
|
|
242
|
-
-q, --quiet
|
|
477
|
+
-h, --help Show this help
|
|
478
|
+
-v, --version Show version
|
|
479
|
+
--format <text|json|markdown|csv> Output format (default: text)
|
|
480
|
+
--json Alias for --format json
|
|
481
|
+
--threshold <0-10> Exit 2 if score meets or exceeds this
|
|
482
|
+
-q, --quiet Print only the numeric score
|
|
483
|
+
--watch [seconds] Poll every N seconds (default 300) and report changes
|
|
484
|
+
--webhook <url> POST scan results to a URL (one-shot or each watch tick)
|
|
243
485
|
|
|
244
486
|
${color("bold", "Exit codes:")}
|
|
245
487
|
0 success
|
|
@@ -248,8 +490,10 @@ ${color("bold", "Exit codes:")}
|
|
|
248
490
|
|
|
249
491
|
${color("bold", "Examples:")}
|
|
250
492
|
npx pqcheck chase.com
|
|
251
|
-
npx pqcheck
|
|
252
|
-
npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if
|
|
493
|
+
npx pqcheck mycompany.com mythirdparty.com --format csv > posture.csv
|
|
494
|
+
npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if score ≥ 7")}
|
|
495
|
+
npx pqcheck mybank.com --format markdown >> issue.md
|
|
496
|
+
npx pqcheck mybank.com --watch 600 --webhook https://hooks.slack.com/services/...
|
|
253
497
|
echo "score: $(npx pqcheck mybank.com -q)"
|
|
254
498
|
|
|
255
499
|
Backed by the patented Decryption Blast Radius methodology.
|
|
@@ -257,6 +501,244 @@ ${color("violet", "https://quantapact.com")}
|
|
|
257
501
|
`);
|
|
258
502
|
}
|
|
259
503
|
|
|
504
|
+
// =============================================================================
|
|
505
|
+
// `pqcheck lock` — QXM (Quantum Exposure Manifest) generator
|
|
506
|
+
// =============================================================================
|
|
507
|
+
// Generates two files committable to a git repo:
|
|
508
|
+
// quantapact.lock — stable JSON manifest (machine-readable)
|
|
509
|
+
// quantapact-report.md — human-readable summary (renders on GitHub)
|
|
510
|
+
//
|
|
511
|
+
// Like SBOM / package-lock.json / cargo audit / snyk test outputs — devs commit
|
|
512
|
+
// these to track quantum exposure as a first-class technical concern.
|
|
513
|
+
//
|
|
514
|
+
// Usage:
|
|
515
|
+
// npx pqcheck lock <domain> Write to ./quantapact.lock + .md
|
|
516
|
+
// npx pqcheck lock <domain> -o dir/ Write into a specific directory
|
|
517
|
+
// npx pqcheck lock <domain> --stdout Print JSON to stdout (no files)
|
|
518
|
+
// npx pqcheck lock Read domain from existing
|
|
519
|
+
// quantapact.lock if present, else error
|
|
520
|
+
// =============================================================================
|
|
521
|
+
|
|
522
|
+
async function runLockCommand(args) {
|
|
523
|
+
const fs = await import("node:fs/promises");
|
|
524
|
+
const path = await import("node:path");
|
|
525
|
+
const crypto = await import("node:crypto");
|
|
526
|
+
|
|
527
|
+
const stdout = args.includes("--stdout");
|
|
528
|
+
const outIdx = args.indexOf("-o");
|
|
529
|
+
const outDir = outIdx >= 0 ? args[outIdx + 1] : ".";
|
|
530
|
+
|
|
531
|
+
// Find the domain — either positional arg, or read from existing lockfile
|
|
532
|
+
const positional = args.filter((a) => !a.startsWith("-") && a !== outDir);
|
|
533
|
+
let domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
|
|
534
|
+
|
|
535
|
+
if (!domain) {
|
|
536
|
+
try {
|
|
537
|
+
const existing = await fs.readFile(path.join(outDir, "quantapact.lock"), "utf8");
|
|
538
|
+
const parsed = JSON.parse(existing);
|
|
539
|
+
domain = parsed.domain;
|
|
540
|
+
if (!stdout) {
|
|
541
|
+
console.error(color("dim", `Re-locking from existing quantapact.lock (domain: ${domain})`));
|
|
542
|
+
}
|
|
543
|
+
} catch {
|
|
544
|
+
console.error(color("red", "error: no domain provided and no existing quantapact.lock found"));
|
|
545
|
+
console.error(color("dim", "Usage: npx pqcheck lock <domain>"));
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!isValidDomain(domain)) {
|
|
551
|
+
console.error(color("red", `error: invalid domain '${domain}'`));
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!stdout) process.stderr.write(color("dim", `Scanning ${domain} for QXM lockfile...`));
|
|
556
|
+
|
|
557
|
+
let report;
|
|
558
|
+
try {
|
|
559
|
+
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
|
|
560
|
+
method: "GET",
|
|
561
|
+
headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (lock)` },
|
|
562
|
+
});
|
|
563
|
+
if (!stdout) process.stderr.write("\r\x1b[K");
|
|
564
|
+
if (!resp.ok) {
|
|
565
|
+
const errBody = await safeJSON(resp);
|
|
566
|
+
console.error(color("red", `error: ${resp.status} ${errBody?.error || resp.statusText}`));
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
report = await resp.json();
|
|
570
|
+
} catch (err) {
|
|
571
|
+
if (!stdout) process.stderr.write("\r\x1b[K");
|
|
572
|
+
console.error(color("red", `error: ${err.message}`));
|
|
573
|
+
process.exit(1);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const manifest = buildQxmManifest(report, crypto);
|
|
577
|
+
const json = JSON.stringify(manifest, null, 2) + "\n";
|
|
578
|
+
|
|
579
|
+
if (stdout) {
|
|
580
|
+
console.log(json);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Write both files
|
|
585
|
+
const lockPath = path.join(outDir, "quantapact.lock");
|
|
586
|
+
const mdPath = path.join(outDir, "quantapact-report.md");
|
|
587
|
+
const md = renderQxmMarkdown(manifest);
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
591
|
+
await fs.writeFile(lockPath, json);
|
|
592
|
+
await fs.writeFile(mdPath, md);
|
|
593
|
+
} catch (err) {
|
|
594
|
+
console.error(color("red", `error writing files: ${err.message}`));
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
console.log("");
|
|
599
|
+
console.log(` ${color("bold", "QXM lockfile written")} for ${color("violet", domain)}:`);
|
|
600
|
+
console.log("");
|
|
601
|
+
console.log(` ${color("green", "✓")} ${lockPath}`);
|
|
602
|
+
console.log(` ${color("green", "✓")} ${mdPath}`);
|
|
603
|
+
console.log("");
|
|
604
|
+
console.log(` ${color("dim", "Decryption Blast Radius:")} ${color("bold", manifest.score + " / 10")} (Grade ${manifest.grade}, ${manifest.scoreLabel})`);
|
|
605
|
+
console.log(` ${color("dim", "Findings:")} ${manifest.findings.length} (${manifest.findings.filter((f) => f.severity === "high" || f.severity === "critical").length} high/critical)`);
|
|
606
|
+
console.log("");
|
|
607
|
+
console.log(color("dim", " Commit these to your repo to track quantum exposure as a versioned artifact."));
|
|
608
|
+
console.log(color("dim", " Re-run `npx pqcheck lock` to refresh; diffs surface real changes in PRs."));
|
|
609
|
+
console.log("");
|
|
610
|
+
console.log(color("violet", ` → Verify online: ${API_BASE}/r/${encodeURIComponent(domain)}`));
|
|
611
|
+
console.log("");
|
|
612
|
+
process.exit(0);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function buildQxmManifest(report, crypto) {
|
|
616
|
+
// Stable hash of the underlying scan, useful for dedup + change detection in CI
|
|
617
|
+
const hashInput = JSON.stringify({
|
|
618
|
+
domain: report.domain,
|
|
619
|
+
score: report.score,
|
|
620
|
+
grade: report.grade,
|
|
621
|
+
findings: (report.findings || []).map((f) => ({ s: f.severity, t: f.title })),
|
|
622
|
+
publicSurface: report.publicSurface,
|
|
623
|
+
});
|
|
624
|
+
const evidenceHash = crypto.createHash("sha256").update(hashInput).digest("hex").slice(0, 32);
|
|
625
|
+
|
|
626
|
+
// Tessera recommendation classification (waitlist-shape; SDK not yet shipped)
|
|
627
|
+
const tesseraNeeded = (report.findings || []).some((f) =>
|
|
628
|
+
/key reused?|reused for|key persist|rsa fallback|chain weakest|hybrid pqc/i.test(f.title || ""),
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
schema: "https://quantapact.com/schemas/qxm/v1",
|
|
633
|
+
schemaVersion: 1,
|
|
634
|
+
generator: `pqcheck-cli/${VERSION}`,
|
|
635
|
+
generatedAt: report.generatedAt || new Date().toISOString(),
|
|
636
|
+
domain: report.domain,
|
|
637
|
+
reachable: !!report.reachable,
|
|
638
|
+
score: report.score,
|
|
639
|
+
grade: report.grade,
|
|
640
|
+
scoreLabel: report.scoreLabel,
|
|
641
|
+
publicSurface: report.publicSurface || null,
|
|
642
|
+
findings: (report.findings || []).map((f) => ({
|
|
643
|
+
severity: f.severity,
|
|
644
|
+
title: f.title,
|
|
645
|
+
detail: f.detail,
|
|
646
|
+
})),
|
|
647
|
+
impact: report.impact || null,
|
|
648
|
+
sectorRanking: report.sectorRanking || null,
|
|
649
|
+
components: report.components || null,
|
|
650
|
+
evidence: {
|
|
651
|
+
evidenceHash,
|
|
652
|
+
methodology: "https://quantapact.com/methodology",
|
|
653
|
+
shareableReport: `https://quantapact.com/r/${encodeURIComponent(report.domain)}`,
|
|
654
|
+
badge: `https://quantapact.com/badge/${encodeURIComponent(report.domain)}.svg`,
|
|
655
|
+
},
|
|
656
|
+
remediation: {
|
|
657
|
+
tessera: tesseraNeeded ? "join-waitlist" : "not-needed",
|
|
658
|
+
tesseraWaitlist: "https://quantapact.com/feedback?source=qxm-tessera-interest",
|
|
659
|
+
notes: tesseraNeeded
|
|
660
|
+
? "Findings include cryptographic exposure that Tessera SDK is being designed to remediate. Tessera is in development; join the waitlist to be notified when ready."
|
|
661
|
+
: "No quantum-decryption-relevant findings requiring Tessera remediation at this time.",
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function renderQxmMarkdown(m) {
|
|
667
|
+
const lines = [];
|
|
668
|
+
lines.push(`# Quantum Exposure Manifest — \`${m.domain}\``);
|
|
669
|
+
lines.push("");
|
|
670
|
+
lines.push(`> **Decryption Blast Radius:** ${m.score} / 10 (Grade ${m.grade}, ${m.scoreLabel})`);
|
|
671
|
+
lines.push(`> Generated by [pqcheck](https://quantapact.com) at ${m.generatedAt}`);
|
|
672
|
+
lines.push("");
|
|
673
|
+
if (!m.reachable) {
|
|
674
|
+
lines.push(`*${m.domain} was not reachable at scan time.*`);
|
|
675
|
+
lines.push("");
|
|
676
|
+
return lines.join("\n");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
lines.push("## Public-surface signals");
|
|
680
|
+
lines.push("");
|
|
681
|
+
lines.push("| Signal | Value |");
|
|
682
|
+
lines.push("|---|---|");
|
|
683
|
+
const ps = m.publicSurface || {};
|
|
684
|
+
lines.push(`| TLS version | ${ps.tlsVersion ?? "?"}${ps.cipher ? ` (${ps.cipher})` : ""} |`);
|
|
685
|
+
lines.push(`| Hybrid PQC | ${ps.hybridPQC ? "yes" : "no"} |`);
|
|
686
|
+
lines.push(`| Cert expires in | ${ps.daysUntilCertExpiry ?? "?"} days |`);
|
|
687
|
+
lines.push(`| HSTS | ${ps.hsts ? "enabled" : "not detected"} |`);
|
|
688
|
+
lines.push(`| Subdomains | ${ps.subdomainCount ?? 0}${ps.wildcardCert ? " (wildcard cert)" : ""} |`);
|
|
689
|
+
if (ps.keyReuseLongestYears) {
|
|
690
|
+
lines.push(`| **Key reuse window** | **${ps.keyReuseLongestYears} years** across ${ps.keyReuseCertsObserved ?? "?"} cert rotations |`);
|
|
691
|
+
}
|
|
692
|
+
lines.push("");
|
|
693
|
+
|
|
694
|
+
if (m.findings && m.findings.length) {
|
|
695
|
+
lines.push("## Findings");
|
|
696
|
+
lines.push("");
|
|
697
|
+
for (const f of m.findings) {
|
|
698
|
+
lines.push(`### \`[${f.severity.toUpperCase()}]\` ${f.title}`);
|
|
699
|
+
lines.push("");
|
|
700
|
+
lines.push(f.detail);
|
|
701
|
+
lines.push("");
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (m.impact && m.impact.headline) {
|
|
706
|
+
lines.push("## Plain-English impact");
|
|
707
|
+
lines.push("");
|
|
708
|
+
lines.push(`> ${m.impact.headline}`);
|
|
709
|
+
lines.push("");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (m.sectorRanking && m.sectorRanking.available) {
|
|
713
|
+
lines.push("## Sector ranking");
|
|
714
|
+
lines.push("");
|
|
715
|
+
lines.push(`Among ${m.sectorRanking.sectorLabel}: **${m.sectorRanking.rank} of ${m.sectorRanking.total}** (worse than ${m.sectorRanking.betterThanCount} peers measured).`);
|
|
716
|
+
lines.push("");
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
lines.push("## Remediation");
|
|
720
|
+
lines.push("");
|
|
721
|
+
lines.push(`- **Tessera SDK status for this domain:** \`${m.remediation.tessera}\``);
|
|
722
|
+
lines.push(`- ${m.remediation.notes}`);
|
|
723
|
+
lines.push(`- [Join Tessera remediation waitlist](${m.remediation.tesseraWaitlist})`);
|
|
724
|
+
lines.push("");
|
|
725
|
+
|
|
726
|
+
lines.push("## Evidence");
|
|
727
|
+
lines.push("");
|
|
728
|
+
lines.push("| | |");
|
|
729
|
+
lines.push("|---|---|");
|
|
730
|
+
lines.push(`| Methodology | ${m.evidence.methodology} |`);
|
|
731
|
+
lines.push(`| Shareable report | ${m.evidence.shareableReport} |`);
|
|
732
|
+
lines.push(`| Embeddable badge | \`${m.evidence.badge}\` |`);
|
|
733
|
+
lines.push(`| Evidence hash | \`${m.evidence.evidenceHash}\` |`);
|
|
734
|
+
lines.push("");
|
|
735
|
+
lines.push("---");
|
|
736
|
+
lines.push("");
|
|
737
|
+
lines.push("*Public-surface measurements only. Internal Blast Radius (east-west traffic, internal databases, VPN tunnels, backup pipelines) is typically 12–40× this score. Re-run `npx pqcheck lock` to refresh; commit the result to your repo to surface changes in pull requests.*");
|
|
738
|
+
lines.push("");
|
|
739
|
+
return lines.join("\n");
|
|
740
|
+
}
|
|
741
|
+
|
|
260
742
|
main().catch((err) => {
|
|
261
743
|
console.error(color("red", `fatal: ${err.message}`));
|
|
262
744
|
process.exit(2);
|