@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,160 @@
1
+ /**
2
+ * `open_cors_on_sensitive` (ARV-256, m-21 pivot) — verify that an
3
+ * authenticated endpoint does not echo arbitrary Origin values with
4
+ * Access-Control-Allow-Credentials: true.
5
+ *
6
+ * The dangerous combo, in two shapes:
7
+ * - `Access-Control-Allow-Origin: *` + `Access-Control-Allow-Credentials: true`
8
+ * (illegal per spec but seen in the wild; some servers patch out the
9
+ * star and emit just the credentials header — still broken)
10
+ * - `Access-Control-Allow-Origin: <reflected attacker Origin>` +
11
+ * `Access-Control-Allow-Credentials: true`
12
+ *
13
+ * Both let any cross-origin site read authenticated responses on behalf
14
+ * of a logged-in user — classic CSRF-with-data-exfil surface.
15
+ *
16
+ * Sends one request with `Origin: https://evil.zond.test`, then
17
+ * inspects the response CORS headers. Evidence_chain proof: request
18
+ * Origin + response headers travel together in the finding.
19
+ *
20
+ * Anti-FP: skips endpoints with `security: []` override (intentionally
21
+ * public). Skips when server doesn't emit any CORS headers (API isn't
22
+ * configured for cross-origin — no problem to find).
23
+ */
24
+ import type { AuthStatefulCheck } from "../stateful.ts";
25
+ import type { OpenAPIV3 } from "openapi-types";
26
+ import type { HttpRequest } from "../../runner/types.ts";
27
+
28
+ const PROBE_ORIGIN = "https://evil.zond.test";
29
+
30
+ function fillPath(
31
+ path: string,
32
+ op: { parameters: OpenAPIV3.ParameterObject[] },
33
+ pathVars: Record<string, string> | undefined,
34
+ ): string {
35
+ return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
36
+ const real = pathVars?.[name];
37
+ if (typeof real === "string" && real.length > 0) return encodeURIComponent(real);
38
+ const param = op.parameters.find((p) => p.name === name && p.in === "path");
39
+ const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
40
+ if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
41
+ if (schema?.type === "integer" || schema?.type === "number") return "1";
42
+ return "x";
43
+ });
44
+ }
45
+
46
+ function getHeader(headers: Record<string, string>, name: string): string | undefined {
47
+ return headers[name] ?? headers[name.toLowerCase()];
48
+ }
49
+
50
+ export const openCorsOnSensitive: AuthStatefulCheck = {
51
+ id: "open_cors_on_sensitive",
52
+ severity: "high",
53
+ defaultExpected:
54
+ "Authenticated endpoints must not echo arbitrary Origin with Allow-Credentials: true (cross-origin read of authed data)",
55
+ references: [{ id: "OWASP-API-09-CORS" }],
56
+ phase: "auth",
57
+ applies(op) {
58
+ // Same anti-FP as ignored_auth: skip explicit-public endpoints.
59
+ if (op.security.length === 0) return false;
60
+ return true;
61
+ },
62
+ async run(op, h) {
63
+ const url = `${h.baseUrl.replace(/\/+$/, "")}${fillPath(op.path, op, h.pathVars)}`;
64
+ const method = op.method.toUpperCase();
65
+ // GET-only probe — bursting on POST/PATCH would create resources.
66
+ // For mutating-only endpoints we still send the actual method with
67
+ // the OPTIONS preflight-style Origin header, since most CORS
68
+ // misconfigurations apply to non-preflight responses.
69
+ const safeMethod = method === "GET" || method === "HEAD" || method === "OPTIONS" ? method : "OPTIONS";
70
+ const req: HttpRequest = {
71
+ method: safeMethod,
72
+ url,
73
+ headers: {
74
+ Accept: "application/json",
75
+ ...h.authHeaders,
76
+ Origin: PROBE_ORIGIN,
77
+ // For non-GET probes, advertise the real method as the preflight
78
+ // would.
79
+ ...(safeMethod === "OPTIONS"
80
+ ? {
81
+ "Access-Control-Request-Method": method,
82
+ "Access-Control-Request-Headers": "Authorization",
83
+ }
84
+ : {}),
85
+ },
86
+ };
87
+ const resp = await h.send(req);
88
+
89
+ const allowOrigin = getHeader(resp.headers, "access-control-allow-origin");
90
+ const allowCreds = getHeader(resp.headers, "access-control-allow-credentials");
91
+
92
+ // No CORS headers emitted → endpoint isn't configured for cross-origin.
93
+ // Nothing to flag.
94
+ if (!allowOrigin && !allowCreds) {
95
+ return { kind: "skip", reason: "no CORS headers in response — API not configured for cross-origin" };
96
+ }
97
+
98
+ const credsTrue = (allowCreds ?? "").toLowerCase() === "true";
99
+ const originIsStar = allowOrigin === "*";
100
+ const originReflects = allowOrigin === PROBE_ORIGIN;
101
+
102
+ // HIGH requires two independent pieces of evidence that authed data is
103
+ // actually CORS-readable by an attacker site:
104
+ //
105
+ // ARV-312: a 2xx response served real content under the reflected
106
+ // Origin. On a non-2xx (401/403/4xx/5xx) *this* response exposed no
107
+ // authenticated payload. Recording the real status also kills the
108
+ // phantom `response_summary.status: 0` (auth checks don't otherwise
109
+ // thread their response back to the runner).
110
+ //
111
+ // ARV-316: an AMBIENT credential the browser auto-attaches cross-origin
112
+ // — i.e. a cookie (apiKey-in-cookie scheme, or a Set-Cookie observed
113
+ // on the probe). `Allow-Credentials: true` is only exploitable with
114
+ // one: for bearer/header/oauth2 token auth the attacker's page can't
115
+ // set the victim's Authorization header, so a reflected Origin is a
116
+ // hygiene nit, not a data leak (Stripe et al are bearer-auth).
117
+ //
118
+ // Without both, cap at LOW per the m-21 "no evidence → no high" matrix.
119
+ const status = resp.status;
120
+ const isTwoXx = status >= 200 && status < 300;
121
+ const schemes = (h.doc?.components?.securitySchemes ?? {}) as Record<
122
+ string,
123
+ OpenAPIV3.SecuritySchemeObject | OpenAPIV3.ReferenceObject
124
+ >;
125
+ const usesCookieAuth = op.security.some((name) => {
126
+ const s = schemes[name];
127
+ return !!s && !("$ref" in s) && s.type === "apiKey" && s.in === "cookie";
128
+ });
129
+ const setCookie = getHeader(resp.headers, "set-cookie");
130
+ const ambientCredential = usesCookieAuth || setCookie !== undefined;
131
+ const authExposed = isTwoXx && ambientCredential;
132
+ const impactNote = authExposed
133
+ ? "any attacker site can read authed cross-origin responses"
134
+ : !isTwoXx
135
+ ? `observed on a ${status} response — cross-origin CORS is misconfigured but authed-data exposure is unproven`
136
+ : "no ambient (cookie) credential — bearer/token auth isn't auto-attached cross-origin, so the reflection isn't exploitable as a data leak";
137
+
138
+ if (credsTrue && (originIsStar || originReflects)) {
139
+ const variant = originIsStar ? "wildcard+credentials" : "reflected+credentials";
140
+ const shape = originIsStar
141
+ ? "Allow-Origin: * with Allow-Credentials: true"
142
+ : "response reflects arbitrary Origin with Allow-Credentials: true";
143
+ return {
144
+ kind: "fail",
145
+ severity: authExposed ? "high" : "low",
146
+ responseStatus: status,
147
+ message: `CORS misconfiguration: ${shape} — ${impactNote}`,
148
+ evidence: {
149
+ request_origin: PROBE_ORIGIN,
150
+ response_status: status,
151
+ access_control_allow_origin: allowOrigin,
152
+ access_control_allow_credentials: allowCreds,
153
+ ambient_credential: ambientCredential,
154
+ variant,
155
+ },
156
+ };
157
+ }
158
+ return { kind: "pass" };
159
+ },
160
+ };
@@ -0,0 +1,419 @@
1
+ /**
2
+ * `pagination_invariants` (m-20 ARV-171, ARV-220 added page-style in m-21)
3
+ * — list-endpoint pagination consistency.
4
+ *
5
+ * Two styles are implemented, gated on the `pagination.type` annotation
6
+ * (and on auto-detection from the OpenAPI spec query params):
7
+ *
8
+ * ── Cursor style (Stripe / Notion / Linear) ─────────────────────────
9
+ * 1. GET ?limit=N → page A.
10
+ * 2. Pick the last item's cursor field (default `id`).
11
+ * 3. GET ?<cursor_param>=<last_id>&limit=N → page B.
12
+ * Invariants:
13
+ * • A∩B disjoint by cursor_field — no item appears on both pages.
14
+ * • has_more consistency — if B is empty, has_more must be false.
15
+ * • A.length == limit when page A advertises has_more=true.
16
+ *
17
+ * ── Page-number style (GitHub / GitLab / Atlassian) ────────────────
18
+ * 1. GET ?page=START&per_page=N → page A.
19
+ * 2. GET ?page=START+1&per_page=N → page B.
20
+ * Invariants:
21
+ * • A∩B disjoint by cursor_field — same data-loss signal.
22
+ * • per_page respected — neither page may exceed N items (server bug
23
+ * if e.g. per_page=2 returns 5).
24
+ * has_more is NOT enforced for page-style: most APIs in this family
25
+ * signal end-of-list via Link headers / total_pages rather than a
26
+ * body boolean, so a missing has_more field is normal.
27
+ *
28
+ * Severity matrix (ARV-288, follow-up to ARV-284 pattern):
29
+ *
30
+ * declared severity = 'low' (proof-cap baseline per ARV-250 — stateful
31
+ * two-page probe is single-signal without an out-of-band diff confirmation).
32
+ *
33
+ * Per-finding dispatch via `outcome.severity` (applied in BOTH
34
+ * runCursorStyle and runPageStyle):
35
+ *
36
+ * HIGH — `kinds` contains 'duplicate_items': items overlap on
37
+ * consecutive pages → real data-loss / off-by-one evidence
38
+ * chain → can reach HIGH (evidence_chain proof).
39
+ *
40
+ * MEDIUM — all other `kinds` only (`has_more_inconsistent`,
41
+ * `partial_page_with_has_more`, `per_page_exceeded`):
42
+ * protocol bugs / single-signal contract violations per
43
+ * ARV-250. Escalated above declared baseline because a
44
+ * concrete invariant is broken, but no data-loss evidence.
45
+ *
46
+ * References: ARV-250 (proof-cap ladder), ARV-284 (per-finding pattern).
47
+ *
48
+ * Anti-FP guards (both styles):
49
+ * • Page A empty → skip ("empty collection — no data to paginate").
50
+ * • Cursor field missing on last item (cursor style) → skip naming the
51
+ * field so the operator can fix the yaml.
52
+ * • 4xx/5xx on either page → broken-baseline skip.
53
+ * • Concurrent writes can race the probe; MVP doesn't double-sweep.
54
+ *
55
+ * Types other than `cursor` / `page` (`offset`, `token`): skip with an
56
+ * explicit reason so callers know the yaml block parsed but the check
57
+ * has no logic for them yet.
58
+ */
59
+ import type { OpenAPIV3 } from "openapi-types";
60
+ import type { CrudStatefulCheck } from "../stateful.ts";
61
+ import type { Severity } from "../../severity/index.ts";
62
+ import type { PaginationConfig } from "../../generator/resources-builder.ts";
63
+ import { fillPathParams } from "./_crud-helpers.ts";
64
+ import { buildUrl as buildUrlBase } from "../../util/url.ts";
65
+
66
+ /** Cursor-style query params we recognise on auto-detect (Stripe,
67
+ * GitHub, Resend, Linear). Lower-cased for case-insensitive match. */
68
+ const CURSOR_QUERY_NAMES = new Set([
69
+ "starting_after",
70
+ "after",
71
+ "cursor",
72
+ "page_token",
73
+ "next_cursor",
74
+ ]);
75
+
76
+ const DEFAULT_LIMIT_PARAM_CURSOR = "limit";
77
+ const DEFAULT_LIMIT_PARAM_PAGE = "per_page";
78
+ const DEFAULT_PAGE_PARAM = "page";
79
+ const DEFAULT_START_PAGE = 1;
80
+ const DEFAULT_CURSOR_FIELD = "id";
81
+ const DEFAULT_HAS_MORE_FIELD = "has_more";
82
+ /** Probe page size — small so two requests land fast and (more
83
+ * importantly) so the probe doesn't repeatedly hammer a 1000-item
84
+ * endpoint just to assert "no duplicates on consecutive pages". */
85
+ const DEFAULT_LIMIT = 2;
86
+ const ITEMS_FIELD_FALLBACKS: ReadonlyArray<string> = ["data", "items", "results", "value"];
87
+
88
+ /** Query-param names that signal a page-number-style endpoint when no
89
+ * yaml annotation is present (GitHub/GitLab/Atlassian/Notion). */
90
+ const PAGE_QUERY_NAMES = new Set(["page", "page_number", "pagenumber"]);
91
+
92
+ function safeParse(v: unknown): unknown {
93
+ if (typeof v !== "string") return v;
94
+ try { return JSON.parse(v); } catch { return v; }
95
+ }
96
+
97
+ function detectCursorParam(list: { parameters: OpenAPIV3.ParameterObject[] }): string | null {
98
+ for (const p of list.parameters) {
99
+ if (p.in !== "query") continue;
100
+ if (CURSOR_QUERY_NAMES.has(p.name.toLowerCase())) return p.name;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function detectPageParam(list: { parameters: OpenAPIV3.ParameterObject[] }): string | null {
106
+ for (const p of list.parameters) {
107
+ if (p.in !== "query") continue;
108
+ if (PAGE_QUERY_NAMES.has(p.name.toLowerCase())) return p.name;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ type CursorResolved = {
114
+ type: "cursor";
115
+ cursorParam: string;
116
+ cursorField: string;
117
+ hasMoreField: string;
118
+ limitParam: string;
119
+ limit: number;
120
+ itemsField: string | null;
121
+ };
122
+
123
+ type PageResolved = {
124
+ type: "page";
125
+ pageParam: string;
126
+ startPage: number;
127
+ cursorField: string;
128
+ limitParam: string;
129
+ limit: number;
130
+ itemsField: string | null;
131
+ };
132
+
133
+ type SkipResolved = { type: PaginationConfig["type"]; reason: string };
134
+
135
+ function resolveConfig(
136
+ cfg: PaginationConfig | undefined,
137
+ list: { parameters: OpenAPIV3.ParameterObject[] },
138
+ ): CursorResolved | PageResolved | SkipResolved | null {
139
+ // Type resolution: explicit yaml wins; otherwise prefer cursor-style
140
+ // auto-detection (Stripe-shaped APIs are the cleaner default), then
141
+ // fall back to page-style detection.
142
+ let type = cfg?.type;
143
+ if (!type) {
144
+ if (detectCursorParam(list)) type = "cursor";
145
+ else if (detectPageParam(list)) type = "page";
146
+ else type = "cursor"; // no signal — will hit the null-return below
147
+ }
148
+
149
+ if (type === "cursor") {
150
+ const cursorParam = cfg?.cursorParam ?? detectCursorParam(list);
151
+ if (!cursorParam) return null;
152
+ return {
153
+ type: "cursor",
154
+ cursorParam,
155
+ cursorField: cfg?.cursorField ?? DEFAULT_CURSOR_FIELD,
156
+ hasMoreField: cfg?.hasMoreField ?? DEFAULT_HAS_MORE_FIELD,
157
+ limitParam: cfg?.limitParam ?? DEFAULT_LIMIT_PARAM_CURSOR,
158
+ limit: cfg?.defaultLimit ?? DEFAULT_LIMIT,
159
+ itemsField: cfg?.itemsField ?? null,
160
+ };
161
+ }
162
+
163
+ if (type === "page") {
164
+ return {
165
+ type: "page",
166
+ pageParam: cfg?.pageParam ?? detectPageParam(list) ?? DEFAULT_PAGE_PARAM,
167
+ startPage: cfg?.startPage ?? DEFAULT_START_PAGE,
168
+ cursorField: cfg?.cursorField ?? DEFAULT_CURSOR_FIELD,
169
+ limitParam: cfg?.limitParam ?? DEFAULT_LIMIT_PARAM_PAGE,
170
+ limit: cfg?.defaultLimit ?? DEFAULT_LIMIT,
171
+ itemsField: cfg?.itemsField ?? null,
172
+ };
173
+ }
174
+
175
+ return { type, reason: `pagination type "${type}" not implemented yet — only cursor and page are supported` };
176
+ }
177
+
178
+ /** Find the array-of-items in a list response. Tries the explicit
179
+ * yaml field first, then a small set of common shapes. Returns null
180
+ * when the body shape is not recognisable. */
181
+ function extractItems(body: unknown, itemsField: string | null): unknown[] | null {
182
+ if (Array.isArray(body)) return body;
183
+ if (!body || typeof body !== "object") return null;
184
+ const obj = body as Record<string, unknown>;
185
+ if (itemsField) {
186
+ const v = obj[itemsField];
187
+ return Array.isArray(v) ? v : null;
188
+ }
189
+ for (const f of ITEMS_FIELD_FALLBACKS) {
190
+ const v = obj[f];
191
+ if (Array.isArray(v)) return v;
192
+ }
193
+ return null;
194
+ }
195
+
196
+ function readHasMore(body: unknown, field: string): boolean | undefined {
197
+ if (!body || typeof body !== "object") return undefined;
198
+ const v = (body as Record<string, unknown>)[field];
199
+ return typeof v === "boolean" ? v : undefined;
200
+ }
201
+
202
+ function pickCursor(item: unknown, field: string): string | number | null {
203
+ if (item == null || typeof item !== "object") return null;
204
+ const v = (item as Record<string, unknown>)[field];
205
+ if (typeof v === "string" || typeof v === "number") return v;
206
+ return null;
207
+ }
208
+
209
+ function buildUrl(base: string, path: string, pathVars: Record<string, string> | undefined, qs: Record<string, string | number>): string {
210
+ return buildUrlBase(base, fillPathParams(path, pathVars), qs);
211
+ }
212
+
213
+ export const paginationInvariants: CrudStatefulCheck = {
214
+ id: "pagination_invariants",
215
+ // ARV-288: declared severity is the proof-cap baseline (low) per ARV-250.
216
+ // Per-finding severity is dispatched via outcome.severity in run() below:
217
+ // duplicate_items (data-loss evidence chain) → high; protocol-only kinds → medium.
218
+ severity: "low",
219
+ defaultExpected: "Consecutive cursor pages must be disjoint and has_more must agree with item presence",
220
+ references: [{ id: "ARV-171" }],
221
+ phase: "crud",
222
+ applies(g) {
223
+ return Boolean(g.list);
224
+ },
225
+ async run(g, h) {
226
+ if (h.bootstrapCleanupFailed) {
227
+ return { kind: "skip", reason: "bootstrap-cleanup failed — stateful checks paused" };
228
+ }
229
+ const list = g.list!;
230
+
231
+ const cfg = h.resourceConfigs?.get(g.resource)?.pagination;
232
+ const resolved = resolveConfig(cfg, list);
233
+ if (resolved == null) {
234
+ return { kind: "skip", reason: "no pagination config and no cursor/page query param in spec" };
235
+ }
236
+ if ("reason" in resolved) {
237
+ return { kind: "skip", reason: resolved.reason };
238
+ }
239
+
240
+ if (resolved.type === "cursor") return runCursorStyle(g, h, list, resolved);
241
+ return runPageStyle(g, h, list, resolved);
242
+ },
243
+ };
244
+
245
+ async function runCursorStyle(
246
+ g: Parameters<CrudStatefulCheck["run"]>[0],
247
+ h: Parameters<CrudStatefulCheck["run"]>[1],
248
+ list: NonNullable<typeof g.list>,
249
+ resolved: CursorResolved,
250
+ ): ReturnType<CrudStatefulCheck["run"]> {
251
+ const baseHeaders = { Accept: "application/json", ...h.authHeaders };
252
+ const urlA = buildUrl(h.baseUrl, list.path, h.pathVars, { [resolved.limitParam]: resolved.limit });
253
+
254
+ const rA = await h.send({ method: "GET", url: urlA, headers: baseHeaders });
255
+ if (rA.status < 200 || rA.status >= 300) {
256
+ return { kind: "skip", reason: `page A returned ${rA.status} — broken-baseline guard` };
257
+ }
258
+ const bodyA = rA.body_parsed ?? safeParse(rA.body);
259
+ const itemsA = extractItems(bodyA, resolved.itemsField);
260
+ if (itemsA == null) {
261
+ return { kind: "skip", reason: `page A: items array not found (tried items_field="${resolved.itemsField ?? "auto"}" + defaults)` };
262
+ }
263
+ if (itemsA.length === 0) {
264
+ return { kind: "skip", reason: "page A empty — no data to paginate" };
265
+ }
266
+
267
+ const hasMoreA = readHasMore(bodyA, resolved.hasMoreField);
268
+ const partialPageWithMore = hasMoreA === true && itemsA.length < resolved.limit;
269
+
270
+ const lastCursor = pickCursor(itemsA[itemsA.length - 1], resolved.cursorField);
271
+ if (lastCursor == null) {
272
+ return { kind: "skip", reason: `cursor field "${resolved.cursorField}" missing on last item of page A` };
273
+ }
274
+
275
+ const urlB = buildUrl(h.baseUrl, list.path, h.pathVars, {
276
+ [resolved.limitParam]: resolved.limit,
277
+ [resolved.cursorParam]: lastCursor,
278
+ });
279
+ const rB = await h.send({ method: "GET", url: urlB, headers: baseHeaders });
280
+ if (rB.status < 200 || rB.status >= 300) {
281
+ return { kind: "skip", reason: `page B returned ${rB.status} — broken-baseline guard` };
282
+ }
283
+ const bodyB = rB.body_parsed ?? safeParse(rB.body);
284
+ const itemsB = extractItems(bodyB, resolved.itemsField);
285
+ if (itemsB == null) {
286
+ return { kind: "skip", reason: "page B: items array shape changed between pages" };
287
+ }
288
+ const hasMoreB = readHasMore(bodyB, resolved.hasMoreField);
289
+
290
+ const duplicates = findDuplicates(itemsA, itemsB, resolved.cursorField);
291
+
292
+ // has_more must be false on the page that ran out of items.
293
+ const inconsistentHasMore = itemsB.length === 0 && hasMoreA === true && hasMoreB !== false;
294
+
295
+ if (duplicates.length === 0 && !inconsistentHasMore && !partialPageWithMore) {
296
+ return { kind: "pass" };
297
+ }
298
+
299
+ const kinds: string[] = [];
300
+ if (duplicates.length > 0) kinds.push("duplicate_items");
301
+ if (inconsistentHasMore) kinds.push("has_more_inconsistent");
302
+ if (partialPageWithMore) kinds.push("partial_page_with_has_more");
303
+
304
+ // ARV-288: per-finding severity dispatch.
305
+ const severity: Severity = kinds.includes("duplicate_items") ? "high" : "medium";
306
+
307
+ return {
308
+ kind: "fail",
309
+ severity,
310
+ message:
311
+ duplicates.length > 0
312
+ ? `Pagination on ${g.resource}: ${duplicates.length} item id(s) appear on both pages (${duplicates.slice(0, 3).join(", ")}${duplicates.length > 3 ? ", …" : ""})`
313
+ : inconsistentHasMore
314
+ ? `Pagination on ${g.resource}: page A advertised has_more=true but page B is empty with has_more!=false`
315
+ : `Pagination on ${g.resource}: page A has ${itemsA.length}/${resolved.limit} items yet has_more=true (partial page with more)`,
316
+ evidence: {
317
+ resource: g.resource,
318
+ kind: kinds.join("+"),
319
+ style: "cursor",
320
+ cursor_param: resolved.cursorParam,
321
+ cursor_field: resolved.cursorField,
322
+ page_a_size: itemsA.length,
323
+ page_b_size: itemsB.length,
324
+ has_more_a: hasMoreA,
325
+ has_more_b: hasMoreB,
326
+ duplicates,
327
+ },
328
+ };
329
+ }
330
+
331
+ async function runPageStyle(
332
+ g: Parameters<CrudStatefulCheck["run"]>[0],
333
+ h: Parameters<CrudStatefulCheck["run"]>[1],
334
+ list: NonNullable<typeof g.list>,
335
+ resolved: PageResolved,
336
+ ): ReturnType<CrudStatefulCheck["run"]> {
337
+ const baseHeaders = { Accept: "application/json", ...h.authHeaders };
338
+ const urlA = buildUrl(h.baseUrl, list.path, h.pathVars, {
339
+ [resolved.pageParam]: resolved.startPage,
340
+ [resolved.limitParam]: resolved.limit,
341
+ });
342
+
343
+ const rA = await h.send({ method: "GET", url: urlA, headers: baseHeaders });
344
+ if (rA.status < 200 || rA.status >= 300) {
345
+ return { kind: "skip", reason: `page A returned ${rA.status} — broken-baseline guard` };
346
+ }
347
+ const bodyA = rA.body_parsed ?? safeParse(rA.body);
348
+ const itemsA = extractItems(bodyA, resolved.itemsField);
349
+ if (itemsA == null) {
350
+ return { kind: "skip", reason: `page A: items array not found (tried items_field="${resolved.itemsField ?? "auto"}" + defaults)` };
351
+ }
352
+ if (itemsA.length === 0) {
353
+ return { kind: "skip", reason: "page A empty — no data to paginate" };
354
+ }
355
+
356
+ const urlB = buildUrl(h.baseUrl, list.path, h.pathVars, {
357
+ [resolved.pageParam]: resolved.startPage + 1,
358
+ [resolved.limitParam]: resolved.limit,
359
+ });
360
+ const rB = await h.send({ method: "GET", url: urlB, headers: baseHeaders });
361
+ if (rB.status < 200 || rB.status >= 300) {
362
+ return { kind: "skip", reason: `page B returned ${rB.status} — broken-baseline guard` };
363
+ }
364
+ const bodyB = rB.body_parsed ?? safeParse(rB.body);
365
+ const itemsB = extractItems(bodyB, resolved.itemsField);
366
+ if (itemsB == null) {
367
+ return { kind: "skip", reason: "page B: items array shape changed between pages" };
368
+ }
369
+
370
+ const duplicates = findDuplicates(itemsA, itemsB, resolved.cursorField);
371
+ const perPageBreach = itemsA.length > resolved.limit || itemsB.length > resolved.limit;
372
+
373
+ if (duplicates.length === 0 && !perPageBreach) {
374
+ return { kind: "pass" };
375
+ }
376
+
377
+ const kinds: string[] = [];
378
+ if (duplicates.length > 0) kinds.push("duplicate_items");
379
+ if (perPageBreach) kinds.push("per_page_exceeded");
380
+
381
+ // ARV-288: per-finding severity dispatch.
382
+ const severity: Severity = kinds.includes("duplicate_items") ? "high" : "medium";
383
+
384
+ return {
385
+ kind: "fail",
386
+ severity,
387
+ message:
388
+ duplicates.length > 0
389
+ ? `Pagination on ${g.resource}: ${duplicates.length} item id(s) appear on pages ${resolved.startPage} and ${resolved.startPage + 1} (${duplicates.slice(0, 3).join(", ")}${duplicates.length > 3 ? ", …" : ""})`
390
+ : `Pagination on ${g.resource}: per_page=${resolved.limit} not respected — server returned ${itemsA.length}/${itemsB.length} items`,
391
+ evidence: {
392
+ resource: g.resource,
393
+ kind: kinds.join("+"),
394
+ style: "page",
395
+ page_param: resolved.pageParam,
396
+ per_page: resolved.limit,
397
+ page_a_number: resolved.startPage,
398
+ page_b_number: resolved.startPage + 1,
399
+ page_a_size: itemsA.length,
400
+ page_b_size: itemsB.length,
401
+ cursor_field: resolved.cursorField,
402
+ duplicates,
403
+ },
404
+ };
405
+ }
406
+
407
+ function findDuplicates(a: unknown[], b: unknown[], cursorField: string): string[] {
408
+ const ids = new Set<string>();
409
+ for (const it of a) {
410
+ const c = pickCursor(it, cursorField);
411
+ if (c != null) ids.add(String(c));
412
+ }
413
+ const dups: string[] = [];
414
+ for (const it of b) {
415
+ const c = pickCursor(it, cursorField);
416
+ if (c != null && ids.has(String(c))) dups.push(String(c));
417
+ }
418
+ return dups;
419
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `positive_data_acceptance` (m-15 ARV-4) — schemathesis-equivalent.
3
+ * Runs against the standard positive case (kind="positive"). When the
4
+ * server *rejects* a generated-as-valid body with a schema-validation
5
+ * status (400 / 422), it's either our generator was wrong or the spec
6
+ * is over-strict — both worth flagging.
7
+ *
8
+ * Auth/lookup statuses (401/403/404/409) are skipped: they aren't
9
+ * schema-validation rejects. 5xx is skipped here too because
10
+ * `not_a_server_error` already covers it.
11
+ */
12
+ import type { Check } from "../types.ts";
13
+
14
+ export const positiveDataAcceptance: Check = {
15
+ id: "positive_data_acceptance",
16
+ severity: "medium",
17
+ defaultExpected: "Server must accept a generated-as-valid body (2xx)",
18
+ references: [{ id: "OWASP-API-08" }],
19
+ applies: (op) => Boolean(op.requestBodySchema),
20
+ run({ response }) {
21
+ const s = response.status;
22
+ if (s >= 200 && s < 300) return { kind: "pass" };
23
+ // Auth / not-found / conflict / server-error → not a schema-rejection signal.
24
+ if (s === 401 || s === 403 || s === 404 || s === 409) return { kind: "pass" };
25
+ if (s >= 500) return { kind: "skip", reason: "5xx covered by not_a_server_error" };
26
+ if (s !== 400 && s !== 422) return { kind: "skip", reason: `status ${s} not a schema-validation reject` };
27
+ return {
28
+ kind: "fail",
29
+ message: `Server rejected a generated-as-valid body with ${s} — generator or spec disagrees with the implementation`,
30
+ evidence: { status: s },
31
+ };
32
+ },
33
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `rate_limit_headers_absent` (ARV-256, m-21 pivot) — flag mutating
3
+ * endpoints (POST/PATCH/PUT/DELETE) whose 2xx responses ship no
4
+ * rate-limit-* headers at all.
5
+ *
6
+ * The full version of this check would burst N requests to detect a
7
+ * 429, but bursting POST creates real resources — too destructive for
8
+ * a default-on check. The lightweight header-inspect version is what
9
+ * Burp/ZAP also do at first pass: detect the absence of standard
10
+ * rate-limit metadata in the response. If the server emits any of
11
+ * `X-RateLimit-*` / `RateLimit-*` / `Retry-After`, this check skips —
12
+ * the server has *some* rate-limit story.
13
+ *
14
+ * Reliability category (ARV-251): missing rate-limit on write
15
+ * endpoints is a production concern (abuse → bill, abuse → DoS), not
16
+ * a security exploit. Severity MEDIUM by default.
17
+ *
18
+ * Anti-FP: skip non-mutating methods. Skip non-2xx responses (a 4xx
19
+ * doesn't tell us anything about rate-limit behaviour on the happy
20
+ * path). Skip endpoints with `security: []` override (public read-only
21
+ * APIs may intentionally omit rate-limit headers).
22
+ */
23
+ import type { Check } from "../types.ts";
24
+
25
+ const RATE_LIMIT_PATTERNS: RegExp[] = [
26
+ /^x-ratelimit-/i,
27
+ /^x-rate-limit-/i,
28
+ /^ratelimit-/i,
29
+ /^retry-after$/i,
30
+ ];
31
+
32
+ function hasRateLimitHeader(headers: Record<string, string>): boolean {
33
+ for (const name of Object.keys(headers)) {
34
+ for (const re of RATE_LIMIT_PATTERNS) {
35
+ if (re.test(name)) return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+
41
+ const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
42
+
43
+ export const rateLimitHeadersAbsent: Check = {
44
+ id: "rate_limit_headers_absent",
45
+ severity: "medium",
46
+ defaultExpected:
47
+ "Mutating endpoints should advertise rate-limit semantics via X-RateLimit-* / RateLimit-* / Retry-After headers",
48
+ references: [
49
+ { id: "RFC-9239-rate-limit-headers" },
50
+ { id: "OWASP-API-04-rate-limit" },
51
+ ],
52
+ applies(op) {
53
+ if (!MUTATING.has(op.method.toUpperCase())) return false;
54
+ // Skip explicitly-public endpoints (security: [] override) — those
55
+ // are often abuse-tolerant by design (anonymous feedback forms etc).
56
+ if (op.security.length === 0) return false;
57
+ return true;
58
+ },
59
+ run({ response }) {
60
+ if (response.status < 200 || response.status >= 300) {
61
+ return { kind: "skip", reason: `non-2xx response (${response.status}) — rate-limit semantics only meaningful on success` };
62
+ }
63
+ if (hasRateLimitHeader(response.headers)) {
64
+ return { kind: "pass" };
65
+ }
66
+ return {
67
+ kind: "fail",
68
+ message:
69
+ "Mutating endpoint returned 2xx without any rate-limit-* / Retry-After header — no advertised abuse protection on a write path",
70
+ evidence: {
71
+ method: "POST/PUT/PATCH/DELETE",
72
+ response_status: response.status,
73
+ looked_for: ["X-RateLimit-*", "RateLimit-*", "Retry-After"],
74
+ },
75
+ };
76
+ },
77
+ };