pqcheck 0.7.9 → 0.10.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 +30 -27
- package/bin/pqcheck.js +523 -56
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ npx pqcheck stripe.com
|
|
|
8
8
|
|
|
9
9
|
Zero install. Works in any terminal with Node 18+. Free, no signup, no API key.
|
|
10
10
|
|
|
11
|
-
The same scanner that powers [
|
|
11
|
+
The same scanner that powers [cipherwake.io](https://cipherwake.io), the browser extension, and the GitHub Action.
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
@@ -42,10 +42,11 @@ Plus a full ASM check suite for credibility:
|
|
|
42
42
|
|
|
43
43
|
```
|
|
44
44
|
npx pqcheck <domain> Scan + print human-readable report
|
|
45
|
-
npx pqcheck lock <domain> Generate
|
|
45
|
+
npx pqcheck lock <domain> Generate cipherwake.lock (QXM) committable manifest
|
|
46
46
|
npx pqcheck deps <domain> Scan all third-party origins on the page (supply-chain HNDL)
|
|
47
47
|
npx pqcheck diff <old.lock> <new.lock> Compare two QXM lockfiles; exit 2 on regression
|
|
48
48
|
npx pqcheck history <domain> Show 90-day score history (sparkline + samples)
|
|
49
|
+
npx pqcheck changes <domain> Summarize public attack-surface changes in last 14 days
|
|
49
50
|
npx pqcheck cert <file.pem> Analyze a local PEM/CRT cert file (offline, no network)
|
|
50
51
|
```
|
|
51
52
|
|
|
@@ -81,7 +82,7 @@ npx pqcheck --file domains.txt Bulk scan from a newline-separated
|
|
|
81
82
|
### Subcommand-specific flags
|
|
82
83
|
|
|
83
84
|
**`pqcheck deps`:**
|
|
84
|
-
- `--lock` — Also write `
|
|
85
|
+
- `--lock` — Also write `cipherwake-deps.lock` + `.md`
|
|
85
86
|
- `-o <dir>` — Output directory for `--lock` files
|
|
86
87
|
- `--max=<N>` — Max third parties to scan (default 20)
|
|
87
88
|
- `--allowlist <file>` — Exit **3** if any third-party not in allowlist (CI vendor-risk gate)
|
|
@@ -160,13 +161,15 @@ Like SBOM, `package-lock.json`, or `cargo audit` outputs — track quantum expos
|
|
|
160
161
|
```bash
|
|
161
162
|
npx pqcheck lock yourcompany.com
|
|
162
163
|
# Writes:
|
|
163
|
-
#
|
|
164
|
-
#
|
|
164
|
+
# cipherwake.lock — stable JSON manifest
|
|
165
|
+
# cipherwake-report.md — human-readable summary (renders on GitHub)
|
|
165
166
|
```
|
|
166
167
|
|
|
167
168
|
Commit both files. Use `npx pqcheck diff old.lock new.lock` in CI to surface regressions in PR comments.
|
|
168
169
|
|
|
169
|
-
|
|
170
|
+
> **Filename history.** This tool was previously named Quantapact and earlier versions wrote `quantapact.lock` + `quantapact-report.md`. Both names work forever — `pqcheck lock` auto-detects an existing legacy lockfile and overwrites it in place rather than silently creating a second file in your repo. New repos get the new `cipherwake.lock` default. No migration required.
|
|
171
|
+
|
|
172
|
+
Schema documented at [cipherwake.io/schemas/qxm/v1](https://cipherwake.io/methodology/qxm).
|
|
170
173
|
|
|
171
174
|
### Supply-chain dependency scanning
|
|
172
175
|
|
|
@@ -175,41 +178,41 @@ npx pqcheck deps stripe.com
|
|
|
175
178
|
# Output: every third-party origin on stripe.com (analytics, CDN, fonts, etc.) graded for quantum risk
|
|
176
179
|
```
|
|
177
180
|
|
|
178
|
-
Add `--lock` to write `
|
|
181
|
+
Add `--lock` to write `cipherwake-deps.lock` + `.md` for committing or PR comparison. Add `--allowlist file.txt` to gate CI on vendor approval.
|
|
179
182
|
|
|
180
183
|
## Companion surfaces
|
|
181
184
|
|
|
182
|
-
This CLI is one of four ways to consume the [Decryption Blast Radius API](https://
|
|
185
|
+
This CLI is one of four ways to consume the [Decryption Blast Radius API](https://cipherwake.io/api):
|
|
183
186
|
|
|
184
187
|
| Surface | Where |
|
|
185
188
|
|---|---|
|
|
186
189
|
| **CLI** (this package) | `npx pqcheck` |
|
|
187
190
|
| **Browser extension** | Chrome Web Store / Firefox AMO / Edge — toolbar badge per tab + dependency analysis |
|
|
188
|
-
| **GitHub Action** | [`
|
|
189
|
-
| **Slack `/pqcheck`** | [Install on workspace](https://
|
|
190
|
-
| **Web** | [
|
|
191
|
+
| **GitHub Action** | [`cipherwake-io/pqcheck/action@main`](https://github.com/cipherwake-io/pqcheck/tree/main/action) — PR comments, SARIF upload, lockfile generation |
|
|
192
|
+
| **Slack `/pqcheck`** | [Install on workspace](https://cipherwake.io/install-slack) |
|
|
193
|
+
| **Web** | [cipherwake.io](https://cipherwake.io) — share-friendly URLs at `/r/<domain>` |
|
|
191
194
|
|
|
192
195
|
## Public API
|
|
193
196
|
|
|
194
|
-
`pqcheck` is a wrapper around the public
|
|
197
|
+
`pqcheck` is a wrapper around the public Cipherwake API. You can also call the API directly:
|
|
195
198
|
|
|
196
199
|
```bash
|
|
197
|
-
curl -s "https://www.
|
|
200
|
+
curl -s "https://www.cipherwake.io/api/scan?domain=stripe.com" | jq '.grade, .score'
|
|
198
201
|
```
|
|
199
202
|
|
|
200
|
-
Full API reference at [
|
|
203
|
+
Full API reference at [cipherwake.io/api](https://cipherwake.io/api).
|
|
201
204
|
|
|
202
|
-
**Rate limits:** 300 scans per hour per IP, 20 `--fresh` (force-refresh) scans per hour per IP. No API key required. Returns HTTP 429 if exceeded — back off and retry, or [let us know via the feedback form](https://
|
|
205
|
+
**Rate limits:** 300 scans per hour per IP, 20 `--fresh` (force-refresh) scans per hour per IP. No API key required. Returns HTTP 429 if exceeded — back off and retry, or [let us know via the feedback form](https://cipherwake.io/feedback) if you need higher limits (we're prioritizing the API tier based on real demand).
|
|
203
206
|
|
|
204
207
|
## Methodology
|
|
205
208
|
|
|
206
209
|
Decryption Blast Radius scoring methodology is fully open. Component weights, PQC discount math, the "what we DON'T claim" sections, edge cases — all documented:
|
|
207
210
|
|
|
208
|
-
- [Decryption Blast Radius](https://
|
|
209
|
-
- [Score components](https://
|
|
210
|
-
- [QXM lockfile schema](https://
|
|
211
|
-
- [Browser extension methodology](https://
|
|
212
|
-
- [Methodology library](https://
|
|
211
|
+
- [Decryption Blast Radius](https://cipherwake.io/methodology/decryption-blast-radius) — core methodology
|
|
212
|
+
- [Score components](https://cipherwake.io/methodology/score-components) — the 4-bar weighted breakdown + PQC discount
|
|
213
|
+
- [QXM lockfile schema](https://cipherwake.io/methodology/qxm) — committable manifest format
|
|
214
|
+
- [Browser extension methodology](https://cipherwake.io/methodology/browser-extension) — supply-chain HNDL detection logic
|
|
215
|
+
- [Methodology library](https://cipherwake.io/methodology) — full index
|
|
213
216
|
|
|
214
217
|
## Versioning + stability
|
|
215
218
|
|
|
@@ -219,20 +222,20 @@ The CLI follows the same policy — output formats are stable across minor versi
|
|
|
219
222
|
|
|
220
223
|
## Privacy
|
|
221
224
|
|
|
222
|
-
`pqcheck` sends the domain you scan to `
|
|
225
|
+
`pqcheck` sends the domain you scan to `cipherwake.io/api/scan` (so the TLS handshake can be performed from the public internet). No other data is sent — no email, no client-side identifier. The server logs anonymized analytics: domain, hashed IP (for rate limiting), user-agent. We don't track individual users across scans. See [cipherwake.io/privacy](https://cipherwake.io/privacy).
|
|
223
226
|
|
|
224
227
|
## CI integration
|
|
225
228
|
|
|
226
229
|
```yaml
|
|
227
230
|
# .github/workflows/quantum-risk-gate.yml
|
|
228
|
-
- name:
|
|
231
|
+
- name: Cipherwake public-surface gate
|
|
229
232
|
run: npx pqcheck@latest mycompany.com --threshold 7
|
|
230
233
|
```
|
|
231
234
|
|
|
232
|
-
For richer integration (sticky PR comments, SARIF upload to Code Scanning, lockfile diff on regression), use the [GitHub Action](https://github.com/
|
|
235
|
+
For richer integration (sticky PR comments, SARIF upload to Code Scanning, lockfile diff on regression), use the [GitHub Action](https://github.com/cipherwake-io/pqcheck/tree/main/action):
|
|
233
236
|
|
|
234
237
|
```yaml
|
|
235
|
-
- uses:
|
|
238
|
+
- uses: cipherwake-io/pqcheck/action@main
|
|
236
239
|
with:
|
|
237
240
|
domain: mycompany.com
|
|
238
241
|
threshold: '7'
|
|
@@ -249,12 +252,12 @@ For richer integration (sticky PR comments, SARIF upload to Code Scanning, lockf
|
|
|
249
252
|
|
|
250
253
|
## License
|
|
251
254
|
|
|
252
|
-
MIT. © 2026
|
|
255
|
+
MIT. © 2026 Cipherwake.
|
|
253
256
|
|
|
254
257
|
---
|
|
255
258
|
|
|
256
|
-
**Source:** [github.com/
|
|
259
|
+
**Source:** [github.com/cipherwake-io/pqcheck](https://github.com/cipherwake-io/pqcheck)
|
|
257
260
|
|
|
258
261
|
**Changelog:** [CHANGELOG.md](./CHANGELOG.md) for version-by-version release notes.
|
|
259
262
|
|
|
260
|
-
**Issues / feedback:** [
|
|
263
|
+
**Issues / feedback:** [cipherwake.io/feedback](https://cipherwake.io/feedback) or open an issue on the repo.
|
package/bin/pqcheck.js
CHANGED
|
@@ -2,12 +2,48 @@
|
|
|
2
2
|
// =============================================================================
|
|
3
3
|
// pqcheck CLI — npx pqcheck <domain>
|
|
4
4
|
// =============================================================================
|
|
5
|
-
// Tiny wrapper around the public scan API at
|
|
5
|
+
// Tiny wrapper around the public scan API at cipherwake.io.
|
|
6
6
|
// Zero deps (uses node:fetch). Works under `npx pqcheck` without installation.
|
|
7
7
|
// =============================================================================
|
|
8
8
|
|
|
9
|
-
const API_BASE = process.env.PQCHECK_API_BASE || "https://
|
|
10
|
-
const VERSION = "0.
|
|
9
|
+
const API_BASE = process.env.PQCHECK_API_BASE || "https://cipherwake.io";
|
|
10
|
+
const VERSION = "0.10.0";
|
|
11
|
+
|
|
12
|
+
// API-key support — paid tiers (Starter $29 / Growth $79 / Scale $199) get
|
|
13
|
+
// per-account monthly quotas instead of the per-IP rate limit. Set via:
|
|
14
|
+
// export CIPHERWAKE_API_KEY=qpk_<32-hex>
|
|
15
|
+
// Anonymous CLI use still works (no env var → falls back to IP rate limit).
|
|
16
|
+
//
|
|
17
|
+
// QUANTAPACT_API_KEY is honored as a deprecated fallback for existing users
|
|
18
|
+
// (rebrand 2026-05-15). Will be removed in v1.0; in the meantime no break.
|
|
19
|
+
const QP_API_KEY = (process.env.CIPHERWAKE_API_KEY || process.env.QUANTAPACT_API_KEY || "").trim();
|
|
20
|
+
|
|
21
|
+
// Builds headers with optional Authorization. Use for every CLI → API call
|
|
22
|
+
// so a single env-var toggle authenticates every endpoint at once.
|
|
23
|
+
function apiHeaders(extra = {}) {
|
|
24
|
+
const h = { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION}`, ...extra };
|
|
25
|
+
if (QP_API_KEY) h.authorization = `Bearer ${QP_API_KEY}`;
|
|
26
|
+
return h;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Helpful messaging when the server tells us auth/quota failed.
|
|
30
|
+
async function handleAuthError(resp) {
|
|
31
|
+
if (resp.status === 401) {
|
|
32
|
+
const body = await safeJSON(resp);
|
|
33
|
+
if (body?.error === "invalid_api_key") {
|
|
34
|
+
console.error(color("red", "CIPHERWAKE_API_KEY is invalid or revoked. Check https://cipherwake.io/account to rotate."));
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (resp.status === 429) {
|
|
39
|
+
const body = await safeJSON(resp);
|
|
40
|
+
if (body?.error === "monthly_quota_exceeded") {
|
|
41
|
+
console.error(color("red", `Monthly quota exceeded${body.detail ? ` — ${body.detail}` : ""}. Upgrade tier at https://cipherwake.io/pricing or wait until the 1st.`));
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
11
47
|
|
|
12
48
|
const ANSI = {
|
|
13
49
|
reset: "\x1b[0m",
|
|
@@ -25,9 +61,17 @@ const color = (c, s) => (supportsColor ? `${ANSI[c]}${s}${ANSI.reset}` : s);
|
|
|
25
61
|
async function main() {
|
|
26
62
|
const args = process.argv.slice(2);
|
|
27
63
|
|
|
28
|
-
|
|
64
|
+
// No-args entry: show a layman's quick-start before the full usage block.
|
|
65
|
+
// Behavior: argv.length === 0 prints the friendly intro then usage and exits 1.
|
|
66
|
+
// --help / -h prints only usage (no intro) and exits 0.
|
|
67
|
+
if (args.length === 0) {
|
|
68
|
+
printQuickStart();
|
|
29
69
|
printUsage();
|
|
30
|
-
process.exit(
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
73
|
+
printUsage();
|
|
74
|
+
process.exit(0);
|
|
31
75
|
}
|
|
32
76
|
if (args.includes("--version") || args.includes("-v")) {
|
|
33
77
|
console.log(`pqcheck ${VERSION}`);
|
|
@@ -47,9 +91,15 @@ async function main() {
|
|
|
47
91
|
if (args[0] === "history") {
|
|
48
92
|
return runHistoryCommand(args.slice(1));
|
|
49
93
|
}
|
|
94
|
+
if (args[0] === "changes") {
|
|
95
|
+
return runChangesCommand(args.slice(1));
|
|
96
|
+
}
|
|
50
97
|
if (args[0] === "cert") {
|
|
51
98
|
return runCertCommand(args.slice(1));
|
|
52
99
|
}
|
|
100
|
+
if (args[0] === "watch") {
|
|
101
|
+
return runWatchCommand(args.slice(1));
|
|
102
|
+
}
|
|
53
103
|
|
|
54
104
|
// Multi-domain support: positional args are domains.
|
|
55
105
|
// --file reads additional domains from a newline-delimited file.
|
|
@@ -133,7 +183,7 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
|
|
|
133
183
|
const qs = fresh ? `?domain=${encodeURIComponent(domain)}&force=1` : `?domain=${encodeURIComponent(domain)}`;
|
|
134
184
|
const resp = await fetch(`${API_BASE}/api/scan${qs}`, {
|
|
135
185
|
method: "GET",
|
|
136
|
-
headers: {
|
|
186
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (scan)` }),
|
|
137
187
|
});
|
|
138
188
|
if (!quiet && format === "text") process.stderr.write("\r\x1b[K");
|
|
139
189
|
if (!resp.ok) {
|
|
@@ -232,7 +282,7 @@ async function runWatch({ domains, format, quiet, threshold, webhookUrl, interva
|
|
|
232
282
|
try {
|
|
233
283
|
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
|
|
234
284
|
method: "GET",
|
|
235
|
-
headers: {
|
|
285
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (watch)` }),
|
|
236
286
|
});
|
|
237
287
|
if (!resp.ok) continue;
|
|
238
288
|
const report = await resp.json();
|
|
@@ -357,7 +407,14 @@ function printCsvRow(r) {
|
|
|
357
407
|
console.log(cells.join(","));
|
|
358
408
|
}
|
|
359
409
|
function csvEscape(s) {
|
|
360
|
-
|
|
410
|
+
// Spreadsheet-formula-injection defense (R2 finding): cells starting
|
|
411
|
+
// with =, +, -, @, TAB, or CR get treated as formulas by Excel/Sheets.
|
|
412
|
+
// Prefix with a literal-string ' to neutralize. Defense-in-depth even
|
|
413
|
+
// though domains are shape-validated upstream.
|
|
414
|
+
let v = String(s ?? "");
|
|
415
|
+
if (/^[=+\-@\t\r\n]/.test(v)) {
|
|
416
|
+
v = "'" + v;
|
|
417
|
+
}
|
|
361
418
|
if (v.includes(",") || v.includes('"') || v.includes("\n")) {
|
|
362
419
|
return '"' + v.replace(/"/g, '""') + '"';
|
|
363
420
|
}
|
|
@@ -393,6 +450,13 @@ function printMarkdown(r, multi) {
|
|
|
393
450
|
lines.push(`> ⚠ Public surface only. Internal Blast Radius is typically 12–40× this score.`);
|
|
394
451
|
lines.push("");
|
|
395
452
|
lines.push(`Full report: ${API_BASE}/?check=${encodeURIComponent(r.domain)} · Share: ${API_BASE}/r/${encodeURIComponent(r.domain)}`);
|
|
453
|
+
// Conversion CTA (generalbusiness.md principle #15): one-line activation
|
|
454
|
+
// path on every value-delivery moment. Pointed at /watch/<domain>.
|
|
455
|
+
// Audit-required 2026-05-14: copy matches current locked plan ($29 Starter
|
|
456
|
+
// not the old "watch free / weekly digest"). PR-comment context plants
|
|
457
|
+
// team-invitation idea for the eventual Growth tier upgrade.
|
|
458
|
+
lines.push(`📌 **Monitor ${r.domain} continuously?** ${API_BASE}/watch/${encodeURIComponent(r.domain)}`);
|
|
459
|
+
lines.push(`Cipherwake Starter $29/mo · 5 domains · daily scans + email alerts on cert/script/posture changes. Invite your team on Growth ($79/mo) when ready.`);
|
|
396
460
|
if (multi) lines.push("\n---\n");
|
|
397
461
|
console.log(lines.join("\n"));
|
|
398
462
|
}
|
|
@@ -506,7 +570,25 @@ function printReport(r) {
|
|
|
506
570
|
console.log("");
|
|
507
571
|
}
|
|
508
572
|
|
|
509
|
-
|
|
573
|
+
// Provenance pill — "Tracked by Cipherwake since X · N observations". Trust
|
|
574
|
+
// signal that this isn't a one-shot probe but a historical record. Only
|
|
575
|
+
// renders if we actually have prior observations for the domain.
|
|
576
|
+
if (r.trackedSince) {
|
|
577
|
+
const trackedDate = String(r.trackedSince).slice(0, 10);
|
|
578
|
+
const obs = typeof r.observations === "number" && r.observations > 0 ? r.observations : null;
|
|
579
|
+
const obsLine = obs ? ` · ${obs} observation${obs === 1 ? "" : "s"}` : "";
|
|
580
|
+
console.log(color("dim", ` Tracked by Cipherwake since ${trackedDate}${obsLine}`));
|
|
581
|
+
console.log("");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Conversion CTA (generalbusiness.md principle #15): every value-delivery
|
|
585
|
+
// moment surfaces the same /watch/<domain> activation path. Audit-required
|
|
586
|
+
// 2026-05-14: copy must match current locked revenue plan ($29 Starter,
|
|
587
|
+
// not the old "free 1-domain weekly digest").
|
|
588
|
+
console.log(color("violet", ` 📌 Monitor ${r.domain} daily: ${API_BASE}/watch/${encodeURIComponent(r.domain)}`));
|
|
589
|
+
console.log(color("dim", ` Cipherwake Starter $29/mo · 5 watched domains · email alerts · cancel anytime`));
|
|
590
|
+
console.log("");
|
|
591
|
+
console.log(color("dim", ` → Full report: ${API_BASE}/?check=${encodeURIComponent(r.domain)}`));
|
|
510
592
|
console.log(color("dim", ` → Share this: ${API_BASE}/r/${encodeURIComponent(r.domain)}`));
|
|
511
593
|
console.log(color("dim", ` → Compare two: ${API_BASE}/compare?a=${encodeURIComponent(r.domain)}&b=`));
|
|
512
594
|
console.log("");
|
|
@@ -531,6 +613,31 @@ async function safeJSON(resp) {
|
|
|
531
613
|
try { return await resp.json(); } catch { return null; }
|
|
532
614
|
}
|
|
533
615
|
|
|
616
|
+
// Friendly first-time intro shown when the CLI is invoked with no args.
|
|
617
|
+
// Goal: tell a brand-new user what this tool does + give them ONE
|
|
618
|
+
// command to copy-paste, before the full usage block. Modeled on
|
|
619
|
+
// `curl`/`gh`/`vercel` first-run output (concise, action-oriented).
|
|
620
|
+
function printQuickStart() {
|
|
621
|
+
console.log(`
|
|
622
|
+
${color("bold", "👋 Welcome to pqcheck")} ${color("dim", `(v${VERSION})`)}
|
|
623
|
+
|
|
624
|
+
Cipherwake's CLI grades any website's quantum-decryption risk —
|
|
625
|
+
the chance that a harvest-now-decrypt-later attack could read its
|
|
626
|
+
TLS traffic once quantum decryption arrives.
|
|
627
|
+
|
|
628
|
+
${color("bold", "Try it:")}
|
|
629
|
+
${color("dim", "$")} npx pqcheck chase.com
|
|
630
|
+
|
|
631
|
+
${color("bold", "What you'll see:")} a single letter grade (A–F), the score components
|
|
632
|
+
(cipher class, cert lifetime, key rotation history, subdomain exposure),
|
|
633
|
+
top findings, and a link to the full interactive report.
|
|
634
|
+
|
|
635
|
+
${color("bold", "Free + open methodology.")} No account needed for single-domain scans.
|
|
636
|
+
Add ${color("dim", "QUANTAPACT_API_KEY")} env var for higher rate limits + private results
|
|
637
|
+
(create one at ${color("dim", "https://cipherwake.io/signin")}).
|
|
638
|
+
`);
|
|
639
|
+
}
|
|
640
|
+
|
|
534
641
|
function printUsage() {
|
|
535
642
|
console.log(`
|
|
536
643
|
${color("bold", "pqcheck")} ${color("dim", `v${VERSION}`)}
|
|
@@ -539,11 +646,13 @@ Public Surface Blast Radius — quantum-decryption risk for any domain.
|
|
|
539
646
|
|
|
540
647
|
${color("bold", "Commands:")}
|
|
541
648
|
npx pqcheck <domain> Scan + print human-readable report
|
|
542
|
-
npx pqcheck lock <domain> Generate
|
|
649
|
+
npx pqcheck lock <domain> Generate cipherwake.lock (QXM) committable manifest
|
|
543
650
|
npx pqcheck deps <domain> Scan all third-party origins on the page (supply-chain HNDL)
|
|
544
651
|
npx pqcheck diff <old.lock> <new.lock> Compare two QXM lockfiles; exit 2 on regression
|
|
545
652
|
npx pqcheck history <domain> Show 90-day score history (sparkline + samples)
|
|
653
|
+
npx pqcheck changes <domain> Summarize public attack-surface changes in last 14 days
|
|
546
654
|
npx pqcheck cert <file.pem> Analyze a local PEM/CRT cert file (offline, no network)
|
|
655
|
+
npx pqcheck watch <domain> Add a domain to your watched-domain list (requires CIPHERWAKE_API_KEY)
|
|
547
656
|
|
|
548
657
|
${color("bold", "Multi-domain:")}
|
|
549
658
|
npx pqcheck a.com b.com c.com Multi-domain scan (positional)
|
|
@@ -568,7 +677,7 @@ ${color("bold", "Common flags:")}
|
|
|
568
677
|
|
|
569
678
|
${color("bold", "Subcommand-specific:")}
|
|
570
679
|
pqcheck deps:
|
|
571
|
-
--lock Also write
|
|
680
|
+
--lock Also write cipherwake-deps.lock + .md
|
|
572
681
|
-o <dir> Output directory for --lock files
|
|
573
682
|
--max=<N> Max third parties to scan (default 20)
|
|
574
683
|
--allowlist <file> Exit 3 if any third-party not in allowlist (CI gate)
|
|
@@ -597,14 +706,14 @@ ${color("bold", "Examples:")}
|
|
|
597
706
|
npx pqcheck deps acme.com --baseline .pqcheck-baseline.json --write-baseline ${color("dim", "# capture initial state")}
|
|
598
707
|
npx pqcheck deps acme.com --baseline .pqcheck-baseline.json --fail-on-new ${color("dim", "# fail PR on new third party")}
|
|
599
708
|
npx pqcheck diff main.lock pr.lock ${color("dim", "# regression detection in PR")}
|
|
600
|
-
npx pqcheck history
|
|
709
|
+
npx pqcheck history cipherwake.io
|
|
601
710
|
npx pqcheck cert ./mycert.pem ${color("dim", "# offline cert analysis")}
|
|
602
711
|
npx pqcheck --file domains.txt --format json > scans.ndjson
|
|
603
712
|
npx pqcheck mybank.com --format sarif > pqcheck.sarif ${color("dim", "# upload to Code Scanning")}
|
|
604
713
|
npx pqcheck mybank.com --gh-action ${color("dim", "# inline PR annotations")}
|
|
605
714
|
|
|
606
715
|
Backed by the patented Decryption Blast Radius methodology.
|
|
607
|
-
${color("violet", "https://
|
|
716
|
+
${color("violet", "https://cipherwake.io")}
|
|
608
717
|
`);
|
|
609
718
|
}
|
|
610
719
|
|
|
@@ -612,20 +721,46 @@ ${color("violet", "https://quantapact.com")}
|
|
|
612
721
|
// `pqcheck lock` — QXM (Quantum Exposure Manifest) generator
|
|
613
722
|
// =============================================================================
|
|
614
723
|
// Generates two files committable to a git repo:
|
|
615
|
-
//
|
|
616
|
-
//
|
|
724
|
+
// cipherwake.lock — stable JSON manifest (machine-readable)
|
|
725
|
+
// cipherwake-report.md — human-readable summary (renders on GitHub)
|
|
617
726
|
//
|
|
618
727
|
// Like SBOM / package-lock.json / cargo audit / snyk test outputs — devs commit
|
|
619
728
|
// these to track quantum exposure as a first-class technical concern.
|
|
620
729
|
//
|
|
730
|
+
// Filename history: this tool was previously named Quantapact, and earlier
|
|
731
|
+
// versions wrote `quantapact.lock` / `quantapact-report.md`. We permanently
|
|
732
|
+
// support reading EITHER filename; existing repos with the old name keep
|
|
733
|
+
// working forever. When re-locking in a directory that has the legacy file,
|
|
734
|
+
// we overwrite it in place rather than silently creating a new file alongside.
|
|
735
|
+
// New repos (no existing lockfile) get the new default `cipherwake.lock`.
|
|
736
|
+
//
|
|
621
737
|
// Usage:
|
|
622
|
-
// npx pqcheck lock <domain> Write to ./
|
|
738
|
+
// npx pqcheck lock <domain> Write to ./cipherwake.lock + .md
|
|
739
|
+
// (or preserves ./quantapact.lock if present)
|
|
623
740
|
// npx pqcheck lock <domain> -o dir/ Write into a specific directory
|
|
624
741
|
// npx pqcheck lock <domain> --stdout Print JSON to stdout (no files)
|
|
625
742
|
// npx pqcheck lock Read domain from existing
|
|
626
|
-
//
|
|
743
|
+
// cipherwake.lock OR quantapact.lock, else error
|
|
627
744
|
// =============================================================================
|
|
628
745
|
|
|
746
|
+
// Discover an existing lockfile in `dir`, preferring the new name but
|
|
747
|
+
// accepting the legacy name. Returns { lockPath, mdPath, isLegacy } if found,
|
|
748
|
+
// or null if neither exists. Read-anywhere, write-back-to-same-name policy.
|
|
749
|
+
async function discoverExistingLockfile(fs, path, dir) {
|
|
750
|
+
const candidates = [
|
|
751
|
+
{ lockName: "cipherwake.lock", mdName: "cipherwake-report.md", isLegacy: false },
|
|
752
|
+
{ lockName: "quantapact.lock", mdName: "quantapact-report.md", isLegacy: true },
|
|
753
|
+
];
|
|
754
|
+
for (const c of candidates) {
|
|
755
|
+
const lockPath = path.join(dir, c.lockName);
|
|
756
|
+
try {
|
|
757
|
+
await fs.access(lockPath);
|
|
758
|
+
return { lockPath, mdPath: path.join(dir, c.mdName), isLegacy: c.isLegacy };
|
|
759
|
+
} catch { /* try next */ }
|
|
760
|
+
}
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
|
|
629
764
|
async function runLockCommand(args) {
|
|
630
765
|
const fs = await import("node:fs/promises");
|
|
631
766
|
const path = await import("node:path");
|
|
@@ -639,16 +774,26 @@ async function runLockCommand(args) {
|
|
|
639
774
|
const positional = args.filter((a) => !a.startsWith("-") && a !== outDir);
|
|
640
775
|
let domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
|
|
641
776
|
|
|
777
|
+
// Discover any existing lockfile (new or legacy). Used both for re-lock
|
|
778
|
+
// domain auto-detection AND to preserve the filename on write.
|
|
779
|
+
const existing = await discoverExistingLockfile(fs, path, outDir);
|
|
780
|
+
|
|
642
781
|
if (!domain) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
782
|
+
if (existing) {
|
|
783
|
+
try {
|
|
784
|
+
const content = await fs.readFile(existing.lockPath, "utf8");
|
|
785
|
+
const parsed = JSON.parse(content);
|
|
786
|
+
domain = parsed.domain;
|
|
787
|
+
if (!stdout) {
|
|
788
|
+
const baseName = path.basename(existing.lockPath);
|
|
789
|
+
console.error(color("dim", `Re-locking from existing ${baseName} (domain: ${domain})`));
|
|
790
|
+
}
|
|
791
|
+
} catch {
|
|
792
|
+
console.error(color("red", `error: could not parse existing ${path.basename(existing.lockPath)}`));
|
|
793
|
+
process.exit(1);
|
|
649
794
|
}
|
|
650
|
-
}
|
|
651
|
-
console.error(color("red", "error: no domain provided and no existing
|
|
795
|
+
} else {
|
|
796
|
+
console.error(color("red", "error: no domain provided and no existing cipherwake.lock found"));
|
|
652
797
|
console.error(color("dim", "Usage: npx pqcheck lock <domain>"));
|
|
653
798
|
process.exit(1);
|
|
654
799
|
}
|
|
@@ -665,7 +810,7 @@ async function runLockCommand(args) {
|
|
|
665
810
|
try {
|
|
666
811
|
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
|
|
667
812
|
method: "GET",
|
|
668
|
-
headers: {
|
|
813
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (lock)` }),
|
|
669
814
|
});
|
|
670
815
|
if (!stdout) process.stderr.write("\r\x1b[K");
|
|
671
816
|
if (!resp.ok) {
|
|
@@ -688,9 +833,16 @@ async function runLockCommand(args) {
|
|
|
688
833
|
return;
|
|
689
834
|
}
|
|
690
835
|
|
|
691
|
-
// Write both files
|
|
692
|
-
|
|
693
|
-
|
|
836
|
+
// Write both files. Filename policy: if a legacy quantapact.lock already
|
|
837
|
+
// exists in this directory, overwrite it in place (preserve user's
|
|
838
|
+
// committed filename — no surprise renames in their repo). Otherwise
|
|
839
|
+
// default to the new cipherwake.lock.
|
|
840
|
+
const lockPath = existing
|
|
841
|
+
? existing.lockPath
|
|
842
|
+
: path.join(outDir, "cipherwake.lock");
|
|
843
|
+
const mdPath = existing
|
|
844
|
+
? existing.mdPath
|
|
845
|
+
: path.join(outDir, "cipherwake-report.md");
|
|
694
846
|
const md = renderQxmMarkdown(manifest);
|
|
695
847
|
|
|
696
848
|
try {
|
|
@@ -736,7 +888,7 @@ function buildQxmManifest(report, crypto) {
|
|
|
736
888
|
);
|
|
737
889
|
|
|
738
890
|
return {
|
|
739
|
-
schema: "https://
|
|
891
|
+
schema: "https://cipherwake.io/schemas/qxm/v1",
|
|
740
892
|
schemaVersion: 1,
|
|
741
893
|
generator: `pqcheck-cli/${VERSION}`,
|
|
742
894
|
generatedAt: report.generatedAt || new Date().toISOString(),
|
|
@@ -756,13 +908,13 @@ function buildQxmManifest(report, crypto) {
|
|
|
756
908
|
components: report.components || null,
|
|
757
909
|
evidence: {
|
|
758
910
|
evidenceHash,
|
|
759
|
-
methodology: "https://
|
|
760
|
-
shareableReport: `https://
|
|
761
|
-
badge: `https://
|
|
911
|
+
methodology: "https://cipherwake.io/methodology",
|
|
912
|
+
shareableReport: `https://cipherwake.io/r/${encodeURIComponent(report.domain)}`,
|
|
913
|
+
badge: `https://cipherwake.io/badge/${encodeURIComponent(report.domain)}.svg`,
|
|
762
914
|
},
|
|
763
915
|
remediation: {
|
|
764
916
|
tessera: tesseraNeeded ? "join-waitlist" : "not-needed",
|
|
765
|
-
tesseraWaitlist: "https://
|
|
917
|
+
tesseraWaitlist: "https://cipherwake.io/feedback?source=qxm-tessera-interest",
|
|
766
918
|
notes: tesseraNeeded
|
|
767
919
|
? "Findings include cryptographic exposure that Tessera SDK is being designed to remediate. Tessera is in development; join the waitlist to be notified when ready."
|
|
768
920
|
: "No quantum-decryption-relevant findings requiring Tessera remediation at this time.",
|
|
@@ -775,7 +927,7 @@ function renderQxmMarkdown(m) {
|
|
|
775
927
|
lines.push(`# Quantum Exposure Manifest — \`${m.domain}\``);
|
|
776
928
|
lines.push("");
|
|
777
929
|
lines.push(`> **Decryption Blast Radius:** ${m.score} / 10 (Grade ${m.grade}, ${m.scoreLabel})`);
|
|
778
|
-
lines.push(`> Generated by [pqcheck](https://
|
|
930
|
+
lines.push(`> Generated by [pqcheck](https://cipherwake.io) at ${m.generatedAt}`);
|
|
779
931
|
lines.push("");
|
|
780
932
|
if (!m.reachable) {
|
|
781
933
|
lines.push(`*${m.domain} was not reachable at scan time.*`);
|
|
@@ -852,7 +1004,8 @@ function renderQxmMarkdown(m) {
|
|
|
852
1004
|
// Fetches the public HTML of the target domain, extracts third-party origins
|
|
853
1005
|
// referenced via <script src>, <iframe src>, <link href>, <img src>, then runs
|
|
854
1006
|
// /api/scan against each unique third party. Outputs a sorted summary + an
|
|
855
|
-
// optional committable lockfile (quantapact-deps.lock
|
|
1007
|
+
// optional committable lockfile (cipherwake-deps.lock; legacy quantapact-deps.lock
|
|
1008
|
+
// is overwritten in place if present, see write-path comments below).
|
|
856
1009
|
//
|
|
857
1010
|
// Parallel to the browser extension's Dependencies tab, exposed as a CLI for
|
|
858
1011
|
// CI integration: gate PR builds on third-party crypto posture.
|
|
@@ -860,7 +1013,7 @@ function renderQxmMarkdown(m) {
|
|
|
860
1013
|
// Usage:
|
|
861
1014
|
// npx pqcheck deps <domain> Scan + print summary table
|
|
862
1015
|
// npx pqcheck deps <domain> --json JSON output (pipe to jq, etc.)
|
|
863
|
-
// npx pqcheck deps <domain> --lock Also write
|
|
1016
|
+
// npx pqcheck deps <domain> --lock Also write cipherwake-deps.lock + .md
|
|
864
1017
|
// npx pqcheck deps <domain> -o dir/ Output directory for --lock files
|
|
865
1018
|
// npx pqcheck deps <domain> --max=20 Cap on third parties scanned (default 20)
|
|
866
1019
|
// =============================================================================
|
|
@@ -980,7 +1133,7 @@ async function runDepsCommand(args) {
|
|
|
980
1133
|
batch.map(async (h) => {
|
|
981
1134
|
try {
|
|
982
1135
|
const r = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(h.host)}&source=cli-deps`, {
|
|
983
|
-
headers: {
|
|
1136
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (deps)` }),
|
|
984
1137
|
});
|
|
985
1138
|
if (!r.ok) return { ...h, types: Array.from(h.types), scan: null, error: `${r.status}` };
|
|
986
1139
|
const body = await r.json();
|
|
@@ -1034,7 +1187,7 @@ async function runDepsCommand(args) {
|
|
|
1034
1187
|
|
|
1035
1188
|
// Build manifest
|
|
1036
1189
|
const manifest = {
|
|
1037
|
-
$schema: "https://
|
|
1190
|
+
$schema: "https://cipherwake.io/schemas/deps/v1",
|
|
1038
1191
|
schemaVersion: "1.2", // bumped for CSP + vendor classification fields
|
|
1039
1192
|
domain,
|
|
1040
1193
|
scannedAt: new Date().toISOString(),
|
|
@@ -1074,7 +1227,7 @@ async function runDepsCommand(args) {
|
|
|
1074
1227
|
try {
|
|
1075
1228
|
const fs2 = await import("node:fs/promises");
|
|
1076
1229
|
const baselinePayload = {
|
|
1077
|
-
$schema: "https://
|
|
1230
|
+
$schema: "https://cipherwake.io/schemas/deps-baseline/v1",
|
|
1078
1231
|
domain,
|
|
1079
1232
|
capturedAt: new Date().toISOString(),
|
|
1080
1233
|
toolVersion: VERSION,
|
|
@@ -1143,10 +1296,19 @@ async function runDepsCommand(args) {
|
|
|
1143
1296
|
const types = r.types.join(",");
|
|
1144
1297
|
// Vendor classification — "(New Relic · errors)" beats "bam.nr-data.net"
|
|
1145
1298
|
// for comprehension. Padded to 22 chars so the table columns stay aligned.
|
|
1299
|
+
// Heuristic matches return `name: null` and just a category — render as
|
|
1300
|
+
// "(cdn — inferred)" to signal we're guessing without claiming certainty.
|
|
1146
1301
|
const vendor = classifyVendor(r.host);
|
|
1147
|
-
|
|
1302
|
+
let vendorStrRaw;
|
|
1303
|
+
if (vendor?.name) {
|
|
1304
|
+
vendorStrRaw = `${vendor.name} (${vendor.category})`;
|
|
1305
|
+
} else if (vendor?.category) {
|
|
1306
|
+
vendorStrRaw = `(${vendor.category} — inferred)`;
|
|
1307
|
+
} else {
|
|
1308
|
+
vendorStrRaw = "—";
|
|
1309
|
+
}
|
|
1148
1310
|
const vendorTruncated = vendorStrRaw.length > 22 ? vendorStrRaw.slice(0, 21) + "…" : vendorStrRaw.padEnd(22, " ");
|
|
1149
|
-
const vendorColored =
|
|
1311
|
+
const vendorColored = color("dim", vendorTruncated);
|
|
1150
1312
|
console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${vendorColored} ${pqc} ${sriCell} ${color("dim", types)}`);
|
|
1151
1313
|
}
|
|
1152
1314
|
console.log("");
|
|
@@ -1158,8 +1320,19 @@ async function runDepsCommand(args) {
|
|
|
1158
1320
|
console.log("");
|
|
1159
1321
|
|
|
1160
1322
|
if (lock) {
|
|
1161
|
-
|
|
1162
|
-
|
|
1323
|
+
// Filename policy mirrors `pqcheck lock`: prefer the new cipherwake-deps.lock,
|
|
1324
|
+
// but if a legacy quantapact-deps.lock exists in this dir, overwrite that one
|
|
1325
|
+
// in place so the user's repo doesn't suddenly grow a second lockfile.
|
|
1326
|
+
const fsSync = await import("node:fs");
|
|
1327
|
+
const legacyDepsLock = path.join(outDir, "quantapact-deps.lock");
|
|
1328
|
+
const legacyDepsMd = path.join(outDir, "quantapact-deps-report.md");
|
|
1329
|
+
const hasLegacy = fsSync.existsSync(legacyDepsLock);
|
|
1330
|
+
const lockPath = hasLegacy
|
|
1331
|
+
? legacyDepsLock
|
|
1332
|
+
: path.join(outDir, "cipherwake-deps.lock");
|
|
1333
|
+
const mdPath = hasLegacy
|
|
1334
|
+
? legacyDepsMd
|
|
1335
|
+
: path.join(outDir, "cipherwake-deps-report.md");
|
|
1163
1336
|
try {
|
|
1164
1337
|
await fs.mkdir(outDir, { recursive: true });
|
|
1165
1338
|
await fs.writeFile(lockPath, JSON.stringify(manifest, null, 2));
|
|
@@ -1199,7 +1372,7 @@ async function fetchPageHTML(domain) {
|
|
|
1199
1372
|
method: "GET",
|
|
1200
1373
|
redirect: "follow",
|
|
1201
1374
|
signal: ctrl.signal,
|
|
1202
|
-
headers: { "User-Agent": `pqcheck-cli/${VERSION} (deps; +https://
|
|
1375
|
+
headers: { "User-Agent": `pqcheck-cli/${VERSION} (deps; +https://cipherwake.io)` },
|
|
1203
1376
|
});
|
|
1204
1377
|
clearTimeout(t);
|
|
1205
1378
|
if (!resp.ok) return null;
|
|
@@ -1303,6 +1476,39 @@ const SERVICE_CATALOG = {
|
|
|
1303
1476
|
"tiktok.com": { name: "TikTok", category: "social" },
|
|
1304
1477
|
};
|
|
1305
1478
|
|
|
1479
|
+
// Conservative heuristic patterns — same as lib/serviceCatalog.ts + popup.js.
|
|
1480
|
+
// Used when a host isn't in the explicit catalog. Doesn't name the vendor
|
|
1481
|
+
// (we don't know), but assigns a high-confidence category like "cdn" or
|
|
1482
|
+
// "ads" so unknown hosts aren't blank.
|
|
1483
|
+
const HEURISTIC_PATTERNS = [
|
|
1484
|
+
// CDN
|
|
1485
|
+
{ re: /^cdn[.-]/, category: "cdn" },
|
|
1486
|
+
{ re: /^static\./, category: "cdn" },
|
|
1487
|
+
{ re: /^assets\./, category: "cdn" },
|
|
1488
|
+
{ re: /\.cloudfront\.net$/, category: "cdn" },
|
|
1489
|
+
{ re: /\.akamai(?:edge|hd|ized)?\.net$/, category: "cdn" },
|
|
1490
|
+
{ re: /\.fastly\.net$/, category: "cdn" },
|
|
1491
|
+
{ re: /\.azureedge\.net$/, category: "cdn" },
|
|
1492
|
+
// Analytics / RUM
|
|
1493
|
+
{ re: /^analytics?\./, category: "analytics" },
|
|
1494
|
+
{ re: /^metrics?\./, category: "analytics" },
|
|
1495
|
+
{ re: /^telemetry\./, category: "analytics" },
|
|
1496
|
+
{ re: /^rum\./, category: "analytics" },
|
|
1497
|
+
// Ads
|
|
1498
|
+
{ re: /^ads?[.-]/, category: "ads" },
|
|
1499
|
+
{ re: /^adserver\./, category: "ads" },
|
|
1500
|
+
{ re: /^pubads\./, category: "ads" },
|
|
1501
|
+
{ re: /\.advertising\./, category: "ads" },
|
|
1502
|
+
// Consent / cookies
|
|
1503
|
+
{ re: /^consent\./, category: "consent" },
|
|
1504
|
+
{ re: /^cookies?\./, category: "consent" },
|
|
1505
|
+
{ re: /^gdpr\./, category: "consent" },
|
|
1506
|
+
// Fonts
|
|
1507
|
+
{ re: /^fonts?\./, category: "fonts" },
|
|
1508
|
+
// Errors / monitoring
|
|
1509
|
+
{ re: /^sentry[.-]/, category: "errors" },
|
|
1510
|
+
];
|
|
1511
|
+
|
|
1306
1512
|
function classifyVendor(host) {
|
|
1307
1513
|
if (!host) return null;
|
|
1308
1514
|
const lower = host.toLowerCase();
|
|
@@ -1312,6 +1518,14 @@ function classifyVendor(host) {
|
|
|
1312
1518
|
return SERVICE_CATALOG[pattern];
|
|
1313
1519
|
}
|
|
1314
1520
|
}
|
|
1521
|
+
for (const { re, category } of HEURISTIC_PATTERNS) {
|
|
1522
|
+
if (re.test(lower)) {
|
|
1523
|
+
// Inferred — no vendor name, just the category. CLI consumers (the
|
|
1524
|
+
// pretty table + JSON output) check `name === null` to distinguish
|
|
1525
|
+
// from explicit catalog matches.
|
|
1526
|
+
return { name: null, category };
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1315
1529
|
return null;
|
|
1316
1530
|
}
|
|
1317
1531
|
|
|
@@ -1451,6 +1665,67 @@ function depsManifestToMarkdown(m) {
|
|
|
1451
1665
|
// SARIF + GitHub Action output formats
|
|
1452
1666
|
// =============================================================================
|
|
1453
1667
|
|
|
1668
|
+
// Derive a SARIF-stable rule ID from a finding. Prefer the registry's stable
|
|
1669
|
+
// `id` (e.g., "tls.rsa_kex_fallback") so the same finding gets the same
|
|
1670
|
+
// ruleId every run — without it, GitHub Code Scanning treats a reordered
|
|
1671
|
+
// finding list as a fresh batch of new findings, blowing up the triage UX.
|
|
1672
|
+
// Short non-crypto hash for SARIF rule-ID disambiguation. djb2-style.
|
|
1673
|
+
// Pure JS so it works in both Node and the CLI bundle. 8-char hex output
|
|
1674
|
+
// is enough entropy to disambiguate distinct legacy findings without
|
|
1675
|
+
// becoming churn-prone — same input string always produces the same hash.
|
|
1676
|
+
function shortHash(input) {
|
|
1677
|
+
let h = 5381;
|
|
1678
|
+
for (let i = 0; i < input.length; i++) {
|
|
1679
|
+
h = ((h << 5) + h + input.charCodeAt(i)) >>> 0;
|
|
1680
|
+
}
|
|
1681
|
+
return h.toString(16).padStart(8, "0");
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function stableRuleId(f) {
|
|
1685
|
+
if (f && typeof f.id === "string" && f.id.length > 0) {
|
|
1686
|
+
// Normalize: if a registry ID already carries the `pqcheck.` prefix
|
|
1687
|
+
// (which happened before this helper existed), don't double-prefix
|
|
1688
|
+
// it into `pqcheck.pqcheck.tls...`. Strip and reattach.
|
|
1689
|
+
const id = f.id.replace(/^pqcheck\./i, "");
|
|
1690
|
+
return `pqcheck.${id}`;
|
|
1691
|
+
}
|
|
1692
|
+
// Fallback for legacy findings emitted without the registry — slug the
|
|
1693
|
+
// title. Still stable across runs (same input → same output).
|
|
1694
|
+
const title = f?.title || "finding";
|
|
1695
|
+
const detail = f?.detail || "";
|
|
1696
|
+
const slug = title
|
|
1697
|
+
.toLowerCase()
|
|
1698
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
1699
|
+
.replace(/^_+|_+$/g, "")
|
|
1700
|
+
.slice(0, 48);
|
|
1701
|
+
// GPT adversarial review 2026-05-12: legacy findings with similar
|
|
1702
|
+
// titles ("No HSTS header" vs "No HSTS Header!") both slug to
|
|
1703
|
+
// "no_hsts_header" and would collide in SARIF, causing GitHub Code
|
|
1704
|
+
// Scanning to merge unrelated findings. Append a short hash of the
|
|
1705
|
+
// full (title + detail) so semantically-different findings get
|
|
1706
|
+
// distinct rule IDs even when their slugged titles match.
|
|
1707
|
+
const disambiguator = shortHash(`${title}|${detail}`);
|
|
1708
|
+
if (slug.length === 0) {
|
|
1709
|
+
return `pqcheck.legacy.unnamed.${disambiguator}`;
|
|
1710
|
+
}
|
|
1711
|
+
return `pqcheck.legacy.${slug}.${disambiguator}`;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Deduplicate the `rules` array — multiple findings can share the same
|
|
1715
|
+
// underlying rule (e.g., two cert findings both pinned to `cert.expired`).
|
|
1716
|
+
// SARIF's rule list must contain each rule once.
|
|
1717
|
+
function dedupeRules(findings) {
|
|
1718
|
+
const seen = new Set();
|
|
1719
|
+
const out = [];
|
|
1720
|
+
for (const f of findings) {
|
|
1721
|
+
const id = stableRuleId(f);
|
|
1722
|
+
if (seen.has(id)) continue;
|
|
1723
|
+
seen.add(id);
|
|
1724
|
+
out.push(f);
|
|
1725
|
+
}
|
|
1726
|
+
return out;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1454
1729
|
function reportToSarif(report) {
|
|
1455
1730
|
// SARIF 2.1.0 minimal schema for security findings.
|
|
1456
1731
|
// Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
|
@@ -1464,9 +1739,15 @@ function reportToSarif(report) {
|
|
|
1464
1739
|
driver: {
|
|
1465
1740
|
name: "pqcheck",
|
|
1466
1741
|
version: VERSION,
|
|
1467
|
-
informationUri: "https://
|
|
1468
|
-
|
|
1469
|
-
|
|
1742
|
+
informationUri: "https://cipherwake.io",
|
|
1743
|
+
// Stable rule IDs — anchored to the finding's registry id (e.g.
|
|
1744
|
+
// "tls.rsa_kex_fallback") when present, otherwise a slug derived
|
|
1745
|
+
// from the title. Previously used positional pqcheck-${i+1} which
|
|
1746
|
+
// made GitHub Code Scanning see every reorder as a "new" finding,
|
|
1747
|
+
// poisoning the dedup/triage UX. Stable IDs let Code Scanning
|
|
1748
|
+
// recognize the same rule across runs.
|
|
1749
|
+
rules: dedupeRules(findings).map((f) => ({
|
|
1750
|
+
id: stableRuleId(f),
|
|
1470
1751
|
name: (f.title || "finding").replace(/[^A-Za-z0-9]/g, "_"),
|
|
1471
1752
|
shortDescription: { text: f.title || "finding" },
|
|
1472
1753
|
fullDescription: { text: f.detail || f.title || "finding" },
|
|
@@ -1474,8 +1755,8 @@ function reportToSarif(report) {
|
|
|
1474
1755
|
})),
|
|
1475
1756
|
},
|
|
1476
1757
|
},
|
|
1477
|
-
results: findings.map((f
|
|
1478
|
-
ruleId:
|
|
1758
|
+
results: findings.map((f) => ({
|
|
1759
|
+
ruleId: stableRuleId(f),
|
|
1479
1760
|
level: sevMap[f.severity] || "note",
|
|
1480
1761
|
message: { text: `${f.title || "finding"}${f.detail ? ` — ${f.detail}` : ""}` },
|
|
1481
1762
|
// GitHub Code Scanning requires file: scheme (or relative path) for
|
|
@@ -1483,7 +1764,7 @@ function reportToSarif(report) {
|
|
|
1483
1764
|
// relative path so findings show up cleanly in the Security tab.
|
|
1484
1765
|
locations: [{
|
|
1485
1766
|
physicalLocation: {
|
|
1486
|
-
artifactLocation: { uri: `
|
|
1767
|
+
artifactLocation: { uri: `cipherwake-scan/${report.domain || "unknown"}.txt` },
|
|
1487
1768
|
region: { startLine: 1, startColumn: 1 },
|
|
1488
1769
|
},
|
|
1489
1770
|
}],
|
|
@@ -1492,7 +1773,7 @@ function reportToSarif(report) {
|
|
|
1492
1773
|
score: report.score,
|
|
1493
1774
|
grade: report.grade,
|
|
1494
1775
|
severity: f.severity,
|
|
1495
|
-
reportUrl: `https://www.
|
|
1776
|
+
reportUrl: `https://www.cipherwake.io/r/${report.domain || ""}`,
|
|
1496
1777
|
},
|
|
1497
1778
|
})),
|
|
1498
1779
|
properties: {
|
|
@@ -1513,10 +1794,10 @@ function printGitHubActionAnnotations(report) {
|
|
|
1513
1794
|
if (report._meta?.degraded) {
|
|
1514
1795
|
const reason = String(report._meta.degradedReason || "live probe failed").replace(/[\r\n]/g, " ").replace(/::/g, ":");
|
|
1515
1796
|
const since = String(report._meta.lastUpdated || "unknown");
|
|
1516
|
-
console.log(`::warning title=
|
|
1797
|
+
console.log(`::warning title=Cipherwake: cached score (live probe failed)::Showing last known-good score from ${since}. Reason: ${reason}. Re-run shortly for a fresh probe.`);
|
|
1517
1798
|
}
|
|
1518
1799
|
// Top-line score/grade as a notice
|
|
1519
|
-
console.log(`::notice title=
|
|
1800
|
+
console.log(`::notice title=Cipherwake: ${report.domain}::Grade ${report.grade || "?"} · score ${report.score ?? "?"} / 10`);
|
|
1520
1801
|
for (const f of findings) {
|
|
1521
1802
|
const cmd = sevMap[f.severity] || "notice";
|
|
1522
1803
|
const title = (f.title || "finding").replace(/[\r\n]/g, " ");
|
|
@@ -1548,7 +1829,7 @@ async function runHistoryCommand(args) {
|
|
|
1548
1829
|
let h;
|
|
1549
1830
|
try {
|
|
1550
1831
|
const r = await fetch(`${API_BASE}/api/history?domain=${encodeURIComponent(domain)}&days=${days}`, {
|
|
1551
|
-
headers: {
|
|
1832
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (history)` }),
|
|
1552
1833
|
});
|
|
1553
1834
|
if (!r.ok) {
|
|
1554
1835
|
console.error(color("red", `error: ${r.status} ${r.statusText}`));
|
|
@@ -1608,6 +1889,84 @@ async function runHistoryCommand(args) {
|
|
|
1608
1889
|
console.log("");
|
|
1609
1890
|
}
|
|
1610
1891
|
|
|
1892
|
+
// =============================================================================
|
|
1893
|
+
// `pqcheck changes <domain>` — surface observation-table deltas for a domain
|
|
1894
|
+
// =============================================================================
|
|
1895
|
+
// Hits /api/changes-summary which aggregates the new observation tables
|
|
1896
|
+
// shipped 2026-05-13 (subdomain_observations, script_observations,
|
|
1897
|
+
// posture_snapshots, cert_observations). Returns "N attack-surface changes
|
|
1898
|
+
// in last 14d" + breakdown. Devs use this in CI ("did anything change since
|
|
1899
|
+
// yesterday?") and in PR descriptions ("attached: 3 changes detected since
|
|
1900
|
+
// last week").
|
|
1901
|
+
|
|
1902
|
+
async function runChangesCommand(args) {
|
|
1903
|
+
const json = args.includes("--json");
|
|
1904
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
1905
|
+
const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
|
|
1906
|
+
if (!domain || !isValidDomain(domain)) {
|
|
1907
|
+
console.error(color("red", "error: pqcheck changes requires a valid domain"));
|
|
1908
|
+
console.error(color("dim", "Usage: npx pqcheck changes <domain> [--json]"));
|
|
1909
|
+
process.exit(1);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
let summary;
|
|
1913
|
+
try {
|
|
1914
|
+
const r = await fetch(`${API_BASE}/api/changes-summary?domain=${encodeURIComponent(domain)}`, {
|
|
1915
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (changes)` }),
|
|
1916
|
+
});
|
|
1917
|
+
if (!r.ok) {
|
|
1918
|
+
console.error(color("red", `error: ${r.status} ${r.statusText}`));
|
|
1919
|
+
process.exit(1);
|
|
1920
|
+
}
|
|
1921
|
+
summary = await r.json();
|
|
1922
|
+
} catch (err) {
|
|
1923
|
+
console.error(color("red", `error: ${err.message}`));
|
|
1924
|
+
process.exit(1);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
if (json) {
|
|
1928
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const tracked = summary.trackedSince;
|
|
1933
|
+
const total = summary.changes?.last14d ?? 0;
|
|
1934
|
+
const b = summary.breakdown ?? {};
|
|
1935
|
+
console.log("");
|
|
1936
|
+
console.log(` ${color("bold", domain)} ${color("dim", "·")} attack-surface change summary`);
|
|
1937
|
+
|
|
1938
|
+
if (!tracked) {
|
|
1939
|
+
console.log(color("dim", " No observation history yet. Run `npx pqcheck " + domain + "` to start accumulating."));
|
|
1940
|
+
console.log("");
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
const trackedDate = String(tracked).slice(0, 10);
|
|
1945
|
+
console.log(color("dim", ` Tracking since ${trackedDate}`));
|
|
1946
|
+
console.log("");
|
|
1947
|
+
|
|
1948
|
+
if (total === 0) {
|
|
1949
|
+
console.log(` ${color("green", "✓")} No public attack-surface changes detected in the last 14 days.`);
|
|
1950
|
+
console.log("");
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
console.log(` ${color("yellow", "⚠")} ${color("bold", total)} change${total === 1 ? "" : "s"} detected in the last 14 days:`);
|
|
1955
|
+
console.log("");
|
|
1956
|
+
if (b.newSubdomains14d) {
|
|
1957
|
+
console.log(` ${color("violet", "•")} ${color("bold", b.newSubdomains14d)} new subdomain${b.newSubdomains14d === 1 ? "" : "s"} observed in CT logs or live scans`);
|
|
1958
|
+
}
|
|
1959
|
+
if (b.newScripts14d) {
|
|
1960
|
+
console.log(` ${color("violet", "•")} ${color("bold", b.newScripts14d)} new third-party script host${b.newScripts14d === 1 ? "" : "s"} loaded`);
|
|
1961
|
+
}
|
|
1962
|
+
if (b.newCertKeys14d) {
|
|
1963
|
+
console.log(` ${color("violet", "•")} ${color("bold", b.newCertKeys14d)} new SPKI fingerprint${b.newCertKeys14d === 1 ? "" : "s"} (cert rotation with new key)`);
|
|
1964
|
+
}
|
|
1965
|
+
console.log("");
|
|
1966
|
+
console.log(color("dim", ` Full changelog: ${API_BASE}/domain/${domain}/security-changelog`));
|
|
1967
|
+
console.log("");
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1611
1970
|
// =============================================================================
|
|
1612
1971
|
// `pqcheck diff` — diff two QXM lockfiles
|
|
1613
1972
|
// =============================================================================
|
|
@@ -1637,7 +1996,7 @@ async function runDiffCommand(args) {
|
|
|
1637
1996
|
}
|
|
1638
1997
|
|
|
1639
1998
|
console.log("");
|
|
1640
|
-
console.log(` ${color("bold", "
|
|
1999
|
+
console.log(` ${color("bold", "Cipherwake lockfile diff")}`);
|
|
1641
2000
|
console.log(` ${color("dim", `${positional[0]} → ${positional[1]}`)}`);
|
|
1642
2001
|
console.log("");
|
|
1643
2002
|
if (diff.scoreChange !== null) {
|
|
@@ -1708,6 +2067,114 @@ function computeLockDiff(oldLock, newLock) {
|
|
|
1708
2067
|
// `pqcheck cert <pem-file>` — analyze a local cert file (offline)
|
|
1709
2068
|
// =============================================================================
|
|
1710
2069
|
|
|
2070
|
+
// `pqcheck watch <domain>` — adds the given domain to the user's watched-
|
|
2071
|
+
// domain list via the authenticated /api/watched-domains POST. Requires
|
|
2072
|
+
// QUANTAPACT_API_KEY env var. Closes the CLI ↔ account loop: developers
|
|
2073
|
+
// who use the CLI can now opt into persistent monitoring from the same
|
|
2074
|
+
// surface without leaving the terminal.
|
|
2075
|
+
async function runWatchCommand(args) {
|
|
2076
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
2077
|
+
if (positional.length === 0) {
|
|
2078
|
+
console.error(color("red", "error: pqcheck watch requires a domain"));
|
|
2079
|
+
console.error(color("dim", "Usage: npx pqcheck watch <domain>"));
|
|
2080
|
+
console.error("");
|
|
2081
|
+
console.error(color("dim", "Example: npx pqcheck watch chase.com"));
|
|
2082
|
+
process.exit(1);
|
|
2083
|
+
}
|
|
2084
|
+
if (!QP_API_KEY) {
|
|
2085
|
+
const rawDomain = positional[0] ? String(positional[0]).trim().toLowerCase() : "";
|
|
2086
|
+
const looksLikeDomain = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(rawDomain);
|
|
2087
|
+
console.error(color("red", "error: pqcheck watch requires CIPHERWAKE_API_KEY (paid tier)"));
|
|
2088
|
+
console.error("");
|
|
2089
|
+
console.error(color("dim", "Two ways to add this domain:"));
|
|
2090
|
+
if (looksLikeDomain) {
|
|
2091
|
+
console.error(color("dim", ` 1. Sign up + click "Watch" in your browser:`));
|
|
2092
|
+
console.error(color("dim", ` ${API_BASE}/watch/${rawDomain}`));
|
|
2093
|
+
} else {
|
|
2094
|
+
console.error(color("dim", ` 1. Sign up + click "Watch" in your browser:`));
|
|
2095
|
+
console.error(color("dim", ` ${API_BASE}/watch/<your-domain>`));
|
|
2096
|
+
}
|
|
2097
|
+
console.error(color("dim", ` 2. Stay on the CLI — sign up + rotate an API key:`));
|
|
2098
|
+
console.error(color("dim", ` ${API_BASE}/signin`));
|
|
2099
|
+
console.error(color("dim", ` ${API_BASE}/account (rotate key)`));
|
|
2100
|
+
console.error(color("dim", ` export CIPHERWAKE_API_KEY=qpk_...`));
|
|
2101
|
+
process.exit(1);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
const raw = positional[0];
|
|
2105
|
+
const domain = normalizeDomain(raw);
|
|
2106
|
+
if (!isValidDomain(domain)) {
|
|
2107
|
+
console.error(color("red", `error: '${raw}' is not a valid domain`));
|
|
2108
|
+
process.exit(1);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
console.log("");
|
|
2112
|
+
console.log(color("violet", ` 📌 Adding ${domain} to your watched-domain list…`));
|
|
2113
|
+
|
|
2114
|
+
let resp;
|
|
2115
|
+
try {
|
|
2116
|
+
resp = await fetch(`${API_BASE}/api/watched-domains`, {
|
|
2117
|
+
method: "POST",
|
|
2118
|
+
headers: {
|
|
2119
|
+
"content-type": "application/json",
|
|
2120
|
+
"authorization": "Bearer " + QP_API_KEY,
|
|
2121
|
+
},
|
|
2122
|
+
body: JSON.stringify({ domain }),
|
|
2123
|
+
});
|
|
2124
|
+
} catch (err) {
|
|
2125
|
+
console.error(color("red", ` network error: ${err.message ?? err}`));
|
|
2126
|
+
process.exit(1);
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
2130
|
+
console.error(color("red", ` authentication failed (HTTP ${resp.status})`));
|
|
2131
|
+
console.error(color("dim", ` Your API key may be invalid or revoked. Regenerate at ${API_BASE}/account`));
|
|
2132
|
+
process.exit(1);
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
let out = {};
|
|
2136
|
+
try { out = await resp.json(); } catch { /* ignore */ }
|
|
2137
|
+
|
|
2138
|
+
if (!resp.ok) {
|
|
2139
|
+
if (out.error === "domain_already_watched") {
|
|
2140
|
+
console.log(color("dim", ` ${domain} is already on your watched-domain list.`));
|
|
2141
|
+
console.log(color("dim", ` Manage at: ${API_BASE}/account`));
|
|
2142
|
+
process.exit(0);
|
|
2143
|
+
}
|
|
2144
|
+
if (out.error === "tier_cap_exceeded") {
|
|
2145
|
+
console.error(color("red", ` ${out.message || "Tier cap reached."}`));
|
|
2146
|
+
console.error(color("dim", ` See pricing: ${API_BASE}/pricing`));
|
|
2147
|
+
process.exit(1);
|
|
2148
|
+
}
|
|
2149
|
+
if (out.error === "invalid_domain") {
|
|
2150
|
+
console.error(color("red", ` ${domain} is not a valid hostname.`));
|
|
2151
|
+
process.exit(1);
|
|
2152
|
+
}
|
|
2153
|
+
console.error(color("red", ` add failed: ${out.message || out.error || `HTTP ${resp.status}`}`));
|
|
2154
|
+
process.exit(1);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
console.log("");
|
|
2158
|
+
console.log(color("green", ` ✓ Now watching ${domain}.`));
|
|
2159
|
+
console.log("");
|
|
2160
|
+
if (out.verificationInstructions) {
|
|
2161
|
+
const v = out.verificationInstructions;
|
|
2162
|
+
console.log(color("bold", " Next: verify ownership"));
|
|
2163
|
+
console.log(color("dim", ` Pick ONE method:`));
|
|
2164
|
+
console.log("");
|
|
2165
|
+
console.log(color("dim", ` DNS TXT — name: ${v.dnsTxt?.recordName}`));
|
|
2166
|
+
console.log(color("dim", ` value: ${v.dnsTxt?.recordValue}`));
|
|
2167
|
+
console.log("");
|
|
2168
|
+
console.log(color("dim", ` HTTP file — url: ${v.wellKnown?.url}`));
|
|
2169
|
+
console.log(color("dim", ` body: ${v.wellKnown?.body}`));
|
|
2170
|
+
console.log("");
|
|
2171
|
+
console.log(color("dim", ` After adding the record, click 'Verify now' at ${API_BASE}/account`));
|
|
2172
|
+
console.log(color("dim", ` or run: npx pqcheck watch ${domain} --verify (coming soon)`));
|
|
2173
|
+
}
|
|
2174
|
+
console.log("");
|
|
2175
|
+
process.exit(0);
|
|
2176
|
+
}
|
|
2177
|
+
|
|
1711
2178
|
async function runCertCommand(args) {
|
|
1712
2179
|
const fs = await import("node:fs/promises");
|
|
1713
2180
|
const crypto = await import("node:crypto");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pqcheck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -17,15 +17,15 @@
|
|
|
17
17
|
"crypto-audit",
|
|
18
18
|
"crypto-inventory"
|
|
19
19
|
],
|
|
20
|
-
"homepage": "https://
|
|
21
|
-
"bugs": "https://
|
|
20
|
+
"homepage": "https://cipherwake.io",
|
|
21
|
+
"bugs": "https://cipherwake.io",
|
|
22
22
|
"repository": {
|
|
23
23
|
"type": "git",
|
|
24
|
-
"url": "https://github.com/
|
|
24
|
+
"url": "https://github.com/cipherwake-io/pqcheck.git",
|
|
25
25
|
"directory": "cli"
|
|
26
26
|
},
|
|
27
27
|
"license": "MIT",
|
|
28
|
-
"author": "
|
|
28
|
+
"author": "Cipherwake",
|
|
29
29
|
"type": "module",
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=18"
|