pqcheck 0.7.9 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -27
- package/bin/pqcheck.js +1659 -56
- package/package.json +5 -5
package/bin/pqcheck.js
CHANGED
|
@@ -2,12 +2,48 @@
|
|
|
2
2
|
// =============================================================================
|
|
3
3
|
// pqcheck CLI — npx pqcheck <domain>
|
|
4
4
|
// =============================================================================
|
|
5
|
-
// Tiny wrapper around the public scan API at
|
|
5
|
+
// Tiny wrapper around the public scan API at cipherwake.io.
|
|
6
6
|
// Zero deps (uses node:fetch). Works under `npx pqcheck` without installation.
|
|
7
7
|
// =============================================================================
|
|
8
8
|
|
|
9
|
-
const API_BASE = process.env.PQCHECK_API_BASE || "https://
|
|
10
|
-
const VERSION = "0.
|
|
9
|
+
const API_BASE = process.env.PQCHECK_API_BASE || "https://cipherwake.io";
|
|
10
|
+
const VERSION = "0.12.0";
|
|
11
|
+
|
|
12
|
+
// API-key support — paid tiers (Starter $29 / Growth $79 / Scale $199) get
|
|
13
|
+
// per-account monthly quotas instead of the per-IP rate limit. Set via:
|
|
14
|
+
// export CIPHERWAKE_API_KEY=qpk_<32-hex>
|
|
15
|
+
// Anonymous CLI use still works (no env var → falls back to IP rate limit).
|
|
16
|
+
//
|
|
17
|
+
// QUANTAPACT_API_KEY is honored as a deprecated fallback for existing users
|
|
18
|
+
// (rebrand 2026-05-15). Will be removed in v1.0; in the meantime no break.
|
|
19
|
+
const QP_API_KEY = (process.env.CIPHERWAKE_API_KEY || process.env.QUANTAPACT_API_KEY || "").trim();
|
|
20
|
+
|
|
21
|
+
// Builds headers with optional Authorization. Use for every CLI → API call
|
|
22
|
+
// so a single env-var toggle authenticates every endpoint at once.
|
|
23
|
+
function apiHeaders(extra = {}) {
|
|
24
|
+
const h = { accept: "application/json", "user-agent": `pqcheck-cli/${VERSION}`, ...extra };
|
|
25
|
+
if (QP_API_KEY) h.authorization = `Bearer ${QP_API_KEY}`;
|
|
26
|
+
return h;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Helpful messaging when the server tells us auth/quota failed.
|
|
30
|
+
async function handleAuthError(resp) {
|
|
31
|
+
if (resp.status === 401) {
|
|
32
|
+
const body = await safeJSON(resp);
|
|
33
|
+
if (body?.error === "invalid_api_key") {
|
|
34
|
+
console.error(color("red", "CIPHERWAKE_API_KEY is invalid or revoked. Check https://cipherwake.io/account to rotate."));
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (resp.status === 429) {
|
|
39
|
+
const body = await safeJSON(resp);
|
|
40
|
+
if (body?.error === "monthly_quota_exceeded") {
|
|
41
|
+
console.error(color("red", `Monthly quota exceeded${body.detail ? ` — ${body.detail}` : ""}. Upgrade tier at https://cipherwake.io/pricing or wait until the 1st.`));
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
11
47
|
|
|
12
48
|
const ANSI = {
|
|
13
49
|
reset: "\x1b[0m",
|
|
@@ -25,9 +61,17 @@ const color = (c, s) => (supportsColor ? `${ANSI[c]}${s}${ANSI.reset}` : s);
|
|
|
25
61
|
async function main() {
|
|
26
62
|
const args = process.argv.slice(2);
|
|
27
63
|
|
|
28
|
-
|
|
64
|
+
// No-args entry: show a layman's quick-start before the full usage block.
|
|
65
|
+
// Behavior: argv.length === 0 prints the friendly intro then usage and exits 1.
|
|
66
|
+
// --help / -h prints only usage (no intro) and exits 0.
|
|
67
|
+
if (args.length === 0) {
|
|
68
|
+
printQuickStart();
|
|
69
|
+
printUsage();
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
29
73
|
printUsage();
|
|
30
|
-
process.exit(
|
|
74
|
+
process.exit(0);
|
|
31
75
|
}
|
|
32
76
|
if (args.includes("--version") || args.includes("-v")) {
|
|
33
77
|
console.log(`pqcheck ${VERSION}`);
|
|
@@ -44,12 +88,39 @@ async function main() {
|
|
|
44
88
|
if (args[0] === "diff") {
|
|
45
89
|
return runDiffCommand(args.slice(1));
|
|
46
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
|
+
}
|
|
47
97
|
if (args[0] === "history") {
|
|
48
98
|
return runHistoryCommand(args.slice(1));
|
|
49
99
|
}
|
|
100
|
+
if (args[0] === "changes") {
|
|
101
|
+
return runChangesCommand(args.slice(1));
|
|
102
|
+
}
|
|
50
103
|
if (args[0] === "cert") {
|
|
51
104
|
return runCertCommand(args.slice(1));
|
|
52
105
|
}
|
|
106
|
+
if (args[0] === "watch") {
|
|
107
|
+
return runWatchCommand(args.slice(1));
|
|
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
|
+
}
|
|
53
124
|
|
|
54
125
|
// Multi-domain support: positional args are domains.
|
|
55
126
|
// --file reads additional domains from a newline-delimited file.
|
|
@@ -133,7 +204,7 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
|
|
|
133
204
|
const qs = fresh ? `?domain=${encodeURIComponent(domain)}&force=1` : `?domain=${encodeURIComponent(domain)}`;
|
|
134
205
|
const resp = await fetch(`${API_BASE}/api/scan${qs}`, {
|
|
135
206
|
method: "GET",
|
|
136
|
-
headers: {
|
|
207
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (scan)` }),
|
|
137
208
|
});
|
|
138
209
|
if (!quiet && format === "text") process.stderr.write("\r\x1b[K");
|
|
139
210
|
if (!resp.ok) {
|
|
@@ -232,7 +303,7 @@ async function runWatch({ domains, format, quiet, threshold, webhookUrl, interva
|
|
|
232
303
|
try {
|
|
233
304
|
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
|
|
234
305
|
method: "GET",
|
|
235
|
-
headers: {
|
|
306
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (watch)` }),
|
|
236
307
|
});
|
|
237
308
|
if (!resp.ok) continue;
|
|
238
309
|
const report = await resp.json();
|
|
@@ -357,7 +428,14 @@ function printCsvRow(r) {
|
|
|
357
428
|
console.log(cells.join(","));
|
|
358
429
|
}
|
|
359
430
|
function csvEscape(s) {
|
|
360
|
-
|
|
431
|
+
// Spreadsheet-formula-injection defense (R2 finding): cells starting
|
|
432
|
+
// with =, +, -, @, TAB, or CR get treated as formulas by Excel/Sheets.
|
|
433
|
+
// Prefix with a literal-string ' to neutralize. Defense-in-depth even
|
|
434
|
+
// though domains are shape-validated upstream.
|
|
435
|
+
let v = String(s ?? "");
|
|
436
|
+
if (/^[=+\-@\t\r\n]/.test(v)) {
|
|
437
|
+
v = "'" + v;
|
|
438
|
+
}
|
|
361
439
|
if (v.includes(",") || v.includes('"') || v.includes("\n")) {
|
|
362
440
|
return '"' + v.replace(/"/g, '""') + '"';
|
|
363
441
|
}
|
|
@@ -393,6 +471,13 @@ function printMarkdown(r, multi) {
|
|
|
393
471
|
lines.push(`> ⚠ Public surface only. Internal Blast Radius is typically 12–40× this score.`);
|
|
394
472
|
lines.push("");
|
|
395
473
|
lines.push(`Full report: ${API_BASE}/?check=${encodeURIComponent(r.domain)} · Share: ${API_BASE}/r/${encodeURIComponent(r.domain)}`);
|
|
474
|
+
// Conversion CTA (generalbusiness.md principle #15): one-line activation
|
|
475
|
+
// path on every value-delivery moment. Pointed at /watch/<domain>.
|
|
476
|
+
// Audit-required 2026-05-14: copy matches current locked plan ($29 Starter
|
|
477
|
+
// not the old "watch free / weekly digest"). PR-comment context plants
|
|
478
|
+
// team-invitation idea for the eventual Growth tier upgrade.
|
|
479
|
+
lines.push(`📌 **Monitor ${r.domain} continuously?** ${API_BASE}/watch/${encodeURIComponent(r.domain)}`);
|
|
480
|
+
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
481
|
if (multi) lines.push("\n---\n");
|
|
397
482
|
console.log(lines.join("\n"));
|
|
398
483
|
}
|
|
@@ -506,7 +591,25 @@ function printReport(r) {
|
|
|
506
591
|
console.log("");
|
|
507
592
|
}
|
|
508
593
|
|
|
509
|
-
|
|
594
|
+
// Provenance pill — "Tracked by Cipherwake since X · N observations". Trust
|
|
595
|
+
// signal that this isn't a one-shot probe but a historical record. Only
|
|
596
|
+
// renders if we actually have prior observations for the domain.
|
|
597
|
+
if (r.trackedSince) {
|
|
598
|
+
const trackedDate = String(r.trackedSince).slice(0, 10);
|
|
599
|
+
const obs = typeof r.observations === "number" && r.observations > 0 ? r.observations : null;
|
|
600
|
+
const obsLine = obs ? ` · ${obs} observation${obs === 1 ? "" : "s"}` : "";
|
|
601
|
+
console.log(color("dim", ` Tracked by Cipherwake since ${trackedDate}${obsLine}`));
|
|
602
|
+
console.log("");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Conversion CTA (generalbusiness.md principle #15): every value-delivery
|
|
606
|
+
// moment surfaces the same /watch/<domain> activation path. Audit-required
|
|
607
|
+
// 2026-05-14: copy must match current locked revenue plan ($29 Starter,
|
|
608
|
+
// not the old "free 1-domain weekly digest").
|
|
609
|
+
console.log(color("violet", ` 📌 Monitor ${r.domain} daily: ${API_BASE}/watch/${encodeURIComponent(r.domain)}`));
|
|
610
|
+
console.log(color("dim", ` Cipherwake Starter $29/mo · 5 watched domains · email alerts · cancel anytime`));
|
|
611
|
+
console.log("");
|
|
612
|
+
console.log(color("dim", ` → Full report: ${API_BASE}/?check=${encodeURIComponent(r.domain)}`));
|
|
510
613
|
console.log(color("dim", ` → Share this: ${API_BASE}/r/${encodeURIComponent(r.domain)}`));
|
|
511
614
|
console.log(color("dim", ` → Compare two: ${API_BASE}/compare?a=${encodeURIComponent(r.domain)}&b=`));
|
|
512
615
|
console.log("");
|
|
@@ -531,6 +634,31 @@ async function safeJSON(resp) {
|
|
|
531
634
|
try { return await resp.json(); } catch { return null; }
|
|
532
635
|
}
|
|
533
636
|
|
|
637
|
+
// Friendly first-time intro shown when the CLI is invoked with no args.
|
|
638
|
+
// Goal: tell a brand-new user what this tool does + give them ONE
|
|
639
|
+
// command to copy-paste, before the full usage block. Modeled on
|
|
640
|
+
// `curl`/`gh`/`vercel` first-run output (concise, action-oriented).
|
|
641
|
+
function printQuickStart() {
|
|
642
|
+
console.log(`
|
|
643
|
+
${color("bold", "👋 Welcome to pqcheck")} ${color("dim", `(v${VERSION})`)}
|
|
644
|
+
|
|
645
|
+
Cipherwake's CLI grades any website's quantum-decryption risk —
|
|
646
|
+
the chance that a harvest-now-decrypt-later attack could read its
|
|
647
|
+
TLS traffic once quantum decryption arrives.
|
|
648
|
+
|
|
649
|
+
${color("bold", "Try it:")}
|
|
650
|
+
${color("dim", "$")} npx pqcheck chase.com
|
|
651
|
+
|
|
652
|
+
${color("bold", "What you'll see:")} a single letter grade (A–F), the score components
|
|
653
|
+
(cipher class, cert lifetime, key rotation history, subdomain exposure),
|
|
654
|
+
top findings, and a link to the full interactive report.
|
|
655
|
+
|
|
656
|
+
${color("bold", "Free + open methodology.")} No account needed for single-domain scans.
|
|
657
|
+
Add ${color("dim", "QUANTAPACT_API_KEY")} env var for higher rate limits + private results
|
|
658
|
+
(create one at ${color("dim", "https://cipherwake.io/signin")}).
|
|
659
|
+
`);
|
|
660
|
+
}
|
|
661
|
+
|
|
534
662
|
function printUsage() {
|
|
535
663
|
console.log(`
|
|
536
664
|
${color("bold", "pqcheck")} ${color("dim", `v${VERSION}`)}
|
|
@@ -539,11 +667,20 @@ Public Surface Blast Radius — quantum-decryption risk for any domain.
|
|
|
539
667
|
|
|
540
668
|
${color("bold", "Commands:")}
|
|
541
669
|
npx pqcheck <domain> Scan + print human-readable report
|
|
542
|
-
npx pqcheck lock <domain> Generate
|
|
670
|
+
npx pqcheck lock <domain> Generate cipherwake.lock (QXM) committable manifest
|
|
543
671
|
npx pqcheck deps <domain> Scan all third-party origins on the page (supply-chain HNDL)
|
|
544
672
|
npx pqcheck diff <old.lock> <new.lock> Compare two QXM lockfiles; exit 2 on regression
|
|
545
673
|
npx pqcheck history <domain> Show 90-day score history (sparkline + samples)
|
|
674
|
+
npx pqcheck changes <domain> Summarize public attack-surface changes in last 14 days
|
|
546
675
|
npx pqcheck cert <file.pem> Analyze a local PEM/CRT cert file (offline, no network)
|
|
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+)
|
|
547
684
|
|
|
548
685
|
${color("bold", "Multi-domain:")}
|
|
549
686
|
npx pqcheck a.com b.com c.com Multi-domain scan (positional)
|
|
@@ -568,7 +705,7 @@ ${color("bold", "Common flags:")}
|
|
|
568
705
|
|
|
569
706
|
${color("bold", "Subcommand-specific:")}
|
|
570
707
|
pqcheck deps:
|
|
571
|
-
--lock Also write
|
|
708
|
+
--lock Also write cipherwake-deps.lock + .md
|
|
572
709
|
-o <dir> Output directory for --lock files
|
|
573
710
|
--max=<N> Max third parties to scan (default 20)
|
|
574
711
|
--allowlist <file> Exit 3 if any third-party not in allowlist (CI gate)
|
|
@@ -592,19 +729,20 @@ ${color("bold", "Exit codes:")}
|
|
|
592
729
|
${color("bold", "Examples:")}
|
|
593
730
|
npx pqcheck chase.com
|
|
594
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)")}
|
|
595
733
|
npx pqcheck deps stripe.com --lock
|
|
596
734
|
npx pqcheck deps acme.com --allowlist allowed-vendors.txt ${color("dim", "# CI vendor-risk gate")}
|
|
597
735
|
npx pqcheck deps acme.com --baseline .pqcheck-baseline.json --write-baseline ${color("dim", "# capture initial state")}
|
|
598
736
|
npx pqcheck deps acme.com --baseline .pqcheck-baseline.json --fail-on-new ${color("dim", "# fail PR on new third party")}
|
|
599
737
|
npx pqcheck diff main.lock pr.lock ${color("dim", "# regression detection in PR")}
|
|
600
|
-
npx pqcheck history
|
|
738
|
+
npx pqcheck history cipherwake.io
|
|
601
739
|
npx pqcheck cert ./mycert.pem ${color("dim", "# offline cert analysis")}
|
|
602
740
|
npx pqcheck --file domains.txt --format json > scans.ndjson
|
|
603
741
|
npx pqcheck mybank.com --format sarif > pqcheck.sarif ${color("dim", "# upload to Code Scanning")}
|
|
604
742
|
npx pqcheck mybank.com --gh-action ${color("dim", "# inline PR annotations")}
|
|
605
743
|
|
|
606
744
|
Backed by the patented Decryption Blast Radius methodology.
|
|
607
|
-
${color("violet", "https://
|
|
745
|
+
${color("violet", "https://cipherwake.io")}
|
|
608
746
|
`);
|
|
609
747
|
}
|
|
610
748
|
|
|
@@ -612,20 +750,46 @@ ${color("violet", "https://quantapact.com")}
|
|
|
612
750
|
// `pqcheck lock` — QXM (Quantum Exposure Manifest) generator
|
|
613
751
|
// =============================================================================
|
|
614
752
|
// Generates two files committable to a git repo:
|
|
615
|
-
//
|
|
616
|
-
//
|
|
753
|
+
// cipherwake.lock — stable JSON manifest (machine-readable)
|
|
754
|
+
// cipherwake-report.md — human-readable summary (renders on GitHub)
|
|
617
755
|
//
|
|
618
756
|
// Like SBOM / package-lock.json / cargo audit / snyk test outputs — devs commit
|
|
619
757
|
// these to track quantum exposure as a first-class technical concern.
|
|
620
758
|
//
|
|
759
|
+
// Filename history: this tool was previously named Quantapact, and earlier
|
|
760
|
+
// versions wrote `quantapact.lock` / `quantapact-report.md`. We permanently
|
|
761
|
+
// support reading EITHER filename; existing repos with the old name keep
|
|
762
|
+
// working forever. When re-locking in a directory that has the legacy file,
|
|
763
|
+
// we overwrite it in place rather than silently creating a new file alongside.
|
|
764
|
+
// New repos (no existing lockfile) get the new default `cipherwake.lock`.
|
|
765
|
+
//
|
|
621
766
|
// Usage:
|
|
622
|
-
// npx pqcheck lock <domain> Write to ./
|
|
767
|
+
// npx pqcheck lock <domain> Write to ./cipherwake.lock + .md
|
|
768
|
+
// (or preserves ./quantapact.lock if present)
|
|
623
769
|
// npx pqcheck lock <domain> -o dir/ Write into a specific directory
|
|
624
770
|
// npx pqcheck lock <domain> --stdout Print JSON to stdout (no files)
|
|
625
771
|
// npx pqcheck lock Read domain from existing
|
|
626
|
-
//
|
|
772
|
+
// cipherwake.lock OR quantapact.lock, else error
|
|
627
773
|
// =============================================================================
|
|
628
774
|
|
|
775
|
+
// Discover an existing lockfile in `dir`, preferring the new name but
|
|
776
|
+
// accepting the legacy name. Returns { lockPath, mdPath, isLegacy } if found,
|
|
777
|
+
// or null if neither exists. Read-anywhere, write-back-to-same-name policy.
|
|
778
|
+
async function discoverExistingLockfile(fs, path, dir) {
|
|
779
|
+
const candidates = [
|
|
780
|
+
{ lockName: "cipherwake.lock", mdName: "cipherwake-report.md", isLegacy: false },
|
|
781
|
+
{ lockName: "quantapact.lock", mdName: "quantapact-report.md", isLegacy: true },
|
|
782
|
+
];
|
|
783
|
+
for (const c of candidates) {
|
|
784
|
+
const lockPath = path.join(dir, c.lockName);
|
|
785
|
+
try {
|
|
786
|
+
await fs.access(lockPath);
|
|
787
|
+
return { lockPath, mdPath: path.join(dir, c.mdName), isLegacy: c.isLegacy };
|
|
788
|
+
} catch { /* try next */ }
|
|
789
|
+
}
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
|
|
629
793
|
async function runLockCommand(args) {
|
|
630
794
|
const fs = await import("node:fs/promises");
|
|
631
795
|
const path = await import("node:path");
|
|
@@ -639,16 +803,26 @@ async function runLockCommand(args) {
|
|
|
639
803
|
const positional = args.filter((a) => !a.startsWith("-") && a !== outDir);
|
|
640
804
|
let domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
|
|
641
805
|
|
|
806
|
+
// Discover any existing lockfile (new or legacy). Used both for re-lock
|
|
807
|
+
// domain auto-detection AND to preserve the filename on write.
|
|
808
|
+
const existing = await discoverExistingLockfile(fs, path, outDir);
|
|
809
|
+
|
|
642
810
|
if (!domain) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
811
|
+
if (existing) {
|
|
812
|
+
try {
|
|
813
|
+
const content = await fs.readFile(existing.lockPath, "utf8");
|
|
814
|
+
const parsed = JSON.parse(content);
|
|
815
|
+
domain = parsed.domain;
|
|
816
|
+
if (!stdout) {
|
|
817
|
+
const baseName = path.basename(existing.lockPath);
|
|
818
|
+
console.error(color("dim", `Re-locking from existing ${baseName} (domain: ${domain})`));
|
|
819
|
+
}
|
|
820
|
+
} catch {
|
|
821
|
+
console.error(color("red", `error: could not parse existing ${path.basename(existing.lockPath)}`));
|
|
822
|
+
process.exit(1);
|
|
649
823
|
}
|
|
650
|
-
}
|
|
651
|
-
console.error(color("red", "error: no domain provided and no existing
|
|
824
|
+
} else {
|
|
825
|
+
console.error(color("red", "error: no domain provided and no existing cipherwake.lock found"));
|
|
652
826
|
console.error(color("dim", "Usage: npx pqcheck lock <domain>"));
|
|
653
827
|
process.exit(1);
|
|
654
828
|
}
|
|
@@ -665,7 +839,7 @@ async function runLockCommand(args) {
|
|
|
665
839
|
try {
|
|
666
840
|
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}`, {
|
|
667
841
|
method: "GET",
|
|
668
|
-
headers: {
|
|
842
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (lock)` }),
|
|
669
843
|
});
|
|
670
844
|
if (!stdout) process.stderr.write("\r\x1b[K");
|
|
671
845
|
if (!resp.ok) {
|
|
@@ -688,9 +862,16 @@ async function runLockCommand(args) {
|
|
|
688
862
|
return;
|
|
689
863
|
}
|
|
690
864
|
|
|
691
|
-
// Write both files
|
|
692
|
-
|
|
693
|
-
|
|
865
|
+
// Write both files. Filename policy: if a legacy quantapact.lock already
|
|
866
|
+
// exists in this directory, overwrite it in place (preserve user's
|
|
867
|
+
// committed filename — no surprise renames in their repo). Otherwise
|
|
868
|
+
// default to the new cipherwake.lock.
|
|
869
|
+
const lockPath = existing
|
|
870
|
+
? existing.lockPath
|
|
871
|
+
: path.join(outDir, "cipherwake.lock");
|
|
872
|
+
const mdPath = existing
|
|
873
|
+
? existing.mdPath
|
|
874
|
+
: path.join(outDir, "cipherwake-report.md");
|
|
694
875
|
const md = renderQxmMarkdown(manifest);
|
|
695
876
|
|
|
696
877
|
try {
|
|
@@ -736,7 +917,7 @@ function buildQxmManifest(report, crypto) {
|
|
|
736
917
|
);
|
|
737
918
|
|
|
738
919
|
return {
|
|
739
|
-
schema: "https://
|
|
920
|
+
schema: "https://cipherwake.io/schemas/qxm/v1",
|
|
740
921
|
schemaVersion: 1,
|
|
741
922
|
generator: `pqcheck-cli/${VERSION}`,
|
|
742
923
|
generatedAt: report.generatedAt || new Date().toISOString(),
|
|
@@ -756,13 +937,13 @@ function buildQxmManifest(report, crypto) {
|
|
|
756
937
|
components: report.components || null,
|
|
757
938
|
evidence: {
|
|
758
939
|
evidenceHash,
|
|
759
|
-
methodology: "https://
|
|
760
|
-
shareableReport: `https://
|
|
761
|
-
badge: `https://
|
|
940
|
+
methodology: "https://cipherwake.io/methodology",
|
|
941
|
+
shareableReport: `https://cipherwake.io/r/${encodeURIComponent(report.domain)}`,
|
|
942
|
+
badge: `https://cipherwake.io/badge/${encodeURIComponent(report.domain)}.svg`,
|
|
762
943
|
},
|
|
763
944
|
remediation: {
|
|
764
945
|
tessera: tesseraNeeded ? "join-waitlist" : "not-needed",
|
|
765
|
-
tesseraWaitlist: "https://
|
|
946
|
+
tesseraWaitlist: "https://cipherwake.io/feedback?source=qxm-tessera-interest",
|
|
766
947
|
notes: tesseraNeeded
|
|
767
948
|
? "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
949
|
: "No quantum-decryption-relevant findings requiring Tessera remediation at this time.",
|
|
@@ -775,7 +956,7 @@ function renderQxmMarkdown(m) {
|
|
|
775
956
|
lines.push(`# Quantum Exposure Manifest — \`${m.domain}\``);
|
|
776
957
|
lines.push("");
|
|
777
958
|
lines.push(`> **Decryption Blast Radius:** ${m.score} / 10 (Grade ${m.grade}, ${m.scoreLabel})`);
|
|
778
|
-
lines.push(`> Generated by [pqcheck](https://
|
|
959
|
+
lines.push(`> Generated by [pqcheck](https://cipherwake.io) at ${m.generatedAt}`);
|
|
779
960
|
lines.push("");
|
|
780
961
|
if (!m.reachable) {
|
|
781
962
|
lines.push(`*${m.domain} was not reachable at scan time.*`);
|
|
@@ -852,7 +1033,8 @@ function renderQxmMarkdown(m) {
|
|
|
852
1033
|
// Fetches the public HTML of the target domain, extracts third-party origins
|
|
853
1034
|
// referenced via <script src>, <iframe src>, <link href>, <img src>, then runs
|
|
854
1035
|
// /api/scan against each unique third party. Outputs a sorted summary + an
|
|
855
|
-
// optional committable lockfile (quantapact-deps.lock
|
|
1036
|
+
// optional committable lockfile (cipherwake-deps.lock; legacy quantapact-deps.lock
|
|
1037
|
+
// is overwritten in place if present, see write-path comments below).
|
|
856
1038
|
//
|
|
857
1039
|
// Parallel to the browser extension's Dependencies tab, exposed as a CLI for
|
|
858
1040
|
// CI integration: gate PR builds on third-party crypto posture.
|
|
@@ -860,7 +1042,7 @@ function renderQxmMarkdown(m) {
|
|
|
860
1042
|
// Usage:
|
|
861
1043
|
// npx pqcheck deps <domain> Scan + print summary table
|
|
862
1044
|
// npx pqcheck deps <domain> --json JSON output (pipe to jq, etc.)
|
|
863
|
-
// npx pqcheck deps <domain> --lock Also write
|
|
1045
|
+
// npx pqcheck deps <domain> --lock Also write cipherwake-deps.lock + .md
|
|
864
1046
|
// npx pqcheck deps <domain> -o dir/ Output directory for --lock files
|
|
865
1047
|
// npx pqcheck deps <domain> --max=20 Cap on third parties scanned (default 20)
|
|
866
1048
|
// =============================================================================
|
|
@@ -980,7 +1162,7 @@ async function runDepsCommand(args) {
|
|
|
980
1162
|
batch.map(async (h) => {
|
|
981
1163
|
try {
|
|
982
1164
|
const r = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(h.host)}&source=cli-deps`, {
|
|
983
|
-
headers: {
|
|
1165
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (deps)` }),
|
|
984
1166
|
});
|
|
985
1167
|
if (!r.ok) return { ...h, types: Array.from(h.types), scan: null, error: `${r.status}` };
|
|
986
1168
|
const body = await r.json();
|
|
@@ -1034,7 +1216,7 @@ async function runDepsCommand(args) {
|
|
|
1034
1216
|
|
|
1035
1217
|
// Build manifest
|
|
1036
1218
|
const manifest = {
|
|
1037
|
-
$schema: "https://
|
|
1219
|
+
$schema: "https://cipherwake.io/schemas/deps/v1",
|
|
1038
1220
|
schemaVersion: "1.2", // bumped for CSP + vendor classification fields
|
|
1039
1221
|
domain,
|
|
1040
1222
|
scannedAt: new Date().toISOString(),
|
|
@@ -1074,7 +1256,7 @@ async function runDepsCommand(args) {
|
|
|
1074
1256
|
try {
|
|
1075
1257
|
const fs2 = await import("node:fs/promises");
|
|
1076
1258
|
const baselinePayload = {
|
|
1077
|
-
$schema: "https://
|
|
1259
|
+
$schema: "https://cipherwake.io/schemas/deps-baseline/v1",
|
|
1078
1260
|
domain,
|
|
1079
1261
|
capturedAt: new Date().toISOString(),
|
|
1080
1262
|
toolVersion: VERSION,
|
|
@@ -1143,10 +1325,19 @@ async function runDepsCommand(args) {
|
|
|
1143
1325
|
const types = r.types.join(",");
|
|
1144
1326
|
// Vendor classification — "(New Relic · errors)" beats "bam.nr-data.net"
|
|
1145
1327
|
// for comprehension. Padded to 22 chars so the table columns stay aligned.
|
|
1328
|
+
// Heuristic matches return `name: null` and just a category — render as
|
|
1329
|
+
// "(cdn — inferred)" to signal we're guessing without claiming certainty.
|
|
1146
1330
|
const vendor = classifyVendor(r.host);
|
|
1147
|
-
|
|
1331
|
+
let vendorStrRaw;
|
|
1332
|
+
if (vendor?.name) {
|
|
1333
|
+
vendorStrRaw = `${vendor.name} (${vendor.category})`;
|
|
1334
|
+
} else if (vendor?.category) {
|
|
1335
|
+
vendorStrRaw = `(${vendor.category} — inferred)`;
|
|
1336
|
+
} else {
|
|
1337
|
+
vendorStrRaw = "—";
|
|
1338
|
+
}
|
|
1148
1339
|
const vendorTruncated = vendorStrRaw.length > 22 ? vendorStrRaw.slice(0, 21) + "…" : vendorStrRaw.padEnd(22, " ");
|
|
1149
|
-
const vendorColored =
|
|
1340
|
+
const vendorColored = color("dim", vendorTruncated);
|
|
1150
1341
|
console.log(` ${gradeColored.padEnd(8, " ")} ${host} ${vendorColored} ${pqc} ${sriCell} ${color("dim", types)}`);
|
|
1151
1342
|
}
|
|
1152
1343
|
console.log("");
|
|
@@ -1158,8 +1349,19 @@ async function runDepsCommand(args) {
|
|
|
1158
1349
|
console.log("");
|
|
1159
1350
|
|
|
1160
1351
|
if (lock) {
|
|
1161
|
-
|
|
1162
|
-
|
|
1352
|
+
// Filename policy mirrors `pqcheck lock`: prefer the new cipherwake-deps.lock,
|
|
1353
|
+
// but if a legacy quantapact-deps.lock exists in this dir, overwrite that one
|
|
1354
|
+
// in place so the user's repo doesn't suddenly grow a second lockfile.
|
|
1355
|
+
const fsSync = await import("node:fs");
|
|
1356
|
+
const legacyDepsLock = path.join(outDir, "quantapact-deps.lock");
|
|
1357
|
+
const legacyDepsMd = path.join(outDir, "quantapact-deps-report.md");
|
|
1358
|
+
const hasLegacy = fsSync.existsSync(legacyDepsLock);
|
|
1359
|
+
const lockPath = hasLegacy
|
|
1360
|
+
? legacyDepsLock
|
|
1361
|
+
: path.join(outDir, "cipherwake-deps.lock");
|
|
1362
|
+
const mdPath = hasLegacy
|
|
1363
|
+
? legacyDepsMd
|
|
1364
|
+
: path.join(outDir, "cipherwake-deps-report.md");
|
|
1163
1365
|
try {
|
|
1164
1366
|
await fs.mkdir(outDir, { recursive: true });
|
|
1165
1367
|
await fs.writeFile(lockPath, JSON.stringify(manifest, null, 2));
|
|
@@ -1199,7 +1401,7 @@ async function fetchPageHTML(domain) {
|
|
|
1199
1401
|
method: "GET",
|
|
1200
1402
|
redirect: "follow",
|
|
1201
1403
|
signal: ctrl.signal,
|
|
1202
|
-
headers: { "User-Agent": `pqcheck-cli/${VERSION} (deps; +https://
|
|
1404
|
+
headers: { "User-Agent": `pqcheck-cli/${VERSION} (deps; +https://cipherwake.io)` },
|
|
1203
1405
|
});
|
|
1204
1406
|
clearTimeout(t);
|
|
1205
1407
|
if (!resp.ok) return null;
|
|
@@ -1303,6 +1505,39 @@ const SERVICE_CATALOG = {
|
|
|
1303
1505
|
"tiktok.com": { name: "TikTok", category: "social" },
|
|
1304
1506
|
};
|
|
1305
1507
|
|
|
1508
|
+
// Conservative heuristic patterns — same as lib/serviceCatalog.ts + popup.js.
|
|
1509
|
+
// Used when a host isn't in the explicit catalog. Doesn't name the vendor
|
|
1510
|
+
// (we don't know), but assigns a high-confidence category like "cdn" or
|
|
1511
|
+
// "ads" so unknown hosts aren't blank.
|
|
1512
|
+
const HEURISTIC_PATTERNS = [
|
|
1513
|
+
// CDN
|
|
1514
|
+
{ re: /^cdn[.-]/, category: "cdn" },
|
|
1515
|
+
{ re: /^static\./, category: "cdn" },
|
|
1516
|
+
{ re: /^assets\./, category: "cdn" },
|
|
1517
|
+
{ re: /\.cloudfront\.net$/, category: "cdn" },
|
|
1518
|
+
{ re: /\.akamai(?:edge|hd|ized)?\.net$/, category: "cdn" },
|
|
1519
|
+
{ re: /\.fastly\.net$/, category: "cdn" },
|
|
1520
|
+
{ re: /\.azureedge\.net$/, category: "cdn" },
|
|
1521
|
+
// Analytics / RUM
|
|
1522
|
+
{ re: /^analytics?\./, category: "analytics" },
|
|
1523
|
+
{ re: /^metrics?\./, category: "analytics" },
|
|
1524
|
+
{ re: /^telemetry\./, category: "analytics" },
|
|
1525
|
+
{ re: /^rum\./, category: "analytics" },
|
|
1526
|
+
// Ads
|
|
1527
|
+
{ re: /^ads?[.-]/, category: "ads" },
|
|
1528
|
+
{ re: /^adserver\./, category: "ads" },
|
|
1529
|
+
{ re: /^pubads\./, category: "ads" },
|
|
1530
|
+
{ re: /\.advertising\./, category: "ads" },
|
|
1531
|
+
// Consent / cookies
|
|
1532
|
+
{ re: /^consent\./, category: "consent" },
|
|
1533
|
+
{ re: /^cookies?\./, category: "consent" },
|
|
1534
|
+
{ re: /^gdpr\./, category: "consent" },
|
|
1535
|
+
// Fonts
|
|
1536
|
+
{ re: /^fonts?\./, category: "fonts" },
|
|
1537
|
+
// Errors / monitoring
|
|
1538
|
+
{ re: /^sentry[.-]/, category: "errors" },
|
|
1539
|
+
];
|
|
1540
|
+
|
|
1306
1541
|
function classifyVendor(host) {
|
|
1307
1542
|
if (!host) return null;
|
|
1308
1543
|
const lower = host.toLowerCase();
|
|
@@ -1312,6 +1547,14 @@ function classifyVendor(host) {
|
|
|
1312
1547
|
return SERVICE_CATALOG[pattern];
|
|
1313
1548
|
}
|
|
1314
1549
|
}
|
|
1550
|
+
for (const { re, category } of HEURISTIC_PATTERNS) {
|
|
1551
|
+
if (re.test(lower)) {
|
|
1552
|
+
// Inferred — no vendor name, just the category. CLI consumers (the
|
|
1553
|
+
// pretty table + JSON output) check `name === null` to distinguish
|
|
1554
|
+
// from explicit catalog matches.
|
|
1555
|
+
return { name: null, category };
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1315
1558
|
return null;
|
|
1316
1559
|
}
|
|
1317
1560
|
|
|
@@ -1451,6 +1694,67 @@ function depsManifestToMarkdown(m) {
|
|
|
1451
1694
|
// SARIF + GitHub Action output formats
|
|
1452
1695
|
// =============================================================================
|
|
1453
1696
|
|
|
1697
|
+
// Derive a SARIF-stable rule ID from a finding. Prefer the registry's stable
|
|
1698
|
+
// `id` (e.g., "tls.rsa_kex_fallback") so the same finding gets the same
|
|
1699
|
+
// ruleId every run — without it, GitHub Code Scanning treats a reordered
|
|
1700
|
+
// finding list as a fresh batch of new findings, blowing up the triage UX.
|
|
1701
|
+
// Short non-crypto hash for SARIF rule-ID disambiguation. djb2-style.
|
|
1702
|
+
// Pure JS so it works in both Node and the CLI bundle. 8-char hex output
|
|
1703
|
+
// is enough entropy to disambiguate distinct legacy findings without
|
|
1704
|
+
// becoming churn-prone — same input string always produces the same hash.
|
|
1705
|
+
function shortHash(input) {
|
|
1706
|
+
let h = 5381;
|
|
1707
|
+
for (let i = 0; i < input.length; i++) {
|
|
1708
|
+
h = ((h << 5) + h + input.charCodeAt(i)) >>> 0;
|
|
1709
|
+
}
|
|
1710
|
+
return h.toString(16).padStart(8, "0");
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function stableRuleId(f) {
|
|
1714
|
+
if (f && typeof f.id === "string" && f.id.length > 0) {
|
|
1715
|
+
// Normalize: if a registry ID already carries the `pqcheck.` prefix
|
|
1716
|
+
// (which happened before this helper existed), don't double-prefix
|
|
1717
|
+
// it into `pqcheck.pqcheck.tls...`. Strip and reattach.
|
|
1718
|
+
const id = f.id.replace(/^pqcheck\./i, "");
|
|
1719
|
+
return `pqcheck.${id}`;
|
|
1720
|
+
}
|
|
1721
|
+
// Fallback for legacy findings emitted without the registry — slug the
|
|
1722
|
+
// title. Still stable across runs (same input → same output).
|
|
1723
|
+
const title = f?.title || "finding";
|
|
1724
|
+
const detail = f?.detail || "";
|
|
1725
|
+
const slug = title
|
|
1726
|
+
.toLowerCase()
|
|
1727
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
1728
|
+
.replace(/^_+|_+$/g, "")
|
|
1729
|
+
.slice(0, 48);
|
|
1730
|
+
// GPT adversarial review 2026-05-12: legacy findings with similar
|
|
1731
|
+
// titles ("No HSTS header" vs "No HSTS Header!") both slug to
|
|
1732
|
+
// "no_hsts_header" and would collide in SARIF, causing GitHub Code
|
|
1733
|
+
// Scanning to merge unrelated findings. Append a short hash of the
|
|
1734
|
+
// full (title + detail) so semantically-different findings get
|
|
1735
|
+
// distinct rule IDs even when their slugged titles match.
|
|
1736
|
+
const disambiguator = shortHash(`${title}|${detail}`);
|
|
1737
|
+
if (slug.length === 0) {
|
|
1738
|
+
return `pqcheck.legacy.unnamed.${disambiguator}`;
|
|
1739
|
+
}
|
|
1740
|
+
return `pqcheck.legacy.${slug}.${disambiguator}`;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// Deduplicate the `rules` array — multiple findings can share the same
|
|
1744
|
+
// underlying rule (e.g., two cert findings both pinned to `cert.expired`).
|
|
1745
|
+
// SARIF's rule list must contain each rule once.
|
|
1746
|
+
function dedupeRules(findings) {
|
|
1747
|
+
const seen = new Set();
|
|
1748
|
+
const out = [];
|
|
1749
|
+
for (const f of findings) {
|
|
1750
|
+
const id = stableRuleId(f);
|
|
1751
|
+
if (seen.has(id)) continue;
|
|
1752
|
+
seen.add(id);
|
|
1753
|
+
out.push(f);
|
|
1754
|
+
}
|
|
1755
|
+
return out;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1454
1758
|
function reportToSarif(report) {
|
|
1455
1759
|
// SARIF 2.1.0 minimal schema for security findings.
|
|
1456
1760
|
// Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
|
@@ -1464,9 +1768,15 @@ function reportToSarif(report) {
|
|
|
1464
1768
|
driver: {
|
|
1465
1769
|
name: "pqcheck",
|
|
1466
1770
|
version: VERSION,
|
|
1467
|
-
informationUri: "https://
|
|
1468
|
-
|
|
1469
|
-
|
|
1771
|
+
informationUri: "https://cipherwake.io",
|
|
1772
|
+
// Stable rule IDs — anchored to the finding's registry id (e.g.
|
|
1773
|
+
// "tls.rsa_kex_fallback") when present, otherwise a slug derived
|
|
1774
|
+
// from the title. Previously used positional pqcheck-${i+1} which
|
|
1775
|
+
// made GitHub Code Scanning see every reorder as a "new" finding,
|
|
1776
|
+
// poisoning the dedup/triage UX. Stable IDs let Code Scanning
|
|
1777
|
+
// recognize the same rule across runs.
|
|
1778
|
+
rules: dedupeRules(findings).map((f) => ({
|
|
1779
|
+
id: stableRuleId(f),
|
|
1470
1780
|
name: (f.title || "finding").replace(/[^A-Za-z0-9]/g, "_"),
|
|
1471
1781
|
shortDescription: { text: f.title || "finding" },
|
|
1472
1782
|
fullDescription: { text: f.detail || f.title || "finding" },
|
|
@@ -1474,8 +1784,8 @@ function reportToSarif(report) {
|
|
|
1474
1784
|
})),
|
|
1475
1785
|
},
|
|
1476
1786
|
},
|
|
1477
|
-
results: findings.map((f
|
|
1478
|
-
ruleId:
|
|
1787
|
+
results: findings.map((f) => ({
|
|
1788
|
+
ruleId: stableRuleId(f),
|
|
1479
1789
|
level: sevMap[f.severity] || "note",
|
|
1480
1790
|
message: { text: `${f.title || "finding"}${f.detail ? ` — ${f.detail}` : ""}` },
|
|
1481
1791
|
// GitHub Code Scanning requires file: scheme (or relative path) for
|
|
@@ -1483,7 +1793,7 @@ function reportToSarif(report) {
|
|
|
1483
1793
|
// relative path so findings show up cleanly in the Security tab.
|
|
1484
1794
|
locations: [{
|
|
1485
1795
|
physicalLocation: {
|
|
1486
|
-
artifactLocation: { uri: `
|
|
1796
|
+
artifactLocation: { uri: `cipherwake-scan/${report.domain || "unknown"}.txt` },
|
|
1487
1797
|
region: { startLine: 1, startColumn: 1 },
|
|
1488
1798
|
},
|
|
1489
1799
|
}],
|
|
@@ -1492,7 +1802,7 @@ function reportToSarif(report) {
|
|
|
1492
1802
|
score: report.score,
|
|
1493
1803
|
grade: report.grade,
|
|
1494
1804
|
severity: f.severity,
|
|
1495
|
-
reportUrl: `https://www.
|
|
1805
|
+
reportUrl: `https://www.cipherwake.io/r/${report.domain || ""}`,
|
|
1496
1806
|
},
|
|
1497
1807
|
})),
|
|
1498
1808
|
properties: {
|
|
@@ -1513,10 +1823,10 @@ function printGitHubActionAnnotations(report) {
|
|
|
1513
1823
|
if (report._meta?.degraded) {
|
|
1514
1824
|
const reason = String(report._meta.degradedReason || "live probe failed").replace(/[\r\n]/g, " ").replace(/::/g, ":");
|
|
1515
1825
|
const since = String(report._meta.lastUpdated || "unknown");
|
|
1516
|
-
console.log(`::warning title=
|
|
1826
|
+
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
1827
|
}
|
|
1518
1828
|
// Top-line score/grade as a notice
|
|
1519
|
-
console.log(`::notice title=
|
|
1829
|
+
console.log(`::notice title=Cipherwake: ${report.domain}::Grade ${report.grade || "?"} · score ${report.score ?? "?"} / 10`);
|
|
1520
1830
|
for (const f of findings) {
|
|
1521
1831
|
const cmd = sevMap[f.severity] || "notice";
|
|
1522
1832
|
const title = (f.title || "finding").replace(/[\r\n]/g, " ");
|
|
@@ -1548,7 +1858,7 @@ async function runHistoryCommand(args) {
|
|
|
1548
1858
|
let h;
|
|
1549
1859
|
try {
|
|
1550
1860
|
const r = await fetch(`${API_BASE}/api/history?domain=${encodeURIComponent(domain)}&days=${days}`, {
|
|
1551
|
-
headers: {
|
|
1861
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (history)` }),
|
|
1552
1862
|
});
|
|
1553
1863
|
if (!r.ok) {
|
|
1554
1864
|
console.error(color("red", `error: ${r.status} ${r.statusText}`));
|
|
@@ -1608,10 +1918,257 @@ async function runHistoryCommand(args) {
|
|
|
1608
1918
|
console.log("");
|
|
1609
1919
|
}
|
|
1610
1920
|
|
|
1921
|
+
// =============================================================================
|
|
1922
|
+
// `pqcheck changes <domain>` — surface observation-table deltas for a domain
|
|
1923
|
+
// =============================================================================
|
|
1924
|
+
// Hits /api/changes-summary which aggregates the new observation tables
|
|
1925
|
+
// shipped 2026-05-13 (subdomain_observations, script_observations,
|
|
1926
|
+
// posture_snapshots, cert_observations). Returns "N attack-surface changes
|
|
1927
|
+
// in last 14d" + breakdown. Devs use this in CI ("did anything change since
|
|
1928
|
+
// yesterday?") and in PR descriptions ("attached: 3 changes detected since
|
|
1929
|
+
// last week").
|
|
1930
|
+
|
|
1931
|
+
async function runChangesCommand(args) {
|
|
1932
|
+
const json = args.includes("--json");
|
|
1933
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
1934
|
+
const domain = positional.length > 0 ? normalizeDomain(positional[0]) : null;
|
|
1935
|
+
if (!domain || !isValidDomain(domain)) {
|
|
1936
|
+
console.error(color("red", "error: pqcheck changes requires a valid domain"));
|
|
1937
|
+
console.error(color("dim", "Usage: npx pqcheck changes <domain> [--json]"));
|
|
1938
|
+
process.exit(1);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
let summary;
|
|
1942
|
+
try {
|
|
1943
|
+
const r = await fetch(`${API_BASE}/api/changes-summary?domain=${encodeURIComponent(domain)}`, {
|
|
1944
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (changes)` }),
|
|
1945
|
+
});
|
|
1946
|
+
if (!r.ok) {
|
|
1947
|
+
console.error(color("red", `error: ${r.status} ${r.statusText}`));
|
|
1948
|
+
process.exit(1);
|
|
1949
|
+
}
|
|
1950
|
+
summary = await r.json();
|
|
1951
|
+
} catch (err) {
|
|
1952
|
+
console.error(color("red", `error: ${err.message}`));
|
|
1953
|
+
process.exit(1);
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
if (json) {
|
|
1957
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
const tracked = summary.trackedSince;
|
|
1962
|
+
const total = summary.changes?.last14d ?? 0;
|
|
1963
|
+
const b = summary.breakdown ?? {};
|
|
1964
|
+
console.log("");
|
|
1965
|
+
console.log(` ${color("bold", domain)} ${color("dim", "·")} attack-surface change summary`);
|
|
1966
|
+
|
|
1967
|
+
if (!tracked) {
|
|
1968
|
+
console.log(color("dim", " No observation history yet. Run `npx pqcheck " + domain + "` to start accumulating."));
|
|
1969
|
+
console.log("");
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
const trackedDate = String(tracked).slice(0, 10);
|
|
1974
|
+
console.log(color("dim", ` Tracking since ${trackedDate}`));
|
|
1975
|
+
console.log("");
|
|
1976
|
+
|
|
1977
|
+
if (total === 0) {
|
|
1978
|
+
console.log(` ${color("green", "✓")} No public attack-surface changes detected in the last 14 days.`);
|
|
1979
|
+
console.log("");
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
console.log(` ${color("yellow", "⚠")} ${color("bold", total)} change${total === 1 ? "" : "s"} detected in the last 14 days:`);
|
|
1984
|
+
console.log("");
|
|
1985
|
+
if (b.newSubdomains14d) {
|
|
1986
|
+
console.log(` ${color("violet", "•")} ${color("bold", b.newSubdomains14d)} new subdomain${b.newSubdomains14d === 1 ? "" : "s"} observed in CT logs or live scans`);
|
|
1987
|
+
}
|
|
1988
|
+
if (b.newScripts14d) {
|
|
1989
|
+
console.log(` ${color("violet", "•")} ${color("bold", b.newScripts14d)} new third-party script host${b.newScripts14d === 1 ? "" : "s"} loaded`);
|
|
1990
|
+
}
|
|
1991
|
+
if (b.newCertKeys14d) {
|
|
1992
|
+
console.log(` ${color("violet", "•")} ${color("bold", b.newCertKeys14d)} new SPKI fingerprint${b.newCertKeys14d === 1 ? "" : "s"} (cert rotation with new key)`);
|
|
1993
|
+
}
|
|
1994
|
+
console.log("");
|
|
1995
|
+
console.log(color("dim", ` Full changelog: ${API_BASE}/domain/${domain}/security-changelog`));
|
|
1996
|
+
console.log("");
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1611
1999
|
// =============================================================================
|
|
1612
2000
|
// `pqcheck diff` — diff two QXM lockfiles
|
|
1613
2001
|
// =============================================================================
|
|
1614
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
|
+
|
|
1615
2172
|
async function runDiffCommand(args) {
|
|
1616
2173
|
const fs = await import("node:fs/promises");
|
|
1617
2174
|
const json = args.includes("--json");
|
|
@@ -1637,7 +2194,7 @@ async function runDiffCommand(args) {
|
|
|
1637
2194
|
}
|
|
1638
2195
|
|
|
1639
2196
|
console.log("");
|
|
1640
|
-
console.log(` ${color("bold", "
|
|
2197
|
+
console.log(` ${color("bold", "Cipherwake lockfile diff")}`);
|
|
1641
2198
|
console.log(` ${color("dim", `${positional[0]} → ${positional[1]}`)}`);
|
|
1642
2199
|
console.log("");
|
|
1643
2200
|
if (diff.scoreChange !== null) {
|
|
@@ -1708,6 +2265,118 @@ function computeLockDiff(oldLock, newLock) {
|
|
|
1708
2265
|
// `pqcheck cert <pem-file>` — analyze a local cert file (offline)
|
|
1709
2266
|
// =============================================================================
|
|
1710
2267
|
|
|
2268
|
+
// `pqcheck watch <domain>` — adds the given domain to the user's watched-
|
|
2269
|
+
// domain list via the authenticated /api/watched-domains POST. Requires
|
|
2270
|
+
// QUANTAPACT_API_KEY env var. Closes the CLI ↔ account loop: developers
|
|
2271
|
+
// who use the CLI can now opt into persistent monitoring from the same
|
|
2272
|
+
// surface without leaving the terminal.
|
|
2273
|
+
async function runWatchCommand(args) {
|
|
2274
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
2275
|
+
if (positional.length === 0) {
|
|
2276
|
+
console.error(color("red", "error: pqcheck watch requires a domain"));
|
|
2277
|
+
console.error(color("dim", "Usage: npx pqcheck watch <domain>"));
|
|
2278
|
+
console.error("");
|
|
2279
|
+
console.error(color("dim", "Example: npx pqcheck watch chase.com"));
|
|
2280
|
+
process.exit(1);
|
|
2281
|
+
}
|
|
2282
|
+
if (!QP_API_KEY) {
|
|
2283
|
+
const rawDomain = positional[0] ? String(positional[0]).trim().toLowerCase() : "";
|
|
2284
|
+
const looksLikeDomain = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(rawDomain);
|
|
2285
|
+
console.error(color("red", "error: pqcheck watch requires CIPHERWAKE_API_KEY (paid tier)"));
|
|
2286
|
+
console.error("");
|
|
2287
|
+
console.error(color("dim", "Two ways to add this domain:"));
|
|
2288
|
+
if (looksLikeDomain) {
|
|
2289
|
+
console.error(color("dim", ` 1. Sign up + click "Watch" in your browser:`));
|
|
2290
|
+
console.error(color("dim", ` ${API_BASE}/watch/${rawDomain}`));
|
|
2291
|
+
} else {
|
|
2292
|
+
console.error(color("dim", ` 1. Sign up + click "Watch" in your browser:`));
|
|
2293
|
+
console.error(color("dim", ` ${API_BASE}/watch/<your-domain>`));
|
|
2294
|
+
}
|
|
2295
|
+
console.error(color("dim", ` 2. Stay on the CLI — sign up + rotate an API key:`));
|
|
2296
|
+
console.error(color("dim", ` ${API_BASE}/signin`));
|
|
2297
|
+
console.error(color("dim", ` ${API_BASE}/account (rotate key)`));
|
|
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.)`));
|
|
2303
|
+
process.exit(1);
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
const raw = positional[0];
|
|
2307
|
+
const domain = normalizeDomain(raw);
|
|
2308
|
+
if (!isValidDomain(domain)) {
|
|
2309
|
+
console.error(color("red", `error: '${raw}' is not a valid domain`));
|
|
2310
|
+
process.exit(1);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
console.log("");
|
|
2314
|
+
console.log(color("violet", ` 📌 Adding ${domain} to your watched-domain list…`));
|
|
2315
|
+
|
|
2316
|
+
let resp;
|
|
2317
|
+
try {
|
|
2318
|
+
resp = await fetch(`${API_BASE}/api/watched-domains`, {
|
|
2319
|
+
method: "POST",
|
|
2320
|
+
headers: {
|
|
2321
|
+
"content-type": "application/json",
|
|
2322
|
+
"authorization": "Bearer " + QP_API_KEY,
|
|
2323
|
+
},
|
|
2324
|
+
body: JSON.stringify({ domain }),
|
|
2325
|
+
});
|
|
2326
|
+
} catch (err) {
|
|
2327
|
+
console.error(color("red", ` network error: ${err.message ?? err}`));
|
|
2328
|
+
process.exit(1);
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
2332
|
+
console.error(color("red", ` authentication failed (HTTP ${resp.status})`));
|
|
2333
|
+
console.error(color("dim", ` Your API key may be invalid or revoked. Regenerate at ${API_BASE}/account`));
|
|
2334
|
+
process.exit(1);
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
let out = {};
|
|
2338
|
+
try { out = await resp.json(); } catch { /* ignore */ }
|
|
2339
|
+
|
|
2340
|
+
if (!resp.ok) {
|
|
2341
|
+
if (out.error === "domain_already_watched") {
|
|
2342
|
+
console.log(color("dim", ` ${domain} is already on your watched-domain list.`));
|
|
2343
|
+
console.log(color("dim", ` Manage at: ${API_BASE}/account`));
|
|
2344
|
+
process.exit(0);
|
|
2345
|
+
}
|
|
2346
|
+
if (out.error === "tier_cap_exceeded") {
|
|
2347
|
+
console.error(color("red", ` ${out.message || "Tier cap reached."}`));
|
|
2348
|
+
console.error(color("dim", ` See pricing: ${API_BASE}/pricing`));
|
|
2349
|
+
process.exit(1);
|
|
2350
|
+
}
|
|
2351
|
+
if (out.error === "invalid_domain") {
|
|
2352
|
+
console.error(color("red", ` ${domain} is not a valid hostname.`));
|
|
2353
|
+
process.exit(1);
|
|
2354
|
+
}
|
|
2355
|
+
console.error(color("red", ` add failed: ${out.message || out.error || `HTTP ${resp.status}`}`));
|
|
2356
|
+
process.exit(1);
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
console.log("");
|
|
2360
|
+
console.log(color("green", ` ✓ Now watching ${domain}.`));
|
|
2361
|
+
console.log("");
|
|
2362
|
+
if (out.verificationInstructions) {
|
|
2363
|
+
const v = out.verificationInstructions;
|
|
2364
|
+
console.log(color("bold", " Next: verify ownership"));
|
|
2365
|
+
console.log(color("dim", ` Pick ONE method:`));
|
|
2366
|
+
console.log("");
|
|
2367
|
+
console.log(color("dim", ` DNS TXT — name: ${v.dnsTxt?.recordName}`));
|
|
2368
|
+
console.log(color("dim", ` value: ${v.dnsTxt?.recordValue}`));
|
|
2369
|
+
console.log("");
|
|
2370
|
+
console.log(color("dim", ` HTTP file — url: ${v.wellKnown?.url}`));
|
|
2371
|
+
console.log(color("dim", ` body: ${v.wellKnown?.body}`));
|
|
2372
|
+
console.log("");
|
|
2373
|
+
console.log(color("dim", ` After adding the record, click 'Verify now' at ${API_BASE}/account`));
|
|
2374
|
+
console.log(color("dim", ` or run: npx pqcheck watch ${domain} --verify (coming soon)`));
|
|
2375
|
+
}
|
|
2376
|
+
console.log("");
|
|
2377
|
+
process.exit(0);
|
|
2378
|
+
}
|
|
2379
|
+
|
|
1711
2380
|
async function runCertCommand(args) {
|
|
1712
2381
|
const fs = await import("node:fs/promises");
|
|
1713
2382
|
const crypto = await import("node:crypto");
|
|
@@ -1793,6 +2462,940 @@ async function runCertCommand(args) {
|
|
|
1793
2462
|
console.log("");
|
|
1794
2463
|
}
|
|
1795
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
|
+
security-events: write # required for SARIF upload to Code Scanning
|
|
2719
|
+
pull-requests: write # required for sticky PR comment (Action v3.1+)
|
|
2720
|
+
|
|
2721
|
+
jobs:
|
|
2722
|
+
trust-diff:
|
|
2723
|
+
runs-on: ubuntu-latest
|
|
2724
|
+
steps:
|
|
2725
|
+
- name: Run Cipherwake Trust Diff
|
|
2726
|
+
uses: cipherwakelabs/pqcheck@v3
|
|
2727
|
+
with:
|
|
2728
|
+
mode: trust-diff
|
|
2729
|
+
domain: ${domain}
|
|
2730
|
+
baseline: ${baseline}
|
|
2731
|
+
fail-on: ${failOn}
|
|
2732
|
+
env:
|
|
2733
|
+
CIPHERWAKE_API_KEY: \${{ secrets.CIPHERWAKE_API_KEY }}
|
|
2734
|
+
`;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
// Tiny readline wrapper. We avoid pulling a CLI prompt library — this is the
|
|
2738
|
+
// only interactive path in pqcheck and Node's built-in readline is enough.
|
|
2739
|
+
async function prompt(question) {
|
|
2740
|
+
const readline = await import("node:readline/promises");
|
|
2741
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2742
|
+
try {
|
|
2743
|
+
const answer = await rl.question(question);
|
|
2744
|
+
return answer;
|
|
2745
|
+
} finally {
|
|
2746
|
+
rl.close();
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// =============================================================================
|
|
2751
|
+
// `pqcheck deploy-check <domain>` — pre-deploy trust gate (habit-loop #5)
|
|
2752
|
+
// =============================================================================
|
|
2753
|
+
// Thin alias for `pqcheck trust-diff` with deploy-friendly framing:
|
|
2754
|
+
// - Default baseline: last-scan (compares vs your most recent scan, which
|
|
2755
|
+
// usually correlates with the previous deploy)
|
|
2756
|
+
// - Default fail-on: high
|
|
2757
|
+
// - Cleaner output for shell-script use in deploy pipelines (Vercel
|
|
2758
|
+
// pre-build, Netlify build commands, custom CD scripts)
|
|
2759
|
+
//
|
|
2760
|
+
// Exit codes match trust-diff: 0 pass · 1 warn · 2 fail · 3 error.
|
|
2761
|
+
// Consumes the same Free 30 Trust Diff calls/month quota.
|
|
2762
|
+
// =============================================================================
|
|
2763
|
+
|
|
2764
|
+
async function runDeployCheckCommand(args) {
|
|
2765
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
2766
|
+
if (positional.length === 0) {
|
|
2767
|
+
console.error(color("red", "error: pqcheck deploy-check requires a domain"));
|
|
2768
|
+
console.error(color("dim", "Usage: npx pqcheck deploy-check <domain> [--baseline last-scan|last-week|<ISO>] [--fail-on high|medium|low|any]"));
|
|
2769
|
+
process.exit(1);
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
// Forward to trust-diff with deploy-tuned defaults if the user didn't specify.
|
|
2773
|
+
const forwarded = [...args];
|
|
2774
|
+
if (!args.includes("--baseline")) forwarded.push("--baseline", "last-scan");
|
|
2775
|
+
if (!args.includes("--fail-on")) forwarded.push("--fail-on", "high");
|
|
2776
|
+
|
|
2777
|
+
// Pre-print a deploy-context header (only in text mode — JSON/SARIF users
|
|
2778
|
+
// are scripting and don't want our preamble polluting their pipe).
|
|
2779
|
+
const format = parseFormat(forwarded);
|
|
2780
|
+
if (format === "text") {
|
|
2781
|
+
console.log("");
|
|
2782
|
+
console.log(` ${color("bold", "🚀 Deploy gate")} ${color("dim", "— checking public trust posture vs last scan")}`);
|
|
2783
|
+
console.log("");
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
return runTrustDiffCommand(forwarded);
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
// =============================================================================
|
|
2790
|
+
// `pqcheck vendors <subcommand>` — vendor lockfile management (habit-loop #10)
|
|
2791
|
+
// =============================================================================
|
|
2792
|
+
// Free tier (Option A, locked 2026-05-16):
|
|
2793
|
+
// pqcheck vendors export <domain> — write cipherwake.vendors.json from
|
|
2794
|
+
// the current observed vendor scripts
|
|
2795
|
+
// (read-only snapshot, no CI enforce)
|
|
2796
|
+
// pqcheck vendors check <domain> — compare current scan to the lockfile,
|
|
2797
|
+
// exit 4 if new origins appeared
|
|
2798
|
+
// (free CI gate via the deps endpoint)
|
|
2799
|
+
//
|
|
2800
|
+
// Starter+ tier:
|
|
2801
|
+
// pqcheck vendors sync <domain> — pull approved-vendor list from
|
|
2802
|
+
// /api/vendor-allowlist and merge into
|
|
2803
|
+
// the lockfile (bidirectional)
|
|
2804
|
+
//
|
|
2805
|
+
// The lockfile is a developer artifact: commit it to the repo to track
|
|
2806
|
+
// vendor-surface drift in PR diffs. Like package-lock.json for third-party
|
|
2807
|
+
// scripts.
|
|
2808
|
+
//
|
|
2809
|
+
// Per [[quantapact-pricing-discipline]]: Free generates the lockfile and uses
|
|
2810
|
+
// it in CI; Starter+ adds the dashboard-sync layer + approved-vendor
|
|
2811
|
+
// enforcement. The dashboard CRUD UI is the Starter wall, not the lockfile.
|
|
2812
|
+
// =============================================================================
|
|
2813
|
+
|
|
2814
|
+
const VENDOR_LOCKFILE_NAME = "cipherwake.vendors.json";
|
|
2815
|
+
|
|
2816
|
+
async function runVendorsCommand(args) {
|
|
2817
|
+
const sub = args[0];
|
|
2818
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
2819
|
+
console.log(`
|
|
2820
|
+
${color("bold", "pqcheck vendors")} ${color("dim", `v${VERSION}`)}
|
|
2821
|
+
|
|
2822
|
+
Vendor lockfile management — track third-party scripts on your domain.
|
|
2823
|
+
|
|
2824
|
+
${color("bold", "Subcommands:")}
|
|
2825
|
+
pqcheck vendors export <domain> Write ${VENDOR_LOCKFILE_NAME} from current observed vendors (Free)
|
|
2826
|
+
pqcheck vendors check <domain> Compare current scan to the lockfile; exit 4 on new origins (Free CI gate)
|
|
2827
|
+
pqcheck vendors sync <domain> Pull approved-vendor list from your account (Starter+, requires CIPHERWAKE_API_KEY)
|
|
2828
|
+
|
|
2829
|
+
${color("bold", "Flags:")}
|
|
2830
|
+
-o <path> Output / input path (default: ./${VENDOR_LOCKFILE_NAME})
|
|
2831
|
+
|
|
2832
|
+
${color("bold", "Examples:")}
|
|
2833
|
+
npx pqcheck vendors export cipherwake.io ${color("dim", "# capture initial state")}
|
|
2834
|
+
npx pqcheck vendors check cipherwake.io ${color("dim", "# CI gate — fails on new origins")}
|
|
2835
|
+
CIPHERWAKE_API_KEY=qpk_... npx pqcheck vendors sync cipherwake.io ${color("dim", "# Starter+ dashboard sync")}
|
|
2836
|
+
|
|
2837
|
+
Methodology: ${color("violet", "https://cipherwake.io/methodology/vendor-lockfile")}
|
|
2838
|
+
`);
|
|
2839
|
+
process.exit(0);
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
const rest = args.slice(1);
|
|
2843
|
+
const positional = rest.filter((a) => !a.startsWith("-"));
|
|
2844
|
+
const raw = positional[0];
|
|
2845
|
+
if (!raw) {
|
|
2846
|
+
console.error(color("red", `error: pqcheck vendors ${sub} requires a domain`));
|
|
2847
|
+
process.exit(1);
|
|
2848
|
+
}
|
|
2849
|
+
const domain = normalizeDomain(raw);
|
|
2850
|
+
if (!isValidDomain(domain)) {
|
|
2851
|
+
console.error(color("red", `error: '${raw}' is not a valid domain`));
|
|
2852
|
+
process.exit(1);
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
const outIdx = rest.indexOf("-o");
|
|
2856
|
+
const outPath = (outIdx >= 0 && rest[outIdx + 1] && !rest[outIdx + 1].startsWith("-"))
|
|
2857
|
+
? rest[outIdx + 1]
|
|
2858
|
+
: VENDOR_LOCKFILE_NAME;
|
|
2859
|
+
|
|
2860
|
+
if (sub === "export") {
|
|
2861
|
+
return runVendorsExport(domain, outPath);
|
|
2862
|
+
}
|
|
2863
|
+
if (sub === "check") {
|
|
2864
|
+
return runVendorsCheck(domain, outPath);
|
|
2865
|
+
}
|
|
2866
|
+
if (sub === "sync") {
|
|
2867
|
+
return runVendorsSync(domain, outPath);
|
|
2868
|
+
}
|
|
2869
|
+
console.error(color("red", `error: unknown subcommand 'vendors ${sub}'. Try: export | check | sync`));
|
|
2870
|
+
process.exit(1);
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
async function fetchVendorOrigins(domain) {
|
|
2874
|
+
// Calls /api/deps which returns the observed third-party origin list for
|
|
2875
|
+
// a domain. Same endpoint that powers `pqcheck deps <domain>`.
|
|
2876
|
+
//
|
|
2877
|
+
// R40 fix (Q2.6): add a 15-second timeout via AbortController. Previously
|
|
2878
|
+
// a hung /api/deps would block the CLI indefinitely — CI runs would
|
|
2879
|
+
// consume their full 6-hour budget waiting for a response that never
|
|
2880
|
+
// comes. 15s is generous enough for the slow tail but bounds the worst
|
|
2881
|
+
// case.
|
|
2882
|
+
const ac = new AbortController();
|
|
2883
|
+
const timer = setTimeout(() => ac.abort(), 15_000);
|
|
2884
|
+
let resp;
|
|
2885
|
+
try {
|
|
2886
|
+
resp = await fetch(`${API_BASE}/api/deps?domain=${encodeURIComponent(domain)}`, {
|
|
2887
|
+
method: "GET",
|
|
2888
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (vendors)` }),
|
|
2889
|
+
signal: ac.signal,
|
|
2890
|
+
});
|
|
2891
|
+
} catch (err) {
|
|
2892
|
+
if (err?.name === "AbortError") throw new Error("/api/deps timed out after 15s");
|
|
2893
|
+
throw err;
|
|
2894
|
+
} finally {
|
|
2895
|
+
clearTimeout(timer);
|
|
2896
|
+
}
|
|
2897
|
+
if (!resp.ok) {
|
|
2898
|
+
const body = await safeJSON(resp);
|
|
2899
|
+
throw new Error(`/api/deps returned ${resp.status} ${body?.error || resp.statusText}`);
|
|
2900
|
+
}
|
|
2901
|
+
const data = await resp.json();
|
|
2902
|
+
const thirds = Array.isArray(data.thirdParties) ? data.thirdParties : [];
|
|
2903
|
+
// R40 fix (Q2.7): preserve the observed protocol. Previously we
|
|
2904
|
+
// force-converted http://vendor.com into https://vendor.com which
|
|
2905
|
+
// mis-represented what we actually observed. Now: keep http:// when
|
|
2906
|
+
// the API gives an explicit http:// origin; default to https:// only
|
|
2907
|
+
// when the host comes without a protocol prefix.
|
|
2908
|
+
const origins = new Set();
|
|
2909
|
+
for (const t of thirds) {
|
|
2910
|
+
const host = typeof t === "string" ? t : (t.host ?? t.origin ?? "");
|
|
2911
|
+
if (!host) continue;
|
|
2912
|
+
const o = normalizeObservedOrigin(host);
|
|
2913
|
+
if (o) origins.add(o);
|
|
2914
|
+
}
|
|
2915
|
+
return Array.from(origins).sort();
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// R40 fix (Q2.7): preserve observed protocol. URL parser handles host
|
|
2919
|
+
// validation + canonicalization (lowercase, default-port stripping).
|
|
2920
|
+
function normalizeObservedOrigin(value) {
|
|
2921
|
+
const s = String(value).trim().toLowerCase();
|
|
2922
|
+
if (!s) return null;
|
|
2923
|
+
try {
|
|
2924
|
+
const u = s.startsWith("http://") || s.startsWith("https://")
|
|
2925
|
+
? new URL(s)
|
|
2926
|
+
: new URL("https://" + s);
|
|
2927
|
+
return u.origin;
|
|
2928
|
+
} catch {
|
|
2929
|
+
return null;
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
function buildVendorLockfile(domain, origins) {
|
|
2934
|
+
return {
|
|
2935
|
+
schema_version: 1,
|
|
2936
|
+
generator: `pqcheck-cli/${VERSION}`,
|
|
2937
|
+
domain,
|
|
2938
|
+
generated_at: new Date().toISOString(),
|
|
2939
|
+
approved_script_origins: origins,
|
|
2940
|
+
// Soft tier marker — read by sync to know if the lockfile carries
|
|
2941
|
+
// dashboard-managed entries. Free-only lockfiles set this to null.
|
|
2942
|
+
synced_from_account: null,
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
async function runVendorsExport(domain, outPath) {
|
|
2947
|
+
const fs = await import("node:fs/promises");
|
|
2948
|
+
console.log("");
|
|
2949
|
+
console.log(` ${color("bold", "Exporting vendor lockfile")} ${color("dim", `— ${domain}`)}`);
|
|
2950
|
+
console.log("");
|
|
2951
|
+
let origins;
|
|
2952
|
+
try {
|
|
2953
|
+
origins = await fetchVendorOrigins(domain);
|
|
2954
|
+
} catch (err) {
|
|
2955
|
+
console.error(color("red", ` error fetching vendor origins: ${err.message}`));
|
|
2956
|
+
process.exit(1);
|
|
2957
|
+
}
|
|
2958
|
+
const lockfile = buildVendorLockfile(domain, origins);
|
|
2959
|
+
try {
|
|
2960
|
+
await fs.writeFile(outPath, JSON.stringify(lockfile, null, 2) + "\n", "utf8");
|
|
2961
|
+
} catch (err) {
|
|
2962
|
+
console.error(color("red", ` error writing ${outPath}: ${err.message}`));
|
|
2963
|
+
process.exit(1);
|
|
2964
|
+
}
|
|
2965
|
+
console.log(color("green", ` ✓ Wrote ${outPath} with ${origins.length} approved script origin${origins.length === 1 ? "" : "s"}.`));
|
|
2966
|
+
console.log("");
|
|
2967
|
+
console.log(` ${color("dim", "Commit this file to your repo to track vendor-surface drift in PR diffs.")}`);
|
|
2968
|
+
// R40 fix (Q2.12): nested template literal — the outer backticks are the
|
|
2969
|
+
// template literal; the inner string passed to color() must ALSO be a
|
|
2970
|
+
// template literal so ${domain} interpolates. Previously this printed
|
|
2971
|
+
// the literal text "${domain}" because color()'s arg was a plain string.
|
|
2972
|
+
console.log(` ${color("dim", `Run \`pqcheck vendors check ${domain}\` in CI to fail PRs that introduce new origins.`)}`);
|
|
2973
|
+
console.log("");
|
|
2974
|
+
process.exit(0);
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
async function runVendorsCheck(domain, lockPath) {
|
|
2978
|
+
const fs = await import("node:fs/promises");
|
|
2979
|
+
let lockfile;
|
|
2980
|
+
try {
|
|
2981
|
+
const raw = await fs.readFile(lockPath, "utf8");
|
|
2982
|
+
lockfile = JSON.parse(raw);
|
|
2983
|
+
} catch (err) {
|
|
2984
|
+
console.error(color("red", ` error reading ${lockPath}: ${err.message}`));
|
|
2985
|
+
console.error(color("dim", ` Run: npx pqcheck vendors export ${domain} to generate one.`));
|
|
2986
|
+
process.exit(1);
|
|
2987
|
+
}
|
|
2988
|
+
if (lockfile.schema_version !== 1) {
|
|
2989
|
+
console.error(color("red", ` error: ${lockPath} schema_version=${lockfile.schema_version}, expected 1`));
|
|
2990
|
+
process.exit(1);
|
|
2991
|
+
}
|
|
2992
|
+
if (lockfile.domain && lockfile.domain !== domain) {
|
|
2993
|
+
console.error(color("yellow", ` warning: lockfile is for ${lockfile.domain} but checking against ${domain}`));
|
|
2994
|
+
}
|
|
2995
|
+
const baseline = new Set(Array.isArray(lockfile.approved_script_origins) ? lockfile.approved_script_origins : []);
|
|
2996
|
+
let observed;
|
|
2997
|
+
try {
|
|
2998
|
+
observed = new Set(await fetchVendorOrigins(domain));
|
|
2999
|
+
} catch (err) {
|
|
3000
|
+
console.error(color("red", ` error fetching current vendors: ${err.message}`));
|
|
3001
|
+
process.exit(1);
|
|
3002
|
+
}
|
|
3003
|
+
const newOrigins = [...observed].filter((o) => !baseline.has(o));
|
|
3004
|
+
const removed = [...baseline].filter((o) => !observed.has(o));
|
|
3005
|
+
|
|
3006
|
+
console.log("");
|
|
3007
|
+
console.log(` ${color("bold", "Vendor lockfile check")} ${color("dim", `— ${domain}`)}`);
|
|
3008
|
+
console.log("");
|
|
3009
|
+
if (newOrigins.length === 0 && removed.length === 0) {
|
|
3010
|
+
console.log(color("green", ` ✓ Vendor surface matches lockfile (${baseline.size} origins).`));
|
|
3011
|
+
console.log("");
|
|
3012
|
+
process.exit(0);
|
|
3013
|
+
}
|
|
3014
|
+
if (newOrigins.length > 0) {
|
|
3015
|
+
console.log(color("red", ` ⚠ ${newOrigins.length} new origin${newOrigins.length === 1 ? "" : "s"} observed (not in lockfile):`));
|
|
3016
|
+
for (const o of newOrigins) console.log(` + ${o}`);
|
|
3017
|
+
console.log("");
|
|
3018
|
+
}
|
|
3019
|
+
if (removed.length > 0) {
|
|
3020
|
+
console.log(color("dim", ` - ${removed.length} origin${removed.length === 1 ? "" : "s"} no longer observed:`));
|
|
3021
|
+
for (const o of removed) console.log(` - ${o}`);
|
|
3022
|
+
console.log("");
|
|
3023
|
+
}
|
|
3024
|
+
if (newOrigins.length > 0) {
|
|
3025
|
+
console.log(color("dim", ` To accept the additions, re-run: npx pqcheck vendors export ${domain}`));
|
|
3026
|
+
console.log(color("dim", ` Then commit the updated ${lockPath} to your repo.`));
|
|
3027
|
+
console.log("");
|
|
3028
|
+
process.exit(4); // New origin(s) detected — same exit code as `deps --fail-on-new`
|
|
3029
|
+
}
|
|
3030
|
+
// Only removals (cleanup), no failure
|
|
3031
|
+
process.exit(0);
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
async function runVendorsSync(domain, outPath) {
|
|
3035
|
+
const fs = await import("node:fs/promises");
|
|
3036
|
+
if (!QP_API_KEY) {
|
|
3037
|
+
console.error(color("red", " error: `vendors sync` requires CIPHERWAKE_API_KEY (Starter+ feature)"));
|
|
3038
|
+
console.error("");
|
|
3039
|
+
console.error(color("dim", " Free tier: use `vendors export` to generate a read-only lockfile."));
|
|
3040
|
+
console.error(color("dim", " Sign up + manage approved vendors at: " + API_BASE + "/account"));
|
|
3041
|
+
console.error(color("dim", " Pricing: " + API_BASE + "/pricing"));
|
|
3042
|
+
process.exit(1);
|
|
3043
|
+
}
|
|
3044
|
+
console.log("");
|
|
3045
|
+
console.log(` ${color("bold", "Syncing vendor lockfile with your account")} ${color("dim", `— ${domain}`)}`);
|
|
3046
|
+
console.log("");
|
|
3047
|
+
let resp;
|
|
3048
|
+
try {
|
|
3049
|
+
resp = await fetch(`${API_BASE}/api/vendor-allowlist?domain=${encodeURIComponent(domain)}`, {
|
|
3050
|
+
method: "GET",
|
|
3051
|
+
headers: {
|
|
3052
|
+
"user-agent": `pqcheck-cli/${VERSION} (vendors-sync)`,
|
|
3053
|
+
"authorization": "Bearer " + QP_API_KEY,
|
|
3054
|
+
},
|
|
3055
|
+
});
|
|
3056
|
+
} catch (err) {
|
|
3057
|
+
console.error(color("red", ` network error: ${err.message ?? err}`));
|
|
3058
|
+
process.exit(1);
|
|
3059
|
+
}
|
|
3060
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
3061
|
+
const body = await safeJSON(resp);
|
|
3062
|
+
console.error(color("red", ` authentication failed (HTTP ${resp.status})`));
|
|
3063
|
+
if (body?.error === "starter_required") {
|
|
3064
|
+
console.error(color("dim", ` Approved-vendor allowlist starts at Starter ($29/mo). ${body.message ?? ""}`));
|
|
3065
|
+
console.error(color("dim", ` ${API_BASE}/pricing?utm_source=cli_vendors_sync`));
|
|
3066
|
+
}
|
|
3067
|
+
process.exit(1);
|
|
3068
|
+
}
|
|
3069
|
+
if (!resp.ok) {
|
|
3070
|
+
console.error(color("red", ` /api/vendor-allowlist returned ${resp.status}`));
|
|
3071
|
+
process.exit(1);
|
|
3072
|
+
}
|
|
3073
|
+
const data = await resp.json();
|
|
3074
|
+
const allowlist = Array.isArray(data.allowlist) ? data.allowlist : [];
|
|
3075
|
+
// Filter to entries for this domain + extract vendor_origin
|
|
3076
|
+
const dashboardOrigins = new Set();
|
|
3077
|
+
for (const item of allowlist) {
|
|
3078
|
+
if (item && item.domain === domain && typeof item.vendor_origin === "string") {
|
|
3079
|
+
dashboardOrigins.add(item.vendor_origin);
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
// Merge with currently observed (so the lockfile covers everything we see + everything the user approved)
|
|
3084
|
+
let observed = new Set();
|
|
3085
|
+
try {
|
|
3086
|
+
observed = new Set(await fetchVendorOrigins(domain));
|
|
3087
|
+
} catch (err) {
|
|
3088
|
+
console.error(color("yellow", ` warning: could not fetch currently observed origins (${err.message}); using dashboard-only list`));
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
const merged = new Set([...observed, ...dashboardOrigins]);
|
|
3092
|
+
const origins = Array.from(merged).sort();
|
|
3093
|
+
const lockfile = buildVendorLockfile(domain, origins);
|
|
3094
|
+
lockfile.synced_from_account = new Date().toISOString();
|
|
3095
|
+
|
|
3096
|
+
try {
|
|
3097
|
+
await fs.writeFile(outPath, JSON.stringify(lockfile, null, 2) + "\n", "utf8");
|
|
3098
|
+
} catch (err) {
|
|
3099
|
+
console.error(color("red", ` error writing ${outPath}: ${err.message}`));
|
|
3100
|
+
process.exit(1);
|
|
3101
|
+
}
|
|
3102
|
+
console.log(color("green", ` ✓ Synced ${outPath} — ${dashboardOrigins.size} dashboard-approved, ${observed.size} currently observed, ${origins.length} total.`));
|
|
3103
|
+
console.log("");
|
|
3104
|
+
console.log(` ${color("dim", "Commit the updated lockfile to your repo. `pqcheck vendors check` in CI will fail PRs that")}`);
|
|
3105
|
+
console.log(` ${color("dim", "introduce origins outside the merged set.")}`);
|
|
3106
|
+
console.log("");
|
|
3107
|
+
process.exit(0);
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
// =============================================================================
|
|
3111
|
+
// `pqcheck onboard <domain>` — one-command setup wizard (locked 2026-05-16)
|
|
3112
|
+
// =============================================================================
|
|
3113
|
+
// Composes existing CLI subcommands into one happy-path flow:
|
|
3114
|
+
// 1. Quick public scan → show current grade so the user sees value first
|
|
3115
|
+
// 2. Scaffold the GitHub Action workflow (via runInitCommand)
|
|
3116
|
+
// 3. Generate a vendor lockfile snapshot (via runVendorsExport)
|
|
3117
|
+
// 4. Generate a release-checklist markdown
|
|
3118
|
+
// 5. Open the user's browser to /account#api-keys for API-key generation
|
|
3119
|
+
// 6. Print next-steps (secret name + commit commands + PR open)
|
|
3120
|
+
//
|
|
3121
|
+
// Design notes:
|
|
3122
|
+
// - Pure composition of already-reviewed subcommands; no new server endpoints.
|
|
3123
|
+
// - Browser-open uses platform default (`open`/`xdg-open`/`start`). When
|
|
3124
|
+
// headless or sandboxed, the URL is still printed so the user can copy.
|
|
3125
|
+
// - Each step is best-effort: a failed step prints a warning and continues
|
|
3126
|
+
// so a partial setup is still useful. Hard errors only stop early steps
|
|
3127
|
+
// where the rest can't proceed (invalid domain).
|
|
3128
|
+
// - --skip-scan / --skip-vendors / --skip-checklist let power users opt out.
|
|
3129
|
+
// - --no-open suppresses the browser launch (CI / SSH / headless friendly).
|
|
3130
|
+
// =============================================================================
|
|
3131
|
+
|
|
3132
|
+
async function runOnboardCommand(args) {
|
|
3133
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
3134
|
+
const raw = positional[0];
|
|
3135
|
+
if (!raw) {
|
|
3136
|
+
console.error(color("red", "error: pqcheck onboard requires a domain"));
|
|
3137
|
+
console.error(color("dim", "Usage: npx pqcheck onboard <domain> [--skip-scan] [--skip-vendors] [--skip-checklist] [--no-open] [--force] [--strict]"));
|
|
3138
|
+
process.exit(1);
|
|
3139
|
+
}
|
|
3140
|
+
const domain = normalizeDomain(raw);
|
|
3141
|
+
if (!isValidDomain(domain)) {
|
|
3142
|
+
console.error(color("red", `error: '${raw}' is not a valid domain`));
|
|
3143
|
+
process.exit(1);
|
|
3144
|
+
}
|
|
3145
|
+
const skipScan = args.includes("--skip-scan");
|
|
3146
|
+
const skipVendors = args.includes("--skip-vendors");
|
|
3147
|
+
const skipChecklist = args.includes("--skip-checklist");
|
|
3148
|
+
const noOpen = args.includes("--no-open");
|
|
3149
|
+
// R41 fix #1: --force lets users intentionally overwrite an existing
|
|
3150
|
+
// setup (idempotent re-runs). Without it, we abort if any of the
|
|
3151
|
+
// 3 output files already exists, so a user re-running onboard by
|
|
3152
|
+
// mistake doesn't lose hand-edited CIPHERWAKE_CHECKLIST.md / vendors
|
|
3153
|
+
// lockfile / workflow YAML.
|
|
3154
|
+
const force = args.includes("--force");
|
|
3155
|
+
// R41 fix #4: --strict makes onboard exit non-zero if any step fails.
|
|
3156
|
+
// For human-driven first-time setup, exit 0 (best-effort) is the right
|
|
3157
|
+
// default — printed warnings tell the user what to retry. For CI
|
|
3158
|
+
// automation around the wizard itself, --strict lets a build fail on
|
|
3159
|
+
// step errors. (Recommended usage in CI is still the individual
|
|
3160
|
+
// subcommands, not onboard.)
|
|
3161
|
+
const strict = args.includes("--strict");
|
|
3162
|
+
|
|
3163
|
+
// R41 fix #1: pre-flight overwrite check. We probe the 3 output paths
|
|
3164
|
+
// BEFORE running any step so an aborted run doesn't half-modify the
|
|
3165
|
+
// user's project. We use sync stat checks because we're not in a hot
|
|
3166
|
+
// path and the readability win is worth the tiny perf cost.
|
|
3167
|
+
if (!force) {
|
|
3168
|
+
const fsSync = await import("node:fs");
|
|
3169
|
+
const existing = [];
|
|
3170
|
+
try { fsSync.statSync(".github/workflows/cipherwake.yml"); existing.push(".github/workflows/cipherwake.yml"); } catch {}
|
|
3171
|
+
if (!skipVendors) {
|
|
3172
|
+
try { fsSync.statSync("cipherwake.vendors.json"); existing.push("cipherwake.vendors.json"); } catch {}
|
|
3173
|
+
}
|
|
3174
|
+
if (!skipChecklist) {
|
|
3175
|
+
try { fsSync.statSync("CIPHERWAKE_CHECKLIST.md"); existing.push("CIPHERWAKE_CHECKLIST.md"); } catch {}
|
|
3176
|
+
}
|
|
3177
|
+
if (existing.length > 0) {
|
|
3178
|
+
console.error("");
|
|
3179
|
+
console.error(color("red", ` error: refusing to overwrite existing files:`));
|
|
3180
|
+
for (const f of existing) console.error(color("dim", ` ${f}`));
|
|
3181
|
+
console.error("");
|
|
3182
|
+
console.error(color("dim", ` Re-run with --force to overwrite, or delete the files manually.`));
|
|
3183
|
+
console.error(color("dim", ` (--skip-vendors / --skip-checklist also bypass individual file checks.)`));
|
|
3184
|
+
process.exit(1);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
// R41 fix #4: track step failures for --strict mode
|
|
3189
|
+
let anyStepFailed = false;
|
|
3190
|
+
|
|
3191
|
+
console.log("");
|
|
3192
|
+
console.log(` ${color("bold", "🚀 Cipherwake onboarding")} ${color("dim", `— ${domain}`)}`);
|
|
3193
|
+
console.log("");
|
|
3194
|
+
console.log(` ${color("dim", "This will write ~3 files to your project and open your browser to grab an API key.")}`);
|
|
3195
|
+
console.log(` ${color("dim", "All steps are best-effort; you can re-run any individual subcommand later.")}`);
|
|
3196
|
+
console.log("");
|
|
3197
|
+
|
|
3198
|
+
// -------------------------------------------------------------------------
|
|
3199
|
+
// STEP 1 — quick scan (value-first; user sees their grade before any setup)
|
|
3200
|
+
// -------------------------------------------------------------------------
|
|
3201
|
+
if (!skipScan) {
|
|
3202
|
+
console.log(color("violet", ` ▸ Step 1 / 4 — scanning ${domain}…`));
|
|
3203
|
+
try {
|
|
3204
|
+
const resp = await fetch(`${API_BASE}/api/scan?domain=${encodeURIComponent(domain)}&source=onboard`, {
|
|
3205
|
+
method: "GET",
|
|
3206
|
+
headers: apiHeaders({ "user-agent": `pqcheck-cli/${VERSION} (onboard)` }),
|
|
3207
|
+
});
|
|
3208
|
+
if (resp.ok) {
|
|
3209
|
+
const report = await resp.json();
|
|
3210
|
+
const score = typeof report.score === "number" ? report.score.toFixed(1) : "?";
|
|
3211
|
+
const grade = report.grade || "?";
|
|
3212
|
+
const label = report.scoreLabel || "—";
|
|
3213
|
+
console.log(` ${color("bold", "Current grade:")} ${color("violet", grade)} (${score}/10 · ${label})`);
|
|
3214
|
+
console.log(` ${color("dim", `Full report: ${API_BASE}/r/${encodeURIComponent(domain)}`)}`);
|
|
3215
|
+
} else {
|
|
3216
|
+
console.log(color("yellow", ` skipped (scan returned HTTP ${resp.status})`));
|
|
3217
|
+
anyStepFailed = true;
|
|
3218
|
+
}
|
|
3219
|
+
} catch (err) {
|
|
3220
|
+
console.log(color("yellow", ` skipped (${err?.message ?? "scan failed"})`));
|
|
3221
|
+
anyStepFailed = true;
|
|
3222
|
+
}
|
|
3223
|
+
console.log("");
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
// -------------------------------------------------------------------------
|
|
3227
|
+
// STEP 2 — workflow scaffold
|
|
3228
|
+
// -------------------------------------------------------------------------
|
|
3229
|
+
console.log(color("violet", ` ▸ Step 2 / 4 — scaffolding GitHub Action workflow…`));
|
|
3230
|
+
try {
|
|
3231
|
+
// Call runInitCommand non-interactively. The function process.exit()'s on
|
|
3232
|
+
// its own; to compose it here we'd have to refactor. Pragmatic approach:
|
|
3233
|
+
// spawn a child node invoking ourselves with `init --yes --domain ...`.
|
|
3234
|
+
// That keeps each step idempotent and isolated.
|
|
3235
|
+
const { spawn } = await import("node:child_process");
|
|
3236
|
+
const result = await new Promise((resolve) => {
|
|
3237
|
+
const p = spawn(process.execPath, [
|
|
3238
|
+
process.argv[1],
|
|
3239
|
+
"init",
|
|
3240
|
+
"--yes",
|
|
3241
|
+
"--domain", domain,
|
|
3242
|
+
"--force",
|
|
3243
|
+
], { stdio: "inherit" });
|
|
3244
|
+
p.on("exit", (code) => resolve(code ?? 0));
|
|
3245
|
+
p.on("error", () => resolve(1));
|
|
3246
|
+
});
|
|
3247
|
+
if (result !== 0) {
|
|
3248
|
+
console.log(color("yellow", ` init exited ${result} — you can re-run \`pqcheck init\` later`));
|
|
3249
|
+
anyStepFailed = true;
|
|
3250
|
+
}
|
|
3251
|
+
} catch (err) {
|
|
3252
|
+
console.log(color("yellow", ` skipped init (${err?.message ?? "subprocess failed"})`));
|
|
3253
|
+
anyStepFailed = true;
|
|
3254
|
+
}
|
|
3255
|
+
console.log("");
|
|
3256
|
+
|
|
3257
|
+
// -------------------------------------------------------------------------
|
|
3258
|
+
// STEP 3 — vendor lockfile (skipped if --skip-vendors)
|
|
3259
|
+
// -------------------------------------------------------------------------
|
|
3260
|
+
if (!skipVendors) {
|
|
3261
|
+
console.log(color("violet", ` ▸ Step 3 / 4 — capturing vendor lockfile…`));
|
|
3262
|
+
try {
|
|
3263
|
+
const { spawn } = await import("node:child_process");
|
|
3264
|
+
const result = await new Promise((resolve) => {
|
|
3265
|
+
const p = spawn(process.execPath, [
|
|
3266
|
+
process.argv[1],
|
|
3267
|
+
"vendors",
|
|
3268
|
+
"export",
|
|
3269
|
+
domain,
|
|
3270
|
+
], { stdio: "inherit" });
|
|
3271
|
+
p.on("exit", (code) => resolve(code ?? 0));
|
|
3272
|
+
p.on("error", () => resolve(1));
|
|
3273
|
+
});
|
|
3274
|
+
if (result !== 0) {
|
|
3275
|
+
console.log(color("yellow", ` vendors export exited ${result} — you can re-run \`pqcheck vendors export ${domain}\` later`));
|
|
3276
|
+
anyStepFailed = true;
|
|
3277
|
+
}
|
|
3278
|
+
} catch (err) {
|
|
3279
|
+
console.log(color("yellow", ` skipped vendors export (${err?.message ?? "subprocess failed"})`));
|
|
3280
|
+
anyStepFailed = true;
|
|
3281
|
+
}
|
|
3282
|
+
console.log("");
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
// -------------------------------------------------------------------------
|
|
3286
|
+
// STEP 4 — release checklist (skipped if --skip-checklist)
|
|
3287
|
+
// -------------------------------------------------------------------------
|
|
3288
|
+
if (!skipChecklist) {
|
|
3289
|
+
console.log(color("violet", ` ▸ Step 4 / 4 — writing release checklist…`));
|
|
3290
|
+
try {
|
|
3291
|
+
const fs = await import("node:fs/promises");
|
|
3292
|
+
const checklist = buildReleaseChecklistMarkdown(domain);
|
|
3293
|
+
await fs.writeFile("CIPHERWAKE_CHECKLIST.md", checklist, "utf8");
|
|
3294
|
+
console.log(` ${color("green", "✓ Wrote CIPHERWAKE_CHECKLIST.md")}`);
|
|
3295
|
+
} catch (err) {
|
|
3296
|
+
console.log(color("yellow", ` skipped checklist (${err?.message ?? "write failed"})`));
|
|
3297
|
+
anyStepFailed = true;
|
|
3298
|
+
}
|
|
3299
|
+
console.log("");
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
// -------------------------------------------------------------------------
|
|
3303
|
+
// Browser open + final next-steps
|
|
3304
|
+
// -------------------------------------------------------------------------
|
|
3305
|
+
// Query MUST come before fragment per RFC 3986. The previous order
|
|
3306
|
+
// `#api-keys?utm_source=onboard` made utm_source part of the fragment
|
|
3307
|
+
// (which never reaches the server), so attribution analytics never fired.
|
|
3308
|
+
const apiKeyUrl = `${API_BASE}/account?utm_source=onboard#api-keys`;
|
|
3309
|
+
console.log(color("bold", " ✓ Setup files written. Three steps remain:"));
|
|
3310
|
+
console.log("");
|
|
3311
|
+
console.log(` ${color("dim", "1.")} ${color("bold", "Get a free API key")} (30 Trust Diff calls/month)`);
|
|
3312
|
+
console.log(` ${color("violet", apiKeyUrl)}`);
|
|
3313
|
+
if (!noOpen) {
|
|
3314
|
+
const opened = await tryOpenBrowser(apiKeyUrl);
|
|
3315
|
+
if (opened) {
|
|
3316
|
+
console.log(` ${color("dim", "(opened in your browser — sign in / sign up there)")}`);
|
|
3317
|
+
} else {
|
|
3318
|
+
console.log(` ${color("dim", "(copy the URL above; --no-open suppresses this hint)")}`);
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
console.log("");
|
|
3322
|
+
console.log(` ${color("dim", "2.")} ${color("bold", "Add the key as a GitHub repo secret")}`);
|
|
3323
|
+
console.log(` ${color("dim", "GitHub → Settings → Secrets and variables → Actions → New repository secret")}`);
|
|
3324
|
+
console.log(` ${color("dim", "Name: CIPHERWAKE_API_KEY Value: qpk_... (from step 1)")}`);
|
|
3325
|
+
console.log("");
|
|
3326
|
+
console.log(` ${color("dim", "3.")} ${color("bold", "Commit + push")}`);
|
|
3327
|
+
// R41 Q1.15 (locked 2026-05-16): build the git-add file list as an array
|
|
3328
|
+
// and join, so we don't print trailing-space args when --skip flags are
|
|
3329
|
+
// used. Harmless bash semantics either way; cleaner output.
|
|
3330
|
+
const filesToAdd = [".github/workflows/cipherwake.yml"];
|
|
3331
|
+
if (!skipVendors) filesToAdd.push("cipherwake.vendors.json");
|
|
3332
|
+
if (!skipChecklist) filesToAdd.push("CIPHERWAKE_CHECKLIST.md");
|
|
3333
|
+
console.log(` ${color("dim", "$")} git add ${filesToAdd.join(" ")}`);
|
|
3334
|
+
console.log(` ${color("dim", "$")} git commit -m "ci: add Cipherwake Trust Diff gate"`);
|
|
3335
|
+
console.log(` ${color("dim", "$")} git push`);
|
|
3336
|
+
console.log("");
|
|
3337
|
+
console.log(` ${color("dim", "Open a PR after pushing and Cipherwake will comment inline within ~60s of the workflow firing.")}`);
|
|
3338
|
+
console.log("");
|
|
3339
|
+
// R41 fix #4: --strict makes onboard exit non-zero if any step failed.
|
|
3340
|
+
// Default (best-effort) exit 0 keeps the wizard friendly for first-time
|
|
3341
|
+
// human setup — the visible yellow warnings tell them what to retry.
|
|
3342
|
+
if (strict && anyStepFailed) {
|
|
3343
|
+
console.log(color("dim", " (--strict: one or more steps failed; exiting non-zero)"));
|
|
3344
|
+
process.exit(1);
|
|
3345
|
+
}
|
|
3346
|
+
process.exit(0);
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
// R41 fix #3: buildReleaseChecklistMarkdown is now a thin alias to the shared
|
|
3350
|
+
// renderReleaseChecklist() helper defined alongside runReleaseChecklistCommand.
|
|
3351
|
+
// Single source of truth — when either subcommand's content changes, both
|
|
3352
|
+
// callers update automatically.
|
|
3353
|
+
function buildReleaseChecklistMarkdown(domain) {
|
|
3354
|
+
return renderReleaseChecklist(domain, { generator: "onboard" });
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
// Cross-platform browser launcher. Returns true if a launcher binary
|
|
3358
|
+
// dispatched successfully; false if no launcher is available (e.g. headless
|
|
3359
|
+
// server, sandboxed CI, broken xdg-open config).
|
|
3360
|
+
//
|
|
3361
|
+
// R41 fix #2 (locked 2026-05-16): use exit-event detection + longer timeout
|
|
3362
|
+
// so we don't falsely claim "(opened in your browser)" when xdg-open is
|
|
3363
|
+
// installed but the launcher exits non-zero (no graphical session, no
|
|
3364
|
+
// MIME handler). Previously a flat 200ms timeout resolved true even when
|
|
3365
|
+
// the launcher exited 3 because no display was available.
|
|
3366
|
+
async function tryOpenBrowser(url) {
|
|
3367
|
+
if (process.env.CI || process.env.CIPHERWAKE_NO_BROWSER) return false;
|
|
3368
|
+
const { spawn } = await import("node:child_process");
|
|
3369
|
+
const platform = process.platform;
|
|
3370
|
+
let cmd, cmdArgs;
|
|
3371
|
+
if (platform === "darwin") {
|
|
3372
|
+
cmd = "open"; cmdArgs = [url];
|
|
3373
|
+
} else if (platform === "win32") {
|
|
3374
|
+
cmd = "cmd"; cmdArgs = ["/c", "start", "", url];
|
|
3375
|
+
} else {
|
|
3376
|
+
cmd = "xdg-open"; cmdArgs = [url];
|
|
3377
|
+
}
|
|
3378
|
+
return await new Promise((resolve) => {
|
|
3379
|
+
let settled = false;
|
|
3380
|
+
let p;
|
|
3381
|
+
try {
|
|
3382
|
+
p = spawn(cmd, cmdArgs, { stdio: "ignore", detached: true });
|
|
3383
|
+
} catch {
|
|
3384
|
+
resolve(false);
|
|
3385
|
+
return;
|
|
3386
|
+
}
|
|
3387
|
+
p.on("error", () => { if (!settled) { settled = true; resolve(false); } });
|
|
3388
|
+
p.on("exit", (code) => { if (!settled) { settled = true; resolve(code === 0); } });
|
|
3389
|
+
p.unref();
|
|
3390
|
+
// Belt-and-suspenders: if the launcher takes >1s to exit AND no error
|
|
3391
|
+
// event has fired, assume it dispatched and went detached (open on
|
|
3392
|
+
// macOS does this — returns after AppleScript-asking Finder/Safari).
|
|
3393
|
+
setTimeout(() => {
|
|
3394
|
+
if (!settled) { settled = true; resolve(true); }
|
|
3395
|
+
}, 1000);
|
|
3396
|
+
});
|
|
3397
|
+
}
|
|
3398
|
+
|
|
1796
3399
|
main().catch((err) => {
|
|
1797
3400
|
console.error(color("red", `fatal: ${err.message}`));
|
|
1798
3401
|
process.exit(2);
|