pqcheck 0.1.1 → 0.3.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 +51 -12
  2. package/bin/pqcheck.js +99 -22
  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
  ```
@@ -55,20 +60,54 @@ $ npx pqcheck chase.com
55
60
  ## Usage
56
61
 
57
62
  ```
58
- npx pqcheck <domain> Scan and print human-readable report
59
- npx pqcheck <domain> --json Output raw JSON for piping / scripting
60
- npx pqcheck --help Show all options
61
- npx pqcheck --version Show version
63
+ npx pqcheck <domain> Scan and print human-readable report
64
+ npx pqcheck <domain> --format json Output raw JSON for piping / scripting
65
+ npx pqcheck <domain> --threshold 7 Exit 2 if score ≥ 7 (CI gate)
66
+ npx pqcheck <domain> --quiet Print only the numeric score
67
+ npx pqcheck --help Show all options
68
+ npx pqcheck --version Show version
69
+ ```
70
+
71
+ ### Exit codes
72
+
73
+ | Code | Meaning |
74
+ |------|---------|
75
+ | 0 | Success — score below threshold (or no threshold set) |
76
+ | 1 | Usage / network / scan error |
77
+ | 2 | Score met or exceeded `--threshold` |
78
+
79
+ ### CI integration
80
+
81
+ The `--threshold` flag turns `pqcheck` into a quantum-risk gate for any pipeline:
82
+
83
+ ```yaml
84
+ # .github/workflows/quantum-risk-gate.yml
85
+ - name: Check public-surface quantum-decryption risk
86
+ run: npx pqcheck mycompany.com --threshold 7
87
+ ```
88
+
89
+ If the score is 7.0 or higher, the step fails and the PR can't merge.
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
+
100
+ For finer control, combine `--quiet` with shell logic:
101
+
102
+ ```bash
103
+ SCORE=$(npx pqcheck mybank.com --quiet)
104
+ echo "Public surface blast radius: $SCORE / 10"
62
105
  ```
63
106
 
64
- The `--json` flag is useful for integrating into CI / monitoring tools:
107
+ Or grab the full JSON for richer analysis:
65
108
 
66
109
  ```bash
67
- # In a GitHub Actions workflow:
68
- SCORE=$(npx pqcheck mybank.com --json | jq '.score')
69
- if (( $(echo "$SCORE > 7" | bc -l) )); then
70
- echo "Score regressed above threshold"; exit 1
71
- fi
110
+ npx pqcheck mybank.com --format json | jq '.findings[] | select(.severity=="high")'
72
111
  ```
73
112
 
74
113
  ## Web version
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.1.1";
10
+ const VERSION = "0.3.0";
11
11
 
12
12
  const ANSI = {
13
13
  reset: "\x1b[0m",
@@ -41,16 +41,22 @@ async function main() {
41
41
  process.exit(1);
42
42
  }
43
43
 
44
- const json = args.includes("--json");
44
+ const quiet = args.includes("--quiet") || args.includes("-q");
45
+ const format = parseFormat(args);
46
+ const threshold = parseThreshold(args);
47
+ if (threshold === "invalid") {
48
+ console.error(color("red", "error: --threshold requires a number 0-10"));
49
+ process.exit(1);
50
+ }
45
51
 
46
- process.stderr.write(color("dim", `Scanning ${domain} ...`));
52
+ if (!quiet) process.stderr.write(color("dim", `Scanning ${domain} ...`));
47
53
  let report;
48
54
  try {
49
55
  const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
50
56
  method: "GET",
51
57
  headers: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION}` },
52
58
  });
53
- process.stderr.write("\r\x1b[K"); // clear "Scanning ..." line
59
+ if (!quiet) process.stderr.write("\r\x1b[K"); // clear "Scanning ..." line
54
60
  if (!resp.ok) {
55
61
  const errBody = await safeJSON(resp);
56
62
  console.error(color("red", `error: ${resp.status} ${errBody?.error || resp.statusText}`));
@@ -59,17 +65,45 @@ async function main() {
59
65
  }
60
66
  report = await resp.json();
61
67
  } catch (err) {
62
- process.stderr.write("\r\x1b[K");
68
+ if (!quiet) process.stderr.write("\r\x1b[K");
63
69
  console.error(color("red", `error: ${err.message}`));
64
70
  process.exit(1);
65
71
  }
66
72
 
67
- if (json) {
73
+ if (quiet) {
74
+ // Numeric score on stdout, nothing else. Suitable for piping.
75
+ console.log(typeof report.score === "number" ? report.score : "");
76
+ } else if (format === "json") {
68
77
  console.log(JSON.stringify(report, null, 2));
69
- process.exit(0);
78
+ } else {
79
+ printReport(report);
70
80
  }
71
81
 
72
- printReport(report);
82
+ // Threshold gating: exit 2 if score >= threshold (distinct from exit 1 = error)
83
+ if (threshold !== null && typeof report.score === "number" && report.score >= threshold) {
84
+ if (!quiet) {
85
+ console.error(color("red", `threshold breach: score ${report.score} >= ${threshold}`));
86
+ }
87
+ process.exit(2);
88
+ }
89
+ process.exit(0);
90
+ }
91
+
92
+ function parseFormat(args) {
93
+ if (args.includes("--json")) return "json"; // back-compat alias
94
+ const i = args.indexOf("--format");
95
+ if (i === -1) return "text";
96
+ const v = (args[i + 1] || "").toLowerCase();
97
+ return v === "json" ? "json" : "text";
98
+ }
99
+
100
+ function parseThreshold(args) {
101
+ const i = args.indexOf("--threshold");
102
+ if (i === -1) return null;
103
+ const raw = args[i + 1];
104
+ const n = Number(raw);
105
+ if (!Number.isFinite(n) || n < 0 || n > 10) return "invalid";
106
+ return n;
73
107
  }
