@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,691 @@
1
+ /**
2
+ * Negative-input probe generator (T49).
3
+ *
4
+ * Goal: catch the class of bugs where an API returns 5xx (unhandled exception)
5
+ * instead of 4xx (validation error) when given malformed input. The contract
6
+ * is simple: any client-supplied invalid input MUST produce a 4xx, never a 5xx.
7
+ *
8
+ * For each endpoint we generate a suite of probe steps. Each step expects a
9
+ * "no 5xx" response (status in [400, 401, 403, 404, 405, 409, 415, 422, 429]).
10
+ * If the API returns 500/502/503 — the test fails and the runner logs it as
11
+ * a bug candidate via the regular reporter / `zond db diagnose` flow.
12
+ *
13
+ * ARV-34: 429 is in the allow-set because rate-limiting is itself a valid
14
+ * server-side rejection of the request — the API refused to process invalid
15
+ * input, the contract is satisfied. Throttled probe runs were producing
16
+ * hundreds of false failures with the warning "N requests hit rate limit"
17
+ * already saying the same thing.
18
+ *
19
+ * The probes are deterministic — same spec → same suites — so the generated
20
+ * YAML can be committed as a regression test.
21
+ */
22
+ import type { OpenAPIV3 } from "openapi-types";
23
+ import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
24
+ import type { RawSuite, RawStep } from "../generator/serializer.ts";
25
+ import { generateFromSchema } from "../generator/data-factory.ts";
26
+ import {
27
+ convertPath,
28
+ endpointStem,
29
+ getAuthHeaders,
30
+ renderPath,
31
+ isMutating,
32
+ findDeleteCounterpart,
33
+ captureFieldFor,
34
+ headersEqual,
35
+ hasJsonBody,
36
+ } from "./shared.ts";
37
+
38
+ // ──────────────────────────────────────────────
39
+ // Constants
40
+ // ──────────────────────────────────────────────
41
+
42
+ /** Statuses we consider an *acceptable* response to invalid input. Anything
43
+ * outside this set (notably 5xx, but also 200/201 which would mean the API
44
+ * silently accepted the bad input) is a probe failure. ARV-34: 429 stays in
45
+ * the allow-set — server-side throttling is a valid rejection. */
46
+ const ACCEPTABLE_4XX = [400, 401, 403, 404, 405, 409, 415, 422, 429];
47
+
48
+ /** Long string for boundary probes — 10_000 chars. */
49
+ const LONG_STRING = "a".repeat(10_000);
50
+
51
+ /** Mixed unicode + emoji + RTL for charset probes. */
52
+ const UNICODE_MIX = "Mix🌐مرحبا\u200B";
53
+
54
+ /** Sentinel non-UUID inputs for path/UUID probes. */
55
+ export const INVALID_UUID_SENTINELS = [
56
+ "not-a-uuid",
57
+ "12345",
58
+ "00000000",
59
+ "../../etc/passwd",
60
+ ] as const;
61
+ const INVALID_UUID_VALUES = INVALID_UUID_SENTINELS;
62
+
63
+ /** Sentinel invalid emails. */
64
+ const INVALID_EMAIL_VALUES = [
65
+ "not-an-email",
66
+ "@no-local.example.com",
67
+ "spaces in@email.com",
68
+ ];
69
+
70
+ /** Sentinel invalid URIs. */
71
+ const INVALID_URI_VALUES = [
72
+ "not a url",
73
+ "javascript:alert(1)",
74
+ "ftp:/missing-slash",
75
+ ];
76
+
77
+ /** Sentinel invalid date-time strings. */
78
+ const INVALID_DATETIME_VALUES = [
79
+ "yesterday",
80
+ "2023-13-45T99:99:99Z",
81
+ "2023-10-06:23:47:56.678Z", // colon-instead-of-T (real bug we caught)
82
+ ];
83
+
84
+ // ──────────────────────────────────────────────
85
+ // Types
86
+ // ──────────────────────────────────────────────
87
+
88
+ export interface ProbeOptions {
89
+ endpoints: EndpointInfo[];
90
+ securitySchemes: SecuritySchemeInfo[];
91
+ /** Cap probes per endpoint (default 50). Hard cutoff for huge schemas. */
92
+ maxProbesPerEndpoint?: number;
93
+ /**
94
+ * Skip emission of follow-up DELETE cleanup steps for mutating probes
95
+ * (POST/PUT/PATCH). Useful for namespace-isolated test environments
96
+ * (staging dump-and-reset) where cleanup is handled out of band.
97
+ */
98
+ noCleanup?: boolean;
99
+ /**
100
+ * TASK-135: when true (default), non-attacked path-params are emitted as
101
+ * runtime placeholders `{{name}}` so `zond run` substitutes them from
102
+ * `.env.yaml`. This avoids the short-circuit where every probe targeting a
103
+ * nested path resolved `{org}=nonexistent-zzzzz` and got 404 before the
104
+ * leaf validator ever fired. Set to false to keep the legacy behaviour
105
+ * (synthetic-by-type for every param).
106
+ */
107
+ useRealParents?: boolean;
108
+ }
109
+
110
+ export interface ProbeResult {
111
+ suites: RawSuite[];
112
+ /** Number of endpoints that received probes. */
113
+ probedEndpoints: number;
114
+ /** Endpoints we skipped (no body & no UUID path params). */
115
+ skippedEndpoints: number;
116
+ /** Total generated probe steps. */
117
+ totalProbes: number;
118
+ /**
119
+ * Generation-time warnings — typically about mutating endpoints whose
120
+ * probes might leak resources because the spec defines no DELETE
121
+ * counterpart. CLI surfaces these to the user.
122
+ */
123
+ warnings: string[];
124
+ }
125
+
126
+ // ──────────────────────────────────────────────
127
+ // Helpers
128
+ // ──────────────────────────────────────────────
129
+
130
+ function findUuidPathParams(ep: EndpointInfo): OpenAPIV3.ParameterObject[] {
131
+ return ep.parameters.filter((p) => {
132
+ if (p.in !== "path") return false;
133
+ const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
134
+ if (!schema) return false;
135
+ if (schema.format === "uuid") return true;
136
+ // also probe path params named like *_id / *_uuid
137
+ const lower = p.name.toLowerCase();
138
+ return lower === "id" || lower.endsWith("_id") || lower === "uuid";
139
+ });
140
+ }
141
+
142
+ /** Walk schema and collect required-field paths up to depth 1 with their schema. */
143
+ function collectRequiredFields(
144
+ schema: OpenAPIV3.SchemaObject | undefined,
145
+ ): Array<{ name: string; schema: OpenAPIV3.SchemaObject }> {
146
+ if (!schema || !schema.properties) return [];
147
+ const required = new Set(schema.required ?? []);
148
+ const out: Array<{ name: string; schema: OpenAPIV3.SchemaObject }> = [];
149
+ for (const [name, propSchema] of Object.entries(schema.properties)) {
150
+ if (required.has(name)) {
151
+ out.push({ name, schema: propSchema as OpenAPIV3.SchemaObject });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /** Walk schema (depth 1) and collect all properties with their schema. */
158
+ function collectAllProps(
159
+ schema: OpenAPIV3.SchemaObject | undefined,
160
+ ): Array<{ name: string; schema: OpenAPIV3.SchemaObject; required: boolean }> {
161
+ if (!schema || !schema.properties) return [];
162
+ const required = new Set(schema.required ?? []);
163
+ return Object.entries(schema.properties).map(([name, s]) => ({
164
+ name,
165
+ schema: s as OpenAPIV3.SchemaObject,
166
+ required: required.has(name),
167
+ }));
168
+ }
169
+
170
+ // ──────────────────────────────────────────────
171
+ // Probe generators
172
+ // ──────────────────────────────────────────────
173
+
174
+ /**
175
+ * Build a step that targets `endpoint`, but with an arbitrary body override.
176
+ * Authentication and required path params are populated with valid placeholders
177
+ * so the request reaches the body-validation layer.
178
+ *
179
+ * `pathOverride` (if provided) is treated as already-final — no further
180
+ * placeholder conversion is applied. Otherwise the path is rendered via
181
+ * `renderPath` with no attack target (so all path-params become runtime
182
+ * placeholders / synthetic sentinels depending on `useRealParents`).
183
+ */
184
+ function buildStep(
185
+ ep: EndpointInfo,
186
+ schemes: SecuritySchemeInfo[],
187
+ opts: {
188
+ name: string;
189
+ json?: unknown;
190
+ pathOverride?: string;
191
+ expectStatusOk?: number[];
192
+ useRealParents: boolean;
193
+ },
194
+ ): RawStep {
195
+ const method = ep.method.toUpperCase();
196
+ const path = opts.pathOverride ?? renderPath(ep, null, { useRealParents: opts.useRealParents });
197
+ const headers = getAuthHeaders(ep, schemes);
198
+
199
+ const expectedStatus = opts.expectStatusOk ?? ACCEPTABLE_4XX;
200
+ const responseBranch = Array.isArray(expectedStatus) ? expectedStatus.map(String).join("|") : String(expectedStatus);
201
+ const step: RawStep = {
202
+ name: opts.name,
203
+ source: {
204
+ generator: "negative-probe",
205
+ endpoint: `${method} ${ep.path}`,
206
+ response_branch: responseBranch,
207
+ },
208
+ [method]: path,
209
+ expect: {
210
+ status: expectedStatus,
211
+ },
212
+ };
213
+ if (headers) step.headers = headers;
214
+ if (opts.json !== undefined) (step as any).json = opts.json;
215
+ return step;
216
+ }
217
+
218
+ function probeEmptyBody(
219
+ ep: EndpointInfo,
220
+ schemes: SecuritySchemeInfo[],
221
+ useRealParents: boolean,
222
+ ): RawStep | null {
223
+ if (!hasJsonBody(ep)) return null;
224
+ const required = collectRequiredFields(ep.requestBodySchema);
225
+ // Only meaningful when there *is* required data — otherwise {} is valid.
226
+ if (required.length === 0) return null;
227
+ return buildStep(ep, schemes, {
228
+ name: "empty body — must reject (no 5xx)",
229
+ json: {},
230
+ useRealParents,
231
+ });
232
+ }
233
+
234
+ function probeMissingRequired(
235
+ ep: EndpointInfo,
236
+ schemes: SecuritySchemeInfo[],
237
+ budget: number,
238
+ useRealParents: boolean,
239
+ ): RawStep[] {
240
+ if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
241
+ const required = collectRequiredFields(ep.requestBodySchema);
242
+ if (required.length === 0) return [];
243
+
244
+ // Build a baseline valid object, then drop one required field at a time.
245
+ const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
246
+ if (typeof baseline !== "object" || baseline === null) return [];
247
+
248
+ const out: RawStep[] = [];
249
+ for (const field of required) {
250
+ if (out.length >= budget) break;
251
+ const variant = { ...baseline };
252
+ delete variant[field.name];
253
+ out.push(
254
+ buildStep(ep, schemes, {
255
+ name: `missing required field "${field.name}" — must reject (no 5xx)`,
256
+ json: variant,
257
+ useRealParents,
258
+ }),
259
+ );
260
+ }
261
+ return out;
262
+ }
263
+
264
+ function probeBoundaryString(
265
+ ep: EndpointInfo,
266
+ schemes: SecuritySchemeInfo[],
267
+ budget: number,
268
+ useRealParents: boolean,
269
+ ): RawStep[] {
270
+ if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
271
+ const props = collectAllProps(ep.requestBodySchema).filter(
272
+ (p) => p.schema.type === "string",
273
+ );
274
+ if (props.length === 0) return [];
275
+
276
+ const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
277
+ if (typeof baseline !== "object" || baseline === null) return [];
278
+
279
+ const out: RawStep[] = [];
280
+ // Only probe the first N string fields to stay within budget
281
+ for (const field of props.slice(0, Math.max(1, Math.floor(budget / 3)))) {
282
+ if (out.length + 3 > budget) break;
283
+ out.push(
284
+ buildStep(ep, schemes, {
285
+ name: `${field.name}: empty string — must reject (no 5xx)`,
286
+ json: { ...baseline, [field.name]: "" },
287
+ useRealParents,
288
+ }),
289
+ buildStep(ep, schemes, {
290
+ name: `${field.name}: 10000-char string — must reject or accept (no 5xx)`,
291
+ json: { ...baseline, [field.name]: LONG_STRING },
292
+ useRealParents,
293
+ }),
294
+ buildStep(ep, schemes, {
295
+ name: `${field.name}: unicode/emoji/RTL — must not 5xx`,
296
+ json: { ...baseline, [field.name]: UNICODE_MIX },
297
+ useRealParents,
298
+ }),
299
+ );
300
+ }
301
+ return out;
302
+ }
303
+
304
+ function probeTypeConfusion(
305
+ ep: EndpointInfo,
306
+ schemes: SecuritySchemeInfo[],
307
+ budget: number,
308
+ useRealParents: boolean,
309
+ ): RawStep[] {
310
+ if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
311
+ const props = collectAllProps(ep.requestBodySchema);
312
+ if (props.length === 0) return [];
313
+
314
+ const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
315
+ if (typeof baseline !== "object" || baseline === null) return [];
316
+
317
+ const out: RawStep[] = [];
318
+ for (const field of props) {
319
+ if (out.length >= budget) break;
320
+ const wrongValue = pickWrongType(field.schema);
321
+ if (wrongValue === undefined) continue;
322
+ out.push(
323
+ buildStep(ep, schemes, {
324
+ name: `${field.name}: wrong type (${describeType(field.schema)} → ${typeof wrongValue}) — must reject (no 5xx)`,
325
+ json: { ...baseline, [field.name]: wrongValue },
326
+ useRealParents,
327
+ }),
328
+ );
329
+ }
330
+ return out;
331
+ }
332
+
333
+ function probeInvalidFormat(
334
+ ep: EndpointInfo,
335
+ schemes: SecuritySchemeInfo[],
336
+ budget: number,
337
+ useRealParents: boolean,
338
+ ): RawStep[] {
339
+ if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
340
+ const props = collectAllProps(ep.requestBodySchema);
341
+ const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
342
+ if (typeof baseline !== "object" || baseline === null) return [];
343
+
344
+ const out: RawStep[] = [];
345
+ for (const field of props) {
346
+ if (out.length >= budget) break;
347
+ const fmt = field.schema.format;
348
+ let badValue: string | undefined;
349
+ if (fmt === "email") badValue = INVALID_EMAIL_VALUES[0];
350
+ else if (fmt === "uri" || fmt === "url") badValue = INVALID_URI_VALUES[0];
351
+ else if (fmt === "date-time") badValue = INVALID_DATETIME_VALUES[0];
352
+ else if (fmt === "uuid") badValue = INVALID_UUID_VALUES[0];
353
+ if (badValue === undefined) continue;
354
+ out.push(
355
+ buildStep(ep, schemes, {
356
+ name: `${field.name}: invalid ${fmt} (${JSON.stringify(badValue)}) — must reject (no 5xx)`,
357
+ json: { ...baseline, [field.name]: badValue },
358
+ useRealParents,
359
+ }),
360
+ );
361
+ }
362
+ return out;
363
+ }
364
+
365
+ function probeInvalidEnum(
366
+ ep: EndpointInfo,
367
+ schemes: SecuritySchemeInfo[],
368
+ budget: number,
369
+ useRealParents: boolean,
370
+ ): RawStep[] {
371
+ if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
372
+ const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
373
+ if (typeof baseline !== "object" || baseline === null) return [];
374
+
375
+ const out: RawStep[] = [];
376
+ // Walk depth 1 for plain enum strings
377
+ for (const field of collectAllProps(ep.requestBodySchema)) {
378
+ if (out.length >= budget) break;
379
+ if (Array.isArray(field.schema.enum) && field.schema.enum.length > 0) {
380
+ out.push(
381
+ buildStep(ep, schemes, {
382
+ name: `${field.name}: unknown enum value "zond_invalid_value" — must reject (no 5xx)`,
383
+ json: { ...baseline, [field.name]: "zond_invalid_value" },
384
+ useRealParents,
385
+ }),
386
+ );
387
+ }
388
+ // enum-of-strings inside an array (e.g. webhooks.events: [enum])
389
+ if (field.schema.type === "array" && field.schema.items) {
390
+ const items = field.schema.items as OpenAPIV3.SchemaObject;
391
+ const enumLike = Array.isArray(items.enum) && items.enum.length > 0;
392
+ const isStringArray = items.type === "string";
393
+ if (enumLike || isStringArray) {
394
+ // even when no enum is declared, names like "events"/"types"/"channels"
395
+ // strongly imply a backing whitelist — bug #05B
396
+ out.push(
397
+ buildStep(ep, schemes, {
398
+ name: `${field.name}: array with unknown value ["zond.nonexistent.event"] — must reject (no 5xx)`,
399
+ json: { ...baseline, [field.name]: ["zond.nonexistent.event"] },
400
+ useRealParents,
401
+ }),
402
+ );
403
+ }
404
+ }
405
+ }
406
+ return out;
407
+ }
408
+
409
+ function probeInvalidPathId(
410
+ ep: EndpointInfo,
411
+ schemes: SecuritySchemeInfo[],
412
+ budget: number,
413
+ useRealParents: boolean,
414
+ ): RawStep[] {
415
+ const params = findUuidPathParams(ep);
416
+ if (params.length === 0) return [];
417
+ // Skip POST /resource (no path id) — covered by body probes
418
+ const out: RawStep[] = [];
419
+ for (const param of params) {
420
+ for (const bad of INVALID_UUID_VALUES) {
421
+ if (out.length >= budget) break;
422
+ const path = renderPath(ep, { name: param.name, value: bad }, { useRealParents });
423
+ out.push(
424
+ buildStep(ep, schemes, {
425
+ name: `path param ${param.name}=${JSON.stringify(bad)} — must reject (no 5xx)`,
426
+ pathOverride: path,
427
+ useRealParents,
428
+ }),
429
+ );
430
+ }
431
+ }
432
+ return out;
433
+ }
434
+
435
+ /** TASK-67: numeric coercion probes for integer/number params (query + path).
436
+ * Round-2 found `GET /emails?limit=1.5` → 500 — the bug class probe-validation
437
+ * exists for. T49 covered numeric type-confusion in body, this extends to
438
+ * query/path. */
439
+ const NUMERIC_BAD_VALUES: Array<{ value: string; label: string }> = [
440
+ { value: "1.5", label: "float on integer" },
441
+ { value: "-1", label: "negative" },
442
+ { value: "0", label: "zero" },
443
+ { value: "abc", label: "non-numeric" },
444
+ { value: "", label: "empty string" },
445
+ // null on a query param means literal "null" string — most parsers treat it as bad input
446
+ { value: "null", label: "literal null" },
447
+ // Number.MAX_SAFE_INTEGER + 1 = 9007199254740992
448
+ { value: "9007199254740992", label: "MAX_SAFE_INTEGER+1" },
449
+ ];
450
+
451
+ function isNumericSchema(schema: OpenAPIV3.SchemaObject | undefined): boolean {
452
+ return schema?.type === "integer" || schema?.type === "number";
453
+ }
454
+
455
+ function probeNumericQueryParams(
456
+ ep: EndpointInfo,
457
+ schemes: SecuritySchemeInfo[],
458
+ budget: number,
459
+ useRealParents: boolean,
460
+ ): RawStep[] {
461
+ const numericQuery = ep.parameters.filter(
462
+ (p) => p.in === "query" && isNumericSchema(p.schema as OpenAPIV3.SchemaObject | undefined),
463
+ );
464
+ if (numericQuery.length === 0) return [];
465
+
466
+ const out: RawStep[] = [];
467
+ for (const param of numericQuery) {
468
+ for (const { value, label } of NUMERIC_BAD_VALUES) {
469
+ if (out.length >= budget) break;
470
+ const basePath = renderPath(ep, null, { useRealParents });
471
+ const sep = basePath.includes("?") ? "&" : "?";
472
+ const pathWithQuery = `${basePath}${sep}${param.name}=${encodeURIComponent(value)}`;
473
+ out.push(
474
+ buildStep(ep, schemes, {
475
+ name: `query ${param.name}=${JSON.stringify(value)} (${label}) — must reject (no 5xx)`,
476
+ pathOverride: pathWithQuery,
477
+ useRealParents,
478
+ }),
479
+ );
480
+ }
481
+ }
482
+ return out;
483
+ }
484
+
485
+ function probeNumericPathParams(
486
+ ep: EndpointInfo,
487
+ schemes: SecuritySchemeInfo[],
488
+ budget: number,
489
+ useRealParents: boolean,
490
+ ): RawStep[] {
491
+ const numericPath = ep.parameters.filter(
492
+ (p) => p.in === "path" && isNumericSchema(p.schema as OpenAPIV3.SchemaObject | undefined),
493
+ );
494
+ if (numericPath.length === 0) return [];
495
+
496
+ const out: RawStep[] = [];
497
+ for (const param of numericPath) {
498
+ for (const { value, label } of NUMERIC_BAD_VALUES) {
499
+ if (value === "") continue; // empty path segment yields a different endpoint
500
+ if (out.length >= budget) break;
501
+ const overriddenPath = renderPath(ep, { name: param.name, value }, { useRealParents });
502
+ out.push(
503
+ buildStep(ep, schemes, {
504
+ name: `path param ${param.name}=${JSON.stringify(value)} (${label}) — must reject (no 5xx)`,
505
+ pathOverride: overriddenPath,
506
+ useRealParents,
507
+ }),
508
+ );
509
+ }
510
+ }
511
+ return out;
512
+ }
513
+
514
+ // ──────────────────────────────────────────────
515
+ // Type-confusion helpers
516
+ // ──────────────────────────────────────────────
517
+
518
+ function pickWrongType(schema: OpenAPIV3.SchemaObject): unknown | undefined {
519
+ switch (schema.type) {
520
+ case "string":
521
+ return 12345; // number where string expected
522
+ case "integer":
523
+ case "number":
524
+ return "five"; // string where number expected
525
+ case "boolean":
526
+ return "true"; // string where boolean expected
527
+ case "array":
528
+ return { not: "an-array" }; // object where array expected
529
+ case "object":
530
+ return ["not", "an", "object"]; // array where object expected
531
+ default:
532
+ return undefined;
533
+ }
534
+ }
535
+
536
+ function describeType(schema: OpenAPIV3.SchemaObject): string {
537
+ if (schema.format) return `${schema.type ?? "any"}/${schema.format}`;
538
+ return schema.type ?? "any";
539
+ }
540
+
541
+ /** Build a cleanup-DELETE step for a single mutating probe. The capture var
542
+ * must come from the paired probe step. If the probe didn't capture (e.g.
543
+ * the API correctly returned 4xx and no resource was created), the runner
544
+ * skips this step via the standard "missing capture" path — exactly the
545
+ * semantics we want. */
546
+ function buildCleanupStep(
547
+ deleteEp: EndpointInfo,
548
+ schemes: SecuritySchemeInfo[],
549
+ captureVar: string,
550
+ probeStepName: string,
551
+ ): RawStep {
552
+ // Replace the DELETE's path-id placeholder with our captured var.
553
+ const path = convertPath(deleteEp.path).replace(/\{\{[^}]+\}\}/, `{{${captureVar}}}`);
554
+ const headers = getAuthHeaders(deleteEp, schemes);
555
+ const step: RawStep = {
556
+ name: `cleanup leaked resource from "${probeStepName}"`,
557
+ source: {
558
+ generator: "negative-probe-cleanup",
559
+ endpoint: `DELETE ${deleteEp.path}`,
560
+ },
561
+ always: true,
562
+ DELETE: path,
563
+ expect: {
564
+ status: [200, 202, 204, 404],
565
+ },
566
+ };
567
+ if (headers) step.headers = headers;
568
+ return step;
569
+ }
570
+
571
+ // ──────────────────────────────────────────────
572
+ // Public API
573
+ // ──────────────────────────────────────────────
574
+
575
+ export function generateNegativeProbes(opts: ProbeOptions): ProbeResult {
576
+ const { endpoints, securitySchemes } = opts;
577
+ const cap = opts.maxProbesPerEndpoint ?? 50;
578
+ const noCleanup = opts.noCleanup === true;
579
+ // TASK-135: default ON. Non-attacked path-params are emitted as runtime
580
+ // placeholders `{{name}}` and resolved from `.env.yaml` by `zond run`.
581
+ const useRealParents = opts.useRealParents !== false;
582
+
583
+ const suites: RawSuite[] = [];
584
+ const warnings: string[] = [];
585
+ let probedEndpoints = 0;
586
+ let skippedEndpoints = 0;
587
+ let totalProbes = 0;
588
+
589
+ for (const ep of endpoints) {
590
+ if (ep.deprecated) continue;
591
+
592
+ const steps: RawStep[] = [];
593
+ const remaining = () => Math.max(0, cap - steps.length);
594
+
595
+ // 1. Path-id probes (cheap, deterministic)
596
+ steps.push(...probeInvalidPathId(ep, securitySchemes, remaining(), useRealParents));
597
+
598
+ // 1b. Numeric query / path coercion probes (T67) — float-on-integer,
599
+ // negative, non-numeric, etc. Catches `GET /x?limit=1.5` → 500.
600
+ const numericQueryProbes = probeNumericQueryParams(ep, securitySchemes, remaining(), useRealParents);
601
+ steps.push(...numericQueryProbes);
602
+ const numericPathProbes = probeNumericPathParams(ep, securitySchemes, remaining(), useRealParents);
603
+ steps.push(...numericPathProbes);
604
+ const hasNumericCoercion = numericQueryProbes.length + numericPathProbes.length > 0;
605
+
606
+ // 2. Body probes (only for body-bearing methods)
607
+ const empty = probeEmptyBody(ep, securitySchemes, useRealParents);
608
+ if (empty && steps.length < cap) steps.push(empty);
609
+
610
+ steps.push(...probeMissingRequired(ep, securitySchemes, remaining(), useRealParents));
611
+ steps.push(...probeTypeConfusion(ep, securitySchemes, remaining(), useRealParents));
612
+ steps.push(...probeInvalidFormat(ep, securitySchemes, remaining(), useRealParents));
613
+ steps.push(...probeBoundaryString(ep, securitySchemes, remaining(), useRealParents));
614
+ steps.push(...probeInvalidEnum(ep, securitySchemes, remaining(), useRealParents));
615
+
616
+ if (steps.length === 0) {
617
+ skippedEndpoints++;
618
+ continue;
619
+ }
620
+
621
+ // T79: Cleanup for mutating probes. If a probe accidentally returns 2xx
622
+ // (the bug class probe-validation hunts for), the resource sticks around
623
+ // unless we follow up with a DELETE. We pair each probe step with a
624
+ // cleanup-DELETE marked `always: true`; the runner skips the DELETE
625
+ // automatically when no id was captured (i.e. the probe correctly got 4xx).
626
+ const cleanupSteps: RawStep[] = [];
627
+ if (isMutating(ep.method) && !noCleanup) {
628
+ const deleteEp = findDeleteCounterpart(ep, endpoints);
629
+ if (deleteEp) {
630
+ const idField = captureFieldFor(ep);
631
+ for (let i = 0; i < steps.length; i++) {
632
+ const probeStep = steps[i]!;
633
+ const captureVar = `leaked_id_${i}`;
634
+ const probeExpect = probeStep.expect as { body?: Record<string, unknown> };
635
+ if (!probeExpect.body) probeExpect.body = {};
636
+ // capture-only rule: doesn't add an assertion, just extracts the id
637
+ // when the response body has one. extractCaptures is a no-op when
638
+ // the field is absent (the typical 4xx case).
639
+ probeExpect.body[idField] = { capture: captureVar };
640
+ cleanupSteps.push(buildCleanupStep(deleteEp, securitySchemes, captureVar, probeStep.name));
641
+ }
642
+ } else {
643
+ warnings.push(
644
+ `${ep.method.toUpperCase()} ${ep.path}: probe-validation may create resources but spec defines no DELETE counterpart — manual cleanup required if any probe unexpectedly returns 2xx`,
645
+ );
646
+ }
647
+ }
648
+ if (cleanupSteps.length > 0) {
649
+ steps.push(...cleanupSteps);
650
+ }
651
+
652
+ probedEndpoints++;
653
+ totalProbes += steps.length;
654
+
655
+ // Hoist auth headers to suite level — every probe in this suite hits the
656
+ // same endpoint, so per-step headers are pure duplication. Dropping them
657
+ // here keeps generated YAML small and makes suite-level overrides
658
+ // (e.g. switching auth tokens) work as expected.
659
+ const suiteHeaders = getAuthHeaders(ep, securitySchemes);
660
+ if (suiteHeaders) {
661
+ for (const step of steps) {
662
+ if (step.headers && headersEqual(step.headers as Record<string, string>, suiteHeaders)) {
663
+ delete (step as { headers?: unknown }).headers;
664
+ }
665
+ }
666
+ }
667
+
668
+ const stem = endpointStem(ep);
669
+ const suite: RawSuite = {
670
+ name: `probe ${ep.method} ${ep.path}`,
671
+ tags: [
672
+ "probe-validation",
673
+ "negative-input",
674
+ "no-5xx",
675
+ ...(hasNumericCoercion ? ["query-coercion"] : []),
676
+ ],
677
+ source: {
678
+ type: "probe-suite",
679
+ generator: "negative-probe",
680
+ endpoint: `${ep.method.toUpperCase()} ${ep.path}`,
681
+ },
682
+ fileStem: `probe-${stem}`,
683
+ base_url: "{{base_url}}",
684
+ ...(suiteHeaders ? { headers: suiteHeaders } : {}),
685
+ tests: steps,
686
+ };
687
+ suites.push(suite);
688
+ }
689
+
690
+ return { suites, probedEndpoints, skippedEndpoints, totalProbes, warnings };
691
+ }