@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,120 @@
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 { encodeFormBody } from "../runner/form-encode.ts";
19
+
20
+ /** ARV-150: form-encoded mutating endpoint (Stripe v1 pattern).
21
+ * Stripe and other Rails/PHP APIs declare requestBody.content with ONLY
22
+ * application/x-www-form-urlencoded — the probes previously skipped
23
+ * every such endpoint, masking real mass-assignment vectors. */
24
+ export function isFormBody(ep: EndpointInfo): boolean {
25
+ return (
26
+ ep.requestBodyContentType === "application/x-www-form-urlencoded"
27
+ && ep.requestBodySchema !== undefined
28
+ );
29
+ }
30
+
31
+ /** Probes can drive either application/json or application/x-www-form-urlencoded
32
+ * endpoints. Anything else (multipart, octet-stream, …) still gets skipped —
33
+ * no general way to construct attack payloads without a body schema. */
34
+ export function hasProbeBody(ep: EndpointInfo): boolean {
35
+ if (ep.method === "GET" || ep.method === "DELETE") return false;
36
+ if (!ep.requestBodySchema) return false;
37
+ return (
38
+ ep.requestBodyContentType === "application/json"
39
+ || ep.requestBodyContentType === "application/x-www-form-urlencoded"
40
+ );
41
+ }
42
+
43
+ /** Serialise an attack body using whichever content type the endpoint
44
+ * declares. Returns the wire-format string + the Content-Type to set. */
45
+ export function serializeProbeBody(
46
+ ep: EndpointInfo,
47
+ body: Record<string, unknown>,
48
+ ): { content: string; contentType: string } {
49
+ if (isFormBody(ep)) {
50
+ return { content: encodeFormBody(body), contentType: "application/x-www-form-urlencoded" };
51
+ }
52
+ return { content: JSON.stringify(body), contentType: "application/json" };
53
+ }
54
+
55
+ /**
56
+ * Resolve an endpoint's URL against the live `base_url` + path-param
57
+ * substitutions. Returns the resolved URL and any leftover `{{var}}`
58
+ * markers the caller couldn't fill — use those to skip the endpoint
59
+ * with a meaningful reason.
60
+ */
61
+ export function buildProbeUrl(
62
+ ep: EndpointInfo,
63
+ vars: Record<string, string>,
64
+ ): { url: string; unresolved: string[] } {
65
+ const baseUrl = (vars["base_url"] ?? "").replace(/\/+$/, "");
66
+ const templated = `${baseUrl}${convertPath(ep.path)}`;
67
+ const url = String(substituteString(templated, vars));
68
+ const unresolved = Array.from(url.matchAll(/\{\{([^}]+)\}\}/g)).map(m => m[1]!);
69
+ return { url, unresolved };
70
+ }
71
+
72
+ /**
73
+ * Standard probe headers: JSON content-type/accept plus the resolved
74
+ * auth header for the endpoint. Empty `liveAuthHeaders` is fine — the
75
+ * spread is a no-op for unauthenticated endpoints.
76
+ */
77
+ export function buildJsonAuthHeaders(
78
+ ep: EndpointInfo,
79
+ schemes: SecuritySchemeInfo[],
80
+ vars: Record<string, string>,
81
+ ): Record<string, string> {
82
+ return {
83
+ "content-type": "application/json",
84
+ accept: "application/json",
85
+ ...liveAuthHeaders(ep, schemes, vars),
86
+ };
87
+ }
88
+
89
+ /** ARV-150: like buildJsonAuthHeaders but picks the Content-Type from the
90
+ * endpoint's spec (form-urlencoded for Stripe v1, JSON otherwise). Accept
91
+ * stays JSON — the server still answers in JSON even when the body is
92
+ * form-encoded. */
93
+ export function buildBodyAuthHeaders(
94
+ ep: EndpointInfo,
95
+ schemes: SecuritySchemeInfo[],
96
+ vars: Record<string, string>,
97
+ ): Record<string, string> {
98
+ const ct = isFormBody(ep) ? "application/x-www-form-urlencoded" : "application/json";
99
+ return {
100
+ "content-type": ct,
101
+ accept: "application/json",
102
+ ...liveAuthHeaders(ep, schemes, vars),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Synthesize a baseline body from the endpoint's request schema and
108
+ * substitute live vars. Returns null when the result isn't a JSON
109
+ * object (array / scalar / null) — both probes treat that as a skip
110
+ * reason ("request body not a JSON object").
111
+ */
112
+ export function buildBaselineFromSpec(
113
+ ep: EndpointInfo,
114
+ vars: Record<string, string>,
115
+ ): Record<string, unknown> | null {
116
+ const raw = ep.requestBodySchema ? generateFromSchema(ep.requestBodySchema) : {};
117
+ const sub = substituteDeep(raw, vars);
118
+ if (typeof sub !== "object" || sub === null || Array.isArray(sub)) return null;
119
+ return sub as Record<string, unknown>;
120
+ }
@@ -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,201 @@
1
+ /**
2
+ * `SecurityProbe` — Probe-contract wrapper around the existing
3
+ * `runSecurityProbes` engine in `security-probe.ts` (m-17 / ARV-49).
4
+ *
5
+ * Behavior is unchanged on this step. dryRun() returns the structured
6
+ * EndpointPlan[] shape (used by the new dry-run envelope in ARV-50);
7
+ * run() delegates to runSecurityProbes(); report() is a thin renderer
8
+ * that converts the SecurityProbeResult to either a markdown digest
9
+ * (existing formatter) or the structured per-endpoint shape (ARV-51).
10
+ */
11
+ import type { Probe, ProbeContext, ProbeFlags, EndpointPlan, ProbeResult, ProbeReportFormat, ProbeEndpointResult, ProbeEndpointStatus } from "./types.ts";
12
+ import {
13
+ runSecurityProbes,
14
+ formatSecurityDigest,
15
+ detectFields,
16
+ SECURITY_CLASSES,
17
+ type SecurityClass,
18
+ type SecurityProbeResult,
19
+ type SecurityVerdict,
20
+ } from "./security-probe.ts";
21
+ import { hasJsonBody, pathTouchesSeededVar } from "./shared.ts";
22
+
23
+ const FLAGS: ProbeFlags = {
24
+ api: true,
25
+ tag: true,
26
+ include: true,
27
+ exclude: true,
28
+ dryRun: true,
29
+ listTags: true,
30
+ json: true,
31
+ output: true,
32
+ report: true,
33
+ };
34
+
35
+ function planForSecurity(
36
+ ctx: ProbeContext,
37
+ classes: SecurityClass[],
38
+ isolated: boolean,
39
+ ): EndpointPlan[] {
40
+ const out: EndpointPlan[] = [];
41
+ for (const ep of ctx.endpoints) {
42
+ if (ep.deprecated) continue;
43
+ const m = ep.method.toUpperCase();
44
+ if (m !== "POST" && m !== "PUT" && m !== "PATCH") continue;
45
+
46
+ if (isolated && (m === "PUT" || m === "PATCH") && pathTouchesSeededVar(ep.path, ctx.vars)) {
47
+ out.push({
48
+ path: ep.path,
49
+ method: m,
50
+ planned: false,
51
+ classes_planned: [],
52
+ fields_planned: [],
53
+ skip_reason: "isolated-protected",
54
+ });
55
+ continue;
56
+ }
57
+
58
+ if (!hasJsonBody(ep)) {
59
+ out.push({
60
+ path: ep.path,
61
+ method: m,
62
+ planned: false,
63
+ classes_planned: [],
64
+ fields_planned: [],
65
+ skip_reason: "no-body",
66
+ });
67
+ continue;
68
+ }
69
+
70
+ const detected = detectFields(ep, classes);
71
+ if (detected.length === 0) {
72
+ out.push({
73
+ path: ep.path,
74
+ method: m,
75
+ planned: false,
76
+ classes_planned: [],
77
+ fields_planned: [],
78
+ skip_reason: "no-matched-field",
79
+ });
80
+ continue;
81
+ }
82
+
83
+ const classesPlanned = Array.from(new Set(detected.map((d) => d.class)));
84
+ const fieldsPlanned = Array.from(new Set(detected.map((d) => d.field)));
85
+ out.push({
86
+ path: ep.path,
87
+ method: m,
88
+ planned: true,
89
+ classes_planned: classesPlanned,
90
+ fields_planned: fieldsPlanned,
91
+ skip_reason: null,
92
+ });
93
+ }
94
+ return out;
95
+ }
96
+
97
+ function statusFromSeverity(s: SecurityVerdict["severity"]): ProbeEndpointStatus {
98
+ if (s === "high") return "high";
99
+ if (s === "low") return "low";
100
+ if (s === "ok") return "ok";
101
+ if (s === "skipped") return "skipped";
102
+ return "inconclusive";
103
+ }
104
+
105
+ function evidenceFromFinding(f: SecurityVerdict["findings"][number]): Record<string, unknown> {
106
+ return {
107
+ field: f.field,
108
+ payload: f.payload,
109
+ status: f.status,
110
+ echoed: f.echoed,
111
+ reason: f.reason,
112
+ ...(f.recommended_action ? { recommended_action: f.recommended_action } : {}),
113
+ };
114
+ }
115
+
116
+ function toProbeResult(sec: SecurityProbeResult): ProbeResult {
117
+ const endpoints: ProbeEndpointResult[] = sec.verdicts.map((v) => ({
118
+ path: v.path,
119
+ method: v.method,
120
+ classes_run: Array.from(new Set(v.detectedFields.map((d) => d.class))),
121
+ findings: v.findings.map((f) => ({
122
+ class: f.class,
123
+ severity:
124
+ f.severity === "inconclusive" || f.severity === "inconclusive-baseline"
125
+ ? "inconclusive"
126
+ : f.severity === "skipped"
127
+ ? "ok"
128
+ : f.severity === "info"
129
+ // ARV-253: ProbeFindingSeverity has no "info" tier. Collapse
130
+ // info → low for the public probe-result envelope; the digest
131
+ // / structured per-endpoint shape preserves the distinction.
132
+ ? "low"
133
+ : f.severity === "medium"
134
+ // ARV-254: ProbeFindingSeverity has no "medium" tier — collapse
135
+ // to "low" for the wire shape. MEDIUM is a digest-only severity
136
+ // marker (SSRF accept on endpoint declaring delivery, no OOB);
137
+ // by design it must NOT gate CI as a HIGH would.
138
+ ? "low"
139
+ : f.severity,
140
+ evidence: evidenceFromFinding(f),
141
+ })),
142
+ status: statusFromSeverity(v.severity),
143
+ ...(v.skipReason ? { skip_reason: v.skipReason } : {}),
144
+ }));
145
+ const by_status: Record<ProbeEndpointStatus, number> = {
146
+ ok: 0, high: 0, low: 0, inconclusive: 0, skipped: 0,
147
+ };
148
+ for (const ep of endpoints) by_status[ep.status]++;
149
+ return {
150
+ endpoints,
151
+ summary: {
152
+ totalEndpoints: sec.totalEndpoints,
153
+ probed: sec.specProbed,
154
+ by_status,
155
+ },
156
+ warnings: sec.warnings,
157
+ };
158
+ }
159
+
160
+ export class SecurityProbe implements Probe {
161
+ readonly name = "security";
162
+ readonly description =
163
+ "Live security probes: SSRF / CRLF / open-redirect. Spec-driven field detection + baseline-OK gate.";
164
+ readonly commonFlags = FLAGS;
165
+
166
+ async dryRun(ctx: ProbeContext): Promise<EndpointPlan[]> {
167
+ const classes = (ctx.classes ?? SECURITY_CLASSES) as SecurityClass[];
168
+ const isolated = ctx.options["isolated"] === true;
169
+ return planForSecurity(ctx, classes, isolated);
170
+ }
171
+
172
+ async run(ctx: ProbeContext): Promise<ProbeResult> {
173
+ const classes = (ctx.classes ?? SECURITY_CLASSES) as SecurityClass[];
174
+ const sec = await runSecurityProbes({
175
+ endpoints: ctx.endpoints,
176
+ securitySchemes: ctx.securitySchemes,
177
+ vars: ctx.vars,
178
+ classes,
179
+ noCleanup: ctx.options["noCleanup"] === true,
180
+ timeoutMs: typeof ctx.options["timeoutMs"] === "number" ? (ctx.options["timeoutMs"] as number) : undefined,
181
+ isolated: ctx.options["isolated"] === true,
182
+ });
183
+ const result = toProbeResult(sec);
184
+ // Pass through the raw sec result for legacy markdown rendering and
185
+ // for orphan-tracker consumers that need the full SecurityVerdict[].
186
+ result.extras = { raw: sec };
187
+ return result;
188
+ }
189
+
190
+ report(format: ProbeReportFormat, result: ProbeResult): string | object {
191
+ if (format === "markdown") {
192
+ const raw = (result.extras?.["raw"] as SecurityProbeResult | undefined);
193
+ if (raw) return formatSecurityDigest(raw, "");
194
+ return "(no markdown digest available)";
195
+ }
196
+ return {
197
+ endpoints: result.endpoints,
198
+ summary: result.summary,
199
+ };
200
+ }
201
+ }