@kirrosh/zond 0.21.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 +758 -3
  2. package/README.md +78 -15
  3. package/package.json +17 -10
  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 +55 -6
  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 +192 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +180 -8
  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 -47
  31. package/src/cli/commands/init/agents-md.ts +61 -0
  32. package/src/cli/commands/init/bootstrap.ts +108 -0
  33. package/src/cli/commands/init/index.ts +244 -0
  34. package/src/cli/commands/init/skills.ts +98 -0
  35. package/src/cli/commands/init/templates/agents.md +77 -0
  36. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  37. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +651 -0
  40. package/src/cli/commands/init/templates/zond-config.yml +14 -0
  41. package/src/cli/commands/prepare-fixtures.ts +135 -0
  42. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  43. package/src/cli/commands/probe/security.ts +454 -0
  44. package/src/cli/commands/probe/static.ts +255 -0
  45. package/src/cli/commands/probe/webhooks.ts +161 -0
  46. package/src/cli/commands/probe.ts +459 -0
  47. package/src/cli/commands/reference.ts +87 -0
  48. package/src/cli/commands/refresh-api.ts +169 -0
  49. package/src/cli/commands/remove-api.ts +150 -0
  50. package/src/cli/commands/report-bundle.ts +318 -0
  51. package/src/cli/commands/report.ts +241 -0
  52. package/src/cli/commands/request.ts +379 -4
  53. package/src/cli/commands/run.ts +911 -33
  54. package/src/cli/commands/session.ts +244 -0
  55. package/src/cli/commands/use.ts +74 -0
  56. package/src/cli/index.ts +36 -607
  57. package/src/cli/json-envelope.ts +112 -3
  58. package/src/cli/json-schemas.ts +263 -0
  59. package/src/cli/program.ts +218 -0
  60. package/src/cli/resolve.ts +105 -0
  61. package/src/cli/status-filter.ts +124 -0
  62. package/src/cli/util/api-context.ts +85 -0
  63. package/src/cli/version.ts +8 -0
  64. package/src/core/anti-fp/bootstrap.ts +34 -0
  65. package/src/core/anti-fp/index.ts +33 -0
  66. package/src/core/anti-fp/registry.ts +44 -0
  67. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  68. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  69. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  70. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  71. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  72. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  73. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  74. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  75. package/src/core/anti-fp/types.ts +68 -0
  76. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  77. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  78. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  79. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  80. package/src/core/checks/checks/cross_call_references.ts +134 -0
  81. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  82. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  83. package/src/core/checks/checks/ignored_auth.ts +211 -0
  84. package/src/core/checks/checks/index.ts +65 -0
  85. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  86. package/src/core/checks/checks/missing_required_header.ts +40 -0
  87. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  88. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  89. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  90. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  91. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  92. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  93. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  94. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  95. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  96. package/src/core/checks/checks/unsupported_method.ts +63 -0
  97. package/src/core/checks/checks/use_after_free.ts +78 -0
  98. package/src/core/checks/index.ts +30 -0
  99. package/src/core/checks/mode.ts +79 -0
  100. package/src/core/checks/recommended-action.ts +64 -0
  101. package/src/core/checks/registry.ts +78 -0
  102. package/src/core/checks/runner.ts +874 -0
  103. package/src/core/checks/sarif.ts +230 -0
  104. package/src/core/checks/stateful.ts +121 -0
  105. package/src/core/checks/types.ts +189 -0
  106. package/src/core/classifier/recommended-action.ts +222 -0
  107. package/src/core/context/current.ts +51 -0
  108. package/src/core/context/session.ts +78 -0
  109. package/src/core/coverage/loader.ts +185 -0
  110. package/src/core/coverage/reasons.ts +300 -0
  111. package/src/core/diagnostics/db-analysis.ts +161 -12
  112. package/src/core/diagnostics/failure-class.ts +120 -0
  113. package/src/core/diagnostics/failure-hints.ts +212 -9
  114. package/src/core/diagnostics/spec-pointer.ts +99 -0
  115. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  116. package/src/core/exporter/case-study/index.ts +270 -0
  117. package/src/core/exporter/curl.ts +40 -0
  118. package/src/core/exporter/exporter.ts +48 -0
  119. package/src/core/exporter/html-report/escape.ts +24 -0
  120. package/src/core/exporter/html-report/index.ts +479 -0
  121. package/src/core/exporter/html-report/script.ts +100 -0
  122. package/src/core/exporter/html-report/styles.ts +408 -0
  123. package/src/core/generator/chunker.ts +53 -15
  124. package/src/core/generator/coverage-phase.ts +0 -0
  125. package/src/core/generator/create-body.ts +89 -0
  126. package/src/core/generator/data-factory.ts +490 -33
  127. package/src/core/generator/describe.ts +1 -1
  128. package/src/core/generator/fixtures-builder.ts +325 -0
  129. package/src/core/generator/index.ts +7 -5
  130. package/src/core/generator/openapi-reader.ts +55 -3
  131. package/src/core/generator/path-param-disambig.ts +114 -0
  132. package/src/core/generator/resources-builder.ts +648 -0
  133. package/src/core/generator/schema-utils.ts +11 -3
  134. package/src/core/generator/serializer.ts +114 -15
  135. package/src/core/generator/suite-generator.ts +484 -77
  136. package/src/core/generator/types.ts +8 -0
  137. package/src/core/identity/identity-file.ts +129 -0
  138. package/src/core/lint/affects.ts +28 -0
  139. package/src/core/lint/config.ts +96 -0
  140. package/src/core/lint/format.ts +42 -0
  141. package/src/core/lint/index.ts +94 -0
  142. package/src/core/lint/reporter.ts +128 -0
  143. package/src/core/lint/rules/consistency.ts +158 -0
  144. package/src/core/lint/rules/heuristics.ts +97 -0
  145. package/src/core/lint/rules/strictness.ts +109 -0
  146. package/src/core/lint/types.ts +96 -0
  147. package/src/core/lint/walker.ts +248 -0
  148. package/src/core/meta/meta-store.ts +6 -73
  149. package/src/core/output/README.md +91 -0
  150. package/src/core/output/index.ts +13 -0
  151. package/src/core/output/run.ts +126 -0
  152. package/src/core/output/types.ts +129 -0
  153. package/src/core/parser/env-interpolation.ts +104 -0
  154. package/src/core/parser/filter.ts +57 -0
  155. package/src/core/parser/schema.ts +132 -5
  156. package/src/core/parser/types.ts +29 -2
  157. package/src/core/parser/variables.ts +0 -0
  158. package/src/core/parser/yaml-parser.ts +108 -13
  159. package/src/core/probe/bootstrap.ts +34 -0
  160. package/src/core/probe/dry-run-envelope.ts +57 -0
  161. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  162. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  163. package/src/core/probe/mass-assignment-template.ts +212 -0
  164. package/src/core/probe/method-probe.ts +164 -0
  165. package/src/core/probe/method-shared.ts +69 -0
  166. package/src/core/probe/negative-probe.ts +691 -0
  167. package/src/core/probe/orphan-tracker.ts +188 -0
  168. package/src/core/probe/path-discovery.ts +440 -0
  169. package/src/core/probe/probe-harness.ts +120 -0
  170. package/src/core/probe/registry.ts +89 -0
  171. package/src/core/probe/runner.ts +136 -0
  172. package/src/core/probe/security-probe-class.ts +201 -0
  173. package/src/core/probe/security-probe.ts +1453 -0
  174. package/src/core/probe/shared.ts +505 -0
  175. package/src/core/probe/static-probe-class.ts +125 -0
  176. package/src/core/probe/types.ts +165 -0
  177. package/src/core/probe/verdict-aggregator.ts +33 -0
  178. package/src/core/probe/webhooks-probe.ts +284 -0
  179. package/src/core/reporter/console.ts +69 -4
  180. package/src/core/reporter/index.ts +2 -3
  181. package/src/core/reporter/json.ts +15 -2
  182. package/src/core/reporter/junit.ts +27 -12
  183. package/src/core/reporter/ndjson.ts +37 -0
  184. package/src/core/reporter/types.ts +3 -0
  185. package/src/core/runner/assertions.ts +62 -2
  186. package/src/core/runner/async-pool.ts +108 -0
  187. package/src/core/runner/auth-path.ts +8 -0
  188. package/src/core/runner/ci-context.ts +72 -0
  189. package/src/core/runner/executor.ts +391 -52
  190. package/src/core/runner/form-encode.ts +51 -0
  191. package/src/core/runner/http-client.ts +115 -7
  192. package/src/core/runner/learn-drift.ts +293 -0
  193. package/src/core/runner/preflight-vars.ts +149 -0
  194. package/src/core/runner/progress-tracker.ts +73 -0
  195. package/src/core/runner/rate-limiter.ts +203 -0
  196. package/src/core/runner/run-kind.ts +39 -0
  197. package/src/core/runner/schema-validator.ts +312 -0
  198. package/src/core/runner/send-request.ts +153 -20
  199. package/src/core/runner/types.ts +38 -0
  200. package/src/core/secrets/registry.ts +164 -0
  201. package/src/core/secrets/secrets-file.ts +115 -0
  202. package/src/core/selectors/operation-filter.ts +144 -0
  203. package/src/core/setup-api.ts +419 -17
  204. package/src/core/severity/category.ts +94 -0
  205. package/src/core/severity/index.ts +121 -0
  206. package/src/core/spec/layers.ts +154 -0
  207. package/src/core/util/format-eta.ts +21 -0
  208. package/src/core/utils.ts +5 -1
  209. package/src/core/workspace/config.ts +129 -0
  210. package/src/core/workspace/manifest.ts +283 -0
  211. package/src/core/workspace/output-rotation.ts +62 -0
  212. package/src/core/workspace/root.ts +94 -0
  213. package/src/core/workspace/triage-path.ts +87 -0
  214. package/src/db/lint-runs.ts +47 -0
  215. package/src/db/migrate.ts +126 -0
  216. package/src/db/migrations/0001_run_kind.sql +25 -0
  217. package/src/db/migrations/sql.d.ts +4 -0
  218. package/src/db/queries/collections.ts +133 -0
  219. package/src/db/queries/coverage.ts +9 -0
  220. package/src/db/queries/dashboard.ts +59 -0
  221. package/src/db/queries/results.ts +128 -0
  222. package/src/db/queries/runs.ts +235 -0
  223. package/src/db/queries/sessions.ts +42 -0
  224. package/src/db/queries/settings.ts +28 -0
  225. package/src/db/queries/types.ts +172 -0
  226. package/src/db/queries.ts +72 -802
  227. package/src/db/schema.ts +179 -48
  228. package/src/cli/commands/export.ts +0 -144
  229. package/src/cli/commands/guide.ts +0 -127
  230. package/src/cli/commands/init.ts +0 -57
  231. package/src/cli/commands/serve.ts +0 -81
  232. package/src/cli/commands/sync.ts +0 -269
  233. package/src/cli/commands/update.ts +0 -189
  234. package/src/cli/commands/validate.ts +0 -34
  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 -21
  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,53 @@
