@kirrosh/zond 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +22 -21
- package/bin/zond.mjs +23 -0
- package/package.json +13 -16
- package/scripts/npm/postinstall.mjs +76 -0
- package/src/CLAUDE.md +0 -112
- package/src/bun-types.d.ts +0 -5
- package/src/cli/argv.ts +0 -122
- package/src/cli/commands/add-api.ts +0 -146
- package/src/cli/commands/api/annotate/idempotency.ts +0 -59
- package/src/cli/commands/api/annotate/index.ts +0 -880
- package/src/cli/commands/api/annotate/lifecycle.ts +0 -74
- package/src/cli/commands/api/annotate/overlay.ts +0 -206
- package/src/cli/commands/api/annotate/pagination.ts +0 -64
- package/src/cli/commands/api/annotate/prompts.ts +0 -220
- package/src/cli/commands/api/annotate/readback.ts +0 -58
- package/src/cli/commands/api/annotate/resources.ts +0 -91
- package/src/cli/commands/api/annotate/seed-bodies.ts +0 -61
- package/src/cli/commands/audit.ts +0 -786
- package/src/cli/commands/catalog.ts +0 -97
- package/src/cli/commands/check.ts +0 -361
- package/src/cli/commands/checks.ts +0 -1072
- package/src/cli/commands/ci-init.ts +0 -223
- package/src/cli/commands/clean.ts +0 -212
- package/src/cli/commands/cleanup.ts +0 -236
- package/src/cli/commands/completions.ts +0 -192
- package/src/cli/commands/coverage.ts +0 -872
- package/src/cli/commands/db.ts +0 -611
- package/src/cli/commands/describe.ts +0 -120
- package/src/cli/commands/discover.ts +0 -1356
- package/src/cli/commands/doctor.ts +0 -661
- package/src/cli/commands/fixtures.ts +0 -402
- package/src/cli/commands/generate.ts +0 -577
- package/src/cli/commands/init/agents-md.ts +0 -61
- package/src/cli/commands/init/bootstrap.ts +0 -111
- package/src/cli/commands/init/index.ts +0 -244
- package/src/cli/commands/init/skills.ts +0 -141
- package/src/cli/commands/init/templates/agents.md +0 -86
- package/src/cli/commands/init/templates/markdown.d.ts +0 -4
- package/src/cli/commands/init/templates/skills/warm-up-target.md +0 -122
- package/src/cli/commands/init/templates/skills/zond-checks.md +0 -621
- package/src/cli/commands/init/templates/skills/zond-seed.md +0 -114
- package/src/cli/commands/init/templates/skills/zond-triage.md +0 -272
- package/src/cli/commands/init/templates/skills/zond.md +0 -861
- package/src/cli/commands/init/templates/zond-config.yml +0 -14
- package/src/cli/commands/prepare-fixtures.ts +0 -97
- package/src/cli/commands/probe/_seed-bodies.ts +0 -52
- package/src/cli/commands/probe/mass-assignment.ts +0 -594
- package/src/cli/commands/probe/security.ts +0 -537
- package/src/cli/commands/probe/static.ts +0 -255
- package/src/cli/commands/probe/webhooks.ts +0 -163
- package/src/cli/commands/probe.ts +0 -535
- package/src/cli/commands/reference.ts +0 -87
- package/src/cli/commands/refresh-api.ts +0 -227
- package/src/cli/commands/remove-api.ts +0 -150
- package/src/cli/commands/report-bundle.ts +0 -310
- package/src/cli/commands/report.ts +0 -241
- package/src/cli/commands/request.ts +0 -548
- package/src/cli/commands/run.ts +0 -1142
- package/src/cli/commands/schema-from-runs.ts +0 -128
- package/src/cli/commands/secrets.ts +0 -133
- package/src/cli/commands/session.ts +0 -244
- package/src/cli/commands/use.ts +0 -74
- package/src/cli/index.ts +0 -55
- package/src/cli/json-envelope.ts +0 -108
- package/src/cli/json-schemas.ts +0 -314
- package/src/cli/output.ts +0 -40
- package/src/cli/program.ts +0 -219
- package/src/cli/resolve.ts +0 -105
- package/src/cli/runtime.ts +0 -7
- package/src/cli/safe-live.ts +0 -24
- package/src/cli/status-filter.ts +0 -114
- package/src/cli/util/api-context.ts +0 -85
- package/src/cli/version.ts +0 -8
- package/src/core/audit/persist.ts +0 -183
- package/src/core/checks/budget.ts +0 -59
- package/src/core/checks/checks/_crud-helpers.ts +0 -133
- package/src/core/checks/checks/_negative_mutator.ts +0 -133
- package/src/core/checks/checks/_readback-helpers.ts +0 -133
- package/src/core/checks/checks/content_type_conformance.ts +0 -39
- package/src/core/checks/checks/cross_call_references.ts +0 -147
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +0 -219
- package/src/core/checks/checks/ensure_resource_availability.ts +0 -62
- package/src/core/checks/checks/idempotency_replay.ts +0 -242
- package/src/core/checks/checks/ignored_auth.ts +0 -254
- package/src/core/checks/checks/index.ts +0 -68
- package/src/core/checks/checks/lifecycle_transitions.ts +0 -416
- package/src/core/checks/checks/missing_required_header.ts +0 -40
- package/src/core/checks/checks/negative_data_rejection.ts +0 -148
- package/src/core/checks/checks/not_a_server_error.ts +0 -35
- package/src/core/checks/checks/open_cors_on_sensitive.ts +0 -160
- package/src/core/checks/checks/pagination_invariants.ts +0 -419
- package/src/core/checks/checks/positive_data_acceptance.ts +0 -33
- package/src/core/checks/checks/rate_limit_headers_absent.ts +0 -77
- package/src/core/checks/checks/response_headers_conformance.ts +0 -74
- package/src/core/checks/checks/response_schema_conformance.ts +0 -30
- package/src/core/checks/checks/status_code_conformance.ts +0 -132
- package/src/core/checks/checks/unsupported_method.ts +0 -63
- package/src/core/checks/checks/use_after_free.ts +0 -78
- package/src/core/checks/index.ts +0 -30
- package/src/core/checks/mode.ts +0 -82
- package/src/core/checks/recommended-action.ts +0 -68
- package/src/core/checks/registry.ts +0 -78
- package/src/core/checks/runner.ts +0 -1461
- package/src/core/checks/sarif.ts +0 -230
- package/src/core/checks/spec-findings.ts +0 -308
- package/src/core/checks/stateful.ts +0 -121
- package/src/core/checks/types.ts +0 -305
- package/src/core/checks/zond-extensions.ts +0 -73
- package/src/core/classifier/recommended-action.ts +0 -251
- package/src/core/context/current.ts +0 -51
- package/src/core/context/session.ts +0 -78
- package/src/core/coverage/loader.ts +0 -216
- package/src/core/coverage/reasons.ts +0 -300
- package/src/core/diagnostics/db-analysis.ts +0 -666
- package/src/core/diagnostics/failure-class.ts +0 -140
- package/src/core/diagnostics/failure-hints.ts +0 -112
- package/src/core/diagnostics/spec-pointer.ts +0 -99
- package/src/core/diagnostics/suggested-fixes.ts +0 -155
- package/src/core/exporter/case-study/index.ts +0 -270
- package/src/core/exporter/curl.ts +0 -40
- package/src/core/exporter/exporter.ts +0 -48
- package/src/core/exporter/html-report/escape.ts +0 -24
- package/src/core/exporter/html-report/index.ts +0 -479
- package/src/core/exporter/html-report/script.ts +0 -100
- package/src/core/exporter/html-report/styles.ts +0 -408
- package/src/core/generator/catalog-builder.ts +0 -179
- package/src/core/generator/chunker.ts +0 -78
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/coverage-scanner.ts +0 -87
- package/src/core/generator/data-factory.ts +0 -759
- package/src/core/generator/describe.ts +0 -302
- package/src/core/generator/endpoint-warnings.ts +0 -52
- package/src/core/generator/fixtures-builder.ts +0 -332
- package/src/core/generator/index.ts +0 -14
- package/src/core/generator/openapi-reader.ts +0 -297
- package/src/core/generator/path-param-disambig.ts +0 -140
- package/src/core/generator/resources-builder.ts +0 -898
- package/src/core/generator/schema-utils.ts +0 -112
- package/src/core/generator/serializer.ts +0 -343
- package/src/core/generator/suite-generator.ts +0 -1301
- package/src/core/generator/types.ts +0 -63
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/lint/affects.ts +0 -28
- package/src/core/lint/config.ts +0 -96
- package/src/core/lint/format.ts +0 -42
- package/src/core/lint/index.ts +0 -94
- package/src/core/lint/reporter.ts +0 -128
- package/src/core/lint/rules/consistency.ts +0 -158
- package/src/core/lint/rules/heuristics.ts +0 -97
- package/src/core/lint/rules/strictness.ts +0 -109
- package/src/core/lint/types.ts +0 -96
- package/src/core/lint/walker.ts +0 -248
- package/src/core/meta/meta-store.ts +0 -11
- package/src/core/output/README.md +0 -73
- package/src/core/output/index.ts +0 -13
- package/src/core/output/run.ts +0 -91
- package/src/core/output/types.ts +0 -122
- package/src/core/parser/dynamic-values.ts +0 -160
- package/src/core/parser/env-interpolation.ts +0 -104
- package/src/core/parser/filter.ts +0 -97
- package/src/core/parser/schema.ts +0 -359
- package/src/core/parser/types.ts +0 -117
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +0 -181
- package/src/core/probe/bootstrap.ts +0 -34
- package/src/core/probe/dry-run-envelope.ts +0 -61
- package/src/core/probe/mass-assignment/classify.ts +0 -175
- package/src/core/probe/mass-assignment/cleanup.ts +0 -52
- package/src/core/probe/mass-assignment/digest.ts +0 -114
- package/src/core/probe/mass-assignment/orchestrator.ts +0 -459
- package/src/core/probe/mass-assignment/regression.ts +0 -141
- package/src/core/probe/mass-assignment/suspects.ts +0 -92
- package/src/core/probe/mass-assignment/types.ts +0 -135
- package/src/core/probe/mass-assignment-probe-class.ts +0 -198
- package/src/core/probe/mass-assignment-probe.ts +0 -27
- package/src/core/probe/mass-assignment-template.ts +0 -240
- package/src/core/probe/method-probe.ts +0 -164
- package/src/core/probe/method-shared.ts +0 -69
- package/src/core/probe/negative-probe.ts +0 -691
- package/src/core/probe/orphan-tracker.ts +0 -188
- package/src/core/probe/path-discovery.ts +0 -439
- package/src/core/probe/probe-harness.ts +0 -119
- package/src/core/probe/registry.ts +0 -89
- package/src/core/probe/runner.ts +0 -136
- package/src/core/probe/security/baseline.ts +0 -174
- package/src/core/probe/security/classify.ts +0 -341
- package/src/core/probe/security/cleanup.ts +0 -125
- package/src/core/probe/security/detectors.ts +0 -71
- package/src/core/probe/security/digest.ts +0 -104
- package/src/core/probe/security/orchestrator.ts +0 -398
- package/src/core/probe/security/regression.ts +0 -103
- package/src/core/probe/security/types.ts +0 -151
- package/src/core/probe/security-probe-class.ts +0 -207
- package/src/core/probe/security-probe.ts +0 -32
- package/src/core/probe/shared.ts +0 -531
- package/src/core/probe/static-probe-class.ts +0 -125
- package/src/core/probe/types.ts +0 -165
- package/src/core/probe/verdict-aggregator.ts +0 -33
- package/src/core/probe/webhooks-probe.ts +0 -282
- package/src/core/reporter/console.ts +0 -240
- package/src/core/reporter/index.ts +0 -22
- package/src/core/reporter/json.ts +0 -22
- package/src/core/reporter/junit.ts +0 -93
- package/src/core/reporter/ndjson.ts +0 -37
- package/src/core/reporter/types.ts +0 -15
- package/src/core/runner/assertions.ts +0 -383
- package/src/core/runner/async-pool.ts +0 -108
- package/src/core/runner/auth-path.ts +0 -8
- package/src/core/runner/ci-context.ts +0 -72
- package/src/core/runner/executor.ts +0 -652
- package/src/core/runner/expr-eval.ts +0 -41
- package/src/core/runner/form-encode.ts +0 -41
- package/src/core/runner/http-client.ts +0 -224
- package/src/core/runner/learn-drift.ts +0 -293
- package/src/core/runner/preflight-vars.ts +0 -153
- package/src/core/runner/progress-tracker.ts +0 -73
- package/src/core/runner/rate-limiter.ts +0 -185
- package/src/core/runner/run-kind.ts +0 -45
- package/src/core/runner/schema-validator.ts +0 -308
- package/src/core/runner/send-request.ts +0 -232
- package/src/core/runner/transforms.ts +0 -65
- package/src/core/runner/types.ts +0 -94
- package/src/core/secrets/registry.ts +0 -164
- package/src/core/secrets/secrets-file.ts +0 -115
- package/src/core/selectors/operation-filter.ts +0 -144
- package/src/core/setup-api.ts +0 -590
- package/src/core/severity/category.ts +0 -94
- package/src/core/severity/index.ts +0 -58
- package/src/core/spec/infer-schema.ts +0 -102
- package/src/core/spec/layers.ts +0 -154
- package/src/core/spec/merge-specs.ts +0 -156
- package/src/core/spec/schema-from-runs.ts +0 -117
- package/src/core/spec/schema-overlay.ts +0 -130
- package/src/core/util/ajv.ts +0 -13
- package/src/core/util/format-eta.ts +0 -21
- package/src/core/util/headers.ts +0 -9
- package/src/core/util/url.ts +0 -24
- package/src/core/utils.ts +0 -13
- package/src/core/workspace/config.ts +0 -129
- package/src/core/workspace/fixture-gap-report.ts +0 -84
- package/src/core/workspace/fixture-gaps.ts +0 -71
- package/src/core/workspace/manifest.ts +0 -283
- package/src/core/workspace/output-rotation.ts +0 -62
- package/src/core/workspace/root.ts +0 -96
- package/src/core/workspace/triage-path.ts +0 -87
- package/src/db/lint-runs.ts +0 -47
- package/src/db/migrate.ts +0 -128
- package/src/db/migrations/0001_run_kind.sql +0 -25
- package/src/db/migrations/0002_run_kind_request.sql +0 -59
- package/src/db/migrations/sql.d.ts +0 -4
- package/src/db/queries/collections.ts +0 -133
- package/src/db/queries/coverage.ts +0 -9
- package/src/db/queries/dashboard.ts +0 -59
- package/src/db/queries/results.ts +0 -216
- package/src/db/queries/runs.ts +0 -289
- package/src/db/queries/sessions.ts +0 -42
- package/src/db/queries/settings.ts +0 -28
- package/src/db/queries/types.ts +0 -172
- package/src/db/queries.ts +0 -75
- package/src/db/schema.ts +0 -298
|
@@ -1,416 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `lifecycle_transitions` (m-20 ARV-172, ARV-219 added observation in m-21)
|
|
3
|
-
* — verify a resource's declared state machine on the live API.
|
|
4
|
-
*
|
|
5
|
-
* Two modes, gated on the `actions` field of the yaml manifest:
|
|
6
|
-
*
|
|
7
|
-
* ── Action-driven mode (preferred when CRUD allows mutation) ───────
|
|
8
|
-
* Requires `g.create && g.read`. For each declared `action`:
|
|
9
|
-
* 1. POST create → capture id + initial state.
|
|
10
|
-
* 2. Assert initial state ∈ declared `states[]`.
|
|
11
|
-
* 3. For each action in object-key order:
|
|
12
|
-
* a. POST <action.endpoint> with {id} substituted.
|
|
13
|
-
* b. GET resource → read `state.field`.
|
|
14
|
-
* c. Assert observed state == `action.expected_state` and the
|
|
15
|
-
* (previous, observed) hop is in `transitions`.
|
|
16
|
-
* d. POST action a second time (idempotency probe); state must
|
|
17
|
-
* not regress.
|
|
18
|
-
*
|
|
19
|
-
* ── Pure-observation mode (ARV-219, for read-only state machines) ──
|
|
20
|
-
* Triggers when `actions: {}` (or omitted). Requires `g.list`.
|
|
21
|
-
* GET list once, walk items, collect any state values not in
|
|
22
|
-
* `states[]`. Surfaces a single finding listing each undeclared
|
|
23
|
-
* state with sample item ids. Useful for APIs where zond cannot
|
|
24
|
-
* POST (read-only PAT, GitHub Issues without write scope, …) but
|
|
25
|
-
* the spec still declares a status enum — drift between observed
|
|
26
|
-
* and declared states is a contract-doc bug.
|
|
27
|
-
*
|
|
28
|
-
* Severity: HIGH. Failure classes share one finding (consistent with
|
|
29
|
-
* cross_call_references / idempotency_replay / pagination_invariants);
|
|
30
|
-
* evidence.kind discriminates.
|
|
31
|
-
*
|
|
32
|
-
* Anti-FP guards (both modes):
|
|
33
|
-
* • Yaml manifest validated at load (validateLifecycleManifest in
|
|
34
|
-
* resources-builder); a malformed manifest skips with the
|
|
35
|
-
* concrete error so the operator gets actionable feedback.
|
|
36
|
-
* • Non-2xx baseline (create or list) → broken-baseline skip.
|
|
37
|
-
* • Action POST non-2xx on first call → action-not-supported skip
|
|
38
|
-
* (the API may have authoritative server-side gating; not a
|
|
39
|
-
* contract bug).
|
|
40
|
-
* • Pure-observation: empty list response → skip (no data to
|
|
41
|
-
* observe), not a finding.
|
|
42
|
-
*
|
|
43
|
-
* Limitations:
|
|
44
|
-
* • Pure-observation cannot verify `transitions[]` — there is no
|
|
45
|
-
* time series in a single list call. The check only enforces
|
|
46
|
-
* `observed ⊆ declared`; the transition graph is purely
|
|
47
|
-
* documentation in observation mode.
|
|
48
|
-
*/
|
|
49
|
-
import type { OpenAPIV3 } from "openapi-types";
|
|
50
|
-
import type { CrudStatefulCheck } from "../stateful.ts";
|
|
51
|
-
import type { LifecycleConfig, LifecycleAction } from "../../generator/resources-builder.ts";
|
|
52
|
-
import { validateLifecycleManifest } from "../../generator/resources-builder.ts";
|
|
53
|
-
import {
|
|
54
|
-
extractIdFromCreateResponse,
|
|
55
|
-
fillPathWithId,
|
|
56
|
-
fillPathParams,
|
|
57
|
-
serializeCheckBody,
|
|
58
|
-
resolveCreateBody,
|
|
59
|
-
} from "./_crud-helpers.ts";
|
|
60
|
-
|
|
61
|
-
function safeParse(v: unknown): unknown {
|
|
62
|
-
if (typeof v !== "string") return v;
|
|
63
|
-
try { return JSON.parse(v); } catch { return v; }
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function readState(body: unknown, field: string): string | null {
|
|
67
|
-
if (!body || typeof body !== "object") return null;
|
|
68
|
-
const v = (body as Record<string, unknown>)[field];
|
|
69
|
-
return typeof v === "string" ? v : null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function parseEndpointLabel(label: string): { method: string; path: string } | null {
|
|
73
|
-
const parts = label.trim().split(/\s+/);
|
|
74
|
-
if (parts.length < 2) return null;
|
|
75
|
-
return { method: parts[0]!.toUpperCase(), path: parts[1]! };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function transitionAllowed(cfg: LifecycleConfig, from: string, to: string): boolean {
|
|
79
|
-
// Same-state replay is always OK (idempotent action).
|
|
80
|
-
if (from === to) return true;
|
|
81
|
-
for (const t of cfg.transitions) {
|
|
82
|
-
if (t.from === from && t.to.includes(to)) return true;
|
|
83
|
-
}
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
interface Finding {
|
|
88
|
-
kind: string;
|
|
89
|
-
message: string;
|
|
90
|
-
extra?: Record<string, unknown>;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export const lifecycleTransitions: CrudStatefulCheck = {
|
|
94
|
-
id: "lifecycle_transitions",
|
|
95
|
-
severity: "high",
|
|
96
|
-
defaultExpected: "Declared lifecycle actions must move the resource through declared states without regression",
|
|
97
|
-
references: [{ id: "ARV-172" }],
|
|
98
|
-
phase: "crud",
|
|
99
|
-
applies(g) {
|
|
100
|
-
return Boolean((g.create && g.read) || g.list);
|
|
101
|
-
},
|
|
102
|
-
async run(g, h) {
|
|
103
|
-
if (h.bootstrapCleanupFailed) {
|
|
104
|
-
return { kind: "skip", reason: "bootstrap-cleanup failed — stateful checks paused" };
|
|
105
|
-
}
|
|
106
|
-
const cfg = h.resourceConfigs?.get(g.resource)?.lifecycle;
|
|
107
|
-
if (!cfg) return { kind: "skip", reason: "no lifecycle config for this resource" };
|
|
108
|
-
|
|
109
|
-
const manifestErrors = validateLifecycleManifest(cfg);
|
|
110
|
-
if (manifestErrors.length > 0) {
|
|
111
|
-
return { kind: "skip", reason: `lifecycle manifest invalid: ${manifestErrors[0]}` };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ARV-219: actions-empty → pure-observation mode (read-only state
|
|
115
|
-
// machine). Requires a list endpoint to sample observed states.
|
|
116
|
-
if (Object.keys(cfg.actions).length === 0) {
|
|
117
|
-
if (!g.list) {
|
|
118
|
-
return { kind: "skip", reason: "lifecycle has no actions and no list endpoint — nothing to verify or observe" };
|
|
119
|
-
}
|
|
120
|
-
return runPureObservation(g, h, cfg);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Action-driven mode requires both create + read.
|
|
124
|
-
if (!g.create || !g.read) {
|
|
125
|
-
return { kind: "skip", reason: "lifecycle actions declared but resource lacks create+read endpoints" };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const create = g.create!;
|
|
129
|
-
const read = g.read!;
|
|
130
|
-
const baseHeaders = { Accept: "application/json", ...h.authHeaders };
|
|
131
|
-
const stateSet = new Set(cfg.states);
|
|
132
|
-
|
|
133
|
-
// 1. Create — prefer seed_body (ARV-187) over generator.
|
|
134
|
-
const seedBody = h.resourceConfigs?.get(g.resource)?.seedBody;
|
|
135
|
-
const generated = resolveCreateBody(create, seedBody) ?? {};
|
|
136
|
-
const { body: createBody, contentType } = serializeCheckBody(
|
|
137
|
-
create,
|
|
138
|
-
generated,
|
|
139
|
-
h.pathVars,
|
|
140
|
-
seedBody?.contentType,
|
|
141
|
-
);
|
|
142
|
-
const createUrl = `${h.baseUrl.replace(/\/+$/, "")}${fillPathParams(create.path, h.pathVars)}`;
|
|
143
|
-
const createResp = await h.send({
|
|
144
|
-
method: "POST",
|
|
145
|
-
url: createUrl,
|
|
146
|
-
headers: { ...baseHeaders, "Content-Type": contentType },
|
|
147
|
-
body: createBody,
|
|
148
|
-
});
|
|
149
|
-
if (createResp.status < 200 || createResp.status >= 300) {
|
|
150
|
-
return { kind: "skip", reason: `create returned ${createResp.status} — broken-baseline guard` };
|
|
151
|
-
}
|
|
152
|
-
const createBodyParsed = createResp.body_parsed ?? safeParse(createResp.body);
|
|
153
|
-
const id = extractIdFromCreateResponse(createBodyParsed, g.idParam);
|
|
154
|
-
if (id == null) return { kind: "skip", reason: "could not extract id from create response" };
|
|
155
|
-
|
|
156
|
-
const findings: Finding[] = [];
|
|
157
|
-
|
|
158
|
-
let currentState = readState(createBodyParsed, cfg.field);
|
|
159
|
-
if (currentState == null) {
|
|
160
|
-
return { kind: "skip", reason: `state field "${cfg.field}" missing on create response — yaml mismatch or hidden field` };
|
|
161
|
-
}
|
|
162
|
-
if (!stateSet.has(currentState)) {
|
|
163
|
-
findings.push({
|
|
164
|
-
kind: "undeclared_state",
|
|
165
|
-
message: `initial state "${currentState}" not in declared states [${cfg.states.join(", ")}]`,
|
|
166
|
-
extra: { observed: currentState, declared: cfg.states },
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const readUrlFor = (resId: string | number): string =>
|
|
171
|
-
`${h.baseUrl.replace(/\/+$/, "")}${fillPathWithId(fillPathParams(read.path, h.pathVars), g.idParam, resId)}`;
|
|
172
|
-
|
|
173
|
-
// 2. For each action: invoke, read, assert.
|
|
174
|
-
for (const [name, action] of Object.entries(cfg.actions) as Array<[string, LifecycleAction]>) {
|
|
175
|
-
const parsed = parseEndpointLabel(action.endpoint);
|
|
176
|
-
if (!parsed) {
|
|
177
|
-
findings.push({
|
|
178
|
-
kind: "action_endpoint_malformed",
|
|
179
|
-
message: `action "${name}".endpoint "${action.endpoint}" must be "METHOD /path"`,
|
|
180
|
-
});
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
const actionUrl = `${h.baseUrl.replace(/\/+$/, "")}${fillPathWithId(fillPathParams(parsed.path, h.pathVars), g.idParam, id)}`;
|
|
184
|
-
const actionBody = action.body
|
|
185
|
-
? serializeCheckBody(create, action.body, h.pathVars)
|
|
186
|
-
: { body: "", contentType: "application/json" };
|
|
187
|
-
const actionHeaders: Record<string, string> = { ...baseHeaders };
|
|
188
|
-
if (action.body) actionHeaders["Content-Type"] = actionBody.contentType;
|
|
189
|
-
|
|
190
|
-
const firstResp = await h.send({
|
|
191
|
-
method: parsed.method,
|
|
192
|
-
url: actionUrl,
|
|
193
|
-
headers: actionHeaders,
|
|
194
|
-
body: action.body ? actionBody.body : undefined,
|
|
195
|
-
});
|
|
196
|
-
if (firstResp.status < 200 || firstResp.status >= 300) {
|
|
197
|
-
// Server-side gating; not a contract violation.
|
|
198
|
-
findings.push({
|
|
199
|
-
kind: "action_rejected",
|
|
200
|
-
message: `action "${name}" returned ${firstResp.status} on first call — server may gate this transition`,
|
|
201
|
-
extra: { action: name, status: firstResp.status },
|
|
202
|
-
});
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Read state after action.
|
|
207
|
-
const readResp = await h.send({ method: "GET", url: readUrlFor(id), headers: baseHeaders });
|
|
208
|
-
if (readResp.status < 200 || readResp.status >= 300) {
|
|
209
|
-
findings.push({
|
|
210
|
-
kind: "read_after_action_failed",
|
|
211
|
-
message: `GET after action "${name}" returned ${readResp.status}`,
|
|
212
|
-
extra: { action: name, read_status: readResp.status },
|
|
213
|
-
});
|
|
214
|
-
break;
|
|
215
|
-
}
|
|
216
|
-
const observedState = readState(readResp.body_parsed ?? safeParse(readResp.body), cfg.field);
|
|
217
|
-
if (observedState == null) {
|
|
218
|
-
findings.push({
|
|
219
|
-
kind: "state_field_missing",
|
|
220
|
-
message: `GET after action "${name}": state field "${cfg.field}" missing`,
|
|
221
|
-
extra: { action: name },
|
|
222
|
-
});
|
|
223
|
-
break;
|
|
224
|
-
}
|
|
225
|
-
if (!stateSet.has(observedState)) {
|
|
226
|
-
findings.push({
|
|
227
|
-
kind: "undeclared_state",
|
|
228
|
-
message: `action "${name}" produced state "${observedState}" not in declared states`,
|
|
229
|
-
extra: { action: name, observed: observedState },
|
|
230
|
-
});
|
|
231
|
-
} else {
|
|
232
|
-
if (!transitionAllowed(cfg, currentState, observedState)) {
|
|
233
|
-
findings.push({
|
|
234
|
-
kind: "forbidden_transition",
|
|
235
|
-
message: `action "${name}": ${currentState} → ${observedState} is not allowed by declared transitions`,
|
|
236
|
-
extra: { action: name, from: currentState, to: observedState },
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
if (observedState !== action.expectedState) {
|
|
240
|
-
findings.push({
|
|
241
|
-
kind: "wrong_expected_state",
|
|
242
|
-
message: `action "${name}": expected state "${action.expectedState}", observed "${observedState}"`,
|
|
243
|
-
extra: { action: name, expected: action.expectedState, observed: observedState },
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// 3. Idempotency probe: invoke action again, state must not regress.
|
|
249
|
-
const secondResp = await h.send({
|
|
250
|
-
method: parsed.method,
|
|
251
|
-
url: actionUrl,
|
|
252
|
-
headers: actionHeaders,
|
|
253
|
-
body: action.body ? actionBody.body : undefined,
|
|
254
|
-
});
|
|
255
|
-
if (secondResp.status >= 500) {
|
|
256
|
-
findings.push({
|
|
257
|
-
kind: "double_action_5xx",
|
|
258
|
-
message: `action "${name}" 5xx'd on replay (${secondResp.status}) — should be idempotent or 4xx`,
|
|
259
|
-
extra: { action: name, status: secondResp.status },
|
|
260
|
-
});
|
|
261
|
-
} else if (secondResp.status >= 200 && secondResp.status < 300) {
|
|
262
|
-
// Replay accepted — state must remain the action's expected state.
|
|
263
|
-
const replayRead = await h.send({ method: "GET", url: readUrlFor(id), headers: baseHeaders });
|
|
264
|
-
if (replayRead.status >= 200 && replayRead.status < 300) {
|
|
265
|
-
const replayState = readState(replayRead.body_parsed ?? safeParse(replayRead.body), cfg.field);
|
|
266
|
-
if (replayState != null && replayState !== observedState) {
|
|
267
|
-
findings.push({
|
|
268
|
-
kind: "state_regression_on_replay",
|
|
269
|
-
message: `action "${name}" replay drifted state ${observedState} → ${replayState}`,
|
|
270
|
-
extra: { action: name, before_replay: observedState, after_replay: replayState },
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
// 4xx on replay is an acceptable "not-idempotent but safe" rejection.
|
|
276
|
-
|
|
277
|
-
currentState = observedState;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (findings.length === 0) return { kind: "pass" };
|
|
281
|
-
const kinds = findings.map((f) => f.kind);
|
|
282
|
-
const message = findings.length === 1
|
|
283
|
-
? findings[0]!.message
|
|
284
|
-
: `Lifecycle on ${g.resource}: ${findings.length} issue(s) — ${kinds.join(", ")}`;
|
|
285
|
-
return {
|
|
286
|
-
kind: "fail",
|
|
287
|
-
message,
|
|
288
|
-
evidence: {
|
|
289
|
-
resource: g.resource,
|
|
290
|
-
id,
|
|
291
|
-
kind: kinds.join("+"),
|
|
292
|
-
findings: findings.map((f) => ({ kind: f.kind, message: f.message, ...(f.extra ?? {}) })),
|
|
293
|
-
},
|
|
294
|
-
};
|
|
295
|
-
},
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
/** Item-array containers we recognise when a list endpoint returns
|
|
299
|
-
* `{ data: [...] }` etc. Mirrors `pagination_invariants` defaults so
|
|
300
|
-
* the two checks stay in sync on response-shape heuristics. */
|
|
301
|
-
const ITEMS_FIELD_FALLBACKS: ReadonlyArray<string> = ["data", "items", "results", "value"];
|
|
302
|
-
|
|
303
|
-
function extractListItems(body: unknown): unknown[] | null {
|
|
304
|
-
if (Array.isArray(body)) return body;
|
|
305
|
-
if (!body || typeof body !== "object") return null;
|
|
306
|
-
const obj = body as Record<string, unknown>;
|
|
307
|
-
for (const f of ITEMS_FIELD_FALLBACKS) {
|
|
308
|
-
const v = obj[f];
|
|
309
|
-
if (Array.isArray(v)) return v;
|
|
310
|
-
}
|
|
311
|
-
// ARV-219 follow-up: GitHub-shape responses use API-specific keys
|
|
312
|
-
// (`workflow_runs`, `check_runs`, `artifacts`, `installations`, …)
|
|
313
|
-
// alongside metadata fields like `total_count`. Pick the longest
|
|
314
|
-
// array-valued property as the items collection — that's the
|
|
315
|
-
// canonical shape across the GitHub REST API. Wrong-array picks
|
|
316
|
-
// surface as `state field missing on all items` (informative skip),
|
|
317
|
-
// never as a finding.
|
|
318
|
-
let best: unknown[] | null = null;
|
|
319
|
-
for (const v of Object.values(obj)) {
|
|
320
|
-
if (Array.isArray(v) && (!best || v.length > best.length)) {
|
|
321
|
-
best = v as unknown[];
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
return best;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function pickSampleId(item: unknown, idParam: string): string | null {
|
|
328
|
-
if (item == null || typeof item !== "object") return null;
|
|
329
|
-
const obj = item as Record<string, unknown>;
|
|
330
|
-
// Try the resource's id-param field, then a generic `id` fallback —
|
|
331
|
-
// GitHub's `/issues` returns `number`, Stripe's returns `id`; both
|
|
332
|
-
// serve as a human-readable sample handle in the finding evidence.
|
|
333
|
-
for (const k of [idParam, "id", "number", "uuid", "key"]) {
|
|
334
|
-
const v = obj[k];
|
|
335
|
-
if (typeof v === "string" || typeof v === "number") return String(v);
|
|
336
|
-
}
|
|
337
|
-
return null;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
async function runPureObservation(
|
|
341
|
-
g: Parameters<CrudStatefulCheck["run"]>[0],
|
|
342
|
-
h: Parameters<CrudStatefulCheck["run"]>[1],
|
|
343
|
-
cfg: LifecycleConfig,
|
|
344
|
-
): ReturnType<CrudStatefulCheck["run"]> {
|
|
345
|
-
const list = g.list!;
|
|
346
|
-
const baseHeaders = { Accept: "application/json", ...h.authHeaders };
|
|
347
|
-
const url = `${h.baseUrl.replace(/\/+$/, "")}${fillPathParams(list.path, h.pathVars)}`;
|
|
348
|
-
|
|
349
|
-
const resp = await h.send({ method: "GET", url, headers: baseHeaders });
|
|
350
|
-
if (resp.status < 200 || resp.status >= 300) {
|
|
351
|
-
return { kind: "skip", reason: `list returned ${resp.status} — broken-baseline guard (observation mode)` };
|
|
352
|
-
}
|
|
353
|
-
const body = resp.body_parsed ?? safeParse(resp.body);
|
|
354
|
-
const items = extractListItems(body);
|
|
355
|
-
if (items == null) {
|
|
356
|
-
return { kind: "skip", reason: "list response shape not recognised (expected array or {data|items|results|value: []})" };
|
|
357
|
-
}
|
|
358
|
-
if (items.length === 0) {
|
|
359
|
-
return { kind: "skip", reason: "list empty — no data to observe" };
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const stateSet = new Set(cfg.states);
|
|
363
|
-
const missingField: string[] = [];
|
|
364
|
-
// Map of undeclared state → up to N sample ids for evidence; using
|
|
365
|
-
// a Map preserves observed-order so the finding mentions states
|
|
366
|
-
// in the order the API surfaced them.
|
|
367
|
-
const undeclared = new Map<string, string[]>();
|
|
368
|
-
const SAMPLE_CAP = 5;
|
|
369
|
-
|
|
370
|
-
for (const item of items) {
|
|
371
|
-
if (item == null || typeof item !== "object") continue;
|
|
372
|
-
const state = (item as Record<string, unknown>)[cfg.field];
|
|
373
|
-
const sampleId = pickSampleId(item, g.idParam) ?? "?";
|
|
374
|
-
if (typeof state !== "string") {
|
|
375
|
-
if (missingField.length < SAMPLE_CAP) missingField.push(sampleId);
|
|
376
|
-
continue;
|
|
377
|
-
}
|
|
378
|
-
if (!stateSet.has(state)) {
|
|
379
|
-
const ids = undeclared.get(state) ?? [];
|
|
380
|
-
if (ids.length < SAMPLE_CAP) ids.push(sampleId);
|
|
381
|
-
undeclared.set(state, ids);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Field-missing is informational only — many APIs nest state under a
|
|
386
|
-
// sub-object the operator may have misspelled. Surface as a skip when
|
|
387
|
-
// EVERY item lacks the field (yaml mismatch), but don't fail the run
|
|
388
|
-
// when only some items lack it (could be a polymorphic schema).
|
|
389
|
-
if (undeclared.size === 0 && missingField.length === items.length) {
|
|
390
|
-
return {
|
|
391
|
-
kind: "skip",
|
|
392
|
-
reason: `state field "${cfg.field}" missing on all ${items.length} observed items — yaml mismatch or nested field`,
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
if (undeclared.size === 0) return { kind: "pass" };
|
|
397
|
-
|
|
398
|
-
const observedList = [...undeclared.entries()].map(([state, ids]) => ({
|
|
399
|
-
state,
|
|
400
|
-
sample_ids: ids,
|
|
401
|
-
occurrence_cap_hit: ids.length === SAMPLE_CAP,
|
|
402
|
-
}));
|
|
403
|
-
const stateNames = [...undeclared.keys()];
|
|
404
|
-
return {
|
|
405
|
-
kind: "fail",
|
|
406
|
-
message: `Lifecycle on ${g.resource} (observation mode): observed ${undeclared.size} undeclared state(s) — ${stateNames.join(", ")}`,
|
|
407
|
-
evidence: {
|
|
408
|
-
resource: g.resource,
|
|
409
|
-
kind: "undeclared_state",
|
|
410
|
-
mode: "observation",
|
|
411
|
-
observed_undeclared: observedList,
|
|
412
|
-
declared_states: cfg.states,
|
|
413
|
-
items_examined: items.length,
|
|
414
|
-
},
|
|
415
|
-
};
|
|
416
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `missing_required_header` — schemathesis-equivalent. Sends a request
|
|
3
|
-
* with one declared-required header dropped; the server must reject
|
|
4
|
-
* with a 4xx (400 / 401 / 403 / 406 / 422). Anything else (2xx, 3xx,
|
|
5
|
-
* 5xx) is a finding.
|
|
6
|
-
*
|
|
7
|
-
* The runner generates the probe case (kind="missing_required_header")
|
|
8
|
-
* only when at least one operation declares a required header — we
|
|
9
|
-
* skip otherwise to avoid a second wave of pointless requests.
|
|
10
|
-
*/
|
|
11
|
-
import type { Check } from "../types.ts";
|
|
12
|
-
|
|
13
|
-
const ACCEPTABLE_REJECT_STATUSES = new Set([400, 401, 403, 406, 422, 412]);
|
|
14
|
-
|
|
15
|
-
export const missingRequiredHeader: Check = {
|
|
16
|
-
id: "missing_required_header",
|
|
17
|
-
severity: "high",
|
|
18
|
-
defaultExpected: "Server must reject the request with 4xx when a required header is missing",
|
|
19
|
-
references: [{ id: "OWASP-API-04" }],
|
|
20
|
-
caseKinds: ["missing_required_header"],
|
|
21
|
-
applies: (op) =>
|
|
22
|
-
op.parameters.some((p) => p.in === "header" && p.required === true),
|
|
23
|
-
run({ case: c, response }) {
|
|
24
|
-
const status = response.status;
|
|
25
|
-
if (status >= 500) {
|
|
26
|
-
return {
|
|
27
|
-
kind: "fail",
|
|
28
|
-
message: `Server 5xx'd when required header was dropped (${c.meta?.dropped_header}) — should be a 4xx rejection`,
|
|
29
|
-
evidence: { dropped_header: c.meta?.dropped_header, status },
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
if (ACCEPTABLE_REJECT_STATUSES.has(status)) return { kind: "pass" };
|
|
33
|
-
if (status >= 400 && status < 500) return { kind: "pass" }; // any 4xx counts
|
|
34
|
-
return {
|
|
35
|
-
kind: "fail",
|
|
36
|
-
message: `Server accepted request without required header "${c.meta?.dropped_header}" (status ${status})`,
|
|
37
|
-
evidence: { dropped_header: c.meta?.dropped_header, status },
|
|
38
|
-
};
|
|
39
|
-
},
|
|
40
|
-
};
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `negative_data_rejection` (m-15 ARV-4) — schemathesis-equivalent.
|
|
3
|
-
* The runner builds a single-site negative case (one mutation against
|
|
4
|
-
* a valid body, see `_negative_mutator.ts`); if the server still
|
|
5
|
-
* accepts it (status outside 4xx/5xx + 401/403/404 admin set), we
|
|
6
|
-
* raise a finding with raw evidence. The agent triages / re-severitizes.
|
|
7
|
-
*
|
|
8
|
-
* Default expected: 400 / 401 / 403 / 404 / 422 / 428 / 5xx.
|
|
9
|
-
* 2xx and 3xx with our payload are findings.
|
|
10
|
-
*
|
|
11
|
-
* Severity matrix (ARV-284, dispatched per finding via outcome.severity
|
|
12
|
-
* — see `severityForEvidence` below):
|
|
13
|
-
*
|
|
14
|
-
* - MEDIUM: concrete schema breach silently accepted (maxLength+1,
|
|
15
|
-
* type-mismatch, pattern-violation, format-invalid,
|
|
16
|
-
* drop-required, uniqueItems-violation, drop-required-query).
|
|
17
|
-
* Server should reject; evidence single-signal but breach
|
|
18
|
-
* is concrete.
|
|
19
|
-
* - LOW: `additionalProperties-violation` (unknown body fields
|
|
20
|
-
* silently dropped is documented forward-compat in many
|
|
21
|
-
* APIs — Stripe by design) and `wrong-type` query params
|
|
22
|
-
* on GET list endpoints (often "invalid id → empty
|
|
23
|
-
* result" documented behaviour). Single-signal, ambiguous
|
|
24
|
-
* intent.
|
|
25
|
-
*
|
|
26
|
-
* Note: 5xx on a negative mutation does NOT escalate this check —
|
|
27
|
-
* `not_a_server_error` (separate check, severity HIGH by design) owns
|
|
28
|
-
* the 5xx signal, and `ACCEPTABLE()` below treats 5xx as a non-silent
|
|
29
|
-
* accept so this check passes for those cases. Avoids double-counting.
|
|
30
|
-
*
|
|
31
|
-
* Per ARV-250's proof-cap principle (no evidence → no high severity):
|
|
32
|
-
* single-signal proof caps at LOW; concrete schema breach escalates to
|
|
33
|
-
* MEDIUM. The declared `severity: "low"` is the natural fallback /
|
|
34
|
-
* proof-cap baseline; stronger findings use `outcome.severity` to
|
|
35
|
-
* override. The agent re-severitizes from the raw evidence.
|
|
36
|
-
*/
|
|
37
|
-
import type { Check, CheckOutcome } from "../types.ts";
|
|
38
|
-
import type { Severity } from "../../severity/index.ts";
|
|
39
|
-
|
|
40
|
-
const ACCEPTABLE = (status: number): boolean => {
|
|
41
|
-
if (status === 400 || status === 401 || status === 403 || status === 404 || status === 422 || status === 428) return true;
|
|
42
|
-
if (status >= 500 && status < 600) return true; // 5xx accepted: a bug, but not a *silent* accept
|
|
43
|
-
return false;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
/** Body-boundary labels we treat as LOW (often by-design vendor
|
|
47
|
-
* behaviour, single-signal, ambiguous intent). All other boundary
|
|
48
|
-
* labels emitted by `coverage-phase.ts:enumerateBoundaryCases` —
|
|
49
|
-
* maxLength+1, pattern-violation, uuid-invalid, drop-required:X, etc.
|
|
50
|
-
* — are concrete schema breaches and stay MEDIUM. */
|
|
51
|
-
const LOW_BODY_BOUNDARIES: ReadonlySet<string> = new Set([
|
|
52
|
-
"additionalProperties-violation",
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
|
-
/** Param-scenario labels (`coverage-phase.ts:enumerateParamBoundaryCases`)
|
|
56
|
-
* we treat as LOW. `wrong-type` is the classic "Stripe returns empty
|
|
57
|
-
* list for invalid id" pattern — many APIs document this; flagging
|
|
58
|
-
* HIGH/MEDIUM produces noise. `drop-required-query` stays MEDIUM (a
|
|
59
|
-
* declared-required param being silently optional is a concrete
|
|
60
|
-
* contract gap, not vendor convention). */
|
|
61
|
-
const LOW_PARAM_SCENARIOS_ON_GET: ReadonlySet<string> = new Set([
|
|
62
|
-
"wrong-type",
|
|
63
|
-
]);
|
|
64
|
-
|
|
65
|
-
interface MutationMeta {
|
|
66
|
-
mutation?: string;
|
|
67
|
-
boundary?: string;
|
|
68
|
-
param_scenario?: string;
|
|
69
|
-
param_location?: string;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function severityForEvidence(
|
|
73
|
-
meta: MutationMeta | undefined,
|
|
74
|
-
method: string,
|
|
75
|
-
): Severity {
|
|
76
|
-
if (!meta) return "low";
|
|
77
|
-
|
|
78
|
-
// Param-side mutations (query / path wrong-type, drop-required-query).
|
|
79
|
-
if (meta.mutation === "param-boundary") {
|
|
80
|
-
const scenario = meta.param_scenario ?? "";
|
|
81
|
-
if (
|
|
82
|
-
meta.param_location === "query"
|
|
83
|
-
&& method.toUpperCase() === "GET"
|
|
84
|
-
&& LOW_PARAM_SCENARIOS_ON_GET.has(scenario)
|
|
85
|
-
) {
|
|
86
|
-
return "low";
|
|
87
|
-
}
|
|
88
|
-
// drop-required-query, wrong-type on non-GET, wrong-type on path
|
|
89
|
-
// params: concrete contract violations server should reject.
|
|
90
|
-
return "medium";
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Body-boundary mutations (additionalProperties, maxLength+1, etc).
|
|
94
|
-
if (meta.mutation === "boundary") {
|
|
95
|
-
const boundary = meta.boundary ?? "";
|
|
96
|
-
if (LOW_BODY_BOUNDARIES.has(boundary)) return "low";
|
|
97
|
-
return "medium";
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Unknown mutation kind — single-signal fallback.
|
|
101
|
-
return "low";
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export const negativeDataRejection: Check = {
|
|
105
|
-
id: "negative_data_rejection",
|
|
106
|
-
// ARV-284: declared severity is the *natural* tier (proof-cap baseline
|
|
107
|
-
// per ARV-250 — single-signal caps at LOW). Per-finding severity is
|
|
108
|
-
// dispatched via `outcome.severity` in `run()` below, so summary
|
|
109
|
-
// tables can show HIGH for 5xx and MEDIUM for concrete schema breach
|
|
110
|
-
// without globally setting the check to HIGH (which masks calibration).
|
|
111
|
-
severity: "low",
|
|
112
|
-
defaultExpected: "Server must reject invalid bodies with 400/401/403/404/422/428 (or 5xx)",
|
|
113
|
-
references: [{ id: "OWASP-API-08" }],
|
|
114
|
-
caseKinds: ["negative_data"],
|
|
115
|
-
applies: (op) => Boolean(op.requestBodySchema),
|
|
116
|
-
run({ case: c, response }): CheckOutcome {
|
|
117
|
-
const meta = c.meta as MutationMeta | undefined;
|
|
118
|
-
const method = c.operation.method.toUpperCase();
|
|
119
|
-
const bodyless = method === "GET" || method === "HEAD" || method === "DELETE";
|
|
120
|
-
// ARV-345 (scope fix, like ARV-340): don't manufacture "accepted an invalid
|
|
121
|
-
// body" findings on methods with no request-body semantics. A GET/HEAD/DELETE
|
|
122
|
-
// that 2xx's a mutated body simply ignored the body — correct, not a gap.
|
|
123
|
-
if (bodyless && meta?.mutation === "boundary") {
|
|
124
|
-
return { kind: "skip", reason: "body mutation not applicable on GET/HEAD/DELETE (no request-body semantics)" };
|
|
125
|
-
}
|
|
126
|
-
// ARV-345: an unknown / wrong-type QUERY param a GET tolerates (2xx) is
|
|
127
|
-
// documented leniency ("invalid id → empty list"), not a rejectable invalid.
|
|
128
|
-
// drop-required-query stays in scope — a declared-required param silently
|
|
129
|
-
// optional IS a concrete contract gap.
|
|
130
|
-
if (
|
|
131
|
-
bodyless
|
|
132
|
-
&& meta?.mutation === "param-boundary"
|
|
133
|
-
&& meta?.param_location === "query"
|
|
134
|
-
&& LOW_PARAM_SCENARIOS_ON_GET.has(meta?.param_scenario ?? "")
|
|
135
|
-
) {
|
|
136
|
-
return { kind: "skip", reason: "unknown/wrong-type query param on GET is documented leniency, not a validation gap" };
|
|
137
|
-
}
|
|
138
|
-
if (ACCEPTABLE(response.status)) return { kind: "pass" };
|
|
139
|
-
return {
|
|
140
|
-
kind: "fail",
|
|
141
|
-
message: `Server accepted an invalid body (status ${response.status}) — single-site mutation: ${
|
|
142
|
-
meta?.mutation ?? "unknown"
|
|
143
|
-
} @ ${meta?.boundary ?? meta?.param_scenario ?? "?"}`,
|
|
144
|
-
evidence: { status: response.status, mutation: meta },
|
|
145
|
-
severity: severityForEvidence(meta, c.operation.method),
|
|
146
|
-
};
|
|
147
|
-
},
|
|
148
|
-
};
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `not_a_server_error` — schemathesis-equivalent baseline check: a
|
|
3
|
-
* well-formed request must never produce a 5xx response. Acts as the
|
|
4
|
-
* seed check for the ARV-1 scaffolding; the rest of the conformance
|
|
5
|
-
* suite lands in ARV-2.
|
|
6
|
-
*/
|
|
7
|
-
import type { Check } from "../types.ts";
|
|
8
|
-
|
|
9
|
-
export const notAServerError: Check = {
|
|
10
|
-
id: "not_a_server_error",
|
|
11
|
-
severity: "high",
|
|
12
|
-
defaultExpected: "Server must not respond with 5xx for any well-formed request",
|
|
13
|
-
references: [
|
|
14
|
-
{ id: "RFC-9110-15.6", url: "https://www.rfc-editor.org/rfc/rfc9110#name-server-error-5xx" },
|
|
15
|
-
],
|
|
16
|
-
// ARV-340: evaluate malformed-input cases too, not just the positive
|
|
17
|
-
// baseline. A negative_data / missing-header request is still a
|
|
18
|
-
// well-formed HTTP request the server must handle with a 4xx — a 5xx
|
|
19
|
-
// there is an unhandled crash (live Stripe: GET /v1/billing/alerts 500
|
|
20
|
-
// on a bad query param slipped through when this defaulted to positive
|
|
21
|
-
// only). `unsupported_method` is excluded on purpose: 501/405 to an
|
|
22
|
-
// undeclared method is legitimate, not a server error.
|
|
23
|
-
caseKinds: ["positive", "negative_data", "missing_required_header"],
|
|
24
|
-
applies: () => true,
|
|
25
|
-
run({ response }) {
|
|
26
|
-
if (response.status >= 500 && response.status < 600) {
|
|
27
|
-
return {
|
|
28
|
-
kind: "fail",
|
|
29
|
-
message: `Server responded with ${response.status} (5xx) — request triggered an unhandled error`,
|
|
30
|
-
evidence: { status: response.status },
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
return { kind: "pass" };
|
|
34
|
-
},
|
|
35
|
-
};
|