pqcheck 0.15.3 → 0.16.0

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 (2) hide show
  1. package/bin/pqcheck.js +293 -18
  2. package/package.json +1 -1
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.3";
27
+ const VERSION = "0.16.0";
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:
@@ -236,17 +236,18 @@ async function main() {
236
236
  // the server side.
237
237
  const fresh = args.includes("--fresh") || args.includes("--force");
238
238
  const aiMode = parseAiMode(args);
239
+ const verbose = isVerboseMode(args); // v0.16.0 — opt-in detailed panel
239
240
 
240
241
  // One-shot scan(s)
241
242
  let worstExit = 0;
242
243
  for (const domain of domains) {
243
- const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1, fresh, aiMode });
244
+ const exit = await runOneScan({ domain, format, quiet, threshold, webhookUrl, multi: domains.length > 1, fresh, aiMode, verbose });
244
245
  if (exit > worstExit) worstExit = exit;
245
246
  }
246
247
  process.exit(worstExit);
247
248
  }
248
249
 
249
- async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi, fresh, aiMode }) {
250
+ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi, fresh, aiMode, verbose }) {
250
251
  if (!quiet && format === "text") process.stderr.write(color("dim", `Scanning ${domain}${fresh ? " (forcing fresh)" : ""} ...`));
251
252
  let report;
252
253
  try {
@@ -405,17 +406,42 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
405
406
  ];
406
407
  }
407
408
 
409
+ // v0.16.0 high-yield rendering. Old layout was: banner + body + footer.
410
+ // New layout: brand header → decision pulse → alerts (if any) → trust
411
+ // posture → verified-signals summary → (verbose body if --verbose) →
412
+ // AI footer block. The structured footer is always last so downstream
413
+ // agents can parse ship_decision regardless of the verbose flag.
414
+ const highSevFindings = findings.filter((f) => severityRank(f.severity) >= 3);
415
+ const alertCount = highSevFindings.length;
416
+
408
417
  console.log("");
