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 +35 -8
- package/bin/cipherwake-statusline.js +48 -20
- package/bin/pqcheck.js +220 -57
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,27 +1,54 @@
|
|
|
1
1
|
# pqcheck
|
|
2
2
|
|
|
3
|
-
> **
|
|
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
|
[](https://www.npmjs.com/package/pqcheck)
|
|
6
6
|
[](https://www.npmjs.com/package/pqcheck)
|
|
7
7
|
[](./LICENSE)
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx pqcheck
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
45
|
+
## Commands at a glance
|
|
20
46
|
|
|
21
47
|
| Command | What it gives you |
|
|
22
48
|
|---|---|
|
|
23
|
-
| `npx pqcheck <domain>` |
|
|
24
|
-
| `npx pqcheck
|
|
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
|
-
◆
|
|
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 —
|
|
68
|
-
//
|
|
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, "◆
|
|
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,
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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,
|
|
127
|
+
c(C.bold, "Cipherwake") +
|
|
128
|
+
" " +
|
|
129
|
+
c(C.dim, "·") +
|
|
103
130
|
" " +
|
|
104
|
-
c(
|
|
131
|
+
c(C.bold, domain || "—") +
|
|
105
132
|
" " +
|
|
106
|
-
c(
|
|
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.
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
if (resp.status === 429
|
|
272
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
2431
|
+
let shipDecision = computeShipDecision({ maxSeverity: maxSev });
|
|
2337
2432
|
const topFinding = [...findings].sort((a, b) => severityRank(b.severity) - severityRank(a.severity))[0];
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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",
|