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.
Files changed (3) hide show
  1. package/README.md +16 -2
  2. package/bin/pqcheck.js +302 -39
  3. 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.2.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
+ }
51
73
 
52
- if (!quiet) process.stderr.write(color("dim", `Scanning ${domain} ...`));
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"); // 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;
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
- // 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`));
@@ -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
- console.log(color("dim", " Findings:"));
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 sev = f.severity.toUpperCase();
138
- const sevColor =
139
- f.severity === "critical" ? "red" :
140
- f.severity === "high" ? "yellow" :
141
- f.severity === "medium" ? "yellow" : "dim";
142
- console.log(` ${color(sevColor, `[${sev}]`)} ${f.title}`);
143
- console.log(color("dim", ` ${f.detail}`));
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> Scan and print human-readable report
201
- npx pqcheck <domain> --format json Output raw JSON
202
- npx pqcheck <domain> --threshold 7 Exit 2 if score ≥ 7 (CI-friendly)
203
- 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
204
463
 
205
464
  ${color("bold", "Options:")}
206
- -h, --help Show this help
207
- -v, --version Show version
208
- --format <text|json> Output format (default: text)
209
- --json Alias for --format json
210
- --threshold <0-10> Exit code 2 if score meets/exceeds this value
211
- -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)
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 quantapact.com --format json
221
- 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/...
222
485
  echo "score: $(npx pqcheck mybank.com -q)"
223
486
 
224
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.2.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",