pqcheck 0.3.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.
Files changed (2) hide show
  1. package/bin/pqcheck.js +263 -31
  2. package/package.json +1 -1
package/bin/pqcheck.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // =============================================================================
8
8
 
9
9
  const API_BASE = process.env.PQCHECK_API_BASE || "https://quantapact.com";
10
- const VERSION = "0.3.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
- const domain = normalizeDomain(args.find((a) => !a.startsWith("-")) || "");
38
- if (!domain) {
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
+ }
73
+
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
+ }
51
82
 
52
- if (!quiet) process.stderr.write(color("dim", `Scanning ${domain} ...`));
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"); // clear "Scanning ..." line
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
- process.exit(1);
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
- process.exit(1);
100
+ if (!quiet && format === "text") process.stderr.write("\r\x1b[K");
101
+ console.error(color("red", `error scanning ${domain}: ${err.message}`));
102
+ return 1;
103
+ }
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 */ });
71
112
  }
72
113
 
114
+ // Output dispatch
73
115
  if (quiet) {
74
- // Numeric score on stdout, nothing else. Suitable for piping.
75
- console.log(typeof report.score === "number" ? report.score : "");
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
- console.log(JSON.stringify(report, null, 2));
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
- process.exit(2);
212
+ await sleep(intervalSec * 1000);
88
213
  }
89
- process.exit(0);
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
- return v === "json" ? "json" : "text";
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`));
@@ -228,18 +451,25 @@ ${color("bold", "pqcheck")} ${color("dim", `v${VERSION}`)}
228
451
  Public Surface Blast Radius — quantum-decryption risk for any domain.
229
452
 
230
453
  ${color("bold", "Usage:")}
231
- npx pqcheck <domain> Scan and print human-readable report
232
- npx pqcheck <domain> --format json Output raw JSON
233
- npx pqcheck <domain> --threshold 7 Exit 2 if score ≥ 7 (CI-friendly)
234
- npx pqcheck <domain> --quiet Print just the score, nothing else
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
235
463
 
236
464
  ${color("bold", "Options:")}
237
- -h, --help Show this help
238
- -v, --version Show version
239
- --format <text|json> Output format (default: text)
240
- --json Alias for --format json
241
- --threshold <0-10> Exit code 2 if score meets/exceeds this value
242
- -q, --quiet Print only the numeric score (pipe-friendly)
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)
243
473
 
244
474
  ${color("bold", "Exit codes:")}
245
475
  0 success
@@ -248,8 +478,10 @@ ${color("bold", "Exit codes:")}
248
478
 
249
479
  ${color("bold", "Examples:")}
250
480
  npx pqcheck chase.com
251
- npx pqcheck quantapact.com --format json
252
- npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if exposed")}
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/...
253
485
  echo "score: $(npx pqcheck mybank.com -q)"
254
486
 
255
487
  Backed by the patented Decryption Blast Radius methodology.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Decryption Blast Radius scanner — find out how much of your data unlocks when quantum decryption arrives.",
5
5
  "keywords": [
6
6
  "post-quantum",