pqcheck 0.10.0 → 0.13.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 +70 -4
  2. package/bin/pqcheck.js +1140 -1
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -12,6 +12,63 @@ The same scanner that powers [cipherwake.io](https://cipherwake.io), the browser
12
12
 
13
13
  ---
14
14
 
15
+ ## Get started in 60 seconds
16
+
17
+ Wire Cipherwake into your CI so every PR gets a Trust Diff comment when your domain's public trust posture changes.
18
+
19
+ **One command does almost everything:**
20
+
21
+ ```bash
22
+ npx pqcheck onboard cipherwake.io
23
+ ```
24
+
25
+ That runs in sequence: scan your domain → write the GitHub Action workflow → capture a vendor lockfile → generate a release checklist → open your browser to the API-key page. You finish by adding the API key as a repo secret + committing.
26
+
27
+ **Or step-by-step if you prefer:**
28
+
29
+ ```bash
30
+ # 1. Scaffold a GitHub Actions workflow (interactive prompts)
31
+ npx pqcheck init
32
+
33
+ # 2. Generate a free API key at https://cipherwake.io/account#api-keys
34
+ # (Free tier: 30 Trust Diff calls/month)
35
+
36
+ # 3. Add the key as a repo secret:
37
+ # GitHub → Settings → Secrets → Actions → New secret
38
+ # Name: CIPHERWAKE_API_KEY Value: qpk_...
39
+
40
+ # 4. Commit + push
41
+ git add .github/workflows/cipherwake.yml
42
+ git commit -m "ci: add Cipherwake Trust Diff gate"
43
+ git push
44
+ ```
45
+
46
+ That's it. Open a PR and Cipherwake comments inline when cert / SPKI / HSTS / CSP / DMARC / vendor scripts drift since your baseline.
47
+
48
+ **Want more?**
49
+ - Pre-commit hook: `npx pqcheck deploy-check <domain>` before every deploy
50
+ - Release ritual: `npx pqcheck release-checklist <domain>` for your release notes
51
+ - Vendor lockfile: `npx pqcheck vendors export <domain>` to commit `cipherwake.vendors.json` and fail PRs introducing new third-party scripts
52
+
53
+ ---
54
+
55
+ ## What's new in 0.12.0
56
+
57
+ **Developer habit-loop bundle (locked 2026-05-16).** Five new subcommands that put Cipherwake where developers already work: PRs, CI, release notes, vendor allowlists. Free tier covers all of them within the 30 Trust Diff calls/month quota.
58
+
59
+ - `pqcheck init` — interactive scaffold for `.github/workflows/cipherwake.yml`. Prompts for domain, fail-on severity, baseline. No copy-paste from docs required.
60
+ - `pqcheck deploy-check <domain>` — pre-deploy Trust Diff gate with deploy-friendly framing. Uses last-scan as default baseline. Same exit semantics as `trust-diff`.
61
+ - `pqcheck release-checklist [domain]` — markdown checklist for release notes. Offline, no API call.
62
+ - `pqcheck vendors export <domain>` — write `cipherwake.vendors.json` from currently observed third-party origins. Like `package-lock.json` for vendor scripts.
63
+ - `pqcheck vendors check <domain>` — CI gate; exits **4** when new origins appear that aren't in the lockfile.
64
+ - `pqcheck vendors sync <domain>` — Starter+ only; pulls your dashboard-managed approved-vendor allowlist into the lockfile.
65
+
66
+ Plus: the GitHub Action v3.1 now posts a **sticky PR comment** with Trust Diff results when `comment-on-pr: true` is set, and `/r/<domain>` has a "Copy as GitHub issue" button on every finding.
67
+
68
+ ## What's new in 0.11.0
69
+
70
+ **Trust Diff subcommand** — `npx pqcheck trust-diff <domain>` calls `/api/trust-diff` and gates CI on regression severity vs a configured baseline. SARIF output uploads to GitHub's Code Scanning. Pair with `cipherwakelabs/pqcheck@v3` action `mode: trust-diff` for one-line CI integration.
71
+
15
72
  ## What's new in 0.7.9
16
73
 
17
74
  **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).
@@ -48,6 +105,15 @@ npx pqcheck diff <old.lock> <new.lock> Compare two QXM lockfiles; exit 2
48
105
  npx pqcheck history <domain> Show 90-day score history (sparkline + samples)
49
106
  npx pqcheck changes <domain> Summarize public attack-surface changes in last 14 days
50
107
  npx pqcheck cert <file.pem> Analyze a local PEM/CRT cert file (offline, no network)
108
+ npx pqcheck trust-diff <domain> Trust Diff vs configured baseline; CI gate (Free: 30/mo)
109
+ npx pqcheck deploy-check <domain> Pre-deploy gate (Trust Diff alias with last-scan baseline)
110
+ npx pqcheck onboard <domain> One-command setup wizard (scan + init + vendors + checklist)
111
+ npx pqcheck init Interactive scaffold for .github/workflows/cipherwake.yml
112
+ npx pqcheck release-checklist [domain] Pre-release trust checklist (markdown, offline)
113
+ npx pqcheck vendors export <domain> Write cipherwake.vendors.json from observed third-party scripts
114
+ npx pqcheck vendors check <domain> CI gate; exit 4 on new origins not in lockfile
115
+ npx pqcheck vendors sync <domain> Pull dashboard allowlist into lockfile (Starter+, needs API key)
116
+ npx pqcheck watch <domain> Add domain to your watched list (needs CIPHERWAKE_API_KEY)
51
117
  ```
52
118
 
53
119
  ### Multi-domain
@@ -188,7 +254,7 @@ This CLI is one of four ways to consume the [Decryption Blast Radius API](https:
188
254
  |---|---|
189
255
  | **CLI** (this package) | `npx pqcheck` |
190
256
  | **Browser extension** | Chrome Web Store / Firefox AMO / Edge — toolbar badge per tab + dependency analysis |
191
- | **GitHub Action** | [`cipherwake-io/pqcheck/action@main`](https://github.com/cipherwake-io/pqcheck/tree/main/action) — PR comments, SARIF upload, lockfile generation |
257
+ | **GitHub Action** | [`cipherwakelabs/pqcheck/action@main`](https://github.com/cipherwakelabs/pqcheck/tree/main/action) — PR comments, SARIF upload, lockfile generation |
192
258
  | **Slack `/pqcheck`** | [Install on workspace](https://cipherwake.io/install-slack) |
193
259
  | **Web** | [cipherwake.io](https://cipherwake.io) — share-friendly URLs at `/r/<domain>` |
194
260
 
@@ -232,10 +298,10 @@ The CLI follows the same policy — output formats are stable across minor versi
232
298
  run: npx pqcheck@latest mycompany.com --threshold 7
233
299
  ```
