@kirrosh/zond 0.23.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +164 -1
- package/README.md +8 -7
- package/package.json +2 -3
- package/src/CLAUDE.md +112 -0
- package/src/cli/commands/add-api.ts +19 -7
- package/src/cli/commands/api/annotate/index.ts +359 -4
- package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
- package/src/cli/commands/api/annotate/overlay.ts +1 -1
- package/src/cli/commands/api/annotate/pagination.ts +10 -6
- package/src/cli/commands/api/annotate/prompts.ts +39 -2
- package/src/cli/commands/audit.ts +360 -54
- package/src/cli/commands/check.ts +15 -2
- package/src/cli/commands/checks.ts +352 -36
- package/src/cli/commands/cleanup.ts +4 -30
- package/src/cli/commands/coverage.ts +275 -57
- package/src/cli/commands/db.ts +311 -8
- package/src/cli/commands/discover.ts +281 -161
- package/src/cli/commands/doctor.ts +57 -3
- package/src/cli/commands/fixtures.ts +1 -1
- package/src/cli/commands/generate.ts +24 -7
- package/src/cli/commands/init/bootstrap.ts +4 -1
- package/src/cli/commands/init/index.ts +2 -2
- package/src/cli/commands/init/skills.ts +43 -0
- package/src/cli/commands/init/templates/agents.md +12 -3
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
- package/src/cli/commands/init/templates/skills/zond.md +274 -64
- package/src/cli/commands/init/templates/zond-config.yml +1 -1
- package/src/cli/commands/prepare-fixtures.ts +14 -52
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +101 -10
- package/src/cli/commands/probe/security.ts +95 -12
- package/src/cli/commands/probe/webhooks.ts +2 -0
- package/src/cli/commands/probe.ts +87 -11
- package/src/cli/commands/refresh-api.ts +59 -1
- package/src/cli/commands/report-bundle.ts +3 -11
- package/src/cli/commands/request.ts +116 -0
- package/src/cli/commands/run.ts +33 -5
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/json-envelope.ts +0 -20
- package/src/cli/json-schemas.ts +51 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +5 -4
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +0 -10
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/cross_call_references.ts +17 -4
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/idempotency_replay.ts +1 -5
- package/src/core/checks/checks/ignored_auth.ts +44 -1
- package/src/core/checks/checks/index.ts +3 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
- package/src/core/checks/checks/negative_data_rejection.ts +119 -16
- package/src/core/checks/checks/not_a_server_error.ts +8 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
- package/src/core/checks/checks/pagination_invariants.ts +298 -117
- package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
- package/src/core/checks/checks/status_code_conformance.ts +78 -7
- package/src/core/checks/mode.ts +3 -0
- package/src/core/checks/recommended-action.ts +5 -1
- package/src/core/checks/runner.ts +614 -27
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/types.ts +117 -1
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +35 -6
- package/src/core/coverage/loader.ts +31 -0
- package/src/core/diagnostics/db-analysis.ts +200 -106
- package/src/core/diagnostics/failure-class.ts +21 -1
- package/src/core/diagnostics/failure-hints.ts +4 -208
- package/src/core/diagnostics/suggested-fixes.ts +2 -3
- package/src/core/generator/chunker.ts +1 -8
- package/src/core/generator/data-factory.ts +199 -61
- package/src/core/generator/fixtures-builder.ts +38 -31
- package/src/core/generator/index.ts +0 -2
- package/src/core/generator/openapi-reader.ts +98 -4
- package/src/core/generator/path-param-disambig.ts +30 -4
- package/src/core/generator/resources-builder.ts +276 -26
- package/src/core/generator/schema-utils.ts +22 -0
- package/src/core/generator/suite-generator.ts +168 -15
- package/src/core/generator/types.ts +6 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/output/README.md +11 -29
- package/src/core/output/index.ts +1 -1
- package/src/core/output/run.ts +0 -35
- package/src/core/output/types.ts +0 -7
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/variables.ts +0 -0
- package/src/core/probe/dry-run-envelope.ts +4 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe.ts +23 -1118
- package/src/core/probe/mass-assignment-template.ts +32 -4
- package/src/core/probe/path-discovery.ts +3 -4
- package/src/core/probe/probe-harness.ts +21 -22
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +8 -2
- package/src/core/probe/security-probe.ts +28 -1449
- package/src/core/probe/shared.ts +26 -0
- package/src/core/probe/webhooks-probe.ts +5 -7
- package/src/core/runner/assertions.ts +1 -1
- package/src/core/runner/executor.ts +3 -18
- package/src/core/runner/form-encode.ts +8 -18
- package/src/core/runner/http-client.ts +38 -1
- package/src/core/runner/preflight-vars.ts +19 -15
- package/src/core/runner/rate-limiter.ts +11 -29
- package/src/core/runner/run-kind.ts +7 -1
- package/src/core/runner/schema-validator.ts +2 -6
- package/src/core/runner/send-request.ts +11 -6
- package/src/core/runner/types.ts +6 -0
- package/src/core/setup-api.ts +53 -15
- package/src/core/severity/index.ts +0 -63
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/db/migrate.ts +2 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/queries/collections.ts +2 -2
- package/src/db/queries/results.ts +88 -0
- package/src/db/queries/runs.ts +56 -2
- package/src/db/queries.ts +3 -0
- package/src/db/schema.ts +7 -7
- package/src/cli/commands/bootstrap.ts +0 -710
- package/src/core/anti-fp/bootstrap.ts +0 -34
- package/src/core/anti-fp/index.ts +0 -33
- package/src/core/anti-fp/registry.ts +0 -44
- package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
- package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
- package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
- package/src/core/anti-fp/types.ts +0 -68
- package/src/core/generator/create-body.ts +0 -89
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursor_boundary_fuzzing` (ARV-273) — fuzz cursor/page-token style
|
|
3
|
+
* query parameters on list endpoints with malformed values. The server
|
|
4
|
+
* is expected to reject with 400/422 (or 401/403 if the path is auth-
|
|
5
|
+
* gated). A 5xx response on *any* well-formed cursor value is a real
|
|
6
|
+
* bug (Stripe `/v1/billing/alerts` was found this way during the m-22
|
|
7
|
+
* Stripe scan).
|
|
8
|
+
*
|
|
9
|
+
* Detection rule: parameter `name` matches a small set of conventional
|
|
10
|
+
* cursor names (cursor / starting_after / ending_before / after /
|
|
11
|
+
* before / page_token / next_token / continuation), parameter is
|
|
12
|
+
* `in: "query"`, and schema type is string (or untyped — many SDKs
|
|
13
|
+
* leave the cursor schema open).
|
|
14
|
+
*
|
|
15
|
+
* Mutation vectors (7): empty string, numeric, "null", very-long
|
|
16
|
+
* string (200 chars), valid-shape-wrong-resource (Stripe-id), SQL-
|
|
17
|
+
* shaped (`' OR 1=1--`), JSON-shaped (`{"foo":"bar"}`).
|
|
18
|
+
*
|
|
19
|
+
* Severity ladder:
|
|
20
|
+
* - any 5xx → HIGH (server should never crash on bad cursor)
|
|
21
|
+
* - any 2xx/204 → LOW (server silently tolerates a malformed cursor)
|
|
22
|
+
* - 4xx other than 4xx-expected → no finding (server rejected — good)
|
|
23
|
+
* - 401/403 only → skip (auth-gated, not the check's concern)
|
|
24
|
+
*
|
|
25
|
+
* Why not done by `negative_data_rejection`: that check mutates one
|
|
26
|
+
* value per case based on the declared schema (string → integer,
|
|
27
|
+
* integer overflow, etc.). Cursor fuzzing is a *cross-API convention*
|
|
28
|
+
* fuzz family that does not derive from schema type; we know these
|
|
29
|
+
* are cursor-shaped strings and we know the standard malformations
|
|
30
|
+
* that crash naive cursor parsers.
|
|
31
|
+
*/
|
|
32
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
33
|
+
import type { CrudStatefulCheck } from "../stateful.ts";
|
|
34
|
+
import type { CheckOutcome, Severity } from "../types.ts";
|
|
35
|
+
import { fillPathParams } from "./_crud-helpers.ts";
|
|
36
|
+
import { buildUrl as buildUrlBase } from "../../util/url.ts";
|
|
37
|
+
import { MAX_REQUESTS_SKIP_REASON } from "../../runner/executor.ts";
|
|
38
|
+
|
|
39
|
+
const CURSOR_PARAM_NAME_RE = /^(cursor|starting_after|ending_before|after|before|page_token|next_token|continuation)$/i;
|
|
40
|
+
|
|
41
|
+
interface MutationVector {
|
|
42
|
+
label: string;
|
|
43
|
+
value: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MUTATION_VECTORS: readonly MutationVector[] = [
|
|
47
|
+
{ label: "empty", value: "" },
|
|
48
|
+
{ label: "numeric", value: "12345" },
|
|
49
|
+
{ label: "null-literal", value: "null" },
|
|
50
|
+
{ label: "very-long", value: "a".repeat(200) },
|
|
51
|
+
{ label: "wrong-resource-id", value: "cus_invalid_zzz" },
|
|
52
|
+
{ label: "sql-shaped", value: "' OR 1=1--" },
|
|
53
|
+
{ label: "json-shaped", value: '{"foo":"bar"}' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function isCursorParam(p: OpenAPIV3.ParameterObject): boolean {
|
|
57
|
+
if (p.in !== "query") return false;
|
|
58
|
+
if (!CURSOR_PARAM_NAME_RE.test(p.name)) return false;
|
|
59
|
+
const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
|
|
60
|
+
if (!schema) return true;
|
|
61
|
+
// Accept untyped schemas (many specs leave cursor open) and string.
|
|
62
|
+
if (!schema.type) return true;
|
|
63
|
+
return schema.type === "string";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildUrl(
|
|
67
|
+
base: string,
|
|
68
|
+
path: string,
|
|
69
|
+
pathVars: Record<string, string> | undefined,
|
|
70
|
+
cursorParam: string,
|
|
71
|
+
cursorValue: string,
|
|
72
|
+
): string {
|
|
73
|
+
return buildUrlBase(base, fillPathParams(path, pathVars), { [cursorParam]: cursorValue });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface VectorOutcome {
|
|
77
|
+
param: string;
|
|
78
|
+
vector: string;
|
|
79
|
+
status: number;
|
|
80
|
+
body_excerpt?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function excerpt(s: string | undefined, n: number): string | undefined {
|
|
84
|
+
if (!s) return undefined;
|
|
85
|
+
const trimmed = s.length > n ? s.slice(0, n) + "…" : s;
|
|
86
|
+
return trimmed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const cursorBoundaryFuzzing: CrudStatefulCheck = {
|
|
90
|
+
id: "cursor_boundary_fuzzing",
|
|
91
|
+
// Per-finding severity overrides via outcome.severity: 5xx → high, 2xx → low.
|
|
92
|
+
// Declared baseline 'low' so it doesn't dominate per-check gating tables.
|
|
93
|
+
severity: "low",
|
|
94
|
+
defaultExpected:
|
|
95
|
+
"Cursor-style query params (cursor / starting_after / page_token / …) must reject malformed values with 4xx, never 5xx",
|
|
96
|
+
references: [{ id: "ARV-273" }],
|
|
97
|
+
phase: "crud",
|
|
98
|
+
applies(g) {
|
|
99
|
+
if (!g.list) return false;
|
|
100
|
+
// ARV-310: cursor fuzzing is only meaningful on a paginating GET list.
|
|
101
|
+
// A create/mutation endpoint that happens to carry a cursor-shaped query
|
|
102
|
+
// param (or a mis-grouped POST bound as `list`) must not trip this check.
|
|
103
|
+
if (g.list.method.toUpperCase() !== "GET") return false;
|
|
104
|
+
return g.list.parameters.some(isCursorParam);
|
|
105
|
+
},
|
|
106
|
+
async run(g, h): Promise<CheckOutcome> {
|
|
107
|
+
if (h.bootstrapCleanupFailed) {
|
|
108
|
+
return { kind: "skip", reason: "bootstrap-cleanup failed — stateful checks paused" };
|
|
109
|
+
}
|
|
110
|
+
const list = g.list!;
|
|
111
|
+
const cursorParams = list.parameters.filter(isCursorParam);
|
|
112
|
+
if (cursorParams.length === 0) {
|
|
113
|
+
return { kind: "skip", reason: "no cursor-style query params on list endpoint" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const baseHeaders = { Accept: "application/json", ...h.authHeaders };
|
|
117
|
+
const serverErrors: VectorOutcome[] = [];
|
|
118
|
+
const silentAccepts: VectorOutcome[] = [];
|
|
119
|
+
let totalAttempted = 0;
|
|
120
|
+
let allAuthGated = true;
|
|
121
|
+
|
|
122
|
+
let budgetExhausted = false;
|
|
123
|
+
outer: for (const param of cursorParams) {
|
|
124
|
+
for (const vec of MUTATION_VECTORS) {
|
|
125
|
+
const url = buildUrl(h.baseUrl, list.path, h.pathVars, param.name, vec.value);
|
|
126
|
+
let resp;
|
|
127
|
+
try {
|
|
128
|
+
resp = await h.send({ method: "GET", url, headers: baseHeaders });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// ARV-308: distinguish budget exhaustion (every subsequent
|
|
131
|
+
// mutation will throw the same MAX_REQUESTS_SKIP_REASON and
|
|
132
|
+
// wasn't dispatched on the wire) from real per-request
|
|
133
|
+
// network errors (genuine connection problems, where we
|
|
134
|
+
// should keep fuzzing the remaining vectors). The earlier
|
|
135
|
+
// `catch {}` swallowed both as "network errors", which
|
|
136
|
+
// misreported the run on Stripe — budget had simply run out
|
|
137
|
+
// before this check got its turn, but the summary blamed
|
|
138
|
+
// the network and the 500 finding never surfaced.
|
|
139
|
+
if (err instanceof Error && err.message === MAX_REQUESTS_SKIP_REASON) {
|
|
140
|
+
budgetExhausted = true;
|
|
141
|
+
break outer;
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
totalAttempted += 1;
|
|
146
|
+
const status = resp.status;
|
|
147
|
+
if (status !== 401 && status !== 403) allAuthGated = false;
|
|
148
|
+
if (status >= 500 && status < 600) {
|
|
149
|
+
serverErrors.push({
|
|
150
|
+
param: param.name,
|
|
151
|
+
vector: vec.label,
|
|
152
|
+
status,
|
|
153
|
+
body_excerpt: excerpt(resp.body, 240),
|
|
154
|
+
});
|
|
155
|
+
} else if (status >= 200 && status < 300) {
|
|
156
|
+
silentAccepts.push({
|
|
157
|
+
param: param.name,
|
|
158
|
+
vector: vec.label,
|
|
159
|
+
status,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (totalAttempted === 0) {
|
|
166
|
+
// ARV-308: dedicated reason for budget exhaustion so the run
|
|
167
|
+
// summary shows the real cause (and an actionable fix — raise
|
|
168
|
+
// --max-requests / --budget) instead of blaming the network.
|
|
169
|
+
if (budgetExhausted) {
|
|
170
|
+
return { kind: "skip", reason: MAX_REQUESTS_SKIP_REASON };
|
|
171
|
+
}
|
|
172
|
+
return { kind: "skip", reason: "no mutations dispatched (network errors on every probe)" };
|
|
173
|
+
}
|
|
174
|
+
if (allAuthGated) {
|
|
175
|
+
return { kind: "skip", reason: "endpoint auth-gated for all probes (401/403 only)" };
|
|
176
|
+
}
|
|
177
|
+
if (serverErrors.length > 0) {
|
|
178
|
+
const sev: Severity = "high";
|
|
179
|
+
const first = serverErrors[0]!;
|
|
180
|
+
return {
|
|
181
|
+
kind: "fail",
|
|
182
|
+
severity: sev,
|
|
183
|
+
// ARV-310: attribute to the GET list actually probed, not the group's
|
|
184
|
+
// POST create.
|
|
185
|
+
operation: { path: list.path, method: "GET", operationId: list.operationId },
|
|
186
|
+
message:
|
|
187
|
+
`Server returned ${first.status} on ${first.vector} cursor (${first.param}) — ${serverErrors.length}/${totalAttempted} mutation(s) hit 5xx`,
|
|
188
|
+
evidence: {
|
|
189
|
+
resource: g.resource,
|
|
190
|
+
list_path: list.path,
|
|
191
|
+
kind: "server_error_on_bad_cursor",
|
|
192
|
+
cursor_params: cursorParams.map((p) => p.name),
|
|
193
|
+
attempted: totalAttempted,
|
|
194
|
+
server_errors: serverErrors,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (silentAccepts.length > 0) {
|
|
199
|
+
return {
|
|
200
|
+
kind: "fail",
|
|
201
|
+
severity: "low",
|
|
202
|
+
// ARV-310: attribute to the GET list actually probed, not the group's
|
|
203
|
+
// POST create.
|
|
204
|
+
operation: { path: list.path, method: "GET", operationId: list.operationId },
|
|
205
|
+
message:
|
|
206
|
+
`Server returned 2xx on ${silentAccepts.length}/${totalAttempted} malformed cursor mutation(s) — likely silent tolerance of bad input`,
|
|
207
|
+
evidence: {
|
|
208
|
+
resource: g.resource,
|
|
209
|
+
list_path: list.path,
|
|
210
|
+
kind: "silent_accept_on_bad_cursor",
|
|
211
|
+
cursor_params: cursorParams.map((p) => p.name),
|
|
212
|
+
attempted: totalAttempted,
|
|
213
|
+
accepts: silentAccepts,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { kind: "pass" };
|
|
218
|
+
},
|
|
219
|
+
};
|
|
@@ -110,11 +110,7 @@ function diffFields(a: unknown, b: unknown, ignore: ReadonlySet<string>): string
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
function generateKey(): string {
|
|
113
|
-
|
|
114
|
-
// mash so tests on minimal stubs still produce a stable-ish key.
|
|
115
|
-
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
116
|
-
if (c?.randomUUID) return c.randomUUID();
|
|
117
|
-
return `zond-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
113
|
+
return crypto.randomUUID();
|
|
118
114
|
}
|
|
119
115
|
|
|
120
116
|
export const idempotencyReplay: CrudStatefulCheck = {
|
|
@@ -29,6 +29,30 @@
|
|
|
29
29
|
* - skip operations with `security: []` override (explicitly public),
|
|
30
30
|
* - skip when `bootstrap_cleanup_failed` (data state corrupted),
|
|
31
31
|
* - skip when no auth headers provided to the harness at all.
|
|
32
|
+
*
|
|
33
|
+
* Severity matrix (ARV-286, dispatched per finding via outcome.severity;
|
|
34
|
+
* follow-up to ARV-284 `negative_data_rejection` pattern):
|
|
35
|
+
*
|
|
36
|
+
* Declared severity: 'low' (proof-cap baseline per ARV-250 — single-
|
|
37
|
+
* signal evidence alone caps at LOW; chain evidence elevates to HIGH).
|
|
38
|
+
*
|
|
39
|
+
* Per-finding dispatch:
|
|
40
|
+
*
|
|
41
|
+
* | evidence.variant | severity | rationale |
|
|
42
|
+
* |---------------------------|----------|-----------------------------------|
|
|
43
|
+
* | no_auth | HIGH | baseline 2xx + no-auth 2xx |
|
|
44
|
+
* | bogus_auth | HIGH | baseline 2xx + bogus 2xx |
|
|
45
|
+
* | no_auth_differential | HIGH | broken-baseline + lower bucket |
|
|
46
|
+
* | bogus_auth_differential | HIGH | broken-baseline + lower bucket |
|
|
47
|
+
* | no_auth_strict | MEDIUM | --strict-401 mismatch, no bypass |
|
|
48
|
+
* | bogus_auth_strict | MEDIUM | --strict-401 mismatch, no bypass |
|
|
49
|
+
*
|
|
50
|
+
* HIGH variants (bypass + differential) provide chain evidence: both a
|
|
51
|
+
* baseline probe and an auth-stripped probe contribute — two independent
|
|
52
|
+
* signals proving auth is ignored. MEDIUM variants (strict-401
|
|
53
|
+
* conformance) are single-signal: auth is likely enforced (server still
|
|
54
|
+
* rejects), just with the wrong status code (403/404 instead of 401).
|
|
55
|
+
* The agent re-severitizes from the raw evidence.
|
|
32
56
|
*/
|
|
33
57
|
import type { OpenAPIV3 } from "openapi-types";
|
|
34
58
|
import type { AuthStatefulCheck } from "../stateful.ts";
|
|
@@ -89,7 +113,10 @@ function isAcceptableRejection(status: number, strict401: boolean): boolean {
|
|
|
89
113
|
|
|
90
114
|
export const ignoredAuth: AuthStatefulCheck = {
|
|
91
115
|
id: "ignored_auth",
|
|
92
|
-
|
|
116
|
+
/** ARV-286: proof-cap baseline (ARV-250). Bypass findings emit
|
|
117
|
+
* outcome.severity="high" (chain evidence); strict-401 conformance
|
|
118
|
+
* findings emit outcome.severity="medium" (single-signal). */
|
|
119
|
+
severity: "low",
|
|
93
120
|
defaultExpected: "Server must reject requests without (or with bogus) auth credentials with 401/403",
|
|
94
121
|
references: [{ id: "OWASP-API-01" }],
|
|
95
122
|
phase: "auth",
|
|
@@ -139,6 +166,8 @@ export const ignoredAuth: AuthStatefulCheck = {
|
|
|
139
166
|
if (noAuth.status >= 200 && noAuth.status < 300) {
|
|
140
167
|
return {
|
|
141
168
|
kind: "fail",
|
|
169
|
+
// chain evidence: baseline 2xx + no-auth 2xx → proven bypass
|
|
170
|
+
severity: "high",
|
|
142
171
|
message: `Server accepted request without auth credentials (status ${noAuth.status}) — auth is being ignored`,
|
|
143
172
|
evidence: { variant: "no_auth", baseline_status: baseline.status, no_auth_status: noAuth.status },
|
|
144
173
|
};
|
|
@@ -146,6 +175,8 @@ export const ignoredAuth: AuthStatefulCheck = {
|
|
|
146
175
|
if (bogus.status >= 200 && bogus.status < 300) {
|
|
147
176
|
return {
|
|
148
177
|
kind: "fail",
|
|
178
|
+
// chain evidence: baseline 2xx + bogus-auth 2xx → credentials not validated
|
|
179
|
+
severity: "high",
|
|
149
180
|
message: `Server accepted request with bogus auth (status ${bogus.status}) — credentials not validated`,
|
|
150
181
|
evidence: { variant: "bogus_auth", baseline_status: baseline.status, bogus_auth_status: bogus.status },
|
|
151
182
|
};
|
|
@@ -154,6 +185,8 @@ export const ignoredAuth: AuthStatefulCheck = {
|
|
|
154
185
|
if (noAuth.status !== 401) {
|
|
155
186
|
return {
|
|
156
187
|
kind: "fail",
|
|
188
|
+
// single-signal: auth is likely enforced (4xx returned), just wrong status code
|
|
189
|
+
severity: "medium",
|
|
157
190
|
message: `no_auth returned ${noAuth.status}, expected 401 (--strict-401)`,
|
|
158
191
|
evidence: { variant: "no_auth_strict", baseline_status: baseline.status, no_auth_status: noAuth.status, strict_401: true },
|
|
159
192
|
};
|
|
@@ -161,6 +194,8 @@ export const ignoredAuth: AuthStatefulCheck = {
|
|
|
161
194
|
if (bogus.status !== 401) {
|
|
162
195
|
return {
|
|
163
196
|
kind: "fail",
|
|
197
|
+
// single-signal: auth is likely enforced (4xx returned), just wrong status code
|
|
198
|
+
severity: "medium",
|
|
164
199
|
message: `bogus_auth returned ${bogus.status}, expected 401 (--strict-401)`,
|
|
165
200
|
evidence: { variant: "bogus_auth_strict", baseline_status: baseline.status, bogus_auth_status: bogus.status, strict_401: true },
|
|
166
201
|
};
|
|
@@ -179,6 +214,8 @@ export const ignoredAuth: AuthStatefulCheck = {
|
|
|
179
214
|
if (noAuthBucket >= 0 && noAuthBucket < baseBucket) {
|
|
180
215
|
return {
|
|
181
216
|
kind: "fail",
|
|
217
|
+
// chain evidence: broken-baseline + lower bucket without auth → smoking gun bypass
|
|
218
|
+
severity: "high",
|
|
182
219
|
message: `Server gave a more permissive status (${noAuth.status}) without auth than with valid auth (${baseline.status}) — possible bypass`,
|
|
183
220
|
evidence: { variant: "no_auth_differential", baseline_status: baseline.status, no_auth_status: noAuth.status },
|
|
184
221
|
};
|
|
@@ -186,6 +223,8 @@ export const ignoredAuth: AuthStatefulCheck = {
|
|
|
186
223
|
if (bogusBucket >= 0 && bogusBucket < baseBucket) {
|
|
187
224
|
return {
|
|
188
225
|
kind: "fail",
|
|
226
|
+
// chain evidence: broken-baseline + lower bucket with bogus token → bypass
|
|
227
|
+
severity: "high",
|
|
189
228
|
message: `Server gave a more permissive status (${bogus.status}) with bogus auth than with valid auth (${baseline.status}) — possible bypass`,
|
|
190
229
|
evidence: { variant: "bogus_auth_differential", baseline_status: baseline.status, bogus_auth_status: bogus.status },
|
|
191
230
|
};
|
|
@@ -194,6 +233,8 @@ export const ignoredAuth: AuthStatefulCheck = {
|
|
|
194
233
|
if (!isAcceptableRejection(noAuth.status, true) && noAuthBucket >= 0) {
|
|
195
234
|
return {
|
|
196
235
|
kind: "fail",
|
|
236
|
+
// single-signal: auth is enforced (server still rejects), wrong status code
|
|
237
|
+
severity: "medium",
|
|
197
238
|
message: `no_auth returned ${noAuth.status}, expected 401 (--strict-401, baseline ${baseline.status})`,
|
|
198
239
|
evidence: { variant: "no_auth_strict", baseline_status: baseline.status, no_auth_status: noAuth.status, strict_401: true },
|
|
199
240
|
};
|
|
@@ -201,6 +242,8 @@ export const ignoredAuth: AuthStatefulCheck = {
|
|
|
201
242
|
if (!isAcceptableRejection(bogus.status, true) && bogusBucket >= 0) {
|
|
202
243
|
return {
|
|
203
244
|
kind: "fail",
|
|
245
|
+
// single-signal: auth is enforced (server still rejects), wrong status code
|
|
246
|
+
severity: "medium",
|
|
204
247
|
message: `bogus_auth returned ${bogus.status}, expected 401 (--strict-401, baseline ${baseline.status})`,
|
|
205
248
|
evidence: { variant: "bogus_auth_strict", baseline_status: baseline.status, bogus_auth_status: bogus.status, strict_401: true },
|
|
206
249
|
};
|
|
@@ -27,6 +27,7 @@ import { paginationInvariants } from "./pagination_invariants.ts";
|
|
|
27
27
|
import { lifecycleTransitions } from "./lifecycle_transitions.ts";
|
|
28
28
|
import { openCorsOnSensitive } from "./open_cors_on_sensitive.ts";
|
|
29
29
|
import { rateLimitHeadersAbsent } from "./rate_limit_headers_absent.ts";
|
|
30
|
+
import { cursorBoundaryFuzzing } from "./cursor_boundary_fuzzing.ts";
|
|
30
31
|
|
|
31
32
|
let registered = false;
|
|
32
33
|
|
|
@@ -59,6 +60,8 @@ export function registerBuiltinChecks(): void {
|
|
|
59
60
|
// ARV-256 (m-21) — small-team value-add checks.
|
|
60
61
|
registerStatefulCheck(openCorsOnSensitive);
|
|
61
62
|
registerCheck(rateLimitHeadersAbsent);
|
|
63
|
+
// ARV-273 (m-22) — cursor/page-token fuzzing on list endpoints.
|
|
64
|
+
registerStatefulCheck(cursorBoundaryFuzzing);
|
|
62
65
|
registered = true;
|
|
63
66
|
}
|
|
64
67
|
|
|
@@ -1,38 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `lifecycle_transitions` (m-20 ARV-172
|
|
3
|
-
* state machine on the live API.
|
|
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
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Two modes, gated on the `actions` field of the yaml manifest:
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* either 4xx (rejected) or 2xx with state unchanged. State
|
|
19
|
-
* regression on replay → finding (`state_regression_on_replay`).
|
|
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.
|
|
20
18
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
24
27
|
*
|
|
25
|
-
*
|
|
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):
|
|
26
33
|
* • Yaml manifest validated at load (validateLifecycleManifest in
|
|
27
34
|
* resources-builder); a malformed manifest skips with the
|
|
28
35
|
* concrete error so the operator gets actionable feedback.
|
|
29
|
-
* •
|
|
36
|
+
* • Non-2xx baseline (create or list) → broken-baseline skip.
|
|
30
37
|
* • Action POST non-2xx on first call → action-not-supported skip
|
|
31
38
|
* (the API may have authoritative server-side gating; not a
|
|
32
39
|
* contract bug).
|
|
33
|
-
* •
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
36
48
|
*/
|
|
37
49
|
import type { OpenAPIV3 } from "openapi-types";
|
|
38
50
|
import type { CrudStatefulCheck } from "../stateful.ts";
|
|
@@ -85,7 +97,7 @@ export const lifecycleTransitions: CrudStatefulCheck = {
|
|
|
85
97
|
references: [{ id: "ARV-172" }],
|
|
86
98
|
phase: "crud",
|
|
87
99
|
applies(g) {
|
|
88
|
-
return Boolean(g.create && g.read);
|
|
100
|
+
return Boolean((g.create && g.read) || g.list);
|
|
89
101
|
},
|
|
90
102
|
async run(g, h) {
|
|
91
103
|
if (h.bootstrapCleanupFailed) {
|
|
@@ -98,8 +110,19 @@ export const lifecycleTransitions: CrudStatefulCheck = {
|
|
|
98
110
|
if (manifestErrors.length > 0) {
|
|
99
111
|
return { kind: "skip", reason: `lifecycle manifest invalid: ${manifestErrors[0]}` };
|
|
100
112
|
}
|
|
113
|
+
|
|
114
|
+
// ARV-219: actions-empty → pure-observation mode (read-only state
|
|
115
|
+
// machine). Requires a list endpoint to sample observed states.
|
|
101
116
|
if (Object.keys(cfg.actions).length === 0) {
|
|
102
|
-
|
|
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" };
|
|
103
126
|
}
|
|
104
127
|
|
|
105
128
|
const create = g.create!;
|
|
@@ -271,3 +294,123 @@ export const lifecycleTransitions: CrudStatefulCheck = {
|
|
|
271
294
|
};
|
|
272
295
|
},
|
|
273
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
|
+
}
|