@kirrosh/zond 0.26.0 → 0.27.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 (261) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +22 -21
  3. package/bin/zond.mjs +23 -0
  4. package/package.json +13 -16
  5. package/scripts/npm/postinstall.mjs +76 -0
  6. package/src/CLAUDE.md +0 -112
  7. package/src/bun-types.d.ts +0 -5
  8. package/src/cli/argv.ts +0 -122
  9. package/src/cli/commands/add-api.ts +0 -146
  10. package/src/cli/commands/api/annotate/idempotency.ts +0 -59
  11. package/src/cli/commands/api/annotate/index.ts +0 -880
  12. package/src/cli/commands/api/annotate/lifecycle.ts +0 -74
  13. package/src/cli/commands/api/annotate/overlay.ts +0 -206
  14. package/src/cli/commands/api/annotate/pagination.ts +0 -64
  15. package/src/cli/commands/api/annotate/prompts.ts +0 -220
  16. package/src/cli/commands/api/annotate/readback.ts +0 -58
  17. package/src/cli/commands/api/annotate/resources.ts +0 -91
  18. package/src/cli/commands/api/annotate/seed-bodies.ts +0 -61
  19. package/src/cli/commands/audit.ts +0 -786
  20. package/src/cli/commands/catalog.ts +0 -97
  21. package/src/cli/commands/check.ts +0 -361
  22. package/src/cli/commands/checks.ts +0 -1072
  23. package/src/cli/commands/ci-init.ts +0 -223
  24. package/src/cli/commands/clean.ts +0 -212
  25. package/src/cli/commands/cleanup.ts +0 -236
  26. package/src/cli/commands/completions.ts +0 -192
  27. package/src/cli/commands/coverage.ts +0 -872
  28. package/src/cli/commands/db.ts +0 -611
  29. package/src/cli/commands/describe.ts +0 -120
  30. package/src/cli/commands/discover.ts +0 -1356
  31. package/src/cli/commands/doctor.ts +0 -661
  32. package/src/cli/commands/fixtures.ts +0 -402
  33. package/src/cli/commands/generate.ts +0 -577
  34. package/src/cli/commands/init/agents-md.ts +0 -61
  35. package/src/cli/commands/init/bootstrap.ts +0 -111
  36. package/src/cli/commands/init/index.ts +0 -244
  37. package/src/cli/commands/init/skills.ts +0 -141
  38. package/src/cli/commands/init/templates/agents.md +0 -86
  39. package/src/cli/commands/init/templates/markdown.d.ts +0 -4
  40. package/src/cli/commands/init/templates/skills/warm-up-target.md +0 -122
  41. package/src/cli/commands/init/templates/skills/zond-checks.md +0 -621
  42. package/src/cli/commands/init/templates/skills/zond-seed.md +0 -114
  43. package/src/cli/commands/init/templates/skills/zond-triage.md +0 -272
  44. package/src/cli/commands/init/templates/skills/zond.md +0 -861
  45. package/src/cli/commands/init/templates/zond-config.yml +0 -14
  46. package/src/cli/commands/prepare-fixtures.ts +0 -97
  47. package/src/cli/commands/probe/_seed-bodies.ts +0 -52
  48. package/src/cli/commands/probe/mass-assignment.ts +0 -594
  49. package/src/cli/commands/probe/security.ts +0 -537
  50. package/src/cli/commands/probe/static.ts +0 -255
  51. package/src/cli/commands/probe/webhooks.ts +0 -163
  52. package/src/cli/commands/probe.ts +0 -535
  53. package/src/cli/commands/reference.ts +0 -87
  54. package/src/cli/commands/refresh-api.ts +0 -227
  55. package/src/cli/commands/remove-api.ts +0 -150
  56. package/src/cli/commands/report-bundle.ts +0 -310
  57. package/src/cli/commands/report.ts +0 -241
  58. package/src/cli/commands/request.ts +0 -548
  59. package/src/cli/commands/run.ts +0 -1142
  60. package/src/cli/commands/schema-from-runs.ts +0 -128
  61. package/src/cli/commands/secrets.ts +0 -133
  62. package/src/cli/commands/session.ts +0 -244
  63. package/src/cli/commands/use.ts +0 -74
  64. package/src/cli/index.ts +0 -55
  65. package/src/cli/json-envelope.ts +0 -108
  66. package/src/cli/json-schemas.ts +0 -314
  67. package/src/cli/output.ts +0 -40
  68. package/src/cli/program.ts +0 -219
  69. package/src/cli/resolve.ts +0 -105
  70. package/src/cli/runtime.ts +0 -7
  71. package/src/cli/safe-live.ts +0 -24
  72. package/src/cli/status-filter.ts +0 -114
  73. package/src/cli/util/api-context.ts +0 -85
  74. package/src/cli/version.ts +0 -8
  75. package/src/core/audit/persist.ts +0 -183
  76. package/src/core/checks/budget.ts +0 -59
  77. package/src/core/checks/checks/_crud-helpers.ts +0 -133
  78. package/src/core/checks/checks/_negative_mutator.ts +0 -133
  79. package/src/core/checks/checks/_readback-helpers.ts +0 -133
  80. package/src/core/checks/checks/content_type_conformance.ts +0 -39
  81. package/src/core/checks/checks/cross_call_references.ts +0 -147
  82. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +0 -219
  83. package/src/core/checks/checks/ensure_resource_availability.ts +0 -62
  84. package/src/core/checks/checks/idempotency_replay.ts +0 -242
  85. package/src/core/checks/checks/ignored_auth.ts +0 -254
  86. package/src/core/checks/checks/index.ts +0 -68
  87. package/src/core/checks/checks/lifecycle_transitions.ts +0 -416
  88. package/src/core/checks/checks/missing_required_header.ts +0 -40
  89. package/src/core/checks/checks/negative_data_rejection.ts +0 -148
  90. package/src/core/checks/checks/not_a_server_error.ts +0 -35
  91. package/src/core/checks/checks/open_cors_on_sensitive.ts +0 -160
  92. package/src/core/checks/checks/pagination_invariants.ts +0 -419
  93. package/src/core/checks/checks/positive_data_acceptance.ts +0 -33
  94. package/src/core/checks/checks/rate_limit_headers_absent.ts +0 -77
  95. package/src/core/checks/checks/response_headers_conformance.ts +0 -74
  96. package/src/core/checks/checks/response_schema_conformance.ts +0 -30
  97. package/src/core/checks/checks/status_code_conformance.ts +0 -132
  98. package/src/core/checks/checks/unsupported_method.ts +0 -63
  99. package/src/core/checks/checks/use_after_free.ts +0 -78
  100. package/src/core/checks/index.ts +0 -30
  101. package/src/core/checks/mode.ts +0 -82
  102. package/src/core/checks/recommended-action.ts +0 -68
  103. package/src/core/checks/registry.ts +0 -78
  104. package/src/core/checks/runner.ts +0 -1461
  105. package/src/core/checks/sarif.ts +0 -230
  106. package/src/core/checks/spec-findings.ts +0 -308
  107. package/src/core/checks/stateful.ts +0 -121
  108. package/src/core/checks/types.ts +0 -305
  109. package/src/core/checks/zond-extensions.ts +0 -73
  110. package/src/core/classifier/recommended-action.ts +0 -251
  111. package/src/core/context/current.ts +0 -51
  112. package/src/core/context/session.ts +0 -78
  113. package/src/core/coverage/loader.ts +0 -216
  114. package/src/core/coverage/reasons.ts +0 -300
  115. package/src/core/diagnostics/db-analysis.ts +0 -666
  116. package/src/core/diagnostics/failure-class.ts +0 -140
  117. package/src/core/diagnostics/failure-hints.ts +0 -112
  118. package/src/core/diagnostics/spec-pointer.ts +0 -99
  119. package/src/core/diagnostics/suggested-fixes.ts +0 -155
  120. package/src/core/exporter/case-study/index.ts +0 -270
  121. package/src/core/exporter/curl.ts +0 -40
  122. package/src/core/exporter/exporter.ts +0 -48
  123. package/src/core/exporter/html-report/escape.ts +0 -24
  124. package/src/core/exporter/html-report/index.ts +0 -479
  125. package/src/core/exporter/html-report/script.ts +0 -100
  126. package/src/core/exporter/html-report/styles.ts +0 -408
  127. package/src/core/generator/catalog-builder.ts +0 -179
  128. package/src/core/generator/chunker.ts +0 -78
  129. package/src/core/generator/coverage-phase.ts +0 -0
  130. package/src/core/generator/coverage-scanner.ts +0 -87
  131. package/src/core/generator/data-factory.ts +0 -759
  132. package/src/core/generator/describe.ts +0 -302
  133. package/src/core/generator/endpoint-warnings.ts +0 -52
  134. package/src/core/generator/fixtures-builder.ts +0 -332
  135. package/src/core/generator/index.ts +0 -14
  136. package/src/core/generator/openapi-reader.ts +0 -297
  137. package/src/core/generator/path-param-disambig.ts +0 -140
  138. package/src/core/generator/resources-builder.ts +0 -898
  139. package/src/core/generator/schema-utils.ts +0 -112
  140. package/src/core/generator/serializer.ts +0 -343
  141. package/src/core/generator/suite-generator.ts +0 -1301
  142. package/src/core/generator/types.ts +0 -63
  143. package/src/core/identity/identity-file.ts +0 -0
  144. package/src/core/lint/affects.ts +0 -28
  145. package/src/core/lint/config.ts +0 -96
  146. package/src/core/lint/format.ts +0 -42
  147. package/src/core/lint/index.ts +0 -94
  148. package/src/core/lint/reporter.ts +0 -128
  149. package/src/core/lint/rules/consistency.ts +0 -158
  150. package/src/core/lint/rules/heuristics.ts +0 -97
  151. package/src/core/lint/rules/strictness.ts +0 -109
  152. package/src/core/lint/types.ts +0 -96
  153. package/src/core/lint/walker.ts +0 -248
  154. package/src/core/meta/meta-store.ts +0 -11
  155. package/src/core/output/README.md +0 -73
  156. package/src/core/output/index.ts +0 -13
  157. package/src/core/output/run.ts +0 -91
  158. package/src/core/output/types.ts +0 -122
  159. package/src/core/parser/dynamic-values.ts +0 -160
  160. package/src/core/parser/env-interpolation.ts +0 -104
  161. package/src/core/parser/filter.ts +0 -97
  162. package/src/core/parser/schema.ts +0 -359
  163. package/src/core/parser/types.ts +0 -117
  164. package/src/core/parser/variables.ts +0 -0
  165. package/src/core/parser/yaml-parser.ts +0 -181
  166. package/src/core/probe/bootstrap.ts +0 -34
  167. package/src/core/probe/dry-run-envelope.ts +0 -61
  168. package/src/core/probe/mass-assignment/classify.ts +0 -175
  169. package/src/core/probe/mass-assignment/cleanup.ts +0 -52
  170. package/src/core/probe/mass-assignment/digest.ts +0 -114
  171. package/src/core/probe/mass-assignment/orchestrator.ts +0 -459
  172. package/src/core/probe/mass-assignment/regression.ts +0 -141
  173. package/src/core/probe/mass-assignment/suspects.ts +0 -92
  174. package/src/core/probe/mass-assignment/types.ts +0 -135
  175. package/src/core/probe/mass-assignment-probe-class.ts +0 -198
  176. package/src/core/probe/mass-assignment-probe.ts +0 -27
  177. package/src/core/probe/mass-assignment-template.ts +0 -240
  178. package/src/core/probe/method-probe.ts +0 -164
  179. package/src/core/probe/method-shared.ts +0 -69
  180. package/src/core/probe/negative-probe.ts +0 -691
  181. package/src/core/probe/orphan-tracker.ts +0 -188
  182. package/src/core/probe/path-discovery.ts +0 -439
  183. package/src/core/probe/probe-harness.ts +0 -119
  184. package/src/core/probe/registry.ts +0 -89
  185. package/src/core/probe/runner.ts +0 -136
  186. package/src/core/probe/security/baseline.ts +0 -174
  187. package/src/core/probe/security/classify.ts +0 -341
  188. package/src/core/probe/security/cleanup.ts +0 -125
  189. package/src/core/probe/security/detectors.ts +0 -71
  190. package/src/core/probe/security/digest.ts +0 -104
  191. package/src/core/probe/security/orchestrator.ts +0 -398
  192. package/src/core/probe/security/regression.ts +0 -103
  193. package/src/core/probe/security/types.ts +0 -151
  194. package/src/core/probe/security-probe-class.ts +0 -207
  195. package/src/core/probe/security-probe.ts +0 -32
  196. package/src/core/probe/shared.ts +0 -531
  197. package/src/core/probe/static-probe-class.ts +0 -125
  198. package/src/core/probe/types.ts +0 -165
  199. package/src/core/probe/verdict-aggregator.ts +0 -33
  200. package/src/core/probe/webhooks-probe.ts +0 -282
  201. package/src/core/reporter/console.ts +0 -240
  202. package/src/core/reporter/index.ts +0 -22
  203. package/src/core/reporter/json.ts +0 -22
  204. package/src/core/reporter/junit.ts +0 -93
  205. package/src/core/reporter/ndjson.ts +0 -37
  206. package/src/core/reporter/types.ts +0 -15
  207. package/src/core/runner/assertions.ts +0 -383
  208. package/src/core/runner/async-pool.ts +0 -108
  209. package/src/core/runner/auth-path.ts +0 -8
  210. package/src/core/runner/ci-context.ts +0 -72
  211. package/src/core/runner/executor.ts +0 -652
  212. package/src/core/runner/expr-eval.ts +0 -41
  213. package/src/core/runner/form-encode.ts +0 -41
  214. package/src/core/runner/http-client.ts +0 -224
  215. package/src/core/runner/learn-drift.ts +0 -293
  216. package/src/core/runner/preflight-vars.ts +0 -153
  217. package/src/core/runner/progress-tracker.ts +0 -73
  218. package/src/core/runner/rate-limiter.ts +0 -185
  219. package/src/core/runner/run-kind.ts +0 -45
  220. package/src/core/runner/schema-validator.ts +0 -308
  221. package/src/core/runner/send-request.ts +0 -232
  222. package/src/core/runner/transforms.ts +0 -65
  223. package/src/core/runner/types.ts +0 -94
  224. package/src/core/secrets/registry.ts +0 -164
  225. package/src/core/secrets/secrets-file.ts +0 -115
  226. package/src/core/selectors/operation-filter.ts +0 -144
  227. package/src/core/setup-api.ts +0 -590
  228. package/src/core/severity/category.ts +0 -94
  229. package/src/core/severity/index.ts +0 -58
  230. package/src/core/spec/infer-schema.ts +0 -102
  231. package/src/core/spec/layers.ts +0 -154
  232. package/src/core/spec/merge-specs.ts +0 -156
  233. package/src/core/spec/schema-from-runs.ts +0 -117
  234. package/src/core/spec/schema-overlay.ts +0 -130
  235. package/src/core/util/ajv.ts +0 -13
  236. package/src/core/util/format-eta.ts +0 -21
  237. package/src/core/util/headers.ts +0 -9
  238. package/src/core/util/url.ts +0 -24
  239. package/src/core/utils.ts +0 -13
  240. package/src/core/workspace/config.ts +0 -129
  241. package/src/core/workspace/fixture-gap-report.ts +0 -84
  242. package/src/core/workspace/fixture-gaps.ts +0 -71
  243. package/src/core/workspace/manifest.ts +0 -283
  244. package/src/core/workspace/output-rotation.ts +0 -62
  245. package/src/core/workspace/root.ts +0 -96
  246. package/src/core/workspace/triage-path.ts +0 -87
  247. package/src/db/lint-runs.ts +0 -47
  248. package/src/db/migrate.ts +0 -128
  249. package/src/db/migrations/0001_run_kind.sql +0 -25
  250. package/src/db/migrations/0002_run_kind_request.sql +0 -59
  251. package/src/db/migrations/sql.d.ts +0 -4
  252. package/src/db/queries/collections.ts +0 -133
  253. package/src/db/queries/coverage.ts +0 -9
  254. package/src/db/queries/dashboard.ts +0 -59
  255. package/src/db/queries/results.ts +0 -216
  256. package/src/db/queries/runs.ts +0 -289
  257. package/src/db/queries/sessions.ts +0 -42
  258. package/src/db/queries/settings.ts +0 -28
  259. package/src/db/queries/types.ts +0 -172
  260. package/src/db/queries.ts +0 -75
  261. package/src/db/schema.ts +0 -298
