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.
- package/README.md +51 -12
- package/bin/pqcheck.js +99 -22
- 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>
|
|
59
|
-
npx pqcheck <domain> --json
|
|
60
|
-
npx pqcheck --
|
|
61
|
-
npx pqcheck --
|
|
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
|
-
|
|
107
|
+
Or grab the full JSON for richer analysis:
|
|
65
108
|
|
|
66
109
|
```bash
|
|
67
|
-
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
-
|
|
78
|
+
} else {
|
|
79
|
+
printReport(report);
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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>
|
|
167
|
-
npx pqcheck <domain> --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
|
|
171
|
-
-v, --version
|
|
172
|
-
--json
|
|
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")}
|