pqcheck 0.16.0 → 0.16.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
@@ -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. |
@@ -121,6 +121,18 @@ const sevSegment = (!isUnreachable && max_severity && max_severity !== "none")
121
121
  ? ` · ${String(max_severity).toUpperCase()}`
122
122
  : "";
123
123
 
124
+ // v0.16.2 — drift narrative suffix. "stable 14d" / "drifted 2d ago" / "now".
125
+ // Sourced from state.lastChanged (smartCache populates) so the customer
126
+ // sees time-series anchor at a glance, not just a single-point snapshot.
127
+ let stabilitySegment = "";
128
+ if (!isUnreachable && state.lastChanged) {
129
+ const dms = Date.now() - new Date(state.lastChanged).getTime();
130
+ const days = Math.floor(dms / 86400000);
131
+ if (days <= 0) stabilitySegment = " · drifted today";
132
+ else if (days < 7) stabilitySegment = ` · drifted ${days}d ago`;
133
+ else stabilitySegment = ` · stable ${days}d`;
134
+ }
135
+
124
136
  process.stdout.write(
125
137
  c(cdec, "◆") +
126
138
  " " +
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.0";
27
+ const VERSION = "0.16.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:
@@ -831,11 +831,13 @@ function countVerifiedSignals(report) {
831
831
  if (report?.sourceMaps?.reachable) cats.push("source maps");
832
832
  // 6. TLS posture
833
833
  if (report?.publicSurface?.tlsVersion || report?.tlsVersion) cats.push("TLS");
834
- // 7. Mixed content — not yet a separate probe but counted when we have
835
- // publicDeps (which exposes any loadedOverHttps:false items).
836
- if (report?.publicDeps?.fetched) cats.push("mixed-content");
834
+ // 7. Mixed content — v0.16.2 promoted to dedicated probe; falls back to
835
+ // publicDeps inference when explicit field missing.
836
+ if (report?.mixedContent?.probed || report?.publicDeps?.fetched) cats.push("mixed-content");
837
837
  // 8. Subdomain scale (from CT log scan)
838
838
  if (typeof report?.publicSurface?.subdomainCount === "number") cats.push("subdomain scale");
839
+ // 9. Protected paths (v0.16.2 — the headline feature)
840
+ if (report?.protectedPaths?.probed) cats.push("protected paths");
839
841
  return { count: cats.length, categories: cats };
840
842
  }
841
843
 
@@ -939,6 +941,131 @@ function formatHighYieldVerbose(report) {
939
941
  return lines.join("\n");
940
942
  }
941
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
+
942
1069
  // Helper: pretty-print 1-3 alerts (when ship_decision = review/block) ABOVE
943
1070
  // the trust-posture line, so the actionable change is the FIRST thing the
944
1071
  // customer sees. Each alert is one line with an emoji + concise summary.
@@ -2762,19 +2889,39 @@ async function runScanBasedDeployCheck(domain, args) {
2762
2889
  ];
2763
2890
  }
2764
2891
 
2892
+ // v0.16.0 high-yield rendering — same shape as runOneScan but with the
2893
+ // "first-deploy" context line above the brand header (since this path
2894
+ // fires only when there's no baseline to diff against).
2895
+ const verbose = isVerboseMode(args);
2896
+ const highSevFindings = findings.filter((f) => severityRank(f.severity) >= 3);
2897
+ const alertCount = highSevFindings.length;
2898
+
2765
2899
  console.log("");
2766
2900
  console.log(color("dim", ` ℹ first deploy-check for ${domain} — using current scan state (no baseline yet to diff against)`));
2767
2901
  console.log("");