1
+ /**
2
+ * ARV-124: migrated from `src/core/checks/checks/_anti_fp.ts` (guard #2).
3
+ *
4
+ * The classic "stringified primitive" trap: schema says `integer`, our
5
+ * mutation flips to `"42"` (string), but servers using Express,
6
+ * FastAPI, Rails coerce the string back to int. The mutation is a
7
+ * no-op on the wire, so a 2xx isn't a real silent-accept.
8
+ *
9
+ * Sources: schemathesis #2312, #2978.
10
+ */
11
+ import type { CheckCase } from "../../../checks/types.ts";
12
+ import type { MutationMeta } from "../../../checks/checks/_negative_mutator.ts";
13
+ import type { FpRule } from "../../types.ts";
14
+
15
+ function getMutation(c: CheckCase): MutationMeta | undefined {
16
+ const m = c.meta as { mutation?: MutationMeta["mutation"] } | undefined;
17
+ if (!m || typeof m.mutation !== "string") return undefined;
18
+ return c.meta as unknown as MutationMeta;
19
+ }
20
+
21
+ export const stringTypeMutationBecomesValidRule: FpRule<CheckCase> = {
22
+ id: "_string_type_mutation_becomes_valid_after_serialization",
23
+ scope: "check:negative_data_rejection",
24
+ references: ["#2312", "#2978"],
25
+ applies(c) {
26
+ const m = getMutation(c);
27
+ if (!m || m.mutation !== "type_mutation") return null;
28
+ const fromNumeric = m.from_type === "integer" || m.from_type === "number";
29
+ const fromBoolean = m.from_type === "boolean";
30
+ const toString = m.to_type === "string" || typeof m.to_value === "string";
31
+ if (!toString) return null;
32
+ if (fromNumeric || fromBoolean) {
33
+ const v = String(m.to_value);
34
+ if (fromNumeric && /^-?\d+(\.\d+)?$/.test(v)) {
35
+ return {
36
+ ruleId: "_string_type_mutation_becomes_valid_after_serialization",
37
+ scope: "check:negative_data_rejection",
38
+ reason: `value "${v}" is numerically coerceable — server may auto-cast`,
39
+ references: ["#2312", "#2978"],
40
+ };
41
+ }
42
+ if (fromBoolean && (v === "true" || v === "false")) {
43
+ return {
44
+ ruleId: "_string_type_mutation_becomes_valid_after_serialization",
45
+ scope: "check:negative_data_rejection",
46
+ reason: `value "${v}" is boolean-coerceable — server may auto-cast`,
47
+ references: ["#2312"],
48
+ };
49
+ }
50
+ }
51
+ return null;
52
+ },
53
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * ARV-125 / ARV-126: subscription/scope-gated anti-FP rule bundle.
3
+ * Same pattern as `rules/schemathesis/index.ts` — each rule is
4
+ * exported individually for tests, the side-effect-free list is
5
+ * consumed by `bootstrapAntiFp`.
6
+ */
7
+ import { PAID_PLAN_403_RULE } from "./paid-plan-403.ts";
8
+
9
+ export { PAID_PLAN_403_RULE };
10
+
11
+ export const SUBSCRIPTION_GATED_RULES = [PAID_PLAN_403_RULE] as const;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ARV-125: migrated from `core/probe/mass-assignment-probe.ts` —
3
+ * inline pattern match for subscription/scope-gated 403 responses.
4
+ *
5
+ * Background (ARV-104 / F9): mass-assignment probing against a
6
+ * paid-plan-gated API slice produced an INCONCLUSIVE baseline on
7
+ * every gated endpoint. The default `inconclusiveBaselineSummary`
8
+ * tail tells the triage agent to "fix fixture / FK / path-params
9
+ * and re-probe" — but there's nothing to fix: the endpoint is gated
10
+ * by subscription/scope, and the agent will crank-turn fixture edits
11
+ * forever. The pattern match swaps the tail to a wontfix banner.
12
+ *
13
+ * Lives in the anti-FP registry as `subscription-gated/paid-plan-403`.
14
+ * Scope is `probe:mass-assignment` (with `probe:security` listed too —
15
+ * the live security probe hits the same surface and surfaces the same
16
+ * gated bodies through ARV-126's migration of its baseline-echo check).
17
+ *
18
+ * Context payload: `{ status, message }`. The mass-assignment probe
19
+ * already extracts the hint string from the response body; passing
20
+ * the extracted string keeps the rule body-format-agnostic so future
21
+ * callers (security probe) can reuse it without replicating the
22
+ * extractor.
23
+ */
24
+ import type { FpRule } from "../../types.ts";
25
+
26
+ export interface PaidPlan403Ctx {
27
+ /** HTTP status of the baseline response. Rule applies only at 403. */
28
+ status: number;
29
+ /** Server-supplied message extracted from the response body. The
30
+ * rule does not parse JSON — callers extract their preferred field
31
+ * (commonly `detail` / `message`) and pass the string. */
32
+ message?: string;
33
+ }
34
+
35
+ /** Lower-cased anchored fragments for the SaaS-flavoured wordings we
36
+ * encounter in the wild. Each entry is one independent signal — a
37
+ * body matching any one of them is treated as subscription-gated. */
38
+ export const SUBSCRIPTION_GATED_PATTERNS: RegExp[] = [
39
+ /\bpaid plan\b/i,
40
+ /\bsubscription (?:required|needed)\b/i,
41
+ /\bnot (?:available|enabled) (?:on|for) your\b/i,
42
+ /\bplan (?:does not include|doesn['']?t include)\b/i,
43
+ /\brequires? (?:the )?[\w:-]+ scope\b/i,
44
+ /\bmissing (?:the )?[\w:-]+ scope\b/i,
45
+ /\bfeature (?:is )?(?:not enabled|disabled|not available)\b/i,
46
+ /\binsufficient (?:permissions?|scope)\b/i,
47
+ ];
48
+
49
+ /** Exported predicate for callers that want a quick yes/no without
50
+ * composing an `applyAntiFp` call (the probe still exposes its own
51
+ * re-export of this for back-compat with pre-ARV-125 tests). */
52
+ export function matchesSubscriptionGated(message: string): boolean {
53
+ for (const re of SUBSCRIPTION_GATED_PATTERNS) {
54
+ if (re.test(message)) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ export const PAID_PLAN_403_RULE: FpRule<PaidPlan403Ctx> = {
60
+ id: "subscription-gated/paid-plan-403",
61
+ scope: ["probe:mass-assignment", "probe:security"],
62
+ references: ["ARV-104"],
63
+ applies(ctx) {
64
+ if (ctx.status !== 403) return null;
65
+ if (!ctx.message || !matchesSubscriptionGated(ctx.message)) return null;
66
+ return {
67
+ ruleId: "subscription-gated/paid-plan-403",
68
+ scope: "probe:mass-assignment",
69
+ reason:
70
+ "endpoint is env/subscription-gated (paid plan, role/scope, feature flag); " +
71
+ "not a fixture issue — wontfix unless scope changes",
72
+ references: ["ARV-104"],
73
+ };
74
+ },
75
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ARV-123 (m-19): typed registry for anti-FP guards.
3
+ *
4
+ * Background: anti-FP logic is scattered across the codebase —
5
+ * - `core/checks/checks/_anti_fp.ts` ships 4 schemathesis-attributed
6
+ * guards for the data-rejection family;
7
+ * - `core/probe/mass-assignment-probe.ts` carries inline regex
8
+ * suppressions (paid-plan / subscription / scope-gating);
9
+ * - `core/probe/security-probe.ts` does a baseline-echo / boundary
10
+ * check inline.
11
+ *
12
+ * They share the same shape — "given a finding plus its context, return
13
+ * a structured suppression with attribution" — but each has its own
14
+ * ad-hoc API, which makes it hard to (a) discover the full set, (b)
15
+ * attribute a suppression to its source (schemathesis #N, vendor
16
+ * plan-limit doc, etc.), and (c) test rules in isolation.
17
+ *
18
+ * This module gives them a common contract. Migration of existing rules
19
+ * lives in ARV-124/125/126 — this task only ships the registry.
20
+ */
21
+
22
+ /**
23
+ * Scope identifies the family of checks/probes a rule applies to.
24
+ * Convention: `<kind>:<name>`. Examples:
25
+ * - `check:negative_data_rejection` / `check:positive_data_acceptance`
26
+ * - `probe:mass-assignment`
27
+ * - `probe:security` (baseline-echo / boundary)
28
+ *
29
+ * A rule may declare a single scope or an array of scopes. `applyAntiFp`
30
+ * filters the registry by the caller's scope before running rules, so
31
+ * a mass-assignment-only rule never gets evaluated against a data
32
+ * rejection finding.
33
+ */
34
+ export type FpScope = string;
35
+
36
+ export interface FpRule<Ctx = unknown> {
37
+ /** Stable identifier — used for dedup, logs, and downstream
38
+ * attribution. Convention mirrors schemathesis: snake_case prefixed
39
+ * with the family (`_body_negation_becomes_valid_after_serialization`).
40
+ * Last-writer wins on re-register, so test setups can swap rules. */
41
+ id: string;
42
+ /** Single scope or set of scopes this rule covers. */
43
+ scope: FpScope | FpScope[];
44
+ /** Decide whether the rule fires for a given context. Return a
45
+ * populated suppression to claim the finding, or null to pass. */
46
+ applies(ctx: Ctx): FpSuppression | null;
47
+ /** Static reason used when the rule's logic just wants to flag the
48
+ * context without composing a runtime string. Optional — most rules
49
+ * prefer to build a context-specific reason inside `applies`. */
50
+ reason?: string;
51
+ /** Backing evidence — schemathesis issue numbers, vendor docs, etc.
52
+ * Surfaced verbatim in the suppression so an agent reading the
53
+ * output can locate the upstream debate. */
54
+ references?: string[];
55
+ }
56
+
57
+ export interface FpSuppression {
58
+ /** The rule that fired. */
59
+ ruleId: string;
60
+ /** The scope under which the rule fired (resolved scope, not the
61
+ * rule's declared scope set). */
62
+ scope: FpScope;
63
+ /** Human-readable reason. Built by the rule's `applies` function. */
64
+ reason: string;
65
+ /** Copied through from the rule definition unless the rule overrode
66
+ * it inside `applies`. */
67
+ references?: string[];
68
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Shared id-extraction helpers for stateful CRUD checks (m-15 ARV-3).
3
+ * Kept under `_` prefix so it doesn't get auto-registered.
4
+ */
5
+ import { encodeFormBody } from "../../runner/form-encode.ts";
6
+ import { substituteDeep } from "../../parser/variables.ts";
7
+ import { generateFromSchema } from "../../generator/data-factory.ts";
8
+ import type { EndpointInfo } from "../../generator/types.ts";
9
+ import type { SeedBodyConfig } from "../../generator/resources-builder.ts";
10
+
11
+ /**
12
+ * ARV-187: pick the create body for a stateful CRUD step. Prefers an
13
+ * LLM-authored `seed_body` block (from `.api-resources.local.yaml`)
14
+ * because random scalars from `generateFromSchema` consistently get
15
+ * rejected by strict-validating APIs (Stripe's `expand[]`, Stripe
16
+ * required-field XORs, FK-bearing creates). When no seed_body is set,
17
+ * falls back to generation — preserves the pre-ARV-187 behaviour for
18
+ * APIs we haven't annotated yet.
19
+ *
20
+ * Returns `null` when neither path can produce an object (no schema +
21
+ * no seed). Caller should skip with a broken-baseline reason.
22
+ */
23
+ export function resolveCreateBody(
24
+ create: EndpointInfo,
25
+ seedBody: SeedBodyConfig | undefined,
26
+ ): Record<string, unknown> | null {
27
+ if (seedBody && seedBody.body && typeof seedBody.body === "object") {
28
+ return seedBody.body;
29
+ }
30
+ if (!create.requestBodySchema) return null;
31
+ const generated = generateFromSchema(create.requestBodySchema);
32
+ if (generated == null || typeof generated !== "object") return null;
33
+ return generated as Record<string, unknown>;
34
+ }
35
+
36
+ /**
37
+ * ARV-191: serialise a generated body using whichever wire format the
38
+ * create endpoint declares, and resolve the `{{$randomString}}` /
39
+ * `{{$randomInt}}` / `{{$randomEmail}}` markers that
40
+ * `generateFromSchema` embeds. Two failure modes this addresses:
41
+ *
42
+ * 1. Content-type — Stripe-style APIs declare only
43
+ * `application/x-www-form-urlencoded`; JSON.stringify yields a
44
+ * 400 "missing required param" the broken-baseline guard swallows.
45
+ * Mirrors `serializeProbeBody` (ARV-150) for probes.
46
+ * 2. Placeholder resolution — `data-factory` emits literal markers
47
+ * that downstream callers (the YAML runner, the probe-harness)
48
+ * resolve via `substituteDeep`. Stateful checks bypassed this and
49
+ * sent `balance={{$randomInt}}` to Stripe → 400. Sending JSON
50
+ * previously masked the bug because Stripe ignored the body
51
+ * entirely on form-encoded endpoints.
52
+ *
53
+ * Pass `vars` when the caller has live env values (path-fixtures); the
54
+ * helper otherwise relies on the built-in `GENERATORS` table inside
55
+ * `substituteDeep` to fabricate values for the random markers.
56
+ */
57
+ export function serializeCheckBody(
58
+ create: { requestBodyContentType?: string },
59
+ body: Record<string, unknown>,
60
+ vars: Record<string, unknown> = {},
61
+ contentTypeOverride?: string,
62
+ ): { body: string; contentType: string } {
63
+ const resolved = substituteDeep(body, vars);
64
+ const obj = (resolved && typeof resolved === "object" && !Array.isArray(resolved))
65
+ ? (resolved as Record<string, unknown>)
66
+ : {};
67
+ const ct = contentTypeOverride ?? create.requestBodyContentType ?? "application/json";
68
+ if (ct === "application/x-www-form-urlencoded") {
69
+ return { body: encodeFormBody(obj), contentType: "application/x-www-form-urlencoded" };
70
+ }
71
+ return { body: JSON.stringify(obj), contentType: ct };
72
+ }
73
+
74
+ export function fillPathWithId(path: string, idParam: string, id: string | number): string {
75
+ const v = encodeURIComponent(String(id));
76
+ return path
77
+ .replace(new RegExp(`\\{${idParam}\\}`), v)
78
+ // Fallback: any single placeholder gets replaced.
79
+ .replace(/\{[^}]+\}/g, v);
80
+ }
81
+
82
+ /** ARV-169: substitute parent-scope path-params on a create endpoint
83
+ * using harness.pathVars. Resource-scoped APIs (Sentry's
84
+ * `/api/0/organizations/{organization_id_or_slug}/projects/`) need
85
+ * the parent id resolved before the create call lands — without it
86
+ * the create 404s and the broken-baseline guard skips the whole
87
+ * CRUD chain. Vars not present in `pathVars` are left as literal
88
+ * placeholders so the caller can spot the gap in skip diagnostics.
89
+ * Idempotent for paths with no placeholders (most flat-CRUD APIs). */
90
+ export function fillPathParams(path: string, pathVars?: Record<string, string>): string {
91
+ if (!pathVars) return path;
92
+ return path.replace(/\{([^}]+)\}/g, (_, name) => {
93
+ const v = pathVars[name];
94
+ return v && v.length > 0 ? encodeURIComponent(v) : `{${name}}`;
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Pull a usable id out of a create-response body. Honours the spec's
100
+ * declared `idParam` first (so `userId` matches `user_id` / `userId`),
101
+ * then falls back to a list of common keys. Returns null if nothing
102
+ * looks like a usable id.
103
+ */
104
+ export function extractIdFromCreateResponse(body: unknown, idParam: string): string | number | null {
105
+ if (body == null || typeof body !== "object") {
106
+ if (typeof body === "string" || typeof body === "number") return body;
107
+ return null;
108
+ }
109
+ // Strings often arrive as parsed JSON via http-client; treat both.
110
+ const obj = body as Record<string, unknown>;
111
+ const candidates = [
112
+ idParam,
113
+ idParam.replace(/[_-]/g, ""),
114
+ "id",
115
+ "uuid",
116
+ "slug",
117
+ "name",
118
+ "key",
119
+ ];
120
+ for (const k of candidates) {
121
+ const v = obj[k];
122
+ if (typeof v === "string" || typeof v === "number") return v;
123
+ }
124
+ // common SaaS-style { data: { id } } envelope.
125
+ const data = obj.data as Record<string, unknown> | undefined;
126
+ if (data && typeof data === "object") {
127
+ for (const k of candidates) {
128
+ const v = data[k];
129
+ if (typeof v === "string" || typeof v === "number") return v;
130
+ }
131
+ }
132
+ return null;
133
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Single-site negative-body mutator (m-15 ARV-4). Applies exactly one
3
+ * mutation to a valid body so the data-rejection check can attribute
4
+ * accept/reject decisions to a known cause. Three strategies, picked
5
+ * in priority order:
6
+ *
7
+ * 1. drop_required — remove the first required field.
8
+ * 2. type_mutation — flip the first scalar field's type.
9
+ * 3. constraint_violation — violate the first declared constraint
10
+ * (minLength/maximum/pattern/enum).
11
+ *
12
+ * The first applicable strategy wins — keeps mutations deterministic
13
+ * across runs (matches schemathesis' "isolate the failure site" goal).
14
+ * `meta` carries the strategy + field path so anti-FP guards and
15
+ * findings can describe what was changed without reparsing the body.
16
+ */
17
+ import type { OpenAPIV3 } from "openapi-types";
18
+ import { generateFromSchema } from "../../generator/data-factory.ts";
19
+
20
+ export interface MutationMeta {
21
+ mutation: "drop_required" | "type_mutation" | "constraint_violation";
22
+ field_path: string;
23
+ /** For type_mutation. */
24
+ from_type?: string;
25
+ to_type?: string;
26
+ to_value?: unknown;
27
+ /** For constraint_violation. */
28
+ constraint?: string;
29
+ }
30
+
31
+ export interface MutationResult {
32
+ body: unknown;
33
+ meta: MutationMeta;
34
+ }
35
+
36
+ function isObject(v: unknown): v is Record<string, unknown> {
37
+ return typeof v === "object" && v !== null && !Array.isArray(v);
38
+ }
39
+
40
+ function pickWrongValue(t: string): { type: string; value: unknown } {
41
+ // Pick a value that is *clearly* the wrong type — and not a string
42
+ // that the server might coerce. Anti-FP guard #2 takes care of the
43
+ // "stringified primitive" case separately, but we avoid emitting it
44
+ // in the first place where we can.
45
+ switch (t) {
46
+ case "integer":
47
+ case "number":
48
+ return { type: "boolean", value: true };
49
+ case "boolean":
50
+ return { type: "integer", value: 7 };
51
+ case "string":
52
+ return { type: "object", value: { unexpected: "shape" } };
53
+ case "array":
54
+ return { type: "object", value: {} };
55
+ case "object":
56
+ return { type: "array", value: [] };
57
+ default:
58
+ return { type: "boolean", value: true };
59
+ }
60
+ }
61
+
62
+ function tryDropRequired(schema: OpenAPIV3.SchemaObject, body: unknown): MutationResult | null {
63
+ if (!isObject(body)) return null;
64
+ const required = schema.required ?? [];
65
+ for (const f of required) {
66
+ if (f in body) {
67
+ const next = { ...body };
68
+ delete next[f];
69
+ return { body: next, meta: { mutation: "drop_required", field_path: f, dropped_field: f } as MutationMeta };
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function tryTypeMutation(schema: OpenAPIV3.SchemaObject, body: unknown): MutationResult | null {
76
+ if (!isObject(body)) return null;
77
+ const props = (schema.properties ?? {}) as Record<string, OpenAPIV3.SchemaObject>;
78
+ for (const [name, propSchema] of Object.entries(props)) {
79
+ const t = propSchema.type;
80
+ if (typeof t !== "string") continue;
81
+ if (!(name in body)) continue;
82
+ const wrong = pickWrongValue(t);
83
+ return {
84
+ body: { ...body, [name]: wrong.value },
85
+ meta: {
86
+ mutation: "type_mutation",
87
+ field_path: name,
88
+ from_type: t,
89
+ to_type: wrong.type,
90
+ to_value: wrong.value,
91
+ },
92
+ };
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function tryConstraintViolation(schema: OpenAPIV3.SchemaObject, body: unknown): MutationResult | null {
98
+ if (!isObject(body)) return null;
99
+ const props = (schema.properties ?? {}) as Record<string, OpenAPIV3.SchemaObject>;
100
+ for (const [name, propSchema] of Object.entries(props)) {
101
+ if (!(name in body)) continue;
102
+ if (propSchema.enum && propSchema.enum.length > 0) {
103
+ return { body: { ...body, [name]: "__not_in_enum__" }, meta: { mutation: "constraint_violation", field_path: name, constraint: "enum" } };
104
+ }
105
+ if (typeof propSchema.minLength === "number" && propSchema.minLength > 0) {
106
+ return { body: { ...body, [name]: "" }, meta: { mutation: "constraint_violation", field_path: name, constraint: "minLength" } };
107
+ }
108
+ if (typeof propSchema.maxLength === "number") {
109
+ return {
110
+ body: { ...body, [name]: "x".repeat(propSchema.maxLength + 1) },
111
+ meta: { mutation: "constraint_violation", field_path: name, constraint: "maxLength" },
112
+ };
113
+ }
114
+ if (typeof propSchema.minimum === "number") {
115
+ return { body: { ...body, [name]: propSchema.minimum - 1 }, meta: { mutation: "constraint_violation", field_path: name, constraint: "minimum" } };
116
+ }
117
+ if (typeof propSchema.maximum === "number") {
118
+ return { body: { ...body, [name]: propSchema.maximum + 1 }, meta: { mutation: "constraint_violation", field_path: name, constraint: "maximum" } };
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Build a single-site negative case from a request-body schema.
126
+ * Returns `null` when the schema offers no exploit surface (no
127
+ * required fields, no typed properties, no constraints) — the caller
128
+ * should skip emitting a probe rather than send a meaningless one.
129
+ */
130
+ export function buildNegativeBody(schema: OpenAPIV3.SchemaObject): MutationResult | null {
131
+ const valid = generateFromSchema(schema);
132
+ return tryDropRequired(schema, valid) ?? tryTypeMutation(schema, valid) ?? tryConstraintViolation(schema, valid);
133
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * ARV-169 (m-20): POST→GET cross-call drift diff logic.
3
+ *
4
+ * The check creates a resource, reads it back, and compares the
5
+ * write-shape (what the client sent + what the create response echoed)
6
+ * against the read-shape (what GET returned). Three flavours of drift:
7
+ *
8
+ * • write-only — POST accepted the field, GET never returned it.
9
+ * Often a secret/write-once design; can also be a silent data drop.
10
+ * Suppressible via `ignore_fields` per resource.
11
+ * • state-not-persisted — POST *echoed* the field in its 2xx response
12
+ * but GET dropped it. This is the high-signal class: server lied
13
+ * about persisting state. Always HIGH unless explicitly ignored.
14
+ * • undeclared-on-read — GET returned a field the spec doesn't
15
+ * document. Surfaced by response_schema_conformance already; this
16
+ * check is the cross-call analogue and stays out of the way.
17
+ *
18
+ * Anti-FP: a baseline `DEFAULT_READBACK_IGNORE` filters timestamp/etag
19
+ * envelope fields shared across every SaaS API, so a probe on a stock
20
+ * spec without yaml overrides doesn't drown in noise. Per-API quirks
21
+ * (Stripe `metadata` stripping, `livemode`) are layered on top via
22
+ * `.api-resources.local.yaml` (authored by `zond api annotate` or by
23
+ * hand — see backlog/notes/m-20-validation.md §«Review boundary»).
24
+ */
25
+ import type { ReadbackDiffConfig } from "../../generator/resources-builder.ts";
26
+
27
+ /** Fields excluded from drift detection on every resource, regardless
28
+ * of yaml config. These are universally non-comparable across the
29
+ * POST→GET hop. */
30
+ export const DEFAULT_READBACK_IGNORE: ReadonlySet<string> = new Set([
31
+ "id",
32
+ "object",
33
+ "created",
34
+ "created_at",
35
+ "createdAt",
36
+ "updated",
37
+ "updated_at",
38
+ "updatedAt",
39
+ "deleted_at",
40
+ "etag",
41
+ "_etag",
42
+ "version",
43
+ "livemode",
44
+ "_links",
45
+ "self",
46
+ "url",
47
+ ]);
48
+
49
+ export interface DriftField {
50
+ field: string;
51
+ kind: "write_only" | "state_not_persisted" | "undeclared_on_read";
52
+ /** For state_not_persisted: the value the create response echoed. */
53
+ writtenValue?: unknown;
54
+ }
55
+
56
+ export interface DriftReport {
57
+ writeOnly: DriftField[];
58
+ stateNotPersisted: DriftField[];
59
+ undeclaredOnRead: DriftField[];
60
+ }
61
+
62
+ function shallowFields(v: unknown): Set<string> {
63
+ if (v == null || typeof v !== "object" || Array.isArray(v)) return new Set();
64
+ return new Set(Object.keys(v as Record<string, unknown>));
65
+ }
66
+
67
+ /**
68
+ * Compute drift between write-shape and read-shape.
69
+ *
70
+ * @param writeBody what the client POSTed (parsed JSON)
71
+ * @param createEcho the 2xx response body of the POST
72
+ * @param readBody the 2xx response body of the subsequent GET
73
+ * @param specDeclared field names declared in spec.responses for the GET
74
+ * (used to suppress write-only fields that the spec
75
+ * marks as write-only — `password`-style secrets).
76
+ * Empty Set ⇒ no suppression by spec.
77
+ * @param cfg per-resource readback overrides
78
+ */
79
+ export function computeDrift(
80
+ writeBody: unknown,
81
+ createEcho: unknown,
82
+ readBody: unknown,
83
+ specDeclared: ReadonlySet<string>,
84
+ cfg: ReadbackDiffConfig | undefined,
85
+ ): DriftReport {
86
+ const writeFields = shallowFields(writeBody);
87
+ const echoFields = shallowFields(createEcho);
88
+ const readFields = shallowFields(readBody);
89
+
90
+ const ignore = new Set<string>(DEFAULT_READBACK_IGNORE);
91
+ for (const f of cfg?.ignoreFields ?? []) ignore.add(f);
92
+ const renameMap = cfg?.writeToReadMap ?? {};
93
+
94
+ // Apply rename: a write-side field maps to a different read-side name.
95
+ const writeAfterRename = new Set<string>();
96
+ for (const f of writeFields) writeAfterRename.add(renameMap[f] ?? f);
97
+ const echoAfterRename = new Set<string>();
98
+ for (const f of echoFields) echoAfterRename.add(renameMap[f] ?? f);
99
+
100
+ const writeOnly: DriftField[] = [];
101
+ for (const f of writeAfterRename) {
102
+ if (ignore.has(f)) continue;
103
+ if (readFields.has(f)) continue;
104
+ // If the field isn't declared on the GET response schema at all,
105
+ // it's a write-only-by-spec contract — not a drift. (Secrets, etc.)
106
+ if (specDeclared.size > 0 && !specDeclared.has(f)) continue;
107
+ writeOnly.push({ field: f, kind: "write_only" });
108
+ }
109
+
110
+ const stateNotPersisted: DriftField[] = [];
111
+ const echoBody = (createEcho ?? {}) as Record<string, unknown>;
112
+ for (const f of echoAfterRename) {
113
+ if (ignore.has(f)) continue;
114
+ if (readFields.has(f)) continue;
115
+ // Only report fields the echo actually carried with a non-null value —
116
+ // a null echo is the server signalling "not set", not a persistence bug.
117
+ const originalKey = Object.keys(renameMap).find((k) => renameMap[k] === f) ?? f;
118
+ const v = echoBody[originalKey];
119
+ if (v === undefined || v === null) continue;
120
+ stateNotPersisted.push({ field: f, kind: "state_not_persisted", writtenValue: v });
121
+ }
122
+
123
+ const undeclaredOnRead: DriftField[] = [];
124
+ if (specDeclared.size > 0) {
125
+ for (const f of readFields) {
126
+ if (ignore.has(f)) continue;
127
+ if (specDeclared.has(f)) continue;
128
+ undeclaredOnRead.push({ field: f, kind: "undeclared_on_read" });
129
+ }
130
+ }
131
+
132
+ return { writeOnly, stateNotPersisted, undeclaredOnRead };
133
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `content_type_conformance` — Content-Type returned by the server
3
+ * isn't among those declared in `op.responses[*].content`. Mirrors
4
+ * schemathesis. We only fail when a body is present and Content-Type
5
+ * is meaningful — empty 204 responses don't carry a type and pass.
6
+ */
7
+ import type { Check } from "../types.ts";
8
+
9
+ function baseType(ct: string): string {
10
+ return ct.split(";")[0]!.trim().toLowerCase();
11
+ }
12
+
13
+ export const contentTypeConformance: Check = {
14
+ id: "content_type_conformance",
15
+ severity: "medium",
16
+ defaultExpected: "Response Content-Type must be one of those declared on the OpenAPI response",
17
+ references: [{ id: "OAS3-mediaType", url: "https://spec.openapis.org/oas/v3.0.3#media-type-object" }],
18
+ applies: (op) => op.responseContentTypes.length > 0,
19
+ run({ case: c, response }) {
20
+ // 204 / 304 by definition have no body — Content-Type irrelevant.
21
+ if (response.status === 204 || response.status === 304) return { kind: "pass" };
22
+ const got = response.headers["content-type"] ?? response.headers["Content-Type"];
23
+ if (!got) {
24
+ return {
25
+ kind: "fail",
26
+ message: `Missing Content-Type header on ${c.operation.method} ${c.operation.path}`,
27
+ evidence: { declared: c.operation.responseContentTypes },
28
+ };
29
+ }
30
+ const declared = c.operation.responseContentTypes.map(baseType);
31
+ if (declared.length === 0) return { kind: "skip", reason: "no declared content types" };
32
+ if (declared.includes(baseType(got))) return { kind: "pass" };
33
+ return {
34
+ kind: "fail",
35
+ message: `Content-Type "${got}" not declared in OpenAPI for ${c.operation.method} ${c.operation.path}`,
36
+ evidence: { actual: got, declared },
37
+ };
38
+ },
39
+ };