@kirrosh/zond 0.23.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 (158) hide show
  1. package/CHANGELOG.md +164 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +33 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. package/src/core/generator/create-body.ts +0 -89
@@ -1,42 +1,67 @@
1
1
  /**
2
- * `pagination_invariants` (m-20 ARV-171) cursor-style page consistency.
2
+ * `pagination_invariants` (m-20 ARV-171, ARV-220 added page-style in m-21)
3
+ * — list-endpoint pagination consistency.
3
4
  *
4
- * For each CRUD group whose list endpoint declares a pagination block
5
- * (or has a recognisable cursor-style query param in the spec):
5
+ * Two styles are implemented, gated on the `pagination.type` annotation
6
+ * (and on auto-detection from the OpenAPI spec query params):
6
7
  *
8
+ * ── Cursor style (Stripe / Notion / Linear) ─────────────────────────
7
9
  * 1. GET ?limit=N → page A.
8
10
  * 2. Pick the last item's cursor field (default `id`).
9
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.
10
16
  *
11
- * Assertions:
12
- * A∩B disjoint by cursor_field — no item appears on both pages.
13
- * has_more consistency if B is empty, has_more must be false.
14
- * • A.length == limit when page A advertises has_more=true — a partial
15
- * first page with has_more=true is a server bug.
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.
16
27
  *
17
- * Severity policy: HIGH. The dominant signal class is duplicates (data
18
- * loss / off-by-one); has_more inconsistency surfaces in the same
19
- * finding via `evidence.kind`.
28
+ * Severity matrix (ARV-288, follow-up to ARV-284 pattern):
20
29
  *
21
- * Anti-FP guards:
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):
22
49
  * • Page A empty → skip ("empty collection — no data to paginate").
23
- * • Cursor field missing on last item → skip with reason naming the
50
+ * • Cursor field missing on last item (cursor style) → skip naming the
24
51
  * field so the operator can fix the yaml.
25
52
  * • 4xx/5xx on either page → broken-baseline skip.
26
- * • Concurrent writes can race the probe; this MVP doesn't double-
27
- * sweep yet (ARV-171 acceptance #2 calls for it). A second sweep
28
- * wrapper will land alongside the data-quality work in ARV-187 —
29
- * today the finding is gated on cursor-field disjointness which is
30
- * much harder to false-positive than counter checks.
53
+ * • Concurrent writes can race the probe; MVP doesn't double-sweep.
31
54
  *
32
- * Pagination types other than `cursor`: skip with explicit reason so
33
- * `page` / `offset` / `token` callers know the yaml block parsed but
34
- * the check has no logic for them yet.
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.
35
58
  */
36
59
  import type { OpenAPIV3 } from "openapi-types";
37
60
  import type { CrudStatefulCheck } from "../stateful.ts";
61
+ import type { Severity } from "../../severity/index.ts";
38
62
  import type { PaginationConfig } from "../../generator/resources-builder.ts";
39
63
  import { fillPathParams } from "./_crud-helpers.ts";
64
+ import { buildUrl as buildUrlBase } from "../../util/url.ts";
40
65
 
41
66
  /** Cursor-style query params we recognise on auto-detect (Stripe,
42
67
  * GitHub, Resend, Linear). Lower-cased for case-insensitive match. */
@@ -48,7 +73,10 @@ const CURSOR_QUERY_NAMES = new Set([
48
73
  "next_cursor",
49
74
  ]);
50
75
 
51
- const DEFAULT_LIMIT_PARAM = "limit";
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;
52
80
  const DEFAULT_CURSOR_FIELD = "id";
53
81
  const DEFAULT_HAS_MORE_FIELD = "has_more";