2768
- console.log(formatAiBanner({
2769
- domain,
2770
- kind: "scan",
2771
- dbr: report.score,
2772
- grade: report.grade,
2773
- maxSeverity: maxSev,
2774
- shipDecision,
2775
- unreachable,
2776
- }));
2777
- console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
2902
+ console.log(formatBrandHeader(domain));
2903
+ console.log("");
2904
+ console.log(formatDecisionPulse({ shipDecision, alertCount, unreachable, diffContext: true }));
2905
+
2906
+ if (unreachable) {
2907
+ console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
2908
+ } else {
2909
+ if (alertCount > 0) {
2910
+ const alerts = formatAlertsLine(highSevFindings);
2911
+ if (alerts) console.log(alerts);
2912
+ }
2913
+ const tpl = formatTrustPostureLine(report);
2914
+ if (tpl) console.log(tpl);
2915
+ const vsl = formatVerifiedSignalsLine(report);
2916
+ if (vsl) console.log(vsl);
2917
+ if (verbose) {
2918
+ console.log("");
2919
+ console.log(formatHighYieldVerbose(report));
2920
+ } else {
2921
+ console.log(color("dim", " Run with --verbose to see all verified signals."));
2922
+ }
2923
+ }
2924
+
2778
2925
  console.log(formatAiFooterBlock({
2779
2926
  status: shipDecision,
2780
2927
  domain,
@@ -2910,16 +3057,41 @@ async function runTrustDiffCommand(args) {
2910
3057
  `If not intentional: revert the deploy or investigate the drift source.`,
2911
3058
  ];
2912
3059
 
2913
- console.log("");
2914
- console.log(formatAiBanner({
3060
+ // v0.16.0 high-yield rendering for trust-diff.
3061
+ const verbose = isVerboseMode(args);
3062
+ const diffNoChange = deltas.length === 0;
3063
+ const fakeReport = {
2915
3064
  domain,
2916
- kind: "trust-diff",
2917
- dbr: result.current_score,
3065
+ score: result.current_score,
2918
3066
  grade: result.current_grade,
2919
- maxSeverity: maxSev,
2920
- shipDecision,
2921
- }));
2922
- console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
3067
+ _meta: { lastChanged: result.last_changed },
3068
+ sectorRanking: result.sectorRanking,
3069
+ };
3070
+
3071
+ console.log("");
3072
+ console.log(formatBrandHeader(domain));
3073
+ console.log("");
3074
+ console.log(formatDecisionPulse({ shipDecision, alertCount: deltas.length, unreachable: false, diffContext: true }));
3075
+
3076
+ if (deltas.length > 0) {
3077
+ // Surface top deltas as alerts
3078
+ const alertFindings = deltas.slice(0, 3).map((d) => ({
3079
+ severity: d.severity || "medium",
3080
+ title: d.title || d.what_changed || d.type || "drift",
3081
+ id: d.id,
3082
+ }));
3083
+ const alerts = formatAlertsLine(alertFindings);
3084
+ if (alerts) console.log(alerts);
3085
+ if (deltas.length > 3) console.log(color("dim", ` +${deltas.length - 3} more (run with --verbose)`));
3086
+ }
3087
+ const tpl = formatTrustPostureLine(fakeReport);
3088
+ if (tpl) console.log(tpl);
3089
+ const vsl = formatVerifiedSignalsLine(fakeReport, { diffNoChange });
3090
+ if (vsl) console.log(vsl);
3091
+ if (!verbose) {
3092
+ console.log(color("dim", " Run with --verbose to see all verified signals."));
3093
+ }
3094
+
2923
3095
  console.log(formatAiFooterBlock({
2924
3096
  status: shipDecision,
2925
3097
  domain,
@@ -3123,16 +3295,82 @@ async function runPreviewDiffCommand(args) {
3123
3295
  `Each "- CSP weakened" or "~ HSTS weakened" reduces production safety.`,
3124
3296
  ];
3125
3297
 
3126
- console.log("");
3127
- console.log(formatAiBanner({
3128
- domain: result?.production?.domain || productionUrl,
3129
- kind: "preview-diff",
3130
- dbr: prevScore,
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).
3303
+ const verbose = isVerboseMode(args);
3304
+ const diffNoChange = !hasUnexpectedDiff;
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.
3332
+ domain: headerDomain,
3333
+ score: prevScore,
3131
3334
  grade: result?.preview?.grade,
3132
- maxSeverity: maxSev,
3133
- shipDecision,
3134
- }));
3135
- console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
3335
+ _meta: { lastChanged: result?.production?.lastChanged },
3336
+ sectorRanking: result?.production?.sectorRanking,
3337
+ };
3338
+ // Render top deltas as alert lines (1 per finding, max 3)
3339
+ const deltaLines = summaryLines.filter((l) => !/no meaningful/i.test(l));
3340
+
3341
+ console.log("");
3342
+ console.log(formatBrandHeader(headerDomain) + color("dim", " (preview ↔ production)"));
3343
+ console.log("");
3344
+ console.log(formatDecisionPulse({ shipDecision, alertCount: deltaLines.length, unreachable: false, diffContext: true }));
3345
+
3346
+ if (deltaLines.length > 0) {
3347
+ for (const dl of deltaLines.slice(0, 3)) {
3348
+ const sym = dl.startsWith("⛔") || dl.startsWith("-") ? "red" : dl.startsWith("⚠") || dl.startsWith("~") ? "yellow" : "dim";
3349
+ console.log(color(sym, dl));
3350
+ }
3351
+ if (deltaLines.length > 3) console.log(color("dim", ` +${deltaLines.length - 3} more (run with --verbose)`));
3352
+ }
3353
+ const tpl = formatTrustPostureLine(reportLike);
3354
+ if (tpl) console.log(tpl);
3355
+ const vsl = formatVerifiedSignalsLine(reportLike, { diffNoChange });
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);
3364
+ if (!verbose) {
3365
+ console.log(color("dim", " Run with --verbose to see per-signal breakdown."));
3366
+ }
3367
+
3368
+ // (Old banner + body removed in v0.16.2; new high-yield layout above
3369
+ // replaces them. AI footer still lands below for downstream agents.)
3370
+ if (verbose) {
3371
+ console.log("");
3372
+ console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
3373
+ }
3136
3374
  console.log(formatAiFooterBlock({
3137
3375
  status: shipDecision,
3138
3376
  domain: result?.production?.domain || productionUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.16.0",
3
+ "version": "0.16.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",