pqcheck 0.7.8 → 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 +34 -27
- package/bin/pqcheck.js +657 -62
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -8,10 +8,14 @@ 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
|
|
|
15
|
+
## What's new in 0.7.9
|
|
16
|
+
|
|
17
|
+
**CSP verdict + vendor labels on `pqcheck deps`.** The supply-chain table now shows a friendly vendor label (`New Relic · errors` / `Cloudflare · cdn` / `Adobe Fonts · fonts`) per host instead of raw `bam.nr-data.net`-style hostnames, plus a one-line site-wide CSP verdict above the table (`✗ No CSP enforcement` / `⚠ CSP is permissive` / `✓ Strict CSP enforced`). Same data shape ships on `/r/<domain>` and in the browser extension — cross-surface parity for the supply-chain story. See [CHANGELOG.md](./CHANGELOG.md).
|
|
18
|
+
|
|
15
19
|
## What's new in 0.7.8
|
|
16
20
|
|
|
17
21
|
**Supply-chain change detection in CI** — `pqcheck deps <domain> --baseline file.json` compares the current third-party host list to a stored baseline. New hosts since the last accepted state are flagged `*NEW*` in the pretty table and `"isNew": true` in JSON. Add `--fail-on-new` to exit `4` if anything new appeared — the Polyfill.io-style CI gate that fails PRs introducing third-party scripts until you deliberately accept them with `--write-baseline`. Each row also shows an `SRI` column (on/off/n/a) so you can see which scripts allow silent vendor-side content swaps. See [CHANGELOG.md](./CHANGELOG.md).
|
|
@@ -38,10 +42,11 @@ Plus a full ASM check suite for credibility:
|
|
|
38
42
|
|
|
39
43
|
```
|
|
40
44
|
npx pqcheck <domain> Scan + print human-readable report
|
|
41
|
-
npx pqcheck lock <domain> Generate
|
|
45
|
+
npx pqcheck lock <domain> Generate cipherwake.lock (QXM) committable manifest
|
|
42
46
|
npx pqcheck deps <domain> Scan all third-party origins on the page (supply-chain HNDL)
|
|
43
47
|
npx pqcheck diff <old.lock> <new.lock> Compare two QXM lockfiles; exit 2 on regression
|
|
44
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
|
|
45
50
|
npx pqcheck cert <file.pem> Analyze a local PEM/CRT cert file (offline, no network)
|
|
46
51
|
```
|
|
47
52
|
|
|
@@ -77,7 +82,7 @@ npx pqcheck --file domains.txt Bulk scan from a newline-separated
|
|
|
77
82
|
### Subcommand-specific flags
|
|
78
83
|
|
|
79
84
|
**`pqcheck deps`:**
|
|
80
|
-
- `--lock` — Also write `
|
|
85
|
+
- `--lock` — Also write `cipherwake-deps.lock` + `.md`
|
|
81
86
|
- `-o <dir>` — Output directory for `--lock` files
|
|
82
87
|
- `--max=<N>` — Max third parties to scan (default 20)
|
|
83
88
|
- `--allowlist <file>` — Exit **3** if any third-party not in allowlist (CI vendor-risk gate)
|
|
@@ -156,13 +161,15 @@ Like SBOM, `package-lock.json`, or `cargo audit` outputs — track quantum expos
|
|
|
156
161
|
```bash
|
|
157
162
|
npx pqcheck lock yourcompany.com
|
|
158
163
|
# Writes:
|
|
159
|
-
#
|
|
160
|
-
#
|
|
164
|
+
# cipherwake.lock — stable JSON manifest
|
|
165
|
+
# cipherwake-report.md — human-readable summary (renders on GitHub)
|
|
161
166
|
```
|
|
162
167
|
|
|
163
168
|
Commit both files. Use `npx pqcheck diff old.lock new.lock` in CI to surface regressions in PR comments.
|
|
164
169
|
|
|
165
|
-
|
|
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).
|
|
166
173
|
|
|
167
174
|
### Supply-chain dependency scanning
|
|
168
175
|
|
|
@@ -171,41 +178,41 @@ npx pqcheck deps stripe.com
|
|
|
171
178
|
# Output: every third-party origin on stripe.com (analytics, CDN, fonts, etc.) graded for quantum risk
|
|
172
179
|
```
|
|
173
180
|
|
|
174
|
-
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.
|
|
175
182
|
|
|
176
183
|
## Companion surfaces
|
|
177
184
|
|
|
178
|
-
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):
|
|
179
186
|
|
|
180
187
|
| Surface | Where |
|
|
181
188
|
|---|---|
|
|
182
189
|
| **CLI** (this package) | `npx pqcheck` |
|
|
183
190
|
| **Browser extension** | Chrome Web Store / Firefox AMO / Edge — toolbar badge per tab + dependency analysis |
|
|
184
|
-
| **GitHub Action** | [`
|
|
185
|
-
| **Slack `/pqcheck`** | [Install on workspace](https://
|
|
186
|
-
| **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>` |
|
|
187
194
|
|
|
188
195
|
## Public API
|
|
189
196
|
|
|
190
|
-
`pqcheck` is a wrapper around the public
|
|
197
|
+
`pqcheck` is a wrapper around the public Cipherwake API. You can also call the API directly:
|
|
191
198
|
|
|
192
199
|
```bash
|
|
193
|
-
curl -s "https://www.
|
|
200
|
+
curl -s "https://www.cipherwake.io/api/scan?domain=stripe.com" | jq '.grade, .score'
|
|
194
201
|
```
|
|
195
202
|
|
|
196
|
-
Full API reference at [
|
|
203
|
+
Full API reference at [cipherwake.io/api](https://cipherwake.io/api).
|
|
197
204
|
|
|
198
|
-
**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).
|
|
199
206
|
|
|
200
207
|
## Methodology
|
|
201
208
|
|
|
202
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:
|
|
203
210
|
|
|
204
|
-
- [Decryption Blast Radius](https://
|
|
205
|
-
- [Score components](https://
|
|
206
|
-
- [QXM lockfile schema](https://
|
|
207
|
-
- [Browser extension methodology](https://
|
|
208
|
-
- [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
|
|
209
216
|
|
|
210
217
|
## Versioning + stability
|
|
211
218
|
|
|
@@ -215,20 +222,20 @@ The CLI follows the same policy — output formats are stable across minor versi
|
|
|
215
222
|
|
|
216
223
|
## Privacy
|
|
217
224
|
|
|
218
|
-
`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).
|
|
219
226
|
|
|
220
227
|
## CI integration
|
|
221
228
|
|
|
222
229
|
```yaml
|
|
223
230
|
# .github/workflows/quantum-risk-gate.yml
|
|
224
|
-
- name:
|
|
231
|
+
- name: Cipherwake public-surface gate
|
|
225
232
|
run: npx pqcheck@latest mycompany.com --threshold 7
|
|
226
233
|
```
|
|
227
234
|
|
|
228
|
-
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):
|
|
229
236
|
|
|
230
237
|
```yaml
|
|
231
|
-
- uses:
|
|
238
|
+
- uses: cipherwake-io/pqcheck/action@main
|
|
232
239
|
with:
|
|
233
240
|
domain: mycompany.com
|
|
234
241
|
threshold: '7'
|
|
@@ -245,12 +252,12 @@ For richer integration (sticky PR comments, SARIF upload to Code Scanning, lockf
|
|
|
245
252
|
|
|
246
253
|
## License
|
|
247
254
|
|
|
248
|
-
MIT. © 2026
|
|
255
|
+
MIT. © 2026 Cipherwake.
|
|
249
256
|
|
|
250
257
|
---
|
|
251
258
|
|
|
252
|
-
**Source:** [github.com/
|
|
259
|
+
**Source:** [github.com/cipherwake-io/pqcheck](https://github.com/cipherwake-io/pqcheck)
|
|
253
260
|
|
|
254
261
|
**Changelog:** [CHANGELOG.md](./CHANGELOG.md) for version-by-version release notes.
|
|
255
262
|
|
|
256
|
-
**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();
|
|
69
|
+
printUsage();
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
29
73
|
printUsage();
|
|
30
|
-
process.exit(
|
|
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
|
// =============================================================================
|
|
@@ -932,12 +1085,15 @@ async function runDepsCommand(args) {
|
|
|
932
1085
|
}
|
|
933
1086
|
|
|
934
1087
|
if (!json) process.stderr.write(color("dim", `Fetching ${domain} HTML...`));
|
|
935
|
-
const
|
|
1088
|
+
const fetched = await fetchPageHTML(domain);
|
|
936
1089
|
if (!json) process.stderr.write("\r\x1b[K");
|
|
937
|
-
if (!
|
|
1090
|
+
if (!fetched) {
|
|
938
1091
|
console.error(color("red", `error: could not fetch https://${domain}/`));
|
|
939
1092
|
process.exit(1);
|
|
940
1093
|
}
|
|
1094
|
+
const { html, headerCsp } = fetched;
|
|
1095
|
+
const metaCsp = extractMetaCsp(html);
|
|
1096
|
+
const cspVerdict = classifyCsp(headerCsp, metaCsp);
|
|
941
1097
|
|
|
942
1098
|
const refs = extractThirdPartyRefs(html, domain);
|
|
943
1099
|
if (refs.length === 0) {
|
|
@@ -977,7 +1133,7 @@ async function runDepsCommand(args) {
|
|
|
977
1133
|
batch.map(async (h) => {
|
|
978
1134
|
try {
|
|
979
1135
|
const r = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(h.host)}&source=cli-deps`, {
|
|
980
|
-
headers: {
|
|
1136
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (deps)` }),
|
|
981
1137
|
});
|
|
982
1138
|
if (!r.ok) return { ...h, types: Array.from(h.types), scan: null, error: `${r.status}` };
|
|
983
1139
|
const body = await r.json();
|
|
@@ -1031,13 +1187,17 @@ async function runDepsCommand(args) {
|
|
|
1031
1187
|
|
|
1032
1188
|
// Build manifest
|
|
1033
1189
|
const manifest = {
|
|
1034
|
-
$schema: "https://
|
|
1035
|
-
schemaVersion: "1.
|
|
1190
|
+
$schema: "https://cipherwake.io/schemas/deps/v1",
|
|
1191
|
+
schemaVersion: "1.2", // bumped for CSP + vendor classification fields
|
|
1036
1192
|
domain,
|
|
1037
1193
|
scannedAt: new Date().toISOString(),
|
|
1038
1194
|
tool: "pqcheck-cli",
|
|
1039
1195
|
toolVersion: VERSION,
|
|
1040
1196
|
summary,
|
|
1197
|
+
csp: {
|
|
1198
|
+
quality: cspVerdict.quality, // "absent" | "weak" | "strict"
|
|
1199
|
+
source: cspVerdict.source, // "header" | "meta" | null
|
|
1200
|
+
},
|
|
1041
1201
|
baseline: baselineHosts ? {
|
|
1042
1202
|
file: baselinePath,
|
|
1043
1203
|
newHosts,
|
|
@@ -1049,6 +1209,7 @@ async function runDepsCommand(args) {
|
|
|
1049
1209
|
types: r.types,
|
|
1050
1210
|
occurrences: r.occurrences,
|
|
1051
1211
|
sri: { allScriptsHaveSri: !r.anyMissingSri, allHttps: r.allHttps },
|
|
1212
|
+
vendor: classifyVendor(r.host), // { name, category } or null
|
|
1052
1213
|
isNew: r.isNew || false,
|
|
1053
1214
|
scan: r.scan,
|
|
1054
1215
|
error: r.error,
|
|
@@ -1066,7 +1227,7 @@ async function runDepsCommand(args) {
|
|
|
1066
1227
|
try {
|
|
1067
1228
|
const fs2 = await import("node:fs/promises");
|
|
1068
1229
|
const baselinePayload = {
|
|
1069
|
-
$schema: "https://
|
|
1230
|
+
$schema: "https://cipherwake.io/schemas/deps-baseline/v1",
|
|
1070
1231
|
domain,
|
|
1071
1232
|
capturedAt: new Date().toISOString(),
|
|
1072
1233
|
toolVersion: VERSION,
|
|
@@ -1101,6 +1262,15 @@ async function runDepsCommand(args) {
|
|
|
1101
1262
|
console.log("");
|
|
1102
1263
|
console.log(` ${color("bold", "Supply-chain HNDL exposure")} for ${color("violet", domain)}`);
|
|
1103
1264
|
console.log(` ${color("dim", `${summary.uniqueOrigins} unique third-party origins · ${summary.totalReferences} references · weakest: ${summary.weakestLink?.host ?? "—"} (${summary.weakestLink?.grade ?? "—"})`)}`);
|
|
1265
|
+
// CSP-quality summary line — single line, site-wide. Mirrors the banner the
|
|
1266
|
+
// extension shows on the Supply Chain tab.
|
|
1267
|
+
if (cspVerdict.quality === "absent") {
|
|
1268
|
+
console.log(` ${color("red", "✗ No CSP enforcement")} ${color("dim", "— vendor swaps go undetected by the browser")}`);
|
|
1269
|
+
} else if (cspVerdict.quality === "weak") {
|
|
1270
|
+
console.log(` ${color("yellow", "⚠ CSP is permissive")} ${color("dim", `— uses unsafe-inline / wildcards / data: (${cspVerdict.source})`)}`);
|
|
1271
|
+
} else if (cspVerdict.quality === "strict") {
|
|
1272
|
+
console.log(` ${color("green", "✓ Strict CSP enforced")} ${color("dim", `(${cspVerdict.source})`)}`);
|
|
1273
|
+
}
|
|
1104
1274
|
if (baselineHosts) {
|
|
1105
1275
|
const baselineSize = baselineHosts.size;
|
|
1106
1276
|
if (baselineSize === 0) {
|
|
@@ -1113,19 +1283,33 @@ async function runDepsCommand(args) {
|
|
|
1113
1283
|
}
|
|
1114
1284
|
}
|
|
1115
1285
|
console.log("");
|
|
1116
|
-
console.log(` ${color("dim", "GRADE HOST PQC SRI TYPES")}`);
|
|
1117
|
-
console.log(` ${color("dim", "───── ───────────────────────────────────────── ─── ─── ─────")}`);
|
|
1286
|
+
console.log(` ${color("dim", "GRADE HOST VENDOR (CATEGORY) PQC SRI TYPES")}`);
|
|
1287
|
+
console.log(` ${color("dim", "───── ───────────────────────────────────────── ────────────────────── ─── ─── ─────")}`);
|
|
1118
1288
|
for (const r of results) {
|
|
1119
1289
|
const gradeStr = r.scan?.grade ?? "?";
|
|
1120
1290
|
const gradeColored = gradeStr === "A" ? color("green", gradeStr) : gradeStr === "F" || gradeStr === "D" ? color("red", gradeStr) : color("yellow", gradeStr);
|
|
1121
|
-
const isNewMark = r.isNew ? color("yellow", " *NEW*") : "";
|
|
1122
1291
|
const hostRaw = r.host + (r.isNew ? " *NEW*" : "");
|
|
1123
1292
|
const host = hostRaw.length > 41 ? hostRaw.slice(0, 40) + "…" : (r.host.padEnd(r.isNew ? 35 : 41, " ") + (r.isNew ? color("yellow", " *NEW*") : ""));
|
|
1124
1293
|
const pqc = r.scan?.hybridPQC ? color("green", "yes") : color("dim", "no ");
|
|
1125
1294
|
const hasScript = (r.types || []).includes("script");
|
|
1126
1295
|
const sriCell = !hasScript ? color("dim", "n/a") : r.anyMissingSri ? color("yellow", "off") : color("green", "on ");
|
|
1127
1296
|
const types = r.types.join(",");
|
|
1128
|
-
|
|
1297
|
+
// Vendor classification — "(New Relic · errors)" beats "bam.nr-data.net"
|
|
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.
|
|
1301
|
+
const vendor = classifyVendor(r.host);
|
|
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
|
+
}
|
|
1310
|
+
const vendorTruncated = vendorStrRaw.length > 22 ? vendorStrRaw.slice(0, 21) + "…" : vendorStrRaw.padEnd(22, " ");
|
|
1311
|
+
const vendorColored = color("dim", vendorTruncated);
|
|
1312
|
+
console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${vendorColored} ${pqc} ${sriCell} ${color("dim", types)}`);
|
|
1129
1313
|
}
|
|
1130
1314
|
console.log("");
|
|
1131
1315
|
if (baselineHosts && baselineHosts.size > 0 && newHosts.length > 0) {
|
|
@@ -1136,8 +1320,19 @@ async function runDepsCommand(args) {
|
|
|
1136
1320
|
console.log("");
|
|
1137
1321
|
|
|
1138
1322
|
if (lock) {
|
|
1139
|
-
|
|
1140
|
-
|
|
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");
|
|
1141
1336
|
try {
|
|
1142
1337
|
await fs.mkdir(outDir, { recursive: true });
|
|
1143
1338
|
await fs.writeFile(lockPath, JSON.stringify(manifest, null, 2));
|
|
@@ -1177,16 +1372,163 @@ async function fetchPageHTML(domain) {
|
|
|
1177
1372
|
method: "GET",
|
|
1178
1373
|
redirect: "follow",
|
|
1179
1374
|
signal: ctrl.signal,
|
|
1180
|
-
headers: { "User-Agent": `pqcheck-cli/${VERSION} (deps; +https://
|
|
1375
|
+
headers: { "User-Agent": `pqcheck-cli/${VERSION} (deps; +https://cipherwake.io)` },
|
|
1181
1376
|
});
|
|
1182
1377
|
clearTimeout(t);
|
|
1183
1378
|
if (!resp.ok) return null;
|
|
1184
|
-
|
|
1379
|
+
const html = await resp.text();
|
|
1380
|
+
// Also capture the CSP header so the deps view can show the site-wide
|
|
1381
|
+
// enforcement level. Report-only header is ignored (not enforced).
|
|
1382
|
+
const headerCsp = resp.headers.get("content-security-policy") || "";
|
|
1383
|
+
return { html, headerCsp };
|
|
1185
1384
|
} catch {
|
|
1186
1385
|
return null;
|
|
1187
1386
|
}
|
|
1188
1387
|
}
|
|
1189
1388
|
|
|
1389
|
+
// Scrape <meta http-equiv="Content-Security-Policy" content="..."> from HTML
|
|
1390
|
+
// as a fallback when the response header is absent.
|
|
1391
|
+
function extractMetaCsp(html) {
|
|
1392
|
+
const re = /<meta[^>]*\bhttp-equiv\s*=\s*["']?content-security-policy["']?[^>]*\bcontent\s*=\s*["']([^"']+)["']/i;
|
|
1393
|
+
const m = html.match(re);
|
|
1394
|
+
return m ? m[1] : "";
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// Site-level CSP-quality classifier. Mirrors lib/serviceCatalog.ts and the
|
|
1398
|
+
// extension's classifyCsp(). Three buckets: absent / weak / strict.
|
|
1399
|
+
function classifyCsp(headerCsp, metaCsp) {
|
|
1400
|
+
const policy = ((headerCsp || "").trim() || (metaCsp || "").trim()).toLowerCase();
|
|
1401
|
+
if (!policy) return { quality: "absent", source: null, raw: "" };
|
|
1402
|
+
const weakSignals = ["'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", " * ", " *;", "data:", "blob:"];
|
|
1403
|
+
const weak = weakSignals.some((sig) => policy.includes(sig));
|
|
1404
|
+
return {
|
|
1405
|
+
quality: weak ? "weak" : "strict",
|
|
1406
|
+
source: (headerCsp || "").trim() ? "header" : "meta",
|
|
1407
|
+
raw: policy,
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Compact vendor catalog — covers ~50 most common third-party origins so the
|
|
1412
|
+
// pretty table can show "(New Relic · errors)" instead of just "bam.nr-data.net".
|
|
1413
|
+
// Mirror of lib/serviceCatalog.ts + extension/popup.js SERVICE_CATALOG; keep
|
|
1414
|
+
// the three in sync when adding vendors.
|
|
1415
|
+
const SERVICE_CATALOG = {
|
|
1416
|
+
"googletagmanager.com": { name: "Google Tag Manager", category: "analytics" },
|
|
1417
|
+
"google-analytics.com": { name: "Google Analytics", category: "analytics" },
|
|
1418
|
+
"googleadservices.com": { name: "Google Ads", category: "ads" },
|
|
1419
|
+
"doubleclick.net": { name: "Google DoubleClick", category: "ads" },
|
|
1420
|
+
"googleapis.com": { name: "Google APIs", category: "cdn" },
|
|
1421
|
+
"gstatic.com": { name: "Google Static", category: "cdn" },
|
|
1422
|
+
"recaptcha.net": { name: "Google reCAPTCHA", category: "captcha" },
|
|
1423
|
+
"youtube.com": { name: "YouTube", category: "social" },
|
|
1424
|
+
"cloudflare.com": { name: "Cloudflare", category: "cdn" },
|
|
1425
|
+
"cdnjs.cloudflare.com": { name: "Cloudflare cdnjs", category: "cdn" },
|
|
1426
|
+
"cloudflareinsights.com": { name: "Cloudflare Web Analytics", category: "analytics" },
|
|
1427
|
+
"challenges.cloudflare.com": { name: "Cloudflare Turnstile", category: "captcha" },
|
|
1428
|
+
"stripe.com": { name: "Stripe", category: "payments" },
|
|
1429
|
+
"js.stripe.com": { name: "Stripe Checkout", category: "payments" },
|
|
1430
|
+
"amazonaws.com": { name: "AWS S3", category: "cdn" },
|
|
1431
|
+
"cloudfront.net": { name: "AWS CloudFront", category: "cdn" },
|
|
1432
|
+
"clarity.ms": { name: "Microsoft Clarity", category: "analytics" },
|
|
1433
|
+
"azureedge.net": { name: "Azure CDN", category: "cdn" },
|
|
1434
|
+
"facebook.com": { name: "Facebook", category: "social" },
|
|
1435
|
+
"facebook.net": { name: "Facebook Pixel", category: "ads" },
|
|
1436
|
+
"fbcdn.net": { name: "Facebook CDN", category: "cdn" },
|
|
1437
|
+
"connect.facebook.net": { name: "Facebook Pixel", category: "ads" },
|
|
1438
|
+
"twitter.com": { name: "Twitter (X)", category: "social" },
|
|
1439
|
+
"linkedin.com": { name: "LinkedIn", category: "social" },
|
|
1440
|
+
"snap.licdn.com": { name: "LinkedIn Tracking", category: "ads" },
|
|
1441
|
+
"use.typekit.net": { name: "Adobe Fonts", category: "fonts" },
|
|
1442
|
+
"typekit.net": { name: "Adobe Fonts", category: "fonts" },
|
|
1443
|
+
"paypal.com": { name: "PayPal", category: "payments" },
|
|
1444
|
+
"auth0.com": { name: "Auth0", category: "auth" },
|
|
1445
|
+
"okta.com": { name: "Okta", category: "auth" },
|
|
1446
|
+
"intercomcdn.com": { name: "Intercom", category: "support" },
|
|
1447
|
+
"zendesk.com": { name: "Zendesk", category: "support" },
|
|
1448
|
+
"sentry.io": { name: "Sentry", category: "errors" },
|
|
1449
|
+
"browser.sentry-cdn.com": { name: "Sentry", category: "errors" },
|
|
1450
|
+
"js-agent.newrelic.com": { name: "New Relic", category: "errors" },
|
|
1451
|
+
"bam.nr-data.net": { name: "New Relic", category: "errors" },
|
|
1452
|
+
"datadoghq.com": { name: "Datadog", category: "errors" },
|
|
1453
|
+
"browser-intake-datadoghq.com": { name: "Datadog RUM", category: "errors" },
|
|
1454
|
+
"mailchimp.com": { name: "Mailchimp", category: "analytics" },
|
|
1455
|
+
"hubspot.com": { name: "HubSpot", category: "analytics" },
|
|
1456
|
+
"js.hubspot.com": { name: "HubSpot Tracking", category: "analytics" },
|
|
1457
|
+
"segment.com": { name: "Segment", category: "analytics" },
|
|
1458
|
+
"amplitude.com": { name: "Amplitude", category: "analytics" },
|
|
1459
|
+
"mxpnl.com": { name: "Mixpanel", category: "analytics" },
|
|
1460
|
+
"hotjar.com": { name: "Hotjar", category: "analytics" },
|
|
1461
|
+
"plausible.io": { name: "Plausible Analytics", category: "analytics" },
|
|
1462
|
+
"js.hcaptcha.com": { name: "hCaptcha", category: "captcha" },
|
|
1463
|
+
"cdn.jsdelivr.net": { name: "jsDelivr CDN", category: "cdn" },
|
|
1464
|
+
"unpkg.com": { name: "unpkg CDN", category: "cdn" },
|
|
1465
|
+
"code.jquery.com": { name: "jQuery CDN", category: "cdn" },
|
|
1466
|
+
"akamaihd.net": { name: "Akamai CDN", category: "cdn" },
|
|
1467
|
+
"akamaized.net": { name: "Akamai CDN", category: "cdn" },
|
|
1468
|
+
"fastly.net": { name: "Fastly CDN", category: "cdn" },
|
|
1469
|
+
"bootstrapcdn.com": { name: "Bootstrap CDN", category: "cdn" },
|
|
1470
|
+
"maxcdn.bootstrapcdn.com": { name: "Bootstrap CDN", category: "cdn" },
|
|
1471
|
+
"cookielaw.org": { name: "OneTrust Cookie Consent", category: "consent" },
|
|
1472
|
+
"cookiebot.com": { name: "Cookiebot", category: "consent" },
|
|
1473
|
+
"vimeo.com": { name: "Vimeo", category: "social" },
|
|
1474
|
+
"shopify.com": { name: "Shopify", category: "ecommerce" },
|
|
1475
|
+
"cdn.shopify.com": { name: "Shopify CDN", category: "ecommerce" },
|
|
1476
|
+
"tiktok.com": { name: "TikTok", category: "social" },
|
|
1477
|
+
};
|
|
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
|
+
|
|
1512
|
+
function classifyVendor(host) {
|
|
1513
|
+
if (!host) return null;
|
|
1514
|
+
const lower = host.toLowerCase();
|
|
1515
|
+
if (SERVICE_CATALOG[lower]) return SERVICE_CATALOG[lower];
|
|
1516
|
+
for (const pattern of Object.keys(SERVICE_CATALOG)) {
|
|
1517
|
+
if (lower === pattern || lower.endsWith("." + pattern)) {
|
|
1518
|
+
return SERVICE_CATALOG[pattern];
|
|
1519
|
+
}
|
|
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
|
+
}
|
|
1529
|
+
return null;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1190
1532
|
function extractThirdPartyRefs(html, targetDomain) {
|
|
1191
1533
|
const out = [];
|
|
1192
1534
|
const targetRoot = registeredDomain(targetDomain);
|
|
@@ -1323,6 +1665,67 @@ function depsManifestToMarkdown(m) {
|
|
|
1323
1665
|
// SARIF + GitHub Action output formats
|
|
1324
1666
|
// =============================================================================
|
|
1325
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
|
+
|
|
1326
1729
|
function reportToSarif(report) {
|
|
1327
1730
|
// SARIF 2.1.0 minimal schema for security findings.
|
|
1328
1731
|
// Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
|
@@ -1336,9 +1739,15 @@ function reportToSarif(report) {
|
|
|
1336
1739
|
driver: {
|
|
1337
1740
|
name: "pqcheck",
|
|
1338
1741
|
version: VERSION,
|
|
1339
|
-
informationUri: "https://
|
|
1340
|
-
|
|
1341
|
-
|
|
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),
|
|
1342
1751
|
name: (f.title || "finding").replace(/[^A-Za-z0-9]/g, "_"),
|
|
1343
1752
|
shortDescription: { text: f.title || "finding" },
|
|
1344
1753
|
fullDescription: { text: f.detail || f.title || "finding" },
|
|
@@ -1346,8 +1755,8 @@ function reportToSarif(report) {
|
|
|
1346
1755
|
})),
|
|
1347
1756
|
},
|
|
1348
1757
|
},
|
|
1349
|
-
results: findings.map((f
|
|
1350
|
-
ruleId:
|
|
1758
|
+
results: findings.map((f) => ({
|
|
1759
|
+
ruleId: stableRuleId(f),
|
|
1351
1760
|
level: sevMap[f.severity] || "note",
|
|
1352
1761
|
message: { text: `${f.title || "finding"}${f.detail ? ` — ${f.detail}` : ""}` },
|
|
1353
1762
|
// GitHub Code Scanning requires file: scheme (or relative path) for
|
|
@@ -1355,7 +1764,7 @@ function reportToSarif(report) {
|
|
|
1355
1764
|
// relative path so findings show up cleanly in the Security tab.
|
|
1356
1765
|
locations: [{
|
|
1357
1766
|
physicalLocation: {
|
|
1358
|
-
artifactLocation: { uri: `
|
|
1767
|
+
artifactLocation: { uri: `cipherwake-scan/${report.domain || "unknown"}.txt` },
|
|
1359
1768
|
region: { startLine: 1, startColumn: 1 },
|
|
1360
1769
|
},
|
|
1361
1770
|
}],
|
|
@@ -1364,7 +1773,7 @@ function reportToSarif(report) {
|
|
|
1364
1773
|
score: report.score,
|
|
1365
1774
|
grade: report.grade,
|
|
1366
1775
|
severity: f.severity,
|
|
1367
|
-
reportUrl: `https://www.
|
|
1776
|
+
reportUrl: `https://www.cipherwake.io/r/${report.domain || ""}`,
|
|
1368
1777
|
},
|
|
1369
1778
|
})),
|
|
1370
1779
|
properties: {
|
|
@@ -1385,10 +1794,10 @@ function printGitHubActionAnnotations(report) {
|
|
|
1385
1794
|
if (report._meta?.degraded) {
|
|
1386
1795
|
const reason = String(report._meta.degradedReason || "live probe failed").replace(/[\r\n]/g, " ").replace(/::/g, ":");
|
|
1387
1796
|
const since = String(report._meta.lastUpdated || "unknown");
|
|
1388
|
-
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.`);
|
|
1389
1798
|
}
|
|
1390
1799
|
// Top-line score/grade as a notice
|
|
1391
|
-
console.log(`::notice title=
|
|
1800
|
+
console.log(`::notice title=Cipherwake: ${report.domain}::Grade ${report.grade || "?"} · score ${report.score ?? "?"} / 10`);
|
|
1392
1801
|
for (const f of findings) {
|
|
1393
1802
|
const cmd = sevMap[f.severity] || "notice";
|
|
1394
1803
|
const title = (f.title || "finding").replace(/[\r\n]/g, " ");
|
|
@@ -1420,7 +1829,7 @@ async function runHistoryCommand(args) {
|
|
|
1420
1829
|
let h;
|
|
1421
1830
|
try {
|
|
1422
1831
|
const r = await fetch(`${API_BASE}/api/history?domain=${encodeURIComponent(domain)}&days=${days}`, {
|
|
1423
|
-
headers: {
|
|
1832
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (history)` }),
|
|
1424
1833
|
});
|
|
1425
1834
|
if (!r.ok) {
|
|
1426
1835
|
console.error(color("red", `error: ${r.status} ${r.statusText}`));
|
|
@@ -1480,6 +1889,84 @@ async function runHistoryCommand(args) {
|
|
|
1480
1889
|
console.log("");
|
|
1481
1890
|
}
|
|
1482
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
|
+
|
|
1483
1970
|
// =============================================================================
|
|
1484
1971
|
// `pqcheck diff` — diff two QXM lockfiles
|
|
1485
1972
|
// =============================================================================
|
|
@@ -1509,7 +1996,7 @@ async function runDiffCommand(args) {
|
|
|
1509
1996
|
}
|
|
1510
1997
|
|
|
1511
1998
|
console.log("");
|
|
1512
|
-
console.log(` ${color("bold", "
|
|
1999
|
+
console.log(` ${color("bold", "Cipherwake lockfile diff")}`);
|
|
1513
2000
|
console.log(` ${color("dim", `${positional[0]} → ${positional[1]}`)}`);
|
|
1514
2001
|
console.log("");
|
|
1515
2002
|
if (diff.scoreChange !== null) {
|
|
@@ -1580,6 +2067,114 @@ function computeLockDiff(oldLock, newLock) {
|
|
|
1580
2067
|
// `pqcheck cert <pem-file>` — analyze a local cert file (offline)
|
|
1581
2068
|
// =============================================================================
|
|
1582
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
|
+
|
|
1583
2178
|
async function runCertCommand(args) {
|
|
1584
2179
|
const fs = await import("node:fs/promises");
|
|
1585
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"
|