@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
@@ -6,10 +6,16 @@
6
6
  * is simple: any client-supplied invalid input MUST produce a 4xx, never a 5xx.
7
7
  *
8
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]).
9
+ * "no 5xx" response (status in [400, 401, 403, 404, 405, 409, 415, 422, 429]).
10
10
  * If the API returns 500/502/503 — the test fails and the runner logs it as
11
11
  * a bug candidate via the regular reporter / `zond db diagnose` flow.
12
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
+ *
13
19
  * The probes are deterministic — same spec → same suites — so the generated
14
20
  * YAML can be committed as a regression test.
15
21
  */
@@ -17,6 +23,17 @@ import type { OpenAPIV3 } from "openapi-types";
17
23
  import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
18
24
  import type { RawSuite, RawStep } from "../generator/serializer.ts";
19
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";
20
37
 
21
38
  // ──────────────────────────────────────────────
22
39
  // Constants
@@ -24,8 +41,9 @@ import { generateFromSchema } from "../generator/data-factory.ts";
24
41
 
25
42
  /** Statuses we consider an *acceptable* response to invalid input. Anything
26
43
  * outside this set (notably 5xx, but also 200/201 which would mean the API
27
- * silently accepted the bad input) is a probe failure. */
28
- const ACCEPTABLE_4XX = [400, 401, 403, 404, 405, 409, 415, 422];
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];
29
47
 
30
48
  /** Long string for boundary probes — 10_000 chars. */
31
49
  const LONG_STRING = "a".repeat(10_000);
@@ -34,12 +52,13 @@ const LONG_STRING = "a".repeat(10_000);
34
52
  const UNICODE_MIX = "Mix🌐مرحبا\u200B";
35
53
 
36
54
  /** Sentinel non-UUID inputs for path/UUID probes. */
37
- const INVALID_UUID_VALUES = [
55
+ export const INVALID_UUID_SENTINELS = [
38
56
  "not-a-uuid",
39
57
  "12345",
40
58
  "00000000",
41
59
  "../../etc/passwd",
42
- ];
60
+ ] as const;
61
+ const INVALID_UUID_VALUES = INVALID_UUID_SENTINELS;
43
62
 
44
63
  /** Sentinel invalid emails. */