@@ -1,416 +0,0 @@
1
- /**
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
- *
5
- * Two modes, gated on the `actions` field of the yaml manifest:
6
- *
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.
18
- *
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.
27
- *
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):
33
- * • Yaml manifest validated at load (validateLifecycleManifest in
34
- * resources-builder); a malformed manifest skips with the
35
- * concrete error so the operator gets actionable feedback.
36
- * • Non-2xx baseline (create or list) → broken-baseline skip.
37
- * • Action POST non-2xx on first call → action-not-supported skip
38
- * (the API may have authoritative server-side gating; not a
39
- * contract bug).
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.
48
- */
49
- import type { OpenAPIV3 } from "openapi-types";
50
- import type { CrudStatefulCheck } from "../stateful.ts";
51
- import type { LifecycleConfig, LifecycleAction } from "../../generator/resources-builder.ts";
52
- import { validateLifecycleManifest } from "../../generator/resources-builder.ts";
53
- import {
54
- extractIdFromCreateResponse,
55
- fillPathWithId,
56
- fillPathParams,
57
- serializeCheckBody,
58
- resolveCreateBody,
59
- } from "./_crud-helpers.ts";
60
-
61
- function safeParse(v: unknown): unknown {
62
- if (typeof v !== "string") return v;
63
- try { return JSON.parse(v); } catch { return v; }
64
- }
65
-
66
- function readState(body: unknown, field: string): string | null {
67
- if (!body || typeof body !== "object") return null;
68
- const v = (body as Record<string, unknown>)[field];
69
- return typeof v === "string" ? v : null;
70
- }
71
-
72
- function parseEndpointLabel(label: string): { method: string; path: string } | null {
73
- const parts = label.trim().split(/\s+/);
74
- if (parts.length < 2) return null;
75
- return { method: parts[0]!.toUpperCase(), path: parts[1]! };
76
- }
77
-
78
- function transitionAllowed(cfg: LifecycleConfig, from: string, to: string): boolean {
79
- // Same-state replay is always OK (idempotent action).
80
- if (from === to) return true;
81
- for (const t of cfg.transitions) {
82
- if (t.from === from && t.to.includes(to)) return true;
83
- }
84
- return false;
85
- }
86
-
87
- interface Finding {
88
- kind: string;
89
- message: string;
90
- extra?: Record<string, unknown>;
91
- }
92
-
93
- export const lifecycleTransitions: CrudStatefulCheck = {
94
- id: "lifecycle_transitions",
95
- severity: "high",
96
- defaultExpected: "Declared lifecycle actions must move the resource through declared states without regression",
97
- references: [{ id: "ARV-172" }],
98
- phase: "crud",
99
- applies(g) {
100
- return Boolean((g.create && g.read) || g.list);
101
- },
102
- async run(g, h) {
103
- if (h.bootstrapCleanupFailed) {
104
- return { kind: "skip", reason: "bootstrap-cleanup failed — stateful checks paused" };
105
- }
106
- const cfg = h.resourceConfigs?.get(g.resource)?.lifecycle;
107
- if (!cfg) return { kind: "skip", reason: "no lifecycle config for this resource" };
108
-
109
- const manifestErrors = validateLifecycleManifest(cfg);
110
- if (manifestErrors.length > 0) {
111
- return { kind: "skip", reason: `lifecycle manifest invalid: ${manifestErrors[0]}` };
112
- }
113
-
114
- // ARV-219: actions-empty → pure-observation mode (read-only state
115
- // machine). Requires a list endpoint to sample observed states.
116
- if (Object.keys(cfg.actions).length === 0) {
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" };
126
- }
127
-
128
- const create = g.create!;
129
- const read = g.read!;
130
- const baseHeaders = { Accept: "application/json", ...h.authHeaders };
131
- const stateSet = new Set(cfg.states);
132
-
133
- // 1. Create — prefer seed_body (ARV-187) over generator.
134
- const seedBody = h.resourceConfigs?.get(g.resource)?.seedBody;
135
- const generated = resolveCreateBody(create, seedBody) ?? {};
136
- const { body: createBody, contentType } = serializeCheckBody(
137
- create,
138
- generated,
139
- h.pathVars,
140
- seedBody?.contentType,
141
- );
142
- const createUrl = `${h.baseUrl.replace(/\/+$/, "")}${fillPathParams(create.path, h.pathVars)}`;
143
- const createResp = await h.send({
144
- method: "POST",
145
- url: createUrl,
146
- headers: { ...baseHeaders, "Content-Type": contentType },
147
- body: createBody,
148
- });
149
- if (createResp.status < 200 || createResp.status >= 300) {
150
- return { kind: "skip", reason: `create returned ${createResp.status} — broken-baseline guard` };
151
- }
152
- const createBodyParsed = createResp.body_parsed ?? safeParse(createResp.body);
153
- const id = extractIdFromCreateResponse(createBodyParsed, g.idParam);
154
- if (id == null) return { kind: "skip", reason: "could not extract id from create response" };
155
-
156
- const findings: Finding[] = [];
157
-
158
- let currentState = readState(createBodyParsed, cfg.field);
159
- if (currentState == null) {
160
- return { kind: "skip", reason: `state field "${cfg.field}" missing on create response — yaml mismatch or hidden field` };
161
- }
162
- if (!stateSet.has(currentState)) {
163
- findings.push({
164
- kind: "undeclared_state",
165
- message: `initial state "${currentState}" not in declared states [${cfg.states.join(", ")}]`,
166
- extra: { observed: currentState, declared: cfg.states },
167
- });
168
- }
169
-
170
- const readUrlFor = (resId: string | number): string =>
171
- `${h.baseUrl.replace(/\/+$/, "")}${fillPathWithId(fillPathParams(read.path, h.pathVars), g.idParam, resId)}`;
172
-
173
- // 2. For each action: invoke, read, assert.
174
- for (const [name, action] of Object.entries(cfg.actions) as Array<[string, LifecycleAction]>) {
175
- const parsed = parseEndpointLabel(action.endpoint);
176
- if (!parsed) {
177
- findings.push({
178
- kind: "action_endpoint_malformed",
179
- message: `action "${name}".endpoint "${action.endpoint}" must be "METHOD /path"`,
180
- });
181
- continue;
182
- }
183
- const actionUrl = `${h.baseUrl.replace(/\/+$/, "")}${fillPathWithId(fillPathParams(parsed.path, h.pathVars), g.idParam, id)}`;
184
- const actionBody = action.body
185
- ? serializeCheckBody(create, action.body, h.pathVars)
186
- : { body: "", contentType: "application/json" };
187
- const actionHeaders: Record<string, string> = { ...baseHeaders };
188
- if (action.body) actionHeaders["Content-Type"] = actionBody.contentType;
189
-
190
- const firstResp = await h.send({
191
- method: parsed.method,
192
- url: actionUrl,
193
- headers: actionHeaders,
194
- body: action.body ? actionBody.body : undefined,
195
- });
196
- if (firstResp.status < 200 || firstResp.status >= 300) {
197
- // Server-side gating; not a contract violation.
198
- findings.push({
199
- kind: "action_rejected",
200
- message: `action "${name}" returned ${firstResp.status} on first call — server may gate this transition`,
201
- extra: { action: name, status: firstResp.status },
202
- });
203
- continue;
204
- }
205
-
206
- // Read state after action.
207
- const readResp = await h.send({ method: "GET", url: readUrlFor(id), headers: baseHeaders });
208
- if (readResp.status < 200 || readResp.status >= 300) {
209
- findings.push({
210
- kind: "read_after_action_failed",
211
- message: `GET after action "${name}" returned ${readResp.status}`,
212
- extra: { action: name, read_status: readResp.status },
213
- });
214
- break;
215
- }
216
- const observedState = readState(readResp.body_parsed ?? safeParse(readResp.body), cfg.field);
217
- if (observedState == null) {
218
- findings.push({
219
- kind: "state_field_missing",
220
- message: `GET after action "${name}": state field "${cfg.field}" missing`,
221
- extra: { action: name },
222
- });
223
- break;
224
- }
225
- if (!stateSet.has(observedState)) {
226
- findings.push({
227
- kind: "undeclared_state",
228
- message: `action "${name}" produced state "${observedState}" not in declared states`,
229
- extra: { action: name, observed: observedState },
230
- });
231
- } else {
232
- if (!transitionAllowed(cfg, currentState, observedState)) {
233
- findings.push({
234
- kind: "forbidden_transition",
235
- message: `action "${name}": ${currentState} → ${observedState} is not allowed by declared transitions`,
236
- extra: { action: name, from: currentState, to: observedState },
237
- });
238
- }
239
- if (observedState !== action.expectedState) {
240
- findings.push({
241
- kind: "wrong_expected_state",
242
- message: `action "${name}": expected state "${action.expectedState}", observed "${observedState}"`,
243
- extra: { action: name, expected: action.expectedState, observed: observedState },
244
- });
245
- }
246
- }
247
-
248
- // 3. Idempotency probe: invoke action again, state must not regress.
249
- const secondResp = await h.send({
250
- method: parsed.method,
251
- url: actionUrl,
252
- headers: actionHeaders,
253
- body: action.body ? actionBody.body : undefined,
254
- });
255
- if (secondResp.status >= 500) {
256
- findings.push({
257
- kind: "double_action_5xx",
258
- message: `action "${name}" 5xx'd on replay (${secondResp.status}) — should be idempotent or 4xx`,
259
- extra: { action: name, status: secondResp.status },
260
- });
261
- } else if (secondResp.status >= 200 && secondResp.status < 300) {
262
- // Replay accepted — state must remain the action's expected state.
263
- const replayRead = await h.send({ method: "GET", url: readUrlFor(id), headers: baseHeaders });
264
- if (replayRead.status >= 200 && replayRead.status < 300) {
265
- const replayState = readState(replayRead.body_parsed ?? safeParse(replayRead.body), cfg.field);
266
- if (replayState != null && replayState !== observedState) {
267
- findings.push({
268
- kind: "state_regression_on_replay",
269
- message: `action "${name}" replay drifted state ${observedState} → ${replayState}`,
270
- extra: { action: name, before_replay: observedState, after_replay: replayState },
271
- });
272
- }
273
- }
274
- }
275
- // 4xx on replay is an acceptable "not-idempotent but safe" rejection.
276
-
277
- currentState = observedState;
278
- }
279
-
280
- if (findings.length === 0) return { kind: "pass" };
281
- const kinds = findings.map((f) => f.kind);
282
- const message = findings.length === 1
283
- ? findings[0]!.message
284
- : `Lifecycle on ${g.resource}: ${findings.length} issue(s) — ${kinds.join(", ")}`;
285
- return {
286
- kind: "fail",
287
- message,
288
- evidence: {
289
- resource: g.resource,
290
- id,
291
- kind: kinds.join("+"),
292
- findings: findings.map((f) => ({ kind: f.kind, message: f.message, ...(f.extra ?? {}) })),
293
- },
294
- };
295
- },
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
- }
@@ -1,40 +0,0 @@
1
- /**
2
- * `missing_required_header` — schemathesis-equivalent. Sends a request
3
- * with one declared-required header dropped; the server must reject
4
- * with a 4xx (400 / 401 / 403 / 406 / 422). Anything else (2xx, 3xx,
5
- * 5xx) is a finding.
6
- *
7
- * The runner generates the probe case (kind="missing_required_header")
8
- * only when at least one operation declares a required header — we
9
- * skip otherwise to avoid a second wave of pointless requests.
10
- */
11
- import type { Check } from "../types.ts";
12
-
13
- const ACCEPTABLE_REJECT_STATUSES = new Set([400, 401, 403, 406, 422, 412]);
14
-
15
- export const missingRequiredHeader: Check = {
16
- id: "missing_required_header",
17
- severity: "high",
18
- defaultExpected: "Server must reject the request with 4xx when a required header is missing",
19
- references: [{ id: "OWASP-API-04" }],
20
- caseKinds: ["missing_required_header"],
21
- applies: (op) =>
22
- op.parameters.some((p) => p.in === "header" && p.required === true),
23
- run({ case: c, response }) {
24
- const status = response.status;
25
- if (status >= 500) {
26
- return {
27
- kind: "fail",
28
- message: `Server 5xx'd when required header was dropped (${c.meta?.dropped_header}) — should be a 4xx rejection`,
29
- evidence: { dropped_header: c.meta?.dropped_header, status },
30
- };
31
- }
32
- if (ACCEPTABLE_REJECT_STATUSES.has(status)) return { kind: "pass" };
33
- if (status >= 400 && status < 500) return { kind: "pass" }; // any 4xx counts
34
- return {
35
- kind: "fail",
36
- message: `Server accepted request without required header "${c.meta?.dropped_header}" (status ${status})`,
37
- evidence: { dropped_header: c.meta?.dropped_header, status },
38
- };
39
- },
40
- };
@@ -1,148 +0,0 @@
1
- /**
2
- * `negative_data_rejection` (m-15 ARV-4) — schemathesis-equivalent.
3
- * The runner builds a single-site negative case (one mutation against
4
- * a valid body, see `_negative_mutator.ts`); if the server still
5
- * accepts it (status outside 4xx/5xx + 401/403/404 admin set), we
6
- * raise a finding with raw evidence. The agent triages / re-severitizes.
7
- *
8
- * Default expected: 400 / 401 / 403 / 404 / 422 / 428 / 5xx.
9
- * 2xx and 3xx with our payload are findings.
10
- *
11
- * Severity matrix (ARV-284, dispatched per finding via outcome.severity
12
- * — see `severityForEvidence` below):
13
- *
14
- * - MEDIUM: concrete schema breach silently accepted (maxLength+1,
15
- * type-mismatch, pattern-violation, format-invalid,
16
- * drop-required, uniqueItems-violation, drop-required-query).
17
- * Server should reject; evidence single-signal but breach
18
- * is concrete.
19
- * - LOW: `additionalProperties-violation` (unknown body fields
20
- * silently dropped is documented forward-compat in many
21
- * APIs — Stripe by design) and `wrong-type` query params
22
- * on GET list endpoints (often "invalid id → empty
23
- * result" documented behaviour). Single-signal, ambiguous
24
- * intent.
25
- *
26
- * Note: 5xx on a negative mutation does NOT escalate this check —
27
- * `not_a_server_error` (separate check, severity HIGH by design) owns
28
- * the 5xx signal, and `ACCEPTABLE()` below treats 5xx as a non-silent
29
- * accept so this check passes for those cases. Avoids double-counting.
30
- *
31
- * Per ARV-250's proof-cap principle (no evidence → no high severity):
32
- * single-signal proof caps at LOW; concrete schema breach escalates to
33
- * MEDIUM. The declared `severity: "low"` is the natural fallback /
34
- * proof-cap baseline; stronger findings use `outcome.severity` to
35
- * override. The agent re-severitizes from the raw evidence.
36
- */
37
- import type { Check, CheckOutcome } from "../types.ts";
38
- import type { Severity } from "../../severity/index.ts";
39
-
40
- const ACCEPTABLE = (status: number): boolean => {
41
- if (status === 400 || status === 401 || status === 403 || status === 404 || status === 422 || status === 428) return true;
42
- if (status >= 500 && status < 600) return true; // 5xx accepted: a bug, but not a *silent* accept
43
- return false;
44
- };
45
-
46
- /** Body-boundary labels we treat as LOW (often by-design vendor
47
- * behaviour, single-signal, ambiguous intent). All other boundary
48
- * labels emitted by `coverage-phase.ts:enumerateBoundaryCases` —
49
- * maxLength+1, pattern-violation, uuid-invalid, drop-required:X, etc.
50
- * — are concrete schema breaches and stay MEDIUM. */
51
- const LOW_BODY_BOUNDARIES: ReadonlySet<string> = new Set([
52
- "additionalProperties-violation",
53
- ]);
54
-
55
- /** Param-scenario labels (`coverage-phase.ts:enumerateParamBoundaryCases`)
56
- * we treat as LOW. `wrong-type` is the classic "Stripe returns empty
57
- * list for invalid id" pattern — many APIs document this; flagging
58
- * HIGH/MEDIUM produces noise. `drop-required-query` stays MEDIUM (a
59
- * declared-required param being silently optional is a concrete
60
- * contract gap, not vendor convention). */
61
- const LOW_PARAM_SCENARIOS_ON_GET: ReadonlySet<string> = new Set([
62
- "wrong-type",
63
- ]);
64
-
65
- interface MutationMeta {
66
- mutation?: string;
67
- boundary?: string;
68
- param_scenario?: string;
69
- param_location?: string;
70
- }
71
-
72
- function severityForEvidence(
73
- meta: MutationMeta | undefined,
74
- method: string,
75
- ): Severity {
76
- if (!meta) return "low";
77
-
78
- // Param-side mutations (query / path wrong-type, drop-required-query).
79
- if (meta.mutation === "param-boundary") {
80
- const scenario = meta.param_scenario ?? "";
81
- if (
82
- meta.param_location === "query"
83
- && method.toUpperCase() === "GET"
84
- && LOW_PARAM_SCENARIOS_ON_GET.has(scenario)
85
- ) {
86
- return "low";
87
- }
88
- // drop-required-query, wrong-type on non-GET, wrong-type on path
89
- // params: concrete contract violations server should reject.
90
- return "medium";
91
- }
92
-
93
- // Body-boundary mutations (additionalProperties, maxLength+1, etc).
94
- if (meta.mutation === "boundary") {
95
- const boundary = meta.boundary ?? "";
96
- if (LOW_BODY_BOUNDARIES.has(boundary)) return "low";
97
- return "medium";
98
- }
99
-
100
- // Unknown mutation kind — single-signal fallback.
101
- return "low";
102
- }
103
-
104
- export const negativeDataRejection: Check = {
105
- id: "negative_data_rejection",
106
- // ARV-284: declared severity is the *natural* tier (proof-cap baseline
107
- // per ARV-250 — single-signal caps at LOW). Per-finding severity is
108
- // dispatched via `outcome.severity` in `run()` below, so summary
109
- // tables can show HIGH for 5xx and MEDIUM for concrete schema breach
110
- // without globally setting the check to HIGH (which masks calibration).
111
- severity: "low",
112
- defaultExpected: "Server must reject invalid bodies with 400/401/403/404/422/428 (or 5xx)",
113
- references: [{ id: "OWASP-API-08" }],
114
- caseKinds: ["negative_data"],
115
- applies: (op) => Boolean(op.requestBodySchema),
116
- run({ case: c, response }): CheckOutcome {
117
- const meta = c.meta as MutationMeta | undefined;
118
- const method = c.operation.method.toUpperCase();
119
- const bodyless = method === "GET" || method === "HEAD" || method === "DELETE";
120
- // ARV-345 (scope fix, like ARV-340): don't manufacture "accepted an invalid
121
- // body" findings on methods with no request-body semantics. A GET/HEAD/DELETE
122
- // that 2xx's a mutated body simply ignored the body — correct, not a gap.
123
- if (bodyless && meta?.mutation === "boundary") {
124
- return { kind: "skip", reason: "body mutation not applicable on GET/HEAD/DELETE (no request-body semantics)" };
125
- }
126
- // ARV-345: an unknown / wrong-type QUERY param a GET tolerates (2xx) is
127
- // documented leniency ("invalid id → empty list"), not a rejectable invalid.
128
- // drop-required-query stays in scope — a declared-required param silently
129
- // optional IS a concrete contract gap.
130
- if (
131
- bodyless
132
- && meta?.mutation === "param-boundary"
133
- && meta?.param_location === "query"
134
- && LOW_PARAM_SCENARIOS_ON_GET.has(meta?.param_scenario ?? "")
135
- ) {
136
- return { kind: "skip", reason: "unknown/wrong-type query param on GET is documented leniency, not a validation gap" };
137
- }
138
- if (ACCEPTABLE(response.status)) return { kind: "pass" };
139
- return {
140
- kind: "fail",
141
- message: `Server accepted an invalid body (status ${response.status}) — single-site mutation: ${
142
- meta?.mutation ?? "unknown"
143
- } @ ${meta?.boundary ?? meta?.param_scenario ?? "?"}`,
144
- evidence: { status: response.status, mutation: meta },
145
- severity: severityForEvidence(meta, c.operation.method),
146
- };
147
- },
148
- };
@@ -1,35 +0,0 @@
1
- /**
2
- * `not_a_server_error` — schemathesis-equivalent baseline check: a
3
- * well-formed request must never produce a 5xx response. Acts as the
4
- * seed check for the ARV-1 scaffolding; the rest of the conformance
5
- * suite lands in ARV-2.
6
- */
7
- import type { Check } from "../types.ts";
8
-
9
- export const notAServerError: Check = {
10
- id: "not_a_server_error",
11
- severity: "high",
12
- defaultExpected: "Server must not respond with 5xx for any well-formed request",
13
- references: [
14
- { id: "RFC-9110-15.6", url: "https://www.rfc-editor.org/rfc/rfc9110#name-server-error-5xx" },
15
- ],
16
- // ARV-340: evaluate malformed-input cases too, not just the positive
17
- // baseline. A negative_data / missing-header request is still a
18
- // well-formed HTTP request the server must handle with a 4xx — a 5xx
19
- // there is an unhandled crash (live Stripe: GET /v1/billing/alerts 500
20
- // on a bad query param slipped through when this defaulted to positive
21
- // only). `unsupported_method` is excluded on purpose: 501/405 to an
22
- // undeclared method is legitimate, not a server error.
23
- caseKinds: ["positive", "negative_data", "missing_required_header"],
24
- applies: () => true,
25
- run({ response }) {
26
- if (response.status >= 500 && response.status < 600) {
27
- return {
28
- kind: "fail",
29
- message: `Server responded with ${response.status} (5xx) — request triggered an unhandled error`,
30
- evidence: { status: response.status },
31
- };
32
- }
33
- return { kind: "pass" };
34
- },
35
- };