@kirrosh/zond 0.23.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +164 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +33 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. package/src/core/generator/create-body.ts +0 -89
@@ -0,0 +1,219 @@
1
+ /**
2
+ * `cursor_boundary_fuzzing` (ARV-273) — fuzz cursor/page-token style
3
+ * query parameters on list endpoints with malformed values. The server
4
+ * is expected to reject with 400/422 (or 401/403 if the path is auth-
5
+ * gated). A 5xx response on *any* well-formed cursor value is a real
6
+ * bug (Stripe `/v1/billing/alerts` was found this way during the m-22
7
+ * Stripe scan).
8
+ *
9
+ * Detection rule: parameter `name` matches a small set of conventional
10
+ * cursor names (cursor / starting_after / ending_before / after /
11
+ * before / page_token / next_token / continuation), parameter is
12
+ * `in: "query"`, and schema type is string (or untyped — many SDKs
13
+ * leave the cursor schema open).
14
+ *
15
+ * Mutation vectors (7): empty string, numeric, "null", very-long
16
+ * string (200 chars), valid-shape-wrong-resource (Stripe-id), SQL-
17
+ * shaped (`' OR 1=1--`), JSON-shaped (`{"foo":"bar"}`).
18
+ *
19
+ * Severity ladder:
20
+ * - any 5xx → HIGH (server should never crash on bad cursor)
21
+ * - any 2xx/204 → LOW (server silently tolerates a malformed cursor)
22
+ * - 4xx other than 4xx-expected → no finding (server rejected — good)
23
+ * - 401/403 only → skip (auth-gated, not the check's concern)
24
+ *
25
+ * Why not done by `negative_data_rejection`: that check mutates one
26
+ * value per case based on the declared schema (string → integer,
27
+ * integer overflow, etc.). Cursor fuzzing is a *cross-API convention*
28
+ * fuzz family that does not derive from schema type; we know these
29
+ * are cursor-shaped strings and we know the standard malformations
30
+ * that crash naive cursor parsers.
31
+ */
32
+ import type { OpenAPIV3 } from "openapi-types";
33
+ import type { CrudStatefulCheck } from "../stateful.ts";
34
+ import type { CheckOutcome, Severity } from "../types.ts";
35
+ import { fillPathParams } from "./_crud-helpers.ts";
36
+ import { buildUrl as buildUrlBase } from "../../util/url.ts";
37
+ import { MAX_REQUESTS_SKIP_REASON } from "../../runner/executor.ts";
38
+
39
+ const CURSOR_PARAM_NAME_RE = /^(cursor|starting_after|ending_before|after|before|page_token|next_token|continuation)$/i;
40
+
41
+ interface MutationVector {
42
+ label: string;
43
+ value: string;
44
+ }
45
+
46
+ const MUTATION_VECTORS: readonly MutationVector[] = [
47
+ { label: "empty", value: "" },
48
+ { label: "numeric", value: "12345" },
49
+ { label: "null-literal", value: "null" },
50
+ { label: "very-long", value: "a".repeat(200) },
51
+ { label: "wrong-resource-id", value: "cus_invalid_zzz" },
52
+ { label: "sql-shaped", value: "' OR 1=1--" },
53
+ { label: "json-shaped", value: '{"foo":"bar"}' },
54
+ ];
55
+
56
+ function isCursorParam(p: OpenAPIV3.ParameterObject): boolean {
57
+ if (p.in !== "query") return false;
58
+ if (!CURSOR_PARAM_NAME_RE.test(p.name)) return false;
59
+ const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
60
+ if (!schema) return true;
61
+ // Accept untyped schemas (many specs leave cursor open) and string.
62
+ if (!schema.type) return true;
63
+ return schema.type === "string";
64
+ }
65
+
66
+ function buildUrl(
67
+ base: string,
68
+ path: string,
69
+ pathVars: Record<string, string> | undefined,
70
+ cursorParam: string,
71
+ cursorValue: string,
72
+ ): string {
73
+ return buildUrlBase(base, fillPathParams(path, pathVars), { [cursorParam]: cursorValue });
74
+ }
75
+
76
+ interface VectorOutcome {
77
+ param: string;
78
+ vector: string;
79
+ status: number;
80
+ body_excerpt?: string;
81
+ }
82
+
83
+ function excerpt(s: string | undefined, n: number): string | undefined {
84
+ if (!s) return undefined;
85
+ const trimmed = s.length > n ? s.slice(0, n) + "…" : s;
86
+ return trimmed;
87
+ }
88
+
89
+ export const cursorBoundaryFuzzing: CrudStatefulCheck = {
90
+ id: "cursor_boundary_fuzzing",
91
+ // Per-finding severity overrides via outcome.severity: 5xx → high, 2xx → low.
92
+ // Declared baseline 'low' so it doesn't dominate per-check gating tables.
93
+ severity: "low",
94
+ defaultExpected:
95
+ "Cursor-style query params (cursor / starting_after / page_token / …) must reject malformed values with 4xx, never 5xx",
96
+ references: [{ id: "ARV-273" }],
97
+ phase: "crud",
98
+ applies(g) {
99
+ if (!g.list) return false;
100
+ // ARV-310: cursor fuzzing is only meaningful on a paginating GET list.
101
+ // A create/mutation endpoint that happens to carry a cursor-shaped query
102
+ // param (or a mis-grouped POST bound as `list`) must not trip this check.
103
+ if (g.list.method.toUpperCase() !== "GET") return false;
104
+ return g.list.parameters.some(isCursorParam);
105
+ },
106
+ async run(g, h): Promise<CheckOutcome> {
107
+ if (h.bootstrapCleanupFailed) {
108
+ return { kind: "skip", reason: "bootstrap-cleanup failed — stateful checks paused" };
109
+ }
110
+ const list = g.list!;
111
+ const cursorParams = list.parameters.filter(isCursorParam);
112
+ if (cursorParams.length === 0) {
113
+ return { kind: "skip", reason: "no cursor-style query params on list endpoint" };
114
+ }
115
+
116
+ const baseHeaders = { Accept: "application/json", ...h.authHeaders };
117
+ const serverErrors: VectorOutcome[] = [];
118
+ const silentAccepts: VectorOutcome[] = [];
119
+ let totalAttempted = 0;
120
+ let allAuthGated = true;
121
+
122
+ let budgetExhausted = false;
123
+ outer: for (const param of cursorParams) {
124
+ for (const vec of MUTATION_VECTORS) {
125
+ const url = buildUrl(h.baseUrl, list.path, h.pathVars, param.name, vec.value);
126
+ let resp;
127
+ try {
128
+ resp = await h.send({ method: "GET", url, headers: baseHeaders });
129
+ } catch (err) {
130
+ // ARV-308: distinguish budget exhaustion (every subsequent
131
+ // mutation will throw the same MAX_REQUESTS_SKIP_REASON and
132
+ // wasn't dispatched on the wire) from real per-request
133
+ // network errors (genuine connection problems, where we
134
+ // should keep fuzzing the remaining vectors). The earlier
135
+ // `catch {}` swallowed both as "network errors", which
136
+ // misreported the run on Stripe — budget had simply run out
137
+ // before this check got its turn, but the summary blamed
138
+ // the network and the 500 finding never surfaced.
139
+ if (err instanceof Error && err.message === MAX_REQUESTS_SKIP_REASON) {
140
+ budgetExhausted = true;
141
+ break outer;
142
+ }
143
+ continue;
144
+ }
145
+ totalAttempted += 1;
146
+ const status = resp.status;
147
+ if (status !== 401 && status !== 403) allAuthGated = false;
148
+ if (status >= 500 && status < 600) {
149
+ serverErrors.push({
150
+ param: param.name,
151
+ vector: vec.label,
152
+ status,
153
+ body_excerpt: excerpt(resp.body, 240),
154
+ });
155
+ } else if (status >= 200 && status < 300) {
156
+ silentAccepts.push({
157
+ param: param.name,
158
+ vector: vec.label,
159
+ status,
160
+ });
161
+ }
162
+ }
163
+ }
164
+
165
+ if (totalAttempted === 0) {
166
+ // ARV-308: dedicated reason for budget exhaustion so the run
167
+ // summary shows the real cause (and an actionable fix — raise
168
+ // --max-requests / --budget) instead of blaming the network.
169
+ if (budgetExhausted) {
170
+ return { kind: "skip", reason: MAX_REQUESTS_SKIP_REASON };
171
+ }
172
+ return { kind: "skip", reason: "no mutations dispatched (network errors on every probe)" };
173
+ }
174
+ if (allAuthGated) {
175
+ return { kind: "skip", reason: "endpoint auth-gated for all probes (401/403 only)" };
176
+ }
177
+ if (serverErrors.length > 0) {
178
+ const sev: Severity = "high";
179
+ const first = serverErrors[0]!;
180
+ return {
181
+ kind: "fail",
182
+ severity: sev,
183
+ // ARV-310: attribute to the GET list actually probed, not the group's
184
+ // POST create.
185
+ operation: { path: list.path, method: "GET", operationId: list.operationId },
186
+ message:
187
+ `Server returned ${first.status} on ${first.vector} cursor (${first.param}) — ${serverErrors.length}/${totalAttempted} mutation(s) hit 5xx`,
188
+ evidence: {
189
+ resource: g.resource,
190
+ list_path: list.path,
191
+ kind: "server_error_on_bad_cursor",
192
+ cursor_params: cursorParams.map((p) => p.name),
193
+ attempted: totalAttempted,
194
+ server_errors: serverErrors,
195
+ },
196
+ };
197
+ }
198
+ if (silentAccepts.length > 0) {
199
+ return {
200
+ kind: "fail",
201
+ severity: "low",
202
+ // ARV-310: attribute to the GET list actually probed, not the group's
203
+ // POST create.
204
+ operation: { path: list.path, method: "GET", operationId: list.operationId },
205
+ message:
206
+ `Server returned 2xx on ${silentAccepts.length}/${totalAttempted} malformed cursor mutation(s) — likely silent tolerance of bad input`,
207
+ evidence: {
208
+ resource: g.resource,
209
+ list_path: list.path,
210
+ kind: "silent_accept_on_bad_cursor",
211
+ cursor_params: cursorParams.map((p) => p.name),
212
+ attempted: totalAttempted,
213
+ accepts: silentAccepts,
214
+ },
215
+ };
216
+ }
217
+ return { kind: "pass" };
218
+ },
219
+ };
@@ -110,11 +110,7 @@ function diffFields(a: unknown, b: unknown, ignore: ReadonlySet<string>): string
110
110
  }
