@kirrosh/zond 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +758 -3
- package/README.md +78 -15
- package/package.json +17 -10
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +55 -6
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +192 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +180 -8
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -47
- package/src/cli/commands/init/agents-md.ts +61 -0
- package/src/cli/commands/init/bootstrap.ts +108 -0
- package/src/cli/commands/init/index.ts +244 -0
- package/src/cli/commands/init/skills.ts +98 -0
- package/src/cli/commands/init/templates/agents.md +77 -0
- package/src/cli/commands/init/templates/markdown.d.ts +4 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +651 -0
- package/src/cli/commands/init/templates/zond-config.yml +14 -0
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +911 -33
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +74 -0
- package/src/cli/index.ts +36 -607
- package/src/cli/json-envelope.ts +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +218 -0
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +8 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +61 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +51 -0
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +161 -12
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +53 -15
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +490 -33
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +55 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +114 -15
- package/src/core/generator/suite-generator.ts +484 -77
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +132 -5
- package/src/core/parser/types.ts +29 -2
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +108 -13
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -0
- package/src/core/probe/method-probe.ts +164 -0
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +691 -0
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +284 -0
- package/src/core/reporter/console.ts +69 -4
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +15 -2
- package/src/core/reporter/junit.ts +27 -12
- package/src/core/reporter/ndjson.ts +37 -0
- package/src/core/reporter/types.ts +3 -0
- package/src/core/runner/assertions.ts +62 -2
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +391 -52
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +115 -7
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +203 -0
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +419 -17
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +94 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +128 -0
- package/src/db/queries/runs.ts +235 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +72 -802
- package/src/db/schema.ts +179 -48
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init.ts +0 -57
- package/src/cli/commands/serve.ts +0 -81
- package/src/cli/commands/sync.ts +0 -269
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -21
- package/src/core/parser/index.ts +0 -21
- package/src/core/runner/execute-run.ts +0 -132
- package/src/core/runner/index.ts +0 -12
- package/src/core/sync/spec-differ.ts +0 -38
- package/src/web/data/collection-state.ts +0 -362
- package/src/web/routes/api.ts +0 -314
- package/src/web/routes/dashboard.ts +0 -350
- package/src/web/routes/runs.ts +0 -64
- package/src/web/schemas.ts +0 -121
- package/src/web/server.ts +0 -134
- package/src/web/static/htmx.min.cjs +0 -1
- package/src/web/static/style.css +0 -1148
- package/src/web/views/endpoints-tab.ts +0 -174
- package/src/web/views/explorer-tab.ts +0 -402
- package/src/web/views/health-strip.ts +0 -92
- package/src/web/views/layout.ts +0 -48
- package/src/web/views/results.ts +0 -210
- package/src/web/views/runs-tab.ts +0 -126
- package/src/web/views/suites-tab.ts +0 -181
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline that turns an OpenAPI spec into `CheckFinding`s by:
|
|
3
|
+
* 1. enumerating operations,
|
|
4
|
+
* 2. for each op, generating one case per *requested* probe kind
|
|
5
|
+
* (positive / missing_required_header / unsupported_method),
|
|
6
|
+
* 3. sending each request,
|
|
7
|
+
* 4. running every active check whose `caseKinds` includes the kind.
|
|
8
|
+
*
|
|
9
|
+
* The runner only generates kinds an active check actually needs — so
|
|
10
|
+
* a `--check not_a_server_error` run never sends the extra probe
|
|
11
|
+
* requests; a `--check unsupported_method` run sends only the method
|
|
12
|
+
* probe, etc.
|
|
13
|
+
*/
|
|
14
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
15
|
+
|
|
16
|
+
import { extractEndpoints, readOpenApiSpec } from "../generator/index.ts";
|
|
17
|
+
import { detectCrudGroups } from "../generator/suite-generator.ts";
|
|
18
|
+
import type { EndpointInfo } from "../generator/types.ts";
|
|
19
|
+
import { generateFromSchema } from "../generator/data-factory.ts";
|
|
20
|
+
import {
|
|
21
|
+
enumerateBoundaryCases,
|
|
22
|
+
enumerateParamBoundaryCases,
|
|
23
|
+
type ParamCoverageCase,
|
|
24
|
+
} from "../generator/coverage-phase.ts";
|
|
25
|
+
import { executeRequest } from "../runner/http-client.ts";
|
|
26
|
+
import { reserveRequest, MAX_REQUESTS_SKIP_REASON, type RequestBudget } from "../runner/executor.ts";
|
|
27
|
+
import { createSchemaValidator, type SchemaValidator } from "../runner/schema-validator.ts";
|
|
28
|
+
import type { HttpRequest, HttpResponse } from "../runner/types.ts";
|
|
29
|
+
import {
|
|
30
|
+
ALL_METHODS,
|
|
31
|
+
bucketEndpointsByPath,
|
|
32
|
+
pathWithMethodPlaceholders,
|
|
33
|
+
} from "../probe/method-shared.ts";
|
|
34
|
+
|
|
35
|
+
import "./checks/index.ts"; // side-effect: register builtins
|
|
36
|
+
import { selectChecks, type SelectionResult } from "./registry.ts";
|
|
37
|
+
import { listStatefulChecks, makeHarness } from "./stateful.ts";
|
|
38
|
+
import { caseMatchesMode, filterChecksByMode, type Mode } from "./mode.ts";
|
|
39
|
+
import { buildNegativeBody } from "./checks/_negative_mutator.ts";
|
|
40
|
+
import { nowIso, type NdjsonEvent } from "../reporter/ndjson.ts";
|
|
41
|
+
import { runPool } from "../runner/async-pool.ts";
|
|
42
|
+
import type { RateLimiter } from "../runner/rate-limiter.ts";
|
|
43
|
+
import { recommendForCheck } from "./recommended-action.ts";
|
|
44
|
+
import {
|
|
45
|
+
emptySummary,
|
|
46
|
+
type CaseKind,
|
|
47
|
+
type Check,
|
|
48
|
+
type CheckCase,
|
|
49
|
+
type CheckFinding,
|
|
50
|
+
type CheckRunData,
|
|
51
|
+
type CheckRunSummary,
|
|
52
|
+
} from "./types.ts";
|
|
53
|
+
import { categoryFor } from "../severity/category.ts";
|
|
54
|
+
|
|
55
|
+
export interface RunChecksOptions {
|
|
56
|
+
specPath: string;
|
|
57
|
+
baseUrl: string;
|
|
58
|
+
include?: string[];
|
|
59
|
+
exclude?: string[];
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
/** Limit the operation set — used by `--include`/`--exclude` regex
|
|
62
|
+
* filtering in ARV-9. ARV-1 only exposes the hook. */
|
|
63
|
+
operationFilter?: (op: EndpointInfo) => boolean;
|
|
64
|
+
/** ARV-3 — auth headers fed to stateful security checks. CLI lifts
|
|
65
|
+
* these from `--auth-header` flags and/or the api's `.env.yaml`. */
|
|
66
|
+
authHeaders?: Record<string, string>;
|
|
67
|
+
/** ARV-3 AC #6 — when true, security checks return skip with a
|
|
68
|
+
* warning. The CLI surfaces this as `--bootstrap-cleanup-failed`. */
|
|
69
|
+
bootstrapCleanupFailed?: boolean;
|
|
70
|
+
/** ARV-6 — `examples` (current default: one positive + the
|
|
71
|
+
* single-site negative mutator) vs `coverage` (deterministic
|
|
72
|
+
* boundary-value enumeration over the body schema) vs `all` (both).
|
|
73
|
+
* Coverage cases carry `meta.boundary` and `meta.phase = "coverage"`
|
|
74
|
+
* for the SARIF reporter and reproducer hints. */
|
|
75
|
+
phase?: "examples" | "coverage" | "all";
|
|
76
|
+
/** ARV-6 AC #5 — gate the NUL byte (\x00) in string boundaries.
|
|
77
|
+
* Off by default because some HTTP/JSON stacks panic on it. */
|
|
78
|
+
allowX00?: boolean;
|
|
79
|
+
/** ARV-7 — `positive` (contract verification only), `negative`
|
|
80
|
+
* (malicious input only), `all` (default — both). Drops both checks
|
|
81
|
+
* and cases that don't belong to the requested mode. */
|
|
82
|
+
mode?: Mode;
|
|
83
|
+
/** ARV-10 — synchronous streaming hook. Fires per
|
|
84
|
+
* `check_start` / `check_result` / `finding` / `summary` event so the
|
|
85
|
+
* NDJSON reporter can flush each line as it happens (instead of
|
|
86
|
+
* buffering until the run finishes). Must not throw — exceptions are
|
|
87
|
+
* the caller's responsibility (the runner doesn't catch). */
|
|
88
|
+
onEvent?: (event: NdjsonEvent) => void;
|
|
89
|
+
/** ARV-8 — bounded async-pool concurrency at the *operation* level.
|
|
90
|
+
* `1` (default) = sequential, identical to the pre-ARV-8 behaviour.
|
|
91
|
+
* Cases within an operation always run sequentially regardless of
|
|
92
|
+
* this — share state (e.g. CRUD chains) lives at op-level, not
|
|
93
|
+
* case-level, so case-parallelism would corrupt it. */
|
|
94
|
+
workers?: number;
|
|
95
|
+
/** ARV-8 — gate every outbound HTTP request through the limiter so
|
|
96
|
+
* bursts of parallel workers respect a global RPS budget (also
|
|
97
|
+
* reacts to RateLimit-* headers via `note()`). */
|
|
98
|
+
rateLimiter?: RateLimiter;
|
|
99
|
+
/** ARV-179: opt-in strict-405 semantics for `unsupported_method`.
|
|
100
|
+
* Off by default — see `CheckRuntimeOptions.strict405` for rationale. */
|
|
101
|
+
strict405?: boolean;
|
|
102
|
+
/** ARV-181: opt-in strict-401 semantics for `ignored_auth`. Off by
|
|
103
|
+
* default — see `CheckRuntimeOptions.strict401` for rationale. */
|
|
104
|
+
strict401?: boolean;
|
|
105
|
+
/** ARV-169 (m-20): per-resource overrides for stateful checks
|
|
106
|
+
* (cross-call drift today; idempotency/pagination/lifecycle next).
|
|
107
|
+
* CLI loads them from `.api-resources.yaml` + `.api-resources.local.yaml`
|
|
108
|
+
* and hands them in; tests pass a literal Map. Optional — undefined
|
|
109
|
+
* ⇒ each probe uses its built-in defaults. */
|
|
110
|
+
resourceConfigs?: Map<string, {
|
|
111
|
+
readbackDiff?: import("../generator/resources-builder.ts").ReadbackDiffConfig;
|
|
112
|
+
idempotency?: import("../generator/resources-builder.ts").IdempotencyConfig;
|
|
113
|
+
pagination?: import("../generator/resources-builder.ts").PaginationConfig;
|
|
114
|
+
lifecycle?: import("../generator/resources-builder.ts").LifecycleConfig;
|
|
115
|
+
}>;
|
|
116
|
+
/** ARV-141: substitute real fixture values into path-param placeholders so
|
|
117
|
+
* the deterministic synthetic 404 (`/issues/x`) becomes a real-id 200/422
|
|
118
|
+
* whenever `.env.yaml` actually has a fixture. This makes `checks run`
|
|
119
|
+
* reactive to fixture-pack growth — without it, two runs against the same
|
|
120
|
+
* spec emit pixel-identical findings/skip counts regardless of how many
|
|
121
|
+
* vars are filled. Keyed by path-param name (e.g. `issue_id`); falls back
|
|
122
|
+
* to the legacy schema-driven placeholder when the name isn't in the map. */
|
|
123
|
+
pathVars?: Record<string, string>;
|
|
124
|
+
/** ARV-227: hard cap on outbound HTTP requests for the entire run.
|
|
125
|
+
* Once `used >= limit`, every subsequent case short-circuits and the
|
|
126
|
+
* summary surfaces the cap via `summary.skipped_outcomes
|
|
127
|
+
* ["max-requests-cap-reached"]`. Stateful-phase sends count toward
|
|
128
|
+
* the same budget so a cap of 100 means 100 requests total across
|
|
129
|
+
* per-response + stateful, not per-phase. Undefined ⇒ uncapped. */
|
|
130
|
+
maxRequests?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface RunChecksResult {
|
|
134
|
+
data: CheckRunData;
|
|
135
|
+
selection: SelectionResult;
|
|
136
|
+
/** HIGH/CRITICAL findings count — drives the exit code. */
|
|
137
|
+
high_or_critical: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function placeholderForParam(p: OpenAPIV3.ParameterObject): string {
|
|
141
|
+
const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
|
|
142
|
+
if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
|
|
143
|
+
if (schema?.type === "integer" || schema?.type === "number") return "1";
|
|
144
|
+
return "x";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function fillPathParams(
|
|
148
|
+
path: string,
|
|
149
|
+
op: EndpointInfo,
|
|
150
|
+
pathVars?: Record<string, string>,
|
|
151
|
+
): string {
|
|
152
|
+
return path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
153
|
+
// ARV-141: real fixture wins over schema-derived placeholder.
|
|
154
|
+
const real = pathVars?.[name];
|
|
155
|
+
if (typeof real === "string" && real.length > 0) {
|
|
156
|
+
return encodeURIComponent(real);
|
|
157
|
+
}
|
|
158
|
+
const match = op.parameters.find(
|
|
159
|
+
(p) => (p as OpenAPIV3.ParameterObject).in === "path"
|
|
160
|
+
&& (p as OpenAPIV3.ParameterObject).name === name,
|
|
161
|
+
);
|
|
162
|
+
return match
|
|
163
|
+
? encodeURIComponent(placeholderForParam(match as OpenAPIV3.ParameterObject))
|
|
164
|
+
: "1";
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function requiredHeaders(op: EndpointInfo): OpenAPIV3.ParameterObject[] {
|
|
169
|
+
return op.parameters.filter(
|
|
170
|
+
(p) => (p as OpenAPIV3.ParameterObject).in === "header"
|
|
171
|
+
&& (p as OpenAPIV3.ParameterObject).required === true,
|
|
172
|
+
) as OpenAPIV3.ParameterObject[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildBaseHeaders(op: EndpointInfo, opts: { withRequired: boolean }): Record<string, string> {
|
|
176
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
177
|
+
if (opts.withRequired) {
|
|
178
|
+
for (const h of requiredHeaders(op)) {
|
|
179
|
+
headers[h.name] = "x";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (op.requestBodySchema && op.method.toUpperCase() !== "GET" && op.method.toUpperCase() !== "DELETE") {
|
|
183
|
+
headers["Content-Type"] = op.requestBodyContentType ?? "application/json";
|
|
184
|
+
}
|
|
185
|
+
return headers;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildBody(op: EndpointInfo): string | undefined {
|
|
189
|
+
if (!op.requestBodySchema) return undefined;
|
|
190
|
+
const m = op.method.toUpperCase();
|
|
191
|
+
if (m === "GET" || m === "DELETE") return undefined;
|
|
192
|
+
return JSON.stringify(generateFromSchema(op.requestBodySchema));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface BuiltCase {
|
|
196
|
+
req: HttpRequest;
|
|
197
|
+
case: CheckCase;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildPositive(op: EndpointInfo, baseUrl: string, pathVars?: Record<string, string>): BuiltCase {
|
|
201
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, pathVars)}`;
|
|
202
|
+
const headers = buildBaseHeaders(op, { withRequired: true });
|
|
203
|
+
const body = buildBody(op);
|
|
204
|
+
const req: HttpRequest = { method: op.method.toUpperCase(), url, headers, body };
|
|
205
|
+
const c: CheckCase = {
|
|
206
|
+
operation: op,
|
|
207
|
+
request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
|
208
|
+
mode: "positive",
|
|
209
|
+
kind: "positive",
|
|
210
|
+
};
|
|
211
|
+
return { req, case: c };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** ARV-184: emit one BuiltCase per required header — drop that header
|
|
215
|
+
* in isolation so `missing_required_header` can identify *which* one
|
|
216
|
+
* the server fails to enforce. Pre-fix this emitted just the first
|
|
217
|
+
* required header (`required[0]`), which on Stripe-style specs with
|
|
218
|
+
* multiple per-op headers (Stripe-Version, Stripe-Account, ...) gave
|
|
219
|
+
* ≤1 finding per op vs schemathesis V4 ~42 in the same overlap. */
|
|
220
|
+
function buildMissingHeader(op: EndpointInfo, baseUrl: string, pathVars?: Record<string, string>): BuiltCase[] {
|
|
221
|
+
const required = requiredHeaders(op);
|
|
222
|
+
if (required.length === 0) return [];
|
|
223
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, pathVars)}`;
|
|
224
|
+
const body = buildBody(op);
|
|
225
|
+
const method = op.method.toUpperCase();
|
|
226
|
+
return required.map((header) => {
|
|
227
|
+
const headers = buildBaseHeaders(op, { withRequired: true });
|
|
228
|
+
delete headers[header.name];
|
|
229
|
+
const req: HttpRequest = { method, url, headers, body };
|
|
230
|
+
return {
|
|
231
|
+
req,
|
|
232
|
+
case: {
|
|
233
|
+
operation: op,
|
|
234
|
+
request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
|
235
|
+
mode: "negative" as const,
|
|
236
|
+
kind: "missing_required_header" as const,
|
|
237
|
+
meta: { dropped_header: header.name },
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** ARV-6: emit one BuiltCase per (field × boundary) over the body schema.
|
|
244
|
+
* Valid boundaries ride as `kind: "positive"` so positive_data_acceptance
|
|
245
|
+
* evaluates them; invalid boundaries ride as `kind: "negative_data"` so
|
|
246
|
+
* negative_data_rejection evaluates them. Both carry `meta.boundary` and
|
|
247
|
+
* `meta.phase: "coverage"` so the finding surfaces *which* boundary the
|
|
248
|
+
* server tripped on. */
|
|
249
|
+
function buildCoverageCases(
|
|
250
|
+
op: EndpointInfo,
|
|
251
|
+
baseUrl: string,
|
|
252
|
+
opts: { allowX00?: boolean; pathVars?: Record<string, string> },
|
|
253
|
+
): BuiltCase[] {
|
|
254
|
+
if (!op.requestBodySchema) return [];
|
|
255
|
+
const m = op.method.toUpperCase();
|
|
256
|
+
if (m === "GET" || m === "DELETE") return [];
|
|
257
|
+
const cases = enumerateBoundaryCases(op.requestBodySchema, { allowX00: opts.allowX00 });
|
|
258
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, opts.pathVars)}`;
|
|
259
|
+
const headers = buildBaseHeaders(op, { withRequired: true });
|
|
260
|
+
const out: BuiltCase[] = [];
|
|
261
|
+
for (const cc of cases) {
|
|
262
|
+
const body = JSON.stringify(cc.body);
|
|
263
|
+
const req: HttpRequest = { method: m, url, headers, body };
|
|
264
|
+
const kind: CaseKind = cc.valid ? "positive" : "negative_data";
|
|
265
|
+
out.push({
|
|
266
|
+
req,
|
|
267
|
+
case: {
|
|
268
|
+
operation: op,
|
|
269
|
+
request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
|
270
|
+
mode: cc.valid ? "positive" : "negative",
|
|
271
|
+
kind,
|
|
272
|
+
meta: {
|
|
273
|
+
phase: "coverage",
|
|
274
|
+
boundary: cc.boundary,
|
|
275
|
+
field_path: cc.field_path,
|
|
276
|
+
mutation: "boundary",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** ARV-180: build a URL whose path/query parameters reflect a coverage
|
|
285
|
+
* mutation. The positive baseline fills every param with a valid
|
|
286
|
+
* shape; this helper takes that baseline and swaps the named param
|
|
287
|
+
* with the mutation value (or drops it, for `drop-required-query`).
|
|
288
|
+
* Path mutations rewrite the placeholder for the named param only —
|
|
289
|
+
* all other path-vars keep their valid baseline values, so the URL
|
|
290
|
+
* still reaches the routing layer. */
|
|
291
|
+
function buildParamMutatedUrl(
|
|
292
|
+
baseUrl: string,
|
|
293
|
+
op: EndpointInfo,
|
|
294
|
+
mut: ParamCoverageCase,
|
|
295
|
+
pathVars: Record<string, string> | undefined,
|
|
296
|
+
): string {
|
|
297
|
+
// Start with the valid baseline path (placeholders filled).
|
|
298
|
+
let pathStr = op.path;
|
|
299
|
+
if (mut.location === "path") {
|
|
300
|
+
// Rewrite only the targeted placeholder; everything else gets the
|
|
301
|
+
// valid baseline (path-vars > schema-derived placeholder).
|
|
302
|
+
pathStr = op.path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
303
|
+
if (name === mut.paramName) return encodeURIComponent(String(mut.value));
|
|
304
|
+
const real = pathVars?.[name];
|
|
305
|
+
if (typeof real === "string" && real.length > 0) return encodeURIComponent(real);
|
|
306
|
+
const match = op.parameters.find(
|
|
307
|
+
(p) => (p as OpenAPIV3.ParameterObject).in === "path"
|
|
308
|
+
&& (p as OpenAPIV3.ParameterObject).name === name,
|
|
309
|
+
);
|
|
310
|
+
return match
|
|
311
|
+
? encodeURIComponent(placeholderForParam(match as OpenAPIV3.ParameterObject))
|
|
312
|
+
: "1";
|
|
313
|
+
});
|
|
314
|
+
} else {
|
|
315
|
+
pathStr = fillPathParams(op.path, op, pathVars);
|
|
316
|
+
}
|
|
317
|
+
let url = `${baseUrl.replace(/\/+$/, "")}${pathStr}`;
|
|
318
|
+
if (mut.location === "query") {
|
|
319
|
+
const qp = new URLSearchParams();
|
|
320
|
+
// Seed required query params with valid baseline values so the
|
|
321
|
+
// mutation is single-site.
|
|
322
|
+
for (const p of op.parameters) {
|
|
323
|
+
const pp = p as OpenAPIV3.ParameterObject;
|
|
324
|
+
if (pp.in !== "query") continue;
|
|
325
|
+
if (pp.name === mut.paramName) {
|
|
326
|
+
if (mut.scenario === "drop-required-query") continue; // drop
|
|
327
|
+
qp.append(pp.name, String(mut.value));
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (pp.required === true) {
|
|
331
|
+
qp.append(pp.name, placeholderForParam(pp));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const qs = qp.toString();
|
|
335
|
+
if (qs.length > 0) url += `?${qs}`;
|
|
336
|
+
}
|
|
337
|
+
return url;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** ARV-180: emit one BuiltCase per (param × scenario) for the
|
|
341
|
+
* operation. All cases ride as `kind: "negative_data"` so
|
|
342
|
+
* `negative_data_rejection` evaluates "did the server reject?", and
|
|
343
|
+
* `status_code_conformance` (now declares `negative_data` in its
|
|
344
|
+
* caseKinds) evaluates "is the resulting status code documented?".
|
|
345
|
+
* This is the cheap-fix gap for `status_code_conformance` on
|
|
346
|
+
* GET-heavy APIs where the body-coverage walker emits zero cases. */
|
|
347
|
+
function buildParamCoverageCases(
|
|
348
|
+
op: EndpointInfo,
|
|
349
|
+
baseUrl: string,
|
|
350
|
+
opts: { allowX00?: boolean; pathVars?: Record<string, string> },
|
|
351
|
+
): BuiltCase[] {
|
|
352
|
+
const params = op.parameters as OpenAPIV3.ParameterObject[];
|
|
353
|
+
const mutations = enumerateParamBoundaryCases(params, { allowX00: opts.allowX00 });
|
|
354
|
+
if (mutations.length === 0) return [];
|
|
355
|
+
const m = op.method.toUpperCase();
|
|
356
|
+
const headers = buildBaseHeaders(op, { withRequired: true });
|
|
357
|
+
const body = buildBody(op);
|
|
358
|
+
const out: BuiltCase[] = [];
|
|
359
|
+
for (const mut of mutations) {
|
|
360
|
+
const url = buildParamMutatedUrl(baseUrl, op, mut, opts.pathVars);
|
|
361
|
+
const req: HttpRequest = { method: m, url, headers, body };
|
|
362
|
+
out.push({
|
|
363
|
+
req,
|
|
364
|
+
case: {
|
|
365
|
+
operation: op,
|
|
366
|
+
request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
|
367
|
+
mode: "negative",
|
|
368
|
+
kind: "negative_data",
|
|
369
|
+
meta: {
|
|
370
|
+
phase: "coverage",
|
|
371
|
+
param_scenario: mut.scenario,
|
|
372
|
+
param_name: mut.paramName,
|
|
373
|
+
param_location: mut.location,
|
|
374
|
+
mutation: "param-boundary",
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function buildNegativeData(op: EndpointInfo, baseUrl: string, pathVars?: Record<string, string>): BuiltCase | null {
|
|
383
|
+
if (!op.requestBodySchema) return null;
|
|
384
|
+
const m = op.method.toUpperCase();
|
|
385
|
+
if (m === "GET" || m === "DELETE") return null;
|
|
386
|
+
const mutated = buildNegativeBody(op.requestBodySchema);
|
|
387
|
+
if (!mutated) return null;
|
|
388
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, pathVars)}`;
|
|
389
|
+
const headers = buildBaseHeaders(op, { withRequired: true });
|
|
390
|
+
const body = JSON.stringify(mutated.body);
|
|
391
|
+
const req: HttpRequest = { method: m, url, headers, body };
|
|
392
|
+
const c: CheckCase = {
|
|
393
|
+
operation: op,
|
|
394
|
+
request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
|
395
|
+
mode: "negative",
|
|
396
|
+
kind: "negative_data",
|
|
397
|
+
meta: { ...mutated.meta },
|
|
398
|
+
};
|
|
399
|
+
return { req, case: c };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** For `unsupported_method` we send every method that isn't declared on
|
|
403
|
+
* the *path bucket*. ARV-179: pre-fix this emitted just `missing[0]`,
|
|
404
|
+
* which produced ≈1 finding per path on real APIs (vs schemathesis's
|
|
405
|
+
* per-method enumeration that finds 100+ on the same target). The
|
|
406
|
+
* check itself coalesces results per-(path, undeclared-method) pair,
|
|
407
|
+
* so a path with 4 missing methods yields up to 4 findings. The
|
|
408
|
+
* per-path "one owner" rule still applies — only the owner-op emits
|
|
409
|
+
* the bucket — so we don't double-count on multi-method paths. */
|
|
410
|
+
function buildUnsupportedMethod(
|
|
411
|
+
op: EndpointInfo,
|
|
412
|
+
declaredOnPath: Set<string>,
|
|
413
|
+
baseUrl: string,
|
|
414
|
+
): BuiltCase[] {
|
|
415
|
+
const declaredUpper = new Set(Array.from(declaredOnPath, (m) => m.toUpperCase()));
|
|
416
|
+
const missing = ALL_METHODS.filter((m) => !declaredUpper.has(m));
|
|
417
|
+
if (missing.length === 0) return [];
|
|
418
|
+
const concretePath = pathWithMethodPlaceholders(op.path, op.parameters);
|
|
419
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${concretePath}`;
|
|
420
|
+
return missing.map((method) => {
|
|
421
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
422
|
+
let body: string | undefined;
|
|
423
|
+
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
|
424
|
+
headers["Content-Type"] = "application/json";
|
|
425
|
+
body = "{}";
|
|
426
|
+
}
|
|
427
|
+
const req: HttpRequest = { method, url, headers, body };
|
|
428
|
+
const c: CheckCase = {
|
|
429
|
+
operation: op,
|
|
430
|
+
request: { method, url, headers, body },
|
|
431
|
+
mode: "negative",
|
|
432
|
+
kind: "unsupported_method",
|
|
433
|
+
meta: { undeclared_method: method },
|
|
434
|
+
};
|
|
435
|
+
return { req, case: c };
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function checkKinds(c: Check): CaseKind[] {
|
|
440
|
+
return c.caseKinds ?? ["positive"];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** ARV-61 (feedback round-01 / F1): inject auth headers into a response-phase
|
|
444
|
+
* case so depth-checks pierce the auth-wall on real APIs. Case-specific
|
|
445
|
+
* headers win (case-insensitive). `missing_required_header` deliberately
|
|
446
|
+
* drops one header — if the dropped one matches an auth header, skip the
|
|
447
|
+
* injection for that key so the probe stays meaningful. */
|
|
448
|
+
function injectAuthHeadersIntoCase(built: BuiltCase, authHeaders: Record<string, string>): void {
|
|
449
|
+
if (!authHeaders || Object.keys(authHeaders).length === 0) return;
|
|
450
|
+
const existing = new Set(Object.keys(built.req.headers).map((k) => k.toLowerCase()));
|
|
451
|
+
const droppedLower =
|
|
452
|
+
built.case.kind === "missing_required_header" && typeof built.case.meta?.dropped_header === "string"
|
|
453
|
+
? (built.case.meta.dropped_header as string).toLowerCase()
|
|
454
|
+
: null;
|
|
455
|
+
for (const [name, value] of Object.entries(authHeaders)) {
|
|
456
|
+
const lower = name.toLowerCase();
|
|
457
|
+
if (existing.has(lower)) continue;
|
|
458
|
+
if (droppedLower === lower) continue;
|
|
459
|
+
built.req.headers[name] = value;
|
|
460
|
+
built.case.request.headers[name] = value;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function summarizeResponse(resp: HttpResponse): { status: number; content_type?: string } {
|
|
465
|
+
const ct = resp.headers["content-type"] ?? resp.headers["Content-Type"];
|
|
466
|
+
return { status: resp.status, content_type: ct };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Build a finding, push it into the per-op buffer, and stream the
|
|
470
|
+
* ARV-10 NDJSON event. Summary aggregation moved out — the caller
|
|
471
|
+
* merges per-op buffers in input order so workers > 1 doesn't have to
|
|
472
|
+
* contend on a shared `summary` object. */
|
|
473
|
+
function recordFinding(
|
|
474
|
+
out: CheckFinding[],
|
|
475
|
+
check: Check,
|
|
476
|
+
c: CheckCase,
|
|
477
|
+
resp: HttpResponse,
|
|
478
|
+
message: string,
|
|
479
|
+
evidence: Record<string, unknown> | undefined,
|
|
480
|
+
onEvent: ((event: NdjsonEvent) => void) | undefined,
|
|
481
|
+
): void {
|
|
482
|
+
const finding: CheckFinding = {
|
|
483
|
+
check: check.id,
|
|
484
|
+
severity: check.severity,
|
|
485
|
+
operation: { path: c.operation.path, method: c.operation.method, operationId: c.operation.operationId },
|
|
486
|
+
request_signature: `${c.request.method} ${c.request.url}`,
|
|
487
|
+
response_summary: summarizeResponse(resp),
|
|
488
|
+
message,
|
|
489
|
+
evidence,
|
|
490
|
+
recommended_action: recommendForCheck(check.id, resp.status),
|
|
491
|
+
};
|
|
492
|
+
out.push(finding);
|
|
493
|
+
if (onEvent) onEvent({ type: "finding", ts: nowIso(), check: check.id, finding });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult> {
|
|
497
|
+
const doc = await readOpenApiSpec(opts.specPath);
|
|
498
|
+
const allOps = extractEndpoints(doc);
|
|
499
|
+
const ops = opts.operationFilter ? allOps.filter(opts.operationFilter) : allOps;
|
|
500
|
+
const buckets = bucketEndpointsByPath(allOps);
|
|
501
|
+
const schemaValidator: SchemaValidator = createSchemaValidator(doc);
|
|
502
|
+
|
|
503
|
+
const mode: Mode = opts.mode ?? "all";
|
|
504
|
+
const rawSelection = selectChecks({ include: opts.include, exclude: opts.exclude });
|
|
505
|
+
// ARV-7: drop checks the active mode doesn't care about — `selection`
|
|
506
|
+
// is what the runner sends to checks; `rawSelection` is what the user
|
|
507
|
+
// *asked for* (kept on the result so warnings still surface unknown ids).
|
|
508
|
+
// feedback-04#F1: stateful checks (ignored_auth, use_after_free,
|
|
509
|
+
// ensure_resource_availability) live in a separate registry but are
|
|
510
|
+
// accepted by `--check`; selectChecks doesn't know about them and would
|
|
511
|
+
// flag the ids as "unknown". Strip those out so the user only sees
|
|
512
|
+
// warnings for ids that are truly absent from `zond checks list`.
|
|
513
|
+
const statefulIds = new Set(listStatefulChecks().map((c) => c.id));
|
|
514
|
+
const selection: SelectionResult = {
|
|
515
|
+
selected: filterChecksByMode(rawSelection.selected, mode),
|
|
516
|
+
unknown: rawSelection.unknown.filter((id) => !statefulIds.has(id)),
|
|
517
|
+
};
|
|
518
|
+
const summary = emptySummary();
|
|
519
|
+
summary.operations = ops.length;
|
|
520
|
+
summary.checks_run = selection.selected.length;
|
|
521
|
+
|
|
522
|
+
// What probe kinds are demanded by the active set this run? Skip
|
|
523
|
+
// generating cases for kinds nobody asked for.
|
|
524
|
+
const neededKinds = new Set<CaseKind>();
|
|
525
|
+
for (const c of selection.selected) for (const k of checkKinds(c)) neededKinds.add(k);
|
|
526
|
+
|
|
527
|
+
// ARV-8: pre-compute the path → "first op" assignment for the
|
|
528
|
+
// unsupported_method probe. The pre-ARV-8 code did this lazily inside
|
|
529
|
+
// the op loop (one shared Set, mutate-on-visit) — that race-conditions
|
|
530
|
+
// when ops are processed in parallel (two workers on the same path
|
|
531
|
+
// would each emit a probe). Resolving it up-front keeps "one probe
|
|
532
|
+
// per path" deterministic regardless of `--workers`.
|
|
533
|
+
const unsupportedMethodOwner = new Map<string, EndpointInfo>();
|
|
534
|
+
if (neededKinds.has("unsupported_method")) {
|
|
535
|
+
for (const op of ops) {
|
|
536
|
+
if (!unsupportedMethodOwner.has(op.path)) unsupportedMethodOwner.set(op.path, op);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const checkRuntimeOptions = {
|
|
541
|
+
strict405: opts.strict405 === true,
|
|
542
|
+
strict401: opts.strict401 === true,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// ARV-227: shared budget across per-response + stateful phases.
|
|
546
|
+
// Mutated in-place by `reserveRequest`; safe under our worker model
|
|
547
|
+
// because JS is single-threaded between awaits.
|
|
548
|
+
const requestBudget: RequestBudget | undefined =
|
|
549
|
+
opts.maxRequests !== undefined && opts.maxRequests > 0
|
|
550
|
+
? { limit: opts.maxRequests, used: 0 }
|
|
551
|
+
: undefined;
|
|
552
|
+
|
|
553
|
+
const phase = opts.phase ?? "examples";
|
|
554
|
+
const wantsExamples = phase === "examples" || phase === "all";
|
|
555
|
+
const wantsCoverage = phase === "coverage" || phase === "all";
|
|
556
|
+
|
|
557
|
+
/** Per-op result — workers push these and the main thread merges them
|
|
558
|
+
* in input order so `findings[]` and `summary.cases` don't depend on
|
|
559
|
+
* worker scheduling (matters for snapshot tests + reproducibility). */
|
|
560
|
+
interface OpReport {
|
|
561
|
+
findings: CheckFinding[];
|
|
562
|
+
cases: number;
|
|
563
|
+
/** ARV-26: skip-outcome counts keyed by `"<check_id>: <reason>"`. */
|
|
564
|
+
skipped: Record<string, number>;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function processOperation(op: EndpointInfo): Promise<OpReport> {
|
|
568
|
+
const localFindings: CheckFinding[] = [];
|
|
569
|
+
let localCases = 0;
|
|
570
|
+
const localSkipped: Record<string, number> = {};
|
|
571
|
+
if (opts.onEvent) {
|
|
572
|
+
opts.onEvent({
|
|
573
|
+
type: "check_start",
|
|
574
|
+
ts: nowIso(),
|
|
575
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
const cases: BuiltCase[] = [];
|
|
579
|
+
if (wantsExamples && neededKinds.has("positive")) cases.push(buildPositive(op, opts.baseUrl, opts.pathVars));
|
|
580
|
+
if (neededKinds.has("missing_required_header")) {
|
|
581
|
+
cases.push(...buildMissingHeader(op, opts.baseUrl, opts.pathVars));
|
|
582
|
+
}
|
|
583
|
+
if (wantsExamples && neededKinds.has("negative_data")) {
|
|
584
|
+
const c = buildNegativeData(op, opts.baseUrl, opts.pathVars);
|
|
585
|
+
if (c) cases.push(c);
|
|
586
|
+
}
|
|
587
|
+
if (wantsCoverage && (neededKinds.has("negative_data") || neededKinds.has("positive"))) {
|
|
588
|
+
const boundary = buildCoverageCases(op, opts.baseUrl, { allowX00: opts.allowX00, pathVars: opts.pathVars });
|
|
589
|
+
for (const b of boundary) {
|
|
590
|
+
if (neededKinds.has(b.case.kind)) cases.push(b);
|
|
591
|
+
}
|
|
592
|
+
// ARV-180: param-axis coverage. Emits negative_data cases for
|
|
593
|
+
// path/query parameter mutations (drop-required-query, wrong-type,
|
|
594
|
+
// invalid-format, invalid-enum, boundary violations). On GET-heavy
|
|
595
|
+
// APIs the body-axis walker above emits zero cases, so this is the
|
|
596
|
+
// only coverage signal for `status_code_conformance` and
|
|
597
|
+
// `negative_data_rejection` on those operations.
|
|
598
|
+
if (neededKinds.has("negative_data")) {
|
|
599
|
+
for (const b of buildParamCoverageCases(op, opts.baseUrl, { allowX00: opts.allowX00, pathVars: opts.pathVars })) {
|
|
600
|
+
cases.push(b);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (unsupportedMethodOwner.get(op.path) === op) {
|
|
605
|
+
const declared = buckets.get(op.path)?.declared ?? new Set([op.method.toUpperCase()]);
|
|
606
|
+
cases.push(...buildUnsupportedMethod(op, declared, opts.baseUrl));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
for (const built of cases) {
|
|
610
|
+
if (!caseMatchesMode(built.case.mode, mode)) continue;
|
|
611
|
+
if (opts.authHeaders) injectAuthHeadersIntoCase(built, opts.authHeaders);
|
|
612
|
+
// ARV-227: stop dispatching new HTTP requests once the cap is
|
|
613
|
+
// reached. Bucket the skip so the summary surfaces it, then keep
|
|
614
|
+
// looping so we still tally the would-have-run count for the user.
|
|
615
|
+
if (!reserveRequest(requestBudget)) {
|
|
616
|
+
localSkipped[`max_requests: ${MAX_REQUESTS_SKIP_REASON}`] =
|
|
617
|
+
(localSkipped[`max_requests: ${MAX_REQUESTS_SKIP_REASON}`] ?? 0) + 1;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
// ARV-8: gate the request through the rate-limiter (no-op when
|
|
621
|
+
// none configured). Acquire happens *inside* the worker so a pool
|
|
622
|
+
// of N workers can't leak more requests/sec than the limiter
|
|
623
|
+
// allows.
|
|
624
|
+
if (opts.rateLimiter) await opts.rateLimiter.acquire();
|
|
625
|
+
let httpResp: HttpResponse;
|
|
626
|
+
try {
|
|
627
|
+
httpResp = await executeRequest(built.req, { timeout: opts.timeoutMs ?? 30000 });
|
|
628
|
+
} catch (err) {
|
|
629
|
+
const finding: CheckFinding = {
|
|
630
|
+
check: "network_error",
|
|
631
|
+
severity: "medium",
|
|
632
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
633
|
+
request_signature: `${built.req.method} ${built.req.url}`,
|
|
634
|
+
response_summary: { status: 0 },
|
|
635
|
+
message: `Network error: ${(err as Error).message}`,
|
|
636
|
+
recommended_action: recommendForCheck("network_error", 0),
|
|
637
|
+
};
|
|
638
|
+
localFindings.push(finding);
|
|
639
|
+
if (opts.onEvent) opts.onEvent({ type: "finding", ts: nowIso(), check: "network_error", finding });
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
localCases += 1;
|
|
644
|
+
const checkResp = {
|
|
645
|
+
status: httpResp.status,
|
|
646
|
+
headers: httpResp.headers,
|
|
647
|
+
body: httpResp.body_parsed ?? httpResp.body,
|
|
648
|
+
duration_ms: httpResp.duration_ms,
|
|
649
|
+
};
|
|
650
|
+
for (const check of selection.selected) {
|
|
651
|
+
if (!checkKinds(check).includes(built.case.kind)) continue;
|
|
652
|
+
if (!check.applies(op)) continue;
|
|
653
|
+
const outcome = check.run({
|
|
654
|
+
case: built.case,
|
|
655
|
+
response: checkResp,
|
|
656
|
+
schemaValidator,
|
|
657
|
+
doc,
|
|
658
|
+
options: checkRuntimeOptions,
|
|
659
|
+
});
|
|
660
|
+
if (outcome.kind === "fail") {
|
|
661
|
+
recordFinding(localFindings, check, built.case, httpResp, outcome.message, outcome.evidence, opts.onEvent);
|
|
662
|
+
}
|
|
663
|
+
if (outcome.kind === "skip") {
|
|
664
|
+
// ARV-26: bucket skips by check+reason so the summary can surface
|
|
665
|
+
// "0 findings BUT 2 skipped (no JSON Schema on this branch)".
|
|
666
|
+
const key = `${check.id}: ${outcome.reason ?? "unspecified"}`;
|
|
667
|
+
localSkipped[key] = (localSkipped[key] ?? 0) + 1;
|
|
668
|
+
}
|
|
669
|
+
if (opts.onEvent && (outcome.kind === "pass" || outcome.kind === "fail")) {
|
|
670
|
+
opts.onEvent({
|
|
671
|
+
type: "check_result",
|
|
672
|
+
ts: nowIso(),
|
|
673
|
+
check: check.id,
|
|
674
|
+
verdict: outcome.kind,
|
|
675
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
676
|
+
request_signature: `${built.case.request.method} ${built.case.request.url}`,
|
|
677
|
+
response: summarizeResponse(httpResp),
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return { findings: localFindings, cases: localCases, skipped: localSkipped };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ARV-8: parallelize the op-loop. workers=1 (default) preserves the
|
|
686
|
+
// sequential code path inside runPool — same microtask interleaving as
|
|
687
|
+
// before, AC #4 backward-compat.
|
|
688
|
+
const workers = opts.workers ?? 1;
|
|
689
|
+
const opReports = await runPool(ops, workers, processOperation);
|
|
690
|
+
|
|
691
|
+
const findings: CheckFinding[] = [];
|
|
692
|
+
for (const report of opReports) {
|
|
693
|
+
summary.cases += report.cases;
|
|
694
|
+
for (const [key, n] of Object.entries(report.skipped)) {
|
|
695
|
+
summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + n;
|
|
696
|
+
}
|
|
697
|
+
for (const f of report.findings) {
|
|
698
|
+
// ARV-251: stamp finding category from check id if not already
|
|
699
|
+
// present. Probes carry their own category; checks derive it
|
|
700
|
+
// from the check id. The bucket increment is the same code path.
|
|
701
|
+
if (!f.category) f.category = categoryFor(f.check);
|
|
702
|
+
findings.push(f);
|
|
703
|
+
summary.findings += 1;
|
|
704
|
+
summary.by_severity[f.severity] += 1;
|
|
705
|
+
summary.by_category[f.category] += 1;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ── Stateful phase (ARV-3) ─────────────────────────────────────────
|
|
710
|
+
// Stateful checks share the same --check / --exclude-check filters as
|
|
711
|
+
// the response-phase ones. We honour `selection` ids and only run a
|
|
712
|
+
// stateful check whose id was either explicitly included or not
|
|
713
|
+
// explicitly excluded.
|
|
714
|
+
const includeSet = opts.include && opts.include.length > 0 ? new Set(opts.include) : null;
|
|
715
|
+
const excludeSet = new Set(opts.exclude ?? []);
|
|
716
|
+
const activeStateful = filterChecksByMode(
|
|
717
|
+
listStatefulChecks().filter((c) => {
|
|
718
|
+
if (excludeSet.has(c.id)) return false;
|
|
719
|
+
if (includeSet && !includeSet.has(c.id)) return false;
|
|
720
|
+
return true;
|
|
721
|
+
}),
|
|
722
|
+
mode,
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
if (activeStateful.length > 0) {
|
|
726
|
+
const harness = makeHarness(opts.baseUrl, doc, {
|
|
727
|
+
authHeaders: opts.authHeaders,
|
|
728
|
+
bootstrapCleanupFailed: opts.bootstrapCleanupFailed,
|
|
729
|
+
timeoutMs: opts.timeoutMs,
|
|
730
|
+
// ARV-181: stateful checks (ignored_auth) need the same
|
|
731
|
+
// fixture-driven path-var substitution that ARV-141 wired into
|
|
732
|
+
// the per-response runner — without this the synthetic baseline
|
|
733
|
+
// lands on literal `/{event_id}` and the broken-baseline guard
|
|
734
|
+
// skips the whole op.
|
|
735
|
+
pathVars: opts.pathVars,
|
|
736
|
+
options: checkRuntimeOptions,
|
|
737
|
+
resourceConfigs: opts.resourceConfigs,
|
|
738
|
+
// ARV-227: same budget instance as the per-response phase so a
|
|
739
|
+
// cap of N applies to the whole run, not per-phase.
|
|
740
|
+
requestBudget,
|
|
741
|
+
});
|
|
742
|
+
const crudGroups = activeStateful.some((c) => c.phase === "crud") ? detectCrudGroups(allOps) : [];
|
|
743
|
+
summary.checks_run += activeStateful.length;
|
|
744
|
+
|
|
745
|
+
// ARV-8: parallelize auth-phase ops and crud-phase groups via the
|
|
746
|
+
// same pool. CRUD-chain integrity stays intact because the *check*
|
|
747
|
+
// owns its own sequential within-chain logic — the pool only runs
|
|
748
|
+
// *independent* groups in parallel.
|
|
749
|
+
const statefulWorkers = opts.workers ?? 1;
|
|
750
|
+
const collected: CheckFinding[] = [];
|
|
751
|
+
function pushStateful(f: CheckFinding): void {
|
|
752
|
+
if (!f.category) f.category = categoryFor(f.check);
|
|
753
|
+
collected.push(f);
|
|
754
|
+
summary.findings += 1;
|
|
755
|
+
summary.by_severity[f.severity] += 1;
|
|
756
|
+
summary.by_category[f.category] += 1;
|
|
757
|
+
if (opts.onEvent) opts.onEvent({ type: "finding", ts: nowIso(), check: f.check, finding: f });
|
|
758
|
+
}
|
|
759
|
+
for (const check of activeStateful) {
|
|
760
|
+
if (check.phase === "auth") {
|
|
761
|
+
const applicable = ops.filter((op) => check.applies(op));
|
|
762
|
+
// ARV-154: track per-op cases + skip reasons for the stateful auth
|
|
763
|
+
// path. Previously this loop only forwarded `fail` outcomes; runs
|
|
764
|
+
// like `--check ignored_auth` on a fully-protected API where every
|
|
765
|
+
// baseline passes returned `{operations: 48, cases: 0, findings: 0}`
|
|
766
|
+
// with no skipped_outcomes, making the check look broken when it
|
|
767
|
+
// was actually working (no auth bypass found). Mirror the
|
|
768
|
+
// observability of the non-stateful path: count attempted cases
|
|
769
|
+
// and bucket skip reasons by `<check>: <reason>`.
|
|
770
|
+
type StatefulOutcome =
|
|
771
|
+
| { kind: "fail"; finding: CheckFinding }
|
|
772
|
+
| { kind: "skip"; reason: string }
|
|
773
|
+
| { kind: "pass" };
|
|
774
|
+
const opReports = await runPool<typeof applicable[number], StatefulOutcome>(
|
|
775
|
+
applicable,
|
|
776
|
+
statefulWorkers,
|
|
777
|
+
async (op): Promise<StatefulOutcome> => {
|
|
778
|
+
let outcome;
|
|
779
|
+
try {
|
|
780
|
+
outcome = await check.run(op, harness);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
outcome = { kind: "skip" as const, reason: `error: ${(err as Error).message}` };
|
|
783
|
+
}
|
|
784
|
+
if (outcome.kind === "fail") {
|
|
785
|
+
const finding: CheckFinding = {
|
|
786
|
+
check: check.id,
|
|
787
|
+
severity: check.severity,
|
|
788
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
789
|
+
request_signature: `${op.method.toUpperCase()} ${op.path}`,
|
|
790
|
+
response_summary: { status: 0 },
|
|
791
|
+
message: outcome.message,
|
|
792
|
+
evidence: outcome.evidence,
|
|
793
|
+
recommended_action: recommendForCheck(check.id),
|
|
794
|
+
};
|
|
795
|
+
return { kind: "fail", finding };
|
|
796
|
+
}
|
|
797
|
+
if (outcome.kind === "skip") {
|
|
798
|
+
return { kind: "skip", reason: outcome.reason ?? "unspecified" };
|
|
799
|
+
}
|
|
800
|
+
return { kind: "pass" };
|
|
801
|
+
});
|
|
802
|
+
for (const o of opReports) {
|
|
803
|
+
summary.cases += 1;
|
|
804
|
+
if (o.kind === "fail") pushStateful(o.finding);
|
|
805
|
+
else if (o.kind === "skip") {
|
|
806
|
+
const key = `${check.id}: ${o.reason}`;
|
|
807
|
+
summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + 1;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
} else {
|
|
811
|
+
const applicable = crudGroups.filter((g) => check.applies(g));
|
|
812
|
+
// ARV-154: mirror the auth-phase observability — count CRUD groups
|
|
813
|
+
// attempted and record skip reasons, not just failures.
|
|
814
|
+
type StatefulOutcome =
|
|
815
|
+
| { kind: "fail"; finding: CheckFinding }
|
|
816
|
+
| { kind: "skip"; reason: string }
|
|
817
|
+
| { kind: "pass" };
|
|
818
|
+
const groupReports = await runPool<typeof applicable[number], StatefulOutcome>(
|
|
819
|
+
applicable,
|
|
820
|
+
statefulWorkers,
|
|
821
|
+
async (group): Promise<StatefulOutcome> => {
|
|
822
|
+
let outcome;
|
|
823
|
+
try {
|
|
824
|
+
outcome = await check.run(group, harness);
|
|
825
|
+
} catch (err) {
|
|
826
|
+
outcome = { kind: "skip" as const, reason: `error: ${(err as Error).message}` };
|
|
827
|
+
}
|
|
828
|
+
if (outcome.kind === "fail") {
|
|
829
|
+
const repOp = group.create ?? group.read!;
|
|
830
|
+
const finding: CheckFinding = {
|
|
831
|
+
check: check.id,
|
|
832
|
+
severity: check.severity,
|
|
833
|
+
operation: { path: repOp.path, method: repOp.method, operationId: repOp.operationId },
|
|
834
|
+
request_signature: `${repOp.method.toUpperCase()} ${repOp.path} (chain)`,
|
|
835
|
+
response_summary: { status: 0 },
|
|
836
|
+
message: outcome.message,
|
|
837
|
+
evidence: outcome.evidence,
|
|
838
|
+
recommended_action: recommendForCheck(check.id),
|
|
839
|
+
};
|
|
840
|
+
return { kind: "fail", finding };
|
|
841
|
+
}
|
|
842
|
+
if (outcome.kind === "skip") {
|
|
843
|
+
return { kind: "skip", reason: outcome.reason ?? "unspecified" };
|
|
844
|
+
}
|
|
845
|
+
return { kind: "pass" };
|
|
846
|
+
});
|
|
847
|
+
for (const o of groupReports) {
|
|
848
|
+
summary.cases += 1;
|
|
849
|
+
if (o.kind === "fail") pushStateful(o.finding);
|
|
850
|
+
else if (o.kind === "skip") {
|
|
851
|
+
const key = `${check.id}: ${o.reason}`;
|
|
852
|
+
summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + 1;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
findings.push(...collected);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const highOrCritical = findings.filter(
|
|
861
|
+
(f) => f.severity === "high" || f.severity === "critical",
|
|
862
|
+
).length;
|
|
863
|
+
|
|
864
|
+
// ARV-10: terminal event so downstream consumers know the run wrapped
|
|
865
|
+
// (vs. the producer crashing). Mirrors what the JSON envelope's
|
|
866
|
+
// `summary` field carries, just delivered as the final NDJSON line.
|
|
867
|
+
if (opts.onEvent) opts.onEvent({ type: "summary", ts: nowIso(), summary });
|
|
868
|
+
|
|
869
|
+
return {
|
|
870
|
+
data: { findings, summary },
|
|
871
|
+
selection,
|
|
872
|
+
high_or_critical: highOrCritical,
|
|
873
|
+
};
|
|
874
|
+
}
|