pqcheck 0.16.24 → 0.16.29
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 +343 -6
- 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.29** — Fixed a high-volume false positive caught by external testing: WAF-mitigated 403s on `/wp-admin` (and any `expect: missing` default) were firing as "WordPress leak" on every Vercel/Cloudflare-hosted non-WP app. The probe now detects `x-vercel-mitigated` / `cf-mitigated` headers and classifies them as `blocked` (not `protected`). And `expect: missing` now semantically means "not reachable to anonymous users" — 404/401/403/3xx/WAF-block all satisfy; only 200-serving-content fails. Every catastrophic catch (`.env` leak at 200, real WP at 200, public `/api/admin`) preserved. Verified with 23 new tests covering all classification × expectation combinations. [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
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
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 ? "
|
|
3846
|
-
scope_note: "ship_decision = worst-of(drift, route_assertions" + (strictPosture ? ", absolute_posture)" : ")") + ".
|
|
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.
|
|
3
|
+
"version": "0.16.29",
|
|
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",
|