111
111
 
112
112
  function generateKey(): string {
113
- // Bun + Node 19+ ship crypto.randomUUID; fall back to a timestamp+rand
114
- // mash so tests on minimal stubs still produce a stable-ish key.
115
- const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
116
- if (c?.randomUUID) return c.randomUUID();
117
- return `zond-${Date.now()}-${Math.random().toString(36).slice(2)}`;
113
+ return crypto.randomUUID();
118
114
  }
119
115
 
120
116
  export const idempotencyReplay: CrudStatefulCheck = {
@@ -29,6 +29,30 @@
29
29
  * - skip operations with `security: []` override (explicitly public),
30
30
  * - skip when `bootstrap_cleanup_failed` (data state corrupted),
31
31
  * - skip when no auth headers provided to the harness at all.
32
+ *
33
+ * Severity matrix (ARV-286, dispatched per finding via outcome.severity;
34
+ * follow-up to ARV-284 `negative_data_rejection` pattern):
35
+ *
36
+ * Declared severity: 'low' (proof-cap baseline per ARV-250 — single-
37
+ * signal evidence alone caps at LOW; chain evidence elevates to HIGH).
38
+ *
39
+ * Per-finding dispatch:
40
+ *
41
+ * | evidence.variant | severity | rationale |
42
+ * |---------------------------|----------|-----------------------------------|
43
+ * | no_auth | HIGH | baseline 2xx + no-auth 2xx |
44
+ * | bogus_auth | HIGH | baseline 2xx + bogus 2xx |
45
+ * | no_auth_differential | HIGH | broken-baseline + lower bucket |
46
+ * | bogus_auth_differential | HIGH | broken-baseline + lower bucket |
47
+ * | no_auth_strict | MEDIUM | --strict-401 mismatch, no bypass |
48
+ * | bogus_auth_strict | MEDIUM | --strict-401 mismatch, no bypass |
49
+ *
50
+ * HIGH variants (bypass + differential) provide chain evidence: both a
51
+ * baseline probe and an auth-stripped probe contribute — two independent
52
+ * signals proving auth is ignored. MEDIUM variants (strict-401
53
+ * conformance) are single-signal: auth is likely enforced (server still
54
+ * rejects), just with the wrong status code (403/404 instead of 401).
55
+ * The agent re-severitizes from the raw evidence.
32
56
  */
33
57
  import type { OpenAPIV3 } from "openapi-types";
34
58
  import type { AuthStatefulCheck } from "../stateful.ts";
@@ -89,7 +113,10 @@ function isAcceptableRejection(status: number, strict401: boolean): boolean {
89
113
 
90
114
  export const ignoredAuth: AuthStatefulCheck = {
91
115
  id: "ignored_auth",
92
- severity: "high",
116
+ /** ARV-286: proof-cap baseline (ARV-250). Bypass findings emit
117
+ * outcome.severity="high" (chain evidence); strict-401 conformance
118
+ * findings emit outcome.severity="medium" (single-signal). */
119
+ severity: "low",
93
120
  defaultExpected: "Server must reject requests without (or with bogus) auth credentials with 401/403",
94
121
  references: [{ id: "OWASP-API-01" }],
95
122
  phase: "auth",
@@ -139,6 +166,8 @@ export const ignoredAuth: AuthStatefulCheck = {
139
166
  if (noAuth.status >= 200 && noAuth.status < 300) {
140
167
  return {
141
168
  kind: "fail",
169
+ // chain evidence: baseline 2xx + no-auth 2xx → proven bypass
170
+ severity: "high",
142
171
  message: `Server accepted request without auth credentials (status ${noAuth.status}) — auth is being ignored`,
143
172
  evidence: { variant: "no_auth", baseline_status: baseline.status, no_auth_status: noAuth.status },
144
173
  };
@@ -146,6 +175,8 @@ export const ignoredAuth: AuthStatefulCheck = {
146
175
  if (bogus.status >= 200 && bogus.status < 300) {
147
176
  return {
148
177
  kind: "fail",
178
+ // chain evidence: baseline 2xx + bogus-auth 2xx → credentials not validated
179
+ severity: "high",
149
180
  message: `Server accepted request with bogus auth (status ${bogus.status}) — credentials not validated`,
150
181
  evidence: { variant: "bogus_auth", baseline_status: baseline.status, bogus_auth_status: bogus.status },
151
182
  };
@@ -154,6 +185,8 @@ export const ignoredAuth: AuthStatefulCheck = {
154
185
  if (noAuth.status !== 401) {
155
186
  return {
156
187
  kind: "fail",
188
+ // single-signal: auth is likely enforced (4xx returned), just wrong status code
189
+ severity: "medium",
157
190
  message: `no_auth returned ${noAuth.status}, expected 401 (--strict-401)`,
158
191
  evidence: { variant: "no_auth_strict", baseline_status: baseline.status, no_auth_status: noAuth.status, strict_401: true },
159
192
  };
@@ -161,6 +194,8 @@ export const ignoredAuth: AuthStatefulCheck = {
161
194
  if (bogus.status !== 401) {
162
195
  return {
163
196
  kind: "fail",
197
+ // single-signal: auth is likely enforced (4xx returned), just wrong status code
198
+ severity: "medium",
164
199
  message: `bogus_auth returned ${bogus.status}, expected 401 (--strict-401)`,
165
200
  evidence: { variant: "bogus_auth_strict", baseline_status: baseline.status, bogus_auth_status: bogus.status, strict_401: true },
166
201
  };
@@ -179,6 +214,8 @@ export const ignoredAuth: AuthStatefulCheck = {
179
214
  if (noAuthBucket >= 0 && noAuthBucket < baseBucket) {
180
215
  return {
181
216
  kind: "fail",
217
+ // chain evidence: broken-baseline + lower bucket without auth → smoking gun bypass
218
+ severity: "high",
182
219
  message: `Server gave a more permissive status (${noAuth.status}) without auth than with valid auth (${baseline.status}) — possible bypass`,
183
220
  evidence: { variant: "no_auth_differential", baseline_status: baseline.status, no_auth_status: noAuth.status },
184
221
  };
@@ -186,6 +223,8 @@ export const ignoredAuth: AuthStatefulCheck = {
186
223
  if (bogusBucket >= 0 && bogusBucket < baseBucket) {
187
224
  return {
188
225
  kind: "fail",
226
+ // chain evidence: broken-baseline + lower bucket with bogus token → bypass
227
+ severity: "high",
189
228
  message: `Server gave a more permissive status (${bogus.status}) with bogus auth than with valid auth (${baseline.status}) — possible bypass`,
190
229
  evidence: { variant: "bogus_auth_differential", baseline_status: baseline.status, bogus_auth_status: bogus.status },
191
230
  };
@@ -194,6 +233,8 @@ export const ignoredAuth: AuthStatefulCheck = {
194
233
  if (!isAcceptableRejection(noAuth.status, true) && noAuthBucket >= 0) {
195
234
  return {
196
235
  kind: "fail",
236
+ // single-signal: auth is enforced (server still rejects), wrong status code
237
+ severity: "medium",
197
238
  message: `no_auth returned ${noAuth.status}, expected 401 (--strict-401, baseline ${baseline.status})`,
198
239
  evidence: { variant: "no_auth_strict", baseline_status: baseline.status, no_auth_status: noAuth.status, strict_401: true },
199
240
  };
@@ -201,6 +242,8 @@ export const ignoredAuth: AuthStatefulCheck = {
201
242
  if (!isAcceptableRejection(bogus.status, true) && bogusBucket >= 0) {
202
243
  return {
203
244
  kind: "fail",
245
+ // single-signal: auth is enforced (server still rejects), wrong status code
246
+ severity: "medium",
204
247
  message: `bogus_auth returned ${bogus.status}, expected 401 (--strict-401, baseline ${baseline.status})`,
205
248
  evidence: { variant: "bogus_auth_strict", baseline_status: baseline.status, bogus_auth_status: bogus.status, strict_401: true },
206
249
  };
@@ -27,6 +27,7 @@ import { paginationInvariants } from "./pagination_invariants.ts";
27
27
  import { lifecycleTransitions } from "./lifecycle_transitions.ts";
28
28
  import { openCorsOnSensitive } from "./open_cors_on_sensitive.ts";
29
29
  import { rateLimitHeadersAbsent } from "./rate_limit_headers_absent.ts";
30
+ import { cursorBoundaryFuzzing } from "./cursor_boundary_fuzzing.ts";
30
31
 
31
32
  let registered = false;
32
33
 
@@ -59,6 +60,8 @@ export function registerBuiltinChecks(): void {
59
60
  // ARV-256 (m-21) — small-team value-add checks.
60
61
  registerStatefulCheck(openCorsOnSensitive);
61
62
  registerCheck(rateLimitHeadersAbsent);
63
+ // ARV-273 (m-22) — cursor/page-token fuzzing on list endpoints.
64
+ registerStatefulCheck(cursorBoundaryFuzzing);
62
65
  registered = true;
63
66
  }
64
67
 
@@ -1,38 +1,50 @@
1
1
  /**
2
- * `lifecycle_transitions` (m-20 ARV-172) verify a resource's declared
3
- * state machine on the live API.
2
+ * `lifecycle_transitions` (m-20 ARV-172, ARV-219 added observation in m-21)
3
+ * — verify a resource's declared state machine on the live API.
4
4
  *
5
- * For each CRUD group whose resource has a `lifecycle:` yaml block:
5
+ * Two modes, gated on the `actions` field of the yaml manifest:
6
6
  *
7
- * 1. POST create capture id + initial state from the response.
8
- * 2. Assert initial state declared `states[]`. Undeclared state →
9
- * finding (`undeclared_state`).
10
- * 3. For each declared `action` in turn:
11
- * a. POST <action.endpoint> with {id} substituted.
12
- * b. GET resource read `state.field`.
13
- * c. Assert observed state == `action.expected_state`. Wrong
14
- * terminal finding (`wrong_expected_state`).
15
- * d. Assert (previous_state, observed_state) declared
16
- * transitions. Forbidden hop finding (`forbidden_transition`).
17
- * e. POST <action.endpoint> a second time (idempotency probe):
18
- * either 4xx (rejected) or 2xx with state unchanged. State
19
- * regression on replay → finding (`state_regression_on_replay`).
7
+ * ── Action-driven mode (preferred when CRUD allows mutation) ───────
8
+ * Requires `g.create && g.read`. For each declared `action`:
9
+ * 1. POST create → capture id + initial state.
10
+ * 2. Assert initial state ∈ declared `states[]`.
11
+ * 3. For each action in object-key order:
12
+ * a. POST <action.endpoint> with {id} substituted.
13
+ * b. GET resource read `state.field`.
14
+ * c. Assert observed state == `action.expected_state` and the
15
+ * (previous, observed) hop is in `transitions`.
16
+ * d. POST action a second time (idempotency probe); state must
17
+ * not regress.
20
18
  *
21
- * Severity: HIGH. The four failure classes share one finding per
22
- * action (consistent with cross_call_references / idempotency_replay /
23
- * pagination_invariants). evidence.kind discriminates.
19
+ * ── Pure-observation mode (ARV-219, for read-only state machines) ──
20
+ * Triggers when `actions: {}` (or omitted). Requires `g.list`.
21
+ * GET list once, walk items, collect any state values not in
22
+ * `states[]`. Surfaces a single finding listing each undeclared
23
+ * state with sample item ids. Useful for APIs where zond cannot
24
+ * POST (read-only PAT, GitHub Issues without write scope, …) but
25
+ * the spec still declares a status enum — drift between observed
26
+ * and declared states is a contract-doc bug.
24
27
  *
25
- * Anti-FP guards:
28
+ * Severity: HIGH. Failure classes share one finding (consistent with
29
+ * cross_call_references / idempotency_replay / pagination_invariants);
30
+ * evidence.kind discriminates.
31
+ *
32
+ * Anti-FP guards (both modes):
26
33
  * • Yaml manifest validated at load (validateLifecycleManifest in
27
34
  * resources-builder); a malformed manifest skips with the
28
35
  * concrete error so the operator gets actionable feedback.
29
- * • POST create non-2xx → broken-baseline skip.
36
+ * • Non-2xx baseline (create or list) → broken-baseline skip.
30
37
  * • Action POST non-2xx on first call → action-not-supported skip
31
38
  * (the API may have authoritative server-side gating; not a
32
39
  * contract bug).
33
- * • Each action runs once per check execution actions interact in
34
- * the order yaml declares them, so authoring the yaml in a
35
- * legal-transition order lets the chain advance the resource.
40
+ * • Pure-observation: empty list response skip (no data to
41
+ * observe), not a finding.
42
+ *
43
+ * Limitations:
44
+ * • Pure-observation cannot verify `transitions[]` — there is no
45
+ * time series in a single list call. The check only enforces
46
+ * `observed ⊆ declared`; the transition graph is purely
47
+ * documentation in observation mode.
36
48
  */
37
49
  import type { OpenAPIV3 } from "openapi-types";
38
50
  import type { CrudStatefulCheck } from "../stateful.ts";
@@ -85,7 +97,7 @@ export const lifecycleTransitions: CrudStatefulCheck = {
85
97
  references: [{ id: "ARV-172" }],
86
98
  phase: "crud",
87
99
  applies(g) {
88
- return Boolean(g.create && g.read);
100
+ return Boolean((g.create && g.read) || g.list);
89
101
  },
90
102
  async run(g, h) {
91
103
  if (h.bootstrapCleanupFailed) {
@@ -98,8 +110,19 @@ export const lifecycleTransitions: CrudStatefulCheck = {
98
110
  if (manifestErrors.length > 0) {
99
111
  return { kind: "skip", reason: `lifecycle manifest invalid: ${manifestErrors[0]}` };
100
112
  }
113
+
114
+ // ARV-219: actions-empty → pure-observation mode (read-only state
115
+ // machine). Requires a list endpoint to sample observed states.
101
116
  if (Object.keys(cfg.actions).length === 0) {
102
- return { kind: "skip", reason: "lifecycle has no actions to verify" };
117
+ if (!g.list) {
118
+ return { kind: "skip", reason: "lifecycle has no actions and no list endpoint — nothing to verify or observe" };
119
+ }
120
+ return runPureObservation(g, h, cfg);
121
+ }
122
+
123
+ // Action-driven mode requires both create + read.
124
+ if (!g.create || !g.read) {
125
+ return { kind: "skip", reason: "lifecycle actions declared but resource lacks create+read endpoints" };
103
126
  }
104
127
 
105
128
  const create = g.create!;
@@ -271,3 +294,123 @@ export const lifecycleTransitions: CrudStatefulCheck = {
271
294
  };
272
295
  },
273
296
  };
297
+
298
+ /** Item-array containers we recognise when a list endpoint returns
299
+ * `{ data: [...] }` etc. Mirrors `pagination_invariants` defaults so
300
+ * the two checks stay in sync on response-shape heuristics. */
301
+ const ITEMS_FIELD_FALLBACKS: ReadonlyArray<string> = ["data", "items", "results", "value"];
302
+
303
+ function extractListItems(body: unknown): unknown[] | null {
304
+ if (Array.isArray(body)) return body;
305
+ if (!body || typeof body !== "object") return null;
306
+ const obj = body as Record<string, unknown>;
307
+ for (const f of ITEMS_FIELD_FALLBACKS) {
308
+ const v = obj[f];
309
+ if (Array.isArray(v)) return v;
310
+ }
311
+ // ARV-219 follow-up: GitHub-shape responses use API-specific keys
312
+ // (`workflow_runs`, `check_runs`, `artifacts`, `installations`, …)
313
+ // alongside metadata fields like `total_count`. Pick the longest
314
+ // array-valued property as the items collection — that's the
315
+ // canonical shape across the GitHub REST API. Wrong-array picks
316
+ // surface as `state field missing on all items` (informative skip),
317
+ // never as a finding.
318
+ let best: unknown[] | null = null;
319
+ for (const v of Object.values(obj)) {
320
+ if (Array.isArray(v) && (!best || v.length > best.length)) {
321
+ best = v as unknown[];
322
+ }
323
+ }
324
+ return best;
325
+ }
326
+
327
+ function pickSampleId(item: unknown, idParam: string): string | null {
328
+ if (item == null || typeof item !== "object") return null;
329
+ const obj = item as Record<string, unknown>;
330
+ // Try the resource's id-param field, then a generic `id` fallback —
331
+ // GitHub's `/issues` returns `number`, Stripe's returns `id`; both
332
+ // serve as a human-readable sample handle in the finding evidence.
333
+ for (const k of [idParam, "id", "number", "uuid", "key"]) {
334
+ const v = obj[k];
335
+ if (typeof v === "string" || typeof v === "number") return String(v);
336
+ }
337
+ return null;
338
+ }
339
+
340
+ async function runPureObservation(
341
+ g: Parameters<CrudStatefulCheck["run"]>[0],
342
+ h: Parameters<CrudStatefulCheck["run"]>[1],
343
+ cfg: LifecycleConfig,
344
+ ): ReturnType<CrudStatefulCheck["run"]> {
345
+ const list = g.list!;
346
+ const baseHeaders = { Accept: "application/json", ...h.authHeaders };
347
+ const url = `${h.baseUrl.replace(/\/+$/, "")}${fillPathParams(list.path, h.pathVars)}`;
348
+
349
+ const resp = await h.send({ method: "GET", url, headers: baseHeaders });
350
+ if (resp.status < 200 || resp.status >= 300) {
351
+ return { kind: "skip", reason: `list returned ${resp.status} — broken-baseline guard (observation mode)` };
352
+ }
353
+ const body = resp.body_parsed ?? safeParse(resp.body);
354
+ const items = extractListItems(body);
355
+ if (items == null) {
356
+ return { kind: "skip", reason: "list response shape not recognised (expected array or {data|items|results|value: []})" };
357
+ }
358
+ if (items.length === 0) {
359
+ return { kind: "skip", reason: "list empty — no data to observe" };
360
+ }
361
+
362
+ const stateSet = new Set(cfg.states);
363
+ const missingField: string[] = [];
364
+ // Map of undeclared state → up to N sample ids for evidence; using
365
+ // a Map preserves observed-order so the finding mentions states
366
+ // in the order the API surfaced them.
367
+ const undeclared = new Map<string, string[]>();
368
+ const SAMPLE_CAP = 5;
369
+
370
+ for (const item of items) {
371
+ if (item == null || typeof item !== "object") continue;
372
+ const state = (item as Record<string, unknown>)[cfg.field];
373
+ const sampleId = pickSampleId(item, g.idParam) ?? "?";
374
+ if (typeof state !== "string") {
375
+ if (missingField.length < SAMPLE_CAP) missingField.push(sampleId);
376
+ continue;
377
+ }
378
+ if (!stateSet.has(state)) {
379
+ const ids = undeclared.get(state) ?? [];
380
+ if (ids.length < SAMPLE_CAP) ids.push(sampleId);
381
+ undeclared.set(state, ids);
382
+ }
383
+ }
384
+
385
+ // Field-missing is informational only — many APIs nest state under a
386
+ // sub-object the operator may have misspelled. Surface as a skip when
387
+ // EVERY item lacks the field (yaml mismatch), but don't fail the run
388
+ // when only some items lack it (could be a polymorphic schema).
389
+ if (undeclared.size === 0 && missingField.length === items.length) {
390
+ return {
391
+ kind: "skip",
392
+ reason: `state field "${cfg.field}" missing on all ${items.length} observed items — yaml mismatch or nested field`,
393
+ };
394
+ }
395
+
396
+ if (undeclared.size === 0) return { kind: "pass" };
397
+
398
+ const observedList = [...undeclared.entries()].map(([state, ids]) => ({
399
+ state,
400
+ sample_ids: ids,
401
+ occurrence_cap_hit: ids.length === SAMPLE_CAP,
402
+ }));
403
+ const stateNames = [...undeclared.keys()];
404
+ return {
405
+ kind: "fail",
406
+ message: `Lifecycle on ${g.resource} (observation mode): observed ${undeclared.size} undeclared state(s) — ${stateNames.join(", ")}`,
407
+ evidence: {
408
+ resource: g.resource,
409
+ kind: "undeclared_state",
410
+ mode: "observation",
411
+ observed_undeclared: observedList,
412
+ declared_states: cfg.states,
413
+ items_examined: items.length,
414
+ },
415
+ };
416
+ }