@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,119 @@
1
+ /**
2
+ * Live probe-runtime primitives shared by mass-assignment and security
3
+ * probes. TASK-185 (m-11) extracted the static spec/scaffold half into
4
+ * `runner.ts`; this module covers the per-endpoint primitives that
5
+ * were still duplicated between the two probe entry points.
6
+ *
7
+ * Scope: small, pure helpers — URL building, baseline body generation,
8
+ * JSON+auth header construction. Cleanup logic is intentionally NOT
9
+ * unified: mass-assignment uses fire-and-forget DELETE before attacks,
10
+ * security-probe uses snapshot/restore + retry-aware DELETE after
11
+ * attacks. Different invariants, different shapes — see TASK-189 notes.
12
+ */
13
+
14
+ import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
15
+ import { generateFromSchema } from "../generator/data-factory.ts";
16
+ import { substituteDeep, substituteString } from "../parser/variables.ts";
17
+ import { convertPath, liveAuthHeaders } from "./shared.ts";
18
+ import { joinBaseAndPath } from "../util/url.ts";
19
+ import { encodeFormBody } from "../runner/form-encode.ts";
20
+ import type { SeedBodyConfig } from "../generator/resources-builder.ts";
21
+
22
+ /** ARV-150: form-encoded mutating endpoint (Stripe v1 pattern).
23
+ * Stripe and other Rails/PHP APIs declare requestBody.content with ONLY
24
+ * application/x-www-form-urlencoded — the probes previously skipped
25
+ * every such endpoint, masking real mass-assignment vectors. */
26
+ export function isFormBody(ep: EndpointInfo): boolean {
27
+ return (
28
+ ep.requestBodyContentType === "application/x-www-form-urlencoded"
29
+ && ep.requestBodySchema !== undefined
30
+ );
31
+ }
32
+
33
+ /** Probes can drive either application/json or application/x-www-form-urlencoded
34
+ * endpoints. Anything else (multipart, octet-stream, …) still gets skipped —
35
+ * no general way to construct attack payloads without a body schema. */
36
+ export function hasProbeBody(ep: EndpointInfo): boolean {
37
+ if (ep.method === "GET" || ep.method === "DELETE") return false;
38
+ if (!ep.requestBodySchema) return false;
39
+ return (
40
+ ep.requestBodyContentType === "application/json"
41
+ || ep.requestBodyContentType === "application/x-www-form-urlencoded"
42
+ );
43
+ }
44
+
45
+ /** Serialise an attack body using whichever content type the endpoint
46
+ * declares. Returns the wire-format string + the Content-Type to set. */
47
+ export function serializeProbeBody(
48
+ ep: EndpointInfo,
49
+ body: Record<string, unknown>,
50
+ ): { content: string; contentType: string } {
51
+ if (isFormBody(ep)) {
52
+ return { content: encodeFormBody(body), contentType: "application/x-www-form-urlencoded" };
53
+ }
54
+ return { content: JSON.stringify(body), contentType: "application/json" };
55
+ }
56
+
57
+ /**
58
+ * Resolve an endpoint's URL against the live `base_url` + path-param
59
+ * substitutions. Returns the resolved URL and any leftover `{{var}}`
60
+ * markers the caller couldn't fill — use those to skip the endpoint
61
+ * with a meaningful reason.
62
+ */
63
+ export function buildProbeUrl(
64
+ ep: EndpointInfo,
65
+ vars: Record<string, string>,
66
+ ): { url: string; unresolved: string[] } {
67
+ const templated = joinBaseAndPath(vars["base_url"], convertPath(ep.path));
68
+ const url = String(substituteString(templated, vars));
69
+ const unresolved = Array.from(url.matchAll(/\{\{([^}]+)\}\}/g)).map(m => m[1]!);
70
+ return { url, unresolved };
71
+ }
72
+
73
+ /**
74
+ * Standard probe headers: content-type from the endpoint's spec
75
+ * (form-urlencoded for Stripe v1, JSON otherwise) plus the resolved
76
+ * auth header for the endpoint. Accept stays JSON — the server still
77
+ * answers in JSON even when the body is form-encoded. Empty
78
+ * `liveAuthHeaders` is fine — the spread is a no-op for unauthenticated
79
+ * endpoints.
80
+ */
81
+ export function buildBodyAuthHeaders(
82
+ ep: EndpointInfo,
83
+ schemes: SecuritySchemeInfo[],
84
+ vars: Record<string, string>,
85
+ ): Record<string, string> {
86
+ const ct = isFormBody(ep) ? "application/x-www-form-urlencoded" : "application/json";
87
+ return {
88
+ "content-type": ct,
89
+ accept: "application/json",
90
+ ...liveAuthHeaders(ep, schemes, vars),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Synthesize a baseline body from the endpoint's request schema and
96
+ * substitute live vars. Returns null when the result isn't a JSON
97
+ * object (array / scalar / null) — both probes treat that as a skip
98
+ * reason ("request body not a JSON object").
99
+ *
100
+ * ARV-269: when `seedBody` is provided (agent-authored overlay from
101
+ * `.api-resources.local.yaml`), it wins over `generateFromSchema`.
102
+ * Strict-validating APIs (Stripe required-field XORs, `expand[]` arrays)
103
+ * reject random scalars from the generator and the whole verdict becomes
104
+ * INCONCLUSIVE-baseline; the overlay carries a payload the API actually
105
+ * accepts. Mirrors the path stateful checks took via `resolveCreateBody`
106
+ * (`core/checks/checks/_crud-helpers.ts`).
107
+ */
108
+ export function buildBaselineFromSpec(
109
+ ep: EndpointInfo,
110
+ vars: Record<string, string>,
111
+ seedBody?: SeedBodyConfig,
112
+ ): Record<string, unknown> | null {
113
+ const raw = seedBody && seedBody.body && typeof seedBody.body === "object"
114
+ ? seedBody.body
115
+ : (ep.requestBodySchema ? generateFromSchema(ep.requestBodySchema) : {});
116
+ const sub = substituteDeep(raw, vars);
117
+ if (typeof sub !== "object" || sub === null || Array.isArray(sub)) return null;
118
+ return sub as Record<string, unknown>;
119
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Probe registry + boot-time validator (m-17 / ARV-49).
3
+ *
4
+ * The CLI bootstrap calls `bootstrapProbes()` exactly once — that
5
+ * function imports each registered probe class and pushes it through
6
+ * `registerProbe`, which throws if the contract from `types.ts` is
7
+ * not fully implemented. Boot fails loud with a list of missing slots,
8
+ * so adding a new probe class without --dry-run / --report support is
9
+ * impossible (replaces the conventions-then-drift status quo that
10
+ * produced F1-15 / F2-15 / F3-15 in feedback round 15).
11
+ */
12
+ import type { Probe, ProbeFlags } from "./types.ts";
13
+
14
+ const REQUIRED_METHODS: Array<keyof Probe> = ["dryRun", "run", "report"];
15
+ const REQUIRED_FLAGS: Array<keyof ProbeFlags> = [
16
+ "api",
17
+ "tag",
18
+ "include",
19
+ "exclude",
20
+ "dryRun",
21
+ "listTags",
22
+ "json",
23
+ "output",
24
+ "report",
25
+ ];
26
+
27
+ export interface ValidationResult {
28
+ ok: boolean;
29
+ errors: string[];
30
+ }
31
+
32
+ /** Pure validator — used by both `registerProbe` and the contract test
33
+ * in `tests/contracts/probe-interface.test.ts`. */
34
+ export function validateProbe(p: unknown): ValidationResult {
35
+ const errors: string[] = [];
36
+ if (p === null || typeof p !== "object") {
37
+ return { ok: false, errors: ["Probe is not an object"] };
38
+ }
39
+ const probe = p as Partial<Probe>;
40
+ const label = probe.name ?? "<anonymous>";
41
+ if (typeof probe.name !== "string" || probe.name.length === 0) {
42
+ errors.push("Probe is missing required field name");
43
+ }
44
+ if (typeof probe.description !== "string" || probe.description.length === 0) {
45
+ errors.push(`Probe "${label}" is missing required field description`);
46
+ }
47
+ for (const m of REQUIRED_METHODS) {
48
+ if (typeof (probe as Record<string, unknown>)[m] !== "function") {
49
+ errors.push(`Probe "${label}" is missing required method ${m}`);
50
+ }
51
+ }
52
+ if (probe.commonFlags === undefined || probe.commonFlags === null || typeof probe.commonFlags !== "object") {
53
+ errors.push(`Probe "${label}" is missing required field commonFlags`);
54
+ } else {
55
+ const flags = probe.commonFlags as unknown as Record<string, unknown>;
56
+ for (const f of REQUIRED_FLAGS) {
57
+ const v = flags[f as string];
58
+ if (typeof v !== "boolean") {
59
+ errors.push(`Probe "${label}" commonFlags is missing slot ${f} (must be boolean)`);
60
+ }
61
+ }
62
+ }
63
+ return { ok: errors.length === 0, errors };
64
+ }
65
+
66
+ const PROBES = new Map<string, Probe>();
67
+
68
+ export function registerProbe(probe: Probe): void {
69
+ const r = validateProbe(probe);
70
+ if (!r.ok) {
71
+ throw new Error(
72
+ `Invalid probe registration:\n - ${r.errors.join("\n - ")}`,
73
+ );
74
+ }
75
+ if (PROBES.has(probe.name)) {
76
+ throw new Error(`Probe "${probe.name}" is already registered`);
77
+ }
78
+ PROBES.set(probe.name, probe);
79
+ }
80
+
81
+ export function listProbes(): readonly Probe[] {
82
+ return Array.from(PROBES.values());
83
+ }
84
+
85
+ /** Test helper — wipes the registry between unit tests. NOT exported
86
+ * through `index.ts`; tests import this module directly. */
87
+ export function clearProbes(): void {
88
+ PROBES.clear();
89
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Shared scaffolding for the four probe commands (`probe validation`,
3
+ * `probe methods`, `probe mass-assignment`, `probe security`).
4
+ *
5
+ * Each command historically duplicated the same boilerplate: read the
6
+ * spec, optionally filter by tag, mkdir the output dir, write the
7
+ * generated suites with an autogen header, and record them in the
8
+ * workspace manifest. This module collapses that scaffolding into two
9
+ * helpers (`loadSpecForProbe` / `writeProbeSuites`) so the cli layer
10
+ * stays thin.
11
+ *
12
+ * Live-runner commands (`mass-assignment`, `security`) reuse the
13
+ * write-suites half for their `--emit-tests` flag; the actual HTTP
14
+ * orchestration lives in `mass-assignment-probe.ts` /
15
+ * `security-probe.ts`.
16
+ *
17
+ * Goals are documented in TASK-185 (m-11).
18
+ */
19
+
20
+ import { mkdir } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+ import {
23
+ readOpenApiSpec,
24
+ extractEndpoints,
25
+ extractSecuritySchemes,
26
+ serializeSuite,
27
+ } from "../generator/index.ts";
28
+ import type { EndpointInfo, SecuritySchemeInfo, RawSuite } from "../generator/index.ts";
29
+ import { collectTags, filterByTag } from "../generator/chunker.ts";
30
+ import {
31
+ recordGeneratedFiles,
32
+ inferApiName,
33
+ autoGenHeader,
34
+ type RecordInput,
35
+ } from "../workspace/manifest.ts";
36
+ import { findWorkspaceRoot } from "../workspace/root.ts";
37
+
38
+ export interface LoadSpecForProbeOptions {
39
+ specPath: string;
40
+ tag?: string;
41
+ /** When true, the caller wants the available-tags list, not endpoints. */
42
+ listTags?: boolean;
43
+ }
44
+
45
+ export type LoadSpecResult =
46
+ | { kind: "endpoints"; endpoints: EndpointInfo[]; securitySchemes: SecuritySchemeInfo[] }
47
+ | { kind: "tags"; tags: string[] }
48
+ | { kind: "tag-not-found"; tag: string; available: string[] };
49
+
50
+ /**
51
+ * Read the spec, optionally filter by tag, and surface either the
52
+ * filtered endpoints or the list of available tags. Centralises the
53
+ * tag-not-found error path so probe commands all produce identical
54
+ * messaging.
55
+ */
56
+ export async function loadSpecForProbe(opts: LoadSpecForProbeOptions): Promise<LoadSpecResult> {
57
+ const doc = await readOpenApiSpec(opts.specPath);
58
+ const allEndpoints = extractEndpoints(doc);
59
+ const securitySchemes = extractSecuritySchemes(doc);
60
+
61
+ if (opts.listTags) {
62
+ return { kind: "tags", tags: collectTags(allEndpoints) };
63
+ }
64
+
65
+ if (opts.tag) {
66
+ const filtered = filterByTag(allEndpoints, opts.tag);
67
+ if (filtered.length === 0) {
68
+ return { kind: "tag-not-found", tag: opts.tag, available: collectTags(allEndpoints) };
69
+ }
70
+ return { kind: "endpoints", endpoints: filtered, securitySchemes };
71
+ }
72
+
73
+ return { kind: "endpoints", endpoints: allEndpoints, securitySchemes };
74
+ }
75
+
76
+ export interface WriteProbeSuitesOptions {
77
+ /** Directory to write suites into. Created recursively if missing. */
78
+ output: string;
79
+ /** Suites produced by the underlying probe generator. */
80
+ suites: RawSuite[];
81
+ /** Manifest `by` field — e.g. `"zond probe-methods --emit"`. */
82
+ command: string;
83
+ /** First arg of `autoGenHeader` (label). Defaults to `command`. */
84
+ headerLabel?: string;
85
+ /** Concrete repro command shown in the autogen header. */
86
+ headerExample?: string;
87
+ /** Manifest category (defaults to `"probes"`). */
88
+ category?: RecordInput["category"];
89
+ }
90
+
91
+ export interface WroteProbeSuites {
92
+ files: Array<{ file: string; suite: string; tests: number }>;
93
+ }
94
+
95
+ /**
96
+ * Materialise generator output to disk and register the files in the
97
+ * workspace manifest. Safe on empty input — returns an empty result and
98
+ * avoids creating an empty directory (m-9 P5).
99
+ */
100
+ export async function writeProbeSuites(
101
+ opts: WriteProbeSuitesOptions,
102
+ ): Promise<WroteProbeSuites> {
103
+ if (opts.suites.length === 0) return { files: [] };
104
+
105
+ await mkdir(opts.output, { recursive: true });
106
+
107
+ const files: WroteProbeSuites["files"] = [];
108
+ const manifestEntries: RecordInput[] = [];
109
+ const inferredApi = inferApiName(opts.output);
110
+ const headerLabel = opts.headerLabel ?? opts.command;
111
+ const headerExample = opts.headerExample ?? opts.command;
112
+
113
+ for (const suite of opts.suites) {
114
+ const fileName = `${suite.fileStem ?? suite.name}.yaml`;
115
+ const filePath = join(opts.output, fileName);
116
+ await Bun.write(filePath, autoGenHeader(headerLabel, headerExample) + serializeSuite(suite));
117
+ files.push({ file: filePath, suite: suite.name, tests: suite.tests.length });
118
+ manifestEntries.push({
119
+ path: filePath,
120
+ by: opts.command,
121
+ api: inferredApi,
122
+ category: opts.category ?? "probes",
123
+ });
124
+ }
125
+
126
+ try {
127
+ const ws = findWorkspaceRoot();
128
+ if (!ws.fromFallback && manifestEntries.length > 0) {
129
+ recordGeneratedFiles(ws.root, manifestEntries);
130
+ }
131
+ } catch {
132
+ /* best-effort: manifest is observability, never fail probe emit on it */
133
+ }
134
+
135
+ return { files };
136
+ }
@@ -0,0 +1,174 @@
1
+ import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
2
+ import { executeRequest } from "../../runner/http-client.ts";
3
+ import { findGetByIdCounterpart, liveAuthHeaders } from "../shared.ts";
4
+ import { buildProbeUrl, serializeProbeBody } from "../probe-harness.ts";
5
+ import type { ProbeStepOpts, SecurityVerdict } from "./types.ts";
6
+
7
+ export interface Snapshot {
8
+ /** Original GET-response body, used to restore state via PUT/PATCH. */
9
+ body: Record<string, unknown>;
10
+ /** ETag (if API uses optimistic locking) — sent back as `If-Match` on restore. */
11
+ etag?: string;
12
+ }
13
+
14
+ export type BaselineResult =
15
+ | { kind: "ok"; status: number; body: unknown; headers: Record<string, string> }
16
+ | { kind: "network"; reason: string };
17
+
18
+ /**
19
+ * Baseline send — wraps executeRequest with shape that distinguishes a real
20
+ * HTTP response from a network error (so the caller can decide whether to
21
+ * retry partial-body / mark the endpoint unreachable).
22
+ */
23
+ export async function sendBaseline(
24
+ ep: EndpointInfo,
25
+ method: string,
26
+ url: string,
27
+ headers: Record<string, string>,
28
+ body: unknown,
29
+ opts: ProbeStepOpts,
30
+ ): Promise<BaselineResult> {
31
+ try {
32
+ // ARV-161: serialize via serializeProbeBody so form-encoded endpoints
33
+ // get x-www-form-urlencoded payload matching Content-Type.
34
+ const wire = body && typeof body === "object" && !Array.isArray(body)
35
+ ? serializeProbeBody(ep, body as Record<string, unknown>).content
36
+ : JSON.stringify(body);
37
+ const resp = await executeRequest(
38
+ { method, url, headers, body: wire },
39
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
40
+ );
41
+ return {
42
+ kind: "ok",
43
+ status: resp.status,
44
+ body: resp.body_parsed ?? resp.body,
45
+ headers: resp.headers ?? {},
46
+ };
47
+ } catch (err) {
48
+ return {
49
+ kind: "network",
50
+ reason: err instanceof Error ? err.message : String(err),
51
+ };
52
+ }
53
+ }
54
+
55
+ /**
56
+ * TASK-151: snapshot original state via GET-by-id counterpart so PUT/PATCH
57
+ * attacks can be undone. Returns null when there's no usable counterpart
58
+ * or the response isn't a JSON object.
59
+ */
60
+ export async function snapshotOriginal(
61
+ ep: EndpointInfo,
62
+ allEndpoints: EndpointInfo[],
63
+ schemes: SecuritySchemeInfo[],
64
+ vars: Record<string, string>,
65
+ opts: ProbeStepOpts,
66
+ ): Promise<Snapshot | null> {
67
+ const getEp = findGetByIdCounterpart(ep, allEndpoints);
68
+ if (!getEp) return null;
69
+ const { url, unresolved } = buildProbeUrl(getEp, vars);
70
+ if (unresolved.length > 0) return null;
71
+ const reqHeaders: Record<string, string> = {
72
+ accept: "application/json",
73
+ ...liveAuthHeaders(getEp, schemes, vars),
74
+ };
75
+ let resp;
76
+ try {
77
+ resp = await executeRequest(
78
+ { method: "GET", url, headers: reqHeaders },
79
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
80
+ );
81
+ } catch {
82
+ return null;
83
+ }
84
+ if (resp.status < 200 || resp.status >= 300) return null;
85
+ const body = resp.body_parsed ?? resp.body;
86
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
87
+
88
+ const respHeaders = resp.headers ?? {};
89
+ const etag =
90
+ respHeaders["etag"] ??
91
+ respHeaders["ETag"] ??
92
+ respHeaders["Etag"];
93
+
94
+ return {
95
+ body: body as Record<string, unknown>,
96
+ etag: typeof etag === "string" ? etag : undefined,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Restore the original state captured by `snapshotOriginal`. Sends a
102
+ * minimal PUT/PATCH containing only the fields the probe mutated —
103
+ * sending the full snapshot body trips `422 use partial PUT` on
104
+ * SaaS-shaped APIs (round-4 regression), so we replay each
105
+ * dirty field as its own single-key request.
106
+ *
107
+ * `verdict.cleanup.error` is **accumulated** across calls (not
108
+ * overwritten) so a single restore failure during the run is still
109
+ * visible in the digest.
110
+ */
111
+ export async function restoreOriginal(
112
+ ep: EndpointInfo,
113
+ snapshot: Snapshot,
114
+ baseHeaders: Record<string, string>,
115
+ _schemes: SecuritySchemeInfo[],
116
+ vars: Record<string, string>,
117
+ opts: ProbeStepOpts,
118
+ verdict: SecurityVerdict,
119
+ dirtyFields: Iterable<string>,
120
+ ): Promise<void> {
121
+ const m = ep.method.toUpperCase();
122
+ const { url, unresolved } = buildProbeUrl(ep, vars);
123
+ if (unresolved.length > 0) return;
124
+ const headers: Record<string, string> = { ...baseHeaders };
125
+ if (snapshot.etag && ep.requiresEtag) {
126
+ headers["If-Match"] = snapshot.etag;
127
+ }
128
+ // Filter out fields the API will reject as read-only.
129
+ const READ_ONLY = new Set([
130
+ "id", "created_at", "createdAt", "updated_at", "updatedAt",
131
+ ]);
132
+ const fields = Array.from(new Set(Array.from(dirtyFields))).filter(
133
+ f => !READ_ONLY.has(f) && f in snapshot.body,
134
+ );
135
+
136
+ // Per-field PUT — works for both partial-PUT APIs and
137
+ // full-PUT APIs (the body just carries one of the legal keys).
138
+ const failures: string[] = [];
139
+ let lastSuccessStatus = 0;
140
+ let attempted = false;
141
+ for (const field of fields) {
142
+ attempted = true;
143
+ const body: Record<string, unknown> = { [field]: snapshot.body[field] };
144
+ let resp;
145
+ try {
146
+ resp = await executeRequest(
147
+ { method: m, url, headers, body: serializeProbeBody(ep, body).content },
148
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
149
+ );
150
+ } catch (err) {
151
+ failures.push(
152
+ `restore.${field} network error: ${err instanceof Error ? err.message : String(err)}`,
153
+ );
154
+ continue;
155
+ }
156
+ if (resp.status < 200 || resp.status >= 300) {
157
+ failures.push(`restore.${field} failed: ${resp.status}`);
158
+ continue;
159
+ }
160
+ lastSuccessStatus = resp.status;
161
+ }
162
+
163
+ // Merge with any prior cleanup state on this verdict.
164
+ const prior = verdict.cleanup ?? { attempted: false };
165
+ const allErrors = [
166
+ ...(prior.error ? [prior.error] : []),
167
+ ...failures,
168
+ ];
169
+ verdict.cleanup = {
170
+ attempted: attempted || prior.attempted,
171
+ ...(lastSuccessStatus ? { status: lastSuccessStatus } : prior.status ? { status: prior.status } : {}),
172
+ ...(allErrors.length > 0 ? { error: allErrors.join(" | ") } : {}),
173
+ };
174
+ }