@kirrosh/zond 0.21.0 → 0.23.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 +758 -3
- package/README.md +78 -15
- package/package.json +17 -10
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +55 -6
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +192 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +180 -8
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -47
- package/src/cli/commands/init/agents-md.ts +61 -0
- package/src/cli/commands/init/bootstrap.ts +108 -0
- package/src/cli/commands/init/index.ts +244 -0
- package/src/cli/commands/init/skills.ts +98 -0
- package/src/cli/commands/init/templates/agents.md +77 -0
- package/src/cli/commands/init/templates/markdown.d.ts +4 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +651 -0
- package/src/cli/commands/init/templates/zond-config.yml +14 -0
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +911 -33
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +74 -0
- package/src/cli/index.ts +36 -607
- package/src/cli/json-envelope.ts +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +218 -0
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +8 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +61 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +51 -0
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +161 -12
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +53 -15
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +490 -33
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +55 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +114 -15
- package/src/core/generator/suite-generator.ts +484 -77
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +132 -5
- package/src/core/parser/types.ts +29 -2
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +108 -13
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -0
- package/src/core/probe/method-probe.ts +164 -0
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +691 -0
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +284 -0
- package/src/core/reporter/console.ts +69 -4
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +15 -2
- package/src/core/reporter/junit.ts +27 -12
- package/src/core/reporter/ndjson.ts +37 -0
- package/src/core/reporter/types.ts +3 -0
- package/src/core/runner/assertions.ts +62 -2
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +391 -52
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +115 -7
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +203 -0
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +419 -17
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +94 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +128 -0
- package/src/db/queries/runs.ts +235 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +72 -802
- package/src/db/schema.ts +179 -48
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init.ts +0 -57
- package/src/cli/commands/serve.ts +0 -81
- package/src/cli/commands/sync.ts +0 -269
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -21
- package/src/core/parser/index.ts +0 -21
- package/src/core/runner/execute-run.ts +0 -132
- package/src/core/runner/index.ts +0 -12
- package/src/core/sync/spec-differ.ts +0 -38
- package/src/web/data/collection-state.ts +0 -362
- package/src/web/routes/api.ts +0 -314
- package/src/web/routes/dashboard.ts +0 -350
- package/src/web/routes/runs.ts +0 -64
- package/src/web/schemas.ts +0 -121
- package/src/web/server.ts +0 -134
- package/src/web/static/htmx.min.cjs +0 -1
- package/src/web/static/style.css +0 -1148
- package/src/web/views/endpoints-tab.ts +0 -174
- package/src/web/views/explorer-tab.ts +0 -402
- package/src/web/views/health-strip.ts +0 -92
- package/src/web/views/layout.ts +0 -48
- package/src/web/views/results.ts +0 -210
- package/src/web/views/runs-tab.ts +0 -126
- package/src/web/views/suites-tab.ts +0 -181
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pagination_invariants` (m-20 ARV-171) — cursor-style page consistency.
|
|
3
|
+
*
|
|
4
|
+
* For each CRUD group whose list endpoint declares a pagination block
|
|
5
|
+
* (or has a recognisable cursor-style query param in the spec):
|
|
6
|
+
*
|
|
7
|
+
* 1. GET ?limit=N → page A.
|
|
8
|
+
* 2. Pick the last item's cursor field (default `id`).
|
|
9
|
+
* 3. GET ?<cursor_param>=<last_id>&limit=N → page B.
|
|
10
|
+
*
|
|
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.
|
|
16
|
+
*
|
|
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`.
|
|
20
|
+
*
|
|
21
|
+
* Anti-FP guards:
|
|
22
|
+
* • Page A empty → skip ("empty collection — no data to paginate").
|
|
23
|
+
* • Cursor field missing on last item → skip with reason naming the
|
|
24
|
+
* field so the operator can fix the yaml.
|
|
25
|
+
* • 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.
|
|
31
|
+
*
|
|
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.
|
|
35
|
+
*/
|
|
36
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
37
|
+
import type { CrudStatefulCheck } from "../stateful.ts";
|
|
38
|
+
import type { PaginationConfig } from "../../generator/resources-builder.ts";
|
|
39
|
+
import { fillPathParams } from "./_crud-helpers.ts";
|
|
40
|
+
|
|
41
|
+
/** Cursor-style query params we recognise on auto-detect (Stripe,
|
|
42
|
+
* GitHub, Resend, Linear). Lower-cased for case-insensitive match. */
|
|
43
|
+
const CURSOR_QUERY_NAMES = new Set([
|
|
44
|
+
"starting_after",
|
|
45
|
+
"after",
|
|
46
|
+
"cursor",
|
|
47
|
+
"page_token",
|
|
48
|
+
"next_cursor",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const DEFAULT_LIMIT_PARAM = "limit";
|
|
52
|
+
const DEFAULT_CURSOR_FIELD = "id";
|
|
53
|
+
const DEFAULT_HAS_MORE_FIELD = "has_more";
|
|
54
|
+
/** Probe page size — small so two requests land fast and (more
|
|
55
|
+
* importantly) so the probe doesn't repeatedly hammer a 1000-item
|
|
56
|
+
* endpoint just to assert "no duplicates on consecutive pages". */
|
|
57
|
+
const DEFAULT_LIMIT = 2;
|
|
58
|
+
const ITEMS_FIELD_FALLBACKS: ReadonlyArray<string> = ["data", "items", "results", "value"];
|
|
59
|
+
|
|
60
|
+
function safeParse(v: unknown): unknown {
|
|
61
|
+
if (typeof v !== "string") return v;
|
|
62
|
+
try { return JSON.parse(v); } catch { return v; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function detectCursorParam(list: { parameters: OpenAPIV3.ParameterObject[] }): string | null {
|
|
66
|
+
for (const p of list.parameters) {
|
|
67
|
+
if (p.in !== "query") continue;
|
|
68
|
+
if (CURSOR_QUERY_NAMES.has(p.name.toLowerCase())) return p.name;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveConfig(
|
|
74
|
+
cfg: PaginationConfig | undefined,
|
|
75
|
+
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` };
|
|
80
|
+
}
|
|
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
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Find the array-of-items in a list response. Tries the explicit
|
|
95
|
+
* yaml field first, then a small set of common shapes. Returns null
|
|
96
|
+
* when the body shape is not recognisable. */
|
|
97
|
+
function extractItems(body: unknown, itemsField: string | null): unknown[] | null {
|
|
98
|
+
if (Array.isArray(body)) return body;
|
|
99
|
+
if (!body || typeof body !== "object") return null;
|
|
100
|
+
const obj = body as Record<string, unknown>;
|
|
101
|
+
if (itemsField) {
|
|
102
|
+
const v = obj[itemsField];
|
|
103
|
+
return Array.isArray(v) ? v : null;
|
|
104
|
+
}
|
|
105
|
+
for (const f of ITEMS_FIELD_FALLBACKS) {
|
|
106
|
+
const v = obj[f];
|
|
107
|
+
if (Array.isArray(v)) return v;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readHasMore(body: unknown, field: string): boolean | undefined {
|
|
113
|
+
if (!body || typeof body !== "object") return undefined;
|
|
114
|
+
const v = (body as Record<string, unknown>)[field];
|
|
115
|
+
return typeof v === "boolean" ? v : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function pickCursor(item: unknown, field: string): string | number | null {
|
|
119
|
+
if (item == null || typeof item !== "object") return null;
|
|
120
|
+
const v = (item as Record<string, unknown>)[field];
|
|
121
|
+
if (typeof v === "string" || typeof v === "number") return v;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
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()}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const paginationInvariants: CrudStatefulCheck = {
|
|
132
|
+
id: "pagination_invariants",
|
|
133
|
+
severity: "high",
|
|
134
|
+
defaultExpected: "Consecutive cursor pages must be disjoint and has_more must agree with item presence",
|
|
135
|
+
references: [{ id: "ARV-171" }],
|
|
136
|
+
phase: "crud",
|
|
137
|
+
applies(g) {
|
|
138
|
+
return Boolean(g.list);
|
|
139
|
+
},
|
|
140
|
+
async run(g, h) {
|
|
141
|
+
if (h.bootstrapCleanupFailed) {
|
|
142
|
+
return { kind: "skip", reason: "bootstrap-cleanup failed — stateful checks paused" };
|
|
143
|
+
}
|
|
144
|
+
const list = g.list!;
|
|
145
|
+
|
|
146
|
+
const cfg = h.resourceConfigs?.get(g.resource)?.pagination;
|
|
147
|
+
const resolved = resolveConfig(cfg, list);
|
|
148
|
+
if (resolved == null) {
|
|
149
|
+
return { kind: "skip", reason: "no pagination config and no cursor-style query param in spec" };
|
|
150
|
+
}
|
|
151
|
+
if ("reason" in resolved) {
|
|
152
|
+
return { kind: "skip", reason: resolved.reason };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const baseHeaders = { Accept: "application/json", ...h.authHeaders };
|
|
156
|
+
const urlA = buildUrl(h.baseUrl, list.path, h.pathVars, { [resolved.limitParam]: resolved.limit });
|
|
157
|
+
|
|
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
|
+
}
|
|
170
|
+
|
|
171
|
+
const hasMoreA = readHasMore(bodyA, resolved.hasMoreField);
|
|
172
|
+
const partialPageWithMore = hasMoreA === true && itemsA.length < resolved.limit;
|
|
173
|
+
|
|
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
|
+
}
|
|
178
|
+
|
|
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);
|
|
193
|
+
|
|
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
|
+
}
|
|
204
|
+
|
|
205
|
+
// has_more must be false on the page that ran out of items.
|
|
206
|
+
const inconsistentHasMore = itemsB.length === 0 && hasMoreA === true && hasMoreB !== false;
|
|
207
|
+
|
|
208
|
+
if (duplicates.length === 0 && !inconsistentHasMore && !partialPageWithMore) {
|
|
209
|
+
return { kind: "pass" };
|
|
210
|
+
}
|
|
211
|
+
|
|
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");
|
|
216
|
+
|
|
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
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
import { applyAntiFp } from "../../anti-fp/index.ts";
|
|
14
|
+
|
|
15
|
+
export const positiveDataAcceptance: Check = {
|
|
16
|
+
id: "positive_data_acceptance",
|
|
17
|
+
severity: "medium",
|
|
18
|
+
defaultExpected: "Server must accept a generated-as-valid body (2xx)",
|
|
19
|
+
references: [{ id: "OWASP-API-08" }],
|
|
20
|
+
applies: (op) => Boolean(op.requestBodySchema),
|
|
21
|
+
run({ case: c, response }) {
|
|
22
|
+
const s = response.status;
|
|
23
|
+
if (s >= 200 && s < 300) return { kind: "pass" };
|
|
24
|
+
// Auth / not-found / conflict / server-error → not a schema-rejection signal.
|
|
25
|
+
if (s === 401 || s === 403 || s === 404 || s === 409) return { kind: "pass" };
|
|
26
|
+
if (s >= 500) return { kind: "skip", reason: "5xx covered by not_a_server_error" };
|
|
27
|
+
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
|
+
return {
|
|
31
|
+
kind: "fail",
|
|
32
|
+
message: `Server rejected a generated-as-valid body with ${s} — generator or spec disagrees with the implementation`,
|
|
33
|
+
evidence: { status: s },
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rate_limit_headers_absent` (ARV-256, m-21 pivot) — flag mutating
|
|
3
|
+
* endpoints (POST/PATCH/PUT/DELETE) whose 2xx responses ship no
|
|
4
|
+
* rate-limit-* headers at all.
|
|
5
|
+
*
|
|
6
|
+
* The full version of this check would burst N requests to detect a
|
|
7
|
+
* 429, but bursting POST creates real resources — too destructive for
|
|
8
|
+
* a default-on check. The lightweight header-inspect version is what
|
|
9
|
+
* Burp/ZAP also do at first pass: detect the absence of standard
|
|
10
|
+
* rate-limit metadata in the response. If the server emits any of
|
|
11
|
+
* `X-RateLimit-*` / `RateLimit-*` / `Retry-After`, this check skips —
|
|
12
|
+
* the server has *some* rate-limit story.
|
|
13
|
+
*
|
|
14
|
+
* Reliability category (ARV-251): missing rate-limit on write
|
|
15
|
+
* endpoints is a production concern (abuse → bill, abuse → DoS), not
|
|
16
|
+
* a security exploit. Severity MEDIUM by default.
|
|
17
|
+
*
|
|
18
|
+
* Anti-FP: skip non-mutating methods. Skip non-2xx responses (a 4xx
|
|
19
|
+
* doesn't tell us anything about rate-limit behaviour on the happy
|
|
20
|
+
* path). Skip endpoints with `security: []` override (public read-only
|
|
21
|
+
* APIs may intentionally omit rate-limit headers).
|
|
22
|
+
*/
|
|
23
|
+
import type { Check } from "../types.ts";
|
|
24
|
+
|
|
25
|
+
const RATE_LIMIT_PATTERNS: RegExp[] = [
|
|
26
|
+
/^x-ratelimit-/i,
|
|
27
|
+
/^x-rate-limit-/i,
|
|
28
|
+
/^ratelimit-/i,
|
|
29
|
+
/^retry-after$/i,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function hasRateLimitHeader(headers: Record<string, string>): boolean {
|
|
33
|
+
for (const name of Object.keys(headers)) {
|
|
34
|
+
for (const re of RATE_LIMIT_PATTERNS) {
|
|
35
|
+
if (re.test(name)) return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
42
|
+
|
|
43
|
+
export const rateLimitHeadersAbsent: Check = {
|
|
44
|
+
id: "rate_limit_headers_absent",
|
|
45
|
+
severity: "medium",
|
|
46
|
+
defaultExpected:
|
|
47
|
+
"Mutating endpoints should advertise rate-limit semantics via X-RateLimit-* / RateLimit-* / Retry-After headers",
|
|
48
|
+
references: [
|
|
49
|
+
{ id: "RFC-9239-rate-limit-headers" },
|
|
50
|
+
{ id: "OWASP-API-04-rate-limit" },
|
|
51
|
+
],
|
|
52
|
+
applies(op) {
|
|
53
|
+
if (!MUTATING.has(op.method.toUpperCase())) return false;
|
|
54
|
+
// Skip explicitly-public endpoints (security: [] override) — those
|
|
55
|
+
// are often abuse-tolerant by design (anonymous feedback forms etc).
|
|
56
|
+
if (op.security.length === 0) return false;
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
run({ response }) {
|
|
60
|
+
if (response.status < 200 || response.status >= 300) {
|
|
61
|
+
return { kind: "skip", reason: `non-2xx response (${response.status}) — rate-limit semantics only meaningful on success` };
|
|
62
|
+
}
|
|
63
|
+
if (hasRateLimitHeader(response.headers)) {
|
|
64
|
+
return { kind: "pass" };
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
kind: "fail",
|
|
68
|
+
message:
|
|
69
|
+
"Mutating endpoint returned 2xx without any rate-limit-* / Retry-After header — no advertised abuse protection on a write path",
|
|
70
|
+
evidence: {
|
|
71
|
+
method: "POST/PUT/PATCH/DELETE",
|
|
72
|
+
response_status: response.status,
|
|
73
|
+
looked_for: ["X-RateLimit-*", "RateLimit-*", "Retry-After"],
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `response_headers_conformance` — every response header declared in
|
|
3
|
+
* the OpenAPI `responses[<status>].headers` map must be present, and
|
|
4
|
+
* (when a schema is given) its value must validate. Schemathesis-style.
|
|
5
|
+
*
|
|
6
|
+
* For the check to mean anything, the spec has to declare some
|
|
7
|
+
* headers; if it declares none, the check skips. Validation is shallow
|
|
8
|
+
* here (string presence + simple type/format match) — full ajv
|
|
9
|
+
* validation lands alongside ARV-5/ARV-6 once we cache validators per
|
|
10
|
+
* (status, header) pair without paying compile cost per response.
|
|
11
|
+
*/
|
|
12
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
13
|
+
import type { Check } from "../types.ts";
|
|
14
|
+
|
|
15
|
+
function getDeclaredHeaders(
|
|
16
|
+
doc: OpenAPIV3.Document,
|
|
17
|
+
path: string,
|
|
18
|
+
method: string,
|
|
19
|
+
status: number,
|
|
20
|
+
): Record<string, OpenAPIV3.HeaderObject> {
|
|
21
|
+
const op = (doc.paths?.[path] as OpenAPIV3.PathItemObject | undefined)
|
|
22
|
+
?.[method.toLowerCase() as OpenAPIV3.HttpMethods] as OpenAPIV3.OperationObject | undefined;
|
|
23
|
+
if (!op?.responses) return {};
|
|
24
|
+
const exact = op.responses[String(status)] as OpenAPIV3.ResponseObject | undefined;
|
|
25
|
+
const wildcard = op.responses[`${Math.floor(status / 100)}XX`] as OpenAPIV3.ResponseObject | undefined;
|
|
26
|
+
const fallback = op.responses.default as OpenAPIV3.ResponseObject | undefined;
|
|
27
|
+
const branch = exact ?? wildcard ?? fallback;
|
|
28
|
+
return (branch?.headers ?? {}) as Record<string, OpenAPIV3.HeaderObject>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function valueOk(value: string | undefined, header: OpenAPIV3.HeaderObject): boolean {
|
|
32
|
+
if (value === undefined) return false;
|
|
33
|
+
const schema = header.schema as OpenAPIV3.SchemaObject | undefined;
|
|
34
|
+
if (!schema) return true;
|
|
35
|
+
if (schema.type === "integer") return /^-?\d+$/.test(value);
|
|
36
|
+
if (schema.type === "number") return /^-?\d+(\.\d+)?$/.test(value);
|
|
37
|
+
if (schema.type === "boolean") return value === "true" || value === "false";
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const responseHeadersConformance: Check = {
|
|
42
|
+
id: "response_headers_conformance",
|
|
43
|
+
severity: "low",
|
|
44
|
+
defaultExpected: "All headers declared on the response must be present and shape-valid",
|
|
45
|
+
references: [{ id: "OAS3-headerObject" }],
|
|
46
|
+
applies: () => true,
|
|
47
|
+
run({ case: c, response, doc }) {
|
|
48
|
+
if (!doc) return { kind: "skip", reason: "spec doc not available" };
|
|
49
|
+
// ARV-183: use originalPath (pre-ARV-40 rename) for spec lookup.
|
|
50
|
+
const specPath = c.operation.originalPath ?? c.operation.path;
|
|
51
|
+
const declared = getDeclaredHeaders(doc, specPath, c.operation.method, response.status);
|
|
52
|
+
const names = Object.keys(declared);
|
|
53
|
+
if (names.length === 0) return { kind: "skip", reason: "no declared response headers" };
|
|
54
|
+
const issues: string[] = [];
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
const header = declared[name]!;
|
|
57
|
+
const required = header.required === true;
|
|
58
|
+
const got = response.headers[name] ?? response.headers[name.toLowerCase()];
|
|
59
|
+
if (got === undefined) {
|
|
60
|
+
if (required) issues.push(`missing required "${name}"`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!valueOk(got, header)) {
|
|
64
|
+
issues.push(`"${name}" value "${got}" doesn't match declared schema`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (issues.length === 0) return { kind: "pass" };
|
|
68
|
+
return {
|
|
69
|
+
kind: "fail",
|
|
70
|
+
message: `Response headers don't conform: ${issues.join("; ")}`,
|
|
71
|
+
evidence: { issues, declared: names },
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `response_schema_conformance` — body must validate against the JSON
|
|
3
|
+
* Schema declared on the matched response branch. Reuses the existing
|
|
4
|
+
* `runner/schema-validator.ts` so we don't rebuild AJV plumbing
|
|
5
|
+
* (ARV-2 AC #3).
|
|
6
|
+
*/
|
|
7
|
+
import type { Check } from "../types.ts";
|
|
8
|
+
|
|
9
|
+
export const responseSchemaConformance: Check = {
|
|
10
|
+
id: "response_schema_conformance",
|
|
11
|
+
severity: "high",
|
|
12
|
+
defaultExpected: "Response body must validate against the OpenAPI response schema",
|
|
13
|
+
references: [{ id: "OAS3-schemaObject" }],
|
|
14
|
+
applies: () => true,
|
|
15
|
+
run({ case: c, response, schemaValidator }) {
|
|
16
|
+
if (!schemaValidator) return { kind: "skip", reason: "validator unavailable" };
|
|
17
|
+
const inspect = schemaValidator.inspect(c.operation.method, c.operation.path, response.status);
|
|
18
|
+
if (!inspect.matchedEndpoint) return { kind: "skip", reason: "no spec endpoint matched" };
|
|
19
|
+
if (!inspect.hasJsonSchema) return { kind: "skip", reason: "no JSON Schema on this response branch" };
|
|
20
|
+
const results = schemaValidator.validate(c.operation.method, c.operation.path, response.status, response.body);
|
|
21
|
+
const failed = results.filter((r) => !r.passed);
|
|
22
|
+
if (failed.length === 0) return { kind: "pass" };
|
|
23
|
+
const messages = failed.slice(0, 5).map((r) => `${r.field}: expected ${JSON.stringify(r.expected)}`);
|
|
24
|
+
return {
|
|
25
|
+
kind: "fail",
|
|
26
|
+
message: `Response body fails schema validation (${failed.length} issue${failed.length === 1 ? "" : "s"})`,
|
|
27
|
+
evidence: { issues: messages },
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `status_code_conformance` — schemathesis-equivalent. Fails when the
|
|
3
|
+
* server returns a status code that's not declared in the OpenAPI
|
|
4
|
+
* `responses` for this operation (and no `default` is declared either).
|
|
5
|
+
*
|
|
6
|
+
* Edge: `default` in OpenAPI means "any status not enumerated above" —
|
|
7
|
+
* the presence of `default` makes every status code conforming. ARV-2
|
|
8
|
+
* AC #6 explicitly tests this case.
|
|
9
|
+
*/
|
|
10
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
11
|
+
|
|
12
|
+
import type { Check } from "../types.ts";
|
|
13
|
+
|
|
14
|
+
function declaredStatuses(doc: OpenAPIV3.Document, path: string, method: string):
|
|
15
|
+
{ codes: Set<number>; hasDefault: boolean; hasWildcard: Map<number, boolean> } {
|
|
16
|
+
const codes = new Set<number>();
|
|
17
|
+
const hasWildcard = new Map<number, boolean>(); // 2,3,4,5 -> declared as 2XX etc.
|
|
18
|
+
let hasDefault = false;
|
|
19
|
+
const op = (doc.paths?.[path] as OpenAPIV3.PathItemObject | undefined)
|
|
20
|
+
?.[method.toLowerCase() as OpenAPIV3.HttpMethods] as OpenAPIV3.OperationObject | undefined;
|
|
21
|
+
if (!op?.responses) return { codes, hasDefault, hasWildcard };
|
|
22
|
+
for (const key of Object.keys(op.responses)) {
|
|
23
|
+
if (key === "default") { hasDefault = true; continue; }
|
|
24
|
+
const n = Number.parseInt(key, 10);
|
|
25
|
+
if (Number.isFinite(n)) { codes.add(n); continue; }
|
|
26
|
+
// 2XX / 3XX / 4XX / 5XX wildcard keys are valid OpenAPI 3.0 forms.
|
|
27
|
+
const m = /^([1-5])XX$/i.exec(key);
|
|
28
|
+
if (m) hasWildcard.set(Number.parseInt(m[1]!, 10), true);
|
|
29
|
+
}
|
|
30
|
+
return { codes, hasDefault, hasWildcard };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const statusCodeConformance: Check = {
|
|
34
|
+
id: "status_code_conformance",
|
|
35
|
+
severity: "medium",
|
|
36
|
+
defaultExpected: "Response status must be declared in the OpenAPI `responses` (or `default`)",
|
|
37
|
+
references: [{ id: "OAS3-responsesObject", url: "https://spec.openapis.org/oas/v3.0.3#responses-object" }],
|
|
38
|
+
// ARV-180: status-code conformance is a property of the response, not
|
|
39
|
+
// of the input. The check must fire on every case kind — including
|
|
40
|
+
// negative-data, dropped-header, and unsupported-method probes — so
|
|
41
|
+
// an undocumented 5xx/404/422 on bad input surfaces as a finding
|
|
42
|
+
// (matches schemathesis V4 default: the check has no input-kind filter).
|
|
43
|
+
caseKinds: ["positive", "negative_data", "missing_required_header", "unsupported_method"],
|
|
44
|
+
applies: () => true,
|
|
45
|
+
run({ case: c, response, doc }) {
|
|
46
|
+
if (!doc) return { kind: "skip", reason: "spec doc not available" };
|
|
47
|
+
// ARV-183: ARV-40 path-disambiguation renames {id} → {<resource>_id}
|
|
48
|
+
// in EndpointInfo.path. doc.paths keeps the original — use
|
|
49
|
+
// originalPath for spec lookup when present.
|
|
50
|
+
const specPath = c.operation.originalPath ?? c.operation.path;
|
|
51
|
+
const { codes, hasDefault, hasWildcard } = declaredStatuses(doc, specPath, c.operation.method);
|
|
52
|
+
if (hasDefault) return { kind: "pass" };
|
|
53
|
+
if (codes.has(response.status)) return { kind: "pass" };
|
|
54
|
+
if (hasWildcard.get(Math.floor(response.status / 100))) return { kind: "pass" };
|
|
55
|
+
return {
|
|
56
|
+
kind: "fail",
|
|
57
|
+
message: `Status ${response.status} not declared in OpenAPI responses for ${c.operation.method} ${c.operation.path}`,
|
|
58
|
+
evidence: { actual: response.status, declared: [...codes].sort((a, b) => a - b) },
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `unsupported_method` — the live-running counterpart of the offline
|
|
3
|
+
* `method-probe`. Sends a method that isn't declared on the path; the
|
|
4
|
+
* server must reject with a 405 (or 401/403/404 fallback). Shares the
|
|
5
|
+
* acceptable-status list with the offline probe via `method-shared`
|
|
6
|
+
* (ARV-2 AC #4).
|
|
7
|
+
*/
|
|
8
|
+
import type { Check } from "../types.ts";
|
|
9
|
+
import {
|
|
10
|
+
ACCEPTABLE_UNSUPPORTED_STATUSES,
|
|
11
|
+
isPermissibleOptionsResponse,
|
|
12
|
+
} from "../../probe/method-shared.ts";
|
|
13
|
+
|
|
14
|
+
const ACCEPTABLE = new Set<number>(ACCEPTABLE_UNSUPPORTED_STATUSES);
|
|
15
|
+
|
|
16
|
+
export const unsupportedMethod: Check = {
|
|
17
|
+
id: "unsupported_method",
|
|
18
|
+
severity: "medium",
|
|
19
|
+
defaultExpected: "Server must reject undeclared HTTP methods with 405 (or 401/403/404)",
|
|
20
|
+
references: [{ id: "RFC-9110-15.5.6", url: "https://www.rfc-editor.org/rfc/rfc9110#name-405-method-not-allowed" }],
|
|
21
|
+
caseKinds: ["unsupported_method"],
|
|
22
|
+
applies: () => true,
|
|
23
|
+
run({ case: c, response, options }) {
|
|
24
|
+
const status = response.status;
|
|
25
|
+
const undeclared = String(c.meta?.undeclared_method ?? c.request.method);
|
|
26
|
+
const strict = options?.strict405 === true;
|
|
27
|
+
|
|
28
|
+
// ARV-179: OPTIONS 2xx is legitimate CORS preflight behaviour on
|
|
29
|
+
// almost every framework. Treat as pass even in strict mode — the
|
|
30
|
+
// m-18 parity replication explicitly ignores OPTIONS responses too.
|
|
31
|
+
if (isPermissibleOptionsResponse(undeclared, status)) return { kind: "pass" };
|
|
32
|
+
|
|
33
|
+
if (strict) {
|
|
34
|
+
if (status === 405) return { kind: "pass" };
|
|
35
|
+
return {
|
|
36
|
+
kind: "fail",
|
|
37
|
+
message: `Undeclared method ${undeclared} returned ${status} — strict mode requires 405`,
|
|
38
|
+
evidence: { undeclared_method: undeclared, status, strict_405: true },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (ACCEPTABLE.has(status)) return { kind: "pass" };
|
|
43
|
+
if (status >= 200 && status < 300) {
|
|
44
|
+
return {
|
|
45
|
+
kind: "fail",
|
|
46
|
+
message: `Server accepted undeclared method ${undeclared} on ${c.operation.path} (status ${status})`,
|
|
47
|
+
evidence: { undeclared_method: undeclared, status },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (status >= 500) {
|
|
51
|
+
return {
|
|
52
|
+
kind: "fail",
|
|
53
|
+
message: `Server 5xx'd on undeclared method ${undeclared} for ${c.operation.path} — should be 405`,
|
|
54
|
+
evidence: { undeclared_method: undeclared, status },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
kind: "fail",
|
|
59
|
+
message: `Undeclared method ${undeclared} returned ${status} — expected 405 (or 401/403/404)`,
|
|
60
|
+
evidence: { undeclared_method: undeclared, status },
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `use_after_free` (m-15 ARV-3) — given a CRUD group with create+read+
|
|
3
|
+
* delete, create a resource, delete it, then GET by id. The server
|
|
4
|
+
* must respond 404/410. Any 2xx means the resource is still readable
|
|
5
|
+
* after a successful DELETE (a classic data-leak / soft-delete bug).
|
|
6
|
+
*/
|
|
7
|
+
import type { CrudStatefulCheck } from "../stateful.ts";
|
|
8
|
+
import { extractIdFromCreateResponse, fillPathWithId, fillPathParams, serializeCheckBody, resolveCreateBody } from "./_crud-helpers.ts";
|
|
9
|
+
|
|
10
|
+
export const useAfterFree: CrudStatefulCheck = {
|
|
11
|
+
id: "use_after_free",
|
|
12
|
+
severity: "high",
|
|
13
|
+
defaultExpected: "GET on a deleted resource must return 404 or 410",
|
|
14
|
+
references: [{ id: "CWE-672" }],
|
|
15
|
+
phase: "crud",
|
|
16
|
+
applies(g) {
|
|
17
|
+
return Boolean(g.create && g.read && g.delete);
|
|
18
|
+
},
|
|
19
|
+
async run(g, h) {
|
|
20
|
+
if (h.bootstrapCleanupFailed) {
|
|
21
|
+
return { kind: "skip", reason: "bootstrap-cleanup failed — security checks paused (ARV-3 AC #6)" };
|
|
22
|
+
}
|
|
23
|
+
const create = g.create!;
|
|
24
|
+
const read = g.read!;
|
|
25
|
+
const del = g.delete!;
|
|
26
|
+
const baseHeaders = { Accept: "application/json", ...h.authHeaders };
|
|
27
|
+
|
|
28
|
+
// 1. create — ARV-191: respect form-urlencoded so Stripe-style APIs
|
|
29
|
+
// don't silently broken-baseline-skip every CRUD chain.
|
|
30
|
+
// ARV-187: prefer LLM-authored seed_body when available.
|
|
31
|
+
const seedBody = h.resourceConfigs?.get(g.resource)?.seedBody;
|
|
32
|
+
const generated = resolveCreateBody(create, seedBody) ?? {};
|
|
33
|
+
const { body: createBody, contentType } = serializeCheckBody(
|
|
34
|
+
create,
|
|
35
|
+
generated,
|
|
36
|
+
h.pathVars,
|
|
37
|
+
seedBody?.contentType,
|
|
38
|
+
);
|
|
39
|
+
const createUrl = `${h.baseUrl.replace(/\/+$/, "")}${fillPathParams(create.path, h.pathVars)}`;
|
|
40
|
+
const createResp = await h.send({
|
|
41
|
+
method: "POST",
|
|
42
|
+
url: createUrl,
|
|
43
|
+
headers: { ...baseHeaders, "Content-Type": contentType },
|
|
44
|
+
body: createBody,
|
|
45
|
+
});
|
|
46
|
+
if (createResp.status < 200 || createResp.status >= 300) {
|
|
47
|
+
return { kind: "skip", reason: `create returned ${createResp.status} — broken-baseline guard` };
|
|
48
|
+
}
|
|
49
|
+
const id = extractIdFromCreateResponse(createResp.body_parsed ?? createResp.body, g.idParam);
|
|
50
|
+
if (id == null) return { kind: "skip", reason: "could not extract id from create response" };
|
|
51
|
+
|
|
52
|
+
// 2. delete
|
|
53
|
+
const delResp = await h.send({
|
|
54
|
+
method: "DELETE",
|
|
55
|
+
url: `${h.baseUrl.replace(/\/+$/, "")}${fillPathWithId(fillPathParams(del.path, h.pathVars), g.idParam, id)}`,
|
|
56
|
+
headers: baseHeaders,
|
|
57
|
+
});
|
|
58
|
+
if (delResp.status < 200 || delResp.status >= 300) {
|
|
59
|
+
return { kind: "skip", reason: `delete returned ${delResp.status} — broken-baseline guard` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. read after delete
|
|
63
|
+
const readResp = await h.send({
|
|
64
|
+
method: "GET",
|
|
65
|
+
url: `${h.baseUrl.replace(/\/+$/, "")}${fillPathWithId(fillPathParams(read.path, h.pathVars), g.idParam, id)}`,
|
|
66
|
+
headers: baseHeaders,
|
|
67
|
+
});
|
|
68
|
+
if (readResp.status === 404 || readResp.status === 410) return { kind: "pass" };
|
|
69
|
+
if (readResp.status >= 200 && readResp.status < 300) {
|
|
70
|
+
return {
|
|
71
|
+
kind: "fail",
|
|
72
|
+
message: `GET on resource ${id} after DELETE returned ${readResp.status} — resource still readable`,
|
|
73
|
+
evidence: { resource: g.resource, id, get_status_after_delete: readResp.status },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { kind: "skip", reason: `read after delete returned ${readResp.status} — neither 404/410 nor 2xx` };
|
|
77
|
+
},
|
|
78
|
+
};
|