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.
Files changed (3) hide show
  1. package/README.md +30 -27
  2. package/bin/pqcheck.js +523 -56
  3. 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 [quantapact.com](https://quantapact.com), the browser extension, and the GitHub Action.
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 quantapact.lock (QXM) committable manifest
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 `quantapact-deps.lock` + `.md`
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
- # quantapact.lock — stable JSON manifest
164
- # quantapact-report.md — human-readable summary (renders on GitHub)
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
- Schema documented at [quantapact.com/schemas/qxm/v1](https://quantapact.com/methodology/qxm).
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 `quantapact-deps.lock` + `.md` for committing or PR comparison. Add `--allowlist file.txt` to gate CI on vendor approval.
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://quantapact.com/api):
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** | [`quantapact/pqcheck/action@main`](https://github.com/quantapact/pqcheck/tree/main/action) — PR comments, SARIF upload, lockfile generation |
189
- | **Slack `/pqcheck`** | [Install on workspace](https://quantapact.com/install-slack) |
190
- | **Web** | [quantapact.com](https://quantapact.com) — share-friendly URLs at `/r/<domain>` |
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 Quantapact API. You can also call the API directly:
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.quantapact.com/api/scan?domain=stripe.com" | jq '.grade, .score'
200
+ curl -s "https://www.cipherwake.io/api/scan?domain=stripe.com" | jq '.grade, .score'
198
201
  ```
199
202
 
200
- Full API reference at [quantapact.com/api](https://quantapact.com/api).
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://quantapact.com/feedback) if you need higher limits (we're prioritizing the API tier based on real demand).
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://quantapact.com/methodology/decryption-blast-radius) — core methodology
209
- - [Score components](https://quantapact.com/methodology/score-components) — the 4-bar weighted breakdown + PQC discount
210
- - [QXM lockfile schema](https://quantapact.com/methodology/qxm) — committable manifest format
211
- - [Browser extension methodology](https://quantapact.com/methodology/browser-extension) — supply-chain HNDL detection logic
212
- - [Methodology library](https://quantapact.com/methodology) — full index
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 `quantapact.com/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 [quantapact.com/privacy](https://quantapact.com/privacy).
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: Quantapact public-surface gate
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/quantapact/pqcheck/tree/main/action):
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: quantapact/pqcheck/action@main
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 Quantapact.
255
+ MIT. © 2026 Cipherwake.
253
256
 
254
257
  ---
255
258
 
256
- **Source:** [github.com/quantapact/pqcheck](https://github.com/quantapact/pqcheck)
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:** [quantapact.com/feedback](https://quantapact.com/feedback) or open an issue on the repo.
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 quantapact.com.
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://quantapact.com";
10
- const VERSION = "0.7.9";
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
- if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
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(args.length === 0 ? 1 : 0);
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: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (scan)` },
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: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (watch)` },
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
- const v = String(s ?? "");
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
- console.log(color("violet", ` → Full report: ${API_BASE}/?check=${encodeURIComponent(r.domain)}`));
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 quantapact.lock (QXM) committable manifest
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 quantapact-deps.lock + .md
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 quantapact.com
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://quantapact.com")}
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
- // quantapact.lock — stable JSON manifest (machine-readable)
616
- // quantapact-report.md — human-readable summary (renders on GitHub)
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 ./quantapact.lock + .md
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
- // quantapact.lock if present, else error
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
- try {
644
- const existing = await fs.readFile(path.join(outDir, "quantapact.lock"), "utf8");
645
- const parsed = JSON.parse(existing);
646
- domain = parsed.domain;
647
- if (!stdout) {
648
- console.error(color("dim", `Re-locking from existing quantapact.lock (domain: ${domain})`));
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
- } catch {
651
- console.error(color("red", "error: no domain provided and no existing quantapact.lock found"));
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: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (lock)` },
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
- const lockPath = path.join(outDir, "quantapact.lock");
693
- const mdPath = path.join(outDir, "quantapact-report.md");
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://quantapact.com/schemas/qxm/v1",
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://quantapact.com/methodology",
760
- shareableReport: `https://quantapact.com/r/${encodeURIComponent(report.domain)}`,
761
- badge: `https://quantapact.com/badge/${encodeURIComponent(report.domain)}.svg`,
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://quantapact.com/feedback?source=qxm-tessera-interest",
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://quantapact.com) at ${m.generatedAt}`);
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 quantapact-deps.lock + .md
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: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (deps)` },
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://quantapact.com/schemas/deps/v1",
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://quantapact.com/schemas/deps-baseline/v1",
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
- const vendorStrRaw = vendor ? `${vendor.name} (${vendor.category})` : "—";
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 = vendor ? color("dim", vendorTruncated) : color("dim", vendorTruncated);
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
- const lockPath = path.join(outDir, "quantapact-deps.lock");
1162
- const mdPath = path.join(outDir, "quantapact-deps-report.md");
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://quantapact.com)` },
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://quantapact.com",
1468
- rules: findings.map((f, i) => ({
1469
- id: `pqcheck-${i + 1}`,
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, i) => ({
1478
- ruleId: `pqcheck-${i + 1}`,
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: `quantapact-scan/${report.domain || "unknown"}.txt` },
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.quantapact.com/r/${report.domain || ""}`,
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=Quantapact: cached score (live probe failed)::Showing last known-good score from ${since}. Reason: ${reason}. Re-run shortly for a fresh probe.`);
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=Quantapact: ${report.domain}::Grade ${report.grade || "?"} · score ${report.score ?? "?"} / 10`);
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: { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION} (history)` },
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", "Quantapact lockfile diff")}`);
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.7.9",
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://quantapact.com",
21
- "bugs": "https://quantapact.com",
20
+ "homepage": "https://cipherwake.io",
21
+ "bugs": "https://cipherwake.io",
22
22
  "repository": {
23
23
  "type": "git",
24
- "url": "https://github.com/quantapact/pqcheck.git",
24
+ "url": "https://github.com/cipherwake-io/pqcheck.git",
25
25
  "directory": "cli"
26
26
  },
27
27
  "license": "MIT",
28
- "author": "Quantapact",
28
+ "author": "Cipherwake",
29
29
  "type": "module",
30
30
  "engines": {
31
31
  "node": ">=18"