@kirrosh/zond 0.22.0 → 0.23.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 (256) hide show
  1. package/CHANGELOG.md +648 -0
  2. package/README.md +58 -6
  3. package/package.json +9 -6
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +262 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +178 -7
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -46
  31. package/src/cli/commands/init/bootstrap.ts +30 -1
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +56 -3
  34. package/src/cli/commands/init/templates/agents.md +65 -61
  35. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  36. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  37. package/src/cli/commands/init/templates/skills/zond.md +592 -125
  38. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  39. package/src/cli/commands/prepare-fixtures.ts +135 -0
  40. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  41. package/src/cli/commands/probe/security.ts +454 -0
  42. package/src/cli/commands/probe/static.ts +255 -0
  43. package/src/cli/commands/probe/webhooks.ts +161 -0
  44. package/src/cli/commands/probe.ts +459 -0
  45. package/src/cli/commands/reference.ts +87 -0
  46. package/src/cli/commands/refresh-api.ts +169 -0
  47. package/src/cli/commands/remove-api.ts +150 -0
  48. package/src/cli/commands/report-bundle.ts +318 -0
  49. package/src/cli/commands/report.ts +241 -0
  50. package/src/cli/commands/request.ts +379 -4
  51. package/src/cli/commands/run.ts +842 -53
  52. package/src/cli/commands/session.ts +244 -0
  53. package/src/cli/commands/use.ts +18 -1
  54. package/src/cli/index.ts +20 -3
  55. package/src/cli/json-envelope.ts +112 -3
  56. package/src/cli/json-schemas.ts +263 -0
  57. package/src/cli/program.ts +198 -635
  58. package/src/cli/resolve.ts +105 -0
  59. package/src/cli/status-filter.ts +124 -0
  60. package/src/cli/util/api-context.ts +85 -0
  61. package/src/cli/version.ts +5 -0
  62. package/src/core/anti-fp/bootstrap.ts +34 -0
  63. package/src/core/anti-fp/index.ts +33 -0
  64. package/src/core/anti-fp/registry.ts +44 -0
  65. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  66. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  67. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  68. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  69. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  70. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  71. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  72. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  73. package/src/core/anti-fp/types.ts +68 -0
  74. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  75. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  76. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  77. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  78. package/src/core/checks/checks/cross_call_references.ts +134 -0
  79. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  80. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  81. package/src/core/checks/checks/ignored_auth.ts +211 -0
  82. package/src/core/checks/checks/index.ts +65 -0
  83. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  84. package/src/core/checks/checks/missing_required_header.ts +40 -0
  85. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  86. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  87. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  88. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  89. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  90. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  91. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  92. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  93. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  94. package/src/core/checks/checks/unsupported_method.ts +63 -0
  95. package/src/core/checks/checks/use_after_free.ts +78 -0
  96. package/src/core/checks/index.ts +30 -0
  97. package/src/core/checks/mode.ts +79 -0
  98. package/src/core/checks/recommended-action.ts +64 -0
  99. package/src/core/checks/registry.ts +78 -0
  100. package/src/core/checks/runner.ts +874 -0
  101. package/src/core/checks/sarif.ts +230 -0
  102. package/src/core/checks/stateful.ts +121 -0
  103. package/src/core/checks/types.ts +189 -0
  104. package/src/core/classifier/recommended-action.ts +222 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +185 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +151 -11
  110. package/src/core/diagnostics/failure-class.ts +120 -0
  111. package/src/core/diagnostics/failure-hints.ts +212 -9
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +42 -16
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/create-body.ts +89 -0
  124. package/src/core/generator/data-factory.ts +445 -19
  125. package/src/core/generator/describe.ts +1 -1
  126. package/src/core/generator/fixtures-builder.ts +325 -0
  127. package/src/core/generator/index.ts +7 -5
  128. package/src/core/generator/openapi-reader.ts +37 -3
  129. package/src/core/generator/path-param-disambig.ts +114 -0
  130. package/src/core/generator/resources-builder.ts +648 -0
  131. package/src/core/generator/schema-utils.ts +11 -3
  132. package/src/core/generator/serializer.ts +103 -13
  133. package/src/core/generator/suite-generator.ts +419 -111
  134. package/src/core/generator/types.ts +8 -0
  135. package/src/core/identity/identity-file.ts +129 -0
  136. package/src/core/lint/affects.ts +28 -0
  137. package/src/core/lint/config.ts +96 -0
  138. package/src/core/lint/format.ts +42 -0
  139. package/src/core/lint/index.ts +94 -0
  140. package/src/core/lint/reporter.ts +128 -0
  141. package/src/core/lint/rules/consistency.ts +158 -0
  142. package/src/core/lint/rules/heuristics.ts +97 -0
  143. package/src/core/lint/rules/strictness.ts +109 -0
  144. package/src/core/lint/types.ts +96 -0
  145. package/src/core/lint/walker.ts +248 -0
  146. package/src/core/meta/meta-store.ts +6 -73
  147. package/src/core/output/README.md +91 -0
  148. package/src/core/output/index.ts +13 -0
  149. package/src/core/output/run.ts +126 -0
  150. package/src/core/output/types.ts +129 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +57 -0
  159. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  160. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  161. package/src/core/probe/mass-assignment-template.ts +212 -0
  162. package/src/core/probe/method-probe.ts +43 -76
  163. package/src/core/probe/method-shared.ts +69 -0
  164. package/src/core/probe/negative-probe.ts +183 -149
  165. package/src/core/probe/orphan-tracker.ts +188 -0
  166. package/src/core/probe/path-discovery.ts +440 -0
  167. package/src/core/probe/probe-harness.ts +120 -0
  168. package/src/core/probe/registry.ts +89 -0
  169. package/src/core/probe/runner.ts +136 -0
  170. package/src/core/probe/security-probe-class.ts +201 -0
  171. package/src/core/probe/security-probe.ts +1453 -0
  172. package/src/core/probe/shared.ts +505 -0
  173. package/src/core/probe/static-probe-class.ts +125 -0
  174. package/src/core/probe/types.ts +165 -0
  175. package/src/core/probe/verdict-aggregator.ts +33 -0
  176. package/src/core/probe/webhooks-probe.ts +284 -0
  177. package/src/core/reporter/console.ts +41 -2
  178. package/src/core/reporter/index.ts +2 -3
  179. package/src/core/reporter/json.ts +11 -1
  180. package/src/core/reporter/junit.ts +27 -12
  181. package/src/core/reporter/ndjson.ts +37 -0
  182. package/src/core/reporter/types.ts +3 -0
  183. package/src/core/runner/assertions.ts +58 -1
  184. package/src/core/runner/async-pool.ts +108 -0
  185. package/src/core/runner/auth-path.ts +8 -0
  186. package/src/core/runner/ci-context.ts +72 -0
  187. package/src/core/runner/executor.ts +264 -20
  188. package/src/core/runner/form-encode.ts +51 -0
  189. package/src/core/runner/http-client.ts +75 -2
  190. package/src/core/runner/learn-drift.ts +293 -0
  191. package/src/core/runner/preflight-vars.ts +149 -0
  192. package/src/core/runner/progress-tracker.ts +73 -0
  193. package/src/core/runner/rate-limiter.ts +89 -17
  194. package/src/core/runner/run-kind.ts +39 -0
  195. package/src/core/runner/schema-validator.ts +312 -0
  196. package/src/core/runner/send-request.ts +153 -20
  197. package/src/core/runner/types.ts +38 -0
  198. package/src/core/secrets/registry.ts +164 -0
  199. package/src/core/secrets/secrets-file.ts +115 -0
  200. package/src/core/selectors/operation-filter.ts +144 -0
  201. package/src/core/setup-api.ts +415 -16
  202. package/src/core/severity/category.ts +94 -0
  203. package/src/core/severity/index.ts +121 -0
  204. package/src/core/spec/layers.ts +154 -0
  205. package/src/core/util/format-eta.ts +21 -0
  206. package/src/core/utils.ts +5 -1
  207. package/src/core/workspace/config.ts +129 -0
  208. package/src/core/workspace/manifest.ts +283 -0
  209. package/src/core/workspace/output-rotation.ts +62 -0
  210. package/src/core/workspace/triage-path.ts +87 -0
  211. package/src/db/lint-runs.ts +47 -0
  212. package/src/db/migrate.ts +126 -0
  213. package/src/db/migrations/0001_run_kind.sql +25 -0
  214. package/src/db/migrations/sql.d.ts +4 -0
  215. package/src/db/queries/collections.ts +133 -0
  216. package/src/db/queries/coverage.ts +9 -0
  217. package/src/db/queries/dashboard.ts +59 -0
  218. package/src/db/queries/results.ts +128 -0
  219. package/src/db/queries/runs.ts +235 -0
  220. package/src/db/queries/sessions.ts +42 -0
  221. package/src/db/queries/settings.ts +28 -0
  222. package/src/db/queries/types.ts +172 -0
  223. package/src/db/queries.ts +72 -802
  224. package/src/db/schema.ts +178 -50
  225. package/src/cli/commands/export.ts +0 -144
  226. package/src/cli/commands/guide.ts +0 -127
  227. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  228. package/src/cli/commands/probe-methods.ts +0 -108
  229. package/src/cli/commands/probe-validation.ts +0 -124
  230. package/src/cli/commands/serve.ts +0 -114
  231. package/src/cli/commands/sync.ts +0 -268
  232. package/src/cli/commands/update.ts +0 -189
  233. package/src/cli/commands/validate.ts +0 -34
  234. package/src/core/diagnostics/render-md.ts +0 -112
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -19
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Auto-registers built-in checks at first import. ARV-1 ships with one
3
+ * seed check (`not_a_server_error`); ARV-2/3/4 fill in the remaining
4
+ * 11 conformance/security checks alongside it.
5
+ *
6
+ * Side-effect import is intentional: anywhere the runner or CLI loads
7
+ * `core/checks` it triggers this module and the registry is populated
8
+ * exactly once (Node/Bun ESM module cache guarantees idempotency).
9
+ */
10
+ import { registerCheck } from "../registry.ts";
11
+ import { registerStatefulCheck } from "../stateful.ts";
12
+ import { notAServerError } from "./not_a_server_error.ts";
13
+ import { statusCodeConformance } from "./status_code_conformance.ts";
14
+ import { contentTypeConformance } from "./content_type_conformance.ts";
15
+ import { responseHeadersConformance } from "./response_headers_conformance.ts";
16
+ import { responseSchemaConformance } from "./response_schema_conformance.ts";
17
+ import { missingRequiredHeader } from "./missing_required_header.ts";
18
+ import { unsupportedMethod } from "./unsupported_method.ts";
19
+ import { ignoredAuth } from "./ignored_auth.ts";
20
+ import { useAfterFree } from "./use_after_free.ts";
21
+ import { ensureResourceAvailability } from "./ensure_resource_availability.ts";
22
+ import { negativeDataRejection } from "./negative_data_rejection.ts";
23
+ import { positiveDataAcceptance } from "./positive_data_acceptance.ts";
24
+ import { crossCallReferences } from "./cross_call_references.ts";
25
+ import { idempotencyReplay } from "./idempotency_replay.ts";
26
+ import { paginationInvariants } from "./pagination_invariants.ts";
27
+ import { lifecycleTransitions } from "./lifecycle_transitions.ts";
28
+ import { openCorsOnSensitive } from "./open_cors_on_sensitive.ts";
29
+ import { rateLimitHeadersAbsent } from "./rate_limit_headers_absent.ts";
30
+
31
+ let registered = false;
32
+
33
+ export function registerBuiltinChecks(): void {
34
+ if (registered) return;
35
+ // ARV-1 seed.
36
+ registerCheck(notAServerError);
37
+ // ARV-2 — 6 conformance checks (7 total with the seed).
38
+ registerCheck(statusCodeConformance);
39
+ registerCheck(contentTypeConformance);
40
+ registerCheck(responseHeadersConformance);
41
+ registerCheck(responseSchemaConformance);
42
+ registerCheck(missingRequiredHeader);
43
+ registerCheck(unsupportedMethod);
44
+ // ARV-3 — 3 stateful security checks.
45
+ registerStatefulCheck(ignoredAuth);
46
+ registerStatefulCheck(useAfterFree);
47
+ registerStatefulCheck(ensureResourceAvailability);
48
+ // ARV-4 — 2 data-rejection checks with anti-FP guards.
49
+ registerCheck(negativeDataRejection);
50
+ registerCheck(positiveDataAcceptance);
51
+ // ARV-169 (m-20) — cross-call POST→GET shape-diff probe.
52
+ registerStatefulCheck(crossCallReferences);
53
+ // ARV-170 (m-20) — Idempotency-Key replay probe.
54
+ registerStatefulCheck(idempotencyReplay);
55
+ // ARV-171 (m-20) — cursor pagination invariants.
56
+ registerStatefulCheck(paginationInvariants);
57
+ // ARV-172 (m-20) — declared state-machine + action transitions.
58
+ registerStatefulCheck(lifecycleTransitions);
59
+ // ARV-256 (m-21) — small-team value-add checks.
60
+ registerStatefulCheck(openCorsOnSensitive);
61
+ registerCheck(rateLimitHeadersAbsent);
62
+ registered = true;
63
+ }
64
+
65
+ registerBuiltinChecks();
@@ -0,0 +1,273 @@
1
+ /**
2
+ * `lifecycle_transitions` (m-20 ARV-172) — verify a resource's declared
3
+ * state machine on the live API.
4
+ *
5
+ * For each CRUD group whose resource has a `lifecycle:` yaml block:
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`).
20
+ *
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.
24
+ *
25
+ * Anti-FP guards:
26
+ * • Yaml manifest validated at load (validateLifecycleManifest in
27
+ * resources-builder); a malformed manifest skips with the
28
+ * concrete error so the operator gets actionable feedback.
29
+ * • POST create non-2xx → broken-baseline skip.
30
+ * • Action POST non-2xx on first call → action-not-supported skip
31
+ * (the API may have authoritative server-side gating; not a
32
+ * 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.
36
+ */
37
+ import type { OpenAPIV3 } from "openapi-types";
38
+ import type { CrudStatefulCheck } from "../stateful.ts";
39
+ import type { LifecycleConfig, LifecycleAction } from "../../generator/resources-builder.ts";
40
+ import { validateLifecycleManifest } from "../../generator/resources-builder.ts";
41
+ import {
42
+ extractIdFromCreateResponse,
43
+ fillPathWithId,
44
+ fillPathParams,
45
+ serializeCheckBody,
46
+ resolveCreateBody,
47
+ } from "./_crud-helpers.ts";
48
+
49
+ function safeParse(v: unknown): unknown {
50
+ if (typeof v !== "string") return v;
51
+ try { return JSON.parse(v); } catch { return v; }
52
+ }
53
+
54
+ function readState(body: unknown, field: string): string | null {
55
+ if (!body || typeof body !== "object") return null;
56
+ const v = (body as Record<string, unknown>)[field];
57
+ return typeof v === "string" ? v : null;
58
+ }
59
+
60
+ function parseEndpointLabel(label: string): { method: string; path: string } | null {
61
+ const parts = label.trim().split(/\s+/);
62
+ if (parts.length < 2) return null;
63
+ return { method: parts[0]!.toUpperCase(), path: parts[1]! };
64
+ }
65
+
66
+ function transitionAllowed(cfg: LifecycleConfig, from: string, to: string): boolean {
67
+ // Same-state replay is always OK (idempotent action).
68
+ if (from === to) return true;
69
+ for (const t of cfg.transitions) {
70
+ if (t.from === from && t.to.includes(to)) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ interface Finding {
76
+ kind: string;
77
+ message: string;
78
+ extra?: Record<string, unknown>;
79
+ }
80
+
81
+ export const lifecycleTransitions: CrudStatefulCheck = {
82
+ id: "lifecycle_transitions",
83
+ severity: "high",
84
+ defaultExpected: "Declared lifecycle actions must move the resource through declared states without regression",
85
+ references: [{ id: "ARV-172" }],
86
+ phase: "crud",
87
+ applies(g) {
88
+ return Boolean(g.create && g.read);
89
+ },
90
+ async run(g, h) {
91
+ if (h.bootstrapCleanupFailed) {
92
+ return { kind: "skip", reason: "bootstrap-cleanup failed — stateful checks paused" };
93
+ }
94
+ const cfg = h.resourceConfigs?.get(g.resource)?.lifecycle;
95
+ if (!cfg) return { kind: "skip", reason: "no lifecycle config for this resource" };
96
+
97
+ const manifestErrors = validateLifecycleManifest(cfg);
98
+ if (manifestErrors.length > 0) {
99
+ return { kind: "skip", reason: `lifecycle manifest invalid: ${manifestErrors[0]}` };
100
+ }
101
+ if (Object.keys(cfg.actions).length === 0) {
102
+ return { kind: "skip", reason: "lifecycle has no actions to verify" };
103
+ }
104
+
105
+ const create = g.create!;
106
+ const read = g.read!;
107
+ const baseHeaders = { Accept: "application/json", ...h.authHeaders };
108
+ const stateSet = new Set(cfg.states);
109
+
110
+ // 1. Create — prefer seed_body (ARV-187) over generator.
111
+ const seedBody = h.resourceConfigs?.get(g.resource)?.seedBody;
112
+ const generated = resolveCreateBody(create, seedBody) ?? {};
113
+ const { body: createBody, contentType } = serializeCheckBody(
114
+ create,
115
+ generated,
116
+ h.pathVars,
117
+ seedBody?.contentType,
118
+ );
119
+ const createUrl = `${h.baseUrl.replace(/\/+$/, "")}${fillPathParams(create.path, h.pathVars)}`;
120
+ const createResp = await h.send({
121
+ method: "POST",
122
+ url: createUrl,
123
+ headers: { ...baseHeaders, "Content-Type": contentType },
124
+ body: createBody,
125
+ });
126
+ if (createResp.status < 200 || createResp.status >= 300) {
127
+ return { kind: "skip", reason: `create returned ${createResp.status} — broken-baseline guard` };
128
+ }
129
+ const createBodyParsed = createResp.body_parsed ?? safeParse(createResp.body);
130
+ const id = extractIdFromCreateResponse(createBodyParsed, g.idParam);
131
+ if (id == null) return { kind: "skip", reason: "could not extract id from create response" };
132
+
133
+ const findings: Finding[] = [];
134
+
135
+ let currentState = readState(createBodyParsed, cfg.field);
136
+ if (currentState == null) {
137
+ return { kind: "skip", reason: `state field "${cfg.field}" missing on create response — yaml mismatch or hidden field` };
138
+ }
139
+ if (!stateSet.has(currentState)) {
140
+ findings.push({
141
+ kind: "undeclared_state",
142
+ message: `initial state "${currentState}" not in declared states [${cfg.states.join(", ")}]`,
143
+ extra: { observed: currentState, declared: cfg.states },
144
+ });
145
+ }
146
+
147
+ const readUrlFor = (resId: string | number): string =>
148
+ `${h.baseUrl.replace(/\/+$/, "")}${fillPathWithId(fillPathParams(read.path, h.pathVars), g.idParam, resId)}`;
149
+
150
+ // 2. For each action: invoke, read, assert.
151
+ for (const [name, action] of Object.entries(cfg.actions) as Array<[string, LifecycleAction]>) {
152
+ const parsed = parseEndpointLabel(action.endpoint);
153
+ if (!parsed) {
154
+ findings.push({
155
+ kind: "action_endpoint_malformed",
156
+ message: `action "${name}".endpoint "${action.endpoint}" must be "METHOD /path"`,
157
+ });
158
+ continue;
159
+ }
160
+ const actionUrl = `${h.baseUrl.replace(/\/+$/, "")}${fillPathWithId(fillPathParams(parsed.path, h.pathVars), g.idParam, id)}`;
161
+ const actionBody = action.body
162
+ ? serializeCheckBody(create, action.body, h.pathVars)
163
+ : { body: "", contentType: "application/json" };
164
+ const actionHeaders: Record<string, string> = { ...baseHeaders };
165
+ if (action.body) actionHeaders["Content-Type"] = actionBody.contentType;
166
+
167
+ const firstResp = await h.send({
168
+ method: parsed.method,
169
+ url: actionUrl,
170
+ headers: actionHeaders,
171
+ body: action.body ? actionBody.body : undefined,
172
+ });
173
+ if (firstResp.status < 200 || firstResp.status >= 300) {
174
+ // Server-side gating; not a contract violation.
175
+ findings.push({
176
+ kind: "action_rejected",
177
+ message: `action "${name}" returned ${firstResp.status} on first call — server may gate this transition`,
178
+ extra: { action: name, status: firstResp.status },
179
+ });
180
+ continue;
181
+ }
182
+
183
+ // Read state after action.
184
+ const readResp = await h.send({ method: "GET", url: readUrlFor(id), headers: baseHeaders });
185
+ if (readResp.status < 200 || readResp.status >= 300) {
186
+ findings.push({
187
+ kind: "read_after_action_failed",
188
+ message: `GET after action "${name}" returned ${readResp.status}`,
189
+ extra: { action: name, read_status: readResp.status },
190
+ });
191
+ break;
192
+ }
193
+ const observedState = readState(readResp.body_parsed ?? safeParse(readResp.body), cfg.field);
194
+ if (observedState == null) {
195
+ findings.push({
196
+ kind: "state_field_missing",
197
+ message: `GET after action "${name}": state field "${cfg.field}" missing`,
198
+ extra: { action: name },
199
+ });
200
+ break;
201
+ }
202
+ if (!stateSet.has(observedState)) {
203
+ findings.push({
204
+ kind: "undeclared_state",
205
+ message: `action "${name}" produced state "${observedState}" not in declared states`,
206
+ extra: { action: name, observed: observedState },
207
+ });
208
+ } else {
209
+ if (!transitionAllowed(cfg, currentState, observedState)) {
210
+ findings.push({
211
+ kind: "forbidden_transition",
212
+ message: `action "${name}": ${currentState} → ${observedState} is not allowed by declared transitions`,
213
+ extra: { action: name, from: currentState, to: observedState },
214
+ });
215
+ }
216
+ if (observedState !== action.expectedState) {
217
+ findings.push({
218
+ kind: "wrong_expected_state",
219
+ message: `action "${name}": expected state "${action.expectedState}", observed "${observedState}"`,
220
+ extra: { action: name, expected: action.expectedState, observed: observedState },
221
+ });
222
+ }
223
+ }
224
+
225
+ // 3. Idempotency probe: invoke action again, state must not regress.
226
+ const secondResp = await h.send({
227
+ method: parsed.method,
228
+ url: actionUrl,
229
+ headers: actionHeaders,
230
+ body: action.body ? actionBody.body : undefined,
231
+ });
232
+ if (secondResp.status >= 500) {
233
+ findings.push({
234
+ kind: "double_action_5xx",
235
+ message: `action "${name}" 5xx'd on replay (${secondResp.status}) — should be idempotent or 4xx`,
236
+ extra: { action: name, status: secondResp.status },
237
+ });
238
+ } else if (secondResp.status >= 200 && secondResp.status < 300) {
239
+ // Replay accepted — state must remain the action's expected state.
240
+ const replayRead = await h.send({ method: "GET", url: readUrlFor(id), headers: baseHeaders });
241
+ if (replayRead.status >= 200 && replayRead.status < 300) {
242
+ const replayState = readState(replayRead.body_parsed ?? safeParse(replayRead.body), cfg.field);
243
+ if (replayState != null && replayState !== observedState) {
244
+ findings.push({
245
+ kind: "state_regression_on_replay",
246
+ message: `action "${name}" replay drifted state ${observedState} → ${replayState}`,
247
+ extra: { action: name, before_replay: observedState, after_replay: replayState },
248
+ });
249
+ }
250
+ }
251
+ }
252
+ // 4xx on replay is an acceptable "not-idempotent but safe" rejection.
253
+
254
+ currentState = observedState;
255
+ }
256
+
257
+ if (findings.length === 0) return { kind: "pass" };
258
+ const kinds = findings.map((f) => f.kind);
259
+ const message = findings.length === 1
260
+ ? findings[0]!.message
261
+ : `Lifecycle on ${g.resource}: ${findings.length} issue(s) — ${kinds.join(", ")}`;
262
+ return {
263
+ kind: "fail",
264
+ message,
265
+ evidence: {
266
+ resource: g.resource,
267
+ id,
268
+ kind: kinds.join("+"),
269
+ findings: findings.map((f) => ({ kind: f.kind, message: f.message, ...(f.extra ?? {}) })),
270
+ },
271
+ };
272
+ },
273
+ };
@@ -0,0 +1,40 @@
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
+ };
@@ -0,0 +1,45 @@
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 — *unless* an anti-FP guard fires (see `_anti_fp.ts`).
7
+ *
8
+ * Default expected: 400 / 401 / 403 / 404 / 422 / 428 / 5xx.
9
+ * 2xx and 3xx with our payload are findings.
10
+ */
11
+ import type { Check } from "../types.ts";
12
+ import { applyAntiFp } from "../../anti-fp/index.ts";
13
+
14
+ const ACCEPTABLE = (status: number): boolean => {
15
+ if (status === 400 || status === 401 || status === 403 || status === 404 || status === 422 || status === 428) return true;
16
+ if (status >= 500 && status < 600) return true; // 5xx accepted: a bug, but not a *silent* accept
17
+ return false;
18
+ };
19
+
20
+ export const negativeDataRejection: Check = {
21
+ id: "negative_data_rejection",
22
+ severity: "high",
23
+ defaultExpected: "Server must reject invalid bodies with 400/401/403/404/422/428 (or 5xx)",
24
+ references: [{ id: "OWASP-API-08" }],
25
+ caseKinds: ["negative_data"],
26
+ applies: (op) => Boolean(op.requestBodySchema),
27
+ run({ case: c, response }) {
28
+ if (ACCEPTABLE(response.status)) return { kind: "pass" };
29
+ const skip = applyAntiFp(c, "check:negative_data_rejection");
30
+ if (skip) {
31
+ return { kind: "skip", reason: `${skip.ruleId}: ${skip.reason}` };
32
+ }
33
+ return {
34
+ kind: "fail",
35
+ message: `Server accepted an invalid body (status ${response.status}) — single-site mutation: ${
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ (c.meta as any)?.mutation
38
+ } @ ${
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ (c.meta as any)?.field_path
41
+ }`,
42
+ evidence: { status: response.status, mutation: c.meta },
43
+ };
44
+ },
45
+ };
@@ -0,0 +1,27 @@
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
+ applies: () => true,
17
+ run({ response }) {
18
+ if (response.status >= 500 && response.status < 600) {
19
+ return {
20
+ kind: "fail",
21
+ message: `Server responded with ${response.status} (5xx) — request triggered an unhandled error`,
22
+ evidence: { status: response.status },
23
+ };
24
+ }
25
+ return { kind: "pass" };
26
+ },
27
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `open_cors_on_sensitive` (ARV-256, m-21 pivot) — verify that an
3
+ * authenticated endpoint does not echo arbitrary Origin values with
4
+ * Access-Control-Allow-Credentials: true.
5
+ *
6
+ * The dangerous combo, in two shapes:
7
+ * - `Access-Control-Allow-Origin: *` + `Access-Control-Allow-Credentials: true`
8
+ * (illegal per spec but seen in the wild; some servers patch out the
9
+ * star and emit just the credentials header — still broken)
10
+ * - `Access-Control-Allow-Origin: <reflected attacker Origin>` +
11
+ * `Access-Control-Allow-Credentials: true`
12
+ *
13
+ * Both let any cross-origin site read authenticated responses on behalf
14
+ * of a logged-in user — classic CSRF-with-data-exfil surface.
15
+ *
16
+ * Sends one request with `Origin: https://evil.zond.test`, then
17
+ * inspects the response CORS headers. Evidence_chain proof: request
18
+ * Origin + response headers travel together in the finding.
19
+ *
20
+ * Anti-FP: skips endpoints with `security: []` override (intentionally
21
+ * public). Skips when server doesn't emit any CORS headers (API isn't
22
+ * configured for cross-origin — no problem to find).
23
+ */
24
+ import type { AuthStatefulCheck } from "../stateful.ts";
25
+ import type { OpenAPIV3 } from "openapi-types";
26
+ import type { HttpRequest } from "../../runner/types.ts";
27
+
28
+ const PROBE_ORIGIN = "https://evil.zond.test";
29
+
30
+ function fillPath(
31
+ path: string,
32
+ op: { parameters: OpenAPIV3.ParameterObject[] },
33
+ pathVars: Record<string, string> | undefined,
34
+ ): string {
35
+ return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
36
+ const real = pathVars?.[name];
37
+ if (typeof real === "string" && real.length > 0) return encodeURIComponent(real);
38
+ const param = op.parameters.find((p) => p.name === name && p.in === "path");
39
+ const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
40
+ if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
41
+ if (schema?.type === "integer" || schema?.type === "number") return "1";
42
+ return "x";
43
+ });
44
+ }
45
+
46
+ function getHeader(headers: Record<string, string>, name: string): string | undefined {
47
+ return headers[name] ?? headers[name.toLowerCase()];
48
+ }
49
+
50
+ export const openCorsOnSensitive: AuthStatefulCheck = {
51
+ id: "open_cors_on_sensitive",
52
+ severity: "high",
53
+ defaultExpected:
54
+ "Authenticated endpoints must not echo arbitrary Origin with Allow-Credentials: true (cross-origin read of authed data)",
55
+ references: [{ id: "OWASP-API-09-CORS" }],
56
+ phase: "auth",
57
+ applies(op) {
58
+ // Same anti-FP as ignored_auth: skip explicit-public endpoints.
59
+ if (op.security.length === 0) return false;
60
+ return true;
61
+ },
62
+ async run(op, h) {
63
+ const url = `${h.baseUrl.replace(/\/+$/, "")}${fillPath(op.path, op, h.pathVars)}`;
64
+ const method = op.method.toUpperCase();
65
+ // GET-only probe — bursting on POST/PATCH would create resources.
66
+ // For mutating-only endpoints we still send the actual method with
67
+ // the OPTIONS preflight-style Origin header, since most CORS
68
+ // misconfigurations apply to non-preflight responses.
69
+ const safeMethod = method === "GET" || method === "HEAD" || method === "OPTIONS" ? method : "OPTIONS";
70
+ const req: HttpRequest = {
71
+ method: safeMethod,
72
+ url,
73
+ headers: {
74
+ Accept: "application/json",
75
+ ...h.authHeaders,
76
+ Origin: PROBE_ORIGIN,
77
+ // For non-GET probes, advertise the real method as the preflight
78
+ // would.
79
+ ...(safeMethod === "OPTIONS"
80
+ ? {
81
+ "Access-Control-Request-Method": method,
82
+ "Access-Control-Request-Headers": "Authorization",
83
+ }
84
+ : {}),
85
+ },
86
+ };
87
+ const resp = await h.send(req);
88
+
89
+ const allowOrigin = getHeader(resp.headers, "access-control-allow-origin");
90
+ const allowCreds = getHeader(resp.headers, "access-control-allow-credentials");
91
+
92
+ // No CORS headers emitted → endpoint isn't configured for cross-origin.
93
+ // Nothing to flag.
94
+ if (!allowOrigin && !allowCreds) {
95
+ return { kind: "skip", reason: "no CORS headers in response — API not configured for cross-origin" };
96
+ }
97
+
98
+ const credsTrue = (allowCreds ?? "").toLowerCase() === "true";
99
+ const originIsStar = allowOrigin === "*";
100
+ const originReflects = allowOrigin === PROBE_ORIGIN;
101
+
102
+ // The smoking guns.
103
+ if (credsTrue && originIsStar) {
104
+ return {
105
+ kind: "fail",
106
+ message:
107
+ "CORS misconfiguration: Allow-Origin: * with Allow-Credentials: true allows cross-origin reads of authenticated data",
108
+ evidence: {
109
+ request_origin: PROBE_ORIGIN,
110
+ access_control_allow_origin: allowOrigin,
111
+ access_control_allow_credentials: allowCreds,
112
+ variant: "wildcard+credentials",
113
+ },
114
+ };
115
+ }
116
+ if (credsTrue && originReflects) {
117
+ return {
118
+ kind: "fail",
119
+ message:
120
+ "CORS misconfiguration: response reflects arbitrary Origin with Allow-Credentials: true — any attacker site can read authed responses",
121
+ evidence: {
122
+ request_origin: PROBE_ORIGIN,
123
+ access_control_allow_origin: allowOrigin,
124
+ access_control_allow_credentials: allowCreds,
125
+ variant: "reflected+credentials",
126
+ },
127
+ };
128
+ }
129
+ return { kind: "pass" };
130
+ },
131
+ };