@kirrosh/zond 0.26.1 → 0.27.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 (261) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +22 -21
  3. package/bin/zond.mjs +23 -0
  4. package/package.json +13 -16
  5. package/scripts/npm/postinstall.mjs +76 -0
  6. package/src/CLAUDE.md +0 -112
  7. package/src/bun-types.d.ts +0 -5
  8. package/src/cli/argv.ts +0 -122
  9. package/src/cli/commands/add-api.ts +0 -146
  10. package/src/cli/commands/api/annotate/idempotency.ts +0 -59
  11. package/src/cli/commands/api/annotate/index.ts +0 -880
  12. package/src/cli/commands/api/annotate/lifecycle.ts +0 -74
  13. package/src/cli/commands/api/annotate/overlay.ts +0 -206
  14. package/src/cli/commands/api/annotate/pagination.ts +0 -64
  15. package/src/cli/commands/api/annotate/prompts.ts +0 -220
  16. package/src/cli/commands/api/annotate/readback.ts +0 -58
  17. package/src/cli/commands/api/annotate/resources.ts +0 -91
  18. package/src/cli/commands/api/annotate/seed-bodies.ts +0 -61
  19. package/src/cli/commands/audit.ts +0 -786
  20. package/src/cli/commands/catalog.ts +0 -97
  21. package/src/cli/commands/check.ts +0 -361
  22. package/src/cli/commands/checks.ts +0 -1072
  23. package/src/cli/commands/ci-init.ts +0 -223
  24. package/src/cli/commands/clean.ts +0 -212
  25. package/src/cli/commands/cleanup.ts +0 -236
  26. package/src/cli/commands/completions.ts +0 -192
  27. package/src/cli/commands/coverage.ts +0 -872
  28. package/src/cli/commands/db.ts +0 -611
  29. package/src/cli/commands/describe.ts +0 -120
  30. package/src/cli/commands/discover.ts +0 -1356
  31. package/src/cli/commands/doctor.ts +0 -661
  32. package/src/cli/commands/fixtures.ts +0 -402
  33. package/src/cli/commands/generate.ts +0 -577
  34. package/src/cli/commands/init/agents-md.ts +0 -61
  35. package/src/cli/commands/init/bootstrap.ts +0 -111
  36. package/src/cli/commands/init/index.ts +0 -244
  37. package/src/cli/commands/init/skills.ts +0 -141
  38. package/src/cli/commands/init/templates/agents.md +0 -86
  39. package/src/cli/commands/init/templates/markdown.d.ts +0 -4
  40. package/src/cli/commands/init/templates/skills/warm-up-target.md +0 -122
  41. package/src/cli/commands/init/templates/skills/zond-checks.md +0 -621
  42. package/src/cli/commands/init/templates/skills/zond-seed.md +0 -114
  43. package/src/cli/commands/init/templates/skills/zond-triage.md +0 -272
  44. package/src/cli/commands/init/templates/skills/zond.md +0 -861
  45. package/src/cli/commands/init/templates/zond-config.yml +0 -14
  46. package/src/cli/commands/prepare-fixtures.ts +0 -97
  47. package/src/cli/commands/probe/_seed-bodies.ts +0 -52
  48. package/src/cli/commands/probe/mass-assignment.ts +0 -594
  49. package/src/cli/commands/probe/security.ts +0 -537
  50. package/src/cli/commands/probe/static.ts +0 -255
  51. package/src/cli/commands/probe/webhooks.ts +0 -163
  52. package/src/cli/commands/probe.ts +0 -535
  53. package/src/cli/commands/reference.ts +0 -87
  54. package/src/cli/commands/refresh-api.ts +0 -227
  55. package/src/cli/commands/remove-api.ts +0 -150
  56. package/src/cli/commands/report-bundle.ts +0 -310
  57. package/src/cli/commands/report.ts +0 -241
  58. package/src/cli/commands/request.ts +0 -548
  59. package/src/cli/commands/run.ts +0 -1162
  60. package/src/cli/commands/schema-from-runs.ts +0 -128
  61. package/src/cli/commands/secrets.ts +0 -133
  62. package/src/cli/commands/session.ts +0 -244
  63. package/src/cli/commands/use.ts +0 -74
  64. package/src/cli/index.ts +0 -55
  65. package/src/cli/json-envelope.ts +0 -108
  66. package/src/cli/json-schemas.ts +0 -314
  67. package/src/cli/output.ts +0 -40
  68. package/src/cli/program.ts +0 -219
  69. package/src/cli/resolve.ts +0 -105
  70. package/src/cli/runtime.ts +0 -7
  71. package/src/cli/safe-live.ts +0 -24
  72. package/src/cli/status-filter.ts +0 -114
  73. package/src/cli/util/api-context.ts +0 -85
  74. package/src/cli/version.ts +0 -8
  75. package/src/core/audit/persist.ts +0 -183
  76. package/src/core/checks/budget.ts +0 -59
  77. package/src/core/checks/checks/_crud-helpers.ts +0 -133
  78. package/src/core/checks/checks/_negative_mutator.ts +0 -133
  79. package/src/core/checks/checks/_readback-helpers.ts +0 -133
  80. package/src/core/checks/checks/content_type_conformance.ts +0 -39
  81. package/src/core/checks/checks/cross_call_references.ts +0 -147
  82. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +0 -219
  83. package/src/core/checks/checks/ensure_resource_availability.ts +0 -62
  84. package/src/core/checks/checks/idempotency_replay.ts +0 -242
  85. package/src/core/checks/checks/ignored_auth.ts +0 -254
  86. package/src/core/checks/checks/index.ts +0 -68
  87. package/src/core/checks/checks/lifecycle_transitions.ts +0 -416
  88. package/src/core/checks/checks/missing_required_header.ts +0 -40
  89. package/src/core/checks/checks/negative_data_rejection.ts +0 -148
  90. package/src/core/checks/checks/not_a_server_error.ts +0 -35
  91. package/src/core/checks/checks/open_cors_on_sensitive.ts +0 -160
  92. package/src/core/checks/checks/pagination_invariants.ts +0 -419
  93. package/src/core/checks/checks/positive_data_acceptance.ts +0 -33
  94. package/src/core/checks/checks/rate_limit_headers_absent.ts +0 -77
  95. package/src/core/checks/checks/response_headers_conformance.ts +0 -74
  96. package/src/core/checks/checks/response_schema_conformance.ts +0 -30
  97. package/src/core/checks/checks/status_code_conformance.ts +0 -132
  98. package/src/core/checks/checks/unsupported_method.ts +0 -63
  99. package/src/core/checks/checks/use_after_free.ts +0 -78
  100. package/src/core/checks/index.ts +0 -30
  101. package/src/core/checks/mode.ts +0 -82
  102. package/src/core/checks/recommended-action.ts +0 -68
  103. package/src/core/checks/registry.ts +0 -78
  104. package/src/core/checks/runner.ts +0 -1461
  105. package/src/core/checks/sarif.ts +0 -230
  106. package/src/core/checks/spec-findings.ts +0 -308
  107. package/src/core/checks/stateful.ts +0 -121
  108. package/src/core/checks/types.ts +0 -305
  109. package/src/core/checks/zond-extensions.ts +0 -73
  110. package/src/core/classifier/recommended-action.ts +0 -251
  111. package/src/core/context/current.ts +0 -51
  112. package/src/core/context/session.ts +0 -78
  113. package/src/core/coverage/loader.ts +0 -216
  114. package/src/core/coverage/reasons.ts +0 -300
  115. package/src/core/diagnostics/db-analysis.ts +0 -666
  116. package/src/core/diagnostics/failure-class.ts +0 -140
  117. package/src/core/diagnostics/failure-hints.ts +0 -112
  118. package/src/core/diagnostics/spec-pointer.ts +0 -99
  119. package/src/core/diagnostics/suggested-fixes.ts +0 -155
  120. package/src/core/exporter/case-study/index.ts +0 -270
  121. package/src/core/exporter/curl.ts +0 -40
  122. package/src/core/exporter/exporter.ts +0 -48
  123. package/src/core/exporter/html-report/escape.ts +0 -24
  124. package/src/core/exporter/html-report/index.ts +0 -479
  125. package/src/core/exporter/html-report/script.ts +0 -100
  126. package/src/core/exporter/html-report/styles.ts +0 -408
  127. package/src/core/generator/catalog-builder.ts +0 -179
  128. package/src/core/generator/chunker.ts +0 -78
  129. package/src/core/generator/coverage-phase.ts +0 -0
  130. package/src/core/generator/coverage-scanner.ts +0 -87
  131. package/src/core/generator/data-factory.ts +0 -759
  132. package/src/core/generator/describe.ts +0 -302
  133. package/src/core/generator/endpoint-warnings.ts +0 -52
  134. package/src/core/generator/fixtures-builder.ts +0 -332
  135. package/src/core/generator/index.ts +0 -14
  136. package/src/core/generator/openapi-reader.ts +0 -297
  137. package/src/core/generator/path-param-disambig.ts +0 -140
  138. package/src/core/generator/resources-builder.ts +0 -898
  139. package/src/core/generator/schema-utils.ts +0 -112
  140. package/src/core/generator/serializer.ts +0 -343
  141. package/src/core/generator/suite-generator.ts +0 -1301
  142. package/src/core/generator/types.ts +0 -63
  143. package/src/core/identity/identity-file.ts +0 -0
  144. package/src/core/lint/affects.ts +0 -28
  145. package/src/core/lint/config.ts +0 -96
  146. package/src/core/lint/format.ts +0 -42
  147. package/src/core/lint/index.ts +0 -94
  148. package/src/core/lint/reporter.ts +0 -128
  149. package/src/core/lint/rules/consistency.ts +0 -158
  150. package/src/core/lint/rules/heuristics.ts +0 -97
  151. package/src/core/lint/rules/strictness.ts +0 -109
  152. package/src/core/lint/types.ts +0 -96
  153. package/src/core/lint/walker.ts +0 -248
  154. package/src/core/meta/meta-store.ts +0 -11
  155. package/src/core/output/README.md +0 -73
  156. package/src/core/output/index.ts +0 -13
  157. package/src/core/output/run.ts +0 -91
  158. package/src/core/output/types.ts +0 -122
  159. package/src/core/parser/dynamic-values.ts +0 -160
  160. package/src/core/parser/env-interpolation.ts +0 -104
  161. package/src/core/parser/filter.ts +0 -97
  162. package/src/core/parser/schema.ts +0 -359
  163. package/src/core/parser/types.ts +0 -117
  164. package/src/core/parser/variables.ts +0 -0
  165. package/src/core/parser/yaml-parser.ts +0 -181
  166. package/src/core/probe/bootstrap.ts +0 -34
  167. package/src/core/probe/dry-run-envelope.ts +0 -61
  168. package/src/core/probe/mass-assignment/classify.ts +0 -175
  169. package/src/core/probe/mass-assignment/cleanup.ts +0 -52
  170. package/src/core/probe/mass-assignment/digest.ts +0 -114
  171. package/src/core/probe/mass-assignment/orchestrator.ts +0 -459
  172. package/src/core/probe/mass-assignment/regression.ts +0 -141
  173. package/src/core/probe/mass-assignment/suspects.ts +0 -92
  174. package/src/core/probe/mass-assignment/types.ts +0 -135
  175. package/src/core/probe/mass-assignment-probe-class.ts +0 -198
  176. package/src/core/probe/mass-assignment-probe.ts +0 -27
  177. package/src/core/probe/mass-assignment-template.ts +0 -240
  178. package/src/core/probe/method-probe.ts +0 -164
  179. package/src/core/probe/method-shared.ts +0 -69
  180. package/src/core/probe/negative-probe.ts +0 -691
  181. package/src/core/probe/orphan-tracker.ts +0 -188
  182. package/src/core/probe/path-discovery.ts +0 -439
  183. package/src/core/probe/probe-harness.ts +0 -119
  184. package/src/core/probe/registry.ts +0 -89
  185. package/src/core/probe/runner.ts +0 -136
  186. package/src/core/probe/security/baseline.ts +0 -174
  187. package/src/core/probe/security/classify.ts +0 -341
  188. package/src/core/probe/security/cleanup.ts +0 -125
  189. package/src/core/probe/security/detectors.ts +0 -71
  190. package/src/core/probe/security/digest.ts +0 -104
  191. package/src/core/probe/security/orchestrator.ts +0 -398
  192. package/src/core/probe/security/regression.ts +0 -103
  193. package/src/core/probe/security/types.ts +0 -151
  194. package/src/core/probe/security-probe-class.ts +0 -207
  195. package/src/core/probe/security-probe.ts +0 -32
  196. package/src/core/probe/shared.ts +0 -531
  197. package/src/core/probe/static-probe-class.ts +0 -125
  198. package/src/core/probe/types.ts +0 -165
  199. package/src/core/probe/verdict-aggregator.ts +0 -33
  200. package/src/core/probe/webhooks-probe.ts +0 -282
  201. package/src/core/reporter/console.ts +0 -240
  202. package/src/core/reporter/index.ts +0 -22
  203. package/src/core/reporter/json.ts +0 -22
  204. package/src/core/reporter/junit.ts +0 -93
  205. package/src/core/reporter/ndjson.ts +0 -37
  206. package/src/core/reporter/types.ts +0 -15
  207. package/src/core/runner/assertions.ts +0 -383
  208. package/src/core/runner/async-pool.ts +0 -108
  209. package/src/core/runner/auth-path.ts +0 -8
  210. package/src/core/runner/ci-context.ts +0 -72
  211. package/src/core/runner/executor.ts +0 -652
  212. package/src/core/runner/expr-eval.ts +0 -41
  213. package/src/core/runner/form-encode.ts +0 -41
  214. package/src/core/runner/http-client.ts +0 -224
  215. package/src/core/runner/learn-drift.ts +0 -293
  216. package/src/core/runner/preflight-vars.ts +0 -153
  217. package/src/core/runner/progress-tracker.ts +0 -73
  218. package/src/core/runner/rate-limiter.ts +0 -185
  219. package/src/core/runner/run-kind.ts +0 -45
  220. package/src/core/runner/schema-validator.ts +0 -308
  221. package/src/core/runner/send-request.ts +0 -232
  222. package/src/core/runner/transforms.ts +0 -65
  223. package/src/core/runner/types.ts +0 -94
  224. package/src/core/secrets/registry.ts +0 -164
  225. package/src/core/secrets/secrets-file.ts +0 -115
  226. package/src/core/selectors/operation-filter.ts +0 -144
  227. package/src/core/setup-api.ts +0 -590
  228. package/src/core/severity/category.ts +0 -94
  229. package/src/core/severity/index.ts +0 -58
  230. package/src/core/spec/infer-schema.ts +0 -102
  231. package/src/core/spec/layers.ts +0 -154
  232. package/src/core/spec/merge-specs.ts +0 -156
  233. package/src/core/spec/schema-from-runs.ts +0 -117
  234. package/src/core/spec/schema-overlay.ts +0 -130
  235. package/src/core/util/ajv.ts +0 -13
  236. package/src/core/util/format-eta.ts +0 -21
  237. package/src/core/util/headers.ts +0 -9
  238. package/src/core/util/url.ts +0 -24
  239. package/src/core/utils.ts +0 -13
  240. package/src/core/workspace/config.ts +0 -129
  241. package/src/core/workspace/fixture-gap-report.ts +0 -84
  242. package/src/core/workspace/fixture-gaps.ts +0 -71
  243. package/src/core/workspace/manifest.ts +0 -283
  244. package/src/core/workspace/output-rotation.ts +0 -62
  245. package/src/core/workspace/root.ts +0 -96
  246. package/src/core/workspace/triage-path.ts +0 -87
  247. package/src/db/lint-runs.ts +0 -47
  248. package/src/db/migrate.ts +0 -128
  249. package/src/db/migrations/0001_run_kind.sql +0 -25
  250. package/src/db/migrations/0002_run_kind_request.sql +0 -59
  251. package/src/db/migrations/sql.d.ts +0 -4
  252. package/src/db/queries/collections.ts +0 -133
  253. package/src/db/queries/coverage.ts +0 -9
  254. package/src/db/queries/dashboard.ts +0 -59
  255. package/src/db/queries/results.ts +0 -216
  256. package/src/db/queries/runs.ts +0 -289
  257. package/src/db/queries/sessions.ts +0 -42
  258. package/src/db/queries/settings.ts +0 -28
  259. package/src/db/queries/types.ts +0 -172
  260. package/src/db/queries.ts +0 -75
  261. package/src/db/schema.ts +0 -298
@@ -1,160 +0,0 @@
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
- };
@@ -1,419 +0,0 @@
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
- }
@@ -1,33 +0,0 @@
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
- };
@@ -1,77 +0,0 @@
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
- };