74
108
 
75
109
  function printReport(r) {
@@ -97,16 +131,47 @@ function printReport(r) {
97
131
  console.log(` • HSTS: ${r.publicSurface.hsts ? color("green", "enabled") : color("dim", "not detected")}`);
98
132
  console.log(` • Subdomains: ${r.publicSurface.subdomainCount}${r.publicSurface.wildcardCert ? color("yellow", " (wildcard cert)") : ""}`);
99
133
  console.log("");
134
+ // Coverage line — communicates breadth of checks
135
+ const coverage = ["TLS", "Cert chain", "Cipher class", color("violet", "★ Key reuse (CT-log)"), "Subdomains"];
136
+ if (r.emailSecurity) coverage.push("SPF", "DMARC", "DKIM", "BIMI");
137
+ if (r.httpHeaders && r.httpHeaders.reachable) coverage.push("HSTS", "CSP", "X-Frame", "Referrer-Policy");
138
+ if (r.subdomainTakeover) coverage.push("Takeover");
139
+ console.log(color("dim", " Checked: ") + coverage.join(color("dim", " · ")));
140
+ console.log("");
141
+
100
142
  if (r.findings && r.findings.length) {
101
- console.log(color("dim", " Findings:"));
143
+ // Group findings by category. Quantum/cert findings come first since
144
+ // they're the differentiator; ASM-completeness findings come after.
145
+ const groups = { quantum: [], takeover: [], email: [], headers: [], other: [] };
102
146
  for (const f of r.findings) {
103
- const sev = f.severity.toUpperCase();
104
- const sevColor =
105
- f.severity === "critical" ? "red" :
106
- f.severity === "high" ? "yellow" :
107
- f.severity === "medium" ? "yellow" : "dim";
108
- console.log(` ${color(sevColor, `[${sev}]`)} ${f.title}`);
109
- console.log(color("dim", ` ${f.detail}`));
147
+ const t = (f.title || "").toLowerCase();
148
+ if (/spf|dmarc|dkim|bimi/.test(t)) groups.email.push(f);
149
+ else if (/hsts|csp|x-frame|content-?type|referrer|clickjacking|server version|permissions-policy/.test(t)) groups.headers.push(f);
150
+ else if (/takeover/.test(t)) groups.takeover.push(f);
151
+ else if (/key reused?|key persist|reused for|cert rotation|chain weakest|rsa fallback|ecdhe|quantum/.test(t)) groups.quantum.push(f);
152
+ else groups.other.push(f);
153
+ }
154
+ const sections = [
155
+ ["quantum", "Quantum & cert exposure (our differentiator):", groups.quantum],
156
+ ["takeover", "Subdomain takeover:", groups.takeover],
157
+ ["email", "Email security (SPF / DMARC / DKIM / BIMI):", groups.email],
158
+ ["headers", "HTTP header security:", groups.headers],
159
+ ["other", "Other:", groups.other],
160
+ ];
161
+ for (const [key, label, arr] of sections) {
162
+ if (!arr || arr.length === 0) continue;
163
+ console.log(color("violet", ` ${label}`));
164
+ for (const f of arr) {
165
+ const sev = f.severity.toUpperCase();
166
+ const sevColor =
167
+ f.severity === "critical" ? "red" :
168
+ f.severity === "high" ? "yellow" :
169
+ f.severity === "medium" ? "yellow" : "dim";
170
+ const isUnique = key === "quantum" && /key reused?|reused for|key persist/.test((f.title || "").toLowerCase());
171
+ const star = isUnique ? color("violet", " ★ UNIQUE TO PQCHECK") : "";
172
+ console.log(` ${color(sevColor, `[${sev}]`)} ${f.title}${star}`);
173
+ console.log(color("dim", ` ${f.detail}`));
174
+ }
110
175
  }
111
176
  console.log("");
112
177
  }
@@ -163,17 +228,29 @@ ${color("bold", "pqcheck")} ${color("dim", `v${VERSION}`)}
163
228
  Public Surface Blast Radius — quantum-decryption risk for any domain.
164
229
 
165
230
  ${color("bold", "Usage:")}
166
- npx pqcheck <domain> Scan and print human-readable report
167
- npx pqcheck <domain> --json Output raw JSON
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
168
235
 
169
236
  ${color("bold", "Options:")}
170
- -h, --help Show this help
171
- -v, --version Show version
172
- --json Print raw JSON output
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)
243
+
244
+ ${color("bold", "Exit codes:")}
245
+ 0 success
246
+ 1 usage / network / scan error
247
+ 2 score met or exceeded --threshold
173
248
 
174
249
  ${color("bold", "Examples:")}
175
250
  npx pqcheck chase.com
176
- npx pqcheck quantapact.com --json
251
+ npx pqcheck quantapact.com --format json
252
+ npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if exposed")}
253
+ echo "score: $(npx pqcheck mybank.com -q)"
177
254
 
178
255
  Backed by the patented Decryption Blast Radius methodology.
179
256
  ${color("violet", "https://quantapact.com")}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.1.1",
3
+ "version": "0.3.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",