@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.
- package/CHANGELOG.md +164 -1
- package/README.md +8 -7
- package/package.json +2 -3
- package/src/CLAUDE.md +112 -0
- package/src/cli/commands/add-api.ts +19 -7
- package/src/cli/commands/api/annotate/index.ts +359 -4
- package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
- package/src/cli/commands/api/annotate/overlay.ts +1 -1
- package/src/cli/commands/api/annotate/pagination.ts +10 -6
- package/src/cli/commands/api/annotate/prompts.ts +39 -2
- package/src/cli/commands/audit.ts +360 -54
- package/src/cli/commands/check.ts +15 -2
- package/src/cli/commands/checks.ts +352 -36
- package/src/cli/commands/cleanup.ts +4 -30
- package/src/cli/commands/coverage.ts +275 -57
- package/src/cli/commands/db.ts +311 -8
- package/src/cli/commands/discover.ts +281 -161
- package/src/cli/commands/doctor.ts +57 -3
- package/src/cli/commands/fixtures.ts +1 -1
- package/src/cli/commands/generate.ts +24 -7
- package/src/cli/commands/init/bootstrap.ts +4 -1
- package/src/cli/commands/init/index.ts +2 -2
- package/src/cli/commands/init/skills.ts +43 -0
- package/src/cli/commands/init/templates/agents.md +12 -3
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
- package/src/cli/commands/init/templates/skills/zond.md +274 -64
- package/src/cli/commands/init/templates/zond-config.yml +1 -1
- package/src/cli/commands/prepare-fixtures.ts +14 -52
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +101 -10
- package/src/cli/commands/probe/security.ts +95 -12
- package/src/cli/commands/probe/webhooks.ts +2 -0
- package/src/cli/commands/probe.ts +87 -11
- package/src/cli/commands/refresh-api.ts +59 -1
- package/src/cli/commands/report-bundle.ts +3 -11
- package/src/cli/commands/request.ts +116 -0
- package/src/cli/commands/run.ts +33 -5
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/json-envelope.ts +0 -20
- package/src/cli/json-schemas.ts +51 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +5 -4
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +0 -10
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/cross_call_references.ts +17 -4
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/idempotency_replay.ts +1 -5
- package/src/core/checks/checks/ignored_auth.ts +44 -1
- package/src/core/checks/checks/index.ts +3 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
- package/src/core/checks/checks/negative_data_rejection.ts +119 -16
- package/src/core/checks/checks/not_a_server_error.ts +8 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
- package/src/core/checks/checks/pagination_invariants.ts +298 -117
- package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
- package/src/core/checks/checks/status_code_conformance.ts +78 -7
- package/src/core/checks/mode.ts +3 -0
- package/src/core/checks/recommended-action.ts +5 -1
- package/src/core/checks/runner.ts +614 -27
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/types.ts +117 -1
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +35 -6
- package/src/core/coverage/loader.ts +31 -0
- package/src/core/diagnostics/db-analysis.ts +200 -106
- package/src/core/diagnostics/failure-class.ts +21 -1
- package/src/core/diagnostics/failure-hints.ts +4 -208
- package/src/core/diagnostics/suggested-fixes.ts +2 -3
- package/src/core/generator/chunker.ts +1 -8
- package/src/core/generator/data-factory.ts +199 -61
- package/src/core/generator/fixtures-builder.ts +38 -31
- package/src/core/generator/index.ts +0 -2
- package/src/core/generator/openapi-reader.ts +98 -4
- package/src/core/generator/path-param-disambig.ts +30 -4
- package/src/core/generator/resources-builder.ts +276 -26
- package/src/core/generator/schema-utils.ts +22 -0
- package/src/core/generator/suite-generator.ts +168 -15
- package/src/core/generator/types.ts +6 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/output/README.md +11 -29
- package/src/core/output/index.ts +1 -1
- package/src/core/output/run.ts +0 -35
- package/src/core/output/types.ts +0 -7
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/variables.ts +0 -0
- package/src/core/probe/dry-run-envelope.ts +4 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe.ts +23 -1118
- package/src/core/probe/mass-assignment-template.ts +32 -4
- package/src/core/probe/path-discovery.ts +3 -4
- package/src/core/probe/probe-harness.ts +21 -22
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +8 -2
- package/src/core/probe/security-probe.ts +28 -1449
- package/src/core/probe/shared.ts +26 -0
- package/src/core/probe/webhooks-probe.ts +5 -7
- package/src/core/runner/assertions.ts +1 -1
- package/src/core/runner/executor.ts +3 -18
- package/src/core/runner/form-encode.ts +8 -18
- package/src/core/runner/http-client.ts +38 -1
- package/src/core/runner/preflight-vars.ts +19 -15
- package/src/core/runner/rate-limiter.ts +11 -29
- package/src/core/runner/run-kind.ts +7 -1
- package/src/core/runner/schema-validator.ts +2 -6
- package/src/core/runner/send-request.ts +11 -6
- package/src/core/runner/types.ts +6 -0
- package/src/core/setup-api.ts +53 -15
- package/src/core/severity/index.ts +0 -63
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/db/migrate.ts +2 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/queries/collections.ts +2 -2
- package/src/db/queries/results.ts +88 -0
- package/src/db/queries/runs.ts +56 -2
- package/src/db/queries.ts +3 -0
- package/src/db/schema.ts +7 -7
- package/src/cli/commands/bootstrap.ts +0 -710
- package/src/core/anti-fp/bootstrap.ts +0 -34
- package/src/core/anti-fp/index.ts +0 -33
- package/src/core/anti-fp/registry.ts +0 -44
- package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
- package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
- package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
- package/src/core/anti-fp/types.ts +0 -68
- package/src/core/generator/create-body.ts +0 -89
|
@@ -1,42 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `pagination_invariants` (m-20 ARV-171
|
|
2
|
+
* `pagination_invariants` (m-20 ARV-171, ARV-220 added page-style in m-21)
|
|
3
|
+
* — list-endpoint pagination consistency.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* (
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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;
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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
|
-
):
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
240
|
+
if (resolved.type === "cursor") return runCursorStyle(g, h, list, resolved);
|
|
241
|
+
return runPageStyle(g, h, list, resolved);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
157
244
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
const inconsistentHasMore = itemsB.length === 0 && hasMoreA === true && hasMoreB !== false;
|
|
290
|
+
const duplicates = findDuplicates(itemsA, itemsB, resolved.cursorField);
|
|
207
291
|
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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({
|
|
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
|
-
|
|
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 ${
|
|
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
|
};
|
package/src/core/checks/mode.ts
CHANGED
|
@@ -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 {
|