pqcheck 0.16.2 → 0.16.4

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
@@ -49,7 +49,7 @@ You get the same scanner that powers [cipherwake.io](https://cipherwake.io), the
49
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
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
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. |
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. |
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. **v0.16.3** — now renders per-signal N vs N+1 status on every run (scripts, headers, cert SPKI, TLS, …) so you can see *every* check fired, not just "did something change." Add `--verbose` for the full side-by-side table. |
53
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. |
54
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. |
55
55
  | **`npx pqcheck guard --domain <D> -- <cmd>`** 🆕 | **Deploy guard wrapper.** Wraps any deploy command. Runs `deploy-check` first; conditionally runs `<cmd>` based on `ship_decision`. Modes: `--gate-mode balanced` (default) / `advisory` / `strict`. ONE command instead of two — the strongest single artifact for AI-coder workflows because the AI never has to remember to chain check + deploy. |
@@ -73,7 +73,11 @@ try {
73
73
  process.exit(0);
74
74
  }
75
75
 
76
- const { domain, score, grade, ship_decision, written_at, max_severity, unreachable } = state;
76
+ const {
77
+ domain, score, grade, ship_decision, written_at, max_severity, unreachable,
78
+ // v0.16.4 — preview-diff-specific fields for the 4-line render
79
+ kind, delta_count, diff_no_change, sector_ranking, verified_signal_categories, last_changed,
80
+ } = state;
77
81
  const age = ageHours(written_at);
78
82
 
79
83
  if (age > STALE_THRESHOLD_HOURS) {
@@ -87,6 +91,70 @@ if (age > STALE_THRESHOLD_HOURS) {
87
91
  process.exit(0);
88
92
  }
89
93
 
94
+ // v0.16.4 — preview-diff 4-line state.
95
+ // User direction: when the last scan was `preview-diff` (two URLs compared),
96
+ // render the high-yield block in the statusline. Other scan kinds keep the
97
+ // 1-line compact format below — the bigger block would be too noisy when
98
+ // it's just a routine domain scan with no diff context.
99
+ if (kind === "preview-diff" && !unreachable) {
100
+ // Line 1: brand header (hostname only).
101
+ const head = c(C.bold, "◆ Cipherwake") + c(C.dim, " — ") + c(C.bold, domain || "—");
102
+ // Line 3: decision pulse, diff-flavored copy.
103
+ let pulse;
104
+ if (ship_decision === "pass") {
105
+ pulse = c(C.green, "✓ PASS") + c(C.dim, " · no risky deploy-surface drift detected");
106
+ } else if (ship_decision === "review") {
107
+ pulse = c(C.yellow, "⚠ REVIEW") + c(C.dim, ` · ${delta_count || 0} public-surface change${delta_count === 1 ? "" : "s"}`);
108
+ } else if (ship_decision === "block") {
109
+ pulse = c(C.red, "⛔ BLOCK") + c(C.dim, ` · ${delta_count || 0} critical change${delta_count === 1 ? "" : "s"}`);
110
+ } else {
111
+ pulse = c(C.dim, "· no decision");
112
+ }
113
+ // Line 4: trust posture (DBR + percentile copy + stability).
114
+ function formatPercentile(percentile, sector) {
115
+ if (typeof percentile !== "number") return null;
116
+ const s = sector || "industry";
117
+ if (percentile <= 10) return `top 10% in ${s}`;
118
+ if (percentile <= 25) return `top 25% in ${s}`;
119
+ if (percentile <= 50) return `above median in ${s}`;
120
+ if (percentile <= 75) return `below median in ${s}`;
121
+ if (percentile <= 90) return `bottom 25% in ${s}`;
122
+ return `bottom 10% in ${s}`;
123
+ }
124
+ function formatStability(iso) {
125
+ if (!iso) return null;
126
+ const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
127
+ if (days <= 0) return "drifted today";
128
+ if (days < 7) return `drifted ${days}d ago`;
129
+ return `stable ${days}d`;
130
+ }
131
+ const trustParts = [];
132
+ if (typeof score === "number") {
133
+ trustParts.push(`DBR ${score.toFixed(1)}${grade ? " " + grade : ""}`);
134
+ }
135
+ const pct = formatPercentile(sector_ranking?.percentile, sector_ranking?.sectorLabel || sector_ranking?.sectorName || sector_ranking?.sector);
136
+ if (pct) trustParts.push(pct);
137
+ const stab = formatStability(last_changed);
138
+ if (stab) trustParts.push(stab);
139
+ const trustLine = trustParts.length > 0
140
+ ? c(C.green, "✓ ") + c(C.bold, "Trust posture: ") + trustParts.join(c(C.dim, " · "))
141
+ : null;
142
+ // Line 5: verified signals (or finding line when there IS drift).
143
+ let verifiedLine = null;
144
+ if (Array.isArray(verified_signal_categories) && verified_signal_categories.length > 0) {
145
+ const head = diff_no_change === true
146
+ ? "Security-relevant surface unchanged"
147
+ : `Verified ${verified_signal_categories.length} signal${verified_signal_categories.length === 1 ? "" : "s"}`;
148
+ verifiedLine = c(C.green, "✓ ") + c(C.bold, head) + c(C.dim, " · " + verified_signal_categories.join(", "));
149
+ }
150
+ // Compose
151
+ const lines = [head, "", pulse];
152
+ if (trustLine) lines.push(trustLine);
153
+ if (verifiedLine) lines.push(verifiedLine);
154
+ process.stdout.write(lines.join("\n"));
155
+ process.exit(0);
156
+ }
157
+
90
158
  // Brand-anchored layout — "Cipherwake" is always the first word after the
91
159
  // diamond, so the customer (and their AI agent) can identify the status
92
160
  // line's source at a glance. Trailing segments depend on what we have:
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.16.2";
27
+ const VERSION = "0.16.4";
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:
@@ -941,6 +941,131 @@ function formatHighYieldVerbose(report) {
941
941
  return lines.join("\n");
942
942
  }
943
943
 
944
+ // v0.16.3 — preview-diff per-signal comparison. Render N vs N+1 signal
945
+ // values for both sides side-by-side so the customer SEES that every
946
+ // signal was checked, even when delta_count=0. Without this, preview-diff
947
+ // was silent on non-changing signals and looked like a binary "did
948
+ // anything change" detector. With this, every preview-diff output proves
949
+ // the 9 signals were exercised on both URLs.
950
+ function formatPreviewDiffPerSignal(prev, prod, options = {}) {
951
+ if (!prev || !prod) return null;
952
+ const verbose = options.verbose === true;
953
+ const rows = [];
954
+ const push = (name, l, r) => rows.push({ name, prev: l, prod: r, same: l === r });
955
+
956
+ if (typeof prev?.score === "number" || typeof prod?.score === "number") {
957
+ const dbrL = typeof prev?.score === "number" ? `${prev.score.toFixed(1)}${prev.grade ? " " + prev.grade : ""}` : "—";
958
+ const dbrR = typeof prod?.score === "number" ? `${prod.score.toFixed(1)}${prod.grade ? " " + prod.grade : ""}` : "—";
959
+ push("DBR", dbrL, dbrR);
960
+ }
961
+
962
+ const prevScripts = prev?.publicDeps?.thirdPartyCount;
963
+ const prodScripts = prod?.publicDeps?.thirdPartyCount;
964
+ if (prevScripts !== undefined || prodScripts !== undefined) {
965
+ push("Scripts", String(prevScripts ?? "—"), String(prodScripts ?? "—"));
966
+ }
967
+
968
+ const hdrSummary = (h) => {
969
+ if (!h?.reachable) return "—";
970
+ const flags = [];
971
+ flags.push(h.csp?.present ? "CSP✓" : "CSP✗");
972
+ flags.push(h.hsts?.present ? `HSTS${h.hsts.preload ? "+preload" : ""}✓` : "HSTS✗");
973
+ flags.push(h.xFrameOptions?.present ? "XFO✓" : "XFO✗");
974
+ return flags.join(" ");
975
+ };
976
+ if (prev?.httpHeaders || prod?.httpHeaders) {
977
+ push("Headers", hdrSummary(prev?.httpHeaders), hdrSummary(prod?.httpHeaders));
978
+ }
979
+
980
+ const ckSummary = (c) => {
981
+ if (!c) return "—";
982
+ if (!c.reachable) return "unreachable";
983
+ const issues = [];
984
+ if (c.anyMissingSecure) issues.push("Secure");
985
+ if (c.anyMissingHttpOnly) issues.push("HttpOnly");
986
+ if (c.anyMissingSameSite) issues.push("SameSite");
987
+ const tag = issues.length ? `miss:${issues.join(",")}` : "all flags";
988
+ return `${c.count ?? 0} (${tag})`;
989
+ };
990
+ if (prev?.cookies || prod?.cookies) push("Cookies", ckSummary(prev?.cookies), ckSummary(prod?.cookies));
991
+
992
+ const smSummary = (s) => {
993
+ if (!s) return "—";
994
+ if (!s.reachable) return "unreachable";
995
+ if (s.exposed) return `${s.exposedCount} EXPOSED`;
996
+ return "none exposed";
997
+ };
998
+ if (prev?.sourceMaps || prod?.sourceMaps) push("Source maps", smSummary(prev?.sourceMaps), smSummary(prod?.sourceMaps));
999
+
1000
+ const mcSummary = (m) => {
1001
+ if (m === null || m === undefined) return "—";
1002
+ if (typeof m === "number") return String(m);
1003
+ if (typeof m === "object") {
1004
+ if (typeof m.insecureCount === "number") return String(m.insecureCount);
1005
+ if (typeof m.count === "number") return String(m.count);
1006
+ }
1007
+ return "—";
1008
+ };
1009
+ if (prev?.mixedContent !== null || prod?.mixedContent !== null) {
1010
+ push("Mixed content", mcSummary(prev?.mixedContent), mcSummary(prod?.mixedContent));
1011
+ }
1012
+
1013
+ const ppSummary = (p) => {
1014
+ if (!p?.probed) return "—";
1015
+ const protected_ = p.protectedCount ?? 0;
1016
+ const exposed = p.exposedCount ?? 0;
1017
+ const total = protected_ + exposed;
1018
+ return total === 0 ? "0/0" : `${protected_}/${total} protected`;
1019
+ };
1020
+ if (prev?.protectedPaths || prod?.protectedPaths) {
1021
+ push("Protected paths", ppSummary(prev?.protectedPaths), ppSummary(prod?.protectedPaths));
1022
+ }
1023
+
1024
+ const certSummary = (c) => {
1025
+ if (!c) return "—";
1026
+ if (c.spkiSha256) return `${String(c.spkiSha256).slice(0, 10)}…`;
1027
+ return c.issuer || "—";
1028
+ };
1029
+ if (prev?.cert || prod?.cert) push("Cert SPKI", certSummary(prev?.cert), certSummary(prod?.cert));
1030
+
1031
+ if (prev?.tlsVersion || prod?.tlsVersion) push("TLS", prev?.tlsVersion || "—", prod?.tlsVersion || "—");
1032
+
1033
+ const prevSub = prev?.publicSurface?.subdomainCount;
1034
+ const prodSub = prod?.publicSurface?.subdomainCount;
1035
+ if (prevSub !== undefined || prodSub !== undefined) {
1036
+ push("Subdomains", String(prevSub ?? "—"), String(prodSub ?? "—"));
1037
+ }
1038
+
1039
+ if (rows.length === 0) return null;
1040
+
1041
+ if (!verbose) {
1042
+ // Compact 1-line summary: "✓ 9/9 signals match (preview ↔ production)"
1043
+ const sameCount = rows.filter((r) => r.same).length;
1044
+ const allMatch = sameCount === rows.length;
1045
+ const symbol = allMatch ? color("green", "✓ ") : color("yellow", "~ ");
1046
+ return symbol + color("bold", `${sameCount}/${rows.length} signals match`) + color("dim", " (preview ↔ production)");
1047
+ }
1048
+
1049
+ // Verbose: per-row table
1050
+ const lines = [];
1051
+ lines.push("");
1052
+ lines.push(color("bold", "Per-signal verification (preview ↔ production):"));
1053
+ const nameWidth = Math.max(...rows.map((r) => r.name.length));
1054
+ const prevWidth = Math.max(...rows.map((r) => r.prev.length));
1055
+ for (const r of rows) {
1056
+ const arrow = r.same ? color("dim", "↔") : color("yellow", "→");
1057
+ const valueColor = r.same ? "dim" : "yellow";
1058
+ lines.push(
1059
+ " " +
1060
+ color("bold", r.name.padEnd(nameWidth)) + " " +
1061
+ color(valueColor, r.prev.padEnd(prevWidth)) + " " +
1062
+ arrow + " " +
1063
+ color(valueColor, r.prod)
1064
+ );
1065
+ }
1066
+ return lines.join("\n");
1067
+ }
1068
+
944
1069
  // Helper: pretty-print 1-3 alerts (when ship_decision = review/block) ABOVE
945
1070
  // the trust-posture line, so the actionable change is the FIRST thing the
946
1071
  // customer sees. Each alert is one line with an emoji + concise summary.
@@ -3170,11 +3295,40 @@ async function runPreviewDiffCommand(args) {
3170
3295
  `Each "- CSP weakened" or "~ HSTS weakened" reduces production safety.`,
3171
3296
  ];
3172
3297
 
3173
- // v0.16.2 high-yield rendering for preview-diff.
3298
+ // v0.16.3 high-yield rendering for preview-diff. Uses the per-side
3299
+ // `signals` snapshot returned by /api/preview-diff so the customer
3300
+ // sees N vs N+1 on every signal (not just the binary "no drift").
3301
+ // Falls back to the v0.16.2 shape when the server hasn't deployed
3302
+ // the signals snapshot yet (older API response).
3174
3303
  const verbose = isVerboseMode(args);
3175
3304
  const diffNoChange = !hasUnexpectedDiff;
3176
- const headerDomain = result?.production?.domain || productionUrl;
3177
- const fakeReport = {
3305
+ const prevSig = result?.preview?.signals || null;
3306
+ const prodSig = result?.production?.signals || null;
3307
+ // Brand header uses the production hostname (e.g. "quantapact.com")
3308
+ // not the full Vercel preview URL, so the customer doesn't see
3309
+ // "◆ Cipherwake — https://quantapact-k3y...vercel.app".
3310
+ const headerDomain = result?.production?.hostname || result?.production?.domain || productionUrl;
3311
+ // Synthesize a report-shaped object from the production-side signals
3312
+ // snapshot so the existing trust-posture + verified-signals helpers
3313
+ // (which read .score, .grade, .sectorRanking, .cookies, etc.) work
3314
+ // without changes.
3315
+ const reportLike = prodSig ? {
3316
+ domain: headerDomain,
3317
+ score: prodSig.score,
3318
+ grade: prodSig.grade,
3319
+ _meta: { lastChanged: prodSig.lastChanged },
3320
+ sectorRanking: prodSig.sectorRanking,
3321
+ publicDeps: prodSig.publicDeps ? { fetched: true, thirdParties: new Array(prodSig.publicDeps.thirdPartyCount || 0) } : null,
3322
+ httpHeaders: prodSig.httpHeaders,
3323
+ cookies: prodSig.cookies,
3324
+ sourceMaps: prodSig.sourceMaps,
3325
+ mixedContent: prodSig.mixedContent,
3326
+ cert: prodSig.cert,
3327
+ tlsVersion: prodSig.tlsVersion,
3328
+ publicSurface: prodSig.publicSurface,
3329
+ protectedPaths: prodSig.protectedPaths,
3330
+ } : {
3331
+ // Fallback: legacy server, only score/grade available.
3178
3332
  domain: headerDomain,
3179
3333
  score: prevScore,
3180
3334
  grade: result?.preview?.grade,
@@ -3185,7 +3339,7 @@ async function runPreviewDiffCommand(args) {
3185
3339
  const deltaLines = summaryLines.filter((l) => !/no meaningful/i.test(l));
3186
3340
 
3187
3341
  console.log("");
3188
- console.log(formatBrandHeader(headerDomain));
3342
+ console.log(formatBrandHeader(headerDomain) + color("dim", " (preview ↔ production)"));
3189
3343
  console.log("");
3190
3344
  console.log(formatDecisionPulse({ shipDecision, alertCount: deltaLines.length, unreachable: false, diffContext: true }));
3191
3345
 
@@ -3196,12 +3350,19 @@ async function runPreviewDiffCommand(args) {
3196
3350
  }
3197
3351
  if (deltaLines.length > 3) console.log(color("dim", ` +${deltaLines.length - 3} more (run with --verbose)`));
3198
3352
  }
3199
- const tpl = formatTrustPostureLine(fakeReport);
3353
+ const tpl = formatTrustPostureLine(reportLike);
3200
3354
  if (tpl) console.log(tpl);
3201
- const vsl = formatVerifiedSignalsLine(fakeReport, { diffNoChange });
3355
+ const vsl = formatVerifiedSignalsLine(reportLike, { diffNoChange });
3202
3356
  if (vsl) console.log(vsl);
3357
+ // v0.16.3 — per-signal N vs N+1 comparison. Renders ALWAYS (default and
3358
+ // --verbose), in different shapes:
3359
+ // - default: 1-line "9/9 signals match (preview ↔ production)" so the
3360
+ // customer sees the checks fired without scrolling
3361
+ // - --verbose: per-row table with both sides spelled out
3362
+ const psl = formatPreviewDiffPerSignal(prevSig, prodSig, { verbose });
3363
+ if (psl) console.log(psl);
3203
3364
  if (!verbose) {
3204
- console.log(color("dim", " Run with --verbose to see all verified signals."));
3365
+ console.log(color("dim", " Run with --verbose to see per-signal breakdown."));
3205
3366
  }
3206
3367
 
3207
3368
  // (Old banner + body removed in v0.16.2; new high-yield layout above
@@ -3229,15 +3390,30 @@ async function runPreviewDiffCommand(args) {
3229
3390
  }));
3230
3391
  console.log("");
3231
3392
 
3393
+ // v0.16.4 — write the data the statusline needs to render its
3394
+ // 4-line preview-diff state (brand header + decision pulse + trust
3395
+ // posture + verified signals). For non-preview-diff kinds we keep
3396
+ // the 1-line statusline; only preview-diff gets the bigger format.
3397
+ const verifiedSignalCats = (() => {
3398
+ try { return countVerifiedSignals(reportLike).categories; } catch { return null; }
3399
+ })();
3232
3400
  await writeLastScanFile({
3233
- domain: result?.production?.domain || productionUrl,
3401
+ domain: result?.production?.hostname || result?.production?.domain || productionUrl,
3234
3402
  kind: "preview-diff",
3235
3403
  preview_url: previewUrl,
3236
3404
  production_url: productionUrl,
3237
- score: typeof prevScore === "number" ? prevScore : null,
3405
+ // v0.16.4 — score/grade come from the PRODUCTION side so the trust
3406
+ // posture line reflects the live customer site, not the in-flight
3407
+ // preview. (Was prevScore before — wrong reference.)
3408
+ score: typeof prodSig?.score === "number" ? prodSig.score : (typeof prevScore === "number" ? prevScore : null),
3409
+ grade: prodSig?.grade || null,
3238
3410
  max_severity: maxSev,
3239
3411
  ship_decision: shipDecision,
3240
3412
  delta_count: summaryLines.filter((l) => !/no meaningful/i.test(l)).length,
3413
+ diff_no_change: diffNoChange,
3414
+ sector_ranking: prodSig?.sectorRanking || null,
3415
+ verified_signal_categories: verifiedSignalCats,
3416
+ last_changed: prodSig?.lastChanged || null,
3241
3417
  });
3242
3418
 
3243
3419
  process.exit(shipDecisionExitCode(shipDecision));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.16.2",
3
+ "version": "0.16.4",
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",