@kirrosh/zond 0.26.1 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +39 -22
  3. package/bin/zond.mjs +23 -0
  4. package/package.json +36 -23
  5. package/scripts/npm/postinstall.mjs +76 -0
  6. package/src/CLAUDE.md +0 -112
  7. package/src/bun-types.d.ts +0 -5
  8. package/src/cli/argv.ts +0 -122
  9. package/src/cli/commands/add-api.ts +0 -146
  10. package/src/cli/commands/api/annotate/idempotency.ts +0 -59
  11. package/src/cli/commands/api/annotate/index.ts +0 -880
  12. package/src/cli/commands/api/annotate/lifecycle.ts +0 -74
  13. package/src/cli/commands/api/annotate/overlay.ts +0 -206
  14. package/src/cli/commands/api/annotate/pagination.ts +0 -64
  15. package/src/cli/commands/api/annotate/prompts.ts +0 -220
  16. package/src/cli/commands/api/annotate/readback.ts +0 -58
  17. package/src/cli/commands/api/annotate/resources.ts +0 -91
  18. package/src/cli/commands/api/annotate/seed-bodies.ts +0 -61
  19. package/src/cli/commands/audit.ts +0 -786
  20. package/src/cli/commands/catalog.ts +0 -97
  21. package/src/cli/commands/check.ts +0 -361
  22. package/src/cli/commands/checks.ts +0 -1072
  23. package/src/cli/commands/ci-init.ts +0 -223
  24. package/src/cli/commands/clean.ts +0 -212
  25. package/src/cli/commands/cleanup.ts +0 -236
  26. package/src/cli/commands/completions.ts +0 -192
  27. package/src/cli/commands/coverage.ts +0 -872
  28. package/src/cli/commands/db.ts +0 -611
  29. package/src/cli/commands/describe.ts +0 -120
  30. package/src/cli/commands/discover.ts +0 -1356
  31. package/src/cli/commands/doctor.ts +0 -661
  32. package/src/cli/commands/fixtures.ts +0 -402
  33. package/src/cli/commands/generate.ts +0 -577
  34. package/src/cli/commands/init/agents-md.ts +0 -61
  35. package/src/cli/commands/init/bootstrap.ts +0 -111
  36. package/src/cli/commands/init/index.ts +0 -244
  37. package/src/cli/commands/init/skills.ts +0 -141
  38. package/src/cli/commands/init/templates/agents.md +0 -86
  39. package/src/cli/commands/init/templates/markdown.d.ts +0 -4
  40. package/src/cli/commands/init/templates/skills/warm-up-target.md +0 -122
  41. package/src/cli/commands/init/templates/skills/zond-checks.md +0 -621
  42. package/src/cli/commands/init/templates/skills/zond-seed.md +0 -114
  43. package/src/cli/commands/init/templates/skills/zond-triage.md +0 -272
  44. package/src/cli/commands/init/templates/skills/zond.md +0 -861
  45. package/src/cli/commands/init/templates/zond-config.yml +0 -14
  46. package/src/cli/commands/prepare-fixtures.ts +0 -97
  47. package/src/cli/commands/probe/_seed-bodies.ts +0 -52
  48. package/src/cli/commands/probe/mass-assignment.ts +0 -594
  49. package/src/cli/commands/probe/security.ts +0 -537
  50. package/src/cli/commands/probe/static.ts +0 -255
  51. package/src/cli/commands/probe/webhooks.ts +0 -163
  52. package/src/cli/commands/probe.ts +0 -535
  53. package/src/cli/commands/reference.ts +0 -87
  54. package/src/cli/commands/refresh-api.ts +0 -227
  55. package/src/cli/commands/remove-api.ts +0 -150
  56. package/src/cli/commands/report-bundle.ts +0 -310
  57. package/src/cli/commands/report.ts +0 -241
  58. package/src/cli/commands/request.ts +0 -548
  59. package/src/cli/commands/run.ts +0 -1162
  60. package/src/cli/commands/schema-from-runs.ts +0 -128
  61. package/src/cli/commands/secrets.ts +0 -133
  62. package/src/cli/commands/session.ts +0 -244
  63. package/src/cli/commands/use.ts +0 -74
  64. package/src/cli/index.ts +0 -55
  65. package/src/cli/json-envelope.ts +0 -108
  66. package/src/cli/json-schemas.ts +0 -314
  67. package/src/cli/output.ts +0 -40
  68. package/src/cli/program.ts +0 -219
  69. package/src/cli/resolve.ts +0 -105
  70. package/src/cli/runtime.ts +0 -7
  71. package/src/cli/safe-live.ts +0 -24
  72. package/src/cli/status-filter.ts +0 -114
  73. package/src/cli/util/api-context.ts +0 -85
  74. package/src/cli/version.ts +0 -8
  75. package/src/core/audit/persist.ts +0 -183
  76. package/src/core/checks/budget.ts +0 -59
  77. package/src/core/checks/checks/_crud-helpers.ts +0 -133
  78. package/src/core/checks/checks/_negative_mutator.ts +0 -133
  79. package/src/core/checks/checks/_readback-helpers.ts +0 -133
  80. package/src/core/checks/checks/content_type_conformance.ts +0 -39
  81. package/src/core/checks/checks/cross_call_references.ts +0 -147
  82. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +0 -219
  83. package/src/core/checks/checks/ensure_resource_availability.ts +0 -62
  84. package/src/core/checks/checks/idempotency_replay.ts +0 -242
  85. package/src/core/checks/checks/ignored_auth.ts +0 -254
  86. package/src/core/checks/checks/index.ts +0 -68
  87. package/src/core/checks/checks/lifecycle_transitions.ts +0 -416
  88. package/src/core/checks/checks/missing_required_header.ts +0 -40
  89. package/src/core/checks/checks/negative_data_rejection.ts +0 -148
  90. package/src/core/checks/checks/not_a_server_error.ts +0 -35
  91. package/src/core/checks/checks/open_cors_on_sensitive.ts +0 -160
  92. package/src/core/checks/checks/pagination_invariants.ts +0 -419
  93. package/src/core/checks/checks/positive_data_acceptance.ts +0 -33
  94. package/src/core/checks/checks/rate_limit_headers_absent.ts +0 -77
  95. package/src/core/checks/checks/response_headers_conformance.ts +0 -74
  96. package/src/core/checks/checks/response_schema_conformance.ts +0 -30
  97. package/src/core/checks/checks/status_code_conformance.ts +0 -132
  98. package/src/core/checks/checks/unsupported_method.ts +0 -63
  99. package/src/core/checks/checks/use_after_free.ts +0 -78
  100. package/src/core/checks/index.ts +0 -30
  101. package/src/core/checks/mode.ts +0 -82
  102. package/src/core/checks/recommended-action.ts +0 -68
  103. package/src/core/checks/registry.ts +0 -78
  104. package/src/core/checks/runner.ts +0 -1461
  105. package/src/core/checks/sarif.ts +0 -230
  106. package/src/core/checks/spec-findings.ts +0 -308
  107. package/src/core/checks/stateful.ts +0 -121
  108. package/src/core/checks/types.ts +0 -305
  109. package/src/core/checks/zond-extensions.ts +0 -73
  110. package/src/core/classifier/recommended-action.ts +0 -251
  111. package/src/core/context/current.ts +0 -51
  112. package/src/core/context/session.ts +0 -78
  113. package/src/core/coverage/loader.ts +0 -216
  114. package/src/core/coverage/reasons.ts +0 -300
  115. package/src/core/diagnostics/db-analysis.ts +0 -666
  116. package/src/core/diagnostics/failure-class.ts +0 -140
  117. package/src/core/diagnostics/failure-hints.ts +0 -112
  118. package/src/core/diagnostics/spec-pointer.ts +0 -99
  119. package/src/core/diagnostics/suggested-fixes.ts +0 -155
  120. package/src/core/exporter/case-study/index.ts +0 -270
  121. package/src/core/exporter/curl.ts +0 -40
  122. package/src/core/exporter/exporter.ts +0 -48
  123. package/src/core/exporter/html-report/escape.ts +0 -24
  124. package/src/core/exporter/html-report/index.ts +0 -479
  125. package/src/core/exporter/html-report/script.ts +0 -100
  126. package/src/core/exporter/html-report/styles.ts +0 -408
  127. package/src/core/generator/catalog-builder.ts +0 -179
  128. package/src/core/generator/chunker.ts +0 -78
  129. package/src/core/generator/coverage-phase.ts +0 -0
  130. package/src/core/generator/coverage-scanner.ts +0 -87
  131. package/src/core/generator/data-factory.ts +0 -759
  132. package/src/core/generator/describe.ts +0 -302
  133. package/src/core/generator/endpoint-warnings.ts +0 -52
  134. package/src/core/generator/fixtures-builder.ts +0 -332
  135. package/src/core/generator/index.ts +0 -14
  136. package/src/core/generator/openapi-reader.ts +0 -297
  137. package/src/core/generator/path-param-disambig.ts +0 -140
  138. package/src/core/generator/resources-builder.ts +0 -898
  139. package/src/core/generator/schema-utils.ts +0 -112
  140. package/src/core/generator/serializer.ts +0 -343
  141. package/src/core/generator/suite-generator.ts +0 -1301
  142. package/src/core/generator/types.ts +0 -63
  143. package/src/core/identity/identity-file.ts +0 -0
  144. package/src/core/lint/affects.ts +0 -28
  145. package/src/core/lint/config.ts +0 -96
  146. package/src/core/lint/format.ts +0 -42
  147. package/src/core/lint/index.ts +0 -94
  148. package/src/core/lint/reporter.ts +0 -128
  149. package/src/core/lint/rules/consistency.ts +0 -158
  150. package/src/core/lint/rules/heuristics.ts +0 -97
  151. package/src/core/lint/rules/strictness.ts +0 -109
  152. package/src/core/lint/types.ts +0 -96
  153. package/src/core/lint/walker.ts +0 -248
  154. package/src/core/meta/meta-store.ts +0 -11
  155. package/src/core/output/README.md +0 -73
  156. package/src/core/output/index.ts +0 -13
  157. package/src/core/output/run.ts +0 -91
  158. package/src/core/output/types.ts +0 -122
  159. package/src/core/parser/dynamic-values.ts +0 -160
  160. package/src/core/parser/env-interpolation.ts +0 -104
  161. package/src/core/parser/filter.ts +0 -97
  162. package/src/core/parser/schema.ts +0 -359
  163. package/src/core/parser/types.ts +0 -117
  164. package/src/core/parser/variables.ts +0 -0
  165. package/src/core/parser/yaml-parser.ts +0 -181
  166. package/src/core/probe/bootstrap.ts +0 -34
  167. package/src/core/probe/dry-run-envelope.ts +0 -61
  168. package/src/core/probe/mass-assignment/classify.ts +0 -175
  169. package/src/core/probe/mass-assignment/cleanup.ts +0 -52
  170. package/src/core/probe/mass-assignment/digest.ts +0 -114
  171. package/src/core/probe/mass-assignment/orchestrator.ts +0 -459
  172. package/src/core/probe/mass-assignment/regression.ts +0 -141
  173. package/src/core/probe/mass-assignment/suspects.ts +0 -92
  174. package/src/core/probe/mass-assignment/types.ts +0 -135
  175. package/src/core/probe/mass-assignment-probe-class.ts +0 -198
  176. package/src/core/probe/mass-assignment-probe.ts +0 -27
  177. package/src/core/probe/mass-assignment-template.ts +0 -240
  178. package/src/core/probe/method-probe.ts +0 -164
  179. package/src/core/probe/method-shared.ts +0 -69
  180. package/src/core/probe/negative-probe.ts +0 -691
  181. package/src/core/probe/orphan-tracker.ts +0 -188
  182. package/src/core/probe/path-discovery.ts +0 -439
  183. package/src/core/probe/probe-harness.ts +0 -119
  184. package/src/core/probe/registry.ts +0 -89
  185. package/src/core/probe/runner.ts +0 -136
  186. package/src/core/probe/security/baseline.ts +0 -174
  187. package/src/core/probe/security/classify.ts +0 -341
  188. package/src/core/probe/security/cleanup.ts +0 -125
  189. package/src/core/probe/security/detectors.ts +0 -71
  190. package/src/core/probe/security/digest.ts +0 -104
  191. package/src/core/probe/security/orchestrator.ts +0 -398
  192. package/src/core/probe/security/regression.ts +0 -103
  193. package/src/core/probe/security/types.ts +0 -151
  194. package/src/core/probe/security-probe-class.ts +0 -207
  195. package/src/core/probe/security-probe.ts +0 -32
  196. package/src/core/probe/shared.ts +0 -531
  197. package/src/core/probe/static-probe-class.ts +0 -125
  198. package/src/core/probe/types.ts +0 -165
  199. package/src/core/probe/verdict-aggregator.ts +0 -33
  200. package/src/core/probe/webhooks-probe.ts +0 -282
  201. package/src/core/reporter/console.ts +0 -240
  202. package/src/core/reporter/index.ts +0 -22
  203. package/src/core/reporter/json.ts +0 -22
  204. package/src/core/reporter/junit.ts +0 -93
  205. package/src/core/reporter/ndjson.ts +0 -37
  206. package/src/core/reporter/types.ts +0 -15
  207. package/src/core/runner/assertions.ts +0 -383
  208. package/src/core/runner/async-pool.ts +0 -108
  209. package/src/core/runner/auth-path.ts +0 -8
  210. package/src/core/runner/ci-context.ts +0 -72
  211. package/src/core/runner/executor.ts +0 -652
  212. package/src/core/runner/expr-eval.ts +0 -41
  213. package/src/core/runner/form-encode.ts +0 -41
  214. package/src/core/runner/http-client.ts +0 -224
  215. package/src/core/runner/learn-drift.ts +0 -293
  216. package/src/core/runner/preflight-vars.ts +0 -153
  217. package/src/core/runner/progress-tracker.ts +0 -73
  218. package/src/core/runner/rate-limiter.ts +0 -185
  219. package/src/core/runner/run-kind.ts +0 -45
  220. package/src/core/runner/schema-validator.ts +0 -308
  221. package/src/core/runner/send-request.ts +0 -232
  222. package/src/core/runner/transforms.ts +0 -65
  223. package/src/core/runner/types.ts +0 -94
  224. package/src/core/secrets/registry.ts +0 -164
  225. package/src/core/secrets/secrets-file.ts +0 -115
  226. package/src/core/selectors/operation-filter.ts +0 -144
  227. package/src/core/setup-api.ts +0 -590
  228. package/src/core/severity/category.ts +0 -94
  229. package/src/core/severity/index.ts +0 -58
  230. package/src/core/spec/infer-schema.ts +0 -102
  231. package/src/core/spec/layers.ts +0 -154
  232. package/src/core/spec/merge-specs.ts +0 -156
  233. package/src/core/spec/schema-from-runs.ts +0 -117
  234. package/src/core/spec/schema-overlay.ts +0 -130
  235. package/src/core/util/ajv.ts +0 -13
  236. package/src/core/util/format-eta.ts +0 -21
  237. package/src/core/util/headers.ts +0 -9
  238. package/src/core/util/url.ts +0 -24
  239. package/src/core/utils.ts +0 -13
  240. package/src/core/workspace/config.ts +0 -129
  241. package/src/core/workspace/fixture-gap-report.ts +0 -84
  242. package/src/core/workspace/fixture-gaps.ts +0 -71
  243. package/src/core/workspace/manifest.ts +0 -283
  244. package/src/core/workspace/output-rotation.ts +0 -62
  245. package/src/core/workspace/root.ts +0 -96
  246. package/src/core/workspace/triage-path.ts +0 -87
  247. package/src/db/lint-runs.ts +0 -47
  248. package/src/db/migrate.ts +0 -128
  249. package/src/db/migrations/0001_run_kind.sql +0 -25
  250. package/src/db/migrations/0002_run_kind_request.sql +0 -59
  251. package/src/db/migrations/sql.d.ts +0 -4
  252. package/src/db/queries/collections.ts +0 -133
  253. package/src/db/queries/coverage.ts +0 -9
  254. package/src/db/queries/dashboard.ts +0 -59
  255. package/src/db/queries/results.ts +0 -216
  256. package/src/db/queries/runs.ts +0 -289
  257. package/src/db/queries/sessions.ts +0 -42
  258. package/src/db/queries/settings.ts +0 -28
  259. package/src/db/queries/types.ts +0 -172
  260. package/src/db/queries.ts +0 -75
  261. package/src/db/schema.ts +0 -298
