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.
- package/README.md +35 -8
- package/bin/pqcheck.js +87 -36
- 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
|
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
|
}
|
|
@@ -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
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
//
|
|
2406
|
-
//
|
|
2407
|
-
//
|
|
2408
|
-
//
|
|
2409
|
-
//
|
|
2410
|
-
|
|
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
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
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.
|
|
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",
|