pqcheck 0.15.2 → 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.
Files changed (3) hide show
  1. package/README.md +35 -8
  2. package/bin/pqcheck.js +87 -36
  3. package/package.json +1 -1
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
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.2";
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
  }
@@ -314,12 +335,17 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
314
335
  // the unreachability was expected (e.g., they know DNS is still
315
336
  // propagating) or fixes the deploy. This matches the protocol's "STOP,
316
337
  // wait for explicit override" routing.
317
- // Only treat literal "reachable === false" as unreachable. _meta.degraded
318
- // is too broad — it also fires for fingerprint disagreement / cache
319
- // fallback / mid-scan state changes on reachable domains (e.g. stripe.com
320
- // mid-deploy), and those scans returned a real score we shouldn't hide
321
- // behind an UNREACHABLE label.
322
- const unreachable = report?.reachable === false;
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;
323
349
  if (unreachable) {
324
350
  shipDecision = "block";
325
351
  }
@@ -347,14 +373,24 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
347
373
  if (aiMode) {
348
374
  let topIssue, whyMatters, nextActions;
349
375
  if (unreachable) {
350
- topIssue = "[REACHABILITY] Domain unreachable — TLS probe failed or DNS unresolved";
351
- whyMatters = report?._meta?.degradedReason || "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.";
352
- nextActions = [
353
- `Check the domain is correct (typo?): ${domain}`,
354
- `Verify DNS: dig +short ${domain}`,
355
- `Verify deploy completed and TLS is live on https://${domain}`,
356
- `Re-run after fix: npx pqcheck deploy-check ${domain} --ai`,
357
- ];
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
+ ];
358
394
  } else {
359
395
  topIssue = topFinding
360
396
  ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
@@ -2402,26 +2438,41 @@ async function runScanBasedDeployCheck(domain, args) {
2402
2438
  // field travels in the state file + structured block so statusline /
2403
2439
  // VS Code ext / chat-hook can render "UNREACHABLE" instead of generic
2404
2440
  // "BLOCK" — semantically clearer for this specific failure mode.
2405
- // Only treat literal "reachable === false" as unreachable. _meta.degraded
2406
- // is too broad — it also fires for fingerprint disagreement / cache
2407
- // fallback / mid-scan state changes on reachable domains (e.g. stripe.com
2408
- // mid-deploy), and those scans returned a real score we shouldn't hide
2409
- // behind an UNREACHABLE label.
2410
- const unreachable = report?.reachable === false;
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;
2411
2452
  if (unreachable) {
2412
2453
  shipDecision = "block";
2413
2454
  }
2414
2455
 
2415
2456
  let topIssue, whyMatters, nextActions;
2416
2457
  if (unreachable) {
2417
- topIssue = "[REACHABILITY] Domain unreachable — TLS probe failed or DNS unresolved";
2418
- whyMatters = report?._meta?.degradedReason || "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.";
2419
- nextActions = [
2420
- `Check the domain is correct (typo?): ${domain}`,
2421
- `Verify DNS: dig +short ${domain}`,
2422
- `Verify deploy completed and TLS is live on https://${domain}`,
2423
- `Re-run after fix: npx pqcheck deploy-check ${domain} --ai`,
2424
- ];
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
+ ];
2425
2476
  } else {
2426
2477
  topIssue = topFinding
2427
2478
  ? `[${String(topFinding.severity || "").toUpperCase()}] ${topFinding.title || topFinding.detail || "finding"}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.15.2",
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",