@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
@@ -0,0 +1,1453 @@
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
+ * Why a CLI command rather than the markdown templates the audit skill
10
+ * shipped with: the templates produced one HIGH (stored CRLF in one real-world API) in
11
+ * 5 minutes — but it was hand-copied per endpoint. Spec-driven autodetection
12
+ * + a baseline-OK gate (TASK-138) turns that into a one-liner.
13
+ */
14
+ import type { OpenAPIV3 } from "openapi-types";
15
+ import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
16
+ import type { RecommendedAction } from "../diagnostics/failure-hints.ts";
17
+ import { classify as classifyRecommendedAction } from "../classifier/recommended-action.ts";
18
+ import type { RawSuite, RawStep } from "../generator/serializer.ts";
19
+ import { generateFromSchema } from "../generator/data-factory.ts";
20
+ import { executeRequest } from "../runner/http-client.ts";
21
+ import {
22
+ convertPath,
23
+ endpointStem,
24
+ findDeleteCounterpart,
25
+ findGetByIdCounterpart,
26
+ captureFieldFor,
27
+ hasJsonBody,
28
+ liveAuthHeaders,
29
+ getAuthHeaders,
30
+ pathTouchesSeededVar,
31
+ classifyPostSemantics,
32
+ } from "./shared.ts";
33
+ import { hasProbeBody, buildBodyAuthHeaders, serializeProbeBody } from "./probe-harness.ts";
34
+ import {
35
+ buildProbeUrl,
36
+ buildJsonAuthHeaders,
37
+ buildBaselineFromSpec,
38
+ } from "./probe-harness.ts";
39
+ import { applyAntiFp } from "../anti-fp/index.ts";
40
+ import type { BaselineEchoCtx } from "../anti-fp/rules/baseline-echo.ts";
41
+
42
+ // ──────────────────────────────────────────────
43
+ // Types
44
+ // ──────────────────────────────────────────────
45
+
46
+ export type SecurityClass = "ssrf" | "crlf" | "open-redirect";
47
+
48
+ export const SECURITY_CLASSES: SecurityClass[] = ["ssrf", "crlf", "open-redirect"];
49
+
50
+ /**
51
+ * Security-probe severity ladder. Includes 'info' (ARV-253) for
52
+ * sanitization-only signals (CRLF accept-without-reflection) and
53
+ * 'medium' (ARV-254) for SSRF accept on endpoints declaring delivery
54
+ * semantics. The full m-21 matrix governs the cap: HIGH requires
55
+ * evidence_chain proof, OOB-backed SSRF lands here only when ARV-177
56
+ * lifts.
57
+ */
58
+ export type SecuritySeverity =
59
+ | "high"
60
+ | "medium"
61
+ | "low"
62
+ | "info"
63
+ | "inconclusive"
64
+ | "inconclusive-baseline"
65
+ | "ok"
66
+ | "skipped";
67
+
68
+ export interface SecurityFieldHit {
69
+ /** Field name in the request body. */
70
+ field: string;
71
+ /** Class that triggered (a field can hit multiple — we record all). */
72
+ class: SecurityClass;
73
+ }
74
+
75
+ export interface SecurityFinding {
76
+ field: string;
77
+ class: SecurityClass;
78
+ payload: string;
79
+ /** Raw HTTP status of the attack request. */
80
+ status: number;
81
+ /** Whether the response body echoes the payload (suggesting stored injection). */
82
+ echoed: boolean;
83
+ /** PASS / FAIL classification per finding. */
84
+ severity: SecuritySeverity;
85
+ reason: string;
86
+ /** TASK-294: agent-routable action. FAIL/WARN → `report_backend_bug`;
87
+ * PASS → undefined (no action needed). */
88
+ recommended_action?: RecommendedAction;
89
+ }
90
+
91
+ export interface SecurityVerdict {
92
+ method: string;
93
+ path: string;
94
+ /** Most-severe finding wins. */
95
+ severity: SecuritySeverity;
96
+ summary: string;
97
+ /** Field hits detected on this endpoint (some may have produced no findings). */
98
+ detectedFields: SecurityFieldHit[];
99
+ /** All attempted attacks. Empty for SKIPPED endpoints. */
100
+ findings: SecurityFinding[];
101
+ baseline?: { status: number };
102
+ cleanup?: {
103
+ attempted: boolean;
104
+ status?: number;
105
+ error?: string;
106
+ /** TASK-278: created resource id (slug/uuid/...) so `zond cleanup --orphans`
107
+ * can retry DELETE without re-running the probe. */
108
+ id?: string | number;
109
+ /** TASK-278: concrete DELETE URL path with the id substituted. */
110
+ deletePath?: string;
111
+ };
112
+ skipReason?: string;
113
+ }
114
+
115
+ export interface SecurityProbeOptions {
116
+ endpoints: EndpointInfo[];
117
+ securitySchemes: SecuritySchemeInfo[];
118
+ vars: Record<string, string>;
119
+ classes: SecurityClass[];
120
+ noCleanup?: boolean;
121
+ timeoutMs?: number;
122
+ /** When true, only print which endpoints/fields would be attacked. */
123
+ dryRun?: boolean;
124
+ /**
125
+ * DELETE-cleanup retry delays in ms (round-5: handles eventual
126
+ * consistency between write replica and read replica). Default
127
+ * `[200, 1000]` — two retries on 404, total worst-case ~1.2s. Tests
128
+ * pass `[]` to disable; ops can pass longer for laggier replicas.
129
+ */
130
+ cleanupRetryDelaysMs?: number[];
131
+ /** TASK-264: when true, refuse to attack PUT/PATCH/DELETE endpoints whose
132
+ * path-params are filled from `.env.yaml` (a.k.a. seeded fixtures). The
133
+ * trade-off: lower coverage (those endpoints get SKIPPED), but a
134
+ * guaranteed «probe doesn't mutate fixtures the user spent time
135
+ * bootstrapping» property. POST endpoints still run — they create their
136
+ * own resources, so isolation is automatic, with cleanup falling back to
137
+ * the existing DELETE-counterpart + orphan-tracker flow (TASK-278). */
138
+ isolated?: boolean;
139
+ /** ARV-140: opt-in to attacks that have no cleanup path (POSTs without a
140
+ * DELETE counterpart). By default we now skip them — round-01/02 Sentry
141
+ * runs left ~18 manually-cleanable orphans in prod because the probe
142
+ * happily POSTed to `/teams/`, `/symbol-sources/`, etc., where the spec
143
+ * has no DELETE. The pre-flight feasibility map drops these unless the
144
+ * caller explicitly accepts the leak. */
145
+ allowLeaks?: boolean;
146
+ }
147
+
148
+ /** ARV-140: cleanup-feasibility map. Built once before the live loop so
149
+ * every POST verdict can see whether the spec has a DELETE counterpart;
150
+ * the summary digest also reports counts for skipped/forced endpoints.
151
+ *
152
+ * ARV-153 extends the status enum with "action": POSTs whose last path
153
+ * segment is a known action verb (`/capture`, `/verify`, `/cancel`, …)
154
+ * operate on an existing resource and never allocate a new one, so a
155
+ * DELETE counterpart isn't meaningful. These are attacked the same way
156
+ * as POSTs with a real DELETE — without `--allow-leaks` — because there
157
+ * is no resource to leak. */
158
+ export interface CleanupFeasibility {
159
+ status: Record<string, "has-delete" | "no-delete-counterpart" | "action">;
160
+ skippedNoCleanup: number;
161
+ forcedNoCleanup: number;
162
+ /** ARV-153: POSTs we attacked even though no DELETE counterpart exists,
163
+ * because the operation is semantically an action (no resource created). */
164
+ actionNoCleanupNeeded: number;
165
+ }
166
+
167
+ export interface SecurityProbeResult {
168
+ classes: SecurityClass[];
169
+ totalEndpoints: number;
170
+ specProbed: number;
171
+ verdicts: SecurityVerdict[];
172
+ warnings: string[];
173
+ /** ARV-140: cleanup-feasibility digest (POSTs without DELETE counterpart). */
174
+ cleanupFeasibility?: CleanupFeasibility;
175
+ }
176
+
177
+ // ──────────────────────────────────────────────
178
+ // Field detectors
179
+ // ──────────────────────────────────────────────
180
+
181
+ const SSRF_NAME_RE =
182
+ /(^url$|url$|webhook|callback|^redirect_uri$|^endpoint$|^uri$|^href$)/i;
183
+ const CRLF_NAME_RE =
184
+ /(^subject$|prefix$|^name$|^title$|^description$|^tag$|^message_subject$)/i;
185
+ const OPEN_REDIRECT_NAME_RE =
186
+ /(^redirect$|^next$|^return_to$|^redirect_url$|^redirect_to$|^redirectTo$)/i;
187
+
188
+ function matchesClass(
189
+ cls: SecurityClass,
190
+ name: string,
191
+ schema: OpenAPIV3.SchemaObject,
192
+ ): boolean {
193
+ // Skip enum-bounded fields — payload would obviously fail validation
194
+ // and we'd just waste requests on guaranteed-4xx attempts.
195
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) return false;
196
+ if (schema.type !== "string" && schema.type !== undefined) return false;
197
+ switch (cls) {
198
+ case "ssrf":
199
+ return SSRF_NAME_RE.test(name) || schema.format === "uri" || schema.format === "url";
200
+ case "crlf":
201
+ return CRLF_NAME_RE.test(name);
202
+ case "open-redirect":
203
+ return OPEN_REDIRECT_NAME_RE.test(name);
204
+ }
205
+ }
206
+
207
+ export function detectFields(
208
+ ep: EndpointInfo,
209
+ classes: SecurityClass[],
210
+ ): SecurityFieldHit[] {
211
+ const out: SecurityFieldHit[] = [];
212
+ const schema = ep.requestBodySchema;
213
+ if (!schema || !schema.properties) return out;
214
+ for (const [name, propSchema] of Object.entries(schema.properties)) {
215
+ const s = propSchema as OpenAPIV3.SchemaObject;
216
+ for (const cls of classes) {
217
+ if (matchesClass(cls, name, s)) out.push({ field: name, class: cls });
218
+ }
219
+ }
220
+ return out;
221
+ }
222
+
223
+ // ──────────────────────────────────────────────
224
+ // Payloads
225
+ // ──────────────────────────────────────────────
226
+
227
+ const PAYLOADS: Record<SecurityClass, string[]> = {
228
+ ssrf: [
229
+ "http://127.0.0.1:80/",
230
+ "http://169.254.169.254/latest/meta-data/",
231
+ "file:///etc/passwd",
232
+ ],
233
+ crlf: [
234
+ "zond-safe\r\nX-Zond-Injected: yes",
235
+ "zond-safe%0d%0aX-Zond-Injected: yes",
236
+ ],
237
+ "open-redirect": [
238
+ "https://zond-attacker.example.tld",
239
+ "//zond-attacker.example.tld",
240
+ ],
241
+ };
242
+
243
+ // ──────────────────────────────────────────────
244
+ // Live probe runner
245
+ // ──────────────────────────────────────────────
246
+
247
+ interface ProbeStepOpts {
248
+ noCleanup: boolean;
249
+ timeoutMs?: number;
250
+ cleanupRetryDelaysMs?: number[];
251
+ }
252
+
253
+ export async function runSecurityProbes(
254
+ opts: SecurityProbeOptions,
255
+ ): Promise<SecurityProbeResult> {
256
+ const verdicts: SecurityVerdict[] = [];
257
+ const warnings: string[] = [];
258
+ let totalEndpoints = 0;
259
+
260
+ // ARV-140: pre-flight cleanup-feasibility scan. For each POST target, look
261
+ // up the DELETE counterpart in the spec once. Without --allow-leaks any
262
+ // attack against a POST-without-DELETE is dropped — orphan tracker can't
263
+ // clean it (no DELETE path to retry) so it would linger in the user's
264
+ // tenant indefinitely (feedback round-01/02 Sentry: 18 manual cleanups).
265
+ const feasibility: CleanupFeasibility = {
266
+ status: {},
267
+ skippedNoCleanup: 0,
268
+ forcedNoCleanup: 0,
269
+ actionNoCleanupNeeded: 0,
270
+ };
271
+ for (const ep of opts.endpoints) {
272
+ if (ep.deprecated) continue;
273
+ if (ep.method.toUpperCase() !== "POST") continue;
274
+ const key = `POST ${ep.path}`;
275
+ // ARV-153: action POSTs (`/capture`, `/verify`, `/cancel`, …) don't
276
+ // allocate a new resource — there is nothing to DELETE. Attacking them
277
+ // without `--allow-leaks` is safe; classifying them up front prevents
278
+ // the feasibility pre-flight from masking 18/22 Stripe action endpoints.
279
+ const semantics = classifyPostSemantics(ep);
280
+ if (semantics === "action") {
281
+ feasibility.status[key] = "action";
282
+ feasibility.actionNoCleanupNeeded += 1;
283
+ continue;
284
+ }
285
+ const hasDelete = findDeleteCounterpart(ep, opts.endpoints) !== undefined;
286
+ feasibility.status[key] = hasDelete ? "has-delete" : "no-delete-counterpart";
287
+ if (!hasDelete) {
288
+ if (opts.allowLeaks) feasibility.forcedNoCleanup += 1;
289
+ else feasibility.skippedNoCleanup += 1;
290
+ }
291
+ }
292
+
293
+ for (const ep of opts.endpoints) {
294
+ if (ep.deprecated) continue;
295
+ const m = ep.method.toUpperCase();
296
+ if (m !== "POST" && m !== "PUT" && m !== "PATCH") continue;
297
+ totalEndpoints++;
298
+
299
+ // ARV-140: cleanup-feasibility gate. POST without a DELETE counterpart
300
+ // (and without --allow-leaks) is dropped before any live request fires.
301
+ // PUT/PATCH have snapshot/restore so they're unaffected here.
302
+ if (m === "POST" && !opts.allowLeaks) {
303
+ const status = feasibility.status[`POST ${ep.path}`];
304
+ if (status === "no-delete-counterpart") {
305
+ verdicts.push(skipped(ep, "skipped: no DELETE counterpart in spec (cleanup-feasibility pre-flight; pass --allow-leaks to override)"));
306
+ continue;
307
+ }
308
+ }
309
+
310
+ // TASK-264: --isolated guard. Mutation on a seeded fixture would corrupt
311
+ // user data the next `zond run` depends on; skip the endpoint instead.
312
+ if (opts.isolated && (m === "PUT" || m === "PATCH") && pathTouchesSeededVar(ep.path, opts.vars)) {
313
+ verdicts.push(skipped(ep, "skipped: --isolated mode protects seeded fixtures (PUT/PATCH on seeded path-params)"));
314
+ continue;
315
+ }
316
+
317
+ // ARV-161 (round-08 F18): parity with mass-assignment — accept
318
+ // application/x-www-form-urlencoded endpoints too. Stripe v1 declares
319
+ // user-controlled URL fields (webhook url, return_url, ...) only on
320
+ // form-encoded bodies; the previous JSON-only gate hid 78+ POSTs from
321
+ // SSRF/CRLF/open-redirect probing.
322
+ if (!hasProbeBody(ep)) {
323
+ verdicts.push(skipped(ep, "no JSON or form-urlencoded request body"));
324
+ continue;
325
+ }
326
+
327
+ const detected = detectFields(ep, opts.classes);
328
+ if (detected.length === 0) {
329
+ verdicts.push(skipped(ep, `no fields matched classes: ${opts.classes.join(",")}`));
330
+ continue;
331
+ }
332
+
333
+ if (opts.dryRun) {
334
+ verdicts.push({
335
+ method: m,
336
+ path: ep.path,
337
+ severity: "skipped",
338
+ summary: "dry-run: would attack " + detected.map(d => `${d.field}/${d.class}`).join(", "),
339
+ detectedFields: detected,
340
+ findings: [],
341
+ skipReason: "dry-run",
342
+ });
343
+ continue;
344
+ }
345
+
346
+ const verdict = await probeOneEndpoint(
347
+ ep,
348
+ opts.endpoints,
349
+ opts.securitySchemes,
350
+ opts.vars,
351
+ detected,
352
+ {
353
+ noCleanup: opts.noCleanup === true,
354
+ timeoutMs: opts.timeoutMs,
355
+ cleanupRetryDelaysMs: opts.cleanupRetryDelaysMs,
356
+ },
357
+ );
358
+ verdicts.push(verdict);
359
+ }
360
+
361
+ return {
362
+ classes: opts.classes,
363
+ totalEndpoints,
364
+ specProbed: verdicts.length,
365
+ verdicts,
366
+ warnings,
367
+ cleanupFeasibility: feasibility,
368
+ };
369
+ }
370
+
371
+ interface Snapshot {
372
+ /** Original GET-response body, used to restore state via PUT/PATCH. */
373
+ body: Record<string, unknown>;
374
+ /** ETag (if API uses optimistic locking) — sent back as `If-Match` on restore. */
375
+ etag?: string;
376
+ }
377
+
378
+ async function probeOneEndpoint(
379
+ ep: EndpointInfo,
380
+ allEndpoints: EndpointInfo[],
381
+ schemes: SecuritySchemeInfo[],
382
+ vars: Record<string, string>,
383
+ detected: SecurityFieldHit[],
384
+ opts: ProbeStepOpts,
385
+ ): Promise<SecurityVerdict> {
386
+ const m = ep.method.toUpperCase();
387
+ const verdict: SecurityVerdict = {
388
+ method: m,
389
+ path: ep.path,
390
+ severity: "ok",
391
+ summary: "",
392
+ detectedFields: detected,
393
+ findings: [],
394
+ };
395
+
396
+ // Build baseline body. Same recipe as mass-assignment: spec → generators → vars.
397
+ const baseline = buildBaselineFromSpec(ep, vars);
398
+ if (baseline === null) {
399
+ return skipped(ep, "request body not a JSON object");
400
+ }
401
+
402
+ const { url, unresolved } = buildProbeUrl(ep, vars);
403
+ if (unresolved.length > 0) {
404
+ return skipped(ep, `cannot resolve path placeholders: ${unresolved.join(", ")}`);
405
+ }
406
+
407
+ // ARV-161: Content-Type follows the spec — form-urlencoded for Stripe v1,
408
+ // JSON otherwise. All outbound payloads in this function (baseline, per-
409
+ // attack, restore-PUT) flow through serializeProbeBody for matching wire
410
+ // encoding.
411
+ const headers = buildBodyAuthHeaders(ep, schemes, vars);
412
+
413
+ // ── Snapshot original state (TASK-151) ────────────────────────────────
414
+ // For PUT/PATCH we MUST capture original state before any mutation. The
415
+ // old DELETE-cleanup is wrong for rename'ы — it can't undo a renamed
416
+ // DSN-key / team-name / webhook URL. Snapshot first, restore after each
417
+ // 2xx. POST falls back to DELETE-cleanup (correct semantics there).
418
+ const isUpdate = m === "PUT" || m === "PATCH";
419
+ const snapshot = isUpdate && !opts.noCleanup
420
+ ? await snapshotOriginal(ep, allEndpoints, schemes, vars, opts)
421
+ : null;
422
+
423
+ // ── Baseline-OK gate ────────────────────────────────────────────────────
424
+ // Eliminates the "5 × 404" output the markdown template produced in the
425
+ // audit. If baseline isn't 2xx, attacks would just hit the same 4xx
426
+ // wall and tell us nothing.
427
+ const fullBaseline = await sendBaseline(ep, m, url, headers,baseline, opts);
428
+ if (fullBaseline.kind === "network") {
429
+ verdict.severity = "high";
430
+ verdict.summary = `baseline network error: ${fullBaseline.reason}`;
431
+ return verdict;
432
+ }
433
+ verdict.baseline = { status: fullBaseline.status };
434
+
435
+ // ── Partial-body fallback (TASK-152) ──────────────────────────────────
436
+ // common SaaS-style APIs accept partial PUT — full bodies
437
+ // generated from spec get rejected (422 / 400). Walking each detected
438
+ // field with a single-key body recovers the proven-HIGH cases that
439
+ // otherwise fall into INCONCLUSIVE-BASELINE.
440
+ let fullOk = fullBaseline.kind === "ok" && fullBaseline.status >= 200 && fullBaseline.status < 300;
441
+ const perFieldBaseline = new Map<string, Record<string, unknown>>();
442
+ if (!fullOk && isUpdate && fullBaseline.kind === "ok") {
443
+ for (const hit of detected) {
444
+ // Reuse spec value when present; otherwise fall back to the substituted
445
+ // generator output for the field. Either way the partial body has
446
+ // exactly one key, which is what partial-PUT APIs accept.
447
+ const partial: Record<string, unknown> = {};
448
+ if (hit.field in baseline) partial[hit.field] = baseline[hit.field];
449
+ else partial[hit.field] = "";
450
+ const partResp = await sendBaseline(ep, m, url, headers,partial, opts);
451
+ if (partResp.kind === "ok" && partResp.status >= 200 && partResp.status < 300) {
452
+ perFieldBaseline.set(hit.field, partial);
453
+ }
454
+ }
455
+ if (perFieldBaseline.size > 0 && snapshot) {
456
+ // Each successful partial baseline mutated only its single key; restore
457
+ // exactly those before attacks start.
458
+ await restoreOriginal(
459
+ ep, snapshot, headers, schemes, vars, opts, verdict,
460
+ perFieldBaseline.keys(),
461
+ );
462
+ }
463
+ }
464
+
465
+ if (!fullOk && perFieldBaseline.size === 0) {
466
+ // fullBaseline.kind === "network" was already returned above; here it
467
+ // must be "ok" with non-2xx status.
468
+ const status = fullBaseline.kind === "ok" ? fullBaseline.status : 0;
469
+ verdict.severity = "inconclusive-baseline";
470
+ verdict.summary = isUpdate
471
+ ? `baseline ${status} on full body; partial-body per-field also rejected — fixture/scope issue`
472
+ : `baseline ${status} — endpoint unreachable or fixture invalid; skipping attacks`;
473
+ return verdict;
474
+ }
475
+
476
+ // Cleanup state mutated by the (full) baseline, before issuing attacks.
477
+ // With a snapshot → restore PUT (full baseline mutated every key).
478
+ // Without snapshot → DELETE-counterpart (POST flow).
479
+ if (fullOk && fullBaseline.kind === "ok" && !opts.noCleanup) {
480
+ if (snapshot) {
481
+ await restoreOriginal(
482
+ ep, snapshot, headers, schemes, vars, opts, verdict,
483
+ Object.keys(baseline),
484
+ );
485
+ } else {
486
+ await tryCleanup(
487
+ ep, allEndpoints, schemes, vars,
488
+ fullBaseline.body, verdict, opts,
489
+ );
490
+ }
491
+ }
492
+
493
+ // ── Attacks ──────────────────────────────────────────────────────────────
494
+ for (const hit of detected) {
495
+ // Pick the body shape that this endpoint actually accepts.
496
+ let baseBody: Record<string, unknown> | undefined;
497
+ let mode: "full" | "partial" | "none" = "none";
498
+ if (fullOk) {
499
+ baseBody = baseline;
500
+ mode = "full";
501
+ } else if (perFieldBaseline.has(hit.field)) {
502
+ baseBody = perFieldBaseline.get(hit.field)!;
503
+ mode = "partial";
504
+ }
505
+
506
+ if (mode === "none" || !baseBody) {
507
+ // Field doesn't have a usable baseline body shape — record one
508
+ // INCONCLUSIVE per payload so the digest still exposes the field.
509
+ for (const payload of PAYLOADS[hit.class]) {
510
+ verdict.findings.push({
511
+ field: hit.field,
512
+ class: hit.class,
513
+ payload,
514
+ status: 0,
515
+ echoed: false,
516
+ severity: "inconclusive",
517
+ reason: "no baseline body shape accepted (full+partial both rejected)",
518
+ });
519
+ }
520
+ continue;
521
+ }
522
+
523
+ for (const payload of PAYLOADS[hit.class]) {
524
+ const body = { ...baseBody, [hit.field]: payload };
525
+ let resp;
526
+ try {
527
+ resp = await executeRequest(
528
+ { method: m, url, headers, body: serializeProbeBody(ep, body).content },
529
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
530
+ );
531
+ } catch (err) {
532
+ verdict.findings.push({
533
+ field: hit.field,
534
+ class: hit.class,
535
+ payload,
536
+ status: 0,
537
+ echoed: false,
538
+ severity: "inconclusive",
539
+ reason: `network error: ${err instanceof Error ? err.message : String(err)}`,
540
+ });
541
+ continue;
542
+ }
543
+ const finding = classify(hit, payload, resp, { endpoint: ep });
544
+ // ARV-126: route the 2xx-no-echo low-severity classification
545
+ // through the anti-FP registry. When the response body deeply
546
+ // equals the baseline body, the server ignored the attack
547
+ // payload entirely — no side-effect to verify — and the
548
+ // `baseline-echo` rule downgrades the finding to OK with a
549
+ // wontfix banner. Only relevant for `mode === "full"` (we don't
550
+ // retain per-field baseline response bodies).
551
+ if (
552
+ finding.severity === "low"
553
+ && !finding.echoed
554
+ && mode === "full"
555
+ && fullBaseline.kind === "ok"
556
+ ) {
557
+ const ctx: BaselineEchoCtx = {
558
+ responseBody: resp.body_parsed ?? resp.body,
559
+ baselineBody: fullBaseline.body,
560
+ };
561
+ const suppression = applyAntiFp(ctx, "probe:security");
562
+ if (suppression) {
563
+ finding.severity = "ok";
564
+ finding.reason = `${suppression.reason} (${suppression.ruleId})`;
565
+ }
566
+ }
567
+ // Annotate which body shape was used for this attack — useful for
568
+ // case-studies and emit-tests.
569
+ finding.reason = mode === "partial"
570
+ ? `${finding.reason} [partial-body]`
571
+ : finding.reason;
572
+ verdict.findings.push(finding);
573
+
574
+ // Per-finding cleanup. Snapshot path takes precedence — DELETE on a
575
+ // PUT-rename'd resource would wipe a live entity, restore-PUT puts
576
+ // it back to the captured original. Only restore the single field
577
+ // this attack mutated — sending a multi-key body trips
578
+ // `422 use partial PUT` on common SaaS-shaped APIs.
579
+ if (resp.status >= 200 && resp.status < 300 && !opts.noCleanup) {
580
+ if (snapshot) {
581
+ await restoreOriginal(
582
+ ep, snapshot, headers, schemes, vars, opts, verdict,
583
+ [hit.field],
584
+ );
585
+ } else {
586
+ await tryCleanup(
587
+ ep, allEndpoints, schemes, vars,
588
+ resp.body_parsed ?? resp.body, verdict, opts,
589
+ );
590
+ }
591
+ }
592
+ }
593
+ }
594
+
595
+ // Roll up to the worst severity. ARV-253: "info" sits below "low"
596
+ // (single_signal sanitization-only). ARV-254: "medium" sits between
597
+ // "high" and "low" (SSRF accept on endpoint declaring delivery).
598
+ const severities: SecuritySeverity[] = verdict.findings.map(f => f.severity);
599
+ if (severities.includes("high")) verdict.severity = "high";
600
+ else if (severities.includes("inconclusive")) verdict.severity = "inconclusive";
601
+ else if (severities.includes("medium")) verdict.severity = "medium";
602
+ else if (severities.includes("low")) verdict.severity = "low";
603
+ else if (severities.includes("info")) verdict.severity = "info";
604
+ else verdict.severity = "ok";
605
+
606
+ verdict.summary = summaryLine(verdict);
607
+ return verdict;
608
+ }
609
+
610
+ // ──────────────────────────────────────────────
611
+ // Baseline send — wraps executeRequest with shape that distinguishes a real
612
+ // HTTP response from a network error (so the caller can decide whether to
613
+ // retry partial-body / mark the endpoint unreachable).
614
+ // ──────────────────────────────────────────────
615
+
616
+ type BaselineResult =
617
+ | { kind: "ok"; status: number; body: unknown; headers: Record<string, string> }
618
+ | { kind: "network"; reason: string };
619
+
620
+ async function sendBaseline(
621
+ ep: EndpointInfo,
622
+ method: string,
623
+ url: string,
624
+ headers: Record<string, string>,
625
+ body: unknown,
626
+ opts: ProbeStepOpts,
627
+ ): Promise<BaselineResult> {
628
+ try {
629
+ // ARV-161: serialize via serializeProbeBody so form-encoded endpoints
630
+ // get x-www-form-urlencoded payload matching Content-Type.
631
+ const wire = body && typeof body === "object" && !Array.isArray(body)
632
+ ? serializeProbeBody(ep, body as Record<string, unknown>).content
633
+ : JSON.stringify(body);
634
+ const resp = await executeRequest(
635
+ { method, url, headers, body: wire },
636
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
637
+ );
638
+ return {
639
+ kind: "ok",
640
+ status: resp.status,
641
+ body: resp.body_parsed ?? resp.body,
642
+ headers: resp.headers ?? {},
643
+ };
644
+ } catch (err) {
645
+ return {
646
+ kind: "network",
647
+ reason: err instanceof Error ? err.message : String(err),
648
+ };
649
+ }
650
+ }
651
+
652
+ // ──────────────────────────────────────────────
653
+ // TASK-151: snapshot + restore for stateful PUT/PATCH endpoints.
654
+ // ──────────────────────────────────────────────
655
+
656
+ async function snapshotOriginal(
657
+ ep: EndpointInfo,
658
+ allEndpoints: EndpointInfo[],
659
+ schemes: SecuritySchemeInfo[],
660
+ vars: Record<string, string>,
661
+ opts: ProbeStepOpts,
662
+ ): Promise<Snapshot | null> {
663
+ const getEp = findGetByIdCounterpart(ep, allEndpoints);
664
+ if (!getEp) return null;
665
+ const { url, unresolved } = buildProbeUrl(getEp, vars);
666
+ if (unresolved.length > 0) return null;
667
+ const reqHeaders: Record<string, string> = {
668
+ accept: "application/json",
669
+ ...liveAuthHeaders(getEp, schemes, vars),
670
+ };
671
+ let resp;
672
+ try {
673
+ resp = await executeRequest(
674
+ { method: "GET", url, headers: reqHeaders },
675
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
676
+ );
677
+ } catch {
678
+ return null;
679
+ }
680
+ if (resp.status < 200 || resp.status >= 300) return null;
681
+ const body = resp.body_parsed ?? resp.body;
682
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
683
+
684
+ const respHeaders = resp.headers ?? {};
685
+ const etag =
686
+ respHeaders["etag"] ??
687
+ respHeaders["ETag"] ??
688
+ respHeaders["Etag"];
689
+
690
+ return {
691
+ body: body as Record<string, unknown>,
692
+ etag: typeof etag === "string" ? etag : undefined,
693
+ };
694
+ }
695
+
696
+ /**
697
+ * Restore the original state captured by `snapshotOriginal`. Sends a
698
+ * minimal PUT/PATCH containing only the fields the probe mutated —
699
+ * sending the full snapshot body trips `422 use partial PUT` on
700
+ * SaaS-shaped APIs (round-4 regression), so we replay each
701
+ * dirty field as its own single-key request.
702
+ *
703
+ * `verdict.cleanup.error` is **accumulated** across calls (not
704
+ * overwritten) so a single restore failure during the run is still
705
+ * visible in the digest.
706
+ */
707
+ async function restoreOriginal(
708
+ ep: EndpointInfo,
709
+ snapshot: Snapshot,
710
+ baseHeaders: Record<string, string>,
711
+ _schemes: SecuritySchemeInfo[],
712
+ vars: Record<string, string>,
713
+ opts: ProbeStepOpts,
714
+ verdict: SecurityVerdict,
715
+ dirtyFields: Iterable<string>,
716
+ ): Promise<void> {
717
+ const m = ep.method.toUpperCase();
718
+ const { url, unresolved } = buildProbeUrl(ep, vars);
719
+ if (unresolved.length > 0) return;
720
+ const headers: Record<string, string> = { ...baseHeaders };
721
+ if (snapshot.etag && ep.requiresEtag) {
722
+ headers["If-Match"] = snapshot.etag;
723
+ }
724
+ // Filter out fields the API will reject as read-only.
725
+ const READ_ONLY = new Set([
726
+ "id", "created_at", "createdAt", "updated_at", "updatedAt",
727
+ ]);
728
+ const fields = Array.from(new Set(Array.from(dirtyFields))).filter(
729
+ f => !READ_ONLY.has(f) && f in snapshot.body,
730
+ );
731
+
732
+ // Per-field PUT — works for both partial-PUT APIs and
733
+ // full-PUT APIs (the body just carries one of the legal keys).
734
+ const failures: string[] = [];
735
+ let lastSuccessStatus = 0;
736
+ let attempted = false;
737
+ for (const field of fields) {
738
+ attempted = true;
739
+ const body: Record<string, unknown> = { [field]: snapshot.body[field] };
740
+ let resp;
741
+ try {
742
+ resp = await executeRequest(
743
+ { method: m, url, headers, body: serializeProbeBody(ep, body).content },
744
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
745
+ );
746
+ } catch (err) {
747
+ failures.push(
748
+ `restore.${field} network error: ${err instanceof Error ? err.message : String(err)}`,
749
+ );
750
+ continue;
751
+ }
752
+ if (resp.status < 200 || resp.status >= 300) {
753
+ failures.push(`restore.${field} failed: ${resp.status}`);
754
+ continue;
755
+ }
756
+ lastSuccessStatus = resp.status;
757
+ }
758
+
759
+ // Merge with any prior cleanup state on this verdict.
760
+ const prior = verdict.cleanup ?? { attempted: false };
761
+ const allErrors = [
762
+ ...(prior.error ? [prior.error] : []),
763
+ ...failures,
764
+ ];
765
+ verdict.cleanup = {
766
+ attempted: attempted || prior.attempted,
767
+ ...(lastSuccessStatus ? { status: lastSuccessStatus } : prior.status ? { status: prior.status } : {}),
768
+ ...(allErrors.length > 0 ? { error: allErrors.join(" | ") } : {}),
769
+ };
770
+ }
771
+
772
+ /** ARV-56: route through the single classifier. */
773
+ function stampAction(f: SecurityFinding): SecurityFinding {
774
+ const action = classifyRecommendedAction({
775
+ finding_class: "probe:security",
776
+ severity: f.severity as Parameters<typeof classifyRecommendedAction>[0]["severity"],
777
+ });
778
+ if (action) f.recommended_action = action;
779
+ return f;
780
+ }
781
+
782
+ interface ClassifyResp {
783
+ status: number;
784
+ body?: unknown;
785
+ body_parsed?: unknown;
786
+ headers?: Record<string, string>;
787
+ }
788
+
789
+ function classify(
790
+ hit: SecurityFieldHit,
791
+ payload: string,
792
+ resp: ClassifyResp,
793
+ ctx: { endpoint?: EndpointInfo } = {},
794
+ ): SecurityFinding {
795
+ return stampAction(classifyInner(hit, payload, resp, ctx));
796
+ }
797
+
798
+ /**
799
+ * ARV-254: detect whether an endpoint declares delivery semantics for
800
+ * a URL field — i.e. the server is documented to actually hit the URL
801
+ * (webhook receiver, push subscription, callback).
802
+ *
803
+ * Without OOB infrastructure (interactsh / Burp Collaborator —
804
+ * deferred to ARV-177 post-pivot), zond can't prove the server fetched
805
+ * the URL. So SSRF "accept" lands as LOW by default. But if the spec
806
+ * declares delivery, we know the URL gets fetched on some schedule,
807
+ * which raises the stakes — surface as MEDIUM with an explicit
808
+ * disclaimer that OOB verification is still required for HIGH.
809
+ *
810
+ * Heuristic: path or tag contains "webhook" / "callback" / "subscription"
811
+ * (case-insensitive). When ARV-189 lands, this also reads
812
+ * `x-zond-delivery: true` from the spec.
813
+ */
814
+ function endpointDeclaresDelivery(ep: EndpointInfo | undefined): boolean {
815
+ if (!ep) return false;
816
+ const haystacks: string[] = [ep.path.toLowerCase()];
817
+ if (Array.isArray(ep.tags)) {
818
+ for (const t of ep.tags) haystacks.push(String(t).toLowerCase());
819
+ }
820
+ return haystacks.some((h) => /webhook|callback|subscription/.test(h));
821
+ }
822
+
823
+ /**
824
+ * Check whether the CRLF payload reflects into any response header
825
+ * value. ARV-253: header reflection is the smoking gun for CRLF —
826
+ * response splitting / header injection becomes exploitable as soon as
827
+ * the server emits attacker-controlled bytes in headers.
828
+ *
829
+ * We check raw payload AND its URL-decoded form so encodings like
830
+ * `%0d%0a` survive the comparison.
831
+ */
832
+ function reflectsInHeaders(payload: string, headers: Record<string, string> | undefined): string | null {
833
+ if (!headers || !payload) return null;
834
+ const decoded = safeDecodeURI(payload);
835
+ const variants = [payload, decoded].filter((v) => v && v.length >= 3);
836
+ for (const [name, value] of Object.entries(headers)) {
837
+ for (const v of variants) {
838
+ if (value.includes(v)) return name;
839
+ }
840
+ }
841
+ return null;
842
+ }
843
+
844
+ function isHtmlContentType(headers: Record<string, string> | undefined): boolean {
845
+ const ct = headers?.["content-type"] ?? headers?.["Content-Type"] ?? "";
846
+ return /text\/html|application\/xhtml/i.test(ct);
847
+ }
848
+
849
+ function classifyInner(
850
+ hit: SecurityFieldHit,
851
+ payload: string,
852
+ resp: ClassifyResp,
853
+ ctx: { endpoint?: EndpointInfo } = {},
854
+ ): SecurityFinding {
855
+ const status = resp.status;
856
+ const echo = classifyEcho(resp.body_parsed ?? resp.body, payload, hit.class);
857
+ const echoed = echo.matched;
858
+
859
+ if (status >= 500) {
860
+ // ARV-250: 5xx on attack payload is a reliability signal, not a
861
+ // proven security issue. Single-signal proof (one crashed response)
862
+ // caps severity at LOW per the m-21 severity matrix. ARV-251
863
+ // relocates this signal to the reliability category; the existing
864
+ // `not_a_server_error` check already tracks 5xx on positive input,
865
+ // so the security probe here is a secondary signal at best.
866
+ return {
867
+ field: hit.field,
868
+ class: hit.class,
869
+ payload,
870
+ status,
871
+ echoed,
872
+ severity: "low",
873
+ reason: `5xx unhandled — server crashed on ${hit.class} payload (reliability signal; see also not_a_server_error check)`,
874
+ };
875
+ }
876
+ if (status >= 200 && status < 300) {
877
+ // ARV-253: CRLF severity now keyed on reflection context, not on
878
+ // raw echo. The pivot principle: HIGH requires evidence the stored
879
+ // payload reaches a dangerous rendering context (header value /
880
+ // unescaped HTML). Echo in a JSON body alone is single_signal —
881
+ // storage is real, exploit pathway is not. Caps at LOW.
882
+ if (hit.class === "crlf") {
883
+ const headerName = reflectsInHeaders(payload, resp.headers);
884
+ if (headerName) {
885
+ return {
886
+ field: hit.field,
887
+ class: hit.class,
888
+ payload,
889
+ status,
890
+ echoed: true,
891
+ severity: "high",
892
+ reason: `payload reflected in response header \`${headerName}\` — response-splitting / header-injection candidate (evidence_chain)`,
893
+ };
894
+ }
895
+ if (echoed && isHtmlContentType(resp.headers)) {
896
+ return {
897
+ field: hit.field,
898
+ class: hit.class,
899
+ payload,
900
+ status,
901
+ echoed,
902
+ severity: "high",
903
+ reason: `payload echoed (${echo.kind}) in text/html response — unescaped reflection candidate (evidence_chain)`,
904
+ };
905
+ }
906
+ if (echoed) {
907
+ return {
908
+ field: hit.field,
909
+ class: hit.class,
910
+ payload,
911
+ status,
912
+ echoed,
913
+ severity: "low",
914
+ reason: `payload echoed (${echo.kind}) in JSON body — storage observed, no dangerous-context reflection. Manual follow-up: check whether the stored value reaches a downstream renderer (HTML page, RSS, custom header).`,
915
+ };
916
+ }
917
+ return {
918
+ field: hit.field,
919
+ class: hit.class,
920
+ payload,
921
+ status,
922
+ echoed: false,
923
+ severity: "info",
924
+ reason: `${status} accepted ${hit.class} payload but no reflection observed — sanitization may be missing but no exploit pathway proven`,
925
+ };
926
+ }
927
+ // ARV-254: SSRF / open-redirect severity rebalance.
928
+ //
929
+ // Without an out-of-band (OOB) channel zond can't prove the server
930
+ // actually fetched the injected URL. "API accepted 169.254" is
931
+ // single_signal proof — caps at LOW per the m-21 matrix.
932
+ //
933
+ // Stake-raising signal: when the spec declares delivery semantics
934
+ // (path/tag mentions webhook / callback / subscription), the server
935
+ // is documented to fetch the URL — surface MEDIUM. Full HIGH is
936
+ // gated on OOB confirmation which lands with ARV-177 (deferred-
937
+ // post-pivot, out of scope for now).
938
+ const declaresDelivery = endpointDeclaresDelivery(ctx.endpoint);
939
+ const oobDisclaimer = "no OOB channel — accept ≠ proven fetch. Verify with Burp Collaborator / interactsh manually for HIGH severity.";
940
+ if (echoed) {
941
+ const label = echo.kind === "verbatim"
942
+ ? "payload echoed verbatim"
943
+ : `payload echoed (${echo.kind})`;
944
+ if (declaresDelivery) {
945
+ return {
946
+ field: hit.field,
947
+ class: hit.class,
948
+ payload,
949
+ status,
950
+ echoed,
951
+ severity: "low",
952
+ reason: `${label}; ${hit.class}: endpoint declares delivery (webhook/callback) but ${oobDisclaimer}`,
953
+ };
954
+ }
955
+ return {
956
+ field: hit.field,
957
+ class: hit.class,
958
+ payload,
959
+ status,
960
+ echoed,
961
+ severity: "low",
962
+ reason: `${label} — stored ${hit.class} candidate; ${oobDisclaimer}`,
963
+ };
964
+ }
965
+ if (declaresDelivery) {
966
+ return {
967
+ field: hit.field,
968
+ class: hit.class,
969
+ payload,
970
+ status,
971
+ echoed,
972
+ severity: "medium",
973
+ reason: `2xx accepted ${hit.class} payload on endpoint declaring delivery semantics (webhook/callback). ${oobDisclaimer}`,
974
+ };
975
+ }
976
+ return {
977
+ field: hit.field,
978
+ class: hit.class,
979
+ payload,
980
+ status,
981
+ echoed,
982
+ severity: "low",
983
+ reason: `2xx accepted ${hit.class} payload but no echo observed. ${oobDisclaimer}`,
984
+ };
985
+ }
986
+ if (status >= 400) {
987
+ return {
988
+ field: hit.field,
989
+ class: hit.class,
990
+ payload,
991
+ status,
992
+ echoed,
993
+ severity: "ok",
994
+ reason: `${status} rejected — ${hit.class} payload refused`,
995
+ };
996
+ }
997
+ return {
998
+ field: hit.field,
999
+ class: hit.class,
1000
+ payload,
1001
+ status,
1002
+ echoed,
1003
+ severity: "inconclusive",
1004
+ reason: `unexpected status ${status}`,
1005
+ };
1006
+ }
1007
+
1008
+ function bodyToString(body: unknown): string {
1009
+ if (!body) return "";
1010
+ if (typeof body === "string") return body;
1011
+ // Walk object/array, concatenating raw string leaves so CR/LF chars aren't
1012
+ // hidden behind JSON escape sequences (\r → "\\r" after JSON.stringify).
1013
+ const parts: string[] = [];
1014
+ const seen = new WeakSet<object>();
1015
+ const visit = (v: unknown): void => {
1016
+ if (typeof v === "string") parts.push(v);
1017
+ else if (v && typeof v === "object") {
1018
+ if (seen.has(v as object)) return;
1019
+ seen.add(v as object);
1020
+ if (Array.isArray(v)) v.forEach(visit);
1021
+ else for (const k of Object.keys(v as object)) visit((v as Record<string, unknown>)[k]);
1022
+ }
1023
+ };
1024
+ try {
1025
+ visit(body);
1026
+ } catch {
1027
+ return "";
1028
+ }
1029
+ return parts.join("\n");
1030
+ }
1031
+
1032
+ function safeDecodeURI(s: string): string {
1033
+ try {
1034
+ return decodeURIComponent(s);
1035
+ } catch {
1036
+ return s;
1037
+ }
1038
+ }
1039
+
1040
+ type EchoKind =
1041
+ | "verbatim"
1042
+ | "url-decoded"
1043
+ | "CR stripped"
1044
+ | "LF stripped"
1045
+ | "CRLF→LF"
1046
+ | "CRLF→CR"
1047
+ | "tail after CRLF";
1048
+
1049
+ interface EchoResult {
1050
+ matched: boolean;
1051
+ kind: EchoKind | "none";
1052
+ }
1053
+
1054
+ export function classifyEcho(body: unknown, payload: string, cls: SecurityClass): EchoResult {
1055
+ if (!payload) return { matched: false, kind: "none" };
1056
+ const haystackRaw = bodyToString(body);
1057
+ if (!haystackRaw) return { matched: false, kind: "none" };
1058
+
1059
+ // SSRF / open-redirect: verbatim only — URLs are usually preserved as-is.
1060
+ if (cls !== "crlf") {
1061
+ return haystackRaw.includes(payload)
1062
+ ? { matched: true, kind: "verbatim" }
1063
+ : { matched: false, kind: "none" };
1064
+ }
1065
+
1066
+ // CRLF: try verbatim → URL-decode pairs → CR/LF normalization variants → tail.
1067
+ if (haystackRaw.includes(payload)) return { matched: true, kind: "verbatim" };
1068
+
1069
+ const haystackDecoded = safeDecodeURI(haystackRaw);
1070
+ const payloadDecoded = safeDecodeURI(payload);
1071
+
1072
+ if (
1073
+ (payloadDecoded !== payload && haystackRaw.includes(payloadDecoded)) ||
1074
+ (haystackDecoded !== haystackRaw && haystackDecoded.includes(payload)) ||
1075
+ (payloadDecoded !== payload && haystackDecoded !== haystackRaw && haystackDecoded.includes(payloadDecoded))
1076
+ ) {
1077
+ return { matched: true, kind: "url-decoded" };
1078
+ }
1079
+
1080
+ // Normalize: try variants of payload where backend stripped CR or LF.
1081
+ const variants: Array<[string, EchoKind]> = [];
1082
+ if (payloadDecoded.includes("\r\n")) {
1083
+ variants.push([payloadDecoded.replace(/\r\n/g, "\n"), "CRLF→LF"]);
1084
+ variants.push([payloadDecoded.replace(/\r\n/g, "\r"), "CRLF→CR"]);
1085
+ variants.push([payloadDecoded.replace(/\r\n/g, ""), "CRLF→LF"]);
1086
+ }
1087
+ if (payloadDecoded.includes("\r")) variants.push([payloadDecoded.replace(/\r/g, ""), "CR stripped"]);
1088
+ if (payloadDecoded.includes("\n")) variants.push([payloadDecoded.replace(/\n/g, ""), "LF stripped"]);
1089
+
1090
+ for (const [variant, kind] of variants) {
1091
+ if (variant && variant !== payloadDecoded && (haystackRaw.includes(variant) || haystackDecoded.includes(variant))) {
1092
+ return { matched: true, kind };
1093
+ }
1094
+ }
1095
+
1096
+ // Tail-substring: parser truncated at newline, only suffix landed in storage.
1097
+ const splitMatch = payloadDecoded.match(/(?:\r\n|%0d%0a|%0a|%0d|\r|\n)(.+)$/i);
1098
+ const tail = splitMatch?.[1];
1099
+ if (tail && tail.length >= 3 && (haystackRaw.includes(tail) || haystackDecoded.includes(tail))) {
1100
+ return { matched: true, kind: "tail after CRLF" };
1101
+ }
1102
+
1103
+ return { matched: false, kind: "none" };
1104
+ }
1105
+
1106
+ function summaryLine(v: SecurityVerdict): string {
1107
+ const counts: Record<SecuritySeverity, number> = {
1108
+ high: 0, medium: 0, low: 0, info: 0, inconclusive: 0, "inconclusive-baseline": 0, ok: 0, skipped: 0,
1109
+ };
1110
+ for (const f of v.findings) counts[f.severity]++;
1111
+ const fields = Array.from(new Set(v.detectedFields.map(d => d.field))).join(", ");
1112
+ return `fields=[${fields}] · HIGH=${counts.high} MED=${counts.medium} LOW=${counts.low} INFO=${counts.info} INCONCLUSIVE=${counts.inconclusive} OK=${counts.ok}`;
1113
+ }
1114
+
1115
+ // ──────────────────────────────────────────────
1116
+ // Cleanup helper — best-effort DELETE on stateful endpoints.
1117
+ // ──────────────────────────────────────────────
1118
+
1119
+ async function tryCleanup(
1120
+ ep: EndpointInfo,
1121
+ allEndpoints: EndpointInfo[],
1122
+ schemes: SecuritySchemeInfo[],
1123
+ vars: Record<string, string>,
1124
+ responseBody: unknown,
1125
+ verdict: SecurityVerdict,
1126
+ opts: ProbeStepOpts,
1127
+ ): Promise<void> {
1128
+ const delEp = findDeleteCounterpart(ep, allEndpoints);
1129
+ if (!delEp) {
1130
+ // Surface the gap. Round-4 dogfooding: 3 DSN keys leaked from
1131
+ // POST /keys/ silently because the spec didn't expose a DELETE
1132
+ // counterpart — flagging it in the digest gives the operator a
1133
+ // chance to clean up by hand instead of finding out later.
1134
+ accumulateCleanupError(verdict, `no DELETE counterpart for ${ep.method.toUpperCase()} ${ep.path}; possible leaked resource`);
1135
+ return;
1136
+ }
1137
+ const idField = captureFieldFor(ep);
1138
+ const id = pickId(responseBody, idField);
1139
+ if (!id) {
1140
+ accumulateCleanupError(verdict, `cleanup skipped: response had no usable id for ${ep.method.toUpperCase()} ${ep.path}`);
1141
+ return;
1142
+ }
1143
+ // DELETE path has one path-param at the end; replace it with the captured id.
1144
+ const concretePath = delEp.path.replace(/\{[^}]+\}/, encodeURIComponent(String(id)));
1145
+ const url = `${(vars["base_url"] ?? "").replace(/\/+$/, "")}${concretePath}`;
1146
+ const headers = liveAuthHeaders(delEp, schemes, vars);
1147
+
1148
+ // TASK-278: stash id + deletePath on the verdict so the orphan tracker
1149
+ // (and `zond cleanup --orphans`) can replay this DELETE without re-running
1150
+ // the probe. Done before retries so even an aborted run leaves a trace.
1151
+ {
1152
+ const prior = verdict.cleanup ?? { attempted: false };
1153
+ verdict.cleanup = {
1154
+ ...prior,
1155
+ attempted: prior.attempted || true,
1156
+ id,
1157
+ deletePath: concretePath,
1158
+ };
1159
+ }
1160
+
1161
+ // Eventual-consistency retry (round-5 follow-up): POST creates on the
1162
+ // write replica, immediate DELETE hits a read replica that hasn't seen
1163
+ // the new id yet → 404. Two short backoffs swallow that transient
1164
+ // 404; a 404 that survives the backoff is a real leak and lands in
1165
+ // verdict.cleanup.error. Only 404 is retried — 5xx, network errors,
1166
+ // 401/403 fail fast (the situation isn't going to improve).
1167
+ const RETRY_DELAYS_MS = opts.cleanupRetryDelaysMs ?? [200, 1000];
1168
+ let lastResp: { status: number } | null = null;
1169
+ let lastNetErr: string | null = null;
1170
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
1171
+ if (attempt > 0) await new Promise(r => setTimeout(r, RETRY_DELAYS_MS[attempt - 1]!));
1172
+ try {
1173
+ const resp = await executeRequest(
1174
+ { method: "DELETE", url, headers },
1175
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
1176
+ );
1177
+ lastResp = { status: resp.status };
1178
+ if (resp.status >= 200 && resp.status < 300) {
1179
+ const prior = verdict.cleanup ?? { attempted: false };
1180
+ verdict.cleanup = {
1181
+ attempted: true,
1182
+ status: resp.status,
1183
+ ...(prior.error ? { error: prior.error } : {}),
1184
+ ...(prior.id !== undefined ? { id: prior.id } : {}),
1185
+ ...(prior.deletePath ? { deletePath: prior.deletePath } : {}),
1186
+ };
1187
+ return;
1188
+ }
1189
+ // Only retry transient 404 (eventual-consistency window).
1190
+ if (resp.status !== 404) break;
1191
+ } catch (err) {
1192
+ lastNetErr = err instanceof Error ? err.message : String(err);
1193
+ // Network errors are not retried — they're not transient in the
1194
+ // eventual-consistency sense (they're config/connectivity issues).
1195
+ break;
1196
+ }
1197
+ }
1198
+
1199
+ if (lastNetErr) {
1200
+ accumulateCleanupError(verdict, `DELETE ${delEp.path} network error: ${lastNetErr}`);
1201
+ } else if (lastResp) {
1202
+ const tail = lastResp.status === 404 ? " (persisted across retries — likely real leak)" : "";
1203
+ accumulateCleanupError(verdict, `DELETE ${delEp.path} → ${lastResp.status} (id=${id})${tail}`);
1204
+ }
1205
+ }
1206
+
1207
+ function accumulateCleanupError(verdict: SecurityVerdict, msg: string): void {
1208
+ const prior = verdict.cleanup ?? { attempted: false };
1209
+ const errors = prior.error ? `${prior.error} | ${msg}` : msg;
1210
+ verdict.cleanup = {
1211
+ attempted: true,
1212
+ ...(prior.status ? { status: prior.status } : {}),
1213
+ ...(prior.id !== undefined ? { id: prior.id } : {}),
1214
+ ...(prior.deletePath ? { deletePath: prior.deletePath } : {}),
1215
+ error: errors,
1216
+ };
1217
+ }
1218
+
1219
+ function pickId(body: unknown, field: string): string | number | undefined {
1220
+ if (!body || typeof body !== "object") return undefined;
1221
+ const obj = body as Record<string, unknown>;
1222
+ for (const key of [field, "id", "slug", "uuid", "key"]) {
1223
+ const v = obj[key];
1224
+ if (typeof v === "string" || typeof v === "number") return v;
1225
+ }
1226
+ return undefined;
1227
+ }
1228
+
1229
+ function skipped(ep: EndpointInfo, reason: string): SecurityVerdict {
1230
+ return {
1231
+ method: ep.method.toUpperCase(),
1232
+ path: ep.path,
1233
+ severity: "skipped",
1234
+ summary: `skipped: ${reason}`,
1235
+ detectedFields: [],
1236
+ findings: [],
1237
+ skipReason: reason,
1238
+ };
1239
+ }
1240
+
1241
+ // ──────────────────────────────────────────────
1242
+ // Markdown digest
1243
+ // ──────────────────────────────────────────────
1244
+
1245
+ /** TASK-154 §N: clip noisy payloads (some SSRF/CRLF/redirect strings are URL-
1246
+ * encoded blobs > 60 chars). Keep the leading prefix users recognise plus an
1247
+ * ellipsis, so the digest line stays readable. */
1248
+ /** ARV-245 (R-04/F16): percent-encode unsafe characters per path segment
1249
+ * for paste-ready manual repro lines in the digest. Mirrors the encoding
1250
+ * rules used by `cleanup --orphans` so the printed command works against
1251
+ * the same APIs the probe targeted. */
1252
+ function encodeDeletePathForRepro(deletePath: string): string {
1253
+ const SAFE = /[A-Za-z0-9._~!$&'()*+,;=:@-]/;
1254
+ return deletePath
1255
+ .split("/")
1256
+ .map((segment) => {
1257
+ if (segment.length === 0) return segment;
1258
+ let out = "";
1259
+ for (let i = 0; i < segment.length; i++) {
1260
+ const ch = segment.charAt(i);
1261
+ if (ch === "%" && /^[0-9A-Fa-f]{2}$/.test(segment.slice(i + 1, i + 3))) {
1262
+ out += segment.slice(i, i + 3);
1263
+ i += 2;
1264
+ continue;
1265
+ }
1266
+ out += SAFE.test(ch) ? ch : encodeURIComponent(ch);
1267
+ }
1268
+ return out;
1269
+ })
1270
+ .join("/");
1271
+ }
1272
+
1273
+ function truncatePayload(payload: string, max: number): string {
1274
+ if (payload.length <= max) return payload;
1275
+ return payload.slice(0, max - 1) + "…";
1276
+ }
1277
+
1278
+ export function formatSecurityDigest(
1279
+ result: SecurityProbeResult,
1280
+ specPath: string,
1281
+ ): string {
1282
+ const lines: string[] = [];
1283
+ lines.push(`# zond probe-security digest`);
1284
+ lines.push("");
1285
+ lines.push(`Spec: \`${specPath}\``);
1286
+ lines.push(`Classes: ${result.classes.join(", ")}`);
1287
+ lines.push(`Endpoints scanned: ${result.totalEndpoints} · probed: ${result.specProbed}`);
1288
+ // ARV-140 AC#4: surface the cleanup-feasibility outcome up front so a
1289
+ // green run doesn't hide "we attacked 14 leak-prone POSTs anyway".
1290
+ if (result.cleanupFeasibility) {
1291
+ const f = result.cleanupFeasibility;
1292
+ if (f.skippedNoCleanup > 0) {
1293
+ lines.push(`Cleanup pre-flight: ${f.skippedNoCleanup} endpoint(s) skipped (no DELETE counterpart). Pass \`--allow-leaks\` to attack anyway.`);
1294
+ } else if (f.forcedNoCleanup > 0) {
1295
+ lines.push(`Cleanup pre-flight: ${f.forcedNoCleanup} endpoint(s) attacked despite no DELETE counterpart (--allow-leaks).`);
1296
+ }
1297
+ // ARV-153: surface action-verb POSTs we now attack without a DELETE
1298
+ // counterpart so green runs make the recall win visible.
1299
+ if (f.actionNoCleanupNeeded > 0) {
1300
+ lines.push(`Cleanup pre-flight: ${f.actionNoCleanupNeeded} action POST(s) attacked (no resource created — DELETE counterpart not needed).`);
1301
+ }
1302
+ }
1303
+ lines.push("");
1304
+
1305
+ // Cleanup failures section is mandatory and goes FIRST when present —
1306
+ // round-4 dogfooding: a "green" run (HIGH=0) silently leaked DSN keys
1307
+ // and left renamed projects, because cleanup failures were buried in
1308
+ // per-verdict objects. Surface them prominently so a green probe is a
1309
+ // signal the org is clean, not just that nothing crashed.
1310
+ const cleanupFailures = result.verdicts.filter(v => v.cleanup?.error);
1311
+ if (cleanupFailures.length > 0) {
1312
+ lines.push(`## ⚠️ Cleanup failures (${cleanupFailures.length}) — manual remediation may be required`);
1313
+ lines.push("");
1314
+ for (const v of cleanupFailures) {
1315
+ lines.push(`- **${v.method} ${v.path}** — ${v.cleanup!.error}`);
1316
+ // ARV-245 (R-04/F16): paste-ready manual repro when we have a
1317
+ // deletePath. Auto-encode the path so operators dealing with
1318
+ // CRLF-poisoned ids (round-4 GitHub labels) don't have to remember
1319
+ // to percent-encode `\r`/`\n`/spaces themselves.
1320
+ const dp = v.cleanup?.deletePath;
1321
+ if (dp) {
1322
+ const encoded = encodeDeletePathForRepro(dp);
1323
+ const note = /[\r\n\t ]/.test(dp) ? " (note: id contains whitespace/CRLF — percent-encoded)" : "";
1324
+ lines.push(` - Manual repro: \`zond request DELETE ${encoded} --api <name>\`${note}`);
1325
+ }
1326
+ }
1327
+ lines.push("");
1328
+ }
1329
+
1330
+ const buckets: Record<SecuritySeverity, SecurityVerdict[]> = {
1331
+ high: [], medium: [], low: [], info: [], inconclusive: [], "inconclusive-baseline": [], ok: [], skipped: [],
1332
+ };
1333
+ for (const v of result.verdicts) buckets[v.severity].push(v);
1334
+
1335
+ const ordered: SecuritySeverity[] = ["high", "inconclusive", "inconclusive-baseline", "medium", "low", "info", "ok", "skipped"];
1336
+ const titles: Record<SecuritySeverity, string> = {
1337
+ high: "🚨 HIGH — header-reflection / HTML reflection / 5xx",
1338
+ medium: "⚠️ MEDIUM — SSRF accept on endpoint declaring delivery (no OOB confirmation)",
1339
+ low: "🟡 LOW — storage observed, no dangerous-context reflection (verify manually)",
1340
+ info: "· INFO — accepted, no reflection observed (sanitization signal only)",
1341
+ inconclusive: "❓ INCONCLUSIVE — could not classify",
1342
+ "inconclusive-baseline": "⚠️ INCONCLUSIVE-BASELINE — baseline 4xx, attacks not run",
1343
+ ok: "✅ OK — payloads rejected with 4xx",
1344
+ skipped: "⏭️ SKIPPED — no detected fields / no body",
1345
+ };
1346
+ for (const sev of ordered) {
1347
+ const list = buckets[sev];
1348
+ if (list.length === 0) continue;
1349
+ lines.push(`## ${titles[sev]} (${list.length})`);
1350
+ lines.push("");
1351
+ for (const v of list) {
1352
+ const cleanupTag = v.cleanup?.error ? " 🧹 cleanup-failure" : "";
1353
+ lines.push(`- **${v.method} ${v.path}**${cleanupTag} — ${v.summary}`);
1354
+ for (const f of v.findings) {
1355
+ // TASK-154 §N: surface the actual payload that triggered the finding
1356
+ // — without it the digest is useless for case-study writing (which
1357
+ // SSRF target? which CRLF shape?). Truncate long payloads so the
1358
+ // line stays readable.
1359
+ const payload = truncatePayload(f.payload, 60);
1360
+ lines.push(` - \`${f.field}\` / ${f.class} [\`${payload}\`] → ${f.status} (${f.severity}) — ${f.reason}`);
1361
+ }
1362
+ }
1363
+ lines.push("");
1364
+ }
1365
+ return lines.join("\n");
1366
+ }
1367
+
1368
+ // ──────────────────────────────────────────────
1369
+ // Regression suite emission
1370
+ // ──────────────────────────────────────────────
1371
+
1372
+ const ATTACK_EXPECTED_STATUS = [400, 403, 404, 405, 409, 415, 422];
1373
+
1374
+ export function emitSecurityRegressionSuites(
1375
+ result: SecurityProbeResult,
1376
+ endpoints: EndpointInfo[],
1377
+ schemes: SecuritySchemeInfo[],
1378
+ ): RawSuite[] {
1379
+ const suites: RawSuite[] = [];
1380
+ for (const v of result.verdicts) {
1381
+ // ARV-247 (R-04/F18): `high` findings are 2xx-with-echoed-payload — the
1382
+ // strongest regression signal we have. Skipping them meant CI had nothing
1383
+ // to gate on for confirmed stored injections. Treat them like `ok` here:
1384
+ // the suite locks in the *expected* state (attack rejected) once the API
1385
+ // owner ships a fix, and fails loud while it's still broken.
1386
+ if (v.severity !== "ok" && v.severity !== "low" && v.severity !== "high") continue;
1387
+ const ep = endpoints.find(
1388
+ e => e.path === v.path && e.method.toUpperCase() === v.method,
1389
+ );
1390
+ if (!ep) continue;
1391
+ const suiteHeaders = getAuthHeaders(ep, schemes);
1392
+ const tests: RawStep[] = [];
1393
+ for (const f of v.findings) {
1394
+ // `ok` = attack already rejected; `high` = attack accepted+echoed (the
1395
+ // regression target is rejection, same expected set as `ok`). `low` =
1396
+ // attack accepted but no echo — lock in the 2xx-without-echo shape.
1397
+ const expected = (f.severity === "ok" || f.severity === "high") ? ATTACK_EXPECTED_STATUS : [200, 201, 202, 204];
1398
+ const body = ep.requestBodySchema ? generateFromSchema(ep.requestBodySchema) : {};
1399
+ if (typeof body === "object" && body !== null && !Array.isArray(body)) {
1400
+ (body as Record<string, unknown>)[f.field] = f.payload;
1401
+ }
1402
+ const step: RawStep = {
1403
+ name: `${f.class}: ${f.field}=${shortPayload(f.payload)} must ${f.severity === "ok" ? "be rejected" : "not echo"}`,
1404
+ source: {
1405
+ generator: "probe-security",
1406
+ endpoint: `${v.method} ${v.path}`,
1407
+ response_branch: expected.map(String).join("|"),
1408
+ },
1409
+ [v.method]: convertPath(ep.path),
1410
+ json: body,
1411
+ expect: { status: expected },
1412
+ };
1413
+ tests.push(step);
1414
+ }
1415
+ if (tests.length === 0) continue;
1416
+ // Attach a generic cleanup step keyed off `created_id` (only fires when
1417
+ // a previous step captured one — same `always:true` semantics other
1418
+ // probes use).
1419
+ const delEp = findDeleteCounterpart(ep, endpoints);
1420
+ if (delEp) {
1421
+ const idField = captureFieldFor(ep);
1422
+ tests[0]!.expect.body = { ...(tests[0]!.expect.body ?? {}), [idField]: { capture: "created_id" } };
1423
+ const idParam = (delEp.path.match(/\{([^}]+)\}/) ?? [])[1] ?? "id";
1424
+ const delStep: RawStep = {
1425
+ name: "cleanup",
1426
+ source: { generator: "probe-security-cleanup", endpoint: `DELETE ${delEp.path}` },
1427
+ always: true,
1428
+ DELETE: convertPath(delEp.path).replace(`{{${idParam}}}`, "{{created_id}}"),
1429
+ expect: { status: [200, 202, 204, 404] },
1430
+ } as RawStep & { always: boolean };
1431
+ tests.push(delStep);
1432
+ }
1433
+ suites.push({
1434
+ name: `probe-security ${v.method} ${v.path}`,
1435
+ tags: ["probe-security", ...result.classes],
1436
+ source: {
1437
+ type: "probe-suite",
1438
+ generator: "probe-security",
1439
+ endpoint: `${v.method} ${v.path}`,
1440
+ },
1441
+ fileStem: `probe-security-${endpointStem(ep)}`,
1442
+ base_url: "{{base_url}}",
1443
+ ...(suiteHeaders ? { headers: suiteHeaders } : {}),
1444
+ tests,
1445
+ });
1446
+ }
1447
+ return suites;
1448
+ }
1449
+
1450
+ function shortPayload(s: string): string {
1451
+ return s.length > 40 ? s.slice(0, 37) + "…" : s;
1452
+ }
1453
+