pqcheck 0.16.23 → 0.16.24
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 +177 -9
- 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.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)
|
|
12
12
|
|
|
13
13
|
## Two ways to use it
|
|
14
14
|
|
package/bin/pqcheck.js
CHANGED
|
@@ -907,6 +907,147 @@ function combineShipDecision(driftDecision, postureDecision, { strict = false }
|
|
|
907
907
|
return a >= b ? driftDecision : postureDecision;
|
|
908
908
|
}
|
|
909
909
|
|
|
910
|
+
// R87 (2026-06-05) — read .cipherwake.json from cwd OR a path the customer
|
|
911
|
+
// pointed us at. Returns the parsed routeAssertions config or null.
|
|
912
|
+
//
|
|
913
|
+
// Customer-declared assertions are the wedge that makes Cipherwake fire on
|
|
914
|
+
// EVERY deploy of a backend/admin-heavy app — even when the public landing
|
|
915
|
+
// page doesn't drift. The CLI reads this file once + forwards it as the
|
|
916
|
+
// `routeAssertionsConfig` field in the trust-diff/scan request body.
|
|
917
|
+
//
|
|
918
|
+
// We DO NOT read credentials or sensitive headers from this file. Just
|
|
919
|
+
// route paths + expected classification. See /methodology/route-assertions
|
|
920
|
+
// for the schema + /methodology/why-not-authenticated-crawling for the
|
|
921
|
+
// strategic reasoning behind keeping this credential-free.
|
|
922
|
+
// R87.4 (2026-06-05) — surface a clear warning when .cipherwake.json has
|
|
923
|
+
// malformed JSON, an invalid shape, or an obviously-wrong path declaration.
|
|
924
|
+
// Without these warnings, the CLI silently dropped the config and the user
|
|
925
|
+
// saw `sources_customer=0` with no explanation. Wrong-shape configs are
|
|
926
|
+
// the second-most-common UX failure after typo'd flags.
|
|
927
|
+
function _warnConfigIssue(msg) {
|
|
928
|
+
process.stderr.write(color("yellow", `⚠ .cipherwake.json: ${msg}\n`));
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function readRouteAssertionsConfig() {
|
|
932
|
+
try {
|
|
933
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
934
|
+
const { join, dirname } = await import("node:path");
|
|
935
|
+
const cwd = process.cwd();
|
|
936
|
+
// Walk up from cwd looking for .cipherwake.json — supports running
|
|
937
|
+
// from any subdirectory of the repo. Cap at 5 levels to bound IO.
|
|
938
|
+
let dir = cwd;
|
|
939
|
+
for (let i = 0; i < 5; i++) {
|
|
940
|
+
const candidate = join(dir, ".cipherwake.json");
|
|
941
|
+
if (existsSync(candidate)) {
|
|
942
|
+
const body = readFileSync(candidate, "utf8");
|
|
943
|
+
let parsed;
|
|
944
|
+
try {
|
|
945
|
+
parsed = JSON.parse(body);
|
|
946
|
+
} catch (e) {
|
|
947
|
+
_warnConfigIssue(`malformed JSON in ${candidate} (${e.message}). Customer assertions will not be sent.`);
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
// Tolerant shape: either { routeAssertions: {...} } or { assertions: [...] }
|
|
951
|
+
// at the top level. Both forms are documented in the methodology page.
|
|
952
|
+
const cfg = parsed?.routeAssertions || (Array.isArray(parsed?.assertions) ? { assertions: parsed.assertions, replace_defaults: parsed.replace_defaults } : null);
|
|
953
|
+
if (!cfg) {
|
|
954
|
+
_warnConfigIssue(`${candidate} found but missing required field "routeAssertions.assertions" (or top-level "assertions" array). See https://cipherwake.io/methodology/route-assertions for the schema.`);
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
if (!Array.isArray(cfg.assertions)) {
|
|
958
|
+
_warnConfigIssue(`"assertions" must be an array of {path, expect, ...} objects.`);
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
// R87.4 — normalize + validate each assertion. Catch common typos:
|
|
962
|
+
// missing leading slash, unknown `expect` value, duplicate paths.
|
|
963
|
+
const seen = new Set();
|
|
964
|
+
const cleaned = [];
|
|
965
|
+
for (let idx = 0; idx < cfg.assertions.length; idx++) {
|
|
966
|
+
const a = cfg.assertions[idx];
|
|
967
|
+
if (!a || typeof a !== "object") {
|
|
968
|
+
_warnConfigIssue(`assertion #${idx + 1} is not an object — skipped.`);
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (typeof a.path !== "string" || !a.path) {
|
|
972
|
+
_warnConfigIssue(`assertion #${idx + 1} missing "path" field — skipped.`);
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
// Normalize leading slash
|
|
976
|
+
let normPath = a.path.trim();
|
|
977
|
+
if (!normPath.startsWith("/")) {
|
|
978
|
+
_warnConfigIssue(`assertion path "${a.path}" missing leading "/" — auto-normalized to "/${normPath}".`);
|
|
979
|
+
normPath = "/" + normPath;
|
|
980
|
+
}
|
|
981
|
+
if (normPath.includes("?")) {
|
|
982
|
+
_warnConfigIssue(`assertion path "${normPath}" contains "?" — query strings are not supported in route assertions. Path probed without query.`);
|
|
983
|
+
normPath = normPath.split("?")[0];
|
|
984
|
+
}
|
|
985
|
+
const validExpects = ["protected", "exposed", "missing"];
|
|
986
|
+
if (!validExpects.includes(a.expect)) {
|
|
987
|
+
_warnConfigIssue(`assertion #${idx + 1} (path "${normPath}") has invalid "expect": ${JSON.stringify(a.expect)}. Must be one of: ${validExpects.join(", ")}. Skipped.`);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
if (seen.has(normPath)) {
|
|
991
|
+
_warnConfigIssue(`duplicate path "${normPath}" in assertions — second occurrence ignored.`);
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
seen.add(normPath);
|
|
995
|
+
cleaned.push({ ...a, path: normPath });
|
|
996
|
+
}
|
|
997
|
+
if (cleaned.length === 0) {
|
|
998
|
+
_warnConfigIssue(`no valid assertions found after validation — customer config will not be sent.`);
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
return { ...cfg, assertions: cleaned };
|
|
1002
|
+
}
|
|
1003
|
+
const parent = dirname(dir);
|
|
1004
|
+
if (parent === dir) break;
|
|
1005
|
+
dir = parent;
|
|
1006
|
+
}
|
|
1007
|
+
return null;
|
|
1008
|
+
} catch (e) {
|
|
1009
|
+
_warnConfigIssue(`unexpected error reading config: ${e.message}`);
|
|
1010
|
+
return null;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// R87 — fold customer route-assertion failures into ship_decision. Any
|
|
1015
|
+
// CRITICAL failure (declared `protected` route that is now `exposed`) blocks
|
|
1016
|
+
// the deploy unconditionally — this is the catastrophic "admin became
|
|
1017
|
+
// public" case the feature exists to catch. High/medium failures promote
|
|
1018
|
+
// to `review`.
|
|
1019
|
+
function shipDecisionFromAssertions(summary) {
|
|
1020
|
+
if (!summary) return null;
|
|
1021
|
+
if (summary.criticalFailures && summary.criticalFailures.length > 0) return "block";
|
|
1022
|
+
if (summary.failed > 0) return "review";
|
|
1023
|
+
return "pass";
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// R87 — emit per-assertion outcomes in a parseable block. Stable format so
|
|
1027
|
+
// AI coders can route on the data; one line per assertion with
|
|
1028
|
+
// expected/actual + path so failures are obvious at a glance.
|
|
1029
|
+
function formatRouteAssertionsBlock(summary) {
|
|
1030
|
+
if (!summary || !Array.isArray(summary.results) || summary.results.length === 0) return "";
|
|
1031
|
+
const lines = ["", "CIPHERWAKE_ROUTE_ASSERTIONS"];
|
|
1032
|
+
lines.push(`total=${summary.total}`);
|
|
1033
|
+
lines.push(`passed=${summary.passed}`);
|
|
1034
|
+
lines.push(`failed=${summary.failed}`);
|
|
1035
|
+
lines.push(`critical_failures=${summary.criticalFailures.length}`);
|
|
1036
|
+
lines.push(`sources_customer=${summary.sources.customer}`);
|
|
1037
|
+
lines.push(`sources_default=${summary.sources.default}`);
|
|
1038
|
+
lines.push(`sources_auto=${summary.sources.auto}`);
|
|
1039
|
+
lines.push(`sources_auto_suppressed=${summary.sources.autoSuppressed || 0}`);
|
|
1040
|
+
for (const r of summary.results) {
|
|
1041
|
+
const status = r.passed ? "PASS" : "FAIL";
|
|
1042
|
+
const sev = r.passed ? "info" : r.severity;
|
|
1043
|
+
const why = r.why ? ` — ${r.why}` : "";
|
|
1044
|
+
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}`);
|
|
1046
|
+
}
|
|
1047
|
+
lines.push("END_CIPHERWAKE_ROUTE_ASSERTIONS");
|
|
1048
|
+
return color("dim", lines.join("\n"));
|
|
1049
|
+
}
|
|
1050
|
+
|
|
910
1051
|
// R86.3 (2026-06-03) — emit per-finding fix snippets in a separate parseable
|
|
911
1052
|
// block after the AI guard block. The guard block stays compact key=value;
|
|
912
1053
|
// fixes are multi-line code so they get their own block with explicit
|
|
@@ -3519,10 +3660,11 @@ async function runTrustDiffCommand(args) {
|
|
|
3519
3660
|
|
|
3520
3661
|
let resp;
|
|
3521
3662
|
try {
|
|
3663
|
+
const _routeCfg = await readRouteAssertionsConfig();
|
|
3522
3664
|
resp = await fetch(`${API_BASE}/api/trust-diff`, {
|
|
3523
3665
|
method: "POST",
|
|
3524
3666
|
headers,
|
|
3525
|
-
body: JSON.stringify({ domain, baseline, fail_on: failOn }),
|
|
3667
|
+
body: JSON.stringify({ domain, baseline, fail_on: failOn, routeAssertionsConfig: _routeCfg }),
|
|
3526
3668
|
});
|
|
3527
3669
|
} catch (err) {
|
|
3528
3670
|
console.error(color("red", `error: network failure calling /api/trust-diff: ${err.message}`));
|
|
@@ -3656,17 +3798,41 @@ async function runTrustDiffCommand(args) {
|
|
|
3656
3798
|
// R86.5 — surface posture advisory line in default (non-strict) mode.
|
|
3657
3799
|
const postureAdvisory = formatPostureAdvisoryLine(posture, strictPosture);
|
|
3658
3800
|
if (postureAdvisory) console.log(postureAdvisory);
|
|
3801
|
+
// R87 (2026-06-05) — fold route-assertion failures into ship_decision.
|
|
3802
|
+
// Customer-declared assertions are surfaced regardless; their critical
|
|
3803
|
+
// failures (declared `protected` route that's now `exposed`) ALWAYS
|
|
3804
|
+
// promote ship_decision to block — this is the catastrophic case the
|
|
3805
|
+
// feature exists to catch ("admin went public") and it does not require
|
|
3806
|
+
// --strict-posture or any opt-in. Default + auto-detected assertions
|
|
3807
|
+
// follow the same gating rules.
|
|
3808
|
+
const routeAssertions = currentReport?.routeAssertions || null;
|
|
3809
|
+
const assertionShipImpact = shipDecisionFromAssertions(routeAssertions);
|
|
3810
|
+
const effectiveShipWithAssertions = assertionShipImpact
|
|
3811
|
+
? (SHIP_DECISION_RANK[assertionShipImpact] > SHIP_DECISION_RANK[effectiveShip] ? assertionShipImpact : effectiveShip)
|
|
3812
|
+
: effectiveShip;
|
|
3813
|
+
const assertionFields = routeAssertions ? {
|
|
3814
|
+
assertions_total: routeAssertions.total,
|
|
3815
|
+
assertions_passed: routeAssertions.passed,
|
|
3816
|
+
assertions_failed: routeAssertions.failed,
|
|
3817
|
+
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}`,
|
|
3819
|
+
assertion_top_failure: routeAssertions.criticalFailures[0]
|
|
3820
|
+
? `${routeAssertions.criticalFailures[0].path}: expected ${routeAssertions.criticalFailures[0].expected}, got ${routeAssertions.criticalFailures[0].actual}`
|
|
3821
|
+
: undefined,
|
|
3822
|
+
} : {};
|
|
3823
|
+
|
|
3659
3824
|
console.log(formatAiFooterBlock({
|
|
3660
|
-
status:
|
|
3825
|
+
status: effectiveShipWithAssertions,
|
|
3661
3826
|
domain,
|
|
3662
3827
|
kind: "trust-diff",
|
|
3663
3828
|
baseline,
|
|
3664
3829
|
verdict,
|
|
3665
3830
|
delta_count: deltas.length,
|
|
3666
3831
|
max_severity: maxSev,
|
|
3667
|
-
ship_decision:
|
|
3832
|
+
ship_decision: effectiveShipWithAssertions,
|
|
3668
3833
|
ship_decision_drift: shipDecision,
|
|
3669
3834
|
ship_decision_posture: posture?.decision,
|
|
3835
|
+
ship_decision_assertions: assertionShipImpact,
|
|
3670
3836
|
ship_decision_mode: strictPosture ? "strict_posture" : "drift_only",
|
|
3671
3837
|
top_issue: topDelta?.id || topDelta?.type || "none",
|
|
3672
3838
|
top_issue_title: topDelta?.title || undefined,
|
|
@@ -3676,12 +3842,14 @@ async function runTrustDiffCommand(args) {
|
|
|
3676
3842
|
quota_limit: result.quota?.monthly_limit ?? undefined,
|
|
3677
3843
|
scanned_at: new Date().toISOString(),
|
|
3678
3844
|
advisory_only: "true",
|
|
3679
|
-
scope: strictPosture ? "
|
|
3680
|
-
scope_note: strictPosture
|
|
3681
|
-
|
|
3682
|
-
: "ship_decision reflects DRIFT only by default (per-deploy regression gate). Posture grade is surfaced via posture_decision / ship_decision_posture as advisory — D/F posture does NOT auto-block. Once your site reaches A/B posture, pass --strict-posture to lock that in. Cipherwake does NOT verify app functionality.",
|
|
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.",
|
|
3847
|
+
...assertionFields,
|
|
3683
3848
|
...postureFields,
|
|
3684
3849
|
}));
|
|
3850
|
+
if (routeAssertions) {
|
|
3851
|
+
console.log(formatRouteAssertionsBlock(routeAssertions));
|
|
3852
|
+
}
|
|
3685
3853
|
if (posture && Array.isArray(posture.fixes) && posture.fixes.length > 0) {
|
|
3686
3854
|
console.log(formatPostureFixesBlock(posture.fixes));
|
|
3687
3855
|
}
|
|
@@ -3693,13 +3861,13 @@ async function runTrustDiffCommand(args) {
|
|
|
3693
3861
|
score: typeof result.current_score === "number" ? result.current_score : null,
|
|
3694
3862
|
grade: result.current_grade || null,
|
|
3695
3863
|
max_severity: maxSev,
|
|
3696
|
-
ship_decision:
|
|
3864
|
+
ship_decision: effectiveShipWithAssertions,
|
|
3697
3865
|
baseline,
|
|
3698
3866
|
delta_count: deltas.length,
|
|
3699
3867
|
top_issue: topDelta?.id || topDelta?.title || null,
|
|
3700
3868
|
});
|
|
3701
3869
|
|
|
3702
|
-
process.exit(shipDecisionExitCode(
|
|
3870
|
+
process.exit(shipDecisionExitCode(effectiveShipWithAssertions));
|
|
3703
3871
|
}
|
|
3704
3872
|
|
|
3705
3873
|
// Format output
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pqcheck",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.24",
|
|
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",
|