pqcheck 0.16.23 → 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 +1 -1
- package/bin/pqcheck.js +514 -9
- package/bin/statsTracker.js +257 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[](https://www.npmjs.com/package/pqcheck)
|
|
9
9
|
[](./LICENSE)
|
|
10
10
|
|
|
11
|
-
> **Latest: v0.16.
|
|
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
|
}
|
|
@@ -907,6 +918,271 @@ function combineShipDecision(driftDecision, postureDecision, { strict = false }
|
|
|
907
918
|
return a >= b ? driftDecision : postureDecision;
|
|
908
919
|
}
|
|
909
920
|
|
|
921
|
+
// R87 (2026-06-05) — read .cipherwake.json from cwd OR a path the customer
|
|
922
|
+
// pointed us at. Returns the parsed routeAssertions config or null.
|
|
923
|
+
//
|
|
924
|
+
// Customer-declared assertions are the wedge that makes Cipherwake fire on
|
|
925
|
+
// EVERY deploy of a backend/admin-heavy app — even when the public landing
|
|
926
|
+
// page doesn't drift. The CLI reads this file once + forwards it as the
|
|
927
|
+
// `routeAssertionsConfig` field in the trust-diff/scan request body.
|
|
928
|
+
//
|
|
929
|
+
// We DO NOT read credentials or sensitive headers from this file. Just
|
|
930
|
+
// route paths + expected classification. See /methodology/route-assertions
|
|
931
|
+
// for the schema + /methodology/why-not-authenticated-crawling for the
|
|
932
|
+
// strategic reasoning behind keeping this credential-free.
|
|
933
|
+
// R87.4 (2026-06-05) — surface a clear warning when .cipherwake.json has
|
|
934
|
+
// malformed JSON, an invalid shape, or an obviously-wrong path declaration.
|
|
935
|
+
// Without these warnings, the CLI silently dropped the config and the user
|
|
936
|
+
// saw `sources_customer=0` with no explanation. Wrong-shape configs are
|
|
937
|
+
// the second-most-common UX failure after typo'd flags.
|
|
938
|
+
function _warnConfigIssue(msg) {
|
|
939
|
+
process.stderr.write(color("yellow", `⚠ .cipherwake.json: ${msg}\n`));
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function readRouteAssertionsConfig() {
|
|
943
|
+
try {
|
|
944
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
945
|
+
const { join, dirname } = await import("node:path");
|
|
946
|
+
const cwd = process.cwd();
|
|
947
|
+
// Walk up from cwd looking for .cipherwake.json — supports running
|
|
948
|
+
// from any subdirectory of the repo. Cap at 5 levels to bound IO.
|
|
949
|
+
let dir = cwd;
|
|
950
|
+
for (let i = 0; i < 5; i++) {
|
|
951
|
+
const candidate = join(dir, ".cipherwake.json");
|
|
952
|
+
if (existsSync(candidate)) {
|
|
953
|
+
const body = readFileSync(candidate, "utf8");
|
|
954
|
+
let parsed;
|
|
955
|
+
try {
|
|
956
|
+
parsed = JSON.parse(body);
|
|
957
|
+
} catch (e) {
|
|
958
|
+
_warnConfigIssue(`malformed JSON in ${candidate} (${e.message}). Customer assertions will not be sent.`);
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
// Tolerant shape: either { routeAssertions: {...} } or { assertions: [...] }
|
|
962
|
+
// at the top level. Both forms are documented in the methodology page.
|
|
963
|
+
const cfg = parsed?.routeAssertions || (Array.isArray(parsed?.assertions) ? { assertions: parsed.assertions, replace_defaults: parsed.replace_defaults } : null);
|
|
964
|
+
if (!cfg) {
|
|
965
|
+
_warnConfigIssue(`${candidate} found but missing required field "routeAssertions.assertions" (or top-level "assertions" array). See https://cipherwake.io/methodology/route-assertions for the schema.`);
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
if (!Array.isArray(cfg.assertions)) {
|
|
969
|
+
_warnConfigIssue(`"assertions" must be an array of {path, expect, ...} objects.`);
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
// R87.4 — normalize + validate each assertion. Catch common typos:
|
|
973
|
+
// missing leading slash, unknown `expect` value, duplicate paths.
|
|
974
|
+
const seen = new Set();
|
|
975
|
+
const cleaned = [];
|
|
976
|
+
for (let idx = 0; idx < cfg.assertions.length; idx++) {
|
|
977
|
+
const a = cfg.assertions[idx];
|
|
978
|
+
if (!a || typeof a !== "object") {
|
|
979
|
+
_warnConfigIssue(`assertion #${idx + 1} is not an object — skipped.`);
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (typeof a.path !== "string" || !a.path) {
|
|
983
|
+
_warnConfigIssue(`assertion #${idx + 1} missing "path" field — skipped.`);
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
// Normalize leading slash
|
|
987
|
+
let normPath = a.path.trim();
|
|
988
|
+
if (!normPath.startsWith("/")) {
|
|
989
|
+
_warnConfigIssue(`assertion path "${a.path}" missing leading "/" — auto-normalized to "/${normPath}".`);
|
|
990
|
+
normPath = "/" + normPath;
|
|
991
|
+
}
|
|
992
|
+
if (normPath.includes("?")) {
|
|
993
|
+
_warnConfigIssue(`assertion path "${normPath}" contains "?" — query strings are not supported in route assertions. Path probed without query.`);
|
|
994
|
+
normPath = normPath.split("?")[0];
|
|
995
|
+
}
|
|
996
|
+
const validExpects = ["protected", "exposed", "missing"];
|
|
997
|
+
if (!validExpects.includes(a.expect)) {
|
|
998
|
+
_warnConfigIssue(`assertion #${idx + 1} (path "${normPath}") has invalid "expect": ${JSON.stringify(a.expect)}. Must be one of: ${validExpects.join(", ")}. Skipped.`);
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
if (seen.has(normPath)) {
|
|
1002
|
+
_warnConfigIssue(`duplicate path "${normPath}" in assertions — second occurrence ignored.`);
|
|
1003
|
+
continue;
|
|
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
|
+
}
|
|
1017
|
+
seen.add(normPath);
|
|
1018
|
+
cleaned.push({ ...a, path: normPath });
|
|
1019
|
+
}
|
|
1020
|
+
if (cleaned.length === 0) {
|
|
1021
|
+
_warnConfigIssue(`no valid assertions found after validation — customer config will not be sent.`);
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
return { ...cfg, assertions: cleaned };
|
|
1025
|
+
}
|
|
1026
|
+
const parent = dirname(dir);
|
|
1027
|
+
if (parent === dir) break;
|
|
1028
|
+
dir = parent;
|
|
1029
|
+
}
|
|
1030
|
+
return null;
|
|
1031
|
+
} catch (e) {
|
|
1032
|
+
_warnConfigIssue(`unexpected error reading config: ${e.message}`);
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// R87 — fold customer route-assertion failures into ship_decision. Any
|
|
1038
|
+
// CRITICAL failure (declared `protected` route that is now `exposed`) blocks
|
|
1039
|
+
// the deploy unconditionally — this is the catastrophic "admin became
|
|
1040
|
+
// public" case the feature exists to catch. High/medium failures promote
|
|
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
|
+
|
|
1067
|
+
function shipDecisionFromAssertions(summary) {
|
|
1068
|
+
if (!summary) return null;
|
|
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";
|
|
1085
|
+
return "pass";
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// R87 — emit per-assertion outcomes in a parseable block. Stable format so
|
|
1089
|
+
// AI coders can route on the data; one line per assertion with
|
|
1090
|
+
// expected/actual + path so failures are obvious at a glance.
|
|
1091
|
+
function formatRouteAssertionsBlock(summary) {
|
|
1092
|
+
if (!summary || !Array.isArray(summary.results) || summary.results.length === 0) return "";
|
|
1093
|
+
const lines = ["", "CIPHERWAKE_ROUTE_ASSERTIONS"];
|
|
1094
|
+
lines.push(`total=${summary.total}`);
|
|
1095
|
+
lines.push(`passed=${summary.passed}`);
|
|
1096
|
+
lines.push(`failed=${summary.failed}`);
|
|
1097
|
+
lines.push(`critical_failures=${summary.criticalFailures.length}`);
|
|
1098
|
+
lines.push(`sources_customer=${summary.sources.customer}`);
|
|
1099
|
+
lines.push(`sources_default=${summary.sources.default}`);
|
|
1100
|
+
lines.push(`sources_auto=${summary.sources.auto}`);
|
|
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
|
+
}
|
|
1174
|
+
for (const r of summary.results) {
|
|
1175
|
+
const status = r.passed ? "PASS" : "FAIL";
|
|
1176
|
+
const sev = r.passed ? "info" : r.severity;
|
|
1177
|
+
const why = r.why ? ` — ${r.why}` : "";
|
|
1178
|
+
const errReason = r.errorReason ? ` reason=${r.errorReason}` : "";
|
|
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}`);
|
|
1181
|
+
}
|
|
1182
|
+
lines.push("END_CIPHERWAKE_ROUTE_ASSERTIONS");
|
|
1183
|
+
return color("dim", lines.join("\n"));
|
|
1184
|
+
}
|
|
1185
|
+
|
|
910
1186
|
// R86.3 (2026-06-03) — emit per-finding fix snippets in a separate parseable
|
|
911
1187
|
// block after the AI guard block. The guard block stays compact key=value;
|
|
912
1188
|
// fixes are multi-line code so they get their own block with explicit
|
|
@@ -3519,10 +3795,11 @@ async function runTrustDiffCommand(args) {
|
|
|
3519
3795
|
|
|
3520
3796
|
let resp;
|
|
3521
3797
|
try {
|
|
3798
|
+
const _routeCfg = await readRouteAssertionsConfig();
|
|
3522
3799
|
resp = await fetch(`${API_BASE}/api/trust-diff`, {
|
|
3523
3800
|
method: "POST",
|
|
3524
3801
|
headers,
|
|
3525
|
-
body: JSON.stringify({ domain, baseline, fail_on: failOn }),
|
|
3802
|
+
body: JSON.stringify({ domain, baseline, fail_on: failOn, routeAssertionsConfig: _routeCfg }),
|
|
3526
3803
|
});
|
|
3527
3804
|
} catch (err) {
|
|
3528
3805
|
console.error(color("red", `error: network failure calling /api/trust-diff: ${err.message}`));
|
|
@@ -3656,17 +3933,67 @@ async function runTrustDiffCommand(args) {
|
|
|
3656
3933
|
// R86.5 — surface posture advisory line in default (non-strict) mode.
|
|
3657
3934
|
const postureAdvisory = formatPostureAdvisoryLine(posture, strictPosture);
|
|
3658
3935
|
if (postureAdvisory) console.log(postureAdvisory);
|
|
3936
|
+
// R87 (2026-06-05) — fold route-assertion failures into ship_decision.
|
|
3937
|
+
// Customer-declared assertions are surfaced regardless; their critical
|
|
3938
|
+
// failures (declared `protected` route that's now `exposed`) ALWAYS
|
|
3939
|
+
// promote ship_decision to block — this is the catastrophic case the
|
|
3940
|
+
// feature exists to catch ("admin went public") and it does not require
|
|
3941
|
+
// --strict-posture or any opt-in. Default + auto-detected assertions
|
|
3942
|
+
// follow the same gating rules.
|
|
3943
|
+
const routeAssertions = currentReport?.routeAssertions || null;
|
|
3944
|
+
const assertionShipImpact = shipDecisionFromAssertions(routeAssertions);
|
|
3945
|
+
const effectiveShipWithAssertions = assertionShipImpact
|
|
3946
|
+
? (SHIP_DECISION_RANK[assertionShipImpact] > SHIP_DECISION_RANK[effectiveShip] ? assertionShipImpact : effectiveShip)
|
|
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;
|
|
3952
|
+
const assertionFields = routeAssertions ? {
|
|
3953
|
+
assertions_total: routeAssertions.total,
|
|
3954
|
+
assertions_passed: routeAssertions.passed,
|
|
3955
|
+
assertions_failed: routeAssertions.failed,
|
|
3956
|
+
assertions_critical_failures: routeAssertions.criticalFailures.length,
|
|
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}`,
|
|
3958
|
+
assertion_top_failure: routeAssertions.criticalFailures[0]
|
|
3959
|
+
? `${routeAssertions.criticalFailures[0].path}: expected ${routeAssertions.criticalFailures[0].expected}, got ${routeAssertions.criticalFailures[0].actual}`
|
|
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,
|
|
3983
|
+
} : {};
|
|
3984
|
+
|
|
3659
3985
|
console.log(formatAiFooterBlock({
|
|
3660
|
-
status:
|
|
3986
|
+
status: effectiveShipWithAssertions,
|
|
3661
3987
|
domain,
|
|
3662
3988
|
kind: "trust-diff",
|
|
3663
3989
|
baseline,
|
|
3664
3990
|
verdict,
|
|
3665
3991
|
delta_count: deltas.length,
|
|
3666
3992
|
max_severity: maxSev,
|
|
3667
|
-
ship_decision:
|
|
3993
|
+
ship_decision: effectiveShipWithAssertions,
|
|
3668
3994
|
ship_decision_drift: shipDecision,
|
|
3669
3995
|
ship_decision_posture: posture?.decision,
|
|
3996
|
+
ship_decision_assertions: assertionShipImpact,
|
|
3670
3997
|
ship_decision_mode: strictPosture ? "strict_posture" : "drift_only",
|
|
3671
3998
|
top_issue: topDelta?.id || topDelta?.type || "none",
|
|
3672
3999
|
top_issue_title: topDelta?.title || undefined,
|
|
@@ -3676,12 +4003,64 @@ async function runTrustDiffCommand(args) {
|
|
|
3676
4003
|
quota_limit: result.quota?.monthly_limit ?? undefined,
|
|
3677
4004
|
scanned_at: new Date().toISOString(),
|
|
3678
4005
|
advisory_only: "true",
|
|
3679
|
-
scope: strictPosture ? "
|
|
3680
|
-
scope_note: strictPosture
|
|
3681
|
-
|
|
3682
|
-
|
|
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,
|
|
4021
|
+
...assertionFields,
|
|
3683
4022
|
...postureFields,
|
|
3684
4023
|
}));
|
|
4024
|
+
if (routeAssertions) {
|
|
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
|
+
}
|
|
4063
|
+
}
|
|
3685
4064
|
if (posture && Array.isArray(posture.fixes) && posture.fixes.length > 0) {
|
|
3686
4065
|
console.log(formatPostureFixesBlock(posture.fixes));
|
|
3687
4066
|
}
|
|
@@ -3693,13 +4072,13 @@ async function runTrustDiffCommand(args) {
|
|
|
3693
4072
|
score: typeof result.current_score === "number" ? result.current_score : null,
|
|
3694
4073
|
grade: result.current_grade || null,
|
|
3695
4074
|
max_severity: maxSev,
|
|
3696
|
-
ship_decision:
|
|
4075
|
+
ship_decision: effectiveShipWithAssertions,
|
|
3697
4076
|
baseline,
|
|
3698
4077
|
delta_count: deltas.length,
|
|
3699
4078
|
top_issue: topDelta?.id || topDelta?.title || null,
|
|
3700
4079
|
});
|
|
3701
4080
|
|
|
3702
|
-
process.exit(shipDecisionExitCode(
|
|
4081
|
+
process.exit(shipDecisionExitCode(effectiveShipWithAssertions));
|
|
3703
4082
|
}
|
|
3704
4083
|
|
|
3705
4084
|
// Format output
|
|
@@ -4789,6 +5168,132 @@ function renderReleaseChecklist(domain, opts = {}) {
|
|
|
4789
5168
|
const VALID_FAIL_ON = ["any", "low", "medium", "high", "critical"];
|
|
4790
5169
|
const VALID_BASELINES = ["last-week", "last-month", "last-scan"];
|
|
4791
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
|
+
|
|
4792
5297
|
async function runInitCommand(args) {
|
|
4793
5298
|
const fs = await import("node:fs/promises");
|
|
4794
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.
|
|
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",
|