409
- console.log(formatAiBanner({
410
- domain,
411
- kind: "scan",
412
- dbr: report.score,
413
- grade: report.grade,
414
- maxSeverity: maxSev,
415
- shipDecision,
416
- unreachable,
417
- }));
418
- console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
418
+ console.log(formatBrandHeader(domain));
419
+ console.log("");
420
+ console.log(formatDecisionPulse({ shipDecision, alertCount, unreachable, diffContext: false }));
421
+
422
+ if (unreachable) {
423
+ // Unreachable path: surface the friendly explanation + next actions
424
+ // immediately (no "verified signals" block — there's nothing to
425
+ // verify if we couldn't reach the site).
426
+ console.log(formatAiBody({ topIssue, whyMatters, nextActions }));
427
+ } else {
428
+ // Alerts above trust posture so the actionable change is first.
429
+ if (alertCount > 0) {
430
+ const alerts = formatAlertsLine(highSevFindings);
431
+ if (alerts) console.log(alerts);
432
+ }
433
+ const tpl = formatTrustPostureLine(report);
434
+ if (tpl) console.log(tpl);
435
+ const vsl = formatVerifiedSignalsLine(report);
436
+ if (vsl) console.log(vsl);
437
+ if (verbose) {
438
+ console.log("");
439
+ console.log(formatHighYieldVerbose(report));
440
+ } else {
441
+ console.log(color("dim", " Run with --verbose to see all verified signals."));
442
+ }
443
+ }
444
+
419
445
  console.log(formatAiFooterBlock({
420
446
  status: shipDecision,
421
447
  domain,
@@ -424,6 +450,7 @@ async function runOneScan({ domain, format, quiet, threshold, webhookUrl, multi,
424
450
  grade: report.grade || "",
425
451
  max_severity: maxSev,
426
452
  ship_decision: shipDecision,
453
+ unreachable: unreachable ? "true" : "false",
427
454
  top_issue: topFinding?.id || topFinding?.title || "none",
428
455
  findings_high: findings.filter((f) => severityRank(f.severity) === 3).length,
429
456
  findings_critical: findings.filter((f) => severityRank(f.severity) === 4).length,
@@ -727,6 +754,254 @@ function formatAiFooterBlock(fields) {
727
754
  return color("dim", lines.join("\n"));
728
755
  }
729
756
 
757
+ // v0.16.0 — high-yield output formatters.
758
+ //
759
+ // Design (per user direction 2026-05-22): every scan should deliver
760
+ // substantive insight, but the default must stay tight (3-5 lines max) so
761
+ // running pqcheck 100 times/month doesn't feel like a mini audit every
762
+ // time. The verbose panel is opt-in via `--verbose`.
763
+ //
764
+ // Three rendering tiers:
765
+ // 1. Status line (1 line): ◆ Cipherwake · X ✓ PASS · DBR 4.7 C · stable 14d
766
+ // 2. Default CLI (3-5 lines): "✓ PASS · no public-surface drift detected"
767
+ // "✓ Trust posture: DBR 4.7 C · top 23% in fintech · stable 14d"
768
+ // "✓ Verified 8 signals · scripts, headers, cookies, cert/SPKI, ..."
769
+ // 3. Verbose (--verbose, 8+ lines): full per-signal breakdown
770
+ // =============================================================================
771
+
772
+ // Plain-English percentile copy. report.sectorRanking.percentile uses
773
+ // "100 = worst, 0 = best". Customer copy inverts to "top N%" framing.
774
+ // No cryptic p-quantiles — `p23` reads like jargon; `top 23%` is universal.
775
+ function formatPercentileCopy(percentile, sectorName) {
776
+ if (typeof percentile !== "number" || !isFinite(percentile)) return null;
777
+ const sector = sectorName || "industry";
778
+ if (percentile <= 10) return `top 10% in ${sector}`;
779
+ if (percentile <= 25) return `top 25% in ${sector}`;
780
+ if (percentile <= 50) return `above median in ${sector}`;
781
+ if (percentile <= 75) return `below median in ${sector}`;
782
+ if (percentile <= 90) return `bottom 25% in ${sector}`;
783
+ return `bottom 10% in ${sector}`;
784
+ }
785
+
786
+ // "stable 14d" / "drifted 2d ago" / "first scan" depending on report data.
787
+ // Reads from report._meta.lastChanged (smartCache populates) — if not
788
+ // available, falls back to "tracked since first scan."
789
+ function formatStabilityCopy(report) {
790
+ const lastChanged = report?._meta?.lastChanged || report?._meta?.lastUpdated;
791
+ if (lastChanged) {
792
+ const ms = Date.now() - new Date(lastChanged).getTime();
793
+ const days = Math.floor(ms / 86400000);
794
+ if (days <= 0) return "drifted today";
795
+ if (days < 7) return `drifted ${days}d ago`;
796
+ return `stable ${days}d`;
797
+ }
798
+ return null;
799
+ }
800
+
801
+ // "✓ Trust posture: DBR 4.7 C · top 23% in fintech · stable 14d"
802
+ // - Only includes sub-segments we have data for. If we can't compute a
803
+ // section, we omit it rather than padding with "—" / "n/a".
804
+ function formatTrustPostureLine(report) {
805
+ const dbr = typeof report?.score === "number" ? report.score.toFixed(1) : null;
806
+ const grade = report?.grade || "";
807
+ if (!dbr) return null;
808
+ const parts = [`DBR ${dbr}${grade ? " " + grade : ""}`];
809
+ const sr = report?.sectorRanking;
810
+ const pctCopy = formatPercentileCopy(sr?.percentile, sr?.sectorLabel || sr?.sectorName || sr?.sector);
811
+ if (pctCopy) parts.push(pctCopy);
812
+ const stability = formatStabilityCopy(report);
813
+ if (stability) parts.push(stability);
814
+ return color("green", "✓ ") + color("bold", "Trust posture: ") + parts.join(color("dim", " · "));
815
+ }
816
+
817
+ // Count verified signals from the scan report. A "signal" is something
818
+ // Cipherwake actively checked AND would surface in a diff if it changed.
819
+ // Returns: { count, categories }.
820
+ function countVerifiedSignals(report) {
821
+ const cats = [];
822
+ // 1. Third-party scripts
823
+ if (report?.publicDeps?.fetched || Array.isArray(report?.publicDeps?.thirdParties)) cats.push("scripts");
824
+ // 2. Security headers (CSP/HSTS/XFO at minimum)
825
+ if (report?.httpHeaders?.reachable) cats.push("headers");
826
+ // 3. Cookies (v0.16.0 — new)
827
+ if (report?.cookies?.reachable) cats.push("cookies");
828
+ // 4. Cert / SPKI
829
+ if (report?.cert || report?._meta?.certSerial) cats.push("cert/SPKI");
830
+ // 5. Source maps (v0.16.0 — new)
831
+ if (report?.sourceMaps?.reachable) cats.push("source maps");
832
+ // 6. TLS posture
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");
837
+ // 8. Subdomain scale (from CT log scan)
838
+ if (typeof report?.publicSurface?.subdomainCount === "number") cats.push("subdomain scale");
839
+ return { count: cats.length, categories: cats };
840
+ }
841
+
842
+ // Default ("Verified N signals · scripts, headers, ...") is for first scans
843
+ // where there's no baseline to compare. With a baseline + no drift we
844
+ // switch to "Security-relevant surface unchanged" — it directly answers
845
+ // the customer's question ("did anything that matters change?") instead
846
+ // of just naming what we checked. User direction 2026-05-22.
847
+ function formatVerifiedSignalsLine(report, options = {}) {
848
+ const { count, categories } = countVerifiedSignals(report);
849
+ if (count === 0) return null;
850
+ const head = options.diffNoChange === true
851
+ ? "Security-relevant surface unchanged"
852
+ : `Verified ${count} signal${count === 1 ? "" : "s"}`;
853
+ return color("green", "✓ ") + color("bold", head) + color("dim", " · " + categories.join(", "));
854
+ }
855
+
856
+ // Default high-yield panel — 3-5 lines max. Used in `pqcheck <domain> --ai`
857
+ // and `pqcheck deploy-check --ai` when no critical findings need to be
858
+ // surfaced prominently (otherwise the alerts go above this block).
859
+ function formatHighYieldDefault(report, { shipDecision, diffContext, diffNoChange } = {}) {
860
+ const lines = [];
861
+ // Header pulse: "✓ PASS · no public-surface drift detected"
862
+ // "⚠ REVIEW · 2 public-surface changes"
863
+ // "⛔ BLOCK · protected path changed"
864
+ if (diffContext) {
865
+ lines.push(diffContext);
866
+ }
867
+ const tpl = formatTrustPostureLine(report);
868
+ if (tpl) lines.push(tpl);
869
+ // diffNoChange flag tells the verified-signals line to switch wording
870
+ // from "Verified N signals" to "Security-relevant surface unchanged" —
871
+ // only set this when there IS a baseline to compare against AND the
872
+ // diff returned zero deltas.
873
+ const vsl = formatVerifiedSignalsLine(report, { diffNoChange: !!diffNoChange });
874
+ if (vsl) lines.push(vsl);
875
+ lines.push(color("dim", " Run with --verbose to see all verified signals."));
876
+ return lines.join("\n");
877
+ }
878
+
879
+ // Verbose panel — opt-in via --verbose. Per-signal bullet breakdown.
880
+ // Used for first-run, troubleshooting, public benchmark/report pages.
881
+ // The caller renders the trust-posture line ABOVE this block, so we
882
+ // don't duplicate it here.
883
+ function formatHighYieldVerbose(report) {
884
+ const lines = [];
885
+ lines.push(color("green", "✓ ") + color("bold", "Verified this deploy:"));
886
+ // Scripts
887
+ if (Array.isArray(report?.publicDeps?.thirdParties)) {
888
+ const hosts = report.publicDeps.thirdParties.slice(0, 6).map(d => d.host || d).filter(Boolean);
889
+ const more = report.publicDeps.thirdParties.length > 6
890
+ ? `, +${report.publicDeps.thirdParties.length - 6} more` : "";
891
+ lines.push(color("dim", ` · ${hosts.length} third-party script${hosts.length === 1 ? "" : "s"} intact (${hosts.join(", ")}${more})`));
892
+ }
893
+ // Headers (always-on)
894
+ const hh = report?.httpHeaders;
895
+ if (hh?.reachable) {
896
+ const hdrs = [];
897
+ if (hh.csp?.present) hdrs.push("CSP enforced");
898
+ if (hh.hsts?.present) hdrs.push(`HSTS${hh.hsts.preload ? " preload" : ""}`);
899
+ if (hh.xFrameOptions?.present) hdrs.push(`X-Frame-Options ${hh.xFrameOptions.value || ""}`.trim());
900
+ if (hh.xContentTypeOptions?.present) hdrs.push("X-Content-Type-Options nosniff");
901
+ if (hh.referrerPolicy?.present) hdrs.push(`Referrer-Policy ${hh.referrerPolicy.value || ""}`.trim());
902
+ if (hh.permissionsPolicy?.present) hdrs.push("Permissions-Policy set");
903
+ if (hdrs.length > 0) lines.push(color("dim", ` · ${hdrs.join(" · ")}`));
904
+ }
905
+ // Cert
906
+ if (report?.cert || report?._meta?.certSerial) {
907
+ const cert = report.cert || {};
908
+ const days = cert.notAfter
909
+ ? Math.floor((new Date(cert.notAfter).getTime() - Date.now()) / 86400000)
910
+ : null;
911
+ const certLine = days !== null
912
+ ? `Cert: ${days}d until expiry${cert.issuer ? " · issued by " + cert.issuer : ""}`
913
+ : `Cert: ${cert.issuer || "issued"}`;
914
+ lines.push(color("dim", ` · ${certLine}`));
915
+ }
916
+ // Cookies (v0.16.0)
917
+ const ck = report?.cookies;
918
+ if (ck?.reachable) {
919
+ if (ck.count === 0) {
920
+ lines.push(color("dim", ` · Cookies: none set`));
921
+ } else {
922
+ const flags = [];
923
+ if (!ck.anyMissingSecure) flags.push("all Secure");
924
+ if (!ck.anyMissingHttpOnly) flags.push("all HttpOnly");
925
+ if (!ck.anyMissingSameSite) flags.push("all SameSite set");
926
+ lines.push(color("dim", ` · Cookies: ${ck.count} set${flags.length ? " · " + flags.join(" + ") : ""}`));
927
+ }
928
+ }
929
+ // Source maps (v0.16.0)
930
+ const sm = report?.sourceMaps;
931
+ if (sm?.reachable) {
932
+ lines.push(color("dim", ` · ${sm.exposed ? `Source maps EXPOSED on ${sm.exposedCount} script(s)` : "No source maps exposed"}`));
933
+ }
934
+ // Mixed content
935
+ if (Array.isArray(report?.publicDeps?.thirdParties)) {
936
+ const insecure = report.publicDeps.thirdParties.filter(d => d.loadedOverHttps === false).length;
937
+ lines.push(color("dim", ` · ${insecure === 0 ? "No mixed-content (all third-parties over HTTPS)" : `Mixed content: ${insecure} resource(s) over HTTP`}`));
938
+ }
939
+ return lines.join("\n");
940
+ }
941
+
942
+ // Helper: pretty-print 1-3 alerts (when ship_decision = review/block) ABOVE
943
+ // the trust-posture line, so the actionable change is the FIRST thing the
944
+ // customer sees. Each alert is one line with an emoji + concise summary.
945
+ function formatAlertsLine(findings, max = 3) {
946
+ if (!Array.isArray(findings) || findings.length === 0) return null;
947
+ const sortedAlerts = [...findings].sort(
948
+ (a, b) => severityRank(b.severity) - severityRank(a.severity),
949
+ );
950
+ const lines = [];
951
+ for (const f of sortedAlerts.slice(0, max)) {
952
+ const sev = String(f.severity || "").toLowerCase();
953
+ const sym = sev === "critical" ? "⛔" : sev === "high" ? "⚠" : "ⓘ";
954
+ const c = sev === "critical" ? "red" : sev === "high" ? "yellow" : "dim";
955
+ const title = f.title || f.id || "finding";
956
+ lines.push(color(c, `${sym} ${title}`));
957
+ }
958
+ if (sortedAlerts.length > max) {
959
+ lines.push(color("dim", ` +${sortedAlerts.length - max} more (run with --verbose)`));
960
+ }
961
+ return lines.join("\n");
962
+ }
963
+
964
+ function isVerboseMode(args) {
965
+ return args.includes("--verbose") || args.includes("--explain");
966
+ }
967
+
968
+ // One-line "decision pulse" — the prominent first line that tells the customer
969
+ // the answer to "is this deploy safe to announce?" in <80 characters.
970
+ // Wording branches on context:
971
+ // - Diff context (deploy-check / trust-diff / preview-diff): talks about
972
+ // CHANGES vs the baseline ("no public-surface drift detected").
973
+ // - Basic scan (no baseline): talks about FINDINGS from this scan
974
+ // ("no high-severity findings").
975
+ function formatDecisionPulse({ shipDecision, alertCount, unreachable, diffContext }) {
976
+ if (unreachable) {
977
+ return color("red", "⛔ UNREACHABLE") + color("dim", " · scanner couldn't reach the domain");
978
+ }
979
+ const n = alertCount || 0;
980
+ if (shipDecision === "pass") {
981
+ const tail = diffContext
982
+ ? "no public-surface drift detected"
983
+ : "no high-severity findings";
984
+ return color("green", "✓ PASS") + color("dim", " · " + tail);
985
+ }
986
+ if (shipDecision === "review") {
987
+ const noun = diffContext ? "public-surface change" : "high-severity finding";
988
+ return color("yellow", "⚠ REVIEW") + color("dim", ` · ${n} ${noun}${n === 1 ? "" : "s"}`);
989
+ }
990
+ if (shipDecision === "block") {
991
+ const noun = diffContext ? "critical change" : "critical finding";
992
+ return color("red", "⛔ BLOCK") + color("dim", ` · ${n} ${noun}${n === 1 ? "" : "s"}`);
993
+ }
994
+ return color("dim", "· no decision");
995
+ }
996
+
997
+ // "◆ Cipherwake — domain" — the v0.16.0 brand+domain header line. Shorter
998
+ // than the old AI banner so the decision-pulse line below it carries the
999
+ // status. The banner color stays neutral (no decision color) because the
1000
+ // decision-pulse line owns that semantic.
1001
+ function formatBrandHeader(domain) {
1002
+ return color("bold", "◆ Cipherwake") + color("dim", " — ") + color("bold", domain);
1003
+ }
1004
+
730
1005
  // Persist last-scan state to ~/.config/cipherwake/last-scan.json.
731
1006
  // Feeds the cipherwake-statusline + cipherwake-prompt-hook + cipherwake-chat-hook
732
1007
  // scripts so users get persistent ambient state in their AI coder's surfaces.
@@ -4770,7 +5045,7 @@ esac
4770
5045
  const CLAUDE_STATUSLINE_CONFIG_SNIPPET = `
4771
5046
  "statusLine": {
4772
5047
  "type": "command",
4773
- "command": "npx cipherwake-statusline"
5048
+ "command": "npx --package=pqcheck@latest cipherwake-statusline"
4774
5049
  }`;
4775
5050
 
4776
5051
  // R74-confirm SHIP #14-15 (GPT 2026-05-22): network connectivity diagnostic.
@@ -5107,12 +5382,12 @@ async function runSetupCommand(args) {
5107
5382
  if (existed && settings.statusLine && typeof settings.statusLine === "object") {
5108
5383
  // Already has a statusLine config — don't overwrite.
5109
5384
  console.log(color("dim", ` ⊝ ~/.claude/settings.json already has a statusLine entry — leaving alone`));
5110
- console.log(color("dim", ` To use the Cipherwake statusline instead, set: "command": "npx cipherwake-statusline"`));
5385
+ console.log(color("dim", ` To use the Cipherwake statusline instead, set: "command": "npx --package=pqcheck@latest cipherwake-statusline"`));
5111
5386
  installSummary.push({ component: "Claude Code statusLine", path: settingsPath, status: "skipped-existing-config" });
5112
5387
  } else {
5113
5388
  const backupPath = existed ? await backupSettingsJson(settingsPath) : null;
5114
5389
  if (backupPath) console.log(color("dim", ` backup: ${backupPath}`));
5115
- settings.statusLine = { type: "command", command: "npx cipherwake-statusline" };
5390
+ settings.statusLine = { type: "command", command: "npx --package=pqcheck@latest cipherwake-statusline" };
5116
5391
  await fs.mkdir(path.dirname(settingsPath), { recursive: true });
5117
5392
  await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
5118
5393
  console.log(color("green", ` ✓ added statusLine config → ~/.claude/settings.json`));
@@ -5151,7 +5426,7 @@ async function runSetupCommand(args) {
5151
5426
  }
5152
5427
  bashEntry.hooks = bashEntry.hooks || [];
5153
5428
 
5154
- const cipherwakeHookCmd = "npx cipherwake-chat-hook";
5429
+ const cipherwakeHookCmd = "npx --package=pqcheck@latest cipherwake-chat-hook";
5155
5430
  const alreadyInstalled = bashEntry.hooks.some(
5156
5431
  (h) => h?.type === "command" && typeof h?.command === "string" && h.command.includes("cipherwake-chat-hook"),
5157
5432
  );
@@ -5196,7 +5471,7 @@ async function runSetupCommand(args) {
5196
5471
  settings.hooks = settings.hooks || {};
5197
5472
  settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit || [];
5198
5473
 
5199
- const cipherwakeHookCmd = "npx cipherwake-prompt-hook";
5474
+ const cipherwakeHookCmd = "npx --package=pqcheck@latest cipherwake-prompt-hook";
5200
5475
  const alreadyInstalled = settings.hooks.UserPromptSubmit.some(
5201
5476
  (entry) => Array.isArray(entry?.hooks) && entry.hooks.some(
5202
5477
  (h) => h?.type === "command" && typeof h?.command === "string" && h.command.includes("cipherwake-prompt-hook"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.15.3",
3
+ "version": "0.16.0",
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",