@@ -1,459 +0,0 @@
1
- /**
2
- * Mass-assignment probe (T58) orchestrator.
3
- *
4
- * For each POST endpoint we craft a JSON body augmented with "suspected" extra
5
- * fields (is_admin, role, account_id, …) plus server-assigned fields lifted
6
- * from the response schema (id, created_at, …). We send the request live,
7
- * read the response, and — when the API returned 2xx — issue a follow-up GET
8
- * to differentiate two outcomes:
9
- *
10
- * • accepted-and-applied — the suspicious value persisted ⇒ privilege
11
- * escalation candidate (HIGH severity).
12
- * • accepted-and-ignored — the suspicious value was silently dropped
13
- * (LOW severity, soft-warn).
14
- *
15
- * Rejected (4xx) is the desired behaviour. 5xx is a separate bug class
16
- * (negative-probe territory).
17
- *
18
- * Auth is loaded from a `.env.yaml`-style file — same surface as `zond run`
19
- * uses via `loadEnvironment`. `base_url`, `auth_token`, `api_key` and any
20
- * path-param placeholders supplied in env are substituted into URLs.
21
- *
22
- * Optionally emits a YAML regression suite (`--emit-tests`) that locks in
23
- * the observed safe behaviour (rejected / ignored) so CI catches drift.
24
- */
25
- import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
26
- import { executeRequest } from "../../runner/http-client.ts";
27
- import {
28
- captureFieldFor,
29
- classifyPostSemantics,
30
- findDeleteCounterpart,
31
- findGetByIdCounterpart,
32
- liveAuthHeaders,
33
- } from "../shared.ts";
34
- import {
35
- buildBaselineFromSpec,
36
- buildBodyAuthHeaders,
37
- buildProbeUrl,
38
- hasProbeBody,
39
- serializeProbeBody,
40
- } from "../probe-harness.ts";
41
- import {
42
- createDiscoveryCache,
43
- discoverBodyFkVars,
44
- discoverPathParams,
45
- type DiscoveryCache,
46
- } from "../path-discovery.ts";
47
- import {
48
- isStrictContract,
49
- serverAssignedExtras,
50
- suspectedExtras,
51
- } from "./suspects.ts";
52
- import {
53
- classifyFromBody,
54
- finaliseSeverity,
55
- findIdParam,
56
- inconclusiveBaselineSummary,
57
- needsFollowUp,
58
- stampRecommendedAction,
59
- } from "./classify.ts";
60
- import { tryCleanupBaseline } from "./cleanup.ts";
61
- import type {
62
- EndpointVerdict,
63
- MassAssignmentOptions,
64
- MassAssignmentResult,
65
- ProbeEndpointOpts,
66
- } from "./types.ts";
67
-
68
- export async function runMassAssignmentProbes(
69
- opts: MassAssignmentOptions,
70
- ): Promise<MassAssignmentResult> {
71
- const { endpoints, securitySchemes, vars, noCleanup, timeoutMs } = opts;
72
- const discover = opts.discover !== false;
73
- const cache: DiscoveryCache = createDiscoveryCache();
74
- const verdicts: EndpointVerdict[] = [];
75
- const warnings: string[] = [];
76
- let totalEndpoints = 0;
77
-
78
- for (const ep of endpoints) {
79
- if (ep.deprecated) continue;
80
- const m = ep.method.toUpperCase();
81
- if (m !== "POST" && m !== "PATCH" && m !== "PUT") continue;
82
- totalEndpoints++;
83
-
84
- // ARV-150: accept form-urlencoded endpoints in addition to JSON. Stripe
85
- // v1 declares only application/x-www-form-urlencoded for every mutating
86
- // operation — 265 endpoints were SKIPPED before this loosening.
87
- if (!hasProbeBody(ep)) {
88
- verdicts.push(skipped(ep, "no JSON or form-urlencoded request body"));
89
- continue;
90
- }
91
-
92
- // Resolve path placeholders, attempting auto-discovery when env doesn't
93
- // supply them and the spec has a sibling list endpoint (TASK-92).
94
- let effectiveVars = vars;
95
- const probe = buildProbeUrl(ep, vars);
96
- if (probe.unresolved.length > 0) {
97
- if (!discover) {
98
- const reason =
99
- m === "POST"
100
- ? `cannot resolve path placeholders: ${probe.unresolved.join(", ")} (set them in --env file)`
101
- : `${m} requires existing resource id; missing env vars: ${probe.unresolved.join(", ")}`;
102
- verdicts.push(skipped(ep, reason));
103
- continue;
104
- }
105
- const discovered = await discoverPathParams({
106
- ep,
107
- unresolved: probe.unresolved,
108
- allEndpoints: endpoints,
109
- schemes: securitySchemes,
110
- vars,
111
- cache,
112
- timeoutMs,
113
- });
114
- if (discovered.kind === "miss") {
115
- verdicts.push(
116
- skipped(
117
- ep,
118
- `cannot resolve path placeholders: ${probe.unresolved.join(", ")} — auto-discover failed (${discovered.reason})`,
119
- ),
120
- );
121
- continue;
122
- }
123
- effectiveVars = { ...vars, ...discovered.values };
124
- }
125
-
126
- // TASK-137: body-FK discovery. Required body fields named `audience_id`,
127
- // `project_slug`, `team_uuid`… get filled from sibling collection
128
- // endpoints. Without this, baseline POST hits 4xx because the random
129
- // string we'd otherwise send fails FK validation, and the verdict
130
- // becomes INCONCLUSIVE-baseline — a noise class that buried 51 verdicts
131
- // in the dogfooding audit (m-8 feedback §B).
132
- const bodyFkMisses: Array<{ field: string; reason: string }> = [];
133
- if (discover) {
134
- const bodyDiscovery = await discoverBodyFkVars({
135
- ep,
136
- allEndpoints: endpoints,
137
- schemes: securitySchemes,
138
- vars: effectiveVars,
139
- cache,
140
- timeoutMs,
141
- });
142
- if (Object.keys(bodyDiscovery.values).length > 0) {
143
- effectiveVars = { ...effectiveVars, ...bodyDiscovery.values };
144
- }
145
- bodyFkMisses.push(...bodyDiscovery.misses);
146
- }
147
-
148
- // Body-FK overlays. discoverBodyFkVars wrote into effectiveVars but the
149
- // baseline body is generated from spec via fake UUIDs / random strings —
150
- // substituteDeep only handles literal `{{var}}` markers, not field-name
151
- // matches. So we pass the resolved field→value map separately and the
152
- // probe overlays it onto baseline directly.
153
- let bodyFkOverlay: Record<string, string> | undefined;
154
- if (discover) {
155
- bodyFkOverlay = {};
156
- for (const k of Object.keys(effectiveVars)) {
157
- if (vars[k] === undefined && k.includes("_") && /(_id|_slug|_uuid|_key)$/.test(k)) {
158
- bodyFkOverlay[k] = effectiveVars[k]!;
159
- }
160
- }
161
- if (Object.keys(bodyFkOverlay).length === 0) bodyFkOverlay = undefined;
162
- }
163
-
164
- const verdict = await probeEndpoint(ep, endpoints, securitySchemes, effectiveVars, {
165
- noCleanup: noCleanup === true,
166
- timeoutMs,
167
- bodyFkMisses,
168
- bodyFkOverlay,
169
- extraSuspectFields: opts.extraSuspectFields,
170
- seedBody: opts.seedBodies?.get(`${ep.method.toUpperCase()} ${ep.path}`),
171
- });
172
- stampRecommendedAction(verdict);
173
- verdicts.push(verdict);
174
- }
175
-
176
- return {
177
- specProbed: verdicts.length,
178
- totalEndpoints,
179
- verdicts,
180
- warnings,
181
- };
182
- }
183
-
184
- function skipped(ep: EndpointInfo, reason: string): EndpointVerdict {
185
- return {
186
- method: ep.method.toUpperCase(),
187
- path: ep.path,
188
- severity: "skipped",
189
- summary: reason,
190
- request: { url: "", body: undefined, injectedFields: [] },
191
- fields: [],
192
- strictContract: isStrictContract(ep.requestBodySchema),
193
- skipReason: reason,
194
- };
195
- }
196
-
197
- async function probeEndpoint(
198
- ep: EndpointInfo,
199
- allEndpoints: EndpointInfo[],
200
- schemes: SecuritySchemeInfo[],
201
- vars: Record<string, string>,
202
- opts: ProbeEndpointOpts,
203
- ): Promise<EndpointVerdict> {
204
- const m = ep.method.toUpperCase();
205
- const strict = isStrictContract(ep.requestBodySchema);
206
-
207
- // Build baseline payload from spec then substitute generators ({{$uuid}}, …).
208
- const baseline = buildBaselineFromSpec(ep, vars, opts.seedBody);
209
- if (baseline === null) {
210
- return skipped(ep, "request body not a JSON object");
211
- }
212
- // TASK-137: overlay discovered FK values directly by field name so the
213
- // baseline body actually carries the real audience_id / project_slug / …
214
- // instead of the random UUID generateFromSchema synthesised.
215
- if (opts.bodyFkOverlay) {
216
- for (const [k, v] of Object.entries(opts.bodyFkOverlay)) {
217
- if (k in baseline) baseline[k] = v;
218
- }
219
- }
220
-
221
- const suspects = suspectedExtras(ep, opts.extraSuspectFields);
222
- const serverFields = serverAssignedExtras(ep);
223
- // Suspects win over server-assigned: if a field is both (e.g. `is_admin`
224
- // appears in the response schema AND is in our suspect list), the suspect
225
- // sentinel must be sent so we can detect privilege escalation.
226
- const injectedSet = { ...serverFields, ...suspects };
227
- const injectedNames = Object.keys(injectedSet);
228
- if (injectedNames.length === 0) {
229
- return skipped(ep, "no extra fields to inject (request schema covers everything)");
230
- }
231
-
232
- const body = { ...baseline, ...injectedSet };
233
- const { url, unresolved } = buildProbeUrl(ep, vars);
234
- if (unresolved.length > 0) {
235
- return skipped(
236
- ep,
237
- `cannot resolve path placeholders: ${unresolved.join(", ")} (set them in --env file)`,
238
- );
239
- }
240
-
241
- // ARV-150: Content-Type follows the spec — form-urlencoded for Stripe v1,
242
- // JSON otherwise. `serializeProbeBody` encodes the actual wire payload.
243
- const headers = buildBodyAuthHeaders(ep, schemes, vars);
244
-
245
- const verdict: EndpointVerdict = {
246
- method: m,
247
- path: ep.path,
248
- severity: "ok",
249
- summary: "",
250
- request: { url, body, injectedFields: injectedNames },
251
- fields: injectedNames.map(name => ({
252
- field: name,
253
- injected: injectedSet[name],
254
- outcome: "unknown",
255
- })),
256
- strictContract: strict,
257
- };
258
-
259
- // ── Baseline probe (TASK-91) ─────────────────────────────────────────────
260
- // Send the *clean* baseline body first. Without this, a 4xx caused by FK
261
- // miss / bad fixture / scope mismatch is indistinguishable from a 4xx that
262
- // actually rejected our extras — false-OK on FK-heavy SaaS APIs (Stripe /
263
- // Linear / GitHub-shaped). The baseline lets us classify:
264
- // • baseline 4xx + injected 4xx → INCONCLUSIVE-baseline (fixture bug).
265
- // • baseline 2xx + injected 4xx → OK (real extras rejection).
266
- // • baseline 4xx + injected 2xx → HIGH (extras opened a code path the
267
- // baseline never reached — privilege/auth bypass).
268
- // • baseline 2xx + injected 2xx → existing applied/ignored flow.
269
- let baselineResp;
270
- try {
271
- baselineResp = await executeRequest(
272
- { method: m, url, headers, body: serializeProbeBody(ep, baseline).content },
273
- { timeout: opts.timeoutMs ?? 30000, retries: 0 },
274
- );
275
- } catch (err) {
276
- verdict.severity = "high";
277
- verdict.summary = `baseline network error: ${err instanceof Error ? err.message : String(err)}`;
278
- return verdict;
279
- }
280
- const baselineBody = baselineResp.body_parsed ?? baselineResp.body;
281
- verdict.baseline = { status: baselineResp.status, body: baselineBody };
282
- const baselineOk = baselineResp.status >= 200 && baselineResp.status < 300;
283
- // If baseline created a resource, DELETE it before issuing the injected
284
- // probe so the second POST doesn't trip a unique-constraint and so we
285
- // don't leak resources.
286
- if (baselineOk && !opts.noCleanup) {
287
- await tryCleanupBaseline(ep, allEndpoints, schemes, vars, baselineBody, opts);
288
- }
289
-
290
- // ── Injected probe ──────────────────────────────────────────────────────
291
- let resp;
292
- try {
293
- resp = await executeRequest(
294
- { method: m, url, headers, body: serializeProbeBody(ep, body).content },
295
- { timeout: opts.timeoutMs ?? 30000, retries: 0 },
296
- );
297
- } catch (err) {
298
- verdict.severity = "high";
299
- verdict.summary = `network error: ${err instanceof Error ? err.message : String(err)}`;
300
- return verdict;
301
- }
302
- verdict.response = { status: resp.status, body: resp.body_parsed ?? resp.body };
303
-
304
- if (resp.status >= 500) {
305
- // TASK-276: if the baseline (no extras) also crashed with ≥500, the
306
- // endpoint is just crashing — mass-assignment semantics aren't
307
- // observable, and validation-probe will already have flagged the same
308
- // endpoint. Don't surface as HIGH privilege-escalation; that buries
309
- // real findings under noise.
310
- if (baselineResp.status >= 500) {
311
- verdict.severity = "inconclusive-5xx";
312
- verdict.summary = `baseline ${baselineResp.status} → injected ${resp.status} — endpoint crashes regardless of extras (likely duplicate of validation-probe)`;
313
- for (const f of verdict.fields) f.outcome = "unknown";
314
- return verdict;
315
- }
316
- verdict.severity = "high";
317
- verdict.summary = `5xx unhandled (${resp.status}) — see negative-probe`;
318
- return verdict;
319
- }
320
-
321
- const injectedOk = resp.status >= 200 && resp.status < 300;
322
-
323
- // Matrix dispatch on baseline×injected (TASK-91):
324
- if (resp.status >= 400 && !injectedOk) {
325
- if (!baselineOk) {
326
- // Baseline body itself invalid — extras never reached validation.
327
- verdict.severity = "inconclusive-baseline";
328
- verdict.summary = inconclusiveBaselineSummary(
329
- baselineResp.status,
330
- baselineBody,
331
- opts.bodyFkMisses,
332
- );
333
- for (const f of verdict.fields) f.outcome = "unknown";
334
- return verdict;
335
- }
336
- // Baseline succeeded, injected rejected → real extras rejection.
337
- verdict.severity = "ok";
338
- verdict.summary = strict
339
- ? `rejected ${resp.status} — strict contract honoured`
340
- : `rejected ${resp.status} — extras refused (baseline ${baselineResp.status})`;
341
- for (const f of verdict.fields) f.outcome = "absent";
342
- return verdict;
343
- }
344
-
345
- if (injectedOk && !baselineOk) {
346
- // Extras-as-bypass: baseline didn't make it through, but adding extras did.
347
- // The extra fields opened a code path that baseline didn't reach (auth
348
- // scope, FK shadowing, etc.). Treat as HIGH — likely a real bug —
349
- // and continue to body-classification so per-field outcomes are still
350
- // recorded for the digest.
351
- verdict.severity = "high";
352
- const bypassReason =
353
- baselineResp.status >= 500
354
- ? "server crash on baseline — extras-bypass turned a 5xx into a successful write"
355
- : "extras opened a code path baseline didn't reach";
356
- verdict.summary = `extras-bypass: baseline ${baselineResp.status} → injected ${resp.status} (${bypassReason})`;
357
- // Fall through to the 2xx classification below; finaliseSeverity won't
358
- // overwrite "high" once it's set — but we also want to still mark
359
- // applied/ignored fields. We skip finaliseSeverity at the end for this
360
- // case to preserve the bypass summary.
361
- }
362
-
363
- // 2xx — analyse the response body for echoed values, then maybe GET.
364
- const respBody =
365
- typeof resp.body_parsed === "object" && resp.body_parsed !== null
366
- ? (resp.body_parsed as Record<string, unknown>)
367
- : undefined;
368
-
369
- classifyFromBody(verdict, respBody);
370
-
371
- // Follow-up GET if any field is still "absent" or "unknown" — to distinguish
372
- // ignored from silently-persisted-but-not-echoed.
373
- if (respBody && needsFollowUp(verdict)) {
374
- const idField = captureFieldFor(ep);
375
- const id = respBody[idField];
376
- const getEp = findGetByIdCounterpart(ep, allEndpoints);
377
- if (id !== undefined && getEp) {
378
- const getVars = { ...vars, [findIdParam(getEp)]: String(id), id: String(id) };
379
- const getUrl = buildProbeUrl(getEp, getVars);
380
- if (getUrl.unresolved.length === 0) {
381
- try {
382
- const getResp = await executeRequest(
383
- {
384
- method: "GET",
385
- url: getUrl.url,
386
- headers: {
387
- accept: "application/json",
388
- ...liveAuthHeaders(getEp, schemes, vars),
389
- },
390
- },
391
- { timeout: opts.timeoutMs ?? 30000, retries: 0 },
392
- );
393
- const getBody =
394
- typeof getResp.body_parsed === "object" && getResp.body_parsed !== null
395
- ? (getResp.body_parsed as Record<string, unknown>)
396
- : undefined;
397
- verdict.followUpGet = {
398
- url: getUrl.url,
399
- status: getResp.status,
400
- body: getResp.body_parsed ?? getResp.body,
401
- };
402
- if (getBody) classifyFromBody(verdict, getBody, true);
403
- } catch (err) {
404
- verdict.notes = [
405
- ...(verdict.notes ?? []),
406
- `follow-up GET failed: ${err instanceof Error ? err.message : String(err)}`,
407
- ];
408
- }
409
- }
410
- }
411
-
412
- // Cleanup
413
- if (!opts.noCleanup && id !== undefined) {
414
- const delEp = findDeleteCounterpart(ep, allEndpoints);
415
- if (delEp) {
416
- const delVars = { ...vars, [findIdParam(delEp)]: String(id), id: String(id) };
417
- const delUrl = buildProbeUrl(delEp, delVars);
418
- if (delUrl.unresolved.length === 0) {
419
- try {
420
- const delResp = await executeRequest(
421
- {
422
- method: "DELETE",
423
- url: delUrl.url,
424
- headers: {
425
- accept: "application/json",
426
- ...liveAuthHeaders(delEp, schemes, vars),
427
- },
428
- },
429
- { timeout: opts.timeoutMs ?? 30000, retries: 0 },
430
- );
431
- verdict.cleanup = { attempted: true, status: delResp.status };
432
- } catch (err) {
433
- verdict.cleanup = {
434
- attempted: true,
435
- error: err instanceof Error ? err.message : String(err),
436
- };
437
- }
438
- } else {
439
- verdict.cleanup = { attempted: false, error: "unresolved DELETE path placeholders" };
440
- }
441
- } else {
442
- // ARV-153: action POSTs (`/capture`, `/verify`, `/cancel`, …) never
443
- // allocate a new resource — surface that instead of the alarming
444
- // "no DELETE counterpart" line that triggered F7's leak-risk noise.
445
- const reason =
446
- classifyPostSemantics(ep) === "action"
447
- ? "no cleanup needed (action endpoint — no resource created)"
448
- : "no DELETE counterpart in spec";
449
- verdict.cleanup = { attempted: false, error: reason };
450
- }
451
- }
452
- }
453
-
454
- // Preserve "high" already set by the extras-bypass branch; otherwise
455
- // derive severity from per-field outcomes.
456
- if (verdict.severity !== "high") finaliseSeverity(verdict, strict);
457
- stampRecommendedAction(verdict);
458
- return verdict;
459
- }
@@ -1,141 +0,0 @@
1
- import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
2
- import { flattenToFormFields } from "../../runner/form-encode.ts";
3
- import type { RawStep, RawSuite } from "../../generator/serializer.ts";
4
- import {
5
- captureFieldFor,
6
- convertPath,
7
- endpointStem,
8
- findDeleteCounterpart,
9
- findGetByIdCounterpart,
10
- getAuthHeaders,
11
- } from "../shared.ts";
12
- import { findIdParam } from "./classify.ts";
13
- import type {
14
- EndpointVerdict,
15
- MassAssignmentResult,
16
- } from "./types.ts";
17
-
18
- const ACCEPTABLE_4XX = [400, 401, 403, 409, 415, 422];
19
-
20
- /**
21
- * Emit YAML suites that lock in the safe behaviour observed during the live
22
- * run:
23
- * • rejected (4xx) → assert status ∈ ACCEPTABLE_4XX (no regression to 2xx).
24
- * • accepted-and-ignored → assert 2xx and that injected fields don't echo
25
- * back. Follow-up GET — when available — additionally asserts the field
26
- * is not persisted.
27
- *
28
- * "applied" / "inconclusive" are deliberately NOT emitted: those are bugs to
29
- * fix, not baselines to lock.
30
- */
31
- export function emitRegressionSuites(
32
- result: MassAssignmentResult,
33
- endpoints: EndpointInfo[],
34
- schemes: SecuritySchemeInfo[],
35
- ): RawSuite[] {
36
- const suites: RawSuite[] = [];
37
- for (const v of result.verdicts) {
38
- // ARV-250: "info" carries the post-pivot semantics of the old "low"
39
- // (extras silently ignored — useful regression even when severity is
40
- // demoted). Both "low" (inconclusive) and "info" (ignored) qualify
41
- // for the ignored-baseline suite.
42
- const isIgnoredCase = v.severity === "low" || v.severity === "info";
43
- if (v.severity !== "ok" && !isIgnoredCase) continue;
44
- const ep = endpoints.find(e => e.path === v.path && e.method.toUpperCase() === v.method);
45
- if (!ep) continue;
46
- const suiteHeaders = getAuthHeaders(ep, schemes);
47
- const probeExpectedStatus = v.severity === "ok" ? ACCEPTABLE_4XX : [200, 201, 202, 204];
48
- // ARV-150: emit `form:` instead of `json:` when the endpoint uses
49
- // form-urlencoded bodies — otherwise the regression suite would send
50
- // JSON and re-hit the original "wrong content-type" 400.
51
- const bodyField =
52
- ep.requestBodyContentType === "application/x-www-form-urlencoded"
53
- ? { form: flattenToFormFields(v.request.body) }
54
- : { json: v.request.body };
55
- const probeStep: RawStep = {
56
- name: `mass-assignment: extras must ${v.severity === "ok" ? "be rejected" : "not apply"}`,
57
- source: {
58
- generator: "mass-assignment-probe",
59
- endpoint: `${v.method} ${v.path}`,
60
- response_branch: probeExpectedStatus.map(String).join("|"),
61
- },
62
- [v.method]: convertPath(ep.path),
63
- ...bodyField,
64
- expect: {
65
- status: probeExpectedStatus,
66
- },
67
- };
68
- const tests: RawStep[] = [probeStep];
69
- // For ignored case + we have a follow-up GET → emit a verifying GET
70
- // that asserts injected fields are absent / overridden.
71
- if (isIgnoredCase && v.followUpGet) {
72
- const idField = captureFieldFor(ep);
73
- probeStep.expect.body = {
74
- ...(probeStep.expect.body ?? {}),
75
- [idField]: { capture: "created_id" },
76
- };
77
- const getEp = findGetByIdCounterpart(ep, endpoints);
78
- if (getEp) {
79
- const idParam = findIdParam(getEp);
80
- const getStep: RawStep = {
81
- name: `verify extras did not persist`,
82
- source: {
83
- generator: "mass-assignment-probe",
84
- endpoint: `GET ${getEp.path}`,
85
- response_branch: "200",
86
- },
87
- GET: convertPath(getEp.path).replace(`{{${idParam}}}`, "{{created_id}}"),
88
- expect: {
89
- status: 200,
90
- body: extrasNotEqualAssertions(v),
91
- },
92
- };
93
- tests.push(getStep);
94
- }
95
- // cleanup
96
- const delEp = findDeleteCounterpart(ep, endpoints);
97
- if (delEp) {
98
- const idParam = findIdParam(delEp);
99
- const delStep: RawStep = {
100
- name: "cleanup",
101
- source: {
102
- generator: "mass-assignment-probe-cleanup",
103
- endpoint: `DELETE ${delEp.path}`,
104
- },
105
- always: true,
106
- DELETE: convertPath(delEp.path).replace(`{{${idParam}}}`, "{{created_id}}"),
107
- expect: { status: [200, 202, 204, 404] },
108
- } as RawStep & { always: boolean };
109
- tests.push(delStep);
110
- }
111
- }
112
- suites.push({
113
- name: `mass-assignment ${v.method} ${v.path}`,
114
- tags: ["probe-mass-assignment", v.severity === "ok" ? "rejected-baseline" : "ignored-baseline"],
115
- source: {
116
- type: "probe-suite",
117
- generator: "mass-assignment-probe",
118
- endpoint: `${v.method} ${v.path}`,
119
- },
120
- fileStem: `mass-assignment-${endpointStem(ep)}`,
121
- base_url: "{{base_url}}",
122
- ...(suiteHeaders ? { headers: suiteHeaders } : {}),
123
- tests,
124
- });
125
- }
126
- return suites;
127
- }
128
-
129
- function extrasNotEqualAssertions(v: EndpointVerdict): Record<string, Record<string, string>> {
130
- const out: Record<string, Record<string, string>> = {};
131
- for (const f of v.fields) {
132
- if (f.outcome === "ignored" || f.outcome === "echoed-overwritten" || f.outcome === "absent") {
133
- // Assert the suspicious value did NOT take effect. We check that the
134
- // observed value (from the live GET) still holds — the API is allowed
135
- // to echo a server default; what's forbidden is echoing OUR sentinel.
136
- const expectedNotEqual = JSON.stringify(f.injected);
137
- out[f.field] = { not_equals: expectedNotEqual };
138
- }
139
- }
140
- return out;
141
- }
@@ -1,92 +0,0 @@
1
- import type { OpenAPIV3 } from "openapi-types";
2
- import type { EndpointInfo } from "../../generator/types.ts";
3
-
4
- /**
5
- * Sentinel values are deliberately distinctive so that — if they appear in a
6
- * follow-up GET response — we can be confident the server actually persisted
7
- * them rather than coincidentally generating the same value.
8
- */
9
- export const SUSPECTED_FIELDS: Record<string, unknown> = {
10
- is_admin: true,
11
- is_system: true,
12
- verified: true,
13
- role: "admin",
14
- account_id: "00000000-0000-0000-0000-00000000beef",
15
- owner_id: "00000000-0000-0000-0000-00000000beef",
16
- user_id: "00000000-0000-0000-0000-00000000beef",
17
- };
18
-
19
- /** Sentinel values for server-assigned fields lifted from response schema. */
20
- const SERVER_FIELD_SENTINEL = {
21
- uuid: "00000000-0000-0000-0000-00000000dead",
22
- isoDate: "2000-01-01T00:00:00.000Z",
23
- string: "zond-injected",
24
- integer: -424242,
25
- number: -424242,
26
- boolean: false,
27
- };
28
-
29
- function requestPropertyNames(schema?: OpenAPIV3.SchemaObject): Set<string> {
30
- const out = new Set<string>();
31
- if (!schema) return out;
32
- if (schema.properties) {
33
- for (const k of Object.keys(schema.properties)) out.add(k);
34
- }
35
- for (const composite of [schema.allOf, schema.oneOf, schema.anyOf]) {
36
- if (Array.isArray(composite)) {
37
- for (const sub of composite) {
38
- const s = sub as OpenAPIV3.SchemaObject;
39
- if (s.properties) for (const k of Object.keys(s.properties)) out.add(k);
40
- }
41
- }
42
- }
43
- return out;
44
- }
45
-
46
- export function isStrictContract(schema?: OpenAPIV3.SchemaObject): boolean {
47
- if (!schema) return false;
48
- return schema.additionalProperties === false;
49
- }
50
-
51
- function pickServerFieldSentinel(s: OpenAPIV3.SchemaObject): unknown {
52
- if (s.format === "uuid") return SERVER_FIELD_SENTINEL.uuid;
53
- if (s.format === "date-time" || s.format === "date") return SERVER_FIELD_SENTINEL.isoDate;
54
- switch (s.type) {
55
- case "string": return SERVER_FIELD_SENTINEL.string;
56
- case "integer": return SERVER_FIELD_SENTINEL.integer;
57
- case "number": return SERVER_FIELD_SENTINEL.number;
58
- case "boolean": return SERVER_FIELD_SENTINEL.boolean;
59
- default: return SERVER_FIELD_SENTINEL.string;
60
- }
61
- }
62
-
63
- /** Server-assigned fields = response 2xx schema props that don't appear in request schema. */
64
- export function serverAssignedExtras(ep: EndpointInfo): Record<string, unknown> {
65
- const reqProps = requestPropertyNames(ep.requestBodySchema);
66
- const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300 && r.schema);
67
- const respProps = success?.schema?.properties;
68
- const out: Record<string, unknown> = {};
69
- if (!respProps) return out;
70
- for (const [name, schema] of Object.entries(respProps)) {
71
- if (reqProps.has(name)) continue;
72
- out[name] = pickServerFieldSentinel(schema as OpenAPIV3.SchemaObject);
73
- }
74
- return out;
75
- }
76
-
77
- /** Extra fields that aren't legitimate request-body properties. */
78
- export function suspectedExtras(
79
- ep: EndpointInfo,
80
- extra: Record<string, unknown> = {},
81
- ): Record<string, unknown> {
82
- const reqProps = requestPropertyNames(ep.requestBodySchema);
83
- const out: Record<string, unknown> = {};
84
- // ARV-252: per-run extras (CLI --suspect-field) compose with the
85
- // curated SUSPECTED_FIELDS list. Later additions win on key collision
86
- // so a user can override a sentinel value if needed.
87
- const merged: Record<string, unknown> = { ...SUSPECTED_FIELDS, ...extra };
88
- for (const [name, value] of Object.entries(merged)) {
89
- if (!reqProps.has(name)) out[name] = value;
90
- }
91
- return out;
92
- }