pqcheck 0.15.1 → 0.15.3

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 CHANGED
@@ -1,27 +1,54 @@
1
1
  # pqcheck
2
2
 
3
- > **Decryption Blast Radius scanner** find out how much of your data unlocks when quantum decryption arrives.
3
+ > **A deploy gate for AI coding agents.** Tell Claude Code / Cursor / Copilot whether the site you just deployed is safe to announce — or whether your AI coworker should pause and ask you first.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/pqcheck.svg?style=flat-square&color=06b6d4)](https://www.npmjs.com/package/pqcheck)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/pqcheck.svg?style=flat-square&color=06b6d4)](https://www.npmjs.com/package/pqcheck)
7
7
  [![license](https://img.shields.io/npm/l/pqcheck.svg?style=flat-square&color=06b6d4)](./LICENSE)
8
8
 
9
9
  ```bash
10
- npx pqcheck stripe.com
10
+ npx pqcheck deploy-check yourdomain.com --ai
11
+ ```
12
+
11
13
  ```
14
+ ◆ Cipherwake · yourdomain.com ⚠ REVIEW · DBR 4.1 C · HIGH
15
+
16
+ Top finding:
17
+ [HIGH] ECDHE-only — quantum-vulnerable key exchange
18
+
19
+ CIPHERWAKE_AI_GUARD_RESULT
20
+ ship_decision=review
21
+ top_issue=tls.ecdhe_only_quantum_vulnerable
22
+ END_CIPHERWAKE_AI_GUARD_RESULT
23
+ ```
24
+
25
+ The last block — `CIPHERWAKE_AI_GUARD_RESULT` — is what your AI coding agent parses. It contains a single field, `ship_decision=pass|review|block`, that tells the agent whether to:
26
+
27
+ - **pass** — go ahead and announce the deploy is shipped
28
+ - **review** — stop and ask you what to do (your HTTPS posture drifted vs. last scan)
29
+ - **block** — refuse to announce until you investigate (something critical changed)
30
+
31
+ Zero install. Works in any terminal with Node 18+. Free, no signup, no API key required for first deploys.
32
+
33
+ ## What pqcheck actually checks
34
+
35
+ Each `pqcheck <domain>` scans your site's public HTTPS surface and produces:
12
36
 
13
- Zero install. Works in any terminal with Node 18+. Free, no signup, no API key.
37
+ - a **DBR score** (Decryption Blast Radius, 0–10) how much of today's traffic would be readable to an attacker who's storing it now and waiting for quantum computers to break today's encryption (the "harvest-now, decrypt-later" risk, typically 5–10 years out per NIST timelines)
38
+ - a **letter grade** (A–F)
39
+ - a **findings list** ranked by severity — TLS cipher suites, certificate quality, security headers (CSP / HSTS / etc.), third-party scripts loaded by the page, key reuse across subdomains, and more
14
40
 
15
- The same scanner that powers [cipherwake.io](https://cipherwake.io), the browser extension, and the GitHub Action.
41
+ You get the same scanner that powers [cipherwake.io](https://cipherwake.io), the browser extension, and the GitHub Action.
16
42
 
17
43
  ---
18
44
 
19
- ## What it does
45
+ ## Commands at a glance
20
46
 
21
47
  | Command | What it gives you |
22
48
  |---|---|
23
- | `npx pqcheck <domain>` | One-shot DBR scan + grade. The original surface. |
24
- | `npx pqcheck trust-diff <domain>` | Compare today's public trust posture vs a baseline (last-week, last-month, or a saved CI baseline). For CI gates + release checklists. |
49
+ | `npx pqcheck <domain>` | The basic scan. Returns DBR score (0–10), letter grade (A–F), and a list of findings ranked by severity. The fastest way to ask "is my site's HTTPS posture healthy today?" |
50
+ | `npx pqcheck deploy-check <domain> --ai` | The flagship command. Wraps the scan with `ship_decision=pass\|review\|block` for your AI coding agent to gate the deploy announcement. Works anonymously on first use; subsequent runs compare against the previous scan. |
51
+ | `npx pqcheck trust-diff <domain>` | Compare today's HTTPS surface against a saved baseline (last week / last month / a saved CI baseline). For CI gates and release checklists. |
25
52
  | `npx pqcheck preview-diff --preview <URL> --production <URL>` | Compare a Vercel/Netlify preview deployment URL to production. Surfaces new third-party scripts, header regressions, and DBR score drops *inside the PR*, before merge. |
26
53
  | `npx pqcheck vendors export/check/sync <domain>` | Vendor lockfile (`cipherwake.vendors.json`) + CI gate that exits non-zero when a new third-party origin appears. Like `package-lock.json` for vendor scripts. |
27
54
  | `npx pqcheck onboard <domain>` | One command: scan → scaffold the GitHub Action → capture a vendor lockfile → set a baseline → commit + push. Zero copy-paste from docs. |
@@ -45,7 +72,7 @@ npx pqcheck cipherwake.io --ai
45
72
  Output:
46
73
 
47
74
  ```
48
- cipherwake · scan · ⚠ REVIEW · cipherwake.io · DBR 4.1 C · HIGH · ship_decision=review
75
+ Cipherwake · cipherwake.io ⚠ REVIEW · DBR 4.1 C · HIGH
49
76
 
50
77
  Top finding:
51
78
  [HIGH] ECDHE-only — quantum-vulnerable key exchange
@@ -64,44 +64,72 @@ let state;
64
64
  try {
65
65
  state = JSON.parse(readFileSync(STATE_FILE, "utf8"));
66
66
  } catch {
67
- // No scan yet — render an onboarding hint so first-time users learn the
68
- // command. Use the dim color so it doesn't dominate the status line.
67
+ // No scan yet — anchor on the brand so first-time users see Cipherwake
68
+ // every turn (and learn what the status line refers to), then nudge to
69
+ // the command. Dim so it doesn't dominate.
69
70
  process.stdout.write(
70
- c(C.dim, "◆ cipherwake · no scan yet · ") + c(C.cyan, "npx pqcheck <domain> --ai")
71
+ c(C.dim, "◆ Cipherwake · no scan yet ") + c(C.cyan, "npx pqcheck <domain> --ai")
71
72
  );
72
73
  process.exit(0);
73
74
  }
74
75
 
75
- const { domain, score, grade, ship_decision, written_at, max_severity, kind } = state;
76
+ const { domain, score, grade, ship_decision, written_at, max_severity, unreachable } = state;
76
77
  const age = ageHours(written_at);
77
78
 
78
79
  if (age > STALE_THRESHOLD_HOURS) {
79
- const stalePart = c(C.dim, `◆ ${domain || "cipherwake"} · stale (${formatAge(written_at)})`);
80
- const hintPart = c(C.cyan, `npx pqcheck ${domain || "<domain>"} --ai`);
81
- process.stdout.write(`${stalePart} · ${hintPart}`);
80
+ // Stale = the cached check is too old to anchor a deploy on. Use ◌
81
+ // (dotted circle) to signal "needs refresh" without alarming. Stays
82
+ // muted so an old check doesn't pretend to be active state.
83
+ process.stdout.write(
84
+ c(C.dim, `◆ Cipherwake · ${domain || "—"} ◌ STALE · last checked ${formatAge(written_at)} — `) +
85
+ c(C.cyan, `npx pqcheck ${domain || "<domain>"} --ai`)
86
+ );
82
87
  process.exit(0);
83
88
  }
84
89
 
85
- const symbolByDecision = { pass: "", review: "⚠", block: "✗" };
90
+ // Brand-anchored layout "Cipherwake" is always the first word after the
91
+ // diamond, so the customer (and their AI agent) can identify the status
92
+ // line's source at a glance. Trailing segments depend on what we have:
93
+ //
94
+ // pass (no DBR): ◆ Cipherwake · pinnedai.dev ✓ PASS · just now
95
+ // pass (w/ DBR): ◆ Cipherwake · pinnedai.dev ✓ PASS · DBR 8.7 A · just now
96
+ // review: ◆ Cipherwake · pinnedai.dev ⚠ REVIEW · DBR 4.1 C · HIGH · 1h ago
97
+ // block: ◆ Cipherwake · pinnedai.dev ⛔ BLOCK · HIGH · now
98
+ // unreachable: ◆ Cipherwake · pinnedai.dev ⊘ UNREACHABLE · now
99
+ //
100
+ // "Unreachable" is a distinct visual + label even though ship_decision=block,
101
+ // because the failure mode is different (we couldn't measure the trust
102
+ // surface at all, vs. we found a critical finding). The AI agent still
103
+ // halts on block-routing — same protocol behavior, clearer customer
104
+ // messaging.
105
+ const isUnreachable = !!unreachable;
106
+ const symbolByDecision = { pass: "✓", review: "⚠", block: "⛔" };
86
107
  const colorByDecision = { pass: C.green, review: C.yellow, block: C.red };
87
- const symbol = symbolByDecision[ship_decision] || "·";
88
- const cdec = colorByDecision[ship_decision] || C.dim;
108
+ const symbol = isUnreachable ? "⊘" : (symbolByDecision[ship_decision] || "·");
109
+ const cdec = isUnreachable ? C.red : (colorByDecision[ship_decision] || C.dim);
110
+ const labelWord = isUnreachable
111
+ ? "UNREACHABLE"
112
+ : (ship_decision || "—").toUpperCase();
89
113
 
90
- const dbrStr = typeof score === "number" ? `DBR ${score.toFixed(1)}` : "";
91
- const gradeStr = grade ? grade : "";
92
- const sevStr = max_severity && max_severity !== "none"
93
- ? ${String(max_severity).toUpperCase()}`
114
+ // When unreachable, suppress DBR/severity trailing segments they aren't
115
+ // meaningful (no score was computed) and would clutter the line. Just the
116
+ // glyph + UNREACHABLE label + age is enough.
117
+ const dbrSegment = (!isUnreachable && typeof score === "number")
118
+ ? ` · DBR ${score.toFixed(1)}${grade ? " " + grade : ""}`
119
+ : "";
120
+ const sevSegment = (!isUnreachable && max_severity && max_severity !== "none")
121
+ ? ` · ${String(max_severity).toUpperCase()}`
94
122
  : "";
95
- const kindStr = kind && kind !== "scan" ? `· ${kind}` : "";
96
123
 
97
- // Final layout (color-coded; ANSI stripped under --no-color / NO_COLOR=1):
98
- // ◆ <domain> ✓|⚠|✗ ship_decision · DBR X.X grade · SEVERITY · kind · age
99
124
  process.stdout.write(
100
125
  c(cdec, "◆") +
101
126
  " " +
102
- c(C.bold, domain || "cipherwake") +
127
+ c(C.bold, "Cipherwake") +
128
+ " " +
129
+ c(C.dim, "·") +
103
130
  " " +
104
- c(cdec, `${symbol} ${(ship_decision || "—").toUpperCase()}`) +
131
+ c(C.bold, domain || "—") +
105
132
  " " +
106
- c(C.dim, `· ${dbrStr}${gradeStr ? " " + gradeStr : ""} ${sevStr} ${kindStr} · ${formatAge(written_at)}`)
133
+ c(cdec, `${symbol} ${labelWord}`) +
134
+ c(C.dim, `${dbrSegment}${sevSegment} · ${formatAge(written_at)}`)
107
135
  );
package/bin/pqcheck.js CHANGED
@@ -24,7 +24,7 @@
24
24
  })();
25
25
 
26
26
  const API_BASE = process.env.PQCHECK_API_BASE || "https://cipherwake.io";
27
- const VERSION = "0.15.1";
27
+ const VERSION = "0.15.3";
28
28
 
29
29
  // API-key support — paid tiers (Starter $29 / Growth $79 / Scale $199) get
30
30
  // per-account monthly quotas instead of the per-IP rate limit. Set via:
@@ -263,13 +263,34 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
263
263
  if (!quiet && format === "text") process.stderr.write("\r\x1b[K");
264
264
  if (!resp.ok) {
265
265
  const errBody = await safeJSON(resp);
266
- console.error(color("red", `error scanning ${domain}: ${resp.status} ${errBody?.error || resp.statusText}`));
267
- if (errBody?.detail) console.error(color("dim", errBody.detail));
268
- // Surface the 429 upsell hint if present tells users how to ask for
269
- // higher limits via the feedback form. Same demand signal we capture
270
- // on the homepage.
271
- if (resp.status === 429 && errBody?.need_more?.feedback_url) {
272
- console.error(color("dim", `${errBody.need_more.message} ${errBody.need_more.feedback_url}`));
266
+ // Friendly per-status messages so customers see actionable guidance
267
+ // instead of "error scanning X: 429 rate_limit_exceeded" raw HTTP
268
+ // verbiage. Batch users scripting many scans in a row hit this hardest
269
+ // surface what they should do (wait + retry, or upgrade) rather than
270
+ // leaving them to guess.
271
+ if (resp.status === 429) {
272
+ const retryAfter = resp.headers.get("retry-after");
273
+ const waitMsg = retryAfter ? `Wait ${retryAfter}s then retry.` : "Wait ~60s then retry.";
274
+ console.error(color("yellow", `⚠ Rate limit hit on ${domain} — too many scans from this IP in the current window.`));
275
+ console.error(color("dim", ` ${waitMsg} For higher limits, get an account: ${API_BASE}/account`));
276
+ if (errBody?.need_more?.feedback_url) {
277
+ console.error(color("dim", ` Need much higher throughput? ${errBody.need_more.feedback_url}`));
278
+ }
279
+ } else if (resp.status >= 500) {
280
+ console.error(color("red", `error scanning ${domain}: Cipherwake's scanner hit a transient issue (HTTP ${resp.status}).`));
281
+ console.error(color("dim", ` Retry in ~1 minute. If it keeps happening, report at ${API_BASE}/feedback (include the domain + timestamp).`));
282
+ if (errBody?.detail) console.error(color("dim", ` Detail: ${errBody.detail}`));
283
+ } else if (resp.status === 400) {
284
+ // Most 400s on /api/scan are "invalid domain" — typically the
285
+ // customer typed a URL with a path or used localhost. The
286
+ // server already returns a clear message; just present it
287
+ // without raw HTTP noise.
288
+ console.error(color("red", `error scanning ${domain}: ${errBody?.error || resp.statusText}`));
289
+ if (errBody?.detail) console.error(color("dim", ` ${errBody.detail}`));
290
+ console.error(color("dim", ` Cipherwake only scans public domains. URLs with paths, IPs, and \`localhost\` aren't supported.`));
291
+ } else {
292
+ console.error(color("red", `error scanning ${domain}: ${resp.status} ${errBody?.error || resp.statusText}`));
293
+ if (errBody?.detail) console.error(color("dim", errBody.detail));
273
294
  }
274
295
  return 1;
275
296
  }
@@ -296,26 +317,93 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
296
317
  console.error(`pqcheck: ⚠ ${domain} — using cached score (live probe failed: ${report._meta.degradedReason || "unknown"}; last verified ${report._meta.lastUpdated || "?"})`);
297
318
  }
298
319
 
320
+ // Compute ship-decision once — needed both for AI-mode footer AND for the
321
+ // per-user/per-repo state files that statusline/chat-hook/prompt-hook read.
322
+ // State files must update on every successful scan, regardless of --ai, so
323
+ // downstream agents see the same scan the human just ran.
324
+ const findings = Array.isArray(report.findings) ? report.findings : [];
325
+ const maxSev = highestSeverity(findings);
326
+ let shipDecision = computeShipDecision({ maxSeverity: maxSev });
327
+ const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
328
+
329
+ // Unreachable / degraded → force "block". A scan that couldn't actually
330
+ // reach the domain (no DNS, TLS handshake failed, deploy hadn't propagated
331
+ // yet, typo in the domain) is categorically different from "trust drift
332
+ // detected" — we couldn't evaluate the trust surface AT ALL. "review"
333
+ // would be too soft (the AI agent might shrug and ship); "block" properly
334
+ // halts announcement per the AI Coder Protocol until the human confirms
335
+ // the unreachability was expected (e.g., they know DNS is still
336
+ // propagating) or fixes the deploy. This matches the protocol's "STOP,
337
+ // wait for explicit override" routing.
338
+ // Treat as "cannot evaluate" — and route through the UNREACHABLE label —
339
+ // when either:
340
+ // • reachable === false (no TCP/TLS handshake at all)
341
+ // • scanAvailable === false (TCP/TLS up, but scan couldn't produce a
342
+ // coherent fingerprint — e.g. medium.com churning vendor scripts
343
+ // faster than our dual-fingerprint check)
344
+ //
345
+ // _meta.degraded alone is too broad (fires on cache fallback + mid-scan
346
+ // state changes on otherwise-scoreable domains like stripe.com), so we
347
+ // narrow to the structured failure signals the server emits.
348
+ const unreachable = report?.reachable === false || report?.scanAvailable === false;
349
+ if (unreachable) {
350
+ shipDecision = "block";
351
+ }
352
+
353
+ // Capture reachability AFTER ship_decision override so state files reflect
354
+ // the truth: ship_decision=block + unreachable=true means "we couldn't
355
+ // reach it, treat as block." Downstream surfaces (statusline / VS Code
356
+ // extension / AI banner) display the "UNREACHABLE" label when
357
+ // unreachable=true instead of the generic "BLOCK" — more informative,
358
+ // same protocol routing.
359
+ await writeLastScanFile({
360
+ domain,
361
+ kind: "scan",
362
+ score: typeof report.score === "number" ? report.score : null,
363
+ grade: report.grade || null,
364
+ max_severity: maxSev,
365
+ ship_decision: shipDecision,
366
+ unreachable: unreachable || false,
367
+ top_issue: topFinding?.id || topFinding?.title || null,
368
+ });
369
+
299
370
  // AI Coder Mode — three-layer output for Claude Code / Cursor / Aider.
300
371
  // Overrides --format when --ai is set; emits the structured block at the
301
372
  // bottom so downstream agents can parse ship_decision deterministically.
302
373
  if (aiMode) {
303
- const findings = Array.isArray(report.findings) ? report.findings : [];
304
- const maxSev = highestSeverity(findings);
305
- const shipDecision = computeShipDecision({ maxSeverity: maxSev });
306
- const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
307
-
308
- const topIssue = topFinding
309
- ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
310
- : "No findings at or above LOW severity.";
311
- const whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk. Findings reflect public-surface signals only.";
312
- const nextActions = shipDecision === "pass"
313
- ? [`Domain looks healthy. View full report: ${API_BASE}/r/${domain}`]
314
- : [
315
- `Review finding above and decide if it was intentional.`,
316
- `View full report: ${API_BASE}/r/${domain}`,
317
- `Re-scan with --fresh after fix: npx pqcheck ${domain} --fresh --ai`,
318
- ];
374
+ let topIssue, whyMatters, nextActions;
375
+ if (unreachable) {
376
+ const isUnstable = report?.reason === "scan_unstable";
377
+ topIssue = isUnstable
378
+ ? "[REACHABILITY] Scan unstable — site changing faster than we can scan it"
379
+ : "[REACHABILITY] Domain unreachable — TLS probe failed or DNS unresolved";
380
+ whyMatters = report?.userMessage || report?._meta?.degradedReason || (isUnstable
381
+ ? "The scanner reached the site but couldn't produce a coherent fingerprint — rolling deploy, rapid A/B variation, or fast vendor-script rotation. Retry usually succeeds once the site settles."
382
+ : "The scanner couldn't reach the domain on port 443. Either DNS hasn't propagated, the deploy hasn't completed, or this domain isn't deployed at the address we expected.");
383
+ nextActions = isUnstable
384
+ ? [
385
+ `Wait ~1 minute then retry: npx pqcheck deploy-check ${domain} --ai`,
386
+ `View full report (last successful scan): ${API_BASE}/r/${domain}`,
387
+ ]
388
+ : [
389
+ `Check the domain is correct (typo?): ${domain}`,
390
+ `Verify DNS: dig +short ${domain}`,
391
+ `Verify deploy completed and TLS is live on https://${domain}`,
392
+ `Re-run after fix: npx pqcheck deploy-check ${domain} --ai`,
393
+ ];
394
+ } else {
395
+ topIssue = topFinding
396
+ ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
397
+ : "No findings at or above LOW severity.";
398
+ whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk. Findings reflect public-surface signals only.";
399
+ nextActions = shipDecision === "pass"
400
+ ? [`Domain looks healthy. View full report: ${API_BASE}/r/${domain}`]
401
+ : [
402
+ `Review finding above and decide if it was intentional.`,
403
+ `View full report: ${API_BASE}/r/${domain}`,
404
+ `Re-scan with --fresh after fix: npx pqcheck ${domain} --fresh --ai`,
405
+ ];
406
+ }
319
407
 
320
408
  console.log("");
321
409
  console.log(formatAiBanner({
@@ -325,6 +413,7 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
325
413
  grade: report.grade,
326
414
  maxSeverity: maxSev,
327
415
  shipDecision,
416
+ unreachable,
328
417
  }));
329
418
  console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
330
419
  console.log(formatAiFooterBlock({
@@ -343,16 +432,6 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
343
432
  }));
344
433
  console.log("");
345
434
 
346
- await writeLastScanFile({
347
- domain,
348
- kind: "scan",
349
- score: typeof report.score === "number" ? report.score : null,
350
- grade: report.grade || null,
351
- max_severity: maxSev,
352
- ship_decision: shipDecision,
353
- top_issue: topFinding?.id || topFinding?.title || null,
354
- });
355
-
356
435
  // Threshold check still applies under --ai (script-pipeable). Otherwise
357
436
  // exit code reflects ship_decision so the caller can route on it.
358
437
  if (threshold !== null && typeof report.score === "number" && report.score >= threshold) {
@@ -585,14 +664,30 @@ function aiStatusEmoji(shipDecision) {
585
664
  return "⚠";
586
665
  }
587
666
 
588
- function formatAiBanner({ domain, kind, dbr, grade, maxSeverity, shipDecision }) {
589
- const emoji = aiStatusEmoji(shipDecision);
590
- const statusWord = ({ pass: "PASS", review: "REVIEW", block: "BLOCK" })[shipDecision] || "REVIEW";
591
- const c = aiBannerColor(shipDecision);
592
- const dbrStr = typeof dbr === "number" ? dbr.toFixed(1) : "—";
593
- const gradeStr = grade ? ` ${grade}` : "";
594
- const sevStr = maxSeverity && maxSeverity !== "none" ? ` · ${String(maxSeverity).toUpperCase()}` : "";
595
- return color(c, `◆ cipherwake · ${kind} · ${emoji} ${statusWord} · ${domain} · DBR ${dbrStr}${gradeStr}${sevStr} · ship_decision=${shipDecision}`);
667
+ function formatAiBanner({ domain, kind, dbr, grade, maxSeverity, shipDecision, unreachable }) {
668
+ // When the scanner couldn't reach the domain we render "UNREACHABLE"
669
+ // (with a distinct glyph) instead of the generic "BLOCK" semantically
670
+ // clearer for the customer: their site isn't deployed / isn't responding,
671
+ // not that we found a critical security finding.
672
+ //
673
+ // Suppress DBR + severity trailing segments when unreachable: even if the
674
+ // API returned a stale cached score on a degraded scan, surfacing it next
675
+ // to "UNREACHABLE" reads as contradictory ("how can it score X if you
676
+ // couldn't reach it?"). The age + banner already convey the situation.
677
+ const isUnreachable = !!unreachable;
678
+ const emoji = isUnreachable ? "⊘" : aiStatusEmoji(shipDecision);
679
+ const statusWord = isUnreachable
680
+ ? "UNREACHABLE"
681
+ : (({ pass: "PASS", review: "REVIEW", block: "BLOCK" })[shipDecision] || "REVIEW");
682
+ const c = aiBannerColor(isUnreachable ? "block" : shipDecision);
683
+ const dbrSegment = (!isUnreachable && typeof dbr === "number")
684
+ ? ` · DBR ${dbr.toFixed(1)}${grade ? " " + grade : ""}`
685
+ : "";
686
+ const sevSegment = (!isUnreachable && maxSeverity && maxSeverity !== "none")
687
+ ? ` · ${String(maxSeverity).toUpperCase()}`
688
+ : "";
689
+ const kindSegment = (kind && kind !== "scan") ? ` · ${kind}` : "";
690
+ return color(c, `◆ Cipherwake · ${domain} ${emoji} ${statusWord}${dbrSegment}${sevSegment}${kindSegment}`);
596
691
  }
597
692
 
598
693
  function formatAiBody({ topIssue, whyMatters, nextActions }) {
@@ -2333,19 +2428,64 @@ async function runScanBasedDeployCheck(domain, args) {
2333
2428
  const report = await resp.json();
2334
2429
  const findings = Array.isArray(report.findings) ? report.findings : [];
2335
2430
  const maxSev = highestSeverity(findings);
2336
- const shipDecision = computeShipDecision({ maxSeverity: maxSev });
2431
+ let shipDecision = computeShipDecision({ maxSeverity: maxSev });
2337
2432
  const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
2338
- const topIssue = topFinding
2339
- ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
2340
- : "No findings at or above LOW severity.";
2341
- const whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk on the public TLS surface.";
2342
- const nextActions = shipDecision === "pass"
2343
- ? [`Domain looks healthy on first scan. View full report: ${API_BASE}/r/${domain}`]
2344
- : [
2345
- `Review finding above and decide if it was intentional.`,
2346
- `View full report: ${API_BASE}/r/${domain}`,
2347
- `Subsequent deploy-checks will diff against this scan as baseline.`,
2348
- ];
2433
+
2434
+ // Unreachable / degraded → force "block" with an UNREACHABLE display label
2435
+ // downstream. See main scan path for the rationale: an undeployed-or-broken
2436
+ // domain can't be evaluated, so the AI agent must halt announcement and ask
2437
+ // the human (was this expected? did the deploy fail?). The `unreachable`
2438
+ // field travels in the state file + structured block so statusline /
2439
+ // VS Code ext / chat-hook can render "UNREACHABLE" instead of generic
2440
+ // "BLOCK" semantically clearer for this specific failure mode.
2441
+ // Treat as "cannot evaluate" — and route through the UNREACHABLE label —
2442
+ // when either:
2443
+ // • reachable === false (no TCP/TLS handshake at all)
2444
+ // • scanAvailable === false (TCP/TLS up, but scan couldn't produce a
2445
+ // coherent fingerprint — e.g. medium.com churning vendor scripts
2446
+ // faster than our dual-fingerprint check)
2447
+ //
2448
+ // _meta.degraded alone is too broad (fires on cache fallback + mid-scan
2449
+ // state changes on otherwise-scoreable domains like stripe.com), so we
2450
+ // narrow to the structured failure signals the server emits.
2451
+ const unreachable = report?.reachable === false || report?.scanAvailable === false;
2452
+ if (unreachable) {
2453
+ shipDecision = "block";
2454
+ }
2455
+
2456
+ let topIssue, whyMatters, nextActions;
2457
+ if (unreachable) {
2458
+ const isUnstable = report?.reason === "scan_unstable";
2459
+ topIssue = isUnstable
2460
+ ? "[REACHABILITY] Scan unstable — site changing faster than we can scan it"
2461
+ : "[REACHABILITY] Domain unreachable — TLS probe failed or DNS unresolved";
2462
+ whyMatters = report?.userMessage || report?._meta?.degradedReason || (isUnstable
2463
+ ? "The scanner reached the site but couldn't produce a coherent fingerprint — rolling deploy, rapid A/B variation, or fast vendor-script rotation. Retry usually succeeds once the site settles."
2464
+ : "The scanner couldn't reach the domain on port 443. Either DNS hasn't propagated, the deploy hasn't completed, or this domain isn't deployed at the address we expected.");
2465
+ nextActions = isUnstable
2466
+ ? [
2467
+ `Wait ~1 minute then retry: npx pqcheck deploy-check ${domain} --ai`,
2468
+ `View full report (last successful scan): ${API_BASE}/r/${domain}`,
2469
+ ]
2470
+ : [
2471
+ `Check the domain is correct (typo?): ${domain}`,
2472
+ `Verify DNS: dig +short ${domain}`,
2473
+ `Verify deploy completed and TLS is live on https://${domain}`,
2474
+ `Re-run after fix: npx pqcheck deploy-check ${domain} --ai`,
2475
+ ];
2476
+ } else {
2477
+ topIssue = topFinding
2478
+ ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
2479
+ : "No findings at or above LOW severity.";
2480
+ whyMatters = topFinding?.detail || "DBR scoring measures harvest-now-decrypt-later risk on the public TLS surface.";
2481
+ nextActions = shipDecision === "pass"
2482
+ ? [`Domain looks healthy on first scan. View full report: ${API_BASE}/r/${domain}`]
2483
+ : [
2484
+ `Review finding above and decide if it was intentional.`,
2485
+ `View full report: ${API_BASE}/r/${domain}`,
2486
+ `Subsequent deploy-checks will diff against this scan as baseline.`,
2487
+ ];
2488
+ }
2349
2489
 
2350
2490
  console.log("");
2351
2491
  console.log(color("dim", ` ℹ first deploy-check for ${domain} — using current scan state (no baseline yet to diff against)`));
@@ -2357,23 +2497,46 @@ async function runScanBasedDeployCheck(domain, args) {
2357
2497
  grade: report.grade,
2358
2498
  maxSeverity: maxSev,
2359
2499
  shipDecision,
2500
+ unreachable,
2360
2501
  }));
2361
2502
  console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
2362
2503
  console.log(formatAiFooterBlock({
2363
- status: shipDecision === "pass" ? "pass" : "review",
2504
+ status: shipDecision,
2364
2505
  domain,
2365
2506
  kind: "scan",
2366
2507
  dbr: report.score,
2367
2508
  grade: report.grade,
2368
2509
  max_severity: maxSev,
2369
2510
  ship_decision: shipDecision,
2370
- top_issue: topFinding ? `findings.${topFinding.id || "unknown"}` : "none",
2511
+ unreachable: unreachable ? "true" : "false",
2512
+ top_issue: unreachable
2513
+ ? "findings.reachability.unreachable"
2514
+ : (topFinding ? `findings.${topFinding.id || "unknown"}` : "none"),
2371
2515
  findings_high: findings.filter((f) => severityRank(f.severity) >= severityRank("high")).length,
2372
2516
  findings_critical: findings.filter((f) => severityRank(f.severity) >= severityRank("critical")).length,
2373
2517
  scanned_at: new Date().toISOString(),
2374
2518
  advisory_only: true,
2375
- note: "first-deploy: no baseline yet, scored on current state",
2519
+ note: unreachable
2520
+ ? "domain unreachable on port 443; deploy may have failed or DNS not propagated"
2521
+ : "first-deploy: no baseline yet, scored on current state",
2376
2522
  }));
2523
+
2524
+ await writeLastScanFile({
2525
+ domain,
2526
+ kind: "scan",
2527
+ score: typeof report.score === "number" ? report.score : null,
2528
+ grade: report.grade || null,
2529
+ max_severity: maxSev,
2530
+ ship_decision: shipDecision,
2531
+ unreachable: unreachable || false,
2532
+ top_issue: unreachable
2533
+ ? "findings.reachability.unreachable"
2534
+ : (topFinding?.id || topFinding?.title || null),
2535
+ note: unreachable
2536
+ ? "domain unreachable on port 443; deploy may have failed or DNS not propagated"
2537
+ : "first-deploy: no baseline yet, scored on current state",
2538
+ });
2539
+
2377
2540
  // Exit code: 0 on pass, non-zero on review/block (matches deploy-check contract)
2378
2541
  process.exit(shipDecision === "pass" ? 0 : 1);
2379
2542
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "Deploy gate for AI-coded web apps. `pqcheck deploy-check --ai` returns ship_decision=pass|review|block for Claude Code / Cursor / Copilot / Aider to gate deploys before they ship. Anonymous, no signup, free for first use.",
5
5
  "keywords": [
6
6
  "ai-coder",