@kirrosh/zond 0.22.0 → 0.26.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 (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -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 +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -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 +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -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 +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -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 +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,398 @@
1
+ /**
2
+ * `zond probe-security <classes>` — live SSRF / CRLF / open-redirect probes.
3
+ *
4
+ * Mirrors the `probe-mass-assignment` shape: live runner, optional regression
5
+ * YAML emission, idempotent cleanup. Where mass-assignment injects extra
6
+ * suspect fields, this probe replaces a single benign field with a security
7
+ * payload (SSRF / CRLF / open-redirect) and classifies the response.
8
+ */
9
+ import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
10
+ import { executeRequest } from "../../runner/http-client.ts";
11
+ import {
12
+ classifyPostSemantics,
13
+ findDeleteCounterpart,
14
+ pathTouchesSeededVar,
15
+ } from "../shared.ts";
16
+ import {
17
+ buildBaselineFromSpec,
18
+ buildBodyAuthHeaders,
19
+ buildProbeUrl,
20
+ hasProbeBody,
21
+ serializeProbeBody,
22
+ } from "../probe-harness.ts";
23
+ import { detectFields, PAYLOADS } from "./detectors.ts";
24
+ import {
25
+ restoreOriginal,
26
+ sendBaseline,
27
+ snapshotOriginal,
28
+ } from "./baseline.ts";
29
+ import { classify } from "./classify.ts";
30
+ import { tryCleanup } from "./cleanup.ts";
31
+ import type {
32
+ CleanupFeasibility,
33
+ ProbeStepOpts,
34
+ SecurityFieldHit,
35
+ SecurityProbeOptions,
36
+ SecurityProbeResult,
37
+ SecuritySeverity,
38
+ SecurityVerdict,
39
+ } from "./types.ts";
40
+
41
+ export async function runSecurityProbes(
42
+ opts: SecurityProbeOptions,
43
+ ): Promise<SecurityProbeResult> {
44
+ const verdicts: SecurityVerdict[] = [];
45
+ const warnings: string[] = [];
46
+ let totalEndpoints = 0;
47
+
48
+ // ARV-140: pre-flight cleanup-feasibility scan. For each POST target, look
49
+ // up the DELETE counterpart in the spec once. Without --allow-leaks any
50
+ // attack against a POST-without-DELETE is dropped — orphan tracker can't
51
+ // clean it (no DELETE path to retry) so it would linger in the user's
52
+ // tenant indefinitely (feedback round-01/02 Sentry: 18 manual cleanups).
53
+ const feasibility: CleanupFeasibility = {
54
+ status: {},
55
+ skippedNoCleanup: 0,
56
+ forcedNoCleanup: 0,
57
+ actionNoCleanupNeeded: 0,
58
+ };
59
+ for (const ep of opts.endpoints) {
60
+ if (ep.deprecated) continue;
61
+ if (ep.method.toUpperCase() !== "POST") continue;
62
+ const key = `POST ${ep.path}`;
63
+ // ARV-153: action POSTs (`/capture`, `/verify`, `/cancel`, …) don't
64
+ // allocate a new resource — there is nothing to DELETE. Attacking them
65
+ // without `--allow-leaks` is safe; classifying them up front prevents
66
+ // the feasibility pre-flight from masking 18/22 Stripe action endpoints.
67
+ const semantics = classifyPostSemantics(ep);
68
+ if (semantics === "action") {
69
+ feasibility.status[key] = "action";
70
+ feasibility.actionNoCleanupNeeded += 1;
71
+ continue;
72
+ }
73
+ const hasDelete = findDeleteCounterpart(ep, opts.endpoints) !== undefined;
74
+ feasibility.status[key] = hasDelete ? "has-delete" : "no-delete-counterpart";
75
+ if (!hasDelete) {
76
+ if (opts.allowLeaks) feasibility.forcedNoCleanup += 1;
77
+ else feasibility.skippedNoCleanup += 1;
78
+ }
79
+ }
80
+
81
+ for (const ep of opts.endpoints) {
82
+ if (ep.deprecated) continue;
83
+ const m = ep.method.toUpperCase();
84
+ if (m !== "POST" && m !== "PUT" && m !== "PATCH") continue;
85
+ totalEndpoints++;
86
+
87
+ // ARV-140: cleanup-feasibility gate. POST without a DELETE counterpart
88
+ // (and without --allow-leaks) is dropped before any live request fires.
89
+ // PUT/PATCH have snapshot/restore so they're unaffected here.
90
+ if (m === "POST" && !opts.allowLeaks) {
91
+ const status = feasibility.status[`POST ${ep.path}`];
92
+ if (status === "no-delete-counterpart") {
93
+ verdicts.push(skipped(ep, "skipped: no DELETE counterpart in spec (cleanup-feasibility pre-flight; pass --allow-leaks to override)"));
94
+ continue;
95
+ }
96
+ }
97
+
98
+ // TASK-264: --isolated guard. Mutation on a seeded fixture would corrupt
99
+ // user data the next `zond run` depends on; skip the endpoint instead.
100
+ if (opts.isolated && (m === "PUT" || m === "PATCH") && pathTouchesSeededVar(ep.path, opts.vars)) {
101
+ verdicts.push(skipped(ep, "skipped: --isolated mode protects seeded fixtures (PUT/PATCH on seeded path-params)"));
102
+ continue;
103
+ }
104
+
105
+ // ARV-161 (round-08 F18): parity with mass-assignment — accept
106
+ // application/x-www-form-urlencoded endpoints too. Stripe v1 declares
107
+ // user-controlled URL fields (webhook url, return_url, ...) only on
108
+ // form-encoded bodies; the previous JSON-only gate hid 78+ POSTs from
109
+ // SSRF/CRLF/open-redirect probing.
110
+ if (!hasProbeBody(ep)) {
111
+ verdicts.push(skipped(ep, "no JSON or form-urlencoded request body"));
112
+ continue;
113
+ }
114
+
115
+ const detected = detectFields(ep, opts.classes);
116
+ if (detected.length === 0) {
117
+ verdicts.push(skipped(ep, `no fields matched classes: ${opts.classes.join(",")}`));
118
+ continue;
119
+ }
120
+
121
+ if (opts.dryRun) {
122
+ verdicts.push({
123
+ method: m,
124
+ path: ep.path,
125
+ severity: "skipped",
126
+ summary: "dry-run: would attack " + detected.map(d => `${d.field}/${d.class}`).join(", "),
127
+ detectedFields: detected,
128
+ findings: [],
129
+ skipReason: "dry-run",
130
+ });
131
+ continue;
132
+ }
133
+
134
+ const verdict = await probeOneEndpoint(
135
+ ep,
136
+ opts.endpoints,
137
+ opts.securitySchemes,
138
+ opts.vars,
139
+ detected,
140
+ {
141
+ noCleanup: opts.noCleanup === true,
142
+ timeoutMs: opts.timeoutMs,
143
+ cleanupRetryDelaysMs: opts.cleanupRetryDelaysMs,
144
+ seedBody: opts.seedBodies?.get(`${ep.method.toUpperCase()} ${ep.path}`),
145
+ },
146
+ );
147
+ verdicts.push(verdict);
148
+ }
149
+
150
+ return {
151
+ classes: opts.classes,
152
+ totalEndpoints,
153
+ specProbed: verdicts.length,
154
+ verdicts,
155
+ warnings,
156
+ cleanupFeasibility: feasibility,
157
+ };
158
+ }
159
+
160
+ async function probeOneEndpoint(
161
+ ep: EndpointInfo,
162
+ allEndpoints: EndpointInfo[],
163
+ schemes: SecuritySchemeInfo[],
164
+ vars: Record<string, string>,
165
+ detected: SecurityFieldHit[],
166
+ opts: ProbeStepOpts,
167
+ ): Promise<SecurityVerdict> {
168
+ const m = ep.method.toUpperCase();
169
+ const verdict: SecurityVerdict = {
170
+ method: m,
171
+ path: ep.path,
172
+ severity: "ok",
173
+ summary: "",
174
+ detectedFields: detected,
175
+ findings: [],
176
+ };
177
+
178
+ // Build baseline body. Same recipe as mass-assignment: spec → generators → vars.
179
+ // ARV-269: agent-authored `seed_body` overlay wins when present.
180
+ const baseline = buildBaselineFromSpec(ep, vars, opts.seedBody);
181
+ if (baseline === null) {
182
+ return skipped(ep, "request body not a JSON object");
183
+ }
184
+
185
+ const { url, unresolved } = buildProbeUrl(ep, vars);
186
+ if (unresolved.length > 0) {
187
+ return skipped(ep, `cannot resolve path placeholders: ${unresolved.join(", ")}`);
188
+ }
189
+
190
+ // ARV-161: Content-Type follows the spec — form-urlencoded for Stripe v1,
191
+ // JSON otherwise. All outbound payloads in this function (baseline, per-
192
+ // attack, restore-PUT) flow through serializeProbeBody for matching wire
193
+ // encoding.
194
+ const headers = buildBodyAuthHeaders(ep, schemes, vars);
195
+
196
+ // ── Snapshot original state (TASK-151) ────────────────────────────────
197
+ // For PUT/PATCH we MUST capture original state before any mutation. The
198
+ // old DELETE-cleanup is wrong for rename'ы — it can't undo a renamed
199
+ // DSN-key / team-name / webhook URL. Snapshot first, restore after each
200
+ // 2xx. POST falls back to DELETE-cleanup (correct semantics there).
201
+ const isUpdate = m === "PUT" || m === "PATCH";
202
+ const snapshot = isUpdate && !opts.noCleanup
203
+ ? await snapshotOriginal(ep, allEndpoints, schemes, vars, opts)
204
+ : null;
205
+
206
+ // ── Baseline-OK gate ────────────────────────────────────────────────────
207
+ // Eliminates the "5 × 404" output the markdown template produced in the
208
+ // audit. If baseline isn't 2xx, attacks would just hit the same 4xx
209
+ // wall and tell us nothing.
210
+ const fullBaseline = await sendBaseline(ep, m, url, headers, baseline, opts);
211
+ if (fullBaseline.kind === "network") {
212
+ verdict.severity = "high";
213
+ verdict.summary = `baseline network error: ${fullBaseline.reason}`;
214
+ return verdict;
215
+ }
216
+ verdict.baseline = { status: fullBaseline.status };
217
+
218
+ // ── Partial-body fallback (TASK-152) ──────────────────────────────────
219
+ // common SaaS-style APIs accept partial PUT — full bodies
220
+ // generated from spec get rejected (422 / 400). Walking each detected
221
+ // field with a single-key body recovers the proven-HIGH cases that
222
+ // otherwise fall into INCONCLUSIVE-BASELINE.
223
+ let fullOk = fullBaseline.kind === "ok" && fullBaseline.status >= 200 && fullBaseline.status < 300;
224
+ const perFieldBaseline = new Map<string, Record<string, unknown>>();
225
+ if (!fullOk && isUpdate && fullBaseline.kind === "ok") {
226
+ for (const hit of detected) {
227
+ // Reuse spec value when present; otherwise fall back to the substituted
228
+ // generator output for the field. Either way the partial body has
229
+ // exactly one key, which is what partial-PUT APIs accept.
230
+ const partial: Record<string, unknown> = {};
231
+ if (hit.field in baseline) partial[hit.field] = baseline[hit.field];
232
+ else partial[hit.field] = "";
233
+ const partResp = await sendBaseline(ep, m, url, headers, partial, opts);
234
+ if (partResp.kind === "ok" && partResp.status >= 200 && partResp.status < 300) {
235
+ perFieldBaseline.set(hit.field, partial);
236
+ }
237
+ }
238
+ if (perFieldBaseline.size > 0 && snapshot) {
239
+ // Each successful partial baseline mutated only its single key; restore
240
+ // exactly those before attacks start.
241
+ await restoreOriginal(
242
+ ep, snapshot, headers, schemes, vars, opts, verdict,
243
+ perFieldBaseline.keys(),
244
+ );
245
+ }
246
+ }
247
+
248
+ if (!fullOk && perFieldBaseline.size === 0) {
249
+ // fullBaseline.kind === "network" was already returned above; here it
250
+ // must be "ok" with non-2xx status.
251
+ const status = fullBaseline.kind === "ok" ? fullBaseline.status : 0;
252
+ verdict.severity = "inconclusive-baseline";
253
+ verdict.summary = isUpdate
254
+ ? `baseline ${status} on full body; partial-body per-field also rejected — fixture/scope issue`
255
+ : `baseline ${status} — endpoint unreachable or fixture invalid; skipping attacks`;
256
+ return verdict;
257
+ }
258
+
259
+ // Cleanup state mutated by the (full) baseline, before issuing attacks.
260
+ // With a snapshot → restore PUT (full baseline mutated every key).
261
+ // Without snapshot → DELETE-counterpart (POST flow).
262
+ if (fullOk && fullBaseline.kind === "ok" && !opts.noCleanup) {
263
+ if (snapshot) {
264
+ await restoreOriginal(
265
+ ep, snapshot, headers, schemes, vars, opts, verdict,
266
+ Object.keys(baseline),
267
+ );
268
+ } else {
269
+ await tryCleanup(
270
+ ep, allEndpoints, schemes, vars,
271
+ fullBaseline.body, verdict, opts,
272
+ );
273
+ }
274
+ }
275
+
276
+ // ── Attacks ──────────────────────────────────────────────────────────────
277
+ for (const hit of detected) {
278
+ // Pick the body shape that this endpoint actually accepts.
279
+ let baseBody: Record<string, unknown> | undefined;
280
+ let mode: "full" | "partial" | "none" = "none";
281
+ if (fullOk) {
282
+ baseBody = baseline;
283
+ mode = "full";
284
+ } else if (perFieldBaseline.has(hit.field)) {
285
+ baseBody = perFieldBaseline.get(hit.field)!;
286
+ mode = "partial";
287
+ }
288
+
289
+ if (mode === "none" || !baseBody) {
290
+ // Field doesn't have a usable baseline body shape — record one
291
+ // INCONCLUSIVE per payload so the digest still exposes the field.
292
+ for (const payload of PAYLOADS[hit.class]) {
293
+ verdict.findings.push({
294
+ field: hit.field,
295
+ class: hit.class,
296
+ payload,
297
+ status: 0,
298
+ echoed: false,
299
+ severity: "inconclusive",
300
+ reason: "no baseline body shape accepted (full+partial both rejected)",
301
+ });
302
+ }
303
+ continue;
304
+ }
305
+
306
+ for (const payload of PAYLOADS[hit.class]) {
307
+ const body = { ...baseBody, [hit.field]: payload };
308
+ let resp;
309
+ try {
310
+ resp = await executeRequest(
311
+ { method: m, url, headers, body: serializeProbeBody(ep, body).content },
312
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
313
+ );
314
+ } catch (err) {
315
+ verdict.findings.push({
316
+ field: hit.field,
317
+ class: hit.class,
318
+ payload,
319
+ status: 0,
320
+ echoed: false,
321
+ severity: "inconclusive",
322
+ reason: `network error: ${err instanceof Error ? err.message : String(err)}`,
323
+ });
324
+ continue;
325
+ }
326
+ const finding = classify(hit, payload, resp, { endpoint: ep });
327
+ // Annotate which body shape was used for this attack — useful for
328
+ // case-studies and emit-tests.
329
+ finding.reason = mode === "partial"
330
+ ? `${finding.reason} [partial-body]`
331
+ : finding.reason;
332
+ verdict.findings.push(finding);
333
+
334
+ // Per-finding cleanup. Snapshot path takes precedence — DELETE on a
335
+ // PUT-rename'd resource would wipe a live entity, restore-PUT puts
336
+ // it back to the captured original. Only restore the single field
337
+ // this attack mutated — sending a multi-key body trips
338
+ // `422 use partial PUT` on common SaaS-shaped APIs.
339
+ if (resp.status >= 200 && resp.status < 300 && !opts.noCleanup) {
340
+ if (snapshot) {
341
+ await restoreOriginal(
342
+ ep, snapshot, headers, schemes, vars, opts, verdict,
343
+ [hit.field],
344
+ );
345
+ } else {
346
+ await tryCleanup(
347
+ ep, allEndpoints, schemes, vars,
348
+ resp.body_parsed ?? resp.body, verdict, opts,
349
+ );
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ verdict.severity = rollupSecuritySeverity(verdict.findings);
356
+ verdict.summary = summaryLine(verdict);
357
+ return verdict;
358
+ }
359
+
360
+ /**
361
+ * Roll up per-finding severities to the worst verdict-level severity.
362
+ * ARV-253: "info" sits below "low" (single_signal sanitization-only).
363
+ * ARV-254: "medium" sits between "high" and "low" (SSRF accept on an
364
+ * endpoint declaring delivery). Exported so callers can recompute the
365
+ * verdict rollup after adjusting individual finding severities.
366
+ */
367
+ export function rollupSecuritySeverity(
368
+ findings: readonly { severity: SecuritySeverity }[],
369
+ ): SecuritySeverity {
370
+ const severities = findings.map((f) => f.severity);
371
+ if (severities.includes("high")) return "high";
372
+ if (severities.includes("inconclusive")) return "inconclusive";
373
+ if (severities.includes("medium")) return "medium";
374
+ if (severities.includes("low")) return "low";
375
+ if (severities.includes("info")) return "info";
376
+ return "ok";
377
+ }
378
+
379
+ function summaryLine(v: SecurityVerdict): string {
380
+ const counts: Record<SecuritySeverity, number> = {
381
+ high: 0, medium: 0, low: 0, info: 0, inconclusive: 0, "inconclusive-baseline": 0, ok: 0, skipped: 0,
382
+ };
383
+ for (const f of v.findings) counts[f.severity]++;
384
+ const fields = Array.from(new Set(v.detectedFields.map(d => d.field))).join(", ");
385
+ return `fields=[${fields}] · HIGH=${counts.high} MED=${counts.medium} LOW=${counts.low} INFO=${counts.info} INCONCLUSIVE=${counts.inconclusive} OK=${counts.ok}`;
386
+ }
387
+
388
+ function skipped(ep: EndpointInfo, reason: string): SecurityVerdict {
389
+ return {
390
+ method: ep.method.toUpperCase(),
391
+ path: ep.path,
392
+ severity: "skipped",
393
+ summary: `skipped: ${reason}`,
394
+ detectedFields: [],
395
+ findings: [],
396
+ skipReason: reason,
397
+ };
398
+ }
@@ -0,0 +1,103 @@
1
+ import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
2
+ import { generateFromSchema } from "../../generator/data-factory.ts";
3
+ import type { RawStep, RawSuite } from "../../generator/serializer.ts";
4
+ import { flattenToFormFields } from "../../runner/form-encode.ts";
5
+ import {
6
+ captureFieldFor,
7
+ convertPath,
8
+ endpointStem,
9
+ findDeleteCounterpart,
10
+ getAuthHeaders,
11
+ } from "../shared.ts";
12
+ import type { SecurityProbeResult } from "./types.ts";
13
+
14
+ const ATTACK_EXPECTED_STATUS = [400, 403, 404, 405, 409, 415, 422];
15
+
16
+ function shortPayload(s: string): string {
17
+ return s.length > 40 ? s.slice(0, 37) + "…" : s;
18
+ }
19
+
20
+ export function emitSecurityRegressionSuites(
21
+ result: SecurityProbeResult,
22
+ endpoints: EndpointInfo[],
23
+ schemes: SecuritySchemeInfo[],
24
+ ): RawSuite[] {
25
+ const suites: RawSuite[] = [];
26
+ for (const v of result.verdicts) {
27
+ // ARV-247 (R-04/F18): `high` findings are 2xx-with-echoed-payload — the
28
+ // strongest regression signal we have. Skipping them meant CI had nothing
29
+ // to gate on for confirmed stored injections. Treat them like `ok` here:
30
+ // the suite locks in the *expected* state (attack rejected) once the API
31
+ // owner ships a fix, and fails loud while it's still broken.
32
+ if (v.severity !== "ok" && v.severity !== "low" && v.severity !== "high") continue;
33
+ const ep = endpoints.find(
34
+ e => e.path === v.path && e.method.toUpperCase() === v.method,
35
+ );
36
+ if (!ep) continue;
37
+ const suiteHeaders = getAuthHeaders(ep, schemes);
38
+ const tests: RawStep[] = [];
39
+ for (const f of v.findings) {
40
+ // `ok` = attack already rejected; `high` = attack accepted+echoed (the
41
+ // regression target is rejection, same expected set as `ok`). `low` =
42
+ // attack accepted but no echo — lock in the 2xx-without-echo shape.
43
+ const expected = (f.severity === "ok" || f.severity === "high") ? ATTACK_EXPECTED_STATUS : [200, 201, 202, 204];
44
+ const body = ep.requestBodySchema ? generateFromSchema(ep.requestBodySchema) : {};
45
+ if (typeof body === "object" && body !== null && !Array.isArray(body)) {
46
+ (body as Record<string, unknown>)[f.field] = f.payload;
47
+ }
48
+ // ARV-161: mirror mass-assignment-probe — emit `form:` instead of
49
+ // `json:` for form-urlencoded endpoints (Stripe v1), otherwise the
50
+ // regression suite gets rejected with 400 "check that your POST
51
+ // content type is application/x-www-form-urlencoded" and never
52
+ // exercises the actual attack vector.
53
+ const bodyField =
54
+ ep.requestBodyContentType === "application/x-www-form-urlencoded"
55
+ ? { form: flattenToFormFields(body) }
56
+ : { json: body };
57
+ const step: RawStep = {
58
+ name: `${f.class}: ${f.field}=${shortPayload(f.payload)} must ${f.severity === "ok" ? "be rejected" : "not echo"}`,
59
+ source: {
60
+ generator: "probe-security",
61
+ endpoint: `${v.method} ${v.path}`,
62
+ response_branch: expected.map(String).join("|"),
63
+ },
64
+ [v.method]: convertPath(ep.path),
65
+ ...bodyField,
66
+ expect: { status: expected },
67
+ };
68
+ tests.push(step);
69
+ }
70
+ if (tests.length === 0) continue;
71
+ // Attach a generic cleanup step keyed off `created_id` (only fires when
72
+ // a previous step captured one — same `always:true` semantics other
73
+ // probes use).
74
+ const delEp = findDeleteCounterpart(ep, endpoints);
75
+ if (delEp) {
76
+ const idField = captureFieldFor(ep);
77
+ tests[0]!.expect.body = { ...(tests[0]!.expect.body ?? {}), [idField]: { capture: "created_id" } };
78
+ const idParam = (delEp.path.match(/\{([^}]+)\}/) ?? [])[1] ?? "id";
79
+ const delStep: RawStep = {
80
+ name: "cleanup",
81
+ source: { generator: "probe-security-cleanup", endpoint: `DELETE ${delEp.path}` },
82
+ always: true,
83
+ DELETE: convertPath(delEp.path).replace(`{{${idParam}}}`, "{{created_id}}"),
84
+ expect: { status: [200, 202, 204, 404] },
85
+ } as RawStep & { always: boolean };
86
+ tests.push(delStep);
87
+ }
88
+ suites.push({
89
+ name: `probe-security ${v.method} ${v.path}`,
90
+ tags: ["probe-security", ...result.classes],
91
+ source: {
92
+ type: "probe-suite",
93
+ generator: "probe-security",
94
+ endpoint: `${v.method} ${v.path}`,
95
+ },
96
+ fileStem: `probe-security-${endpointStem(ep)}`,
97
+ base_url: "{{base_url}}",
98
+ ...(suiteHeaders ? { headers: suiteHeaders } : {}),
99
+ tests,
100
+ });
101
+ }
102
+ return suites;
103
+ }
@@ -0,0 +1,151 @@
1
+ import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
2
+ import type { SeedBodyConfig } from "../../generator/resources-builder.ts";
3
+ import type { RecommendedAction } from "../../diagnostics/failure-hints.ts";
4
+
5
+ export type SecurityClass = "ssrf" | "crlf" | "open-redirect";
6
+
7
+ export const SECURITY_CLASSES: SecurityClass[] = ["ssrf", "crlf", "open-redirect"];
8
+
9
+ /**
10
+ * Security-probe severity ladder. Includes 'info' (ARV-253) for
11
+ * sanitization-only signals (CRLF accept-without-reflection) and
12
+ * 'medium' (ARV-254) for SSRF accept on endpoints declaring delivery
13
+ * semantics. The full m-21 matrix governs the cap: HIGH requires
14
+ * evidence_chain proof, OOB-backed SSRF lands here only when ARV-177
15
+ * lifts.
16
+ */
17
+ export type SecuritySeverity =
18
+ | "high"
19
+ | "medium"
20
+ | "low"
21
+ | "info"
22
+ | "inconclusive"
23
+ | "inconclusive-baseline"
24
+ | "ok"
25
+ | "skipped";
26
+
27
+ export interface SecurityFieldHit {
28
+ /** Field name in the request body. */
29
+ field: string;
30
+ /** Class that triggered (a field can hit multiple — we record all). */
31
+ class: SecurityClass;
32
+ }
33
+
34
+ export interface SecurityFinding {
35
+ field: string;
36
+ class: SecurityClass;
37
+ payload: string;
38
+ /** Raw HTTP status of the attack request. */
39
+ status: number;
40
+ /** Whether the response body echoes the payload (suggesting stored injection). */
41
+ echoed: boolean;
42
+ /** PASS / FAIL classification per finding. */
43
+ severity: SecuritySeverity;
44
+ reason: string;
45
+ /** TASK-294: agent-routable action. FAIL/WARN → `report_backend_bug`;
46
+ * PASS → undefined (no action needed). */
47
+ recommended_action?: RecommendedAction;
48
+ /** Reserved suppression trace (audit-trail). Currently unset — the
49
+ * agent triages severity from the raw finding. */
50
+ suppressed_by?: { source: string; rule_index: number; reason: string };
51
+ }
52
+
53
+ export interface SecurityVerdict {
54
+ method: string;
55
+ path: string;
56
+ /** Most-severe finding wins. */
57
+ severity: SecuritySeverity;
58
+ summary: string;
59
+ /** Field hits detected on this endpoint (some may have produced no findings). */
60
+ detectedFields: SecurityFieldHit[];
61
+ /** All attempted attacks. Empty for SKIPPED endpoints. */
62
+ findings: SecurityFinding[];
63
+ baseline?: { status: number };
64
+ cleanup?: {
65
+ attempted: boolean;
66
+ status?: number;
67
+ error?: string;
68
+ /** TASK-278: created resource id (slug/uuid/...) so `zond cleanup --orphans`
69
+ * can retry DELETE without re-running the probe. */
70
+ id?: string | number;
71
+ /** TASK-278: concrete DELETE URL path with the id substituted. */
72
+ deletePath?: string;
73
+ };
74
+ skipReason?: string;
75
+ }
76
+
77
+ export interface SecurityProbeOptions {
78
+ endpoints: EndpointInfo[];
79
+ securitySchemes: SecuritySchemeInfo[];
80
+ vars: Record<string, string>;
81
+ classes: SecurityClass[];
82
+ noCleanup?: boolean;
83
+ timeoutMs?: number;
84
+ /** When true, only print which endpoints/fields would be attacked. */
85
+ dryRun?: boolean;
86
+ /**
87
+ * DELETE-cleanup retry delays in ms (round-5: handles eventual
88
+ * consistency between write replica and read replica). Default
89
+ * `[200, 1000]` — two retries on 404, total worst-case ~1.2s. Tests
90
+ * pass `[]` to disable; ops can pass longer for laggier replicas.
91
+ */
92
+ cleanupRetryDelaysMs?: number[];
93
+ /** TASK-264: when true, refuse to attack PUT/PATCH/DELETE endpoints whose
94
+ * path-params are filled from `.env.yaml` (a.k.a. seeded fixtures). The
95
+ * trade-off: lower coverage (those endpoints get SKIPPED), but a
96
+ * guaranteed «probe doesn't mutate fixtures the user spent time
97
+ * bootstrapping» property. POST endpoints still run — they create their
98
+ * own resources, so isolation is automatic, with cleanup falling back to
99
+ * the existing DELETE-counterpart + orphan-tracker flow (TASK-278). */
100
+ isolated?: boolean;
101
+ /** ARV-140: opt-in to attacks that have no cleanup path (POSTs without a
102
+ * DELETE counterpart). By default we now skip them — round-01/02 Sentry
103
+ * runs left ~18 manually-cleanable orphans in prod because the probe
104
+ * happily POSTed to `/teams/`, `/symbol-sources/`, etc., where the spec
105
+ * has no DELETE. The pre-flight feasibility map drops these unless the
106
+ * caller explicitly accepts the leak. */
107
+ allowLeaks?: boolean;
108
+ /** ARV-269: agent-authored `seed_body` overlays from `.api-resources.local.yaml`,
109
+ * keyed by `"METHOD /path"`. Wins over `generateFromSchema` when the
110
+ * endpoint matches — see `MassAssignmentOptions.seedBodies` for the
111
+ * rationale (strict APIs reject random-scalar baselines). */
112
+ seedBodies?: Map<string, SeedBodyConfig>;
113
+ }
114
+
115
+ /** ARV-140: cleanup-feasibility map. Built once before the live loop so
116
+ * every POST verdict can see whether the spec has a DELETE counterpart;
117
+ * the summary digest also reports counts for skipped/forced endpoints.
118
+ *
119
+ * ARV-153 extends the status enum with "action": POSTs whose last path
120
+ * segment is a known action verb (`/capture`, `/verify`, `/cancel`, …)
121
+ * operate on an existing resource and never allocate a new one, so a
122
+ * DELETE counterpart isn't meaningful. These are attacked the same way
123
+ * as POSTs with a real DELETE — without `--allow-leaks` — because there
124
+ * is no resource to leak. */
125
+ export interface CleanupFeasibility {
126
+ status: Record<string, "has-delete" | "no-delete-counterpart" | "action">;
127
+ skippedNoCleanup: number;
128
+ forcedNoCleanup: number;
129
+ /** ARV-153: POSTs we attacked even though no DELETE counterpart exists,
130
+ * because the operation is semantically an action (no resource created). */
131
+ actionNoCleanupNeeded: number;
132
+ }
133
+
134
+ export interface SecurityProbeResult {
135
+ classes: SecurityClass[];
136
+ totalEndpoints: number;
137
+ specProbed: number;
138
+ verdicts: SecurityVerdict[];
139
+ warnings: string[];
140
+ /** ARV-140: cleanup-feasibility digest (POSTs without DELETE counterpart). */
141
+ cleanupFeasibility?: CleanupFeasibility;
142
+ }
143
+
144
+ /** Internal: per-step options shared between orchestrator/baseline/cleanup. */
145
+ export interface ProbeStepOpts {
146
+ noCleanup: boolean;
147
+ timeoutMs?: number;
148
+ cleanupRetryDelaysMs?: number[];
149
+ /** ARV-269: optional `seed_body` overlay for this endpoint. */
150
+ seedBody?: SeedBodyConfig;
151
+ }