234
300
 
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):
301
+ For richer integration (sticky PR comments, SARIF upload to Code Scanning, lockfile diff on regression), use the [GitHub Action](https://github.com/cipherwakelabs/pqcheck/tree/main/action):
236
302
 
237
303
  ```yaml
238
- - uses: cipherwake-io/pqcheck/action@main
304
+ - uses: cipherwakelabs/pqcheck/action@main
239
305
  with:
240
306
  domain: mycompany.com
241
307
  threshold: '7'
@@ -256,7 +322,7 @@ MIT. © 2026 Cipherwake.
256
322
 
257
323
  ---
258
324
 
259
- **Source:** [github.com/cipherwake-io/pqcheck](https://github.com/cipherwake-io/pqcheck)
325
+ **Source:** [github.com/cipherwakelabs/pqcheck](https://github.com/cipherwakelabs/pqcheck)
260
326
 
261
327
  **Changelog:** [CHANGELOG.md](./CHANGELOG.md) for version-by-version release notes.
262
328
 
package/bin/pqcheck.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // =============================================================================
8
8
 
9
9
  const API_BASE = process.env.PQCHECK_API_BASE || "https://cipherwake.io";
10
- const VERSION = "0.10.0";
10
+ const VERSION = "0.12.0";
11
11
 
12
12
  // API-key support — paid tiers (Starter $29 / Growth $79 / Scale $199) get
13
13
  // per-account monthly quotas instead of the per-IP rate limit. Set via:
@@ -88,6 +88,12 @@ async function main() {
88
88
  if (args[0] === "diff") {
89
89
  return runDiffCommand(args.slice(1));
90
90
  }
91
+ if (args[0] === "trust-diff") {
92
+ // CLI v0.11.0 (locked 2026-05-16): new subcommand that calls /api/trust-diff
93
+ // and outputs verdict in selected format. Designed for CI use via the
94
+ // cipherwakelabs/pqcheck Action mode: trust-diff.
95
+ return runTrustDiffCommand(args.slice(1));
96
+ }
91
97
  if (args[0] === "history") {
92
98
  return runHistoryCommand(args.slice(1));
93
99
  }
@@ -100,6 +106,21 @@ async function main() {
100
106
  if (args[0] === "watch") {
101
107
  return runWatchCommand(args.slice(1));
102
108
  }
109
+ if (args[0] === "release-checklist") {
110
+ return runReleaseChecklistCommand(args.slice(1));
111
+ }
112
+ if (args[0] === "init") {
113
+ return runInitCommand(args.slice(1));
114
+ }
115
+ if (args[0] === "deploy-check") {
116
+ return runDeployCheckCommand(args.slice(1));
117
+ }
118
+ if (args[0] === "vendors") {
119
+ return runVendorsCommand(args.slice(1));
120
+ }
121
+ if (args[0] === "onboard") {
122
+ return runOnboardCommand(args.slice(1));
123
+ }
103
124
 
104
125
  // Multi-domain support: positional args are domains.
105
126
  // --file reads additional domains from a newline-delimited file.
@@ -653,6 +674,13 @@ ${color("bold", "Commands:")}
653
674
  npx pqcheck changes <domain> Summarize public attack-surface changes in last 14 days
654
675
  npx pqcheck cert <file.pem> Analyze a local PEM/CRT cert file (offline, no network)
655
676
  npx pqcheck watch <domain> Add a domain to your watched-domain list (requires CIPHERWAKE_API_KEY)
677
+ npx pqcheck onboard <domain> One-command setup wizard (scan + init + vendors + checklist + open browser)
678
+ npx pqcheck init Interactive scaffold for .github/workflows/cipherwake.yml
679
+ npx pqcheck deploy-check <domain> Pre-deploy trust gate (Trust Diff vs last scan; deploy-friendly framing)
680
+ npx pqcheck release-checklist [domain] Print a pre-release trust checklist (markdown, offline)
681
+ npx pqcheck vendors export <domain> Write cipherwake.vendors.json from observed third-party scripts
682
+ npx pqcheck vendors check <domain> Compare current scan to lockfile; exit 4 on new origins (Free CI gate)
683
+ npx pqcheck vendors sync <domain> Pull approved-vendor list from your account (Starter+)
656
684
 
657
685
  ${color("bold", "Multi-domain:")}
658
686
  npx pqcheck a.com b.com c.com Multi-domain scan (positional)
@@ -701,6 +729,7 @@ ${color("bold", "Exit codes:")}
701
729
  ${color("bold", "Examples:")}
702
730
  npx pqcheck chase.com
703
731
  npx pqcheck mybank.com --threshold 7 ${color("dim", "# fail CI if score ≥ 7")}
732
+ npx pqcheck mybank.com --watch 600 ${color("dim", "# poll locally every 10 min, log on change (no API key required)")}
704
733
  npx pqcheck deps stripe.com --lock
705
734
  npx pqcheck deps acme.com --allowlist allowed-vendors.txt ${color("dim", "# CI vendor-risk gate")}
706
735
  npx pqcheck deps acme.com --baseline .pqcheck-baseline.json --write-baseline ${color("dim", "# capture initial state")}
@@ -1971,6 +2000,175 @@ async function runChangesCommand(args) {
1971
2000
  // `pqcheck diff` — diff two QXM lockfiles
1972
2001
  // =============================================================================
1973
2002
 
2003
+ /**
2004
+ * `pqcheck trust-diff <domain>` — compare current public trust posture vs a
2005
+ * baseline via /api/trust-diff. Phase 2 launch feature (CLI v0.11.0).
2006
+ *
2007
+ * Inputs:
2008
+ * --baseline last-week | last-month | last-scan | <ISO date> (default: last-week)
2009
+ * --fail-on any | low | medium | high | critical (default: high)
2010
+ * --format pretty | json | sarif | github (default: pretty)
2011
+ *
2012
+ * Exit codes:
2013
+ * 0 = pass — no deltas at or above fail-on severity
2014
+ * 1 = warn — deltas observed but below fail-on threshold
2015
+ * 2 = fail — deltas observed at or above fail-on threshold
2016
+ * 3 = error — auth/quota/network failure
2017
+ *
2018
+ * Requires CIPHERWAKE_API_KEY env var (Free tier: 30 calls/mo at /account#api-keys).
2019
+ */
2020
+ async function runTrustDiffCommand(args) {
2021
+ const positional = args.filter((a) => !a.startsWith("-") && !isFlagValue(args, a));
2022
+ if (positional.length === 0) {
2023
+ console.error(color("red", "error: pqcheck trust-diff requires a domain"));
2024
+ console.error(color("dim", "Usage: npx pqcheck trust-diff <domain> [--baseline last-week] [--fail-on high] [--format pretty|json|sarif|github]"));
2025
+ process.exit(3);
2026
+ }
2027
+ const domain = normalizeDomain(positional[0]);
2028
+ if (!domain) {
2029
+ console.error(color("red", `error: invalid domain "${positional[0]}"`));
2030
+ process.exit(3);
2031
+ }
2032
+ if (!QP_API_KEY) {
2033
+ console.error(color("red", "error: pqcheck trust-diff requires CIPHERWAKE_API_KEY"));
2034
+ console.error(color("dim", "Generate a free key (30 calls/mo) at https://cipherwake.io/account#api-keys"));
2035
+ console.error(color("dim", "Then: export CIPHERWAKE_API_KEY=qpk_<32-hex>"));
2036
+ process.exit(3);
2037
+ }
2038
+
2039
+ const baseline = parseFlag(args, "--baseline") || "last-week";
2040
+ const failOn = parseFlag(args, "--fail-on") || "high";
2041
+ const format = parseFlag(args, "--format") || "pretty";
2042
+
2043
+ let resp;
2044
+ try {
2045
+ resp = await fetch(`${API_BASE}/api/trust-diff`, {
2046
+ method: "POST",
2047
+ headers: {
2048
+ "Content-Type": "application/json",
2049
+ "Authorization": `Bearer ${QP_API_KEY}`,
2050
+ "User-Agent": `pqcheck-cli/${VERSION}`,
2051
+ },
2052
+ body: JSON.stringify({ domain, baseline, fail_on: failOn }),
2053
+ });
2054
+ } catch (err) {
2055
+ console.error(color("red", `error: network failure calling /api/trust-diff: ${err.message}`));
2056
+ process.exit(3);
2057
+ }
2058
+
2059
+ if (resp.status === 401 || resp.status === 403) {
2060
+ await handleAuthError(resp);
2061
+ process.exit(3);
2062
+ }
2063
+ if (resp.status === 429) {
2064
+ const body = await safeJSON(resp);
2065
+ console.error(color("red", "error: Trust Diff API quota exceeded for this month"));
2066
+ if (body?.message) console.error(color("dim", body.message));
2067
+ process.exit(3);
2068
+ }
2069
+ if (!resp.ok) {
2070
+ const body = await safeJSON(resp);
2071
+ console.error(color("red", `error: /api/trust-diff returned ${resp.status}`));
2072
+ if (body?.message) console.error(color("dim", body.message));
2073
+ process.exit(3);
2074
+ }
2075
+
2076
+ const result = await resp.json();
2077
+ const verdict = result.verdict || "pass";
2078
+ const deltas = Array.isArray(result.deltas) ? result.deltas : [];
2079
+
2080
+ // Format output
2081
+ if (format === "json") {
2082
+ console.log(JSON.stringify(result, null, 2));
2083
+ } else if (format === "sarif") {
2084
+ console.log(JSON.stringify(trustDiffToSarif(result), null, 2));
2085
+ } else if (format === "github") {
2086
+ // GitHub Actions workflow command output
2087
+ for (const d of deltas) {
2088
+ const sev = d.severity === "critical" || d.severity === "high" ? "error" : d.severity === "medium" ? "warning" : "notice";
2089
+ const msg = `${d.title || d.type}: ${d.what_changed || ""}`.replace(/\n/g, "%0A").replace(/\r/g, "");
2090
+ console.log(`::${sev}::${msg}`);
2091
+ }
2092
+ console.log(`\nTrust Diff verdict: ${verdict.toUpperCase()} — ${deltas.length} delta${deltas.length === 1 ? "" : "s"} observed.`);
2093
+ console.log(`Quota: ${result.quota?.used_this_month || 0}/${result.quota?.monthly_limit || 0} used.`);
2094
+ } else {
2095
+ // pretty (default)
2096
+ console.log("");
2097
+ console.log(` ${color("bold", "Cipherwake Trust Diff")}`);
2098
+ console.log(` ${color("dim", `${domain} · baseline=${baseline} · fail-on=${failOn}`)}`);
2099
+ console.log("");
2100
+ if (deltas.length === 0) {
2101
+ console.log(` ${color("green", "✓ No deltas observed")}`);
2102
+ } else {
2103
+ const colorByLevel = (sev) => sev === "critical" ? "red" : sev === "high" ? "red" : sev === "medium" ? "yellow" : "dim";
2104
+ for (const d of deltas) {
2105
+ const sevTag = d.severity ? `[${d.severity.toUpperCase()}]` : "";
2106
+ console.log(` ${color(colorByLevel(d.severity), sevTag)} ${d.title || d.type}`);
2107
+ if (d.what_changed) console.log(` ${color("dim", d.what_changed)}`);
2108
+ }
2109
+ }
2110
+ console.log("");
2111
+ const verdictColor = verdict === "fail" ? "red" : verdict === "warn" ? "yellow" : "green";
2112
+ console.log(` Verdict: ${color(verdictColor, verdict.toUpperCase())}`);
2113
+ console.log(` Quota: ${result.quota?.used_this_month || 0}/${result.quota?.monthly_limit || 0} used this month`);
2114
+ if (result.upgrade_hint) {
2115
+ console.log("");
2116
+ console.log(` ${color("dim", "💡 " + result.upgrade_hint)}`);
2117
+ }
2118
+ }
2119
+
2120
+ // Exit code based on verdict
2121
+ if (verdict === "fail") process.exit(2);
2122
+ if (verdict === "warn") process.exit(1);
2123
+ process.exit(0);
2124
+ }
2125
+
2126
+ /**
2127
+ * Convert /api/trust-diff response to SARIF 2.1.0 for upload via
2128
+ * github/codeql-action/upload-sarif@v3. Each delta becomes a result with
2129
+ * level = error|warning|note based on severity.
2130
+ */
2131
+ function trustDiffToSarif(result) {
2132
+ const deltas = Array.isArray(result.deltas) ? result.deltas : [];
2133
+ const rules = [...new Set(deltas.map((d) => d.type))].map((type) => ({
2134
+ id: type,
2135
+ name: type,
2136
+ shortDescription: { text: type.replace(/_/g, " ").toLowerCase() },
2137
+ helpUri: `https://cipherwake.io/methodology/change-briefs#${type.toLowerCase()}`,
2138
+ }));
2139
+ const results = deltas.map((d) => ({
2140
+ ruleId: d.type,
2141
+ level: d.severity === "critical" || d.severity === "high" ? "error" : d.severity === "medium" ? "warning" : "note",
2142
+ message: { text: `${d.title || d.type}: ${d.what_changed || ""}` },
2143
+ locations: [{
2144
+ physicalLocation: {
2145
+ artifactLocation: { uri: `cipherwake://${result.domain || "domain"}` },
2146
+ },
2147
+ }],
2148
+ }));
2149
+ return {
2150
+ $schema: "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
2151
+ version: "2.1.0",
2152
+ runs: [{
2153
+ tool: {
2154
+ driver: {
2155
+ name: "Cipherwake Trust Diff",
2156
+ version: VERSION,
2157
+ informationUri: "https://cipherwake.io",
2158
+ rules,
2159
+ },
2160
+ },
2161
+ results,
2162
+ }],
2163
+ };
2164
+ }
2165
+
2166
+ function parseFlag(args, name) {
2167
+ const idx = args.indexOf(name);
2168
+ if (idx === -1 || idx === args.length - 1) return null;
2169
+ return args[idx + 1];
2170
+ }
2171
+
1974
2172
  async function runDiffCommand(args) {
1975
2173
  const fs = await import("node:fs/promises");
1976
2174
  const json = args.includes("--json");
@@ -2098,6 +2296,10 @@ async function runWatchCommand(args) {
2098
2296
  console.error(color("dim", ` ${API_BASE}/signin`));
2099
2297
  console.error(color("dim", ` ${API_BASE}/account (rotate key)`));
2100
2298
  console.error(color("dim", ` export CIPHERWAKE_API_KEY=qpk_...`));
2299
+ console.error("");
2300
+ console.error(color("dim", `Just want to poll locally without an account? Use --watch instead:`));
2301
+ console.error(color("dim", ` npx pqcheck ${looksLikeDomain ? rawDomain : "<your-domain>"} --watch 600`));
2302
+ console.error(color("dim", ` (No API key required. Polls every N seconds, logs on score change.)`));
2101
2303
  process.exit(1);
2102
2304
  }
2103
2305
 
@@ -2260,6 +2462,943 @@ async function runCertCommand(args) {
2260
2462
  console.log("");
2261
2463
  }
2262
2464
 
2465
+ // =============================================================================
2466
+ // `pqcheck release-checklist [domain]` — pre-release trust checklist generator
2467
+ // =============================================================================
2468
+ // Outputs a markdown checklist for teams to paste into release notes or run
2469
+ // as a pre-deploy gate. Pure offline (no API call). Domain is optional —
2470
+ // when present, the checklist is interpolated; when absent, a `<your-domain>`
2471
+ // placeholder is left for the user to fill.
2472
+ //
2473
+ // Habit-loop feature locked 2026-05-16: turns Cipherwake into part of the
2474
+ // release ritual without heavy integrations. See [[cipherwake-launch-plan-2026-05]].
2475
+ // Free tier — no API quota consumed.
2476
+ // =============================================================================
2477
+
2478
+ async function runReleaseChecklistCommand(args) {
2479
+ const positional = args.filter((a) => !a.startsWith("-"));
2480
+ const raw = positional[0];
2481
+ let target = "<your-domain>";
2482
+ if (raw) {
2483
+ const normalized = normalizeDomain(raw);
2484
+ if (!isValidDomain(normalized)) {
2485
+ console.error(color("red", `error: '${raw}' is not a valid domain`));
2486
+ process.exit(1);
2487
+ }
2488
+ target = normalized;
2489
+ }
2490
+ const out = renderReleaseChecklist(target, { generator: "release-checklist" });
2491
+ console.log(out);
2492
+ process.exit(0);
2493
+ }
2494
+
2495
+ // R41 fix #3 (locked 2026-05-16): shared release-checklist helper used by
2496
+ // both `pqcheck release-checklist` (prints to stdout) and `pqcheck onboard`
2497
+ // (writes to CIPHERWAKE_CHECKLIST.md). Single source of truth for the 9
2498
+ // checklist items + verification commands + "where to look" links.
2499
+ //
2500
+ // generator: "release-checklist" or "onboard" — controls the intro paragraph
2501
+ // so the file written by `onboard` says "Generated by `pqcheck onboard`"
2502
+ // while the standalone `release-checklist` command emits the user-facing
2503
+ // "Run these in CI or paste..." intro. All other content is identical.
2504
+ function renderReleaseChecklist(domain, opts = {}) {
2505
+ const generator = opts.generator === "onboard" ? "onboard" : "release-checklist";
2506
+ const intro = generator === "onboard"
2507
+ ? `Generated by \`pqcheck onboard\` — re-run \`pqcheck release-checklist ${domain}\` anytime.`
2508
+ : `Run these in CI or paste into your release-notes template. Each item maps to a Cipherwake check; recommended commands are below. Free tier covers all of these on 1 monitored domain.`;
2509
+ return [
2510
+ `## Pre-release trust checklist for ${domain}`,
2511
+ ``,
2512
+ intro,
2513
+ ``,
2514
+ `- [ ] Trust Diff passes vs last successful deploy`,
2515
+ `- [ ] No new unapproved vendor scripts observed since last release`,
2516
+ `- [ ] HSTS still present and unchanged`,
2517
+ `- [ ] CSP still present and unchanged`,
2518
+ `- [ ] DMARC policy unchanged`,
2519
+ `- [ ] Certificate issuer expected (no surprise CA rotation)`,
2520
+ `- [ ] SPKI / key rotation matches your deploy pipeline`,
2521
+ `- [ ] HNDL Decryption Blast Radius score within target range`,
2522
+ `- [ ] Cipherwake monitoring still active (last scan within 24h)`,
2523
+ ``,
2524
+ `### How to verify`,
2525
+ ``,
2526
+ `\`\`\`bash`,
2527
+ `# Trust posture vs last successful deploy (Free: 30 calls/mo)`,
2528
+ `npx pqcheck trust-diff ${domain} --baseline last-week --fail-on high`,
2529
+ ``,
2530
+ `# Third-party origins on the page (vendor scripts)`,
2531
+ `npx pqcheck vendors check ${domain}`,
2532
+ ``,
2533
+ `# Live grade + score components`,
2534
+ `npx pqcheck ${domain}`,
2535
+ `\`\`\``,
2536
+ ``,
2537
+ `### Where to look`,
2538
+ ``,
2539
+ `- Full dashboard: https://cipherwake.io/r/${encodeURIComponent(domain)}`,
2540
+ `- Methodology + what each check means: https://cipherwake.io/methodology/`,
2541
+ `- 30-day Trust Timeline + Change Briefs: https://cipherwake.io/account`,
2542
+ ``,
2543
+ ].join("\n");
2544
+ }
2545
+
2546
+ // =============================================================================
2547
+ // `pqcheck init` — interactive workflow scaffold (habit-loop #4, locked 2026-05-16)
2548
+ // =============================================================================
2549
+ // Writes a ready-to-commit .github/workflows/cipherwake.yml that calls
2550
+ // cipherwakelabs/pqcheck@v3 in trust-diff mode. Zero copy-paste docs friction.
2551
+ //
2552
+ // Flags:
2553
+ // --domain <d> Skip the domain prompt
2554
+ // --fail-on <level> Skip the severity prompt (any|low|medium|high|critical)
2555
+ // --baseline <ref> Skip the baseline prompt (last-week|last-month|last-scan|<ISO>)
2556
+ // --yes / -y Use defaults for everything not explicitly passed
2557
+ // --force Overwrite an existing workflow file without prompting
2558
+ // --stdout Print the workflow to stdout instead of writing files
2559
+ //
2560
+ // Free tier: no API call made by init itself. The generated workflow runs
2561
+ // against the user's CIPHERWAKE_API_KEY secret (30 free Trust Diff calls/mo).
2562
+ // =============================================================================
2563
+
2564
+ const VALID_FAIL_ON = ["any", "low", "medium", "high", "critical"];
2565
+ const VALID_BASELINES = ["last-week", "last-month", "last-scan"];
2566
+
2567
+ async function runInitCommand(args) {
2568
+ const fs = await import("node:fs/promises");
2569
+ const path = await import("node:path");
2570
+ const useDefaults = args.includes("--yes") || args.includes("-y");
2571
+ const stdout = args.includes("--stdout");
2572
+ const force = args.includes("--force");
2573
+
2574
+ const flagDomain = readFlagValue(args, "--domain");
2575
+ const flagFailOn = readFlagValue(args, "--fail-on");
2576
+ const flagBaseline = readFlagValue(args, "--baseline");
2577
+
2578
+ console.log("");
2579
+ console.log(` ${color("bold", "pqcheck init")} ${color("dim", "— scaffold a Cipherwake GitHub Action workflow")}`);
2580
+ console.log("");
2581
+
2582
+ let domain = flagDomain ? normalizeDomain(flagDomain) : null;
2583
+ if (!domain) {
2584
+ if (useDefaults) {
2585
+ console.error(color("red", "error: --yes requires --domain (no interactive prompt to fill from)"));
2586
+ process.exit(1);
2587
+ }
2588
+ const answer = await prompt(` Domain to monitor (e.g. cipherwake.io): `);
2589
+ domain = normalizeDomain((answer || "").trim());
2590
+ }
2591
+ if (!isValidDomain(domain)) {
2592
+ console.error(color("red", ` error: '${domain}' is not a valid hostname`));
2593
+ process.exit(1);
2594
+ }
2595
+
2596
+ let failOn = flagFailOn || "high";
2597
+ if (!flagFailOn && !useDefaults) {
2598
+ const answer = await prompt(` Fail CI on severity ${color("dim", "[any|low|medium|high(default)|critical]")}: `);
2599
+ if (answer && answer.trim()) failOn = answer.trim().toLowerCase();
2600
+ }
2601
+ if (!VALID_FAIL_ON.includes(failOn)) {
2602
+ console.error(color("red", ` error: --fail-on must be one of ${VALID_FAIL_ON.join("|")}`));
2603
+ process.exit(1);
2604
+ }
2605
+
2606
+ let baseline = flagBaseline || "last-week";
2607
+ if (!flagBaseline && !useDefaults) {
2608
+ const answer = await prompt(` Baseline ${color("dim", "[last-week(default)|last-month|last-scan|<ISO date>]")}: `);
2609
+ if (answer && answer.trim()) baseline = answer.trim();
2610
+ }
2611
+ if (!isValidBaseline(baseline)) {
2612
+ console.error(color("red", ` error: --baseline must be last-week|last-month|last-scan or an ISO date (YYYY-MM-DD)`));
2613
+ process.exit(1);
2614
+ }
2615
+
2616
+ const workflow = renderTrustDiffWorkflow({ domain, failOn, baseline });
2617
+
2618
+ if (stdout) {
2619
+ console.log(workflow);
2620
+ process.exit(0);
2621
+ }
2622
+
2623
+ // Resolve target path: ./.github/workflows/cipherwake.yml in cwd
2624
+ const cwd = process.cwd();
2625
+ const workflowDir = path.join(cwd, ".github", "workflows");
2626
+ const workflowPath = path.join(workflowDir, "cipherwake.yml");
2627
+
2628
+ try {
2629
+ await fs.mkdir(workflowDir, { recursive: true });
2630
+ } catch (err) {
2631
+ console.error(color("red", ` error creating ${workflowDir}: ${err.message}`));
2632
+ process.exit(1);
2633
+ }
2634
+
2635
+ // Check existing file
2636
+ let exists = false;
2637
+ try {
2638
+ await fs.access(workflowPath);
2639
+ exists = true;
2640
+ } catch { /* doesn't exist */ }
2641
+
2642
+ if (exists && !force) {
2643
+ if (useDefaults) {
2644
+ console.error(color("red", ` error: ${workflowPath} already exists (re-run with --force to overwrite)`));
2645
+ process.exit(1);
2646
+ }
2647
+ const answer = await prompt(` ${color("yellow", workflowPath + " already exists — overwrite?")} ${color("dim", "[y/N]")}: `);
2648
+ if (!/^y(es)?$/i.test((answer || "").trim())) {
2649
+ console.error(color("dim", " cancelled — workflow not written"));
2650
+ process.exit(1);
2651
+ }
2652
+ }
2653
+
2654
+ try {
2655
+ await fs.writeFile(workflowPath, workflow, "utf8");
2656
+ } catch (err) {
2657
+ console.error(color("red", ` error writing ${workflowPath}: ${err.message}`));
2658
+ process.exit(1);
2659
+ }
2660
+
2661
+ const relPath = path.relative(cwd, workflowPath);
2662
+ console.log("");
2663
+ console.log(color("green", ` ✓ Wrote ${relPath}`));
2664
+ console.log("");
2665
+ console.log(` ${color("bold", "Next steps:")}`);
2666
+ console.log("");
2667
+ console.log(` ${color("dim", "1.")} Generate a Cipherwake API key at ${color("violet", "https://cipherwake.io/account#api-keys")}`);
2668
+ console.log(` ${color("dim", "Free tier: 30 Trust Diff calls/month")}`);
2669
+ console.log("");
2670
+ console.log(` ${color("dim", "2.")} Add it as a repo secret:`);
2671
+ console.log(` ${color("dim", "Settings → Secrets and variables → Actions → New repository secret")}`);
2672
+ console.log(` ${color("dim", "Name: CIPHERWAKE_API_KEY")}`);
2673
+ console.log("");
2674
+ console.log(` ${color("dim", "3.")} Commit + push:`);
2675
+ console.log(` ${color("dim", "$")} git add ${relPath}`);
2676
+ console.log(` ${color("dim", "$")} git commit -m "ci: add Cipherwake Trust Diff gate"`);
2677
+ console.log(` ${color("dim", "$")} git push`);
2678
+ console.log("");
2679
+ console.log(` Open a PR to see the gate run.`);
2680
+ console.log("");
2681
+ process.exit(0);
2682
+ }
2683
+
2684
+ function readFlagValue(args, name) {
2685
+ const idx = args.indexOf(name);
2686
+ if (idx === -1) return null;
2687
+ const v = args[idx + 1];
2688
+ return v && !v.startsWith("-") ? v : null;
2689
+ }
2690
+
2691
+ function isValidBaseline(value) {
2692
+ if (VALID_BASELINES.includes(value)) return true;
2693
+ // ISO date YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ
2694
+ return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?Z?)?$/.test(value);
2695
+ }
2696
+
2697
+ function renderTrustDiffWorkflow({ domain, failOn, baseline }) {
2698
+ return `# Cipherwake — Trust Diff gate
2699
+ # Generated by \`pqcheck init\` (v${VERSION}).
2700
+ # Runs on every PR and pushes to main: fails the build if your public trust
2701
+ # posture regresses vs the baseline (cert / SPKI / vendor scripts / HSTS / CSP /
2702
+ # DMARC / HNDL).
2703
+ #
2704
+ # Free tier: 30 Trust Diff calls/month per CIPHERWAKE_API_KEY.
2705
+ # Methodology: https://cipherwake.io/methodology/
2706
+ # Action source: https://github.com/cipherwakelabs/pqcheck
2707
+
2708
+ name: Cipherwake Trust Diff
2709
+
2710
+ on:
2711
+ pull_request:
2712
+ branches: [main]
2713
+ push:
2714
+ branches: [main]
2715
+
2716
+ permissions:
2717
+ contents: read
2718
+ id-token: write # required for OIDC-based metering (Free=30 calls/repo/mo, no API key needed)
2719
+ security-events: write # required for SARIF upload to Code Scanning
2720
+ pull-requests: write # required for sticky PR comment (Action v3.1+)
2721
+
2722
+ jobs:
2723
+ trust-diff:
2724
+ runs-on: ubuntu-latest
2725
+ steps:
2726
+ - name: Run Cipherwake Trust Diff
2727
+ uses: cipherwakelabs/pqcheck@v3
2728
+ with:
2729
+ mode: trust-diff
2730
+ domain: ${domain}
2731
+ baseline: ${baseline}
2732
+ fail-on: ${failOn}
2733
+ # No env/secrets needed for Free tier — the action uses the
2734
+ # workflow's id-token: write permission to fetch a GitHub-signed
2735
+ # OIDC token and meters per repo (30 calls/mo, no setup).
2736
+ # If you want higher limits, link this repo to a paid Cipherwake
2737
+ # account at https://cipherwake.io/account → Linked repos.
2738
+ `;
2739
+ }
2740
+
2741
+ // Tiny readline wrapper. We avoid pulling a CLI prompt library — this is the
2742
+ // only interactive path in pqcheck and Node's built-in readline is enough.
2743
+ async function prompt(question) {
2744
+ const readline = await import("node:readline/promises");
2745
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2746
+ try {
2747
+ const answer = await rl.question(question);
2748
+ return answer;
2749
+ } finally {
2750
+ rl.close();
2751
+ }
2752
+ }
2753
+
2754
+ // =============================================================================
2755
+ // `pqcheck deploy-check <domain>` — pre-deploy trust gate (habit-loop #5)
2756
+ // =============================================================================
2757
+ // Thin alias for `pqcheck trust-diff` with deploy-friendly framing:
2758
+ // - Default baseline: last-scan (compares vs your most recent scan, which
2759
+ // usually correlates with the previous deploy)
2760
+ // - Default fail-on: high
2761
+ // - Cleaner output for shell-script use in deploy pipelines (Vercel
2762
+ // pre-build, Netlify build commands, custom CD scripts)
2763
+ //
2764
+ // Exit codes match trust-diff: 0 pass · 1 warn · 2 fail · 3 error.
2765
+ // Consumes the same Free 30 Trust Diff calls/month quota.
2766
+ // =============================================================================
2767
+
2768
+ async function runDeployCheckCommand(args) {
2769
+ const positional = args.filter((a) => !a.startsWith("-"));
2770
+ if (positional.length === 0) {
2771
+ console.error(color("red", "error: pqcheck deploy-check requires a domain"));
2772
+ console.error(color("dim", "Usage: npx pqcheck deploy-check <domain> [--baseline last-scan|last-week|<ISO>] [--fail-on high|medium|low|any]"));
2773
+ process.exit(1);
2774
+ }
2775
+
2776
+ // Forward to trust-diff with deploy-tuned defaults if the user didn't specify.
2777
+ const forwarded = [...args];
2778
+ if (!args.includes("--baseline")) forwarded.push("--baseline", "last-scan");
2779
+ if (!args.includes("--fail-on")) forwarded.push("--fail-on", "high");
2780
+
2781
+ // Pre-print a deploy-context header (only in text mode — JSON/SARIF users
2782
+ // are scripting and don't want our preamble polluting their pipe).
2783
+ const format = parseFormat(forwarded);
2784
+ if (format === "text") {
2785
+ console.log("");
2786
+ console.log(` ${color("bold", "🚀 Deploy gate")} ${color("dim", "— checking public trust posture vs last scan")}`);
2787
+ console.log("");
2788
+ }
2789
+
2790
+ return runTrustDiffCommand(forwarded);
2791
+ }
2792
+
2793
+ // =============================================================================
2794
+ // `pqcheck vendors <subcommand>` — vendor lockfile management (habit-loop #10)
2795
+ // =============================================================================
2796
+ // Free tier (Option A, locked 2026-05-16):
2797
+ // pqcheck vendors export <domain> — write cipherwake.vendors.json from
2798
+ // the current observed vendor scripts
2799
+ // (read-only snapshot, no CI enforce)
2800
+ // pqcheck vendors check <domain> — compare current scan to the lockfile,
2801
+ // exit 4 if new origins appeared
2802
+ // (free CI gate via the deps endpoint)
2803
+ //
2804
+ // Starter+ tier:
2805
+ // pqcheck vendors sync <domain> — pull approved-vendor list from
2806
+ // /api/vendor-allowlist and merge into
2807
+ // the lockfile (bidirectional)
2808
+ //
2809
+ // The lockfile is a developer artifact: commit it to the repo to track
2810
+ // vendor-surface drift in PR diffs. Like package-lock.json for third-party
2811
+ // scripts.
2812
+ //
2813
+ // Per [[quantapact-pricing-discipline]]: Free generates the lockfile and uses
2814
+ // it in CI; Starter+ adds the dashboard-sync layer + approved-vendor
2815
+ // enforcement. The dashboard CRUD UI is the Starter wall, not the lockfile.
2816
+ // =============================================================================
2817
+
2818
+ const VENDOR_LOCKFILE_NAME = "cipherwake.vendors.json";
2819
+
2820
+ async function runVendorsCommand(args) {
2821
+ const sub = args[0];
2822
+ if (!sub || sub === "--help" || sub === "-h") {
2823
+ console.log(`
2824
+ ${color("bold", "pqcheck vendors")} ${color("dim", `v${VERSION}`)}
2825
+
2826
+ Vendor lockfile management — track third-party scripts on your domain.
2827
+
2828
+ ${color("bold", "Subcommands:")}
2829
+ pqcheck vendors export <domain> Write ${VENDOR_LOCKFILE_NAME} from current observed vendors (Free)
2830
+ pqcheck vendors check <domain> Compare current scan to the lockfile; exit 4 on new origins (Free CI gate)
2831
+ pqcheck vendors sync <domain> Pull approved-vendor list from your account (Starter+, requires CIPHERWAKE_API_KEY)
2832
+
2833
+ ${color("bold", "Flags:")}
2834
+ -o <path> Output / input path (default: ./${VENDOR_LOCKFILE_NAME})
2835
+
2836
+ ${color("bold", "Examples:")}
2837
+ npx pqcheck vendors export cipherwake.io ${color("dim", "# capture initial state")}
2838
+ npx pqcheck vendors check cipherwake.io ${color("dim", "# CI gate — fails on new origins")}
2839
+ CIPHERWAKE_API_KEY=qpk_... npx pqcheck vendors sync cipherwake.io ${color("dim", "# Starter+ dashboard sync")}
2840
+
2841
+ Methodology: ${color("violet", "https://cipherwake.io/methodology/vendor-lockfile")}
2842
+ `);
2843
+ process.exit(0);
2844
+ }
2845
+
2846
+ const rest = args.slice(1);
2847
+ const positional = rest.filter((a) => !a.startsWith("-"));
2848
+ const raw = positional[0];
2849
+ if (!raw) {
2850
+ console.error(color("red", `error: pqcheck vendors ${sub} requires a domain`));
2851
+ process.exit(1);
2852
+ }
2853
+ const domain = normalizeDomain(raw);
2854
+ if (!isValidDomain(domain)) {
2855
+ console.error(color("red", `error: '${raw}' is not a valid domain`));
2856
+ process.exit(1);
2857
+ }
2858
+
2859
+ const outIdx = rest.indexOf("-o");
2860
+ const outPath = (outIdx >= 0 && rest[outIdx + 1] && !rest[outIdx + 1].startsWith("-"))
2861
+ ? rest[outIdx + 1]
2862
+ : VENDOR_LOCKFILE_NAME;
2863
+
2864
+ if (sub === "export") {
2865
+ return runVendorsExport(domain, outPath);
2866
+ }
2867
+ if (sub === "check") {
2868
+ return runVendorsCheck(domain, outPath);
2869
+ }
2870
+ if (sub === "sync") {
2871
+ return runVendorsSync(domain, outPath);
2872
+ }
2873
+ console.error(color("red", `error: unknown subcommand 'vendors ${sub}'. Try: export | check | sync`));
2874
+ process.exit(1);
2875
+ }
2876
+
2877
+ async function fetchVendorOrigins(domain) {
2878
+ // Calls /api/deps which returns the observed third-party origin list for
2879
+ // a domain. Same endpoint that powers `pqcheck deps <domain>`.
2880
+ //
2881
+ // R40 fix (Q2.6): add a 15-second timeout via AbortController. Previously
2882
+ // a hung /api/deps would block the CLI indefinitely — CI runs would
2883
+ // consume their full 6-hour budget waiting for a response that never
2884
+ // comes. 15s is generous enough for the slow tail but bounds the worst
2885
+ // case.
2886
+ const ac = new AbortController();
2887
+ const timer = setTimeout(() => ac.abort(), 15_000);
2888
+ let resp;
2889
+ try {
2890
+ resp = await fetch(`${API_BASE}/api/deps?domain=${encodeURIComponent(domain)}`, {
2891
+ method: "GET",
2892
+ headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (vendors)` }),
2893
+ signal: ac.signal,
2894
+ });
2895
+ } catch (err) {
2896
+ if (err?.name === "AbortError") throw new Error("/api/deps timed out after 15s");
2897
+ throw err;
2898
+ } finally {
2899
+ clearTimeout(timer);
2900
+ }
2901
+ if (!resp.ok) {
2902
+ const body = await safeJSON(resp);
2903
+ throw new Error(`/api/deps returned ${resp.status} ${body?.error || resp.statusText}`);
2904
+ }
2905
+ const data = await resp.json();
2906
+ const thirds = Array.isArray(data.thirdParties) ? data.thirdParties : [];
2907
+ // R40 fix (Q2.7): preserve the observed protocol. Previously we
2908
+ // force-converted http://vendor.com into https://vendor.com which
2909
+ // mis-represented what we actually observed. Now: keep http:// when
2910
+ // the API gives an explicit http:// origin; default to https:// only
2911
+ // when the host comes without a protocol prefix.
2912
+ const origins = new Set();
2913
+ for (const t of thirds) {
2914
+ const host = typeof t === "string" ? t : (t.host ?? t.origin ?? "");
2915
+ if (!host) continue;
2916
+ const o = normalizeObservedOrigin(host);
2917
+ if (o) origins.add(o);
2918
+ }
2919
+ return Array.from(origins).sort();
2920
+ }
2921
+
2922
+ // R40 fix (Q2.7): preserve observed protocol. URL parser handles host
2923
+ // validation + canonicalization (lowercase, default-port stripping).
2924
+ function normalizeObservedOrigin(value) {
2925
+ const s = String(value).trim().toLowerCase();
2926
+ if (!s) return null;
2927
+ try {
2928
+ const u = s.startsWith("http://") || s.startsWith("https://")
2929
+ ? new URL(s)
2930
+ : new URL("https://" + s);
2931
+ return u.origin;
2932
+ } catch {
2933
+ return null;
2934
+ }
2935
+ }
2936
+
2937
+ function buildVendorLockfile(domain, origins) {
2938
+ return {
2939
+ schema_version: 1,
2940
+ generator: `pqcheck-cli/${VERSION}`,
2941
+ domain,
2942
+ generated_at: new Date().toISOString(),
2943
+ approved_script_origins: origins,
2944
+ // Soft tier marker — read by sync to know if the lockfile carries
2945
+ // dashboard-managed entries. Free-only lockfiles set this to null.
2946
+ synced_from_account: null,
2947
+ };
2948
+ }
2949
+
2950
+ async function runVendorsExport(domain, outPath) {
2951
+ const fs = await import("node:fs/promises");
2952
+ console.log("");
2953
+ console.log(` ${color("bold", "Exporting vendor lockfile")} ${color("dim", `— ${domain}`)}`);
2954
+ console.log("");
2955
+ let origins;
2956
+ try {
2957
+ origins = await fetchVendorOrigins(domain);
2958
+ } catch (err) {
2959
+ console.error(color("red", ` error fetching vendor origins: ${err.message}`));
2960
+ process.exit(1);
2961
+ }
2962
+ const lockfile = buildVendorLockfile(domain, origins);
2963
+ try {
2964
+ await fs.writeFile(outPath, JSON.stringify(lockfile, null, 2) + "\n", "utf8");
2965
+ } catch (err) {
2966
+ console.error(color("red", ` error writing ${outPath}: ${err.message}`));
2967
+ process.exit(1);
2968
+ }
2969
+ console.log(color("green", ` ✓ Wrote ${outPath} with ${origins.length} approved script origin${origins.length === 1 ? "" : "s"}.`));
2970
+ console.log("");
2971
+ console.log(` ${color("dim", "Commit this file to your repo to track vendor-surface drift in PR diffs.")}`);
2972
+ // R40 fix (Q2.12): nested template literal — the outer backticks are the
2973
+ // template literal; the inner string passed to color() must ALSO be a
2974
+ // template literal so ${domain} interpolates. Previously this printed
2975
+ // the literal text "${domain}" because color()'s arg was a plain string.
2976
+ console.log(` ${color("dim", `Run \`pqcheck vendors check ${domain}\` in CI to fail PRs that introduce new origins.`)}`);
2977
+ console.log("");
2978
+ process.exit(0);
2979
+ }
2980
+
2981
+ async function runVendorsCheck(domain, lockPath) {
2982
+ const fs = await import("node:fs/promises");
2983
+ let lockfile;
2984
+ try {
2985
+ const raw = await fs.readFile(lockPath, "utf8");
2986
+ lockfile = JSON.parse(raw);
2987
+ } catch (err) {
2988
+ console.error(color("red", ` error reading ${lockPath}: ${err.message}`));
2989
+ console.error(color("dim", ` Run: npx pqcheck vendors export ${domain} to generate one.`));
2990
+ process.exit(1);
2991
+ }
2992
+ if (lockfile.schema_version !== 1) {
2993
+ console.error(color("red", ` error: ${lockPath} schema_version=${lockfile.schema_version}, expected 1`));
2994
+ process.exit(1);
2995
+ }
2996
+ if (lockfile.domain && lockfile.domain !== domain) {
2997
+ console.error(color("yellow", ` warning: lockfile is for ${lockfile.domain} but checking against ${domain}`));
2998
+ }
2999
+ const baseline = new Set(Array.isArray(lockfile.approved_script_origins) ? lockfile.approved_script_origins : []);
3000
+ let observed;
3001
+ try {
3002
+ observed = new Set(await fetchVendorOrigins(domain));
3003
+ } catch (err) {
3004
+ console.error(color("red", ` error fetching current vendors: ${err.message}`));
3005
+ process.exit(1);
3006
+ }
3007
+ const newOrigins = [...observed].filter((o) => !baseline.has(o));
3008
+ const removed = [...baseline].filter((o) => !observed.has(o));
3009
+
3010
+ console.log("");
3011
+ console.log(` ${color("bold", "Vendor lockfile check")} ${color("dim", `— ${domain}`)}`);
3012
+ console.log("");
3013
+ if (newOrigins.length === 0 && removed.length === 0) {
3014
+ console.log(color("green", ` ✓ Vendor surface matches lockfile (${baseline.size} origins).`));
3015
+ console.log("");
3016
+ process.exit(0);
3017
+ }
3018
+ if (newOrigins.length > 0) {
3019
+ console.log(color("red", ` ⚠ ${newOrigins.length} new origin${newOrigins.length === 1 ? "" : "s"} observed (not in lockfile):`));
3020
+ for (const o of newOrigins) console.log(` + ${o}`);
3021
+ console.log("");
3022
+ }
3023
+ if (removed.length > 0) {
3024
+ console.log(color("dim", ` - ${removed.length} origin${removed.length === 1 ? "" : "s"} no longer observed:`));
3025
+ for (const o of removed) console.log(` - ${o}`);
3026
+ console.log("");
3027
+ }
3028
+ if (newOrigins.length > 0) {
3029
+ console.log(color("dim", ` To accept the additions, re-run: npx pqcheck vendors export ${domain}`));
3030
+ console.log(color("dim", ` Then commit the updated ${lockPath} to your repo.`));
3031
+ console.log("");
3032
+ process.exit(4); // New origin(s) detected — same exit code as `deps --fail-on-new`
3033
+ }
3034
+ // Only removals (cleanup), no failure
3035
+ process.exit(0);
3036
+ }
3037
+
3038
+ async function runVendorsSync(domain, outPath) {
3039
+ const fs = await import("node:fs/promises");
3040
+ if (!QP_API_KEY) {
3041
+ console.error(color("red", " error: `vendors sync` requires CIPHERWAKE_API_KEY (Starter+ feature)"));
3042
+ console.error("");
3043
+ console.error(color("dim", " Free tier: use `vendors export` to generate a read-only lockfile."));
3044
+ console.error(color("dim", " Sign up + manage approved vendors at: " + API_BASE + "/account"));
3045
+ console.error(color("dim", " Pricing: " + API_BASE + "/pricing"));
3046
+ process.exit(1);
3047
+ }
3048
+ console.log("");
3049
+ console.log(` ${color("bold", "Syncing vendor lockfile with your account")} ${color("dim", `— ${domain}`)}`);
3050
+ console.log("");
3051
+ let resp;
3052
+ try {
3053
+ resp = await fetch(`${API_BASE}/api/vendor-allowlist?domain=${encodeURIComponent(domain)}`, {
3054
+ method: "GET",
3055
+ headers: {
3056
+ "user-agent": `pqcheck-cli/${VERSION} (vendors-sync)`,
3057
+ "authorization": "Bearer " + QP_API_KEY,
3058
+ },
3059
+ });
3060
+ } catch (err) {
3061
+ console.error(color("red", ` network error: ${err.message ?? err}`));
3062
+ process.exit(1);
3063
+ }
3064
+ if (resp.status === 401 || resp.status === 403) {
3065
+ const body = await safeJSON(resp);
3066
+ console.error(color("red", ` authentication failed (HTTP ${resp.status})`));
3067
+ if (body?.error === "starter_required") {
3068
+ console.error(color("dim", ` Approved-vendor allowlist starts at Starter ($29/mo). ${body.message ?? ""}`));
3069
+ console.error(color("dim", ` ${API_BASE}/pricing?utm_source=cli_vendors_sync`));
3070
+ }
3071
+ process.exit(1);
3072
+ }
3073
+ if (!resp.ok) {
3074
+ console.error(color("red", ` /api/vendor-allowlist returned ${resp.status}`));
3075
+ process.exit(1);
3076
+ }
3077
+ const data = await resp.json();
3078
+ const allowlist = Array.isArray(data.allowlist) ? data.allowlist : [];
3079
+ // Filter to entries for this domain + extract vendor_origin
3080
+ const dashboardOrigins = new Set();
3081
+ for (const item of allowlist) {
3082
+ if (item && item.domain === domain && typeof item.vendor_origin === "string") {
3083
+ dashboardOrigins.add(item.vendor_origin);
3084
+ }
3085
+ }
3086
+
3087
+ // Merge with currently observed (so the lockfile covers everything we see + everything the user approved)
3088
+ let observed = new Set();
3089
+ try {
3090
+ observed = new Set(await fetchVendorOrigins(domain));
3091
+ } catch (err) {
3092
+ console.error(color("yellow", ` warning: could not fetch currently observed origins (${err.message}); using dashboard-only list`));
3093
+ }
3094
+
3095
+ const merged = new Set([...observed, ...dashboardOrigins]);
3096
+ const origins = Array.from(merged).sort();
3097
+ const lockfile = buildVendorLockfile(domain, origins);
3098
+ lockfile.synced_from_account = new Date().toISOString();
3099
+
3100
+ try {
3101
+ await fs.writeFile(outPath, JSON.stringify(lockfile, null, 2) + "\n", "utf8");
3102
+ } catch (err) {
3103
+ console.error(color("red", ` error writing ${outPath}: ${err.message}`));
3104
+ process.exit(1);
3105
+ }
3106
+ console.log(color("green", ` ✓ Synced ${outPath} — ${dashboardOrigins.size} dashboard-approved, ${observed.size} currently observed, ${origins.length} total.`));
3107
+ console.log("");
3108
+ console.log(` ${color("dim", "Commit the updated lockfile to your repo. `pqcheck vendors check` in CI will fail PRs that")}`);
3109
+ console.log(` ${color("dim", "introduce origins outside the merged set.")}`);
3110
+ console.log("");
3111
+ process.exit(0);
3112
+ }
3113
+
3114
+ // =============================================================================
3115
+ // `pqcheck onboard <domain>` — one-command setup wizard (locked 2026-05-16)
3116
+ // =============================================================================
3117
+ // Composes existing CLI subcommands into one happy-path flow:
3118
+ // 1. Quick public scan → show current grade so the user sees value first
3119
+ // 2. Scaffold the GitHub Action workflow (via runInitCommand)
3120
+ // 3. Generate a vendor lockfile snapshot (via runVendorsExport)
3121
+ // 4. Generate a release-checklist markdown
3122
+ // 5. Open the user's browser to /account#api-keys for API-key generation
3123
+ // 6. Print next-steps (secret name + commit commands + PR open)
3124
+ //
3125
+ // Design notes:
3126
+ // - Pure composition of already-reviewed subcommands; no new server endpoints.
3127
+ // - Browser-open uses platform default (`open`/`xdg-open`/`start`). When
3128
+ // headless or sandboxed, the URL is still printed so the user can copy.
3129
+ // - Each step is best-effort: a failed step prints a warning and continues
3130
+ // so a partial setup is still useful. Hard errors only stop early steps
3131
+ // where the rest can't proceed (invalid domain).
3132
+ // - --skip-scan / --skip-vendors / --skip-checklist let power users opt out.
3133
+ // - --no-open suppresses the browser launch (CI / SSH / headless friendly).
3134
+ // =============================================================================
3135
+
3136
+ async function runOnboardCommand(args) {
3137
+ const positional = args.filter((a) => !a.startsWith("-"));
3138
+ const raw = positional[0];
3139
+ if (!raw) {
3140
+ console.error(color("red", "error: pqcheck onboard requires a domain"));
3141
+ console.error(color("dim", "Usage: npx pqcheck onboard <domain> [--skip-scan] [--skip-vendors] [--skip-checklist] [--no-open] [--force] [--strict]"));
3142
+ process.exit(1);
3143
+ }
3144
+ const domain = normalizeDomain(raw);
3145
+ if (!isValidDomain(domain)) {
3146
+ console.error(color("red", `error: '${raw}' is not a valid domain`));
3147
+ process.exit(1);
3148
+ }
3149
+ const skipScan = args.includes("--skip-scan");
3150
+ const skipVendors = args.includes("--skip-vendors");
3151
+ const skipChecklist = args.includes("--skip-checklist");
3152
+ const noOpen = args.includes("--no-open");
3153
+ // R41 fix #1: --force lets users intentionally overwrite an existing
3154
+ // setup (idempotent re-runs). Without it, we abort if any of the
3155
+ // 3 output files already exists, so a user re-running onboard by
3156
+ // mistake doesn't lose hand-edited CIPHERWAKE_CHECKLIST.md / vendors
3157
+ // lockfile / workflow YAML.
3158
+ const force = args.includes("--force");
3159
+ // R41 fix #4: --strict makes onboard exit non-zero if any step fails.
3160
+ // For human-driven first-time setup, exit 0 (best-effort) is the right
3161
+ // default — printed warnings tell the user what to retry. For CI
3162
+ // automation around the wizard itself, --strict lets a build fail on
3163
+ // step errors. (Recommended usage in CI is still the individual
3164
+ // subcommands, not onboard.)
3165
+ const strict = args.includes("--strict");
3166
+
3167
+ // R41 fix #1: pre-flight overwrite check. We probe the 3 output paths
3168
+ // BEFORE running any step so an aborted run doesn't half-modify the
3169
+ // user's project. We use sync stat checks because we're not in a hot
3170
+ // path and the readability win is worth the tiny perf cost.
3171
+ if (!force) {
3172
+ const fsSync = await import("node:fs");
3173
+ const existing = [];
3174
+ try { fsSync.statSync(".github/workflows/cipherwake.yml"); existing.push(".github/workflows/cipherwake.yml"); } catch {}
3175
+ if (!skipVendors) {
3176
+ try { fsSync.statSync("cipherwake.vendors.json"); existing.push("cipherwake.vendors.json"); } catch {}
3177
+ }
3178
+ if (!skipChecklist) {
3179
+ try { fsSync.statSync("CIPHERWAKE_CHECKLIST.md"); existing.push("CIPHERWAKE_CHECKLIST.md"); } catch {}
3180
+ }
3181
+ if (existing.length > 0) {
3182
+ console.error("");
3183
+ console.error(color("red", ` error: refusing to overwrite existing files:`));
3184
+ for (const f of existing) console.error(color("dim", ` ${f}`));
3185
+ console.error("");
3186
+ console.error(color("dim", ` Re-run with --force to overwrite, or delete the files manually.`));
3187
+ console.error(color("dim", ` (--skip-vendors / --skip-checklist also bypass individual file checks.)`));
3188
+ process.exit(1);
3189
+ }
3190
+ }
3191
+
3192
+ // R41 fix #4: track step failures for --strict mode
3193
+ let anyStepFailed = false;
3194
+
3195
+ console.log("");
3196
+ console.log(` ${color("bold", "🚀 Cipherwake onboarding")} ${color("dim", `— ${domain}`)}`);
3197
+ console.log("");
3198
+ console.log(` ${color("dim", "This will write ~3 files to your project and open your browser to grab an API key.")}`);
3199
+ console.log(` ${color("dim", "All steps are best-effort; you can re-run any individual subcommand later.")}`);
3200
+ console.log("");
3201
+
3202
+ // -------------------------------------------------------------------------
3203
+ // STEP 1 — quick scan (value-first; user sees their grade before any setup)
3204
+ // -------------------------------------------------------------------------
3205
+ if (!skipScan) {
3206
+ console.log(color("violet", ` ▸ Step 1 / 4 — scanning ${domain}…`));
3207
+ try {
3208
+ const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}&source=onboard`, {
3209
+ method: "GET",
3210
+ headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (onboard)` }),
3211
+ });
3212
+ if (resp.ok) {
3213
+ const report = await resp.json();
3214
+ const score = typeof report.score === "number" ? report.score.toFixed(1) : "?";
3215
+ const grade = report.grade || "?";
3216
+ const label = report.scoreLabel || "—";
3217
+ console.log(` ${color("bold", "Current grade:")} ${color("violet", grade)} (${score}/10 · ${label})`);
3218
+ console.log(` ${color("dim", `Full report: ${API_BASE}/r/${encodeURIComponent(domain)}`)}`);
3219
+ } else {
3220
+ console.log(color("yellow", ` skipped (scan returned HTTP ${resp.status})`));
3221
+ anyStepFailed = true;
3222
+ }
3223
+ } catch (err) {
3224
+ console.log(color("yellow", ` skipped (${err?.message ?? "scan failed"})`));
3225
+ anyStepFailed = true;
3226
+ }
3227
+ console.log("");
3228
+ }
3229
+
3230
+ // -------------------------------------------------------------------------
3231
+ // STEP 2 — workflow scaffold
3232
+ // -------------------------------------------------------------------------
3233
+ console.log(color("violet", ` ▸ Step 2 / 4 — scaffolding GitHub Action workflow…`));
3234
+ try {
3235
+ // Call runInitCommand non-interactively. The function process.exit()'s on
3236
+ // its own; to compose it here we'd have to refactor. Pragmatic approach:
3237
+ // spawn a child node invoking ourselves with `init --yes --domain ...`.
3238
+ // That keeps each step idempotent and isolated.
3239
+ const { spawn } = await import("node:child_process");
3240
+ const result = await new Promise((resolve) => {
3241
+ const p = spawn(process.execPath, [
3242
+ process.argv[1],
3243
+ "init",
3244
+ "--yes",
3245
+ "--domain", domain,
3246
+ "--force",
3247
+ ], { stdio: "inherit" });
3248
+ p.on("exit", (code) => resolve(code ?? 0));
3249
+ p.on("error", () => resolve(1));
3250
+ });
3251
+ if (result !== 0) {
3252
+ console.log(color("yellow", ` init exited ${result} — you can re-run \`pqcheck init\` later`));
3253
+ anyStepFailed = true;
3254
+ }
3255
+ } catch (err) {
3256
+ console.log(color("yellow", ` skipped init (${err?.message ?? "subprocess failed"})`));
3257
+ anyStepFailed = true;
3258
+ }
3259
+ console.log("");
3260
+
3261
+ // -------------------------------------------------------------------------
3262
+ // STEP 3 — vendor lockfile (skipped if --skip-vendors)
3263
+ // -------------------------------------------------------------------------
3264
+ if (!skipVendors) {
3265
+ console.log(color("violet", ` ▸ Step 3 / 4 — capturing vendor lockfile…`));
3266
+ try {
3267
+ const { spawn } = await import("node:child_process");
3268
+ const result = await new Promise((resolve) => {
3269
+ const p = spawn(process.execPath, [
3270
+ process.argv[1],
3271
+ "vendors",
3272
+ "export",
3273
+ domain,
3274
+ ], { stdio: "inherit" });
3275
+ p.on("exit", (code) => resolve(code ?? 0));
3276
+ p.on("error", () => resolve(1));
3277
+ });
3278
+ if (result !== 0) {
3279
+ console.log(color("yellow", ` vendors export exited ${result} — you can re-run \`pqcheck vendors export ${domain}\` later`));
3280
+ anyStepFailed = true;
3281
+ }
3282
+ } catch (err) {
3283
+ console.log(color("yellow", ` skipped vendors export (${err?.message ?? "subprocess failed"})`));
3284
+ anyStepFailed = true;
3285
+ }
3286
+ console.log("");
3287
+ }
3288
+
3289
+ // -------------------------------------------------------------------------
3290
+ // STEP 4 — release checklist (skipped if --skip-checklist)
3291
+ // -------------------------------------------------------------------------
3292
+ if (!skipChecklist) {
3293
+ console.log(color("violet", ` ▸ Step 4 / 4 — writing release checklist…`));
3294
+ try {
3295
+ const fs = await import("node:fs/promises");
3296
+ const checklist = buildReleaseChecklistMarkdown(domain);
3297
+ await fs.writeFile("CIPHERWAKE_CHECKLIST.md", checklist, "utf8");
3298
+ console.log(` ${color("green", "✓ Wrote CIPHERWAKE_CHECKLIST.md")}`);
3299
+ } catch (err) {
3300
+ console.log(color("yellow", ` skipped checklist (${err?.message ?? "write failed"})`));
3301
+ anyStepFailed = true;
3302
+ }
3303
+ console.log("");
3304
+ }
3305
+
3306
+ // -------------------------------------------------------------------------
3307
+ // Final next-steps (v0.13 OIDC path — no API key needed for Free tier)
3308
+ // -------------------------------------------------------------------------
3309
+ // Pre-v0.13 this step opened a browser to the API-key page + asked the user
3310
+ // to paste the key as a GitHub repo secret. With Action v3.2 + OIDC repo
3311
+ // metering, the scaffolded workflow has `permissions: { id-token: write }`
3312
+ // and the action fetches a GitHub-signed token automatically — no key, no
3313
+ // secret, no browser hop. Free tier is 30 calls/repo/mo, enforced server-
3314
+ // side via the `meter_gh_action_call` RPC against `gh_action_repo_quota`.
3315
+ // For higher limits, the user links this repo to a paid account at /account
3316
+ // (one-time OAuth) — still no API key in CI.
3317
+ console.log(color("bold", " ✓ Setup files written. Two steps remain:"));
3318
+ console.log("");
3319
+ console.log(` ${color("dim", "1.")} ${color("bold", "Commit + push")} (no API key, no secrets needed for the Free tier)`);
3320
+ const filesToAdd = [".github/workflows/cipherwake.yml"];
3321
+ if (!skipVendors) filesToAdd.push("cipherwake.vendors.json");
3322
+ if (!skipChecklist) filesToAdd.push("CIPHERWAKE_CHECKLIST.md");
3323
+ console.log(` ${color("dim", "$")} git add ${filesToAdd.join(" ")}`);
3324
+ console.log(` ${color("dim", "$")} git commit -m "ci: add Cipherwake Trust Diff gate"`);
3325
+ console.log(` ${color("dim", "$")} git push`);
3326
+ console.log("");
3327
+ console.log(` ${color("dim", "2.")} ${color("bold", "Open a PR")}`);
3328
+ console.log(` ${color("dim", "Cipherwake will comment inline within ~60s of the workflow firing. The action uses GitHub OIDC to meter usage per repo (Free = 30 calls/mo).")}`);
3329
+ console.log("");
3330
+ // R48 (post-R47 review MAJOR #6): the /account → "Linked repos" UI is
3331
+ // not yet shipped (out of R47 scope). Pointing users to a nonexistent
3332
+ // page-hash would create a broken growth path at the moment of intent.
3333
+ // Route through the feedback form until the linking UI lands.
3334
+ console.log(` ${color("dim", "Want higher limits (1K/10K/50K Trust Diff calls/mo)?")}`);
3335
+ console.log(` ${color("violet", `${API_BASE}/feedback?topic=linked-repos`)}`);
3336
+ console.log(` ${color("dim", "Repo-linking UI is rolling out — request early access via the form.")}`);
3337
+ console.log("");
3338
+ // R41 fix #4 carried forward: --strict gates exit code on step failures.
3339
+ // noOpen flag is now a no-op since we don't open a browser, but we keep it
3340
+ // accepted for backward compat with users who already pass --no-open.
3341
+ void noOpen;
3342
+ // R41 fix #4: --strict makes onboard exit non-zero if any step failed.
3343
+ // Default (best-effort) exit 0 keeps the wizard friendly for first-time
3344
+ // human setup — the visible yellow warnings tell them what to retry.
3345
+ if (strict && anyStepFailed) {
3346
+ console.log(color("dim", " (--strict: one or more steps failed; exiting non-zero)"));
3347
+ process.exit(1);
3348
+ }
3349
+ process.exit(0);
3350
+ }
3351
+
3352
+ // R41 fix #3: buildReleaseChecklistMarkdown is now a thin alias to the shared
3353
+ // renderReleaseChecklist() helper defined alongside runReleaseChecklistCommand.
3354
+ // Single source of truth — when either subcommand's content changes, both
3355
+ // callers update automatically.
3356
+ function buildReleaseChecklistMarkdown(domain) {
3357
+ return renderReleaseChecklist(domain, { generator: "onboard" });
3358
+ }
3359
+
3360
+ // Cross-platform browser launcher. Returns true if a launcher binary
3361
+ // dispatched successfully; false if no launcher is available (e.g. headless
3362
+ // server, sandboxed CI, broken xdg-open config).
3363
+ //
3364
+ // R41 fix #2 (locked 2026-05-16): use exit-event detection + longer timeout
3365
+ // so we don't falsely claim "(opened in your browser)" when xdg-open is
3366
+ // installed but the launcher exits non-zero (no graphical session, no
3367
+ // MIME handler). Previously a flat 200ms timeout resolved true even when
3368
+ // the launcher exited 3 because no display was available.
3369
+ async function tryOpenBrowser(url) {
3370
+ if (process.env.CI || process.env.CIPHERWAKE_NO_BROWSER) return false;
3371
+ const { spawn } = await import("node:child_process");
3372
+ const platform = process.platform;
3373
+ let cmd, cmdArgs;
3374
+ if (platform === "darwin") {
3375
+ cmd = "open"; cmdArgs = [url];
3376
+ } else if (platform === "win32") {
3377
+ cmd = "cmd"; cmdArgs = ["/c", "start", "", url];
3378
+ } else {
3379
+ cmd = "xdg-open"; cmdArgs = [url];
3380
+ }
3381
+ return await new Promise((resolve) => {
3382
+ let settled = false;
3383
+ let p;
3384
+ try {
3385
+ p = spawn(cmd, cmdArgs, { stdio: "ignore", detached: true });
3386
+ } catch {
3387
+ resolve(false);
3388
+ return;
3389
+ }
3390
+ p.on("error", () => { if (!settled) { settled = true; resolve(false); } });
3391
+ p.on("exit", (code) => { if (!settled) { settled = true; resolve(code === 0); } });
3392
+ p.unref();
3393
+ // Belt-and-suspenders: if the launcher takes >1s to exit AND no error
3394
+ // event has fired, assume it dispatched and went detached (open on
3395
+ // macOS does this — returns after AppleScript-asking Finder/Safari).
3396
+ setTimeout(() => {
3397
+ if (!settled) { settled = true; resolve(true); }
3398
+ }, 1000);
3399
+ });
3400
+ }
3401
+
2263
3402
  main().catch((err) => {
2264
3403
  console.error(color("red", `fatal: ${err.message}`));
2265
3404
  process.exit(2);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.10.0",
4
- "description": "Decryption Blast Radius scanner find out how much of your data unlocks when quantum decryption arrives.",
3
+ "version": "0.13.0",
4
+ "description": "HTTPS posture scanner with Trust Diff for CI, vendor lockfile + drift alerts, cross-tenant key map, and HNDL/quantum-decryption risk scoring. Free, no signup.",
5
5
  "keywords": [
6
6
  "post-quantum",
7
7
  "cryptography",
@@ -21,7 +21,7 @@
21
21
  "bugs": "https://cipherwake.io",
22
22
  "repository": {
23
23
  "type": "git",
24
- "url": "https://github.com/cipherwake-io/pqcheck.git",
24
+ "url": "https://github.com/cipherwakelabs/pqcheck.git",
25
25
  "directory": "cli"
26
26
  },
27
27
  "license": "MIT",