54
82
  /** Probe page size — small so two requests land fast and (more
@@ -57,6 +85,10 @@ const DEFAULT_HAS_MORE_FIELD = "has_more";
57
85
  const DEFAULT_LIMIT = 2;
58
86
  const ITEMS_FIELD_FALLBACKS: ReadonlyArray<string> = ["data", "items", "results", "value"];
59
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
+
60
92
  function safeParse(v: unknown): unknown {
61
93
  if (typeof v !== "string") return v;
62
94
  try { return JSON.parse(v); } catch { return v; }
@@ -70,25 +102,77 @@ function detectCursorParam(list: { parameters: OpenAPIV3.ParameterObject[] }): s
70
102
  return null;
71
103
  }
72
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
+
73
135
  function resolveConfig(
74
136
  cfg: PaginationConfig | undefined,
75
137
  list: { parameters: OpenAPIV3.ParameterObject[] },
76
- ): { type: "cursor"; cursorParam: string; cursorField: string; hasMoreField: string; limitParam: string; limit: number; itemsField: string | null } | { type: PaginationConfig["type"]; reason: string } | null {
77
- const type = cfg?.type ?? "cursor";
78
- if (type !== "cursor") {
79
- return { type, reason: `pagination type "${type}" not implemented yet — cursor-style only in this milestone` };
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
80
147
  }
81
- const cursorParam = cfg?.cursorParam ?? detectCursorParam(list);
82
- if (!cursorParam) return null;
83
- return {
84
- type: "cursor",
85
- cursorParam,
86
- cursorField: cfg?.cursorField ?? DEFAULT_CURSOR_FIELD,
87
- hasMoreField: cfg?.hasMoreField ?? DEFAULT_HAS_MORE_FIELD,
88
- limitParam: cfg?.limitParam ?? DEFAULT_LIMIT_PARAM,
89
- limit: cfg?.defaultLimit ?? DEFAULT_LIMIT,
90
- itemsField: cfg?.itemsField ?? null,
91
- };
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` };
92
176
  }
93
177
 
94
178
  /** Find the array-of-items in a list response. Tries the explicit
@@ -123,14 +207,15 @@ function pickCursor(item: unknown, field: string): string | number | null {
123
207
  }
124
208
 
125
209
  function buildUrl(base: string, path: string, pathVars: Record<string, string> | undefined, qs: Record<string, string | number>): string {
126
- const params = new URLSearchParams();
127
- for (const [k, v] of Object.entries(qs)) params.append(k, String(v));
128
- return `${base.replace(/\/+$/, "")}${fillPathParams(path, pathVars)}?${params.toString()}`;
210
+ return buildUrlBase(base, fillPathParams(path, pathVars), qs);
129
211
  }
130
212
 
131
213
  export const paginationInvariants: CrudStatefulCheck = {
132
214
  id: "pagination_invariants",
133
- severity: "high",
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",
134
219
  defaultExpected: "Consecutive cursor pages must be disjoint and has_more must agree with item presence",
135
220
  references: [{ id: "ARV-171" }],
136
221
  phase: "crud",
@@ -146,93 +231,189 @@ export const paginationInvariants: CrudStatefulCheck = {
146
231
  const cfg = h.resourceConfigs?.get(g.resource)?.pagination;
147
232
  const resolved = resolveConfig(cfg, list);
148
233
  if (resolved == null) {
149
- return { kind: "skip", reason: "no pagination config and no cursor-style query param in spec" };
234
+ return { kind: "skip", reason: "no pagination config and no cursor/page query param in spec" };
150
235
  }
151
236
  if ("reason" in resolved) {
152
237
  return { kind: "skip", reason: resolved.reason };
153
238
  }
154
239
 
155
- const baseHeaders = { Accept: "application/json", ...h.authHeaders };
156
- const urlA = buildUrl(h.baseUrl, list.path, h.pathVars, { [resolved.limitParam]: resolved.limit });
240
+ if (resolved.type === "cursor") return runCursorStyle(g, h, list, resolved);
241
+ return runPageStyle(g, h, list, resolved);
242
+ },
243
+ };
157
244
 
158
- const rA = await h.send({ method: "GET", url: urlA, headers: baseHeaders });
159
- if (rA.status < 200 || rA.status >= 300) {
160
- return { kind: "skip", reason: `page A returned ${rA.status} — broken-baseline guard` };
161
- }
162
- const bodyA = rA.body_parsed ?? safeParse(rA.body);
163
- const itemsA = extractItems(bodyA, resolved.itemsField);
164
- if (itemsA == null) {
165
- return { kind: "skip", reason: `page A: items array not found (tried items_field="${resolved.itemsField ?? "auto"}" + defaults)` };
166
- }
167
- if (itemsA.length === 0) {
168
- return { kind: "skip", reason: "page A empty — no data to paginate" };
169
- }
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 });
170
253
 
171
- const hasMoreA = readHasMore(bodyA, resolved.hasMoreField);
172
- const partialPageWithMore = hasMoreA === true && itemsA.length < resolved.limit;
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
+ }
173
266
 
174
- const lastCursor = pickCursor(itemsA[itemsA.length - 1], resolved.cursorField);
175
- if (lastCursor == null) {
176
- return { kind: "skip", reason: `cursor field "${resolved.cursorField}" missing on last item of page A` };
177
- }
267
+ const hasMoreA = readHasMore(bodyA, resolved.hasMoreField);
268
+ const partialPageWithMore = hasMoreA === true && itemsA.length < resolved.limit;
178
269
 
179
- const urlB = buildUrl(h.baseUrl, list.path, h.pathVars, {
180
- [resolved.limitParam]: resolved.limit,
181
- [resolved.cursorParam]: lastCursor,
182
- });
183
- const rB = await h.send({ method: "GET", url: urlB, headers: baseHeaders });
184
- if (rB.status < 200 || rB.status >= 300) {
185
- return { kind: "skip", reason: `page B returned ${rB.status} — broken-baseline guard` };
186
- }
187
- const bodyB = rB.body_parsed ?? safeParse(rB.body);
188
- const itemsB = extractItems(bodyB, resolved.itemsField);
189
- if (itemsB == null) {
190
- return { kind: "skip", reason: "page B: items array shape changed between pages" };
191
- }
192
- const hasMoreB = readHasMore(bodyB, resolved.hasMoreField);
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
+ }
193
274
 
194
- const idsA = new Set<string>();
195
- for (const it of itemsA) {
196
- const c = pickCursor(it, resolved.cursorField);
197
- if (c != null) idsA.add(String(c));
198
- }
199
- const duplicates: string[] = [];
200
- for (const it of itemsB) {
201
- const c = pickCursor(it, resolved.cursorField);
202
- if (c != null && idsA.has(String(c))) duplicates.push(String(c));
203
- }
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);
204
289
 
205
- // has_more must be false on the page that ran out of items.
206
- const inconsistentHasMore = itemsB.length === 0 && hasMoreA === true && hasMoreB !== false;
290
+ const duplicates = findDuplicates(itemsA, itemsB, resolved.cursorField);
207
291
 
208
- if (duplicates.length === 0 && !inconsistentHasMore && !partialPageWithMore) {
209
- return { kind: "pass" };
210
- }
292
+ // has_more must be false on the page that ran out of items.
293
+ const inconsistentHasMore = itemsB.length === 0 && hasMoreA === true && hasMoreB !== false;
211
294
 
212
- const kinds: string[] = [];
213
- if (duplicates.length > 0) kinds.push("duplicate_items");
214
- if (inconsistentHasMore) kinds.push("has_more_inconsistent");
215
- if (partialPageWithMore) kinds.push("partial_page_with_has_more");
295
+ if (duplicates.length === 0 && !inconsistentHasMore && !partialPageWithMore) {
296
+ return { kind: "pass" };
297
+ }
216
298
 
217
- return {
218
- kind: "fail",
219
- message:
220
- duplicates.length > 0
221
- ? `Pagination on ${g.resource}: ${duplicates.length} item id(s) appear on both pages (${duplicates.slice(0, 3).join(", ")}${duplicates.length > 3 ? ", …" : ""})`
222
- : inconsistentHasMore
223
- ? `Pagination on ${g.resource}: page A advertised has_more=true but page B is empty with has_more!=false`
224
- : `Pagination on ${g.resource}: page A has ${itemsA.length}/${resolved.limit} items yet has_more=true (partial page with more)`,
225
- evidence: {
226
- resource: g.resource,
227
- kind: kinds.join("+"),
228
- cursor_param: resolved.cursorParam,
229
- cursor_field: resolved.cursorField,
230
- page_a_size: itemsA.length,
231
- page_b_size: itemsB.length,
232
- has_more_a: hasMoreA,
233
- has_more_b: hasMoreB,
234
- duplicates,
235
- },
236
- };
237
- },
238
- };
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
+ }
@@ -10,7 +10,6 @@
10
10
  * `not_a_server_error` already covers it.
11
11
  */