45
64
  const INVALID_EMAIL_VALUES = [
@@ -77,6 +96,15 @@ export interface ProbeOptions {
77
96
  * (staging dump-and-reset) where cleanup is handled out of band.
78
97
  */
79
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;
80
108
  }
81
109
 
82
110
  export interface ProbeResult {
@@ -99,66 +127,6 @@ export interface ProbeResult {
99
127
  // Helpers
100
128
  // ──────────────────────────────────────────────
101
129
 
102
- function convertPath(path: string): string {
103
- return path.replace(/\{([^}]+)\}/g, "{{$1}}");
104
- }
105
-
106
- function slugify(s: string): string {
107
- return s
108
- .toLowerCase()
109
- .replace(/[^a-z0-9]+/g, "-")
110
- .replace(/^-|-$/g, "");
111
- }
112
-
113
- function endpointStem(ep: EndpointInfo): string {
114
- const path = ep.path
115
- .replace(/\{[^}]+\}/g, "by-id")
116
- .replace(/^\//, "")
117
- .replace(/\//g, "-");
118
- return slugify(`${ep.method.toLowerCase()}-${path}`);
119
- }
120
-
121
- function getAuthHeaders(
122
- ep: EndpointInfo,
123
- schemes: SecuritySchemeInfo[],
124
- ): Record<string, string> | undefined {
125
- if (ep.security.length === 0) return undefined;
126
- for (const secName of ep.security) {
127
- const scheme = schemes.find((s) => s.name === secName);
128
- if (!scheme) continue;
129
- if (scheme.type === "http") {
130
- if (scheme.scheme === "bearer" || !scheme.scheme) {
131
- return { Authorization: "Bearer {{auth_token}}" };
132
- }
133
- if (scheme.scheme === "basic") {
134
- return { Authorization: "Basic {{auth_token}}" };
135
- }
136
- }
137
- if (scheme.type === "apiKey" && scheme.in === "header" && scheme.apiKeyName) {
138
- if (scheme.apiKeyName === "Authorization") {
139
- return { Authorization: "Bearer {{auth_token}}" };
140
- }
141
- return { [scheme.apiKeyName]: "{{api_key}}" };
142
- }
143
- }
144
- return undefined;
145
- }
146
-
147
- /** Path with placeholders replaced by valid-but-nonexistent IDs (for body probes
148
- * on PUT/PATCH/DELETE — we don't want path validation to mask body errors). */
149
- function pathWithPlaceholders(ep: EndpointInfo, badId: string): string {
150
- return ep.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
151
- const param = ep.parameters.find((p) => p.name === name && p.in === "path");
152
- const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
153
- if (badId === "valid-shape") {
154
- if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
155
- if (schema?.type === "integer" || schema?.type === "number") return "999999999";
156
- return "nonexistent-zzzzz";
157
- }
158
- return badId;
159
- });
160
- }
161
-
162
130
  function findUuidPathParams(ep: EndpointInfo): OpenAPIV3.ParameterObject[] {
163
131
  return ep.parameters.filter((p) => {
164
132
  if (p.in !== "path") return false;
@@ -207,6 +175,11 @@ function collectAllProps(
207
175
  * Build a step that targets `endpoint`, but with an arbitrary body override.
208
176
  * Authentication and required path params are populated with valid placeholders
209
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`).
210
183
  */
211
184
  function buildStep(
212
185
  ep: EndpointInfo,
@@ -216,17 +189,25 @@ function buildStep(
216
189
  json?: unknown;
217
190
  pathOverride?: string;
218
191
  expectStatusOk?: number[];
192
+ useRealParents: boolean;
219
193
  },
220
194
  ): RawStep {
221
195
  const method = ep.method.toUpperCase();
222
- const path = opts.pathOverride ?? pathWithPlaceholders(ep, "valid-shape");
196
+ const path = opts.pathOverride ?? renderPath(ep, null, { useRealParents: opts.useRealParents });
223
197
  const headers = getAuthHeaders(ep, schemes);
224
198
 
199
+ const expectedStatus = opts.expectStatusOk ?? ACCEPTABLE_4XX;
200
+ const responseBranch = Array.isArray(expectedStatus) ? expectedStatus.map(String).join("|") : String(expectedStatus);
225
201
  const step: RawStep = {
226
202
  name: opts.name,
227
- [method]: convertPath(path),
203
+ source: {
204
+ generator: "negative-probe",
205
+ endpoint: `${method} ${ep.path}`,
206
+ response_branch: responseBranch,
207
+ },
208
+ [method]: path,
228
209
  expect: {
229
- status: opts.expectStatusOk ?? ACCEPTABLE_4XX,
210
+ status: expectedStatus,
230
211
  },
231
212
  };
232
213
  if (headers) step.headers = headers;
@@ -234,7 +215,11 @@ function buildStep(
234
215
  return step;
235
216
  }
236
217
 
237
- function probeEmptyBody(ep: EndpointInfo, schemes: SecuritySchemeInfo[]): RawStep | null {
218
+ function probeEmptyBody(
219
+ ep: EndpointInfo,
220
+ schemes: SecuritySchemeInfo[],
221
+ useRealParents: boolean,
222
+ ): RawStep | null {
238
223
  if (!hasJsonBody(ep)) return null;
239
224
  const required = collectRequiredFields(ep.requestBodySchema);
240
225
  // Only meaningful when there *is* required data — otherwise {} is valid.
@@ -242,6 +227,7 @@ function probeEmptyBody(ep: EndpointInfo, schemes: SecuritySchemeInfo[]): RawSte
242
227
  return buildStep(ep, schemes, {
243
228
  name: "empty body — must reject (no 5xx)",
244
229
  json: {},
230
+ useRealParents,
245
231
  });
246
232
  }
247
233
 
@@ -249,6 +235,7 @@ function probeMissingRequired(
249
235
  ep: EndpointInfo,
250
236
  schemes: SecuritySchemeInfo[],
251
237
  budget: number,
238
+ useRealParents: boolean,
252
239
  ): RawStep[] {
253
240
  if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
254
241
  const required = collectRequiredFields(ep.requestBodySchema);
@@ -267,6 +254,7 @@ function probeMissingRequired(
267
254
  buildStep(ep, schemes, {
268
255
  name: `missing required field "${field.name}" — must reject (no 5xx)`,
269
256
  json: variant,
257
+ useRealParents,
270
258
  }),
271
259
  );
272
260
  }
@@ -277,6 +265,7 @@ function probeBoundaryString(
277
265
  ep: EndpointInfo,
278
266
  schemes: SecuritySchemeInfo[],
279
267
  budget: number,
268
+ useRealParents: boolean,
280
269
  ): RawStep[] {
281
270
  if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
282
271
  const props = collectAllProps(ep.requestBodySchema).filter(
@@ -295,14 +284,17 @@ function probeBoundaryString(
295
284
  buildStep(ep, schemes, {
296
285
  name: `${field.name}: empty string — must reject (no 5xx)`,
297
286
  json: { ...baseline, [field.name]: "" },
287
+ useRealParents,
298
288
  }),
299
289
  buildStep(ep, schemes, {
300
290
  name: `${field.name}: 10000-char string — must reject or accept (no 5xx)`,
301
291
  json: { ...baseline, [field.name]: LONG_STRING },
292
+ useRealParents,
302
293
  }),
303
294
  buildStep(ep, schemes, {
304
295
  name: `${field.name}: unicode/emoji/RTL — must not 5xx`,
305
296
  json: { ...baseline, [field.name]: UNICODE_MIX },
297
+ useRealParents,
306
298
  }),
307
299
  );
308
300
  }
@@ -313,6 +305,7 @@ function probeTypeConfusion(
313
305
  ep: EndpointInfo,
314
306
  schemes: SecuritySchemeInfo[],
315
307
  budget: number,
308
+ useRealParents: boolean,
316
309
  ): RawStep[] {
317
310
  if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
318
311
  const props = collectAllProps(ep.requestBodySchema);
@@ -330,6 +323,7 @@ function probeTypeConfusion(
330
323
  buildStep(ep, schemes, {
331
324
  name: `${field.name}: wrong type (${describeType(field.schema)} → ${typeof wrongValue}) — must reject (no 5xx)`,
332
325
  json: { ...baseline, [field.name]: wrongValue },
326
+ useRealParents,
333
327
  }),
334
328
  );
335
329
  }
@@ -340,6 +334,7 @@ function probeInvalidFormat(
340
334
  ep: EndpointInfo,
341
335
  schemes: SecuritySchemeInfo[],
342
336
  budget: number,
337
+ useRealParents: boolean,
343
338
  ): RawStep[] {
344
339
  if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
345
340
  const props = collectAllProps(ep.requestBodySchema);
@@ -360,6 +355,7 @@ function probeInvalidFormat(
360
355
  buildStep(ep, schemes, {
361
356
  name: `${field.name}: invalid ${fmt} (${JSON.stringify(badValue)}) — must reject (no 5xx)`,
362
357
  json: { ...baseline, [field.name]: badValue },
358
+ useRealParents,
363
359
  }),
364
360
  );
365
361
  }
@@ -370,6 +366,7 @@ function probeInvalidEnum(
370
366
  ep: EndpointInfo,
371
367
  schemes: SecuritySchemeInfo[],
372
368
  budget: number,
369
+ useRealParents: boolean,
373
370
  ): RawStep[] {
374
371
  if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
375
372
  const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
@@ -384,6 +381,7 @@ function probeInvalidEnum(
384
381
  buildStep(ep, schemes, {
385
382
  name: `${field.name}: unknown enum value "zond_invalid_value" — must reject (no 5xx)`,
386
383
  json: { ...baseline, [field.name]: "zond_invalid_value" },
384
+ useRealParents,
387
385
  }),
388
386
  );
389
387
  }
@@ -399,6 +397,7 @@ function probeInvalidEnum(
399
397
  buildStep(ep, schemes, {
400
398
  name: `${field.name}: array with unknown value ["zond.nonexistent.event"] — must reject (no 5xx)`,
401
399
  json: { ...baseline, [field.name]: ["zond.nonexistent.event"] },
400
+ useRealParents,
402
401
  }),
403
402
  );
404
403
  }
@@ -411,6 +410,7 @@ function probeInvalidPathId(
411
410
  ep: EndpointInfo,
412
411
  schemes: SecuritySchemeInfo[],
413
412
  budget: number,
413
+ useRealParents: boolean,
414
414
  ): RawStep[] {
415
415
  const params = findUuidPathParams(ep);
416
416
  if (params.length === 0) return [];
@@ -419,18 +419,91 @@ function probeInvalidPathId(
419
419
  for (const param of params) {
420
420
  for (const bad of INVALID_UUID_VALUES) {
421
421
  if (out.length >= budget) break;
422
- const path = ep.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
423
- if (name === param.name) return bad;
424
- const other = ep.parameters.find((p) => p.name === name && p.in === "path");
425
- const schema = other?.schema as OpenAPIV3.SchemaObject | undefined;
426
- if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
427
- if (schema?.type === "integer" || schema?.type === "number") return "999999999";
428
- return "nonexistent-zzzzz";
429
- });
422
+ const path = renderPath(ep, { name: param.name, value: bad }, { useRealParents });
430
423
  out.push(
431
424
  buildStep(ep, schemes, {
432
425
  name: `path param ${param.name}=${JSON.stringify(bad)} — must reject (no 5xx)`,
433
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,
434
507
  }),
435
508
  );
436
509
  }
@@ -465,62 +538,6 @@ function describeType(schema: OpenAPIV3.SchemaObject): string {
465
538
  return schema.type ?? "any";
466
539
  }
467
540
 
468
- function hasJsonBody(ep: EndpointInfo): boolean {
469
- return (
470
- ep.method !== "GET" &&
471
- ep.method !== "DELETE" &&
472
- ep.requestBodyContentType === "application/json" &&
473
- ep.requestBodySchema !== undefined
474
- );
475
- }
476
-
477
- function escapeRegex(s: string): string {
478
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
479
- }
480
-
481
- function isMutating(method: string): boolean {
482
- const m = method.toUpperCase();
483
- return m === "POST" || m === "PUT" || m === "PATCH";
484
- }
485
-
486
- /**
487
- * Find the DELETE endpoint that owns resources created by `ep`:
488
- * - POST /collection → DELETE /collection/{id}
489
- * - PUT /collection/{id} → DELETE /collection/{id} (same path)
490
- * - PATCH /collection/{id} → DELETE /collection/{id} (same path)
491
- */
492
- function findDeleteCounterpart(
493
- ep: EndpointInfo,
494
- all: EndpointInfo[],
495
- ): EndpointInfo | undefined {
496
- const m = ep.method.toUpperCase();
497
- if (m === "POST") {
498
- const re = new RegExp(`^${escapeRegex(ep.path)}/\\{[^}]+\\}$`);
499
- return all.find(e => e.method.toUpperCase() === "DELETE" && !e.deprecated && re.test(e.path));
500
- }
501
- if (m === "PUT" || m === "PATCH") {
502
- return all.find(e => e.method.toUpperCase() === "DELETE" && !e.deprecated && e.path === ep.path);
503
- }
504
- return undefined;
505
- }
506
-
507
- /**
508
- * Pick the response field that holds the new resource's id. Defaults to "id";
509
- * falls back to the first integer / uuid property when no `id` field exists.
510
- */
511
- function captureFieldFor(ep: EndpointInfo): string {
512
- const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300 && r.schema);
513
- const schema = success?.schema;
514
- if (schema?.properties) {
515
- if ("id" in schema.properties) return "id";
516
- for (const [name, propSchema] of Object.entries(schema.properties)) {
517
- const s = propSchema as OpenAPIV3.SchemaObject;
518
- if (s.type === "integer" || s.format === "uuid") return name;
519
- }
520
- }
521
- return "id";
522
- }
523
-
524
541
  /** Build a cleanup-DELETE step for a single mutating probe. The capture var
525
542
  * must come from the paired probe step. If the probe didn't capture (e.g.
526
543
  * the API correctly returned 4xx and no resource was created), the runner
@@ -537,6 +554,10 @@ function buildCleanupStep(
537
554
  const headers = getAuthHeaders(deleteEp, schemes);
538
555
  const step: RawStep = {
539
556
  name: `cleanup leaked resource from "${probeStepName}"`,
557
+ source: {
558
+ generator: "negative-probe-cleanup",
559
+ endpoint: `DELETE ${deleteEp.path}`,
560
+ },
540
561
  always: true,
541
562
  DELETE: path,
542
563
  expect: {
@@ -547,14 +568,6 @@ function buildCleanupStep(
547
568
  return step;
548
569
  }
549
570
 
550
- function headersEqual(a: Record<string, string>, b: Record<string, string>): boolean {
551
- const ka = Object.keys(a);
552
- const kb = Object.keys(b);
553
- if (ka.length !== kb.length) return false;
554
- for (const k of ka) if (a[k] !== b[k]) return false;
555
- return true;
556
- }
557
-
558
571
  // ──────────────────────────────────────────────
559
572
  // Public API
560
573
  // ──────────────────────────────────────────────
@@ -563,6 +576,9 @@ export function generateNegativeProbes(opts: ProbeOptions): ProbeResult {
563
576
  const { endpoints, securitySchemes } = opts;
564
577
  const cap = opts.maxProbesPerEndpoint ?? 50;
565
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;
566
582
 
567
583
  const suites: RawSuite[] = [];
568
584
  const warnings: string[] = [];
@@ -577,17 +593,25 @@ export function generateNegativeProbes(opts: ProbeOptions): ProbeResult {
577
593
  const remaining = () => Math.max(0, cap - steps.length);
578
594
 
579
595
  // 1. Path-id probes (cheap, deterministic)
580
- steps.push(...probeInvalidPathId(ep, securitySchemes, remaining()));
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;
581
605
 
582
606
  // 2. Body probes (only for body-bearing methods)
583
- const empty = probeEmptyBody(ep, securitySchemes);
607
+ const empty = probeEmptyBody(ep, securitySchemes, useRealParents);
584
608
  if (empty && steps.length < cap) steps.push(empty);
585
609
 
586
- steps.push(...probeMissingRequired(ep, securitySchemes, remaining()));
587
- steps.push(...probeTypeConfusion(ep, securitySchemes, remaining()));
588
- steps.push(...probeInvalidFormat(ep, securitySchemes, remaining()));
589
- steps.push(...probeBoundaryString(ep, securitySchemes, remaining()));
590
- steps.push(...probeInvalidEnum(ep, securitySchemes, remaining()));
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));
591
615
 
592
616
  if (steps.length === 0) {
593
617
  skippedEndpoints++;
@@ -644,7 +668,17 @@ export function generateNegativeProbes(opts: ProbeOptions): ProbeResult {
644
668
  const stem = endpointStem(ep);
645
669
  const suite: RawSuite = {
646
670
  name: `probe ${ep.method} ${ep.path}`,
647
- tags: ["probe-validation", "negative-input", "no-5xx"],
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
+ },
648
682
  fileStem: `probe-${stem}`,
649
683
  base_url: "{{base_url}}",
650
684
  ...(suiteHeaders ? { headers: suiteHeaders } : {}),