@kirrosh/zond 0.22.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +811 -0
- package/README.md +59 -6
- package/package.json +9 -7
- package/src/CLAUDE.md +112 -0
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +146 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +880 -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 +64 -0
- package/src/cli/commands/api/annotate/prompts.ts +220 -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 +786 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +361 -0
- package/src/cli/commands/checks.ts +1072 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +236 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +823 -132
- package/src/cli/commands/db.ts +486 -12
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1356 -0
- package/src/cli/commands/doctor.ts +661 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +438 -47
- package/src/cli/commands/init/bootstrap.ts +34 -2
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +99 -3
- package/src/cli/commands/init/templates/agents.md +77 -64
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
- package/src/cli/commands/init/templates/skills/zond.md +802 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +97 -0
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +594 -0
- package/src/cli/commands/probe/security.ts +537 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +163 -0
- package/src/cli/commands/probe.ts +535 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +227 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +310 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +495 -4
- package/src/cli/commands/run.ts +870 -53
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +18 -1
- package/src/cli/index.ts +20 -3
- package/src/cli/json-envelope.ts +92 -3
- package/src/cli/json-schemas.ts +314 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +199 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +114 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -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 +147 -0
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +242 -0
- package/src/core/checks/checks/ignored_auth.ts +254 -0
- package/src/core/checks/checks/index.ts +68 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +148 -0
- package/src/core/checks/checks/not_a_server_error.ts +35 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
- package/src/core/checks/checks/pagination_invariants.ts +419 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +33 -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 +132 -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 +82 -0
- package/src/core/checks/recommended-action.ts +68 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +1461 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +305 -0
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +251 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +216 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +293 -59
- package/src/core/diagnostics/failure-class.ts +140 -0
- package/src/core/diagnostics/failure-hints.ts +88 -89
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +155 -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 +38 -19
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/data-factory.ts +586 -22
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +332 -0
- package/src/core/generator/index.ts +5 -5
- package/src/core/generator/openapi-reader.ts +135 -7
- package/src/core/generator/path-param-disambig.ts +140 -0
- package/src/core/generator/resources-builder.ts +898 -0
- package/src/core/generator/schema-utils.ts +33 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +583 -122
- package/src/core/generator/types.ts +14 -0
- package/src/core/identity/identity-file.ts +0 -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 +73 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +91 -0
- package/src/core/output/types.ts +122 -0
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +129 -4
- package/src/core/parser/types.ts +19 -1
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +58 -12
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +61 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +27 -0
- package/src/core/probe/mass-assignment-template.ts +240 -0
- package/src/core/probe/method-probe.ts +43 -76
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +183 -149
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +439 -0
- package/src/core/probe/probe-harness.ts +119 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +207 -0
- package/src/core/probe/security-probe.ts +32 -0
- package/src/core/probe/shared.ts +531 -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 +282 -0
- package/src/core/reporter/console.ts +41 -2
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +11 -1
- 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 +59 -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 +265 -36
- package/src/core/runner/form-encode.ts +41 -0
- package/src/core/runner/http-client.ts +112 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +153 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +87 -33
- package/src/core/runner/run-kind.ts +45 -0
- package/src/core/runner/schema-validator.ts +308 -0
- package/src/core/runner/send-request.ts +158 -20
- package/src/core/runner/types.ts +44 -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 +457 -20
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +58 -0
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +128 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -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 +216 -0
- package/src/db/queries/runs.ts +289 -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 +75 -802
- package/src/db/schema.ts +178 -50
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
- package/src/cli/commands/probe-methods.ts +0 -108
- package/src/cli/commands/probe-validation.ts +0 -124
- package/src/cli/commands/serve.ts +0 -114
- package/src/cli/commands/sync.ts +0 -268
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/diagnostics/render-md.ts +0 -112
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -19
- 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,1461 @@
|
|
|
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 { buildApiResourceMap } from "../generator/resources-builder.ts";
|
|
19
|
+
import type { CrudGroup } from "../generator/types.ts";
|
|
20
|
+
import type { EndpointInfo } from "../generator/types.ts";
|
|
21
|
+
import { generateFromSchema } from "../generator/data-factory.ts";
|
|
22
|
+
import {
|
|
23
|
+
enumerateBoundaryCases,
|
|
24
|
+
enumerateParamBoundaryCases,
|
|
25
|
+
type ParamCoverageCase,
|
|
26
|
+
} from "../generator/coverage-phase.ts";
|
|
27
|
+
import { executeRequest } from "../runner/http-client.ts";
|
|
28
|
+
import { reserveRequest, MAX_REQUESTS_SKIP_REASON, type RequestBudget } from "../runner/executor.ts";
|
|
29
|
+
import { createSchemaValidator, type SchemaValidator } from "../runner/schema-validator.ts";
|
|
30
|
+
import type { HttpRequest, HttpResponse } from "../runner/types.ts";
|
|
31
|
+
import {
|
|
32
|
+
ALL_METHODS,
|
|
33
|
+
bucketEndpointsByPath,
|
|
34
|
+
pathWithMethodPlaceholders,
|
|
35
|
+
} from "../probe/method-shared.ts";
|
|
36
|
+
|
|
37
|
+
import "./checks/index.ts"; // side-effect: register builtins
|
|
38
|
+
import { selectChecks, type SelectionResult } from "./registry.ts";
|
|
39
|
+
import { listStatefulChecks, makeHarness } from "./stateful.ts";
|
|
40
|
+
import { caseMatchesMode, filterChecksByMode, type Mode } from "./mode.ts";
|
|
41
|
+
import { buildNegativeBody } from "./checks/_negative_mutator.ts";
|
|
42
|
+
import { nowIso, type NdjsonEvent } from "../reporter/ndjson.ts";
|
|
43
|
+
import { runPool } from "../runner/async-pool.ts";
|
|
44
|
+
import type { RateLimiter } from "../runner/rate-limiter.ts";
|
|
45
|
+
import { recommendForCheck } from "./recommended-action.ts";
|
|
46
|
+
import { gapKey } from "../workspace/fixture-gaps.ts";
|
|
47
|
+
import { endpointSkipsCheck, reasonForSkip } from "./zond-extensions.ts";
|
|
48
|
+
import {
|
|
49
|
+
emptySummary,
|
|
50
|
+
groupSkippedOutcomes,
|
|
51
|
+
type CaseKind,
|
|
52
|
+
type Check,
|
|
53
|
+
type CheckCase,
|
|
54
|
+
type CheckFinding,
|
|
55
|
+
type CheckRunData,
|
|
56
|
+
type CheckRunSummary,
|
|
57
|
+
type SpecFinding,
|
|
58
|
+
} from "./types.ts";
|
|
59
|
+
import type { Severity } from "../severity/index.ts";
|
|
60
|
+
import { categoryFor } from "../severity/category.ts";
|
|
61
|
+
import {
|
|
62
|
+
computeSpecFindings,
|
|
63
|
+
applyBrokenBaselineGuard,
|
|
64
|
+
type PerCheckObservations,
|
|
65
|
+
} from "./spec-findings.ts";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ARV-265: per-HTTP-case audit envelope. One emitted per request the
|
|
69
|
+
* checks runner actually dispatches (or attempted to dispatch — the
|
|
70
|
+
* `error` field is set for transport-layer failures and skipped cases).
|
|
71
|
+
*
|
|
72
|
+
* - `phase`: which sub-loop emitted the case. Lets the persistence
|
|
73
|
+
* adapter group them under `suite_name = "checks/<phase>"`.
|
|
74
|
+
* - `kind`: per-response CaseKind ("positive" / "negative_data" / …)
|
|
75
|
+
* when phase === "response". For stateful checks it is the
|
|
76
|
+
* check id (e.g. "ignored_auth", "crud_lifecycle"), since
|
|
77
|
+
* one stateful check owns the whole sub-chain.
|
|
78
|
+
* - `verdict`: best-effort outcome — "pass"/"fail" mirror the per-check
|
|
79
|
+
* verdict (passes are bucketed when ALL checks on the case
|
|
80
|
+
* passed); "error" = network/transport failure; "skip" =
|
|
81
|
+
* pre-cap skips (max-requests budget exhausted).
|
|
82
|
+
* - `checkId`: the canonical check id for the case. For per-response
|
|
83
|
+
* cases without a single owning check, this is the
|
|
84
|
+
* first-fired check id; for stateful, it's the check itself.
|
|
85
|
+
*/
|
|
86
|
+
export interface ChecksCaseEvent {
|
|
87
|
+
phase: "response" | "stateful_auth" | "stateful_crud";
|
|
88
|
+
checkId: string;
|
|
89
|
+
kind: string;
|
|
90
|
+
operation: { method: string; path: string; operationId?: string };
|
|
91
|
+
request: HttpRequest;
|
|
92
|
+
response?: HttpResponse;
|
|
93
|
+
durationMs: number;
|
|
94
|
+
verdict: "pass" | "fail" | "error" | "skip";
|
|
95
|
+
error?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface RunChecksOptions {
|
|
99
|
+
specPath: string;
|
|
100
|
+
baseUrl: string;
|
|
101
|
+
include?: string[];
|
|
102
|
+
exclude?: string[];
|
|
103
|
+
timeoutMs?: number;
|
|
104
|
+
/** Limit the operation set — used by `--include`/`--exclude` regex
|
|
105
|
+
* filtering in ARV-9. ARV-1 only exposes the hook. */
|
|
106
|
+
operationFilter?: (op: EndpointInfo) => boolean;
|
|
107
|
+
/** ARV-3 — auth headers fed to stateful security checks. CLI lifts
|
|
108
|
+
* these from `--auth-header` flags and/or the api's `.env.yaml`. */
|
|
109
|
+
authHeaders?: Record<string, string>;
|
|
110
|
+
/** ARV-3 AC #6 — when true, security checks return skip with a
|
|
111
|
+
* warning. The CLI surfaces this as `--bootstrap-cleanup-failed`. */
|
|
112
|
+
bootstrapCleanupFailed?: boolean;
|
|
113
|
+
/** ARV-6 — `examples` (current default: one positive + the
|
|
114
|
+
* single-site negative mutator) vs `coverage` (deterministic
|
|
115
|
+
* boundary-value enumeration over the body schema) vs `all` (both).
|
|
116
|
+
* Coverage cases carry `meta.boundary` and `meta.phase = "coverage"`
|
|
117
|
+
* for the SARIF reporter and reproducer hints. */
|
|
118
|
+
phase?: "examples" | "coverage" | "all";
|
|
119
|
+
/** ARV-6 AC #5 — gate the NUL byte (\x00) in string boundaries.
|
|
120
|
+
* Off by default because some HTTP/JSON stacks panic on it. */
|
|
121
|
+
allowX00?: boolean;
|
|
122
|
+
/** ARV-7 — `positive` (contract verification only), `negative`
|
|
123
|
+
* (malicious input only), `all` (default — both). Drops both checks
|
|
124
|
+
* and cases that don't belong to the requested mode. */
|
|
125
|
+
mode?: Mode;
|
|
126
|
+
/** ARV-10 — synchronous streaming hook. Fires per
|
|
127
|
+
* `check_start` / `check_result` / `finding` / `summary` event so the
|
|
128
|
+
* NDJSON reporter can flush each line as it happens (instead of
|
|
129
|
+
* buffering until the run finishes). Must not throw — exceptions are
|
|
130
|
+
* the caller's responsibility (the runner doesn't catch). */
|
|
131
|
+
onEvent?: (event: NdjsonEvent) => void;
|
|
132
|
+
/** ARV-265 — fires once per HTTP case actually dispatched (both
|
|
133
|
+
* per-response and stateful phases). Lets the CLI surface every touch
|
|
134
|
+
* into `runs`/`results` so `audit-coverage` can attribute it back.
|
|
135
|
+
* Network-failure cases also fire (with `error` set) so the audit metric
|
|
136
|
+
* counts attempts as well as successes. Must not throw. */
|
|
137
|
+
onCase?: (event: ChecksCaseEvent) => void;
|
|
138
|
+
/** ARV-8 — bounded async-pool concurrency at the *operation* level.
|
|
139
|
+
* `1` (default) = sequential, identical to the pre-ARV-8 behaviour.
|
|
140
|
+
* Cases within an operation always run sequentially regardless of
|
|
141
|
+
* this — share state (e.g. CRUD chains) lives at op-level, not
|
|
142
|
+
* case-level, so case-parallelism would corrupt it. */
|
|
143
|
+
workers?: number;
|
|
144
|
+
/** ARV-8 — gate every outbound HTTP request through the limiter so
|
|
145
|
+
* bursts of parallel workers respect a global RPS budget (also
|
|
146
|
+
* reacts to RateLimit-* headers via `note()`). */
|
|
147
|
+
rateLimiter?: RateLimiter;
|
|
148
|
+
/** ARV-179: opt-in strict-405 semantics for `unsupported_method`.
|
|
149
|
+
* Off by default — see `CheckRuntimeOptions.strict405` for rationale. */
|
|
150
|
+
strict405?: boolean;
|
|
151
|
+
/** ARV-181: opt-in strict-401 semantics for `ignored_auth`. Off by
|
|
152
|
+
* default — see `CheckRuntimeOptions.strict401` for rationale. */
|
|
153
|
+
strict401?: boolean;
|
|
154
|
+
/** ARV-169 (m-20): per-resource overrides for stateful checks
|
|
155
|
+
* (cross-call drift today; idempotency/pagination/lifecycle next).
|
|
156
|
+
* CLI loads them from `.api-resources.yaml` + `.api-resources.local.yaml`
|
|
157
|
+
* and hands them in; tests pass a literal Map. Optional — undefined
|
|
158
|
+
* ⇒ each probe uses its built-in defaults. */
|
|
159
|
+
resourceConfigs?: Map<string, {
|
|
160
|
+
readbackDiff?: import("../generator/resources-builder.ts").ReadbackDiffConfig;
|
|
161
|
+
idempotency?: import("../generator/resources-builder.ts").IdempotencyConfig;
|
|
162
|
+
pagination?: import("../generator/resources-builder.ts").PaginationConfig;
|
|
163
|
+
lifecycle?: import("../generator/resources-builder.ts").LifecycleConfig;
|
|
164
|
+
}>;
|
|
165
|
+
/** ARV-141: substitute real fixture values into path-param placeholders so
|
|
166
|
+
* the deterministic synthetic 404 (`/issues/x`) becomes a real-id 200/422
|
|
167
|
+
* whenever `.env.yaml` actually has a fixture. This makes `checks run`
|
|
168
|
+
* reactive to fixture-pack growth — without it, two runs against the same
|
|
169
|
+
* spec emit pixel-identical findings/skip counts regardless of how many
|
|
170
|
+
* vars are filled. Keyed by path-param name (e.g. `issue_id`); falls back
|
|
171
|
+
* to the legacy schema-driven placeholder when the name isn't in the map. */
|
|
172
|
+
pathVars?: Record<string, string>;
|
|
173
|
+
/** ARV-324: operations `.fixture-gaps.yaml` already confirmed as a
|
|
174
|
+
* known-empty/inaccessible resource (keyed by `"METHOD /path"` via
|
|
175
|
+
* `gapKey()`). A finding on one of these operations gets
|
|
176
|
+
* `recommended_action: fix_fixture` instead of `report_backend_bug` —
|
|
177
|
+
* it's a known gap in our own test data, not new backend evidence. */
|
|
178
|
+
fixtureGaps?: Set<string>;
|
|
179
|
+
/** ARV-342: operation-window. `skipOps` drops the first N operations
|
|
180
|
+
* (post-filter, in the deterministic extraction order); `maxOps` caps
|
|
181
|
+
* the window to N operations. Together they let a caller sweep a huge
|
|
182
|
+
* spec in bounded, resumable slices (skip 0/max 50, skip 50/max 50, …)
|
|
183
|
+
* that each finish inside a short run budget — the fix for
|
|
184
|
+
* "587-op sweep SIGTERM-killed at 15%". Pure deterministic slicing of
|
|
185
|
+
* the sorted op list: same skip/max ⇒ same window, no adaptive pacing.
|
|
186
|
+
* A window's `summary.operations` < `maxOps` signals the last slice. */
|
|
187
|
+
skipOps?: number;
|
|
188
|
+
maxOps?: number;
|
|
189
|
+
/** ARV-227: hard cap on outbound HTTP requests for the entire run.
|
|
190
|
+
* Once `used >= limit`, every subsequent case short-circuits and the
|
|
191
|
+
* summary surfaces the cap via `summary.skipped_outcomes
|
|
192
|
+
* ["max-requests-cap-reached"]`. Stateful-phase sends count toward
|
|
193
|
+
* the same budget so a cap of 100 means 100 requests total across
|
|
194
|
+
* per-response + stateful, not per-phase. Undefined ⇒ uncapped. */
|
|
195
|
+
maxRequests?: number;
|
|
196
|
+
/** ARV-292: skip the stateful (CRUD + auth) phase entirely. Driven by
|
|
197
|
+
* `--budget quick` from the CLI. Skipped stateful ids are surfaced
|
|
198
|
+
* in `summary.skipped_outcomes` so the user sees what was dropped. */
|
|
199
|
+
skipStateful?: boolean;
|
|
200
|
+
/** ARV-328: fired after each operation finishes the response phase —
|
|
201
|
+
* lets the CLI print a throttled progress line during long coverage
|
|
202
|
+
* runs. `done`/`total` are operations (case count isn't known up
|
|
203
|
+
* front); `cases` is HTTP cases executed so far.
|
|
204
|
+
* ponytail: response phase only — stateful phase is short by
|
|
205
|
+
* comparison; extend there if it ever dominates wall-clock. */
|
|
206
|
+
onProgress?: (progress: { done: number; total: number; cases: number }) => void;
|
|
207
|
+
/** ARV-299: safe mode (CLI default). Mirrors `audit --safe`: no
|
|
208
|
+
* destructive traffic. The stateful CRUD phase builds groups from
|
|
209
|
+
* read-only ops only, so create/update/delete chains
|
|
210
|
+
* (ensure_resource_availability, use_after_free) self-skip instead of
|
|
211
|
+
* POSTing real resources. Read-only stateful checks (pagination,
|
|
212
|
+
* observation-mode lifecycle) still run. `--live` sets this false. */
|
|
213
|
+
safe?: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface RunChecksResult {
|
|
217
|
+
data: CheckRunData;
|
|
218
|
+
selection: SelectionResult;
|
|
219
|
+
/** HIGH/CRITICAL findings count — drives the exit code. */
|
|
220
|
+
high_or_critical: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function placeholderForParam(p: OpenAPIV3.ParameterObject): string {
|
|
224
|
+
const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
|
|
225
|
+
if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
|
|
226
|
+
if (schema?.type === "integer" || schema?.type === "number") return "1";
|
|
227
|
+
return "x";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function fillPathParams(
|
|
231
|
+
path: string,
|
|
232
|
+
op: EndpointInfo,
|
|
233
|
+
pathVars?: Record<string, string>,
|
|
234
|
+
): string {
|
|
235
|
+
return path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
236
|
+
// ARV-141: real fixture wins over schema-derived placeholder.
|
|
237
|
+
const real = pathVars?.[name];
|
|
238
|
+
if (typeof real === "string" && real.length > 0) {
|
|
239
|
+
return encodeURIComponent(real);
|
|
240
|
+
}
|
|
241
|
+
const match = op.parameters.find(
|
|
242
|
+
(p) => (p as OpenAPIV3.ParameterObject).in === "path"
|
|
243
|
+
&& (p as OpenAPIV3.ParameterObject).name === name,
|
|
244
|
+
);
|
|
245
|
+
return match
|
|
246
|
+
? encodeURIComponent(placeholderForParam(match as OpenAPIV3.ParameterObject))
|
|
247
|
+
: "1";
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** ARV-344: a REQUIRED path param with no real fixture value — `fillPathParams`
|
|
252
|
+
* would substitute a synthetic placeholder ("x"). Harmless for GET/HEAD/DELETE
|
|
253
|
+
* existence probes (ARV-141's deterministic synthetic 404), but firing a
|
|
254
|
+
* POST/PUT/PATCH create/update against a nonexistent parent just manufactures
|
|
255
|
+
* noise findings (network_error / phantom 404) and burns rate budget. Callers
|
|
256
|
+
* skip mutating ops in that state and bucket the reason as a fixture gap. */
|
|
257
|
+
export function hasUnresolvedRequiredPathParam(op: EndpointInfo, pathVars?: Record<string, string>): boolean {
|
|
258
|
+
for (const p of op.parameters) {
|
|
259
|
+
const pp = p as OpenAPIV3.ParameterObject;
|
|
260
|
+
if (pp.in !== "path" || pp.required !== true) continue;
|
|
261
|
+
const v = pathVars?.[pp.name];
|
|
262
|
+
if (!(typeof v === "string" && v.length > 0)) return true;
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** ARV-353: transient connection resets under high windowed concurrency
|
|
268
|
+
* (ECONNRESET / socket hang up / EPIPE) are transport flake, not an API
|
|
269
|
+
* defect. Retry a small fixed number of times before letting the error become
|
|
270
|
+
* a `network_error` finding, so flake is distinguished from a real
|
|
271
|
+
* `fix_network_config` signal. Deterministic: fixed retry count, only on
|
|
272
|
+
* reset-shaped errors (a real DNS/refused/timeout error is re-thrown at once). */
|
|
273
|
+
const TRANSIENT_RESET_RE = /ECONNRESET|socket hang up|socket closed|connection reset|EPIPE|read ECONN/i;
|
|
274
|
+
|
|
275
|
+
export async function executeWithResetRetry(
|
|
276
|
+
req: HttpRequest,
|
|
277
|
+
timeout: number,
|
|
278
|
+
retries = 2,
|
|
279
|
+
exec: (r: HttpRequest, o: { timeout: number }) => Promise<HttpResponse> = executeRequest,
|
|
280
|
+
): Promise<HttpResponse> {
|
|
281
|
+
let lastErr: unknown;
|
|
282
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
283
|
+
try {
|
|
284
|
+
return await exec(req, { timeout });
|
|
285
|
+
} catch (err) {
|
|
286
|
+
lastErr = err;
|
|
287
|
+
if (!TRANSIENT_RESET_RE.test((err as Error).message ?? "")) throw err;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
throw lastErr;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function requiredHeaders(op: EndpointInfo): OpenAPIV3.ParameterObject[] {
|
|
294
|
+
return op.parameters.filter(
|
|
295
|
+
(p) => (p as OpenAPIV3.ParameterObject).in === "header"
|
|
296
|
+
&& (p as OpenAPIV3.ParameterObject).required === true,
|
|
297
|
+
) as OpenAPIV3.ParameterObject[];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function buildBaseHeaders(op: EndpointInfo, opts: { withRequired: boolean }): Record<string, string> {
|
|
301
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
302
|
+
if (opts.withRequired) {
|
|
303
|
+
for (const h of requiredHeaders(op)) {
|
|
304
|
+
headers[h.name] = "x";
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (op.requestBodySchema && op.method.toUpperCase() !== "GET" && op.method.toUpperCase() !== "DELETE") {
|
|
308
|
+
headers["Content-Type"] = op.requestBodyContentType ?? "application/json";
|
|
309
|
+
}
|
|
310
|
+
return headers;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function buildBody(op: EndpointInfo): string | undefined {
|
|
314
|
+
if (!op.requestBodySchema) return undefined;
|
|
315
|
+
const m = op.method.toUpperCase();
|
|
316
|
+
if (m === "GET" || m === "DELETE") return undefined;
|
|
317
|
+
return JSON.stringify(generateFromSchema(op.requestBodySchema));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface BuiltCase {
|
|
321
|
+
req: HttpRequest;
|
|
322
|
+
case: CheckCase;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function buildPositive(op: EndpointInfo, baseUrl: string, pathVars?: Record<string, string>): BuiltCase {
|
|
326
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, pathVars)}`;
|
|
327
|
+
const headers = buildBaseHeaders(op, { withRequired: true });
|
|
328
|
+
const body = buildBody(op);
|
|
329
|
+
const req: HttpRequest = { method: op.method.toUpperCase(), url, headers, body };
|
|
330
|
+
const c: CheckCase = {
|
|
331
|
+
operation: op,
|
|
332
|
+
request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
|
333
|
+
mode: "positive",
|
|
334
|
+
kind: "positive",
|
|
335
|
+
};
|
|
336
|
+
return { req, case: c };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** ARV-184: emit one BuiltCase per required header — drop that header
|
|
340
|
+
* in isolation so `missing_required_header` can identify *which* one
|
|
341
|
+
* the server fails to enforce. Pre-fix this emitted just the first
|
|
342
|
+
* required header (`required[0]`), which on Stripe-style specs with
|
|
343
|
+
* multiple per-op headers (Stripe-Version, Stripe-Account, ...) gave
|
|
344
|
+
* ≤1 finding per op vs schemathesis V4 ~42 in the same overlap. */
|
|
345
|
+
function buildMissingHeader(op: EndpointInfo, baseUrl: string, pathVars?: Record<string, string>): BuiltCase[] {
|
|
346
|
+
const required = requiredHeaders(op);
|
|
347
|
+
if (required.length === 0) return [];
|
|
348
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, pathVars)}`;
|
|
349
|
+
const body = buildBody(op);
|
|
350
|
+
const method = op.method.toUpperCase();
|
|
351
|
+
return required.map((header) => {
|
|
352
|
+
const headers = buildBaseHeaders(op, { withRequired: true });
|
|
353
|
+
delete headers[header.name];
|
|
354
|
+
const req: HttpRequest = { method, url, headers, body };
|
|
355
|
+
return {
|
|
356
|
+
req,
|
|
357
|
+
case: {
|
|
358
|
+
operation: op,
|
|
359
|
+
request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
|
360
|
+
mode: "negative" as const,
|
|
361
|
+
kind: "missing_required_header" as const,
|
|
362
|
+
meta: { dropped_header: header.name },
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** ARV-6: emit one BuiltCase per (field × boundary) over the body schema.
|
|
369
|
+
* Valid boundaries ride as `kind: "positive"` so positive_data_acceptance
|
|
370
|
+
* evaluates them; invalid boundaries ride as `kind: "negative_data"` so
|
|
371
|
+
* negative_data_rejection evaluates them. Both carry `meta.boundary` and
|
|
372
|
+
* `meta.phase: "coverage"` so the finding surfaces *which* boundary the
|
|
373
|
+
* server tripped on. */
|
|
374
|
+
function buildCoverageCases(
|
|
375
|
+
op: EndpointInfo,
|
|
376
|
+
baseUrl: string,
|
|
377
|
+
opts: { allowX00?: boolean; pathVars?: Record<string, string> },
|
|
378
|
+
): BuiltCase[] {
|
|
379
|
+
if (!op.requestBodySchema) return [];
|
|
380
|
+
const m = op.method.toUpperCase();
|
|
381
|
+
if (m === "GET" || m === "DELETE") return [];
|
|
382
|
+
const cases = enumerateBoundaryCases(op.requestBodySchema, { allowX00: opts.allowX00 });
|
|
383
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, opts.pathVars)}`;
|
|
384
|
+
const headers = buildBaseHeaders(op, { withRequired: true });
|
|
385
|
+
const out: BuiltCase[] = [];
|
|
386
|
+
for (const cc of cases) {
|
|
387
|
+
const body = JSON.stringify(cc.body);
|
|
388
|
+
const req: HttpRequest = { method: m, url, headers, body };
|
|
389
|
+
const kind: CaseKind = cc.valid ? "positive" : "negative_data";
|
|
390
|
+
out.push({
|
|
391
|
+
req,
|
|
392
|
+
case: {
|
|
393
|
+
operation: op,
|
|
394
|
+
request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
|
395
|
+
mode: cc.valid ? "positive" : "negative",
|
|
396
|
+
kind,
|
|
397
|
+
meta: {
|
|
398
|
+
phase: "coverage",
|
|
399
|
+
boundary: cc.boundary,
|
|
400
|
+
field_path: cc.field_path,
|
|
401
|
+
mutation: "boundary",
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** ARV-180: build a URL whose path/query parameters reflect a coverage
|
|
410
|
+
* mutation. The positive baseline fills every param with a valid
|
|
411
|
+
* shape; this helper takes that baseline and swaps the named param
|
|
412
|
+
* with the mutation value (or drops it, for `drop-required-query`).
|
|
413
|
+
* Path mutations rewrite the placeholder for the named param only —
|
|
414
|
+
* all other path-vars keep their valid baseline values, so the URL
|
|
415
|
+
* still reaches the routing layer. */
|
|
416
|
+
function buildParamMutatedUrl(
|
|
417
|
+
baseUrl: string,
|
|
418
|
+
op: EndpointInfo,
|
|
419
|
+
mut: ParamCoverageCase,
|
|
420
|
+
pathVars: Record<string, string> | undefined,
|
|
421
|
+
): string {
|
|
422
|
+
// Start with the valid baseline path (placeholders filled).
|
|
423
|
+
let pathStr = op.path;
|
|
424
|
+
if (mut.location === "path") {
|
|
425
|
+
// Rewrite only the targeted placeholder; everything else gets the
|
|
426
|
+
// valid baseline (path-vars > schema-derived placeholder).
|
|
427
|
+
pathStr = op.path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
428
|
+
if (name === mut.paramName) return encodeURIComponent(String(mut.value));
|
|
429
|
+
const real = pathVars?.[name];
|
|
430
|
+
if (typeof real === "string" && real.length > 0) return encodeURIComponent(real);
|
|
431
|
+
const match = op.parameters.find(
|
|
432
|
+
(p) => (p as OpenAPIV3.ParameterObject).in === "path"
|
|
433
|
+
&& (p as OpenAPIV3.ParameterObject).name === name,
|
|
434
|
+
);
|
|
435
|
+
return match
|
|
436
|
+
? encodeURIComponent(placeholderForParam(match as OpenAPIV3.ParameterObject))
|
|
437
|
+
: "1";
|
|
438
|
+
});
|
|
439
|
+
} else {
|
|
440
|
+
pathStr = fillPathParams(op.path, op, pathVars);
|
|
441
|
+
}
|
|
442
|
+
let url = `${baseUrl.replace(/\/+$/, "")}${pathStr}`;
|
|
443
|
+
if (mut.location === "query") {
|
|
444
|
+
const qp = new URLSearchParams();
|
|
445
|
+
// Seed required query params with valid baseline values so the
|
|
446
|
+
// mutation is single-site.
|
|
447
|
+
for (const p of op.parameters) {
|
|
448
|
+
const pp = p as OpenAPIV3.ParameterObject;
|
|
449
|
+
if (pp.in !== "query") continue;
|
|
450
|
+
if (pp.name === mut.paramName) {
|
|
451
|
+
if (mut.scenario === "drop-required-query") continue; // drop
|
|
452
|
+
qp.append(pp.name, String(mut.value));
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (pp.required === true) {
|
|
456
|
+
qp.append(pp.name, placeholderForParam(pp));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const qs = qp.toString();
|
|
460
|
+
if (qs.length > 0) url += `?${qs}`;
|
|
461
|
+
}
|
|
462
|
+
return url;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** ARV-180: emit one BuiltCase per (param × scenario) for the
|
|
466
|
+
* operation. All cases ride as `kind: "negative_data"` so
|
|
467
|
+
* `negative_data_rejection` evaluates "did the server reject?", and
|
|
468
|
+
* `status_code_conformance` (now declares `negative_data` in its
|
|
469
|
+
* caseKinds) evaluates "is the resulting status code documented?".
|
|
470
|
+
* This is the cheap-fix gap for `status_code_conformance` on
|
|
471
|
+
* GET-heavy APIs where the body-coverage walker emits zero cases. */
|
|
472
|
+
function buildParamCoverageCases(
|
|
473
|
+
op: EndpointInfo,
|
|
474
|
+
baseUrl: string,
|
|
475
|
+
opts: { allowX00?: boolean; pathVars?: Record<string, string> },
|
|
476
|
+
): BuiltCase[] {
|
|
477
|
+
const params = op.parameters as OpenAPIV3.ParameterObject[];
|
|
478
|
+
const mutations = enumerateParamBoundaryCases(params, { allowX00: opts.allowX00 });
|
|
479
|
+
if (mutations.length === 0) return [];
|
|
480
|
+
const m = op.method.toUpperCase();
|
|
481
|
+
const headers = buildBaseHeaders(op, { withRequired: true });
|
|
482
|
+
const body = buildBody(op);
|
|
483
|
+
const out: BuiltCase[] = [];
|
|
484
|
+
for (const mut of mutations) {
|
|
485
|
+
const url = buildParamMutatedUrl(baseUrl, op, mut, opts.pathVars);
|
|
486
|
+
const req: HttpRequest = { method: m, url, headers, body };
|
|
487
|
+
out.push({
|
|
488
|
+
req,
|
|
489
|
+
case: {
|
|
490
|
+
operation: op,
|
|
491
|
+
request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
|
492
|
+
mode: "negative",
|
|
493
|
+
kind: "negative_data",
|
|
494
|
+
meta: {
|
|
495
|
+
phase: "coverage",
|
|
496
|
+
param_scenario: mut.scenario,
|
|
497
|
+
param_name: mut.paramName,
|
|
498
|
+
param_location: mut.location,
|
|
499
|
+
mutation: "param-boundary",
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function buildNegativeData(op: EndpointInfo, baseUrl: string, pathVars?: Record<string, string>): BuiltCase | null {
|
|
508
|
+
if (!op.requestBodySchema) return null;
|
|
509
|
+
const m = op.method.toUpperCase();
|
|
510
|
+
if (m === "GET" || m === "DELETE") return null;
|
|
511
|
+
const mutated = buildNegativeBody(op.requestBodySchema);
|
|
512
|
+
if (!mutated) return null;
|
|
513
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${fillPathParams(op.path, op, pathVars)}`;
|
|
514
|
+
const headers = buildBaseHeaders(op, { withRequired: true });
|
|
515
|
+
const body = JSON.stringify(mutated.body);
|
|
516
|
+
const req: HttpRequest = { method: m, url, headers, body };
|
|
517
|
+
const c: CheckCase = {
|
|
518
|
+
operation: op,
|
|
519
|
+
request: { method: req.method, url: req.url, headers: req.headers, body: req.body },
|
|
520
|
+
mode: "negative",
|
|
521
|
+
kind: "negative_data",
|
|
522
|
+
meta: { ...mutated.meta },
|
|
523
|
+
};
|
|
524
|
+
return { req, case: c };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** For `unsupported_method` we send every method that isn't declared on
|
|
528
|
+
* the *path bucket*. ARV-179: pre-fix this emitted just `missing[0]`,
|
|
529
|
+
* which produced ≈1 finding per path on real APIs (vs schemathesis's
|
|
530
|
+
* per-method enumeration that finds 100+ on the same target). The
|
|
531
|
+
* check itself coalesces results per-(path, undeclared-method) pair,
|
|
532
|
+
* so a path with 4 missing methods yields up to 4 findings. The
|
|
533
|
+
* per-path "one owner" rule still applies — only the owner-op emits
|
|
534
|
+
* the bucket — so we don't double-count on multi-method paths. */
|
|
535
|
+
function buildUnsupportedMethod(
|
|
536
|
+
op: EndpointInfo,
|
|
537
|
+
declaredOnPath: Set<string>,
|
|
538
|
+
baseUrl: string,
|
|
539
|
+
): BuiltCase[] {
|
|
540
|
+
const declaredUpper = new Set(Array.from(declaredOnPath, (m) => m.toUpperCase()));
|
|
541
|
+
const missing = ALL_METHODS.filter((m) => !declaredUpper.has(m));
|
|
542
|
+
if (missing.length === 0) return [];
|
|
543
|
+
const concretePath = pathWithMethodPlaceholders(op.path, op.parameters);
|
|
544
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${concretePath}`;
|
|
545
|
+
return missing.map((method) => {
|
|
546
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
547
|
+
let body: string | undefined;
|
|
548
|
+
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
|
549
|
+
headers["Content-Type"] = "application/json";
|
|
550
|
+
body = "{}";
|
|
551
|
+
}
|
|
552
|
+
const req: HttpRequest = { method, url, headers, body };
|
|
553
|
+
const c: CheckCase = {
|
|
554
|
+
operation: op,
|
|
555
|
+
request: { method, url, headers, body },
|
|
556
|
+
mode: "negative",
|
|
557
|
+
kind: "unsupported_method",
|
|
558
|
+
meta: { undeclared_method: method },
|
|
559
|
+
};
|
|
560
|
+
return { req, case: c };
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function checkKinds(c: Check): CaseKind[] {
|
|
565
|
+
return c.caseKinds ?? ["positive"];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** ARV-61 (feedback round-01 / F1): inject auth headers into a response-phase
|
|
569
|
+
* case so depth-checks pierce the auth-wall on real APIs. Case-specific
|
|
570
|
+
* headers win (case-insensitive). `missing_required_header` deliberately
|
|
571
|
+
* drops one header — if the dropped one matches an auth header, skip the
|
|
572
|
+
* injection for that key so the probe stays meaningful. */
|
|
573
|
+
function injectAuthHeadersIntoCase(built: BuiltCase, authHeaders: Record<string, string>): void {
|
|
574
|
+
if (!authHeaders || Object.keys(authHeaders).length === 0) return;
|
|
575
|
+
const existing = new Set(Object.keys(built.req.headers).map((k) => k.toLowerCase()));
|
|
576
|
+
const droppedLower =
|
|
577
|
+
built.case.kind === "missing_required_header" && typeof built.case.meta?.dropped_header === "string"
|
|
578
|
+
? (built.case.meta.dropped_header as string).toLowerCase()
|
|
579
|
+
: null;
|
|
580
|
+
for (const [name, value] of Object.entries(authHeaders)) {
|
|
581
|
+
const lower = name.toLowerCase();
|
|
582
|
+
if (existing.has(lower)) continue;
|
|
583
|
+
if (droppedLower === lower) continue;
|
|
584
|
+
built.req.headers[name] = value;
|
|
585
|
+
built.case.request.headers[name] = value;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function summarizeResponse(resp: HttpResponse): { status: number; content_type?: string } {
|
|
590
|
+
const ct = resp.headers["content-type"] ?? resp.headers["Content-Type"];
|
|
591
|
+
return { status: resp.status, content_type: ct };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** ARV-319: `CrudGroup.create`/`.list`/`.read`/`.update`/`.delete` are ALL
|
|
595
|
+
* optional — a group can be update/delete-only (no create, no read), which
|
|
596
|
+
* is common on wide specs like Stripe. Pick whichever canonical operation
|
|
597
|
+
* exists, in create > list > read > update > delete order, for use as a
|
|
598
|
+
* representative op (finding attribution, x-zond-skip lookup, ndjson
|
|
599
|
+
* event). Returns undefined only if the group somehow has none of the
|
|
600
|
+
* five — callers must not assume non-null. */
|
|
601
|
+
function representativeOp(g: CrudGroup): EndpointInfo | undefined {
|
|
602
|
+
return g.create ?? g.list ?? g.read ?? g.update ?? g.delete;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Build a finding, push it into the per-op buffer, and stream the
|
|
606
|
+
* ARV-10 NDJSON event. Summary aggregation moved out — the caller
|
|
607
|
+
* merges per-op buffers in input order so workers > 1 doesn't have to
|
|
608
|
+
* contend on a shared `summary` object. The finding carries raw
|
|
609
|
+
* evidence + its built-in severity default; the agent re-severitizes. */
|
|
610
|
+
function recordFinding(
|
|
611
|
+
out: CheckFinding[],
|
|
612
|
+
check: Check,
|
|
613
|
+
c: CheckCase,
|
|
614
|
+
resp: HttpResponse,
|
|
615
|
+
message: string,
|
|
616
|
+
evidence: Record<string, unknown> | undefined,
|
|
617
|
+
onEvent: ((event: NdjsonEvent) => void) | undefined,
|
|
618
|
+
/** ARV-284: per-finding severity from CheckOutcome — overrides
|
|
619
|
+
* the check's natural tier when the check wants to dispatch by
|
|
620
|
+
* evidence (e.g. negative_data_rejection LOW for additionalProperties,
|
|
621
|
+
* HIGH for 5xx response). */
|
|
622
|
+
outcomeSeverity?: Severity,
|
|
623
|
+
/** ARV-324: known-gap index, see `RunChecksOptions.fixtureGaps`. */
|
|
624
|
+
fixtureGaps?: Set<string>,
|
|
625
|
+
): void {
|
|
626
|
+
const unresolvedFixture = fixtureGaps?.has(gapKey(c.operation.method, c.operation.path)) ?? false;
|
|
627
|
+
const action = recommendForCheck(check.id, resp.status, unresolvedFixture);
|
|
628
|
+
const finding: CheckFinding = {
|
|
629
|
+
check: check.id,
|
|
630
|
+
severity: outcomeSeverity ?? check.severity,
|
|
631
|
+
operation: { path: c.operation.path, method: c.operation.method, operationId: c.operation.operationId },
|
|
632
|
+
request_signature: `${c.request.method} ${c.request.url}`,
|
|
633
|
+
response_summary: summarizeResponse(resp),
|
|
634
|
+
message,
|
|
635
|
+
evidence,
|
|
636
|
+
recommended_action: action,
|
|
637
|
+
};
|
|
638
|
+
out.push(finding);
|
|
639
|
+
if (onEvent) onEvent({ type: "finding", ts: nowIso(), check: check.id, finding });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult> {
|
|
643
|
+
const doc = await readOpenApiSpec(opts.specPath);
|
|
644
|
+
const allOps = extractEndpoints(doc);
|
|
645
|
+
const filteredOps = opts.operationFilter ? allOps.filter(opts.operationFilter) : allOps;
|
|
646
|
+
// ARV-342: deterministic operation-window. Slice the post-filter op
|
|
647
|
+
// list so a caller can sweep a large spec in bounded, resumable slices.
|
|
648
|
+
const skipOps = Math.max(0, opts.skipOps ?? 0);
|
|
649
|
+
const ops = opts.maxOps !== undefined && opts.maxOps >= 0
|
|
650
|
+
? filteredOps.slice(skipOps, skipOps + opts.maxOps)
|
|
651
|
+
: filteredOps.slice(skipOps);
|
|
652
|
+
const buckets = bucketEndpointsByPath(allOps);
|
|
653
|
+
const schemaValidator: SchemaValidator = createSchemaValidator(doc);
|
|
654
|
+
|
|
655
|
+
const mode: Mode = opts.mode ?? "all";
|
|
656
|
+
const rawSelection = selectChecks({ include: opts.include, exclude: opts.exclude });
|
|
657
|
+
// ARV-7: drop checks the active mode doesn't care about — `selection`
|
|
658
|
+
// is what the runner sends to checks; `rawSelection` is what the user
|
|
659
|
+
// *asked for* (kept on the result so warnings still surface unknown ids).
|
|
660
|
+
// feedback-04#F1: stateful checks (ignored_auth, use_after_free,
|
|
661
|
+
// ensure_resource_availability) live in a separate registry but are
|
|
662
|
+
// accepted by `--check`; selectChecks doesn't know about them and would
|
|
663
|
+
// flag the ids as "unknown". Strip those out so the user only sees
|
|
664
|
+
// warnings for ids that are truly absent from `zond checks list`.
|
|
665
|
+
const statefulIds = new Set(listStatefulChecks().map((c) => c.id));
|
|
666
|
+
const selection: SelectionResult = {
|
|
667
|
+
selected: filterChecksByMode(rawSelection.selected, mode),
|
|
668
|
+
unknown: rawSelection.unknown.filter((id) => !statefulIds.has(id)),
|
|
669
|
+
};
|
|
670
|
+
const summary = emptySummary();
|
|
671
|
+
summary.operations = ops.length;
|
|
672
|
+
summary.checks_run = selection.selected.length;
|
|
673
|
+
|
|
674
|
+
// What probe kinds are demanded by the active set this run? Skip
|
|
675
|
+
// generating cases for kinds nobody asked for.
|
|
676
|
+
const neededKinds = new Set<CaseKind>();
|
|
677
|
+
for (const c of selection.selected) for (const k of checkKinds(c)) neededKinds.add(k);
|
|
678
|
+
|
|
679
|
+
// ARV-8: pre-compute the path → "first op" assignment for the
|
|
680
|
+
// unsupported_method probe. The pre-ARV-8 code did this lazily inside
|
|
681
|
+
// the op loop (one shared Set, mutate-on-visit) — that race-conditions
|
|
682
|
+
// when ops are processed in parallel (two workers on the same path
|
|
683
|
+
// would each emit a probe). Resolving it up-front keeps "one probe
|
|
684
|
+
// per path" deterministic regardless of `--workers`.
|
|
685
|
+
const unsupportedMethodOwner = new Map<string, EndpointInfo>();
|
|
686
|
+
if (neededKinds.has("unsupported_method")) {
|
|
687
|
+
for (const op of ops) {
|
|
688
|
+
if (!unsupportedMethodOwner.has(op.path)) unsupportedMethodOwner.set(op.path, op);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const checkRuntimeOptions = {
|
|
693
|
+
strict405: opts.strict405 === true,
|
|
694
|
+
strict401: opts.strict401 === true,
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// ARV-227: shared budget across per-response + stateful phases.
|
|
698
|
+
// Mutated in-place by `reserveRequest`; safe under our worker model
|
|
699
|
+
// because JS is single-threaded between awaits.
|
|
700
|
+
const requestBudget: RequestBudget | undefined =
|
|
701
|
+
opts.maxRequests !== undefined && opts.maxRequests > 0
|
|
702
|
+
? { limit: opts.maxRequests, used: 0 }
|
|
703
|
+
: undefined;
|
|
704
|
+
|
|
705
|
+
const phase = opts.phase ?? "examples";
|
|
706
|
+
const wantsExamples = phase === "examples" || phase === "all";
|
|
707
|
+
const wantsCoverage = phase === "coverage" || phase === "all";
|
|
708
|
+
|
|
709
|
+
/** Per-op result — workers push these and the main thread merges them
|
|
710
|
+
* in input order so `findings[]` and `summary.cases` don't depend on
|
|
711
|
+
* worker scheduling (matters for snapshot tests + reproducibility). */
|
|
712
|
+
interface OpReport {
|
|
713
|
+
findings: CheckFinding[];
|
|
714
|
+
cases: number;
|
|
715
|
+
/** ARV-26: skip-outcome counts keyed by `"<check_id>: <reason>"`. */
|
|
716
|
+
skipped: Record<string, number>;
|
|
717
|
+
/** ARV-60: check ids that returned `applies(op) === true` for this
|
|
718
|
+
* operation. Counted at merge-time to derive each check's
|
|
719
|
+
* applicable-operation population for spec_findings rollup. One
|
|
720
|
+
* entry per check per op (deduplicated within the op). */
|
|
721
|
+
applicableChecks: string[];
|
|
722
|
+
/** ARV-60: per-check case count for this op (passed + failed +
|
|
723
|
+
* skipped). Summed at merge-time. */
|
|
724
|
+
casesByCheck: Record<string, number>;
|
|
725
|
+
/** ARV-307: positive (expected-success) probe responses observed on
|
|
726
|
+
* this op — feeds the run-level broken-baseline guard. */
|
|
727
|
+
positiveTotal: number;
|
|
728
|
+
positiveTwoxx: number;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function processOperation(op: EndpointInfo): Promise<OpReport> {
|
|
732
|
+
const localFindings: CheckFinding[] = [];
|
|
733
|
+
let localCases = 0;
|
|
734
|
+
let localPositiveTotal = 0;
|
|
735
|
+
let localPositiveTwoxx = 0;
|
|
736
|
+
const localSkipped: Record<string, number> = {};
|
|
737
|
+
// ARV-60: per-check observations for this op — `applies()` membership
|
|
738
|
+
// and case-count. Deduplicated within the op (one applies-vote per
|
|
739
|
+
// check regardless of how many case kinds we generate against it).
|
|
740
|
+
const localApplicable = new Set<string>();
|
|
741
|
+
const localCasesByCheck: Record<string, number> = {};
|
|
742
|
+
if (opts.onEvent) {
|
|
743
|
+
opts.onEvent({
|
|
744
|
+
type: "check_start",
|
|
745
|
+
ts: nowIso(),
|
|
746
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
const cases: BuiltCase[] = [];
|
|
750
|
+
if (wantsExamples && neededKinds.has("positive")) cases.push(buildPositive(op, opts.baseUrl, opts.pathVars));
|
|
751
|
+
if (neededKinds.has("missing_required_header")) {
|
|
752
|
+
cases.push(...buildMissingHeader(op, opts.baseUrl, opts.pathVars));
|
|
753
|
+
}
|
|
754
|
+
if (wantsExamples && neededKinds.has("negative_data")) {
|
|
755
|
+
const c = buildNegativeData(op, opts.baseUrl, opts.pathVars);
|
|
756
|
+
if (c) cases.push(c);
|
|
757
|
+
}
|
|
758
|
+
if (wantsCoverage && (neededKinds.has("negative_data") || neededKinds.has("positive"))) {
|
|
759
|
+
const boundary = buildCoverageCases(op, opts.baseUrl, { allowX00: opts.allowX00, pathVars: opts.pathVars });
|
|
760
|
+
for (const b of boundary) {
|
|
761
|
+
if (neededKinds.has(b.case.kind)) cases.push(b);
|
|
762
|
+
}
|
|
763
|
+
// ARV-180: param-axis coverage. Emits negative_data cases for
|
|
764
|
+
// path/query parameter mutations (drop-required-query, wrong-type,
|
|
765
|
+
// invalid-format, invalid-enum, boundary violations). On GET-heavy
|
|
766
|
+
// APIs the body-axis walker above emits zero cases, so this is the
|
|
767
|
+
// only coverage signal for `status_code_conformance` and
|
|
768
|
+
// `negative_data_rejection` on those operations.
|
|
769
|
+
if (neededKinds.has("negative_data")) {
|
|
770
|
+
for (const b of buildParamCoverageCases(op, opts.baseUrl, { allowX00: opts.allowX00, pathVars: opts.pathVars })) {
|
|
771
|
+
cases.push(b);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (unsupportedMethodOwner.get(op.path) === op) {
|
|
776
|
+
const declared = buckets.get(op.path)?.declared ?? new Set([op.method.toUpperCase()]);
|
|
777
|
+
cases.push(...buildUnsupportedMethod(op, declared, opts.baseUrl));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ARV-344: don't dispatch a mutation (POST/PUT/PATCH) whose required path
|
|
781
|
+
// param is unresolved — it targets a synthetic parent id ("/accounts/x/…")
|
|
782
|
+
// and only manufactures noise. Skip the op's cases and bucket the reason as
|
|
783
|
+
// a fixture gap (GET/HEAD/DELETE keep the ARV-141 synthetic-404 probe).
|
|
784
|
+
{
|
|
785
|
+
const m = op.method.toUpperCase();
|
|
786
|
+
const isMutation = m === "POST" || m === "PUT" || m === "PATCH";
|
|
787
|
+
if (isMutation && cases.length > 0 && hasUnresolvedRequiredPathParam(op, opts.pathVars)) {
|
|
788
|
+
const reason = "unresolved required path param (fixture gap) — mutation against a synthetic parent id skipped";
|
|
789
|
+
for (const built of cases) {
|
|
790
|
+
localSkipped[`${built.case.kind}: ${reason}`] = (localSkipped[`${built.case.kind}: ${reason}`] ?? 0) + 1;
|
|
791
|
+
if (opts.onCase) {
|
|
792
|
+
opts.onCase({
|
|
793
|
+
phase: "response",
|
|
794
|
+
checkId: built.case.kind,
|
|
795
|
+
kind: built.case.kind,
|
|
796
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
797
|
+
request: built.req,
|
|
798
|
+
durationMs: 0,
|
|
799
|
+
verdict: "skip",
|
|
800
|
+
error: reason,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
cases.length = 0;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
for (const built of cases) {
|
|
809
|
+
if (!caseMatchesMode(built.case.mode, mode)) continue;
|
|
810
|
+
if (opts.authHeaders) injectAuthHeadersIntoCase(built, opts.authHeaders);
|
|
811
|
+
// ARV-227: stop dispatching new HTTP requests once the cap is
|
|
812
|
+
// reached. Bucket the skip so the summary surfaces it, then keep
|
|
813
|
+
// looping so we still tally the would-have-run count for the user.
|
|
814
|
+
if (!reserveRequest(requestBudget)) {
|
|
815
|
+
localSkipped[`max_requests: ${MAX_REQUESTS_SKIP_REASON}`] =
|
|
816
|
+
(localSkipped[`max_requests: ${MAX_REQUESTS_SKIP_REASON}`] ?? 0) + 1;
|
|
817
|
+
if (opts.onCase) {
|
|
818
|
+
opts.onCase({
|
|
819
|
+
phase: "response",
|
|
820
|
+
checkId: built.case.kind,
|
|
821
|
+
kind: built.case.kind,
|
|
822
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
823
|
+
request: built.req,
|
|
824
|
+
durationMs: 0,
|
|
825
|
+
verdict: "skip",
|
|
826
|
+
error: MAX_REQUESTS_SKIP_REASON,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
// ARV-8: gate the request through the rate-limiter (no-op when
|
|
832
|
+
// none configured). Acquire happens *inside* the worker so a pool
|
|
833
|
+
// of N workers can't leak more requests/sec than the limiter
|
|
834
|
+
// allows.
|
|
835
|
+
if (opts.rateLimiter) await opts.rateLimiter.acquire();
|
|
836
|
+
let httpResp: HttpResponse;
|
|
837
|
+
try {
|
|
838
|
+
httpResp = await executeWithResetRetry(built.req, opts.timeoutMs ?? 30000);
|
|
839
|
+
} catch (err) {
|
|
840
|
+
const finding: CheckFinding = {
|
|
841
|
+
check: "network_error",
|
|
842
|
+
severity: "medium",
|
|
843
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
844
|
+
request_signature: `${built.req.method} ${built.req.url}`,
|
|
845
|
+
response_summary: { status: 0 },
|
|
846
|
+
message: `Network error: ${(err as Error).message}`,
|
|
847
|
+
recommended_action: recommendForCheck("network_error", 0),
|
|
848
|
+
};
|
|
849
|
+
localFindings.push(finding);
|
|
850
|
+
if (opts.onEvent) opts.onEvent({ type: "finding", ts: nowIso(), check: "network_error", finding });
|
|
851
|
+
if (opts.onCase) {
|
|
852
|
+
opts.onCase({
|
|
853
|
+
phase: "response",
|
|
854
|
+
checkId: "network_error",
|
|
855
|
+
kind: built.case.kind,
|
|
856
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
857
|
+
request: built.req,
|
|
858
|
+
durationMs: 0,
|
|
859
|
+
verdict: "error",
|
|
860
|
+
error: (err as Error).message,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
localCases += 1;
|
|
867
|
+
// ARV-307: tally the positive-probe baseline. Only the positive case
|
|
868
|
+
// kind is "expected to succeed" — negative/boundary cases legitimately
|
|
869
|
+
// 4xx, so they must not count toward the broken-baseline ratio.
|
|
870
|
+
if (built.case.kind === "positive") {
|
|
871
|
+
localPositiveTotal += 1;
|
|
872
|
+
if (httpResp.status >= 200 && httpResp.status < 300) localPositiveTwoxx += 1;
|
|
873
|
+
}
|
|
874
|
+
const checkResp = {
|
|
875
|
+
status: httpResp.status,
|
|
876
|
+
headers: httpResp.headers,
|
|
877
|
+
body: httpResp.body_parsed ?? httpResp.body,
|
|
878
|
+
duration_ms: httpResp.duration_ms,
|
|
879
|
+
};
|
|
880
|
+
// ARV-265: accumulate per-case verdict. The case is "fail" if any
|
|
881
|
+
// applicable check on it failed, "pass" if at least one ran and all
|
|
882
|
+
// passed, "skip" if every check was skipped. Owning check id is the
|
|
883
|
+
// first one that returned a verdict — used as a hint for triage.
|
|
884
|
+
let caseVerdict: "pass" | "fail" | "skip" = "skip";
|
|
885
|
+
let caseCheckId: string = built.case.kind;
|
|
886
|
+
for (const check of selection.selected) {
|
|
887
|
+
if (!checkKinds(check).includes(built.case.kind)) continue;
|
|
888
|
+
if (!check.applies(op)) continue;
|
|
889
|
+
// ARV-189: per-operation `x-zond-skip` / `x-zond-public` opt-out.
|
|
890
|
+
// Fires AFTER applies() so the applicability count still reflects
|
|
891
|
+
// the universe of checks that *would* have run; the spec-level
|
|
892
|
+
// skip is surfaced through the skipped-outcomes summary instead.
|
|
893
|
+
if (endpointSkipsCheck(op, check.id)) {
|
|
894
|
+
const key = `${check.id}: ${reasonForSkip(op, check.id)}`;
|
|
895
|
+
localSkipped[key] = (localSkipped[key] ?? 0) + 1;
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
// ARV-60: applies()=true → bump applicability + cases-by-check.
|
|
899
|
+
localApplicable.add(check.id);
|
|
900
|
+
localCasesByCheck[check.id] = (localCasesByCheck[check.id] ?? 0) + 1;
|
|
901
|
+
const outcome = check.run({
|
|
902
|
+
case: built.case,
|
|
903
|
+
response: checkResp,
|
|
904
|
+
schemaValidator,
|
|
905
|
+
doc,
|
|
906
|
+
options: checkRuntimeOptions,
|
|
907
|
+
});
|
|
908
|
+
if (outcome.kind === "fail") {
|
|
909
|
+
recordFinding(localFindings, check, built.case, httpResp, outcome.message, outcome.evidence, opts.onEvent, outcome.severity, opts.fixtureGaps);
|
|
910
|
+
}
|
|
911
|
+
if (outcome.kind === "skip") {
|
|
912
|
+
// ARV-26: bucket skips by check+reason so the summary can surface
|
|
913
|
+
// "0 findings BUT 2 skipped (no JSON Schema on this branch)".
|
|
914
|
+
const key = `${check.id}: ${outcome.reason ?? "unspecified"}`;
|
|
915
|
+
localSkipped[key] = (localSkipped[key] ?? 0) + 1;
|
|
916
|
+
}
|
|
917
|
+
if (opts.onEvent && (outcome.kind === "pass" || outcome.kind === "fail")) {
|
|
918
|
+
opts.onEvent({
|
|
919
|
+
type: "check_result",
|
|
920
|
+
ts: nowIso(),
|
|
921
|
+
check: check.id,
|
|
922
|
+
// ARV-351: emit the SAME severity the finding carries, not the
|
|
923
|
+
// static declared default — else one case reads `high` on its
|
|
924
|
+
// check_result and `low` on its finding (open_cors_on_sensitive).
|
|
925
|
+
severity: (outcome.kind === "fail" ? outcome.severity : undefined) ?? check.severity,
|
|
926
|
+
verdict: outcome.kind,
|
|
927
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
928
|
+
request_signature: `${built.case.request.method} ${built.case.request.url}`,
|
|
929
|
+
response: summarizeResponse(httpResp),
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
// ARV-265: a case fails as soon as one check on it fails.
|
|
933
|
+
if (outcome.kind === "fail") {
|
|
934
|
+
caseVerdict = "fail";
|
|
935
|
+
if (caseCheckId === built.case.kind) caseCheckId = check.id;
|
|
936
|
+
} else if (outcome.kind === "pass" && caseVerdict !== "fail") {
|
|
937
|
+
caseVerdict = "pass";
|
|
938
|
+
if (caseCheckId === built.case.kind) caseCheckId = check.id;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
// ARV-265: one audit row per case, regardless of how many checks ran.
|
|
942
|
+
if (opts.onCase) {
|
|
943
|
+
opts.onCase({
|
|
944
|
+
phase: "response",
|
|
945
|
+
checkId: caseCheckId,
|
|
946
|
+
kind: built.case.kind,
|
|
947
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
948
|
+
request: built.req,
|
|
949
|
+
response: httpResp,
|
|
950
|
+
durationMs: httpResp.duration_ms,
|
|
951
|
+
verdict: caseVerdict,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
findings: localFindings,
|
|
957
|
+
cases: localCases,
|
|
958
|
+
skipped: localSkipped,
|
|
959
|
+
applicableChecks: [...localApplicable],
|
|
960
|
+
casesByCheck: localCasesByCheck,
|
|
961
|
+
positiveTotal: localPositiveTotal,
|
|
962
|
+
positiveTwoxx: localPositiveTwoxx,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// ARV-8: parallelize the op-loop. workers=1 (default) preserves the
|
|
967
|
+
// sequential code path inside runPool — same microtask interleaving as
|
|
968
|
+
// before, AC #4 backward-compat.
|
|
969
|
+
const workers = opts.workers ?? 1;
|
|
970
|
+
// ARV-328: progress accounting rides on op completion.
|
|
971
|
+
let opsDone = 0;
|
|
972
|
+
let casesDone = 0;
|
|
973
|
+
const opReports = await runPool(ops, workers, async (op: EndpointInfo) => {
|
|
974
|
+
const report = await processOperation(op);
|
|
975
|
+
opsDone += 1;
|
|
976
|
+
casesDone += report.cases;
|
|
977
|
+
opts.onProgress?.({ done: opsDone, total: ops.length, cases: casesDone });
|
|
978
|
+
return report;
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
let findings: CheckFinding[] = [];
|
|
982
|
+
/** ARV-60: per-check accumulator for spec_findings rollup. Built from
|
|
983
|
+
* the per-op `applicableChecks` and `casesByCheck` fields each worker
|
|
984
|
+
* returns; skipped is reconstructed from `summary.skipped_outcomes`
|
|
985
|
+
* after the loop. */
|
|
986
|
+
const perCheckApplicable: Map<string, number> = new Map();
|
|
987
|
+
const perCheckCases: Map<string, number> = new Map();
|
|
988
|
+
// ARV-307: run-level positive-probe baseline health.
|
|
989
|
+
let positiveTotal = 0;
|
|
990
|
+
let positiveTwoxx = 0;
|
|
991
|
+
for (const report of opReports) {
|
|
992
|
+
summary.cases += report.cases;
|
|
993
|
+
positiveTotal += report.positiveTotal;
|
|
994
|
+
positiveTwoxx += report.positiveTwoxx;
|
|
995
|
+
for (const [key, n] of Object.entries(report.skipped)) {
|
|
996
|
+
summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + n;
|
|
997
|
+
}
|
|
998
|
+
for (const id of report.applicableChecks) {
|
|
999
|
+
perCheckApplicable.set(id, (perCheckApplicable.get(id) ?? 0) + 1);
|
|
1000
|
+
}
|
|
1001
|
+
for (const [id, n] of Object.entries(report.casesByCheck)) {
|
|
1002
|
+
perCheckCases.set(id, (perCheckCases.get(id) ?? 0) + n);
|
|
1003
|
+
}
|
|
1004
|
+
for (const f of report.findings) {
|
|
1005
|
+
// ARV-251: stamp finding category from check id if not already
|
|
1006
|
+
// present. Probes carry their own category; checks derive it
|
|
1007
|
+
// from the check id. The bucket increment is the same code path.
|
|
1008
|
+
if (!f.category) f.category = categoryFor(f.check);
|
|
1009
|
+
findings.push(f);
|
|
1010
|
+
// ARV-283: suppressed findings stay in the buffer (for ndjson
|
|
1011
|
+
// audit-trail) but skip the summary tallies that drive CI gates.
|
|
1012
|
+
if (f.suppressed_by) {
|
|
1013
|
+
summary.suppressed = (summary.suppressed ?? 0) + 1;
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
summary.findings += 1;
|
|
1017
|
+
summary.by_severity[f.severity] += 1;
|
|
1018
|
+
summary.by_category[f.category] += 1;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// ── Stateful phase (ARV-3) ─────────────────────────────────────────
|
|
1023
|
+
// Stateful checks share the same --check / --exclude-check filters as
|
|
1024
|
+
// the response-phase ones. We honour `selection` ids and only run a
|
|
1025
|
+
// stateful check whose id was either explicitly included or not
|
|
1026
|
+
// explicitly excluded.
|
|
1027
|
+
const includeSet = opts.include && opts.include.length > 0 ? new Set(opts.include) : null;
|
|
1028
|
+
const excludeSet = new Set(opts.exclude ?? []);
|
|
1029
|
+
const activeStateful = filterChecksByMode(
|
|
1030
|
+
listStatefulChecks().filter((c) => {
|
|
1031
|
+
if (excludeSet.has(c.id)) return false;
|
|
1032
|
+
if (includeSet && !includeSet.has(c.id)) return false;
|
|
1033
|
+
return true;
|
|
1034
|
+
}),
|
|
1035
|
+
mode,
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
if (opts.skipStateful && activeStateful.length > 0) {
|
|
1039
|
+
const key = `stateful-skipped:budget`;
|
|
1040
|
+
summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + activeStateful.length;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (!opts.skipStateful && activeStateful.length > 0) {
|
|
1044
|
+
const baseHarness = makeHarness(opts.baseUrl, doc, {
|
|
1045
|
+
authHeaders: opts.authHeaders,
|
|
1046
|
+
bootstrapCleanupFailed: opts.bootstrapCleanupFailed,
|
|
1047
|
+
timeoutMs: opts.timeoutMs,
|
|
1048
|
+
// ARV-181: stateful checks (ignored_auth) need the same
|
|
1049
|
+
// fixture-driven path-var substitution that ARV-141 wired into
|
|
1050
|
+
// the per-response runner — without this the synthetic baseline
|
|
1051
|
+
// lands on literal `/{event_id}` and the broken-baseline guard
|
|
1052
|
+
// skips the whole op.
|
|
1053
|
+
pathVars: opts.pathVars,
|
|
1054
|
+
options: checkRuntimeOptions,
|
|
1055
|
+
resourceConfigs: opts.resourceConfigs,
|
|
1056
|
+
// ARV-227: same budget instance as the per-response phase so a
|
|
1057
|
+
// cap of N applies to the whole run, not per-phase.
|
|
1058
|
+
requestBudget,
|
|
1059
|
+
});
|
|
1060
|
+
// ARV-265: per-check harness wrapper that fires onCase for every
|
|
1061
|
+
// HTTP call the stateful check performs. The phase tag distinguishes
|
|
1062
|
+
// auth vs crud — used by the persistence adapter to bucket suite names.
|
|
1063
|
+
function harnessFor(checkId: string, phase: "stateful_auth" | "stateful_crud"): typeof baseHarness {
|
|
1064
|
+
if (!opts.onCase) return baseHarness;
|
|
1065
|
+
return {
|
|
1066
|
+
...baseHarness,
|
|
1067
|
+
send: async (req, sendOpts) => {
|
|
1068
|
+
try {
|
|
1069
|
+
const resp = await baseHarness.send(req, sendOpts);
|
|
1070
|
+
opts.onCase!({
|
|
1071
|
+
phase,
|
|
1072
|
+
checkId,
|
|
1073
|
+
kind: checkId,
|
|
1074
|
+
operation: { method: req.method, path: req.url },
|
|
1075
|
+
request: req,
|
|
1076
|
+
response: resp,
|
|
1077
|
+
durationMs: resp.duration_ms,
|
|
1078
|
+
verdict: "pass",
|
|
1079
|
+
});
|
|
1080
|
+
return resp;
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
opts.onCase!({
|
|
1083
|
+
phase,
|
|
1084
|
+
checkId,
|
|
1085
|
+
kind: checkId,
|
|
1086
|
+
operation: { method: req.method, path: req.url },
|
|
1087
|
+
request: req,
|
|
1088
|
+
durationMs: 0,
|
|
1089
|
+
verdict: "error",
|
|
1090
|
+
error: (err as Error).message,
|
|
1091
|
+
});
|
|
1092
|
+
throw err;
|
|
1093
|
+
}
|
|
1094
|
+
},
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
// ARV-332: build CRUD groups from the *filtered* op set (`ops`), not
|
|
1098
|
+
// `allOps`. Under a read-only scope (`--include method:GET`) the filter
|
|
1099
|
+
// strips POST/PUT/PATCH, so no group carries a `create`/`update` and the
|
|
1100
|
+
// mutating stateful checks (ensure_resource_availability, use_after_free)
|
|
1101
|
+
// self-skip via `applies(g)` — instead of leaking a live POST create
|
|
1102
|
+
// despite the GET-only filter. Read-only stateful checks (pagination
|
|
1103
|
+
// invariants, observation-mode lifecycle) still run on the list/read ops.
|
|
1104
|
+
// ARV-299: in safe mode, feed only read-only ops to the CRUD builder so
|
|
1105
|
+
// no group carries a create/update/delete — same self-skip path ARV-332
|
|
1106
|
+
// uses for `--include method:GET`, but driven by the safe/live toggle.
|
|
1107
|
+
const statefulOps = opts.safe
|
|
1108
|
+
? ops.filter((o) => {
|
|
1109
|
+
const m = o.method.toUpperCase();
|
|
1110
|
+
return m === "GET" || m === "HEAD";
|
|
1111
|
+
})
|
|
1112
|
+
: ops;
|
|
1113
|
+
const crudGroups = activeStateful.some((c) => c.phase === "crud")
|
|
1114
|
+
? augmentWithListOnlyGroups(detectCrudGroups(statefulOps), statefulOps)
|
|
1115
|
+
: [];
|
|
1116
|
+
summary.checks_run += activeStateful.length;
|
|
1117
|
+
|
|
1118
|
+
// ARV-8: parallelize auth-phase ops and crud-phase groups via the
|
|
1119
|
+
// same pool. CRUD-chain integrity stays intact because the *check*
|
|
1120
|
+
// owns its own sequential within-chain logic — the pool only runs
|
|
1121
|
+
// *independent* groups in parallel.
|
|
1122
|
+
const statefulWorkers = opts.workers ?? 1;
|
|
1123
|
+
const collected: CheckFinding[] = [];
|
|
1124
|
+
function pushStateful(f: CheckFinding): void {
|
|
1125
|
+
if (!f.category) f.category = categoryFor(f.check);
|
|
1126
|
+
collected.push(f);
|
|
1127
|
+
if (f.suppressed_by) {
|
|
1128
|
+
summary.suppressed = (summary.suppressed ?? 0) + 1;
|
|
1129
|
+
} else {
|
|
1130
|
+
summary.findings += 1;
|
|
1131
|
+
summary.by_severity[f.severity] += 1;
|
|
1132
|
+
summary.by_category[f.category] += 1;
|
|
1133
|
+
}
|
|
1134
|
+
if (opts.onEvent) opts.onEvent({ type: "finding", ts: nowIso(), check: f.check, finding: f });
|
|
1135
|
+
}
|
|
1136
|
+
for (const check of activeStateful) {
|
|
1137
|
+
if (check.phase === "auth") {
|
|
1138
|
+
const allApplicable = ops.filter((op) => check.applies(op));
|
|
1139
|
+
// ARV-189: spec-level x-zond-skip removes endpoints from the
|
|
1140
|
+
// worker pool entirely. The skip is surfaced via skipped_outcomes
|
|
1141
|
+
// so the operator sees how many ops were spec-suppressed.
|
|
1142
|
+
const applicable: typeof allApplicable = [];
|
|
1143
|
+
for (const op of allApplicable) {
|
|
1144
|
+
if (endpointSkipsCheck(op, check.id)) {
|
|
1145
|
+
const key = `${check.id}: ${reasonForSkip(op, check.id)}`;
|
|
1146
|
+
summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + 1;
|
|
1147
|
+
summary.cases += 1;
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
applicable.push(op);
|
|
1151
|
+
}
|
|
1152
|
+
// ARV-60: track applicability + cases for spec_findings rollup.
|
|
1153
|
+
perCheckApplicable.set(check.id, (perCheckApplicable.get(check.id) ?? 0) + applicable.length);
|
|
1154
|
+
// ARV-154: track per-op cases + skip reasons for the stateful auth
|
|
1155
|
+
// path. Previously this loop only forwarded `fail` outcomes; runs
|
|
1156
|
+
// like `--check ignored_auth` on a fully-protected API where every
|
|
1157
|
+
// baseline passes returned `{operations: 48, cases: 0, findings: 0}`
|
|
1158
|
+
// with no skipped_outcomes, making the check look broken when it
|
|
1159
|
+
// was actually working (no auth bypass found). Mirror the
|
|
1160
|
+
// observability of the non-stateful path: count attempted cases
|
|
1161
|
+
// and bucket skip reasons by `<check>: <reason>`.
|
|
1162
|
+
type StatefulOutcome =
|
|
1163
|
+
| { kind: "fail"; finding: CheckFinding }
|
|
1164
|
+
| { kind: "skip"; reason: string }
|
|
1165
|
+
| { kind: "pass" };
|
|
1166
|
+
const authHarness = harnessFor(check.id, "stateful_auth");
|
|
1167
|
+
const opReports = await runPool<typeof applicable[number], StatefulOutcome>(
|
|
1168
|
+
applicable,
|
|
1169
|
+
statefulWorkers,
|
|
1170
|
+
async (op): Promise<StatefulOutcome> => {
|
|
1171
|
+
let outcome;
|
|
1172
|
+
try {
|
|
1173
|
+
outcome = await check.run(op, authHarness);
|
|
1174
|
+
} catch (err) {
|
|
1175
|
+
outcome = { kind: "skip" as const, reason: `error: ${(err as Error).message}` };
|
|
1176
|
+
}
|
|
1177
|
+
// ARV-314: emit check_result for stateful checks too, so the ndjson
|
|
1178
|
+
// event schema is stable regardless of --check selection (the
|
|
1179
|
+
// per-response phase already does this). Without it a consumer keyed
|
|
1180
|
+
// on .type=="check_result" got zero rows from a stateful-only run.
|
|
1181
|
+
if (opts.onEvent && (outcome.kind === "pass" || outcome.kind === "fail")) {
|
|
1182
|
+
opts.onEvent({
|
|
1183
|
+
type: "check_result",
|
|
1184
|
+
ts: nowIso(),
|
|
1185
|
+
check: check.id,
|
|
1186
|
+
// ARV-351: match the finding's severity (see per-response path).
|
|
1187
|
+
severity: (outcome.kind === "fail" ? outcome.severity : undefined) ?? check.severity,
|
|
1188
|
+
verdict: outcome.kind,
|
|
1189
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
1190
|
+
request_signature: `${op.method.toUpperCase()} ${op.path}`,
|
|
1191
|
+
response: { status: (outcome.kind === "fail" ? outcome.responseStatus : undefined) ?? 0 },
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
if (outcome.kind === "fail") {
|
|
1195
|
+
// ARV-286 (follow-up ARV-284): respect per-finding severity
|
|
1196
|
+
// returned by stateful check via `outcome.severity` — declared
|
|
1197
|
+
// `check.severity` is the proof-cap baseline.
|
|
1198
|
+
const finding: CheckFinding = {
|
|
1199
|
+
check: check.id,
|
|
1200
|
+
severity: outcome.severity ?? check.severity,
|
|
1201
|
+
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
1202
|
+
request_signature: `${op.method.toUpperCase()} ${op.path}`,
|
|
1203
|
+
response_summary: { status: outcome.responseStatus ?? 0 },
|
|
1204
|
+
message: outcome.message,
|
|
1205
|
+
evidence: outcome.evidence,
|
|
1206
|
+
recommended_action: recommendForCheck(check.id),
|
|
1207
|
+
};
|
|
1208
|
+
return { kind: "fail", finding };
|
|
1209
|
+
}
|
|
1210
|
+
if (outcome.kind === "skip") {
|
|
1211
|
+
return { kind: "skip", reason: outcome.reason ?? "unspecified" };
|
|
1212
|
+
}
|
|
1213
|
+
return { kind: "pass" };
|
|
1214
|
+
});
|
|
1215
|
+
for (const o of opReports) {
|
|
1216
|
+
summary.cases += 1;
|
|
1217
|
+
perCheckCases.set(check.id, (perCheckCases.get(check.id) ?? 0) + 1);
|
|
1218
|
+
if (o.kind === "fail") pushStateful(o.finding);
|
|
1219
|
+
else if (o.kind === "skip") {
|
|
1220
|
+
const key = `${check.id}: ${o.reason}`;
|
|
1221
|
+
summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + 1;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
} else {
|
|
1225
|
+
const allApplicable = crudGroups.filter((g) => check.applies(g));
|
|
1226
|
+
// ARV-189: spec-level x-zond-skip on the resource's canonical
|
|
1227
|
+
// endpoint (create > list > read) opts the entire CRUD group
|
|
1228
|
+
// out — `x-zond-skip: [...]` placed on POST /widgets suppresses
|
|
1229
|
+
// every stateful check listed there for the whole widget chain.
|
|
1230
|
+
const applicable: typeof allApplicable = [];
|
|
1231
|
+
for (const g of allApplicable) {
|
|
1232
|
+
const repOp = representativeOp(g);
|
|
1233
|
+
if (repOp && endpointSkipsCheck(repOp, check.id)) {
|
|
1234
|
+
const key = `${check.id}: ${reasonForSkip(repOp, check.id)}`;
|
|
1235
|
+
summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + 1;
|
|
1236
|
+
summary.cases += 1;
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
applicable.push(g);
|
|
1240
|
+
}
|
|
1241
|
+
// ARV-60: track applicability + cases for spec_findings rollup.
|
|
1242
|
+
perCheckApplicable.set(check.id, (perCheckApplicable.get(check.id) ?? 0) + applicable.length);
|
|
1243
|
+
// ARV-154: mirror the auth-phase observability — count CRUD groups
|
|
1244
|
+
// attempted and record skip reasons, not just failures.
|
|
1245
|
+
type StatefulOutcome =
|
|
1246
|
+
| { kind: "fail"; finding: CheckFinding }
|
|
1247
|
+
| { kind: "skip"; reason: string }
|
|
1248
|
+
| { kind: "pass" };
|
|
1249
|
+
const crudHarness = harnessFor(check.id, "stateful_crud");
|
|
1250
|
+
const groupReports = await runPool<typeof applicable[number], StatefulOutcome>(
|
|
1251
|
+
applicable,
|
|
1252
|
+
statefulWorkers,
|
|
1253
|
+
async (group): Promise<StatefulOutcome> => {
|
|
1254
|
+
let outcome;
|
|
1255
|
+
try {
|
|
1256
|
+
outcome = await check.run(group, crudHarness);
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
outcome = { kind: "skip" as const, reason: `error: ${(err as Error).message}` };
|
|
1259
|
+
}
|
|
1260
|
+
// ARV-314: emit check_result for CRUD-stateful checks too (see the
|
|
1261
|
+
// auth loop above) so the ndjson event schema stays stable.
|
|
1262
|
+
// ARV-319: `group.create`/`.read` are BOTH optional (a group can be
|
|
1263
|
+
// update/delete/list-only) — the earlier `group.create ?? group.read!`
|
|
1264
|
+
// non-null assertion crashed with "undefined is not an object" the
|
|
1265
|
+
// first time a check passed/failed on such a group (Stripe has
|
|
1266
|
+
// plenty: update-only or list-only resources). representativeOp()
|
|
1267
|
+
// widens the fallback chain and stays undefined-safe.
|
|
1268
|
+
const groupRepOp = representativeOp(group);
|
|
1269
|
+
const outcomeOp = outcome.kind === "fail" ? outcome.operation : undefined;
|
|
1270
|
+
const evOp = outcomeOp ?? groupRepOp;
|
|
1271
|
+
if (opts.onEvent && (outcome.kind === "pass" || outcome.kind === "fail") && evOp) {
|
|
1272
|
+
opts.onEvent({
|
|
1273
|
+
type: "check_result",
|
|
1274
|
+
ts: nowIso(),
|
|
1275
|
+
check: check.id,
|
|
1276
|
+
// ARV-351: match the finding's severity (see finding below).
|
|
1277
|
+
severity: (outcome.kind === "fail" ? outcome.severity : undefined) ?? check.severity,
|
|
1278
|
+
verdict: outcome.kind,
|
|
1279
|
+
operation: evOp,
|
|
1280
|
+
request_signature: `${evOp.method.toUpperCase()} ${evOp.path} (chain)`,
|
|
1281
|
+
response: { status: (outcome.kind === "fail" ? outcome.responseStatus : undefined) ?? 0 },
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
if (outcome.kind === "fail") {
|
|
1285
|
+
// ARV-310: prefer the check's explicit operation attribution (e.g.
|
|
1286
|
+
// cursor_boundary_fuzzing → the GET list it probed) over the
|
|
1287
|
+
// group's canonical create/read op. ARV-319: groupRepOp can be
|
|
1288
|
+
// undefined for a group with no create/list/read/update/delete
|
|
1289
|
+
// (shouldn't happen if `check.applies()` gated correctly, but
|
|
1290
|
+
// this path must not crash if it does) — fall back to a
|
|
1291
|
+
// synthetic operation rather than dereferencing undefined.
|
|
1292
|
+
const opFor = outcome.operation ?? (groupRepOp
|
|
1293
|
+
? { path: groupRepOp.path, method: groupRepOp.method, operationId: groupRepOp.operationId }
|
|
1294
|
+
: { path: group.basePath, method: "UNKNOWN" });
|
|
1295
|
+
// ARV-287/288 (follow-up ARV-284): respect per-finding severity
|
|
1296
|
+
// from stateful CRUD checks (cross_call_references,
|
|
1297
|
+
// pagination_invariants) — declared severity is the proof-cap
|
|
1298
|
+
// baseline.
|
|
1299
|
+
const finding: CheckFinding = {
|
|
1300
|
+
check: check.id,
|
|
1301
|
+
severity: outcome.severity ?? check.severity,
|
|
1302
|
+
operation: opFor,
|
|
1303
|
+
request_signature: `${opFor.method.toUpperCase()} ${opFor.path} (chain)`,
|
|
1304
|
+
response_summary: { status: outcome.responseStatus ?? 0 },
|
|
1305
|
+
message: outcome.message,
|
|
1306
|
+
evidence: outcome.evidence,
|
|
1307
|
+
recommended_action: recommendForCheck(check.id),
|
|
1308
|
+
};
|
|
1309
|
+
return { kind: "fail", finding };
|
|
1310
|
+
}
|
|
1311
|
+
if (outcome.kind === "skip") {
|
|
1312
|
+
return { kind: "skip", reason: outcome.reason ?? "unspecified" };
|
|
1313
|
+
}
|
|
1314
|
+
return { kind: "pass" };
|
|
1315
|
+
});
|
|
1316
|
+
for (const o of groupReports) {
|
|
1317
|
+
summary.cases += 1;
|
|
1318
|
+
perCheckCases.set(check.id, (perCheckCases.get(check.id) ?? 0) + 1);
|
|
1319
|
+
if (o.kind === "fail") pushStateful(o.finding);
|
|
1320
|
+
else if (o.kind === "skip") {
|
|
1321
|
+
const key = `${check.id}: ${o.reason}`;
|
|
1322
|
+
summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + 1;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
findings.push(...collected);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// ARV-307: run-level broken-baseline guard for the conformance family.
|
|
1331
|
+
// When the positive-probe baseline was degenerate (>90% non-2xx), the
|
|
1332
|
+
// conformance findings are baseline artifacts — replace the per-op pile
|
|
1333
|
+
// with a single broken_baseline spec_finding and decrement the summary
|
|
1334
|
+
// tallies for the removed findings so CI gates and category rollups match.
|
|
1335
|
+
const baselineGuard = applyBrokenBaselineGuard({ findings, positiveTotal, positiveTwoxx });
|
|
1336
|
+
const extraSpecFindings: SpecFinding[] = [];
|
|
1337
|
+
if (baselineGuard.specFinding) {
|
|
1338
|
+
findings = baselineGuard.kept;
|
|
1339
|
+
for (const f of baselineGuard.removed) {
|
|
1340
|
+
summary.findings -= 1;
|
|
1341
|
+
summary.by_severity[f.severity] -= 1;
|
|
1342
|
+
if (f.category) summary.by_category[f.category] -= 1;
|
|
1343
|
+
// ARV-322: the finding event already streamed to NDJSON before the
|
|
1344
|
+
// guard fired, so a consumer counting `type:finding` records would
|
|
1345
|
+
// see one more than `summary.findings`. Route the removal through
|
|
1346
|
+
// `summary.suppressed` (same bucket as ARV-283 calibration
|
|
1347
|
+
// suppressions) so `findings + suppressed` always reconciles with
|
|
1348
|
+
// the stream instead of silently diverging.
|
|
1349
|
+
summary.suppressed = (summary.suppressed ?? 0) + 1;
|
|
1350
|
+
}
|
|
1351
|
+
const reasonKey = `status_code_conformance: broken-baseline (${baselineGuard.removed.length} conformance finding(s) suppressed)`;
|
|
1352
|
+
summary.skipped_outcomes[reasonKey] = (summary.skipped_outcomes[reasonKey] ?? 0) + baselineGuard.removed.length;
|
|
1353
|
+
extraSpecFindings.push(baselineGuard.specFinding);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const highOrCritical = findings.filter(
|
|
1357
|
+
(f) => f.severity === "high" || f.severity === "critical",
|
|
1358
|
+
).length;
|
|
1359
|
+
|
|
1360
|
+
// ARV-60: spec-level rollup. Build per-check observations from the
|
|
1361
|
+
// accumulators above + the skipped-outcome buckets keyed by
|
|
1362
|
+
// `<check_id>: <reason>`. Then compute clusters that cross the 80%
|
|
1363
|
+
// threshold so the CLI / JSON envelope / NDJSON stream all agree on
|
|
1364
|
+
// which findings are really "one spec gap × N sites".
|
|
1365
|
+
const perCheck: Map<string, PerCheckObservations> = new Map();
|
|
1366
|
+
const allCheckIds = new Set<string>([
|
|
1367
|
+
...perCheckApplicable.keys(),
|
|
1368
|
+
...perCheckCases.keys(),
|
|
1369
|
+
]);
|
|
1370
|
+
for (const id of allCheckIds) {
|
|
1371
|
+
const skipped: Record<string, number> = {};
|
|
1372
|
+
const prefix = `${id}: `;
|
|
1373
|
+
for (const [key, n] of Object.entries(summary.skipped_outcomes)) {
|
|
1374
|
+
if (key.startsWith(prefix)) skipped[key] = n;
|
|
1375
|
+
}
|
|
1376
|
+
perCheck.set(id, {
|
|
1377
|
+
applicable: perCheckApplicable.get(id) ?? 0,
|
|
1378
|
+
cases: perCheckCases.get(id) ?? 0,
|
|
1379
|
+
skipped,
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
// ARV-307: broken-baseline rollup(s) prepend the computed clusters so the
|
|
1383
|
+
// reader sees "your baseline is broken" before any residual per-op rows.
|
|
1384
|
+
const spec_findings = [...extraSpecFindings, ...computeSpecFindings(findings, perCheck)];
|
|
1385
|
+
// ARV-83: build the structured view of `skipped_outcomes` once, after
|
|
1386
|
+
// all per-op/per-group writers have settled. Sorted by descending count
|
|
1387
|
+
// so the most-impactful skip reason lands first.
|
|
1388
|
+
summary.skipped_outcomes_grouped = groupSkippedOutcomes(summary.skipped_outcomes);
|
|
1389
|
+
|
|
1390
|
+
// ARV-60: emit each spec finding as its own NDJSON event before the
|
|
1391
|
+
// terminal summary line, so a streaming consumer sees rollups in the
|
|
1392
|
+
// same order the CLI prints them.
|
|
1393
|
+
if (opts.onEvent) {
|
|
1394
|
+
for (const sf of spec_findings) {
|
|
1395
|
+
opts.onEvent({ type: "spec_finding", ts: nowIso(), check: sf.check, spec_finding: sf });
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ARV-10: terminal event so downstream consumers know the run wrapped
|
|
1400
|
+
// (vs. the producer crashing). Mirrors what the JSON envelope's
|
|
1401
|
+
// `summary` field carries, just delivered as the final NDJSON line.
|
|
1402
|
+
if (opts.onEvent) opts.onEvent({ type: "summary", ts: nowIso(), summary });
|
|
1403
|
+
|
|
1404
|
+
return {
|
|
1405
|
+
data: { findings, summary, spec_findings },
|
|
1406
|
+
selection,
|
|
1407
|
+
high_or_critical: highOrCritical,
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* ARV-219 follow-up: `detectCrudGroups` only emits groups for resources
|
|
1413
|
+
* with a POST endpoint, so list-only collections (workflow runs, search
|
|
1414
|
+
* results, public lists) never reach the stateful CRUD phase. Several
|
|
1415
|
+
* stateful checks operate on lists alone:
|
|
1416
|
+
* - `pagination_invariants` (page/cursor disjointness)
|
|
1417
|
+
* - `lifecycle_transitions` observation mode (observed ⊆ declared states)
|
|
1418
|
+
*
|
|
1419
|
+
* Synthesize minimal groups for list-only resources surfaced by the
|
|
1420
|
+
* resource-map builder (which already knows how to identify
|
|
1421
|
+
* implicit-list paths via `ownerListPaths`). The synthesized group
|
|
1422
|
+
* carries just `list` + optional `read` — sufficient for the lookups
|
|
1423
|
+
* each list-only check performs. Resources already covered by a real
|
|
1424
|
+
* CRUD group (matched by name) are not duplicated.
|
|
1425
|
+
*/
|
|
1426
|
+
function augmentWithListOnlyGroups(crudGroups: CrudGroup[], allOps: EndpointInfo[]): CrudGroup[] {
|
|
1427
|
+
let map;
|
|
1428
|
+
try {
|
|
1429
|
+
map = buildApiResourceMap({ endpoints: allOps, specHash: "transient" });
|
|
1430
|
+
} catch {
|
|
1431
|
+
// Defensive: never fail the run because the resource map couldn't
|
|
1432
|
+
// be built (real CRUD groups still run).
|
|
1433
|
+
return crudGroups;
|
|
1434
|
+
}
|
|
1435
|
+
const existing = new Set(crudGroups.map(g => g.resource));
|
|
1436
|
+
const findEp = (label: string | undefined): EndpointInfo | undefined => {
|
|
1437
|
+
if (!label) return undefined;
|
|
1438
|
+
const idx = label.indexOf(" ");
|
|
1439
|
+
if (idx === -1) return undefined;
|
|
1440
|
+
const method = label.slice(0, idx).toUpperCase();
|
|
1441
|
+
const path = label.slice(idx + 1);
|
|
1442
|
+
return allOps.find(e => e.method.toUpperCase() === method && e.path === path);
|
|
1443
|
+
};
|
|
1444
|
+
const augmented: CrudGroup[] = [];
|
|
1445
|
+
for (const r of map.resources) {
|
|
1446
|
+
if (existing.has(r.resource)) continue;
|
|
1447
|
+
if (!r.endpoints.list) continue;
|
|
1448
|
+
const listEp = findEp(r.endpoints.list);
|
|
1449
|
+
if (!listEp) continue;
|
|
1450
|
+
const readEp = findEp(r.endpoints.read);
|
|
1451
|
+
augmented.push({
|
|
1452
|
+
resource: r.resource,
|
|
1453
|
+
basePath: r.basePath,
|
|
1454
|
+
itemPath: r.itemPath,
|
|
1455
|
+
idParam: r.idParam,
|
|
1456
|
+
list: listEp,
|
|
1457
|
+
...(readEp ? { read: readEp } : {}),
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
return augmented.length > 0 ? [...crudGroups, ...augmented] : crudGroups;
|
|
1461
|
+
}
|