pqcheck 0.16.24 → 0.16.28

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
@@ -8,7 +8,7 @@
8
8
  [![npm downloads](https://img.shields.io/npm/dm/pqcheck.svg?style=flat-square&color=06b6d4)](https://www.npmjs.com/package/pqcheck)
9
9
  [![license](https://img.shields.io/npm/l/pqcheck.svg?style=flat-square&color=06b6d4)](./LICENSE)
10
10
 
11
- > **Latest: v0.16.24** — **Route Assertions (R87).** Closes the gap that left Cipherwake silent on backend/admin-heavy deploys. Declare your private routes in `.cipherwake.json` and Cipherwake asserts they're still gated on every deploy. Any critical failure (declared `protected`, actually `exposed`) blocks the deploy unconditionally the catastrophic "/admin became public" case. Three sources merge: customer config + nearly-universal defaults + auto-detected from `robots.txt` and homepage. New `/methodology/route-assertions` documents the feature; `/methodology/why-not-authenticated-crawling` explains the design decision to never hold customer credentials. [Full changelog →](./CHANGELOG.md)
11
+ > **Latest: v0.16.28** — Cipherwake is now the independent gate that fires on every deploy. Catches: broken deploys (5xx / framework error / blank / landmark missing), leaked secrets (AWS / GitHub / Stripe / OpenAI / Supabase service-role JWTs with FP-discrimination that ignores `NEXT_PUBLIC_` / `pk_*` / anon JWTs), dropped cookie flags (HttpOnly / Secure / SameSite), missing required HTTP headers, sensitive-file leaks (`.env` / `.git/config` / `/api/debug`), **new public routes since last deploy** (AI accidentally shipped `/api/internal`), **new third-party scripts** (supply-chain alert), and **TLS cert expiring soon**. Blocks the deploy before the AI announces it. Local stats (`pqcheck stats`) — your results stay on your machine, no telemetry. All in-lane: public surface, no credentials, provider-neutral. [Full changelog →](./CHANGELOG.md)
12
12
 
13
13
  ## Two ways to use it
14
14
 
package/bin/pqcheck.js CHANGED
@@ -188,6 +188,17 @@ async function main() {
188
188
  if (args[0] === "init") {
189
189
  return runInitCommand(args.slice(1));
190
190
  }
191
+ // R89.LOCAL — local-only stats commands. Read/write .cipherwake/stats.json
192
+ // in the repo root. ZERO network requests. Privacy-by-design.
193
+ if (args[0] === "stats") {
194
+ return runStatsCommand(args.slice(1));
195
+ }
196
+ if (args[0] === "dismiss") {
197
+ return runDismissCommand(args.slice(1));
198
+ }
199
+ if (args[0] === "confirm") {
200
+ return runConfirmCommand(args.slice(1));
201
+ }
191
202
  if (args[0] === "deploy-check") {
192
203
  return runDeployCheckCommand(args.slice(1));
193
204
  }
@@ -991,6 +1002,18 @@ async function readRouteAssertionsConfig() {
991
1002
  _warnConfigIssue(`duplicate path "${normPath}" in assertions — second occurrence ignored.`);
992
1003
  continue;
993
1004
  }
1005
+ // R87.6 — validate bodyContains / bodyAbsent if present.
1006
+ if (a.bodyContains !== undefined && typeof a.bodyContains !== "string" && !(Array.isArray(a.bodyContains) && a.bodyContains.every((s) => typeof s === "string"))) {
1007
+ _warnConfigIssue(`assertion #${idx + 1} (path "${normPath}") has invalid "bodyContains" — must be a string or array of strings. Body check disabled for this assertion.`);
1008
+ delete a.bodyContains;
1009
+ }
1010
+ if (a.bodyAbsent !== undefined && typeof a.bodyAbsent !== "string" && !(Array.isArray(a.bodyAbsent) && a.bodyAbsent.every((s) => typeof s === "string"))) {
1011
+ _warnConfigIssue(`assertion #${idx + 1} (path "${normPath}") has invalid "bodyAbsent" — must be a string or array of strings. Body check disabled for this assertion.`);
1012
+ delete a.bodyAbsent;
1013
+ }
1014
+ if ((a.bodyContains !== undefined || a.bodyAbsent !== undefined) && a.expect !== "protected") {
1015
+ _warnConfigIssue(`assertion #${idx + 1} (path "${normPath}") has body assertions but expect=${JSON.stringify(a.expect)}. Body checks only run on expect=protected.`);
1016
+ }
994
1017
  seen.add(normPath);
995
1018
  cleaned.push({ ...a, path: normPath });
996
1019
  }
@@ -1016,10 +1039,49 @@ async function readRouteAssertionsConfig() {
1016
1039
  // the deploy unconditionally — this is the catastrophic "admin became
1017
1040
  // public" case the feature exists to catch. High/medium failures promote
1018
1041
  // to `review`.
1042
+ // R89.G (2026-06-05) — narrative line for every scan. The reason "drift-only"
1043
+ // feels not-worth-paying-for is the silent-on-pass UX. A one-liner narrative
1044
+ // on every scan ("Public surface since last deploy: 0 routes changed, 0
1045
+ // headers regressed, deploy healthy") makes the gate FEEL alive even when
1046
+ // it's green. The AI coder reads this in the response and can surface it
1047
+ // to the user in plain English.
1048
+ function buildTrustDiffNarrative({ deltaCount, assertionsFailedCount, assertionsCriticalCount, deployStatus, secretsCriticalCount, secretsFindingsTotal, cookieFailedCount, headerFailedCount, posture }) {
1049
+ const bits = [];
1050
+ // Lead with the catastrophic items first
1051
+ if (secretsCriticalCount > 0) bits.push(`${secretsCriticalCount} leaked secret${secretsCriticalCount === 1 ? "" : "s"} in JS bundle`);
1052
+ if (deployStatus && deployStatus !== "healthy" && deployStatus !== "unreachable") {
1053
+ bits.push(`deploy ${deployStatus.replace(/_/g, " ")}`);
1054
+ }
1055
+ if (assertionsCriticalCount > 0) bits.push(`${assertionsCriticalCount} declared route${assertionsCriticalCount === 1 ? "" : "s"} regressed`);
1056
+ if (cookieFailedCount > 0) bits.push(`${cookieFailedCount} cookie flag${cookieFailedCount === 1 ? "" : "s"} weakened`);
1057
+ if (headerFailedCount > 0) bits.push(`${headerFailedCount} required header${headerFailedCount === 1 ? "" : "s"} missing`);
1058
+ if (deltaCount > 0) bits.push(`${deltaCount} trust-surface change${deltaCount === 1 ? "" : "s"} since baseline`);
1059
+ if (assertionsFailedCount > 0 && assertionsCriticalCount === 0) bits.push(`${assertionsFailedCount} route assertion${assertionsFailedCount === 1 ? "" : "s"} for review`);
1060
+ if (secretsFindingsTotal > secretsCriticalCount) bits.push(`${secretsFindingsTotal - secretsCriticalCount} non-critical secret finding${(secretsFindingsTotal - secretsCriticalCount) === 1 ? "" : "s"}`);
1061
+ if (bits.length === 0) {
1062
+ return `Public surface since last deploy: no drift, all declared invariants pass${deployStatus === "healthy" ? ", deploy healthy" : ""}${posture ? `, posture ${posture}` : ""}.`;
1063
+ }
1064
+ return `Public surface since last deploy: ${bits.join("; ")}${deployStatus === "healthy" ? " (homepage healthy)" : ""}.`;
1065
+ }
1066
+
1019
1067
  function shipDecisionFromAssertions(summary) {
1020
1068
  if (!summary) return null;
1021
- if (summary.criticalFailures && summary.criticalFailures.length > 0) return "block";
1022
- if (summary.failed > 0) return "review";
1069
+ // R89/R88 wave 2 + R90 fold in deploy health + secrets + cookies + headers + TLS.
1070
+ // Critical case auto-blocks; any non-critical failure promotes to review.
1071
+ const deployBroken = summary.deployHealth && summary.deployHealth.status !== "healthy" && summary.deployHealth.status !== "unreachable";
1072
+ const secretsLeaked = summary.secrets && summary.secrets.criticalCount > 0;
1073
+ const cookieCritical = (summary.cookieCriticalFailures || 0) > 0;
1074
+ const tlsCritical = summary.tlsExpiry && !summary.tlsExpiry.passed && summary.tlsExpiry.severity === "critical";
1075
+ if ((summary.criticalFailures && summary.criticalFailures.length > 0) ||
1076
+ (summary.headerCriticalFailures && summary.headerCriticalFailures.length > 0) ||
1077
+ deployBroken || secretsLeaked || cookieCritical || tlsCritical) {
1078
+ return "block";
1079
+ }
1080
+ const headerFailed = (summary.headerResults || []).filter((r) => !r.passed).length;
1081
+ const cookieFailed = (summary.cookieResults || []).filter((r) => !r.passed).length;
1082
+ const secretsFound = summary.secrets && summary.secrets.findings && summary.secrets.findings.length > 0;
1083
+ const tlsFailed = summary.tlsExpiry && !summary.tlsExpiry.passed;
1084
+ if (summary.failed > 0 || headerFailed > 0 || cookieFailed > 0 || secretsFound || tlsFailed) return "review";
1023
1085
  return "pass";
1024
1086
  }
1025
1087
 
@@ -1037,12 +1099,85 @@ function formatRouteAssertionsBlock(summary) {
1037
1099
  lines.push(`sources_default=${summary.sources.default}`);
1038
1100
  lines.push(`sources_auto=${summary.sources.auto}`);
1039
1101
  lines.push(`sources_auto_suppressed=${summary.sources.autoSuppressed || 0}`);
1102
+ lines.push(`sources_dismissed=${summary.sources.dismissed || 0}`);
1103
+ // R88 — header invariants block alongside route assertions
1104
+ if (Array.isArray(summary.headerResults) && summary.headerResults.length > 0) {
1105
+ lines.push("--- HEADER INVARIANTS ---");
1106
+ lines.push(`header_total=${summary.headerResults.length}`);
1107
+ lines.push(`header_passed=${summary.headerResults.filter((r) => r.passed).length}`);
1108
+ lines.push(`header_failed=${summary.headerResults.filter((r) => !r.passed).length}`);
1109
+ lines.push(`header_critical_failures=${(summary.headerCriticalFailures || []).length}`);
1110
+ for (const h of summary.headerResults) {
1111
+ const status = h.passed ? "PASS" : "FAIL";
1112
+ const sev = h.passed ? "info" : h.severity;
1113
+ const wantVal = h.expectedValue ? `="${h.expectedValue}"` : "";
1114
+ const gotVal = h.actualValue !== null && h.actualValue !== undefined ? `, got "${String(h.actualValue).slice(0, 80)}"` : ", got <absent>";
1115
+ const why = h.why ? ` — ${h.why}` : "";
1116
+ lines.push(`${status} [${sev}] header:${h.header} expect=${h.expect}${wantVal}${gotVal}${why}`);
1117
+ }
1118
+ }
1119
+ // R89 — deploy-health check
1120
+ if (summary.deployHealth) {
1121
+ const dh = summary.deployHealth;
1122
+ lines.push("--- DEPLOY HEALTH ---");
1123
+ lines.push(`deploy_status=${dh.status}`);
1124
+ lines.push(`deploy_http_status=${dh.httpStatus === null ? "?" : dh.httpStatus}`);
1125
+ lines.push(`deploy_body_bytes=${dh.bodyBytes}`);
1126
+ if (dh.matchedErrorMarker) lines.push(`deploy_matched_error_marker=${dh.matchedErrorMarker}`);
1127
+ if (dh.missingLandmark) lines.push(`deploy_missing_landmark=${dh.missingLandmark}`);
1128
+ if (dh.errorReason) lines.push(`deploy_error_reason=${dh.errorReason}`);
1129
+ lines.push(`deploy_summary=${dh.summary}`);
1130
+ }
1131
+ // R88 wave 2 — secret scanner
1132
+ if (summary.secrets) {
1133
+ const s = summary.secrets;
1134
+ lines.push("--- SECRET SCAN ---");
1135
+ lines.push(`secrets_scanned=${s.scanned}`);
1136
+ lines.push(`secrets_bundles_fetched=${s.bundlesFetched}`);
1137
+ lines.push(`secrets_findings_total=${s.findings.length}`);
1138
+ lines.push(`secrets_critical_count=${s.criticalCount}`);
1139
+ for (const f of s.findings) {
1140
+ lines.push(`FAIL [${f.severity}] secret:${f.patternId} source=${f.source} redacted=${f.redactedSample} — ${f.name}`);
1141
+ }
1142
+ }
1143
+ // R90 — TLS cert expiry invariant
1144
+ if (summary.tlsExpiry) {
1145
+ const t = summary.tlsExpiry;
1146
+ lines.push("--- TLS EXPIRY ---");
1147
+ lines.push(`tls_checked=${t.checked}`);
1148
+ lines.push(`tls_days_remaining=${t.daysRemaining === null ? "?" : t.daysRemaining}`);
1149
+ lines.push(`tls_min_required=${t.minRequired}`);
1150
+ lines.push(`tls_passed=${t.passed}`);
1151
+ lines.push(`tls_summary=${t.summary}`);
1152
+ }
1153
+ // R88 wave 2 — cookie invariants
1154
+ if (Array.isArray(summary.cookieResults) && summary.cookieResults.length > 0) {
1155
+ lines.push("--- COOKIE INVARIANTS ---");
1156
+ lines.push(`cookie_total=${summary.cookieResults.length}`);
1157
+ lines.push(`cookie_passed=${summary.cookieResults.filter((r) => r.passed).length}`);
1158
+ lines.push(`cookie_failed=${summary.cookieResults.filter((r) => !r.passed).length}`);
1159
+ lines.push(`cookie_critical_failures=${summary.cookieCriticalFailures || 0}`);
1160
+ for (const c of summary.cookieResults) {
1161
+ const status = c.passed ? "PASS" : "FAIL";
1162
+ const sev = c.passed ? "info" : c.severity;
1163
+ const matched = c.matchedCookies.length > 0 ? c.matchedCookies.join(",") : "<no matching cookies>";
1164
+ const why = c.why ? ` — ${c.why}` : "";
1165
+ if (c.failures.length === 0) {
1166
+ lines.push(`${status} [${sev}] cookie:${c.namePattern} matched=${matched}${why}`);
1167
+ } else {
1168
+ for (const f of c.failures) {
1169
+ lines.push(`FAIL [${sev}] cookie:${c.namePattern} cookie=${f.cookieName} reason=${f.reason}${why}`);
1170
+ }
1171
+ }
1172
+ }
1173
+ }
1040
1174
  for (const r of summary.results) {
1041
1175
  const status = r.passed ? "PASS" : "FAIL";
1042
1176
  const sev = r.passed ? "info" : r.severity;
1043
1177
  const why = r.why ? ` — ${r.why}` : "";
1044
1178
  const errReason = r.errorReason ? ` reason=${r.errorReason}` : "";
1045
- lines.push(`${status} [${sev}] ${r.source}:${r.path} expected=${r.expected} actual=${r.actual} status=${r.status === null ? "?" : r.status}${errReason}${why}`);
1179
+ const bodyTag = r.bodyCheck && r.bodyCheck !== "body_check_skipped" ? ` body=${r.bodyCheck}` : "";
1180
+ lines.push(`${status} [${sev}] ${r.source}:${r.path} expected=${r.expected} actual=${r.actual} status=${r.status === null ? "?" : r.status}${errReason}${bodyTag}${why}`);
1046
1181
  }
1047
1182
  lines.push("END_CIPHERWAKE_ROUTE_ASSERTIONS");
1048
1183
  return color("dim", lines.join("\n"));
@@ -3810,15 +3945,41 @@ async function runTrustDiffCommand(args) {
3810
3945
  const effectiveShipWithAssertions = assertionShipImpact
3811
3946
  ? (SHIP_DECISION_RANK[assertionShipImpact] > SHIP_DECISION_RANK[effectiveShip] ? assertionShipImpact : effectiveShip)
3812
3947
  : effectiveShip;
3948
+ // R88 wave 2 + R89 — fold in deploy health, secrets, cookies.
3949
+ const dh = routeAssertions?.deployHealth || null;
3950
+ const sc = routeAssertions?.secrets || null;
3951
+ const cr2 = routeAssertions?.cookieResults || null;
3813
3952
  const assertionFields = routeAssertions ? {
3814
3953
  assertions_total: routeAssertions.total,
3815
3954
  assertions_passed: routeAssertions.passed,
3816
3955
  assertions_failed: routeAssertions.failed,
3817
3956
  assertions_critical_failures: routeAssertions.criticalFailures.length,
3818
- assertions_sources: `customer=${routeAssertions.sources.customer},default=${routeAssertions.sources.default},auto=${routeAssertions.sources.auto},auto_suppressed=${routeAssertions.sources.autoSuppressed || 0}`,
3957
+ assertions_sources: `customer=${routeAssertions.sources.customer},default=${routeAssertions.sources.default},auto=${routeAssertions.sources.auto},auto_suppressed=${routeAssertions.sources.autoSuppressed || 0},dismissed=${routeAssertions.sources.dismissed || 0}`,
3819
3958
  assertion_top_failure: routeAssertions.criticalFailures[0]
3820
3959
  ? `${routeAssertions.criticalFailures[0].path}: expected ${routeAssertions.criticalFailures[0].expected}, got ${routeAssertions.criticalFailures[0].actual}`
3821
3960
  : undefined,
3961
+ // Deploy health
3962
+ deploy_status: dh?.status,
3963
+ deploy_http_status: dh?.httpStatus,
3964
+ deploy_summary: dh?.summary?.slice(0, 200),
3965
+ ship_decision_health: dh && dh.status !== "healthy" && dh.status !== "unreachable" ? "block" : (dh ? "pass" : undefined),
3966
+ // Secret scanner
3967
+ secrets_scanned: sc?.scanned,
3968
+ secrets_findings_total: sc?.findings?.length,
3969
+ secrets_critical_count: sc?.criticalCount,
3970
+ ship_decision_secrets: sc && sc.criticalCount > 0 ? "block" : (sc && sc.findings.length > 0 ? "review" : (sc ? "pass" : undefined)),
3971
+ // Cookie invariants
3972
+ cookie_failed: cr2 ? cr2.filter((c) => !c.passed).length : undefined,
3973
+ cookie_critical_failures: routeAssertions.cookieCriticalFailures || 0,
3974
+ ship_decision_cookies: cr2 && cr2.length > 0
3975
+ ? (routeAssertions.cookieCriticalFailures > 0 ? "block" : (cr2.filter((c) => !c.passed).length > 0 ? "review" : "pass"))
3976
+ : undefined,
3977
+ // R90 — TLS expiry
3978
+ tls_days_remaining: routeAssertions.tlsExpiry?.daysRemaining,
3979
+ tls_passed: routeAssertions.tlsExpiry?.passed,
3980
+ ship_decision_tls: routeAssertions.tlsExpiry?.checked
3981
+ ? (routeAssertions.tlsExpiry.passed ? "pass" : (routeAssertions.tlsExpiry.severity === "critical" ? "block" : "review"))
3982
+ : undefined,
3822
3983
  } : {};
3823
3984
 
3824
3985
  console.log(formatAiFooterBlock({
@@ -3842,13 +4003,63 @@ async function runTrustDiffCommand(args) {
3842
4003
  quota_limit: result.quota?.monthly_limit ?? undefined,
3843
4004
  scanned_at: new Date().toISOString(),
3844
4005
  advisory_only: "true",
3845
- scope: strictPosture ? "trust_surface_drift_plus_absolute_posture_plus_route_assertions" : "trust_surface_drift_plus_route_assertions",
3846
- scope_note: "ship_decision = worst-of(drift, route_assertions" + (strictPosture ? ", absolute_posture)" : ")") + ". Route assertions verify your declared private routes (e.g. /api/admin) are still gated; any critical failure (protected route now exposed) blocks the deploy unconditionally. Posture is " + (strictPosture ? "also gated due to --strict-posture." : "advisory unless --strict-posture is set.") + " Cipherwake does NOT verify app functionality.",
4006
+ scope: strictPosture ? "trust_surface_drift_plus_absolute_posture_plus_route_assertions_plus_health" : "trust_surface_drift_plus_route_assertions_plus_health",
4007
+ scope_note: "ship_decision = worst-of(drift, route_assertions, deploy_health, secret_scan, cookie_invariants" + (strictPosture ? ", absolute_posture)" : ")") + ". Cipherwake checked the public trust surface independently of what your AI coder claims; this is the gate that should fire before the AI announces a deploy. Cipherwake does NOT verify app functionality.",
4008
+ narrative: routeAssertions
4009
+ ? buildTrustDiffNarrative({
4010
+ deltaCount: deltas.length,
4011
+ assertionsFailedCount: routeAssertions.failed,
4012
+ assertionsCriticalCount: routeAssertions.criticalFailures.length,
4013
+ deployStatus: routeAssertions.deployHealth?.status,
4014
+ secretsCriticalCount: routeAssertions.secrets?.criticalCount || 0,
4015
+ secretsFindingsTotal: routeAssertions.secrets?.findings?.length || 0,
4016
+ cookieFailedCount: (routeAssertions.cookieResults || []).filter((c) => !c.passed).length,
4017
+ headerFailedCount: (routeAssertions.headerResults || []).filter((r) => !r.passed).length,
4018
+ posture: posture?.grade,
4019
+ })
4020
+ : undefined,
3847
4021
  ...assertionFields,
3848
4022
  ...postureFields,
3849
4023
  }));
3850
4024
  if (routeAssertions) {
3851
4025
  console.log(formatRouteAssertionsBlock(routeAssertions));
4026
+ // R89.LOCAL — persist local stats. ZERO network requests.
4027
+ // R90 — also record surface snapshot for B/C (new-routes / new-scripts diff)
4028
+ try {
4029
+ const { recordResults, recordSurfaceSnapshot } = await import(new URL("./statsTracker.js", import.meta.url).href);
4030
+ await recordResults(extractStatsEntries(routeAssertions));
4031
+ // Extract publicRoutes + thirdPartyHosts from the report for snapshot
4032
+ const publicRoutes = Array.isArray(currentReport?.publicRoutes?.paths)
4033
+ ? currentReport.publicRoutes.paths.filter((p) => p.classification === "public").map((p) => p.path)
4034
+ : [];
4035
+ const thirdPartyHosts = Array.isArray(currentReport?.publicDeps?.thirdParties)
4036
+ ? [...new Set(currentReport.publicDeps.thirdParties.map((t) => t.host).filter(Boolean))]
4037
+ : [];
4038
+ const certDaysToExpiry = typeof currentReport?.publicSurface?.daysUntilCertExpiry === "number"
4039
+ ? currentReport.publicSurface.daysUntilCertExpiry
4040
+ : null;
4041
+ const surfaceDiff = await recordSurfaceSnapshot({ domain, publicRoutes, thirdPartyHosts, certDaysToExpiry });
4042
+ if (surfaceDiff && (surfaceDiff.newRoutes.length > 0 || surfaceDiff.newHosts.length > 0 || surfaceDiff.removedRoutes.length > 0 || surfaceDiff.removedHosts.length > 0)) {
4043
+ console.log("");
4044
+ console.log(color("dim", "CIPHERWAKE_SURFACE_DIFF"));
4045
+ console.log(color("dim", `prev_snapshot_at=${surfaceDiff.prevSnapshotAt}`));
4046
+ for (const r of surfaceDiff.newRoutes) {
4047
+ console.log(color("yellow", `NEW_PUBLIC_ROUTE: ${r} — was not publicly reachable in last deploy. Review intent.`));
4048
+ }
4049
+ for (const r of surfaceDiff.removedRoutes) {
4050
+ console.log(color("dim", `REMOVED_PUBLIC_ROUTE: ${r} — was publicly reachable in last deploy.`));
4051
+ }
4052
+ for (const h of surfaceDiff.newHosts) {
4053
+ console.log(color("red", `NEW_THIRD_PARTY_SCRIPT: ${h} — supply-chain alert: a new third-party script appeared on your homepage since last deploy. Verify intent (vendor add) or investigate (injection / compromise).`));
4054
+ }
4055
+ for (const h of surfaceDiff.removedHosts) {
4056
+ console.log(color("dim", `REMOVED_THIRD_PARTY_SCRIPT: ${h} — third-party script removed since last deploy.`));
4057
+ }
4058
+ console.log(color("dim", "END_CIPHERWAKE_SURFACE_DIFF"));
4059
+ }
4060
+ } catch {
4061
+ // never block on stats failures
4062
+ }
3852
4063
  }
3853
4064
  if (posture && Array.isArray(posture.fixes) && posture.fixes.length > 0) {
3854
4065
  console.log(formatPostureFixesBlock(posture.fixes));
@@ -4957,6 +5168,132 @@ function renderReleaseChecklist(domain, opts = {}) {
4957
5168
  const VALID_FAIL_ON = ["any", "low", "medium", "high", "critical"];
4958
5169
  const VALID_BASELINES = ["last-week", "last-month", "last-scan"];
4959
5170
 
5171
+ // R89.LOCAL — `pqcheck stats` reads .cipherwake/stats.json + prints
5172
+ // per-check stats. ZERO network requests. Privacy-by-design.
5173
+ async function runStatsCommand(args) {
5174
+ const { loadStats, formatStatsTable } = await import(new URL("./statsTracker.js", import.meta.url).href);
5175
+ const stats = await loadStats();
5176
+ console.log(formatStatsTable(stats));
5177
+ console.log("");
5178
+ console.log(color("dim", "Stats are local-only. They never leave your machine. Add .cipherwake/ to .gitignore if you don't want to commit them."));
5179
+ }
5180
+
5181
+ async function runDismissCommand(args) {
5182
+ const id = args[0];
5183
+ if (!id) {
5184
+ console.error(color("red", "error: pqcheck dismiss requires a check id"));
5185
+ console.error(color("dim", "Usage: pqcheck dismiss route:/admin/ideas OR pqcheck dismiss header:Strict-Transport-Security"));
5186
+ process.exit(3);
5187
+ }
5188
+ const { markDismissed } = await import(new URL("./statsTracker.js", import.meta.url).href);
5189
+ const path = await markDismissed(id);
5190
+ if (path) {
5191
+ console.log(color("green", `✓ Marked "${id}" as dismissed-intentional in ${path}`));
5192
+ } else {
5193
+ console.error(color("red", `error: could not persist dismissal for "${id}"`));
5194
+ process.exit(1);
5195
+ }
5196
+ }
5197
+
5198
+ async function runConfirmCommand(args) {
5199
+ const id = args[0];
5200
+ if (!id) {
5201
+ console.error(color("red", "error: pqcheck confirm requires a check id"));
5202
+ console.error(color("dim", "Usage: pqcheck confirm route:/admin/ideas — marks as a confirmed real catch (a regression was fixed)"));
5203
+ process.exit(3);
5204
+ }
5205
+ const { markConfirmed } = await import(new URL("./statsTracker.js", import.meta.url).href);
5206
+ const path = await markConfirmed(id);
5207
+ if (path) {
5208
+ console.log(color("green", `✓ Marked "${id}" as confirmed-real in ${path}`));
5209
+ } else {
5210
+ console.error(color("red", `error: could not persist confirmation for "${id}"`));
5211
+ process.exit(1);
5212
+ }
5213
+ }
5214
+
5215
+ // R89.LOCAL — extract per-check entries from a trust-diff response for
5216
+ // stats recording. Records ZERO network requests beyond what the probe
5217
+ // already made.
5218
+ function extractStatsEntries(routeAssertions) {
5219
+ const entries = [];
5220
+ if (!routeAssertions) return entries;
5221
+ // Route assertions
5222
+ for (const r of routeAssertions.results || []) {
5223
+ entries.push({
5224
+ id: `route:${r.path}`,
5225
+ result: r.passed ? "pass" : "fail",
5226
+ status: r.status,
5227
+ severity: r.severity,
5228
+ source: r.source,
5229
+ });
5230
+ }
5231
+ // Header invariants
5232
+ for (const h of routeAssertions.headerResults || []) {
5233
+ entries.push({
5234
+ id: `header:${h.header}`,
5235
+ result: h.passed ? "pass" : "fail",
5236
+ status: null,
5237
+ severity: h.severity,
5238
+ source: "customer",
5239
+ });
5240
+ }
5241
+ // Cookie invariants
5242
+ for (const c of routeAssertions.cookieResults || []) {
5243
+ entries.push({
5244
+ id: `cookie:${c.namePattern}`,
5245
+ result: c.passed ? "pass" : "fail",
5246
+ status: null,
5247
+ severity: c.severity,
5248
+ source: "customer",
5249
+ });
5250
+ }
5251
+ // Secret scanner findings (each finding is its own check)
5252
+ if (routeAssertions.secrets) {
5253
+ if (routeAssertions.secrets.findings.length === 0 && routeAssertions.secrets.scanned) {
5254
+ entries.push({
5255
+ id: "secret:scan",
5256
+ result: "pass",
5257
+ status: null,
5258
+ severity: "info",
5259
+ source: "secret",
5260
+ });
5261
+ } else {
5262
+ for (const f of routeAssertions.secrets.findings) {
5263
+ entries.push({
5264
+ id: `secret:${f.patternId}`,
5265
+ result: "fail",
5266
+ status: null,
5267
+ severity: f.severity,
5268
+ source: "secret",
5269
+ });
5270
+ }
5271
+ }
5272
+ }
5273
+ // Deploy health
5274
+ if (routeAssertions.deployHealth) {
5275
+ const dh = routeAssertions.deployHealth;
5276
+ entries.push({
5277
+ id: "health:homepage",
5278
+ result: dh.status === "healthy" ? "pass" : "fail",
5279
+ status: dh.httpStatus,
5280
+ severity: dh.status === "healthy" ? "info" : "critical",
5281
+ source: "health",
5282
+ });
5283
+ }
5284
+ // R90 — TLS expiry
5285
+ if (routeAssertions.tlsExpiry && routeAssertions.tlsExpiry.checked) {
5286
+ entries.push({
5287
+ id: "tls:expiry",
5288
+ result: routeAssertions.tlsExpiry.passed ? "pass" : "fail",
5289
+ status: routeAssertions.tlsExpiry.daysRemaining,
5290
+ severity: routeAssertions.tlsExpiry.severity,
5291
+ source: "tls",
5292
+ });
5293
+ }
5294
+ return entries;
5295
+ }
5296
+
4960
5297
  async function runInitCommand(args) {
4961
5298
  const fs = await import("node:fs/promises");
4962
5299
  const path = await import("node:path");
@@ -0,0 +1,257 @@
1
+ // =============================================================================
2
+ // Local-only check stats (R89.LOCAL — 2026-06-05)
3
+ // =============================================================================
4
+ // Persists per-assertion / per-check stats to .cipherwake/stats.json in the
5
+ // customer's repo. Records every result the CIPHERWAKE_* blocks already
6
+ // print to stdout — pass/fail, status, severity, source, timestamp — so the
7
+ // customer can later see (via `pqcheck stats`) which checks actually
8
+ // catch real things vs. sit silent or false-flag.
9
+ //
10
+ // HARD RULE — privacy guarantee:
11
+ // This module emits ZERO network requests. It only writes to a local file.
12
+ // No telemetry, no analytics, no cross-repo aggregation. The customer's
13
+ // results stay on their machine. This matches Cipherwake's "no credentials"
14
+ // stance — "no credentials and now no data exhaust either."
15
+ //
16
+ // Cross-repo aggregation is a SEPARATE opt-in feature (paid tier hosted
17
+ // analytics) that requires explicit account configuration. That path is
18
+ // not implemented in this CLI module.
19
+ // =============================================================================
20
+
21
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
22
+ import { existsSync } from "node:fs";
23
+ import { join, dirname } from "node:path";
24
+
25
+ const STATS_DIR = ".cipherwake";
26
+ const STATS_FILE = "stats.json";
27
+
28
+ /**
29
+ * Stats entry shape per check id. Check ids follow a stable scheme:
30
+ * route:<path> — route assertion
31
+ * header:<header-name> — header invariant
32
+ * cookie:<name-pattern> — cookie invariant
33
+ * secret:<pattern-id> — secret scanner finding
34
+ * deployHealth — deploy-not-broken
35
+ *
36
+ * @typedef {Object} CheckStats
37
+ * @property {number} runs
38
+ * @property {number} passed
39
+ * @property {number} failed
40
+ * @property {number} confirmedReal — incremented when a previously-failing check now passes (inferred fix)
41
+ * @property {number} dismissedIntentional — incremented when the customer marks the failure as intentional
42
+ * @property {"pass"|"fail"|"unknown"} lastResult
43
+ * @property {number|null} lastStatus
44
+ * @property {string} lastSeenAt — ISO timestamp
45
+ * @property {string} severity — last severity seen
46
+ * @property {string} source — "customer" | "default" | "auto" | "health" | "secret"
47
+ */
48
+
49
+ /**
50
+ * Walk up from cwd to find the nearest repo root (.cipherwake.json or .git).
51
+ * Stats file lives in <repo-root>/.cipherwake/stats.json.
52
+ */
53
+ async function findStatsDir() {
54
+ let dir = process.cwd();
55
+ for (let i = 0; i < 8; i++) {
56
+ if (existsSync(join(dir, ".cipherwake.json")) || existsSync(join(dir, ".git"))) {
57
+ return join(dir, STATS_DIR);
58
+ }
59
+ const parent = dirname(dir);
60
+ if (parent === dir) break;
61
+ dir = parent;
62
+ }
63
+ // Fallback: write to cwd
64
+ return join(process.cwd(), STATS_DIR);
65
+ }
66
+
67
+ /**
68
+ * Load stats.json from disk. Returns empty stats object if missing or
69
+ * malformed (we never crash the deploy-check on stats issues).
70
+ */
71
+ export async function loadStats() {
72
+ try {
73
+ const statsDir = await findStatsDir();
74
+ const path = join(statsDir, STATS_FILE);
75
+ if (!existsSync(path)) return { version: 1, checks: {} };
76
+ const raw = await readFile(path, "utf8");
77
+ const parsed = JSON.parse(raw);
78
+ if (!parsed || typeof parsed !== "object" || !parsed.checks) return { version: 1, checks: {} };
79
+ return parsed;
80
+ } catch {
81
+ return { version: 1, checks: {} };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Persist stats.json. Creates .cipherwake/ if missing.
87
+ */
88
+ async function persistStats(stats) {
89
+ try {
90
+ const statsDir = await findStatsDir();
91
+ if (!existsSync(statsDir)) {
92
+ await mkdir(statsDir, { recursive: true });
93
+ }
94
+ const path = join(statsDir, STATS_FILE);
95
+ await writeFile(path, JSON.stringify(stats, null, 2), "utf8");
96
+ return path;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Apply a batch of check outcomes from a single deploy-check run. Each entry:
104
+ * { id, result: "pass"|"fail", status, severity, source }
105
+ *
106
+ * Inferred confirmedReal: when the previous lastResult was "fail" and the
107
+ * new result is "pass", increment confirmedReal. This catches the "I fixed
108
+ * the regression Cipherwake flagged" event.
109
+ */
110
+ export async function recordResults(entries) {
111
+ if (!Array.isArray(entries) || entries.length === 0) return null;
112
+ const stats = await loadStats();
113
+ const now = new Date().toISOString();
114
+ for (const e of entries) {
115
+ if (!e || typeof e.id !== "string") continue;
116
+ const cur = stats.checks[e.id] || {
117
+ runs: 0,
118
+ passed: 0,
119
+ failed: 0,
120
+ confirmedReal: 0,
121
+ dismissedIntentional: 0,
122
+ lastResult: "unknown",
123
+ lastStatus: null,
124
+ lastSeenAt: now,
125
+ severity: e.severity || "low",
126
+ source: e.source || "default",
127
+ };
128
+ cur.runs += 1;
129
+ if (e.result === "pass") {
130
+ cur.passed += 1;
131
+ if (cur.lastResult === "fail") cur.confirmedReal += 1;
132
+ cur.lastResult = "pass";
133
+ } else if (e.result === "fail") {
134
+ cur.failed += 1;
135
+ cur.lastResult = "fail";
136
+ }
137
+ cur.lastStatus = (typeof e.status === "number" || e.status === null) ? e.status : cur.lastStatus;
138
+ cur.lastSeenAt = now;
139
+ cur.severity = e.severity || cur.severity;
140
+ cur.source = e.source || cur.source;
141
+ stats.checks[e.id] = cur;
142
+ }
143
+ return await persistStats(stats);
144
+ }
145
+
146
+ /**
147
+ * Mark a check as dismissed-intentional. Customer ran `pqcheck dismiss <id>`
148
+ * after reviewing it as a false positive / intentional state.
149
+ */
150
+ export async function markDismissed(id) {
151
+ if (typeof id !== "string" || !id) return null;
152
+ const stats = await loadStats();
153
+ const cur = stats.checks[id] || { runs: 0, passed: 0, failed: 0, confirmedReal: 0, dismissedIntentional: 0, lastResult: "unknown", lastStatus: null, lastSeenAt: new Date().toISOString(), severity: "low", source: "customer" };
154
+ cur.dismissedIntentional += 1;
155
+ cur.lastSeenAt = new Date().toISOString();
156
+ stats.checks[id] = cur;
157
+ return await persistStats(stats);
158
+ }
159
+
160
+ /**
161
+ * Mark a check as a confirmed real catch (used by `pqcheck confirm <id>`
162
+ * when the customer wants to record it explicitly rather than relying on
163
+ * the inferred-from-fix mechanism).
164
+ */
165
+ export async function markConfirmed(id) {
166
+ if (typeof id !== "string" || !id) return null;
167
+ const stats = await loadStats();
168
+ const cur = stats.checks[id] || { runs: 0, passed: 0, failed: 0, confirmedReal: 0, dismissedIntentional: 0, lastResult: "unknown", lastStatus: null, lastSeenAt: new Date().toISOString(), severity: "low", source: "customer" };
169
+ cur.confirmedReal += 1;
170
+ cur.lastSeenAt = new Date().toISOString();
171
+ stats.checks[id] = cur;
172
+ return await persistStats(stats);
173
+ }
174
+
175
+ /**
176
+ * R90 (2026-06-05) — record snapshot of "what's on the public surface" so
177
+ * the next deploy-check can diff it. Captures:
178
+ * - publicRoutes: list of /privacy, /terms, etc. paths returning 200
179
+ * - thirdPartyHosts: list of third-party script hosts loaded by the homepage
180
+ * - certDaysToExpiry: TLS cert remaining days
181
+ *
182
+ * Compared against the prior snapshot at next-deploy time to emit
183
+ * "new since last deploy" diffs (B and C in the final build brief).
184
+ * Snapshots live in .cipherwake/stats.json (local, no transmission).
185
+ *
186
+ * Returns the diff vs the prior snapshot (or null if this is the first run).
187
+ */
188
+ export async function recordSurfaceSnapshot(snapshot) {
189
+ try {
190
+ const stats = await loadStats();
191
+ if (!stats.surfaceSnapshots) stats.surfaceSnapshots = {};
192
+ const now = new Date().toISOString();
193
+ const prev = stats.surfaceSnapshots[snapshot.domain] || null;
194
+ const diff = computeSurfaceDiff(prev, snapshot);
195
+ stats.surfaceSnapshots[snapshot.domain] = {
196
+ capturedAt: now,
197
+ publicRoutes: snapshot.publicRoutes || [],
198
+ thirdPartyHosts: snapshot.thirdPartyHosts || [],
199
+ certDaysToExpiry: snapshot.certDaysToExpiry ?? null,
200
+ };
201
+ await persistStats(stats);
202
+ return diff;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ function computeSurfaceDiff(prev, current) {
209
+ if (!prev) return null;
210
+ const prevRoutes = new Set(prev.publicRoutes || []);
211
+ const currRoutes = new Set(current.publicRoutes || []);
212
+ const prevHosts = new Set(prev.thirdPartyHosts || []);
213
+ const currHosts = new Set(current.thirdPartyHosts || []);
214
+ return {
215
+ newRoutes: [...currRoutes].filter((r) => !prevRoutes.has(r)),
216
+ removedRoutes: [...prevRoutes].filter((r) => !currRoutes.has(r)),
217
+ newHosts: [...currHosts].filter((h) => !prevHosts.has(h)),
218
+ removedHosts: [...prevHosts].filter((h) => !currHosts.has(h)),
219
+ prevSnapshotAt: prev.capturedAt,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Format stats as a human-readable table for `pqcheck stats`.
225
+ */
226
+ export function formatStatsTable(stats) {
227
+ const ids = Object.keys(stats.checks || {}).sort();
228
+ if (ids.length === 0) return "No check results recorded yet. Run `pqcheck deploy-check` to start.";
229
+ const lines = [];
230
+ lines.push("Cipherwake check stats (local, never transmitted)");
231
+ lines.push("=" .repeat(96));
232
+ lines.push("CHECK".padEnd(40) + " RUNS PASS FAIL CONFIRMED DISMISSED LAST SEVERITY");
233
+ lines.push("-" .repeat(96));
234
+ for (const id of ids) {
235
+ const s = stats.checks[id];
236
+ const last = s.lastResult || "?";
237
+ lines.push(
238
+ id.slice(0, 39).padEnd(40) +
239
+ String(s.runs).padStart(5) +
240
+ String(s.passed).padStart(6) +
241
+ String(s.failed).padStart(6) +
242
+ String(s.confirmedReal).padStart(11) +
243
+ String(s.dismissedIntentional).padStart(11) +
244
+ " " + last.padEnd(5) +
245
+ " " + (s.severity || "?")
246
+ );
247
+ }
248
+ lines.push("-" .repeat(96));
249
+ const totalRuns = ids.reduce((a, id) => a + (stats.checks[id].runs || 0), 0);
250
+ const totalFailed = ids.reduce((a, id) => a + (stats.checks[id].failed || 0), 0);
251
+ const totalConfirmed = ids.reduce((a, id) => a + (stats.checks[id].confirmedReal || 0), 0);
252
+ const totalDismissed = ids.reduce((a, id) => a + (stats.checks[id].dismissedIntentional || 0), 0);
253
+ const catchRate = totalFailed > 0 ? ((totalConfirmed / totalFailed) * 100).toFixed(0) : "—";
254
+ lines.push(`TOTAL: ${ids.length} checks tracked, ${totalRuns} runs, ${totalFailed} failures, ${totalConfirmed} confirmed real, ${totalDismissed} dismissed.`);
255
+ lines.push(`Confirmed-catch rate: ${catchRate}% of failures were confirmed real (a fix landed afterward).`);
256
+ return lines.join("\n");
257
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pqcheck",
3
- "version": "0.16.24",
3
+ "version": "0.16.28",
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",