12
12
  import type { Check } from "../types.ts";
13
- import { applyAntiFp } from "../../anti-fp/index.ts";
14
13
 
15
14
  export const positiveDataAcceptance: Check = {
16
15
  id: "positive_data_acceptance",
@@ -18,15 +17,13 @@ export const positiveDataAcceptance: Check = {
18
17
  defaultExpected: "Server must accept a generated-as-valid body (2xx)",
19
18
  references: [{ id: "OWASP-API-08" }],
20
19
  applies: (op) => Boolean(op.requestBodySchema),
21
- run({ case: c, response }) {
20
+ run({ response }) {
22
21
  const s = response.status;
23
22
  if (s >= 200 && s < 300) return { kind: "pass" };
24
23
  // Auth / not-found / conflict / server-error → not a schema-rejection signal.
25
24
  if (s === 401 || s === 403 || s === 404 || s === 409) return { kind: "pass" };
26
25
  if (s >= 500) return { kind: "skip", reason: "5xx covered by not_a_server_error" };
27
26
  if (s !== 400 && s !== 422) return { kind: "skip", reason: `status ${s} not a schema-validation reject` };
28
- const skip = applyAntiFp(c, "check:positive_data_acceptance");
29
- if (skip) return { kind: "skip", reason: `${skip.ruleId}: ${skip.reason}` };
30
27
  return {
31
28
  kind: "fail",
32
29
  message: `Server rejected a generated-as-valid body with ${s} — generator or spec disagrees with the implementation`,
@@ -6,10 +6,40 @@
6
6
  * Edge: `default` in OpenAPI means "any status not enumerated above" —
7
7
  * the presence of `default` makes every status code conforming. ARV-2
8
8
  * AC #6 explicitly tests this case.
9
+ *
10
+ * Severity matrix (ARV-285, dispatched per finding via outcome.severity
11
+ * — see `severityFor` below):
12
+ *
13
+ * - HIGH: 5xx undeclared (no 5XX wildcard, no default). A server-error
14
+ * on an undocumented branch is concrete evidence of an unhandled
15
+ * code path — likely a real bug.
16
+ * - MEDIUM: 4xx undeclared but at least one other 4xx is declared.
17
+ * Partial contract — the API documents *some* client errors but
18
+ * missed this one. Concrete gap, actionable for the team.
19
+ * - LOW: 4xx undeclared + no declared 4xx at all (minimal spec). The
20
+ * spec only lists 2xx; the 4xx is a real response but the spec
21
+ * gap could simply be spec-under-documentation, not a real bug.
22
+ * - LOW: 2xx/3xx undeclared on negative_data / missing_required_header
23
+ * / unsupported_method probes. The negative probe already surfaces
24
+ * the acceptance as a finding in its own check — the status gap
25
+ * is a secondary signal here (spec hygiene, not a fresh breach).
26
+ * - MEDIUM: 2xx/3xx undeclared on positive probes. A conforming happy-path
27
+ * response with an undocumented status is a concrete spec gap.
28
+ *
29
+ * Note: `wildcard.get(5)` / `wildcard.get(4)` already gate at the top —
30
+ * those return pass before we reach this dispatch. The 5xx-undeclared case
31
+ * only fires when there is no 5XX wildcard and no default.
32
+ *
33
+ * Per ARV-250's proof-cap principle (no evidence → no high severity):
34
+ * the declared `severity: "low"` is the natural fallback / proof-cap
35
+ * baseline; evidence strength escalates individual findings via
36
+ * `outcome.severity` to override it. The agent re-severitizes from the
37
+ * raw evidence.
9
38
  */
10
39
  import type { OpenAPIV3 } from "openapi-types";
11
40
 
12
- import type { Check } from "../types.ts";
41
+ import type { Check, CheckOutcome, CaseKind } from "../types.ts";
42
+ import type { Severity } from "../../severity/index.ts";
13
43
 
14
44
  function declaredStatuses(doc: OpenAPIV3.Document, path: string, method: string):
15
45
  { codes: Set<number>; hasDefault: boolean; hasWildcard: Map<number, boolean> } {
@@ -21,18 +51,51 @@ function declaredStatuses(doc: OpenAPIV3.Document, path: string, method: string)
21
51
  if (!op?.responses) return { codes, hasDefault, hasWildcard };
22
52
  for (const key of Object.keys(op.responses)) {
23
53
  if (key === "default") { hasDefault = true; continue; }
24
- const n = Number.parseInt(key, 10);
25
- if (Number.isFinite(n)) { codes.add(n); continue; }
26
54
  // 2XX / 3XX / 4XX / 5XX wildcard keys are valid OpenAPI 3.0 forms.
55
+ // Must check before parseInt — parseInt("4XX") === 4 which is finite
56
+ // and would otherwise be treated as a literal status code 4.
27
57
  const m = /^([1-5])XX$/i.exec(key);
28
- if (m) hasWildcard.set(Number.parseInt(m[1]!, 10), true);
58
+ if (m) { hasWildcard.set(Number.parseInt(m[1]!, 10), true); continue; }
59
+ const n = Number.parseInt(key, 10);
60
+ if (Number.isFinite(n)) codes.add(n);
29
61
  }
30
62
  return { codes, hasDefault, hasWildcard };
31
63
  }
32
64
 
65
+ /** Negative case kinds — the probe itself already surfaces acceptance; the
66
+ * status-code gap here is a secondary spec-hygiene signal (LOW). */
67
+ const NEGATIVE_KINDS: ReadonlySet<CaseKind> = new Set([
68
+ "negative_data",
69
+ "missing_required_header",
70
+ "unsupported_method",
71
+ ]);
72
+
73
+ /**
74
+ * Per-finding severity dispatch (ARV-285).
75
+ *
76
+ * @param status - actual response status
77
+ * @param codes - set of explicitly declared numeric status codes in spec
78
+ * @param kind - CheckCase.kind for the current probe
79
+ */
80
+ function severityFor(status: number, codes: Set<number>, kind: CaseKind): Severity {
81
+ if (status >= 500 && status < 600) return "high";
82
+ if (status >= 400 && status < 500) {
83
+ const hasDeclared4xx = [...codes].some((c) => c >= 400 && c < 500);
84
+ return hasDeclared4xx ? "medium" : "low";
85
+ }
86
+ // 2xx / 3xx
87
+ if (NEGATIVE_KINDS.has(kind)) return "low";
88
+ return "medium"; // positive case: undocumented success status is a real spec gap
89
+ }
90
+
33
91
  export const statusCodeConformance: Check = {
34
92
  id: "status_code_conformance",
35
- severity: "medium",
93
+ // ARV-285: declared severity is the *natural* tier (proof-cap baseline
94
+ // per ARV-250 — single-signal caps at LOW). Per-finding severity is
95
+ // dispatched via `outcome.severity` in `run()` below, so summary
96
+ // tables can show HIGH for 5xx and MEDIUM for partial-4xx-contract
97
+ // without globally setting the check to HIGH (which masks calibration).
98
+ severity: "low",
36
99
  defaultExpected: "Response status must be declared in the OpenAPI `responses` (or `default`)",
37
100
  references: [{ id: "OAS3-responsesObject", url: "https://spec.openapis.org/oas/v3.0.3#responses-object" }],
38
101
  // ARV-180: status-code conformance is a property of the response, not
@@ -42,7 +105,7 @@ export const statusCodeConformance: Check = {
42
105
  // (matches schemathesis V4 default: the check has no input-kind filter).
43
106
  caseKinds: ["positive", "negative_data", "missing_required_header", "unsupported_method"],
44
107
  applies: () => true,
45
- run({ case: c, response, doc }) {
108
+ run({ case: c, response, doc }): CheckOutcome {
46
109
  if (!doc) return { kind: "skip", reason: "spec doc not available" };
47
110
  // ARV-183: ARV-40 path-disambiguation renames {id} → {<resource>_id}
48
111
  // in EndpointInfo.path. doc.paths keeps the original — use
@@ -52,10 +115,18 @@ export const statusCodeConformance: Check = {
52
115
  if (hasDefault) return { kind: "pass" };
53
116
  if (codes.has(response.status)) return { kind: "pass" };
54
117
  if (hasWildcard.get(Math.floor(response.status / 100))) return { kind: "pass" };
118
+ // ARV-224: use the actual request method (c.request.method) instead
119
+ // of the operation's declared method. For unsupported_method probes
120
+ // the request fires POST/PUT/PATCH against a GET-only endpoint and the
121
+ // legacy `c.operation.method` would print "for GET" while the
122
+ // request_signature (downstream SARIF / triage) shows POST — sending
123
+ // operators in the wrong direction.
124
+ const reqMethod = c.request.method.toUpperCase();
55
125
  return {
56
126
  kind: "fail",
57
- message: `Status ${response.status} not declared in OpenAPI responses for ${c.operation.method} ${c.operation.path}`,
127
+ message: `Status ${response.status} not declared in OpenAPI responses for ${reqMethod} ${c.operation.path}`,
58
128
  evidence: { actual: response.status, declared: [...codes].sort((a, b) => a - b) },
129
+ severity: severityFor(response.status, codes, c.kind),
59
130
  };
60
131
  },
61
132
  };
@@ -52,6 +52,9 @@ export const MODE_BY_CHECK: Record<string, Mode> = {
52
52
  // ARV-256 (m-21): rate-limit headers check inspects 2xx responses
53
53
  // for advertised rate-limit metadata — runs on the positive path.
54
54
  rate_limit_headers_absent: "positive",
55
+ // ARV-273 (m-22): cursor-fuzzing sends malformed cursor values and
56
+ // expects 4xx — classic negative-mode probe.
57
+ cursor_boundary_fuzzing: "negative",
55
58
  };
56
59
 
57
60
  export function modeFor(checkId: string): Mode {