@kirrosh/zond 0.26.1 → 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 +47 -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 -1162
- 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,160 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `open_cors_on_sensitive` (ARV-256, m-21 pivot) — verify that an
|
|
3
|
-
* authenticated endpoint does not echo arbitrary Origin values with
|
|
4
|
-
* Access-Control-Allow-Credentials: true.
|
|
5
|
-
*
|
|
6
|
-
* The dangerous combo, in two shapes:
|
|
7
|
-
* - `Access-Control-Allow-Origin: *` + `Access-Control-Allow-Credentials: true`
|
|
8
|
-
* (illegal per spec but seen in the wild; some servers patch out the
|
|
9
|
-
* star and emit just the credentials header — still broken)
|
|
10
|
-
* - `Access-Control-Allow-Origin: <reflected attacker Origin>` +
|
|
11
|
-
* `Access-Control-Allow-Credentials: true`
|
|
12
|
-
*
|
|
13
|
-
* Both let any cross-origin site read authenticated responses on behalf
|
|
14
|
-
* of a logged-in user — classic CSRF-with-data-exfil surface.
|
|
15
|
-
*
|
|
16
|
-
* Sends one request with `Origin: https://evil.zond.test`, then
|
|
17
|
-
* inspects the response CORS headers. Evidence_chain proof: request
|
|
18
|
-
* Origin + response headers travel together in the finding.
|
|
19
|
-
*
|
|
20
|
-
* Anti-FP: skips endpoints with `security: []` override (intentionally
|
|
21
|
-
* public). Skips when server doesn't emit any CORS headers (API isn't
|
|
22
|
-
* configured for cross-origin — no problem to find).
|
|
23
|
-
*/
|
|
24
|
-
import type { AuthStatefulCheck } from "../stateful.ts";
|
|
25
|
-
import type { OpenAPIV3 } from "openapi-types";
|
|
26
|
-
import type { HttpRequest } from "../../runner/types.ts";
|
|
27
|
-
|
|
28
|
-
const PROBE_ORIGIN = "https://evil.zond.test";
|
|
29
|
-
|
|
30
|
-
function fillPath(
|
|
31
|
-
path: string,
|
|
32
|
-
op: { parameters: OpenAPIV3.ParameterObject[] },
|
|
33
|
-
pathVars: Record<string, string> | undefined,
|
|
34
|
-
): string {
|
|
35
|
-
return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
36
|
-
const real = pathVars?.[name];
|
|
37
|
-
if (typeof real === "string" && real.length > 0) return encodeURIComponent(real);
|
|
38
|
-
const param = op.parameters.find((p) => p.name === name && p.in === "path");
|
|
39
|
-
const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
40
|
-
if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
|
|
41
|
-
if (schema?.type === "integer" || schema?.type === "number") return "1";
|
|
42
|
-
return "x";
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function getHeader(headers: Record<string, string>, name: string): string | undefined {
|
|
47
|
-
return headers[name] ?? headers[name.toLowerCase()];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export const openCorsOnSensitive: AuthStatefulCheck = {
|
|
51
|
-
id: "open_cors_on_sensitive",
|
|
52
|
-
severity: "high",
|
|
53
|
-
defaultExpected:
|
|
54
|
-
"Authenticated endpoints must not echo arbitrary Origin with Allow-Credentials: true (cross-origin read of authed data)",
|
|
55
|
-
references: [{ id: "OWASP-API-09-CORS" }],
|
|
56
|
-
phase: "auth",
|
|
57
|
-
applies(op) {
|
|
58
|
-
// Same anti-FP as ignored_auth: skip explicit-public endpoints.
|
|
59
|
-
if (op.security.length === 0) return false;
|
|
60
|
-
return true;
|
|
61
|
-
},
|
|
62
|
-
async run(op, h) {
|
|
63
|
-
const url = `${h.baseUrl.replace(/\/+$/, "")}${fillPath(op.path, op, h.pathVars)}`;
|
|
64
|
-
const method = op.method.toUpperCase();
|
|
65
|
-
// GET-only probe — bursting on POST/PATCH would create resources.
|
|
66
|
-
// For mutating-only endpoints we still send the actual method with
|
|
67
|
-
// the OPTIONS preflight-style Origin header, since most CORS
|
|
68
|
-
// misconfigurations apply to non-preflight responses.
|
|
69
|
-
const safeMethod = method === "GET" || method === "HEAD" || method === "OPTIONS" ? method : "OPTIONS";
|
|
70
|
-
const req: HttpRequest = {
|
|
71
|
-
method: safeMethod,
|
|
72
|
-
url,
|
|
73
|
-
headers: {
|
|
74
|
-
Accept: "application/json",
|
|
75
|
-
...h.authHeaders,
|
|
76
|
-
Origin: PROBE_ORIGIN,
|
|
77
|
-
// For non-GET probes, advertise the real method as the preflight
|
|
78
|
-
// would.
|
|
79
|
-
...(safeMethod === "OPTIONS"
|
|
80
|
-
? {
|
|
81
|
-
"Access-Control-Request-Method": method,
|
|
82
|
-
"Access-Control-Request-Headers": "Authorization",
|
|
83
|
-
}
|
|
84
|
-
: {}),
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
const resp = await h.send(req);
|
|
88
|
-
|
|
89
|
-
const allowOrigin = getHeader(resp.headers, "access-control-allow-origin");
|
|
90
|
-
const allowCreds = getHeader(resp.headers, "access-control-allow-credentials");
|
|
91
|
-
|
|
92
|
-
// No CORS headers emitted → endpoint isn't configured for cross-origin.
|
|
93
|
-
// Nothing to flag.
|
|
94
|
-
if (!allowOrigin && !allowCreds) {
|
|
95
|
-
return { kind: "skip", reason: "no CORS headers in response — API not configured for cross-origin" };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const credsTrue = (allowCreds ?? "").toLowerCase() === "true";
|
|
99
|
-
const originIsStar = allowOrigin === "*";
|
|
100
|
-
const originReflects = allowOrigin === PROBE_ORIGIN;
|
|
101
|
-
|
|
102
|
-
// HIGH requires two independent pieces of evidence that authed data is
|
|
103
|
-
// actually CORS-readable by an attacker site:
|
|
104
|
-
//
|
|
105
|
-
// ARV-312: a 2xx response served real content under the reflected
|
|
106
|
-
// Origin. On a non-2xx (401/403/4xx/5xx) *this* response exposed no
|
|
107
|
-
// authenticated payload. Recording the real status also kills the
|
|
108
|
-
// phantom `response_summary.status: 0` (auth checks don't otherwise
|
|
109
|
-
// thread their response back to the runner).
|
|
110
|
-
//
|
|
111
|
-
// ARV-316: an AMBIENT credential the browser auto-attaches cross-origin
|
|
112
|
-
// — i.e. a cookie (apiKey-in-cookie scheme, or a Set-Cookie observed
|
|
113
|
-
// on the probe). `Allow-Credentials: true` is only exploitable with
|
|
114
|
-
// one: for bearer/header/oauth2 token auth the attacker's page can't
|
|
115
|
-
// set the victim's Authorization header, so a reflected Origin is a
|
|
116
|
-
// hygiene nit, not a data leak (Stripe et al are bearer-auth).
|
|
117
|
-
//
|
|
118
|
-
// Without both, cap at LOW per the m-21 "no evidence → no high" matrix.
|
|
119
|
-
const status = resp.status;
|
|
120
|
-
const isTwoXx = status >= 200 && status < 300;
|
|
121
|
-
const schemes = (h.doc?.components?.securitySchemes ?? {}) as Record<
|
|
122
|
-
string,
|
|
123
|
-
OpenAPIV3.SecuritySchemeObject | OpenAPIV3.ReferenceObject
|
|
124
|
-
>;
|
|
125
|
-
const usesCookieAuth = op.security.some((name) => {
|
|
126
|
-
const s = schemes[name];
|
|
127
|
-
return !!s && !("$ref" in s) && s.type === "apiKey" && s.in === "cookie";
|
|
128
|
-
});
|
|
129
|
-
const setCookie = getHeader(resp.headers, "set-cookie");
|
|
130
|
-
const ambientCredential = usesCookieAuth || setCookie !== undefined;
|
|
131
|
-
const authExposed = isTwoXx && ambientCredential;
|
|
132
|
-
const impactNote = authExposed
|
|
133
|
-
? "any attacker site can read authed cross-origin responses"
|
|
134
|
-
: !isTwoXx
|
|
135
|
-
? `observed on a ${status} response — cross-origin CORS is misconfigured but authed-data exposure is unproven`
|
|
136
|
-
: "no ambient (cookie) credential — bearer/token auth isn't auto-attached cross-origin, so the reflection isn't exploitable as a data leak";
|
|
137
|
-
|
|
138
|
-
if (credsTrue && (originIsStar || originReflects)) {
|
|
139
|
-
const variant = originIsStar ? "wildcard+credentials" : "reflected+credentials";
|
|
140
|
-
const shape = originIsStar
|
|
141
|
-
? "Allow-Origin: * with Allow-Credentials: true"
|
|
142
|
-
: "response reflects arbitrary Origin with Allow-Credentials: true";
|
|
143
|
-
return {
|
|
144
|
-
kind: "fail",
|
|
145
|
-
severity: authExposed ? "high" : "low",
|
|
146
|
-
responseStatus: status,
|
|
147
|
-
message: `CORS misconfiguration: ${shape} — ${impactNote}`,
|
|
148
|
-
evidence: {
|
|
149
|
-
request_origin: PROBE_ORIGIN,
|
|
150
|
-
response_status: status,
|
|
151
|
-
access_control_allow_origin: allowOrigin,
|
|
152
|
-
access_control_allow_credentials: allowCreds,
|
|
153
|
-
ambient_credential: ambientCredential,
|
|
154
|
-
variant,
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
return { kind: "pass" };
|
|
159
|
-
},
|
|
160
|
-
};
|
|
@@ -1,419 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `pagination_invariants` (m-20 ARV-171, ARV-220 added page-style in m-21)
|
|
3
|
-
* — list-endpoint pagination consistency.
|
|
4
|
-
*
|
|
5
|
-
* Two styles are implemented, gated on the `pagination.type` annotation
|
|
6
|
-
* (and on auto-detection from the OpenAPI spec query params):
|
|
7
|
-
*
|
|
8
|
-
* ── Cursor style (Stripe / Notion / Linear) ─────────────────────────
|
|
9
|
-
* 1. GET ?limit=N → page A.
|
|
10
|
-
* 2. Pick the last item's cursor field (default `id`).
|
|
11
|
-
* 3. GET ?<cursor_param>=<last_id>&limit=N → page B.
|
|
12
|
-
* Invariants:
|
|
13
|
-
* • A∩B disjoint by cursor_field — no item appears on both pages.
|
|
14
|
-
* • has_more consistency — if B is empty, has_more must be false.
|
|
15
|
-
* • A.length == limit when page A advertises has_more=true.
|
|
16
|
-
*
|
|
17
|
-
* ── Page-number style (GitHub / GitLab / Atlassian) ────────────────
|
|
18
|
-
* 1. GET ?page=START&per_page=N → page A.
|
|
19
|
-
* 2. GET ?page=START+1&per_page=N → page B.
|
|
20
|
-
* Invariants:
|
|
21
|
-
* • A∩B disjoint by cursor_field — same data-loss signal.
|
|
22
|
-
* • per_page respected — neither page may exceed N items (server bug
|
|
23
|
-
* if e.g. per_page=2 returns 5).
|
|
24
|
-
* has_more is NOT enforced for page-style: most APIs in this family
|
|
25
|
-
* signal end-of-list via Link headers / total_pages rather than a
|
|
26
|
-
* body boolean, so a missing has_more field is normal.
|
|
27
|
-
*
|
|
28
|
-
* Severity matrix (ARV-288, follow-up to ARV-284 pattern):
|
|
29
|
-
*
|
|
30
|
-
* declared severity = 'low' (proof-cap baseline per ARV-250 — stateful
|
|
31
|
-
* two-page probe is single-signal without an out-of-band diff confirmation).
|
|
32
|
-
*
|
|
33
|
-
* Per-finding dispatch via `outcome.severity` (applied in BOTH
|
|
34
|
-
* runCursorStyle and runPageStyle):
|
|
35
|
-
*
|
|
36
|
-
* HIGH — `kinds` contains 'duplicate_items': items overlap on
|
|
37
|
-
* consecutive pages → real data-loss / off-by-one evidence
|
|
38
|
-
* chain → can reach HIGH (evidence_chain proof).
|
|
39
|
-
*
|
|
40
|
-
* MEDIUM — all other `kinds` only (`has_more_inconsistent`,
|
|
41
|
-
* `partial_page_with_has_more`, `per_page_exceeded`):
|
|
42
|
-
* protocol bugs / single-signal contract violations per
|
|
43
|
-
* ARV-250. Escalated above declared baseline because a
|
|
44
|
-
* concrete invariant is broken, but no data-loss evidence.
|
|
45
|
-
*
|
|
46
|
-
* References: ARV-250 (proof-cap ladder), ARV-284 (per-finding pattern).
|
|
47
|
-
*
|
|
48
|
-
* Anti-FP guards (both styles):
|
|
49
|
-
* • Page A empty → skip ("empty collection — no data to paginate").
|
|
50
|
-
* • Cursor field missing on last item (cursor style) → skip naming the
|
|
51
|
-
* field so the operator can fix the yaml.
|
|
52
|
-
* • 4xx/5xx on either page → broken-baseline skip.
|
|
53
|
-
* • Concurrent writes can race the probe; MVP doesn't double-sweep.
|
|
54
|
-
*
|
|
55
|
-
* Types other than `cursor` / `page` (`offset`, `token`): skip with an
|
|
56
|
-
* explicit reason so callers know the yaml block parsed but the check
|
|
57
|
-
* has no logic for them yet.
|
|
58
|
-
*/
|
|
59
|
-
import type { OpenAPIV3 } from "openapi-types";
|
|
60
|
-
import type { CrudStatefulCheck } from "../stateful.ts";
|
|
61
|
-
import type { Severity } from "../../severity/index.ts";
|
|
62
|
-
import type { PaginationConfig } from "../../generator/resources-builder.ts";
|
|
63
|
-
import { fillPathParams } from "./_crud-helpers.ts";
|
|
64
|
-
import { buildUrl as buildUrlBase } from "../../util/url.ts";
|
|
65
|
-
|
|
66
|
-
/** Cursor-style query params we recognise on auto-detect (Stripe,
|
|
67
|
-
* GitHub, Resend, Linear). Lower-cased for case-insensitive match. */
|
|
68
|
-
const CURSOR_QUERY_NAMES = new Set([
|
|
69
|
-
"starting_after",
|
|
70
|
-
"after",
|
|
71
|
-
"cursor",
|
|
72
|
-
"page_token",
|
|
73
|
-
"next_cursor",
|
|
74
|
-
]);
|
|
75
|
-
|
|
76
|
-
const DEFAULT_LIMIT_PARAM_CURSOR = "limit";
|
|
77
|
-
const DEFAULT_LIMIT_PARAM_PAGE = "per_page";
|
|
78
|
-
const DEFAULT_PAGE_PARAM = "page";
|
|
79
|
-
const DEFAULT_START_PAGE = 1;
|
|
80
|
-
const DEFAULT_CURSOR_FIELD = "id";
|
|
81
|
-
const DEFAULT_HAS_MORE_FIELD = "has_more";
|
|
82
|
-
/** Probe page size — small so two requests land fast and (more
|
|
83
|
-
* importantly) so the probe doesn't repeatedly hammer a 1000-item
|
|
84
|
-
* endpoint just to assert "no duplicates on consecutive pages". */
|
|
85
|
-
const DEFAULT_LIMIT = 2;
|
|
86
|
-
const ITEMS_FIELD_FALLBACKS: ReadonlyArray<string> = ["data", "items", "results", "value"];
|
|
87
|
-
|
|
88
|
-
/** Query-param names that signal a page-number-style endpoint when no
|
|
89
|
-
* yaml annotation is present (GitHub/GitLab/Atlassian/Notion). */
|
|
90
|
-
const PAGE_QUERY_NAMES = new Set(["page", "page_number", "pagenumber"]);
|
|
91
|
-
|
|
92
|
-
function safeParse(v: unknown): unknown {
|
|
93
|
-
if (typeof v !== "string") return v;
|
|
94
|
-
try { return JSON.parse(v); } catch { return v; }
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function detectCursorParam(list: { parameters: OpenAPIV3.ParameterObject[] }): string | null {
|
|
98
|
-
for (const p of list.parameters) {
|
|
99
|
-
if (p.in !== "query") continue;
|
|
100
|
-
if (CURSOR_QUERY_NAMES.has(p.name.toLowerCase())) return p.name;
|
|
101
|
-
}
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function detectPageParam(list: { parameters: OpenAPIV3.ParameterObject[] }): string | null {
|
|
106
|
-
for (const p of list.parameters) {
|
|
107
|
-
if (p.in !== "query") continue;
|
|
108
|
-
if (PAGE_QUERY_NAMES.has(p.name.toLowerCase())) return p.name;
|
|
109
|
-
}
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
type CursorResolved = {
|
|
114
|
-
type: "cursor";
|
|
115
|
-
cursorParam: string;
|
|
116
|
-
cursorField: string;
|
|
117
|
-
hasMoreField: string;
|
|
118
|
-
limitParam: string;
|
|
119
|
-
limit: number;
|
|
120
|
-
itemsField: string | null;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
type PageResolved = {
|
|
124
|
-
type: "page";
|
|
125
|
-
pageParam: string;
|
|
126
|
-
startPage: number;
|
|
127
|
-
cursorField: string;
|
|
128
|
-
limitParam: string;
|
|
129
|
-
limit: number;
|
|
130
|
-
itemsField: string | null;
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
type SkipResolved = { type: PaginationConfig["type"]; reason: string };
|
|
134
|
-
|
|
135
|
-
function resolveConfig(
|
|
136
|
-
cfg: PaginationConfig | undefined,
|
|
137
|
-
list: { parameters: OpenAPIV3.ParameterObject[] },
|
|
138
|
-
): CursorResolved | PageResolved | SkipResolved | null {
|
|
139
|
-
// Type resolution: explicit yaml wins; otherwise prefer cursor-style
|
|
140
|
-
// auto-detection (Stripe-shaped APIs are the cleaner default), then
|
|
141
|
-
// fall back to page-style detection.
|
|
142
|
-
let type = cfg?.type;
|
|
143
|
-
if (!type) {
|
|
144
|
-
if (detectCursorParam(list)) type = "cursor";
|
|
145
|
-
else if (detectPageParam(list)) type = "page";
|
|
146
|
-
else type = "cursor"; // no signal — will hit the null-return below
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (type === "cursor") {
|
|
150
|
-
const cursorParam = cfg?.cursorParam ?? detectCursorParam(list);
|
|
151
|
-
if (!cursorParam) return null;
|
|
152
|
-
return {
|
|
153
|
-
type: "cursor",
|
|
154
|
-
cursorParam,
|
|
155
|
-
cursorField: cfg?.cursorField ?? DEFAULT_CURSOR_FIELD,
|
|
156
|
-
hasMoreField: cfg?.hasMoreField ?? DEFAULT_HAS_MORE_FIELD,
|
|
157
|
-
limitParam: cfg?.limitParam ?? DEFAULT_LIMIT_PARAM_CURSOR,
|
|
158
|
-
limit: cfg?.defaultLimit ?? DEFAULT_LIMIT,
|
|
159
|
-
itemsField: cfg?.itemsField ?? null,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (type === "page") {
|
|
164
|
-
return {
|
|
165
|
-
type: "page",
|
|
166
|
-
pageParam: cfg?.pageParam ?? detectPageParam(list) ?? DEFAULT_PAGE_PARAM,
|
|
167
|
-
startPage: cfg?.startPage ?? DEFAULT_START_PAGE,
|
|
168
|
-
cursorField: cfg?.cursorField ?? DEFAULT_CURSOR_FIELD,
|
|
169
|
-
limitParam: cfg?.limitParam ?? DEFAULT_LIMIT_PARAM_PAGE,
|
|
170
|
-
limit: cfg?.defaultLimit ?? DEFAULT_LIMIT,
|
|
171
|
-
itemsField: cfg?.itemsField ?? null,
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return { type, reason: `pagination type "${type}" not implemented yet — only cursor and page are supported` };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Find the array-of-items in a list response. Tries the explicit
|
|
179
|
-
* yaml field first, then a small set of common shapes. Returns null
|
|
180
|
-
* when the body shape is not recognisable. */
|
|
181
|
-
function extractItems(body: unknown, itemsField: string | null): unknown[] | null {
|
|
182
|
-
if (Array.isArray(body)) return body;
|
|
183
|
-
if (!body || typeof body !== "object") return null;
|
|
184
|
-
const obj = body as Record<string, unknown>;
|
|
185
|
-
if (itemsField) {
|
|
186
|
-
const v = obj[itemsField];
|
|
187
|
-
return Array.isArray(v) ? v : null;
|
|
188
|
-
}
|
|
189
|
-
for (const f of ITEMS_FIELD_FALLBACKS) {
|
|
190
|
-
const v = obj[f];
|
|
191
|
-
if (Array.isArray(v)) return v;
|
|
192
|
-
}
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function readHasMore(body: unknown, field: string): boolean | undefined {
|
|
197
|
-
if (!body || typeof body !== "object") return undefined;
|
|
198
|
-
const v = (body as Record<string, unknown>)[field];
|
|
199
|
-
return typeof v === "boolean" ? v : undefined;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function pickCursor(item: unknown, field: string): string | number | null {
|
|
203
|
-
if (item == null || typeof item !== "object") return null;
|
|
204
|
-
const v = (item as Record<string, unknown>)[field];
|
|
205
|
-
if (typeof v === "string" || typeof v === "number") return v;
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function buildUrl(base: string, path: string, pathVars: Record<string, string> | undefined, qs: Record<string, string | number>): string {
|
|
210
|
-
return buildUrlBase(base, fillPathParams(path, pathVars), qs);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export const paginationInvariants: CrudStatefulCheck = {
|
|
214
|
-
id: "pagination_invariants",
|
|
215
|
-
// ARV-288: declared severity is the proof-cap baseline (low) per ARV-250.
|
|
216
|
-
// Per-finding severity is dispatched via outcome.severity in run() below:
|
|
217
|
-
// duplicate_items (data-loss evidence chain) → high; protocol-only kinds → medium.
|
|
218
|
-
severity: "low",
|
|
219
|
-
defaultExpected: "Consecutive cursor pages must be disjoint and has_more must agree with item presence",
|
|
220
|
-
references: [{ id: "ARV-171" }],
|
|
221
|
-
phase: "crud",
|
|
222
|
-
applies(g) {
|
|
223
|
-
return Boolean(g.list);
|
|
224
|
-
},
|
|
225
|
-
async run(g, h) {
|
|
226
|
-
if (h.bootstrapCleanupFailed) {
|
|
227
|
-
return { kind: "skip", reason: "bootstrap-cleanup failed — stateful checks paused" };
|
|
228
|
-
}
|
|
229
|
-
const list = g.list!;
|
|
230
|
-
|
|
231
|
-
const cfg = h.resourceConfigs?.get(g.resource)?.pagination;
|
|
232
|
-
const resolved = resolveConfig(cfg, list);
|
|
233
|
-
if (resolved == null) {
|
|
234
|
-
return { kind: "skip", reason: "no pagination config and no cursor/page query param in spec" };
|
|
235
|
-
}
|
|
236
|
-
if ("reason" in resolved) {
|
|
237
|
-
return { kind: "skip", reason: resolved.reason };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (resolved.type === "cursor") return runCursorStyle(g, h, list, resolved);
|
|
241
|
-
return runPageStyle(g, h, list, resolved);
|
|
242
|
-
},
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
async function runCursorStyle(
|
|
246
|
-
g: Parameters<CrudStatefulCheck["run"]>[0],
|
|
247
|
-
h: Parameters<CrudStatefulCheck["run"]>[1],
|
|
248
|
-
list: NonNullable<typeof g.list>,
|
|
249
|
-
resolved: CursorResolved,
|
|
250
|
-
): ReturnType<CrudStatefulCheck["run"]> {
|
|
251
|
-
const baseHeaders = { Accept: "application/json", ...h.authHeaders };
|
|
252
|
-
const urlA = buildUrl(h.baseUrl, list.path, h.pathVars, { [resolved.limitParam]: resolved.limit });
|
|
253
|
-
|
|
254
|
-
const rA = await h.send({ method: "GET", url: urlA, headers: baseHeaders });
|
|
255
|
-
if (rA.status < 200 || rA.status >= 300) {
|
|
256
|
-
return { kind: "skip", reason: `page A returned ${rA.status} — broken-baseline guard` };
|
|
257
|
-
}
|
|
258
|
-
const bodyA = rA.body_parsed ?? safeParse(rA.body);
|
|
259
|
-
const itemsA = extractItems(bodyA, resolved.itemsField);
|
|
260
|
-
if (itemsA == null) {
|
|
261
|
-
return { kind: "skip", reason: `page A: items array not found (tried items_field="${resolved.itemsField ?? "auto"}" + defaults)` };
|
|
262
|
-
}
|
|
263
|
-
if (itemsA.length === 0) {
|
|
264
|
-
return { kind: "skip", reason: "page A empty — no data to paginate" };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const hasMoreA = readHasMore(bodyA, resolved.hasMoreField);
|
|
268
|
-
const partialPageWithMore = hasMoreA === true && itemsA.length < resolved.limit;
|
|
269
|
-
|
|
270
|
-
const lastCursor = pickCursor(itemsA[itemsA.length - 1], resolved.cursorField);
|
|
271
|
-
if (lastCursor == null) {
|
|
272
|
-
return { kind: "skip", reason: `cursor field "${resolved.cursorField}" missing on last item of page A` };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const urlB = buildUrl(h.baseUrl, list.path, h.pathVars, {
|
|
276
|
-
[resolved.limitParam]: resolved.limit,
|
|
277
|
-
[resolved.cursorParam]: lastCursor,
|
|
278
|
-
});
|
|
279
|
-
const rB = await h.send({ method: "GET", url: urlB, headers: baseHeaders });
|
|
280
|
-
if (rB.status < 200 || rB.status >= 300) {
|
|
281
|
-
return { kind: "skip", reason: `page B returned ${rB.status} — broken-baseline guard` };
|
|
282
|
-
}
|
|
283
|
-
const bodyB = rB.body_parsed ?? safeParse(rB.body);
|
|
284
|
-
const itemsB = extractItems(bodyB, resolved.itemsField);
|
|
285
|
-
if (itemsB == null) {
|
|
286
|
-
return { kind: "skip", reason: "page B: items array shape changed between pages" };
|
|
287
|
-
}
|
|
288
|
-
const hasMoreB = readHasMore(bodyB, resolved.hasMoreField);
|
|
289
|
-
|
|
290
|
-
const duplicates = findDuplicates(itemsA, itemsB, resolved.cursorField);
|
|
291
|
-
|
|
292
|
-
// has_more must be false on the page that ran out of items.
|
|
293
|
-
const inconsistentHasMore = itemsB.length === 0 && hasMoreA === true && hasMoreB !== false;
|
|
294
|
-
|
|
295
|
-
if (duplicates.length === 0 && !inconsistentHasMore && !partialPageWithMore) {
|
|
296
|
-
return { kind: "pass" };
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const kinds: string[] = [];
|
|
300
|
-
if (duplicates.length > 0) kinds.push("duplicate_items");
|
|
301
|
-
if (inconsistentHasMore) kinds.push("has_more_inconsistent");
|
|
302
|
-
if (partialPageWithMore) kinds.push("partial_page_with_has_more");
|
|
303
|
-
|
|
304
|
-
// ARV-288: per-finding severity dispatch.
|
|
305
|
-
const severity: Severity = kinds.includes("duplicate_items") ? "high" : "medium";
|
|
306
|
-
|
|
307
|
-
return {
|
|
308
|
-
kind: "fail",
|
|
309
|
-
severity,
|
|
310
|
-
message:
|
|
311
|
-
duplicates.length > 0
|
|
312
|
-
? `Pagination on ${g.resource}: ${duplicates.length} item id(s) appear on both pages (${duplicates.slice(0, 3).join(", ")}${duplicates.length > 3 ? ", …" : ""})`
|
|
313
|
-
: inconsistentHasMore
|
|
314
|
-
? `Pagination on ${g.resource}: page A advertised has_more=true but page B is empty with has_more!=false`
|
|
315
|
-
: `Pagination on ${g.resource}: page A has ${itemsA.length}/${resolved.limit} items yet has_more=true (partial page with more)`,
|
|
316
|
-
evidence: {
|
|
317
|
-
resource: g.resource,
|
|
318
|
-
kind: kinds.join("+"),
|
|
319
|
-
style: "cursor",
|
|
320
|
-
cursor_param: resolved.cursorParam,
|
|
321
|
-
cursor_field: resolved.cursorField,
|
|
322
|
-
page_a_size: itemsA.length,
|
|
323
|
-
page_b_size: itemsB.length,
|
|
324
|
-
has_more_a: hasMoreA,
|
|
325
|
-
has_more_b: hasMoreB,
|
|
326
|
-
duplicates,
|
|
327
|
-
},
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
async function runPageStyle(
|
|
332
|
-
g: Parameters<CrudStatefulCheck["run"]>[0],
|
|
333
|
-
h: Parameters<CrudStatefulCheck["run"]>[1],
|
|
334
|
-
list: NonNullable<typeof g.list>,
|
|
335
|
-
resolved: PageResolved,
|
|
336
|
-
): ReturnType<CrudStatefulCheck["run"]> {
|
|
337
|
-
const baseHeaders = { Accept: "application/json", ...h.authHeaders };
|
|
338
|
-
const urlA = buildUrl(h.baseUrl, list.path, h.pathVars, {
|
|
339
|
-
[resolved.pageParam]: resolved.startPage,
|
|
340
|
-
[resolved.limitParam]: resolved.limit,
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
const rA = await h.send({ method: "GET", url: urlA, headers: baseHeaders });
|
|
344
|
-
if (rA.status < 200 || rA.status >= 300) {
|
|
345
|
-
return { kind: "skip", reason: `page A returned ${rA.status} — broken-baseline guard` };
|
|
346
|
-
}
|
|
347
|
-
const bodyA = rA.body_parsed ?? safeParse(rA.body);
|
|
348
|
-
const itemsA = extractItems(bodyA, resolved.itemsField);
|
|
349
|
-
if (itemsA == null) {
|
|
350
|
-
return { kind: "skip", reason: `page A: items array not found (tried items_field="${resolved.itemsField ?? "auto"}" + defaults)` };
|
|
351
|
-
}
|
|
352
|
-
if (itemsA.length === 0) {
|
|
353
|
-
return { kind: "skip", reason: "page A empty — no data to paginate" };
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const urlB = buildUrl(h.baseUrl, list.path, h.pathVars, {
|
|
357
|
-
[resolved.pageParam]: resolved.startPage + 1,
|
|
358
|
-
[resolved.limitParam]: resolved.limit,
|
|
359
|
-
});
|
|
360
|
-
const rB = await h.send({ method: "GET", url: urlB, headers: baseHeaders });
|
|
361
|
-
if (rB.status < 200 || rB.status >= 300) {
|
|
362
|
-
return { kind: "skip", reason: `page B returned ${rB.status} — broken-baseline guard` };
|
|
363
|
-
}
|
|
364
|
-
const bodyB = rB.body_parsed ?? safeParse(rB.body);
|
|
365
|
-
const itemsB = extractItems(bodyB, resolved.itemsField);
|
|
366
|
-
if (itemsB == null) {
|
|
367
|
-
return { kind: "skip", reason: "page B: items array shape changed between pages" };
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const duplicates = findDuplicates(itemsA, itemsB, resolved.cursorField);
|
|
371
|
-
const perPageBreach = itemsA.length > resolved.limit || itemsB.length > resolved.limit;
|
|
372
|
-
|
|
373
|
-
if (duplicates.length === 0 && !perPageBreach) {
|
|
374
|
-
return { kind: "pass" };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const kinds: string[] = [];
|
|
378
|
-
if (duplicates.length > 0) kinds.push("duplicate_items");
|
|
379
|
-
if (perPageBreach) kinds.push("per_page_exceeded");
|
|
380
|
-
|
|
381
|
-
// ARV-288: per-finding severity dispatch.
|
|
382
|
-
const severity: Severity = kinds.includes("duplicate_items") ? "high" : "medium";
|
|
383
|
-
|
|
384
|
-
return {
|
|
385
|
-
kind: "fail",
|
|
386
|
-
severity,
|
|
387
|
-
message:
|
|
388
|
-
duplicates.length > 0
|
|
389
|
-
? `Pagination on ${g.resource}: ${duplicates.length} item id(s) appear on pages ${resolved.startPage} and ${resolved.startPage + 1} (${duplicates.slice(0, 3).join(", ")}${duplicates.length > 3 ? ", …" : ""})`
|
|
390
|
-
: `Pagination on ${g.resource}: per_page=${resolved.limit} not respected — server returned ${itemsA.length}/${itemsB.length} items`,
|
|
391
|
-
evidence: {
|
|
392
|
-
resource: g.resource,
|
|
393
|
-
kind: kinds.join("+"),
|
|
394
|
-
style: "page",
|
|
395
|
-
page_param: resolved.pageParam,
|
|
396
|
-
per_page: resolved.limit,
|
|
397
|
-
page_a_number: resolved.startPage,
|
|
398
|
-
page_b_number: resolved.startPage + 1,
|
|
399
|
-
page_a_size: itemsA.length,
|
|
400
|
-
page_b_size: itemsB.length,
|
|
401
|
-
cursor_field: resolved.cursorField,
|
|
402
|
-
duplicates,
|
|
403
|
-
},
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function findDuplicates(a: unknown[], b: unknown[], cursorField: string): string[] {
|
|
408
|
-
const ids = new Set<string>();
|
|
409
|
-
for (const it of a) {
|
|
410
|
-
const c = pickCursor(it, cursorField);
|
|
411
|
-
if (c != null) ids.add(String(c));
|
|
412
|
-
}
|
|
413
|
-
const dups: string[] = [];
|
|
414
|
-
for (const it of b) {
|
|
415
|
-
const c = pickCursor(it, cursorField);
|
|
416
|
-
if (c != null && ids.has(String(c))) dups.push(String(c));
|
|
417
|
-
}
|
|
418
|
-
return dups;
|
|
419
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
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
|
-
|
|
14
|
-
export const positiveDataAcceptance: Check = {
|
|
15
|
-
id: "positive_data_acceptance",
|
|
16
|
-
severity: "medium",
|
|
17
|
-
defaultExpected: "Server must accept a generated-as-valid body (2xx)",
|
|
18
|
-
references: [{ id: "OWASP-API-08" }],
|
|
19
|
-
applies: (op) => Boolean(op.requestBodySchema),
|
|
20
|
-
run({ response }) {
|
|
21
|
-
const s = response.status;
|
|
22
|
-
if (s >= 200 && s < 300) return { kind: "pass" };
|
|
23
|
-
// Auth / not-found / conflict / server-error → not a schema-rejection signal.
|
|
24
|
-
if (s === 401 || s === 403 || s === 404 || s === 409) return { kind: "pass" };
|
|
25
|
-
if (s >= 500) return { kind: "skip", reason: "5xx covered by not_a_server_error" };
|
|
26
|
-
if (s !== 400 && s !== 422) return { kind: "skip", reason: `status ${s} not a schema-validation reject` };
|
|
27
|
-
return {
|
|
28
|
-
kind: "fail",
|
|
29
|
-
message: `Server rejected a generated-as-valid body with ${s} — generator or spec disagrees with the implementation`,
|
|
30
|
-
evidence: { status: s },
|
|
31
|
-
};
|
|
32
|
-
},
|
|
33
|
-
};
|
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
};
|