@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,240 @@
1
+ /**
2
+ * TASK-146: emit-template generator.
3
+ *
4
+ * Builds a ready-to-edit YAML probe template for a single endpoint, so the
5
+ * user doesn't have to copy-paste the boilerplate from the skill (Phase 5.1).
6
+ * Used when the auto-prober marked an endpoint INCONCLUSIVE / INCONCLUSIVE-5XX
7
+ * and the user wants to drop down to manual catch-up.
8
+ *
9
+ * Heuristics:
10
+ * - Suspected fields: classic mass-assignment vectors (is_admin, role,
11
+ * owner_id, account_id, ...).
12
+ * - readOnly: true / x-zond-protected fields lifted from the request body
13
+ * schema — these MUST NOT round-trip back from the server.
14
+ * - For POST endpoints with discoverable item path (GET-by-id / DELETE
15
+ * counterpart) we emit a full create → verify → cleanup chain.
16
+ */
17
+ import type { OpenAPIV3 } from "openapi-types";
18
+ import type { EndpointInfo } from "../generator/types.ts";
19
+ import type { RawSuite, RawStep } from "../generator/serializer.ts";
20
+ import { serializeSuite } from "../generator/serializer.ts";
21
+ import { readOpenApiSpec, extractEndpoints } from "../generator/openapi-reader.ts";
22
+ import { findDeleteCounterpart, findGetByIdCounterpart, captureFieldFor } from "./shared.ts";
23
+ import { SUSPECTED_FIELDS } from "./mass-assignment-probe.ts";
24
+
25
+ export interface EmitTemplateOptions {
26
+ specPath: string;
27
+ method: string;
28
+ path: string;
29
+ }
30
+
31
+ export type EmitTemplateResult =
32
+ | { kind: "ok"; yaml: string; chain: "full" | "single"; protectedFields: string[] }
33
+ | { kind: "endpoint-not-found"; method: string; path: string; nearest: string[] };
34
+
35
+ export async function buildMassAssignmentTemplate(
36
+ opts: EmitTemplateOptions,
37
+ ): Promise<EmitTemplateResult> {
38
+ const doc = await readOpenApiSpec(opts.specPath);
39
+ const all = extractEndpoints(doc);
40
+
41
+ const wantMethod = opts.method.toUpperCase();
42
+ const target = all.find(
43
+ e => e.method.toUpperCase() === wantMethod && pathsEqual(e.path, opts.path),
44
+ );
45
+
46
+ if (!target) {
47
+ const nearest = all
48
+ .filter(e => e.method.toUpperCase() === wantMethod)
49
+ .map(e => e.path)
50
+ .filter(p => similar(p, opts.path))
51
+ .slice(0, 5);
52
+ return { kind: "endpoint-not-found", method: wantMethod, path: opts.path, nearest };
53
+ }
54
+
55
+ const protectedFields = collectProtectedFields(target.requestBodySchema);
56
+ const baselineBody = buildBaselineSkeleton(target.requestBodySchema);
57
+ const privilegedBody = mergePrivileged(baselineBody, protectedFields);
58
+
59
+ const isMutatingCreateLike = wantMethod === "POST";
60
+ const readSibling = isMutatingCreateLike ? findGetByIdCounterpart(target, all) : undefined;
61
+ const deleteSibling = findDeleteCounterpart(target, all);
62
+
63
+ const suite = isMutatingCreateLike && readSibling
64
+ ? buildFullChain(target, readSibling, deleteSibling, privilegedBody, protectedFields)
65
+ : buildSingleStep(target, privilegedBody, protectedFields);
66
+
67
+ return {
68
+ kind: "ok",
69
+ yaml: serializeSuite(suite),
70
+ chain: isMutatingCreateLike && readSibling ? "full" : "single",
71
+ protectedFields,
72
+ };
73
+ }
74
+
75
+ function collectProtectedFields(schema?: OpenAPIV3.SchemaObject): string[] {
76
+ if (!schema || !schema.properties) return [];
77
+ const out: string[] = [];
78
+ for (const [name, raw] of Object.entries(schema.properties)) {
79
+ const prop = raw as OpenAPIV3.SchemaObject & { "x-zond-protected"?: boolean };
80
+ if (prop.readOnly === true || prop["x-zond-protected"] === true) out.push(name);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function buildBaselineSkeleton(schema?: OpenAPIV3.SchemaObject): Record<string, unknown> {
86
+ // Skeleton is intentionally minimal — `# …real create body sourced from
87
+ // fixtures…` placeholder shows up in the YAML so the user fills it in.
88
+ if (!schema || !schema.properties) return {};
89
+ const out: Record<string, unknown> = {};
90
+ for (const [name, raw] of Object.entries(schema.properties)) {
91
+ const prop = raw as OpenAPIV3.SchemaObject;
92
+ if (prop.readOnly === true) continue;
93
+ if (schema.required?.includes(name)) {
94
+ out[name] = placeholderForType(prop);
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function placeholderForType(prop: OpenAPIV3.SchemaObject): unknown {
101
+ if (prop.example !== undefined) return prop.example;
102
+ switch (prop.type) {
103
+ case "integer":
104
+ case "number": return 1;
105
+ case "boolean": return false;
106
+ case "array": return [];
107
+ case "object": return {};
108
+ default: return `ma-test-{{$randomString}}`;
109
+ }
110
+ }
111
+
112
+ function mergePrivileged(
113
+ baseline: Record<string, unknown>,
114
+ protectedFields: string[],
115
+ ): Record<string, unknown> {
116
+ const merged: Record<string, unknown> = { ...baseline };
117
+ // Suspected fields always added.
118
+ for (const [k, v] of Object.entries(SUSPECTED_FIELDS)) merged[k] = v;
119
+ // readOnly / x-zond-protected fields: inject distinctive sentinel values
120
+ // so we can detect server-side echo vs server-side regeneration.
121
+ for (const f of protectedFields) {
122
+ if (!(f in merged)) merged[f] = `attacker-${f}-{{$uuid}}`;
123
+ }
124
+ return merged;
125
+ }
126
+
127
+ /** ARV-198: declared content type signals whether the create step needs
128
+ * `form:` (Stripe v1, Rails/PHP-style APIs) or the default `json:` body. */
129
+ function isFormEndpoint(ep: EndpointInfo): boolean {
130
+ return ep.requestBodyContentType === "application/x-www-form-urlencoded";
131
+ }
132
+
133
+ function buildFullChain(
134
+ create: EndpointInfo,
135
+ read: EndpointInfo,
136
+ del: EndpointInfo | undefined,
137
+ privilegedBody: Record<string, unknown>,
138
+ protectedFields: string[],
139
+ ): RawSuite {
140
+ const idVar = captureFieldFor(create) || "created_id";
141
+ const tests: RawStep[] = [];
142
+
143
+ // ARV-198: when the spec declares only application/x-www-form-urlencoded
144
+ // for this mutating endpoint, emit a `form:` block + Content-Type header.
145
+ // The serializer force-quotes form values (ARV-162) so booleans/numbers
146
+ // round-trip as strings on the wire; the template is paste-ready against
147
+ // Stripe-style APIs without any post-processing.
148
+ const createIsForm = isFormEndpoint(create);
149
+ const createStep: Record<string, unknown> = {
150
+ name: "create with privileged fields",
151
+ [create.method.toUpperCase()]: create.path,
152
+ expect: { status: [200, 201], body: { id: { capture: idVar } } },
153
+ };
154
+ if (createIsForm) {
155
+ createStep.headers = { "Content-Type": "application/x-www-form-urlencoded" };
156
+ createStep.form = privilegedBody;
157
+ } else {
158
+ createStep.json = privilegedBody;
159
+ }
160
+ tests.push(createStep as unknown as RawStep);
161
+
162
+ // Canonical assertion vocabulary: only `not_equals` is supported (no `not`,
163
+ // no `not_starts_with`). For protected fields we assert the exact attacker
164
+ // sentinel value did NOT round-trip back from the server.
165
+ const verifyBody: Record<string, Record<string, unknown>> = {};
166
+ for (const k of Object.keys(SUSPECTED_FIELDS)) {
167
+ verifyBody[k] = { not_equals: SUSPECTED_FIELDS[k] };
168
+ }
169
+ for (const f of protectedFields) {
170
+ if (!(f in verifyBody)) verifyBody[f] = { not_contains: "attacker-" };
171
+ }
172
+
173
+ tests.push({
174
+ name: "verify privileged fields not echoed",
175
+ [read.method.toUpperCase()]: read.path.replace(/\{[^}]+\}/, `{{${idVar}}}`),
176
+ expect: { status: 200, body: verifyBody as unknown as Record<string, Record<string, string>> },
177
+ } as unknown as RawStep);
178
+
179
+ if (del) {
180
+ tests.push({
181
+ name: "cleanup",
182
+ [del.method.toUpperCase()]: del.path.replace(/\{[^}]+\}/, `{{${idVar}}}`),
183
+ always: true,
184
+ expect: { status: [200, 202, 204] },
185
+ } as unknown as RawStep);
186
+ }
187
+
188
+ return {
189
+ name: `ma ${slugFromPath(create.path)}`,
190
+ base_url: "{{base_url}}",
191
+ headers: { Authorization: "Bearer {{auth_token}}" },
192
+ tests,
193
+ };
194
+ }
195
+
196
+ function buildSingleStep(
197
+ ep: EndpointInfo,
198
+ privilegedBody: Record<string, unknown>,
199
+ _protectedFields: string[],
200
+ ): RawSuite {
201
+ const method = ep.method.toUpperCase();
202
+ const tests: RawStep[] = [];
203
+ const step: Record<string, unknown> = {
204
+ name: `mass-assignment ${method} ${ep.path}`,
205
+ [method]: ep.path,
206
+ expect: { status: [400, 422] },
207
+ };
208
+ if (method !== "GET" && method !== "DELETE") {
209
+ // ARV-198: same form/json branching as the full-chain create — see
210
+ // buildFullChain for rationale (Stripe-style mutators declare only
211
+ // application/x-www-form-urlencoded).
212
+ if (isFormEndpoint(ep)) {
213
+ step.headers = { "Content-Type": "application/x-www-form-urlencoded" };
214
+ step.form = privilegedBody;
215
+ } else {
216
+ step.json = privilegedBody;
217
+ }
218
+ }
219
+ tests.push(step as unknown as RawStep);
220
+ return {
221
+ name: `ma ${slugFromPath(ep.path)}`,
222
+ base_url: "{{base_url}}",
223
+ headers: { Authorization: "Bearer {{auth_token}}" },
224
+ tests,
225
+ };
226
+ }
227
+
228
+ function pathsEqual(a: string, b: string): boolean {
229
+ return a.replace(/\/$/, "") === b.replace(/\/$/, "");
230
+ }
231
+
232
+ function similar(a: string, b: string): boolean {
233
+ const aSeg = a.split("/").filter(Boolean);
234
+ const bSeg = b.split("/").filter(Boolean);
235
+ return aSeg.some(s => bSeg.includes(s));
236
+ }
237
+
238
+ function slugFromPath(p: string): string {
239
+ return p.replace(/^\//, "").replace(/\/?\{[^}]+\}/g, "").replace(/\//g, "-") || "endpoint";
240
+ }
@@ -18,19 +18,19 @@
18
18
  import type { OpenAPIV3 } from "openapi-types";
19
19
  import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
20
20
  import type { RawSuite, RawStep } from "../generator/serializer.ts";
21
-
22
- // ──────────────────────────────────────────────
23
- // Constants
24
- // ──────────────────────────────────────────────
25
-
26
- const ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
27
- type Method = (typeof ALL_METHODS)[number];
28
-
29
- /** Statuses we accept on a *missing* method. 405 is canonical, 404 is a
30
- * common fallback (path not registered for that method), 401/403 are
31
- * acceptable when auth is checked before routing. Anything else — notably
32
- * 5xx (unhandled), 200/201 (silent acceptance) is a probe failure. */
33
- const ACCEPTABLE_STATUSES = [401, 403, 404, 405];
21
+ import { pathWithByAliases, getAuthHeaders } from "./shared.ts";
22
+ import {
23
+ ALL_METHODS,
24
+ ACCEPTABLE_UNSUPPORTED_STATUSES,
25
+ bucketEndpointsByPath,
26
+ pathWithMethodPlaceholders,
27
+ type Method,
28
+ } from "./method-shared.ts";
29
+
30
+ // 405-or-equivalent statuses for an *undeclared* method probe. ARV-2
31
+ // (m-15) extracted this list to method-shared.ts so the live
32
+ // `unsupported_method` check stays in lock-step with the offline probe.
33
+ const ACCEPTABLE_STATUSES = [...ACCEPTABLE_UNSUPPORTED_STATUSES];
34
34
 
35
35
  // ──────────────────────────────────────────────
36
36
  // Types
@@ -67,75 +67,22 @@ function slugify(s: string): string {
67
67
  }
68
68
 
69
69
  function pathStem(path: string): string {
70
- const cleaned = path
71
- .replace(/\{[^}]+\}/g, "by-id")
70
+ // TASK-159 (m-9 P3): preserve placeholder name (`by-org`, `by-proj`)
71
+ // instead of collapsing every `{x}` to a generic `by-id`.
72
+ const cleaned = pathWithByAliases(path)
72
73
  .replace(/^\//, "")
73
74
  .replace(/\//g, "-");
74
75
  return slugify(cleaned) || "root";
75
76
  }
76
77
 
77
- /** Replace path params with valid-shape placeholders so the request can
78
- * reach the routing layer without being rejected purely on path syntax. */
79
- function pathWithPlaceholders(
80
- path: string,
81
- parameters: OpenAPIV3.ParameterObject[],
82
- ): string {
83
- return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
84
- const param = parameters.find((p) => p.name === name && p.in === "path");
85
- const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
86
- if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
87
- if (schema?.type === "integer" || schema?.type === "number") return "999999999";
88
- return "nonexistent-zzzzz";
89
- });
90
- }
91
-
92
- function getAuthHeaders(
93
- ep: EndpointInfo,
94
- schemes: SecuritySchemeInfo[],
95
- ): Record<string, string> | undefined {
96
- if (ep.security.length === 0) return undefined;
97
- for (const secName of ep.security) {
98
- const scheme = schemes.find((s) => s.name === secName);
99
- if (!scheme) continue;
100
- if (scheme.type === "http") {
101
- if (scheme.scheme === "bearer" || !scheme.scheme) {
102
- return { Authorization: "Bearer {{auth_token}}" };
103
- }
104
- if (scheme.scheme === "basic") {
105
- return { Authorization: "Basic {{auth_token}}" };
106
- }
107
- }
108
- if (scheme.type === "apiKey" && scheme.in === "header" && scheme.apiKeyName) {
109
- if (scheme.apiKeyName === "Authorization") {
110
- return { Authorization: "Bearer {{auth_token}}" };
111
- }
112
- return { [scheme.apiKeyName]: "{{api_key}}" };
113
- }
114
- }
115
- return undefined;
116
- }
117
-
118
- interface PathBucket {
78
+ // pathWithPlaceholders + bucketByPath moved to ./method-shared.ts for
79
+ // reuse by the live `unsupported_method` check (m-15 ARV-2).
80
+ const pathWithPlaceholders = pathWithMethodPlaceholders;
81
+ const bucketByPath = (endpoints: EndpointInfo[]): Array<{
119
82
  path: string;
120
- /** Methods declared on this path, normalized to upper-case. */
121
83
  declared: Set<string>;
122
- /** A representative endpoint we can borrow auth/path-param shape from. */
123
84
  sample: EndpointInfo;
124
- }
125
-
126
- function bucketByPath(endpoints: EndpointInfo[]): PathBucket[] {
127
- const map = new Map<string, PathBucket>();
128
- for (const ep of endpoints) {
129
- if (ep.deprecated) continue;
130
- let bucket = map.get(ep.path);
131
- if (!bucket) {
132
- bucket = { path: ep.path, declared: new Set(), sample: ep };
133
- map.set(ep.path, bucket);
134
- }
135
- bucket.declared.add(ep.method.toUpperCase());
136
- }
137
- return Array.from(map.values());
138
- }
85
+ }> => Array.from(bucketEndpointsByPath(endpoints).values());
139
86
 
140
87
  // ──────────────────────────────────────────────
141
88
  // Public API
@@ -165,10 +112,25 @@ export function generateMethodProbes(opts: MethodProbeOptions): MethodProbeResul
165
112
  const headers = getAuthHeaders(bucket.sample, securitySchemes);
166
113
 
167
114
  const steps: RawStep[] = missing.map((method) => {
115
+ // ARV-179: OPTIONS on an undeclared path is legitimately handled
116
+ // by most stacks (CORS preflight, 200/204 with Allow header). Let
117
+ // the probe accept 2xx for OPTIONS only; everything else keeps
118
+ // the strict "no 2xx, no 5xx" expectation.
119
+ const acceptable = method === "OPTIONS"
120
+ ? [200, 204, ...ACCEPTABLE_STATUSES]
121
+ : ACCEPTABLE_STATUSES;
122
+ const expectLabel = method === "OPTIONS"
123
+ ? `${method} ${bucket.path} — undeclared method must not 5xx (OPTIONS may legitimately succeed)`
124
+ : `${method} ${bucket.path} — undeclared method must reject (no 5xx, no 2xx)`;
168
125
  const step: RawStep = {
169
- name: `${method} ${bucket.path} — undeclared method must reject (no 5xx, no 2xx)`,
126
+ name: expectLabel,
127
+ source: {
128
+ generator: "method-probe",
129
+ endpoint: `${method} ${bucket.path}`,
130
+ response_branch: acceptable.map(String).join("|"),
131
+ },
170
132
  [method]: convertPath(concretePath),
171
- expect: { status: ACCEPTABLE_STATUSES },
133
+ expect: { status: acceptable },
172
134
  };
173
135
  // Body-bearing methods on an undeclared route — send a minimal valid
174
136
  // JSON object to provoke any body-parsing path while the router is
@@ -186,6 +148,11 @@ export function generateMethodProbes(opts: MethodProbeOptions): MethodProbeResul
186
148
  suites.push({
187
149
  name: `probe methods ${bucket.path}`,
188
150
  tags: ["probe-methods", "negative-method", "no-5xx", "smoke"],
151
+ source: {
152
+ type: "probe-suite",
153
+ generator: "method-probe",
154
+ endpoint: bucket.path,
155
+ },
189
156
  fileStem: `probe-methods-${stem}`,
190
157
  base_url: "{{base_url}}",
191
158
  ...(headers ? { headers } : {}),
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared bits between the offline `method-probe` (which emits YAML
3
+ * suites) and the live `unsupported_method` check from `core/checks`
4
+ * (m-15 ARV-2). Both ask the same question — "which HTTP methods aren't
5
+ * declared on this path?" — so the constants and helpers live here so
6
+ * the two stay in lock-step (ARV-2 AC #4).
7
+ */
8
+ import type { OpenAPIV3 } from "openapi-types";
9
+ import type { EndpointInfo } from "../generator/types.ts";
10
+
11
+ /** ARV-179: full HTTP method complement used for `unsupported_method`
12
+ * enumeration. Matches schemathesis V4 `DEFAULT_UNEXPECTED_METHODS`
13
+ * minus the WebDAV-style `query` (not a REST norm) and `CONNECT`
14
+ * (irrelevant to API routing). `HEAD` is intentionally excluded
15
+ * because most stacks auto-derive it from `GET`, so a 2xx response is
16
+ * expected behaviour rather than a leak. */
17
+ export const ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"] as const;
18
+ export type Method = (typeof ALL_METHODS)[number];
19
+
20
+ /** Statuses we accept for an *undeclared* method on a path. 405 is
21
+ * canonical, 404 is a common fallback (path not registered for that
22
+ * method), 401/403 are acceptable when auth is checked before routing. */
23
+ export const ACCEPTABLE_UNSUPPORTED_STATUSES = [401, 403, 404, 405] as const;
24
+
25
+ /** ARV-179: OPTIONS is special — it's legitimately implemented by most
26
+ * stacks for CORS preflight and may legitimately return 2xx with
27
+ * `Allow`/`Access-Control-*` headers. A 2xx OPTIONS on an undeclared
28
+ * path is the spec-compliant outcome, not a finding. Use this helper
29
+ * in both the offline probe and the live check to keep the policy in
30
+ * one place. */
31
+ export function isPermissibleOptionsResponse(method: string, status: number): boolean {
32
+ return method.toUpperCase() === "OPTIONS" && status >= 200 && status < 300;
33
+ }
34
+
35
+ /**
36
+ * Replace `{name}` segments with valid-shape placeholders so the
37
+ * request can reach the routing layer without being rejected purely on
38
+ * path syntax. Used by both the offline probe and the live check.
39
+ */
40
+ export function pathWithMethodPlaceholders(
41
+ path: string,
42
+ parameters: OpenAPIV3.ParameterObject[],
43
+ ): string {
44
+ return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
45
+ const param = parameters.find((p) => p.name === name && p.in === "path");
46
+ const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
47
+ if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
48
+ if (schema?.type === "integer" || schema?.type === "number") return "999999999";
49
+ return "nonexistent-zzzzz";
50
+ });
51
+ }
52
+
53
+ export function bucketEndpointsByPath(endpoints: EndpointInfo[]): Map<string, {
54
+ path: string;
55
+ declared: Set<string>;
56
+ sample: EndpointInfo;
57
+ }> {
58
+ const map = new Map<string, { path: string; declared: Set<string>; sample: EndpointInfo }>();
59
+ for (const ep of endpoints) {
60
+ if (ep.deprecated) continue;
61
+ let bucket = map.get(ep.path);
62
+ if (!bucket) {
63
+ bucket = { path: ep.path, declared: new Set(), sample: ep };
64
+ map.set(ep.path, bucket);
65
+ }
66
+ bucket.declared.add(ep.method.toUpperCase());
67
+ }
68
+ return map;
69
+ }