@kirrosh/zond 0.23.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +164 -1
- package/README.md +8 -7
- package/package.json +2 -3
- package/src/CLAUDE.md +112 -0
- package/src/cli/commands/add-api.ts +19 -7
- package/src/cli/commands/api/annotate/index.ts +359 -4
- package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
- package/src/cli/commands/api/annotate/overlay.ts +1 -1
- package/src/cli/commands/api/annotate/pagination.ts +10 -6
- package/src/cli/commands/api/annotate/prompts.ts +39 -2
- package/src/cli/commands/audit.ts +360 -54
- package/src/cli/commands/check.ts +15 -2
- package/src/cli/commands/checks.ts +352 -36
- package/src/cli/commands/cleanup.ts +4 -30
- package/src/cli/commands/coverage.ts +275 -57
- package/src/cli/commands/db.ts +311 -8
- package/src/cli/commands/discover.ts +281 -161
- package/src/cli/commands/doctor.ts +57 -3
- package/src/cli/commands/fixtures.ts +1 -1
- package/src/cli/commands/generate.ts +24 -7
- package/src/cli/commands/init/bootstrap.ts +4 -1
- package/src/cli/commands/init/index.ts +2 -2
- package/src/cli/commands/init/skills.ts +43 -0
- package/src/cli/commands/init/templates/agents.md +12 -3
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
- package/src/cli/commands/init/templates/skills/zond.md +274 -64
- package/src/cli/commands/init/templates/zond-config.yml +1 -1
- package/src/cli/commands/prepare-fixtures.ts +14 -52
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +101 -10
- package/src/cli/commands/probe/security.ts +95 -12
- package/src/cli/commands/probe/webhooks.ts +2 -0
- package/src/cli/commands/probe.ts +87 -11
- package/src/cli/commands/refresh-api.ts +59 -1
- package/src/cli/commands/report-bundle.ts +3 -11
- package/src/cli/commands/request.ts +116 -0
- package/src/cli/commands/run.ts +33 -5
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/json-envelope.ts +0 -20
- package/src/cli/json-schemas.ts +51 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +5 -4
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +0 -10
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/cross_call_references.ts +17 -4
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/idempotency_replay.ts +1 -5
- package/src/core/checks/checks/ignored_auth.ts +44 -1
- package/src/core/checks/checks/index.ts +3 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
- package/src/core/checks/checks/negative_data_rejection.ts +119 -16
- package/src/core/checks/checks/not_a_server_error.ts +8 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
- package/src/core/checks/checks/pagination_invariants.ts +298 -117
- package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
- package/src/core/checks/checks/status_code_conformance.ts +78 -7
- package/src/core/checks/mode.ts +3 -0
- package/src/core/checks/recommended-action.ts +5 -1
- package/src/core/checks/runner.ts +614 -27
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/types.ts +117 -1
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +35 -6
- package/src/core/coverage/loader.ts +31 -0
- package/src/core/diagnostics/db-analysis.ts +200 -106
- package/src/core/diagnostics/failure-class.ts +21 -1
- package/src/core/diagnostics/failure-hints.ts +4 -208
- package/src/core/diagnostics/suggested-fixes.ts +2 -3
- package/src/core/generator/chunker.ts +1 -8
- package/src/core/generator/data-factory.ts +199 -61
- package/src/core/generator/fixtures-builder.ts +38 -31
- package/src/core/generator/index.ts +0 -2
- package/src/core/generator/openapi-reader.ts +98 -4
- package/src/core/generator/path-param-disambig.ts +30 -4
- package/src/core/generator/resources-builder.ts +276 -26
- package/src/core/generator/schema-utils.ts +22 -0
- package/src/core/generator/suite-generator.ts +168 -15
- package/src/core/generator/types.ts +6 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/output/README.md +11 -29
- package/src/core/output/index.ts +1 -1
- package/src/core/output/run.ts +0 -35
- package/src/core/output/types.ts +0 -7
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/variables.ts +0 -0
- package/src/core/probe/dry-run-envelope.ts +4 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe.ts +23 -1118
- package/src/core/probe/mass-assignment-template.ts +32 -4
- package/src/core/probe/path-discovery.ts +3 -4
- package/src/core/probe/probe-harness.ts +21 -22
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +8 -2
- package/src/core/probe/security-probe.ts +28 -1449
- package/src/core/probe/shared.ts +26 -0
- package/src/core/probe/webhooks-probe.ts +5 -7
- package/src/core/runner/assertions.ts +1 -1
- package/src/core/runner/executor.ts +3 -18
- package/src/core/runner/form-encode.ts +8 -18
- package/src/core/runner/http-client.ts +38 -1
- package/src/core/runner/preflight-vars.ts +19 -15
- package/src/core/runner/rate-limiter.ts +11 -29
- package/src/core/runner/run-kind.ts +7 -1
- package/src/core/runner/schema-validator.ts +2 -6
- package/src/core/runner/send-request.ts +11 -6
- package/src/core/runner/types.ts +6 -0
- package/src/core/setup-api.ts +53 -15
- package/src/core/severity/index.ts +0 -63
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/db/migrate.ts +2 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/queries/collections.ts +2 -2
- package/src/db/queries/results.ts +88 -0
- package/src/db/queries/runs.ts +56 -2
- package/src/db/queries.ts +3 -0
- package/src/db/schema.ts +7 -7
- package/src/cli/commands/bootstrap.ts +0 -710
- package/src/core/anti-fp/bootstrap.ts +0 -34
- package/src/core/anti-fp/index.ts +0 -33
- package/src/core/anti-fp/registry.ts +0 -44
- package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
- package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
- package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
- package/src/core/anti-fp/types.ts +0 -68
- package/src/core/generator/create-body.ts +0 -89
|
@@ -15,6 +15,8 @@ import type { OpenAPIV3 } from "openapi-types";
|
|
|
15
15
|
|
|
16
16
|
import { extractEndpoints, readOpenApiSpec } from "../generator/index.ts";
|
|
17
17
|
import { detectCrudGroups } from "../generator/suite-generator.ts";
|
|
18
|
+
import { buildApiResourceMap } from "../generator/resources-builder.ts";
|
|
19
|
+
import type { CrudGroup } from "../generator/types.ts";
|
|
18
20
|
import type { EndpointInfo } from "../generator/types.ts";
|
|
19
21
|
import { generateFromSchema } from "../generator/data-factory.ts";
|
|
20
22
|
import {
|
|
@@ -41,16 +43,57 @@ import { nowIso, type NdjsonEvent } from "../reporter/ndjson.ts";
|
|
|
41
43
|
import { runPool } from "../runner/async-pool.ts";
|
|
42
44
|
import type { RateLimiter } from "../runner/rate-limiter.ts";
|
|
43
45
|
import { recommendForCheck } from "./recommended-action.ts";
|
|
46
|
+
import { gapKey } from "../workspace/fixture-gaps.ts";
|
|
47
|
+
import { endpointSkipsCheck, reasonForSkip } from "./zond-extensions.ts";
|
|
44
48
|
import {
|
|
45
49
|
emptySummary,
|
|
50
|
+
groupSkippedOutcomes,
|
|
46
51
|
type CaseKind,
|
|
47
52
|
type Check,
|
|
48
53
|
type CheckCase,
|
|
49
54
|
type CheckFinding,
|
|
50
55
|
type CheckRunData,
|
|
51
56
|
type CheckRunSummary,
|
|
57
|
+
type SpecFinding,
|
|
52
58
|
} from "./types.ts";
|
|
59
|
+
import type { Severity } from "../severity/index.ts";
|
|
53
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
|
+
}
|
|
54
97
|
|
|
55
98
|
export interface RunChecksOptions {
|
|
56
99
|
specPath: string;
|
|
@@ -86,6 +129,12 @@ export interface RunChecksOptions {
|
|
|
86
129
|
* buffering until the run finishes). Must not throw — exceptions are
|
|
87
130
|
* the caller's responsibility (the runner doesn't catch). */
|
|
88
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;
|
|
89
138
|
/** ARV-8 — bounded async-pool concurrency at the *operation* level.
|
|
90
139
|
* `1` (default) = sequential, identical to the pre-ARV-8 behaviour.
|
|
91
140
|
* Cases within an operation always run sequentially regardless of
|
|
@@ -121,6 +170,22 @@ export interface RunChecksOptions {
|
|
|
121
170
|
* vars are filled. Keyed by path-param name (e.g. `issue_id`); falls back
|
|
122
171
|
* to the legacy schema-driven placeholder when the name isn't in the map. */
|
|
123
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;
|
|
124
189
|
/** ARV-227: hard cap on outbound HTTP requests for the entire run.
|
|
125
190
|
* Once `used >= limit`, every subsequent case short-circuits and the
|
|
126
191
|
* summary surfaces the cap via `summary.skipped_outcomes
|
|
@@ -128,6 +193,24 @@ export interface RunChecksOptions {
|
|
|
128
193
|
* the same budget so a cap of 100 means 100 requests total across
|
|
129
194
|
* per-response + stateful, not per-phase. Undefined ⇒ uncapped. */
|
|
130
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;
|
|
131
214
|
}
|
|
132
215
|
|
|
133
216
|
export interface RunChecksResult {
|
|
@@ -165,6 +248,48 @@ function fillPathParams(
|
|
|
165
248
|
});
|
|
166
249
|
}
|
|
167
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
|
+
|
|
168
293
|
function requiredHeaders(op: EndpointInfo): OpenAPIV3.ParameterObject[] {
|
|
169
294
|
return op.parameters.filter(
|
|
170
295
|
(p) => (p as OpenAPIV3.ParameterObject).in === "header"
|
|
@@ -466,10 +591,22 @@ function summarizeResponse(resp: HttpResponse): { status: number; content_type?:
|
|
|
466
591
|
return { status: resp.status, content_type: ct };
|
|
467
592
|
}
|
|
468
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
|
+
|
|
469
605
|
/** Build a finding, push it into the per-op buffer, and stream the
|
|
470
606
|
* ARV-10 NDJSON event. Summary aggregation moved out — the caller
|
|
471
607
|
* merges per-op buffers in input order so workers > 1 doesn't have to
|
|
472
|
-
* contend on a shared `summary` object.
|
|
608
|
+
* contend on a shared `summary` object. The finding carries raw
|
|
609
|
+
* evidence + its built-in severity default; the agent re-severitizes. */
|
|
473
610
|
function recordFinding(
|
|
474
611
|
out: CheckFinding[],
|
|
475
612
|
check: Check,
|
|
@@ -478,16 +615,25 @@ function recordFinding(
|
|
|
478
615
|
message: string,
|
|
479
616
|
evidence: Record<string, unknown> | undefined,
|
|
480
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>,
|
|
481
625
|
): void {
|
|
626
|
+
const unresolvedFixture = fixtureGaps?.has(gapKey(c.operation.method, c.operation.path)) ?? false;
|
|
627
|
+
const action = recommendForCheck(check.id, resp.status, unresolvedFixture);
|
|
482
628
|
const finding: CheckFinding = {
|
|
483
629
|
check: check.id,
|
|
484
|
-
severity: check.severity,
|
|
630
|
+
severity: outcomeSeverity ?? check.severity,
|
|
485
631
|
operation: { path: c.operation.path, method: c.operation.method, operationId: c.operation.operationId },
|
|
486
632
|
request_signature: `${c.request.method} ${c.request.url}`,
|
|
487
633
|
response_summary: summarizeResponse(resp),
|
|
488
634
|
message,
|
|
489
635
|
evidence,
|
|
490
|
-
recommended_action:
|
|
636
|
+
recommended_action: action,
|
|
491
637
|
};
|
|
492
638
|
out.push(finding);
|
|
493
639
|
if (onEvent) onEvent({ type: "finding", ts: nowIso(), check: check.id, finding });
|
|
@@ -496,7 +642,13 @@ function recordFinding(
|
|
|
496
642
|
export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult> {
|
|
497
643
|
const doc = await readOpenApiSpec(opts.specPath);
|
|
498
644
|
const allOps = extractEndpoints(doc);
|
|
499
|
-
const
|
|
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);
|
|
500
652
|
const buckets = bucketEndpointsByPath(allOps);
|
|
501
653
|
const schemaValidator: SchemaValidator = createSchemaValidator(doc);
|
|
502
654
|
|
|
@@ -562,12 +714,31 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
562
714
|
cases: number;
|
|
563
715
|
/** ARV-26: skip-outcome counts keyed by `"<check_id>: <reason>"`. */
|
|
564
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;
|
|
565
729
|
}
|
|
566
730
|
|
|
567
731
|
async function processOperation(op: EndpointInfo): Promise<OpReport> {
|
|
568
732
|
const localFindings: CheckFinding[] = [];
|
|
569
733
|
let localCases = 0;
|
|
734
|
+
let localPositiveTotal = 0;
|
|
735
|
+
let localPositiveTwoxx = 0;
|
|
570
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> = {};
|
|
571
742
|
if (opts.onEvent) {
|
|
572
743
|
opts.onEvent({
|
|
573
744
|
type: "check_start",
|
|
@@ -606,6 +777,34 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
606
777
|
cases.push(...buildUnsupportedMethod(op, declared, opts.baseUrl));
|
|
607
778
|
}
|
|
608
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
|
+
|
|
609
808
|
for (const built of cases) {
|
|
610
809
|
if (!caseMatchesMode(built.case.mode, mode)) continue;
|
|
611
810
|
if (opts.authHeaders) injectAuthHeadersIntoCase(built, opts.authHeaders);
|
|
@@ -615,6 +814,18 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
615
814
|
if (!reserveRequest(requestBudget)) {
|
|
616
815
|
localSkipped[`max_requests: ${MAX_REQUESTS_SKIP_REASON}`] =
|
|
617
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
|
+
}
|
|
618
829
|
continue;
|
|
619
830
|
}
|
|
620
831
|
// ARV-8: gate the request through the rate-limiter (no-op when
|
|
@@ -624,7 +835,7 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
624
835
|
if (opts.rateLimiter) await opts.rateLimiter.acquire();
|
|
625
836
|
let httpResp: HttpResponse;
|
|
626
837
|
try {
|
|
627
|
-
httpResp = await
|
|
838
|
+
httpResp = await executeWithResetRetry(built.req, opts.timeoutMs ?? 30000);
|
|
628
839
|
} catch (err) {
|
|
629
840
|
const finding: CheckFinding = {
|
|
630
841
|
check: "network_error",
|
|
@@ -637,19 +848,56 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
637
848
|
};
|
|
638
849
|
localFindings.push(finding);
|
|
639
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
|
+
}
|
|
640
863
|
continue;
|
|
641
864
|
}
|
|
642
865
|
|
|
643
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
|
+
}
|
|
644
874
|
const checkResp = {
|
|
645
875
|
status: httpResp.status,
|
|
646
876
|
headers: httpResp.headers,
|
|
647
877
|
body: httpResp.body_parsed ?? httpResp.body,
|
|
648
878
|
duration_ms: httpResp.duration_ms,
|
|
649
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;
|
|
650
886
|
for (const check of selection.selected) {
|
|
651
887
|
if (!checkKinds(check).includes(built.case.kind)) continue;
|
|
652
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;
|
|
653
901
|
const outcome = check.run({
|
|
654
902
|
case: built.case,
|
|
655
903
|
response: checkResp,
|
|
@@ -658,7 +906,7 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
658
906
|
options: checkRuntimeOptions,
|
|
659
907
|
});
|
|
660
908
|
if (outcome.kind === "fail") {
|
|
661
|
-
recordFinding(localFindings, check, built.case, httpResp, outcome.message, outcome.evidence, opts.onEvent);
|
|
909
|
+
recordFinding(localFindings, check, built.case, httpResp, outcome.message, outcome.evidence, opts.onEvent, outcome.severity, opts.fixtureGaps);
|
|
662
910
|
}
|
|
663
911
|
if (outcome.kind === "skip") {
|
|
664
912
|
// ARV-26: bucket skips by check+reason so the summary can surface
|
|
@@ -671,35 +919,100 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
671
919
|
type: "check_result",
|
|
672
920
|
ts: nowIso(),
|
|
673
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,
|
|
674
926
|
verdict: outcome.kind,
|
|
675
927
|
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
676
928
|
request_signature: `${built.case.request.method} ${built.case.request.url}`,
|
|
677
929
|
response: summarizeResponse(httpResp),
|
|
678
930
|
});
|
|
679
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
|
+
});
|
|
680
953
|
}
|
|
681
954
|
}
|
|
682
|
-
return {
|
|
955
|
+
return {
|
|
956
|
+
findings: localFindings,
|
|
957
|
+
cases: localCases,
|
|
958
|
+
skipped: localSkipped,
|
|
959
|
+
applicableChecks: [...localApplicable],
|
|
960
|
+
casesByCheck: localCasesByCheck,
|
|
961
|
+
positiveTotal: localPositiveTotal,
|
|
962
|
+
positiveTwoxx: localPositiveTwoxx,
|
|
963
|
+
};
|
|
683
964
|
}
|
|
684
965
|
|
|
685
966
|
// ARV-8: parallelize the op-loop. workers=1 (default) preserves the
|
|
686
967
|
// sequential code path inside runPool — same microtask interleaving as
|
|
687
968
|
// before, AC #4 backward-compat.
|
|
688
969
|
const workers = opts.workers ?? 1;
|
|
689
|
-
|
|
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
|
+
});
|
|
690
980
|
|
|
691
|
-
|
|
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;
|
|
692
991
|
for (const report of opReports) {
|
|
693
992
|
summary.cases += report.cases;
|
|
993
|
+
positiveTotal += report.positiveTotal;
|
|
994
|
+
positiveTwoxx += report.positiveTwoxx;
|
|
694
995
|
for (const [key, n] of Object.entries(report.skipped)) {
|
|
695
996
|
summary.skipped_outcomes[key] = (summary.skipped_outcomes[key] ?? 0) + n;
|
|
696
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
|
+
}
|
|
697
1004
|
for (const f of report.findings) {
|
|
698
1005
|
// ARV-251: stamp finding category from check id if not already
|
|
699
1006
|
// present. Probes carry their own category; checks derive it
|
|
700
1007
|
// from the check id. The bucket increment is the same code path.
|
|
701
1008
|
if (!f.category) f.category = categoryFor(f.check);
|
|
702
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
|
+
}
|
|
703
1016
|
summary.findings += 1;
|
|
704
1017
|
summary.by_severity[f.severity] += 1;
|
|
705
1018
|
summary.by_category[f.category] += 1;
|
|
@@ -722,8 +1035,13 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
722
1035
|
mode,
|
|
723
1036
|
);
|
|
724
1037
|
|
|
725
|
-
if (activeStateful.length > 0) {
|
|
726
|
-
const
|
|
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, {
|
|
727
1045
|
authHeaders: opts.authHeaders,
|
|
728
1046
|
bootstrapCleanupFailed: opts.bootstrapCleanupFailed,
|
|
729
1047
|
timeoutMs: opts.timeoutMs,
|
|
@@ -739,7 +1057,62 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
739
1057
|
// cap of N applies to the whole run, not per-phase.
|
|
740
1058
|
requestBudget,
|
|
741
1059
|
});
|
|
742
|
-
|
|
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
|
+
: [];
|
|
743
1116
|
summary.checks_run += activeStateful.length;
|
|
744
1117
|
|
|
745
1118
|
// ARV-8: parallelize auth-phase ops and crud-phase groups via the
|
|
@@ -751,14 +1124,33 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
751
1124
|
function pushStateful(f: CheckFinding): void {
|
|
752
1125
|
if (!f.category) f.category = categoryFor(f.check);
|
|
753
1126
|
collected.push(f);
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
+
}
|
|
757
1134
|
if (opts.onEvent) opts.onEvent({ type: "finding", ts: nowIso(), check: f.check, finding: f });
|
|
758
1135
|
}
|
|
759
1136
|
for (const check of activeStateful) {
|
|
760
1137
|
if (check.phase === "auth") {
|
|
761
|
-
const
|
|
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);
|
|
762
1154
|
// ARV-154: track per-op cases + skip reasons for the stateful auth
|
|
763
1155
|
// path. Previously this loop only forwarded `fail` outcomes; runs
|
|
764
1156
|
// like `--check ignored_auth` on a fully-protected API where every
|
|
@@ -771,23 +1163,44 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
771
1163
|
| { kind: "fail"; finding: CheckFinding }
|
|
772
1164
|
| { kind: "skip"; reason: string }
|
|
773
1165
|
| { kind: "pass" };
|
|
1166
|
+
const authHarness = harnessFor(check.id, "stateful_auth");
|
|
774
1167
|
const opReports = await runPool<typeof applicable[number], StatefulOutcome>(
|
|
775
1168
|
applicable,
|
|
776
1169
|
statefulWorkers,
|
|
777
1170
|
async (op): Promise<StatefulOutcome> => {
|
|
778
1171
|
let outcome;
|
|
779
1172
|
try {
|
|
780
|
-
outcome = await check.run(op,
|
|
1173
|
+
outcome = await check.run(op, authHarness);
|
|
781
1174
|
} catch (err) {
|
|
782
1175
|
outcome = { kind: "skip" as const, reason: `error: ${(err as Error).message}` };
|
|
783
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
|
+
}
|
|
784
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.
|
|
785
1198
|
const finding: CheckFinding = {
|
|
786
1199
|
check: check.id,
|
|
787
|
-
severity: check.severity,
|
|
1200
|
+
severity: outcome.severity ?? check.severity,
|
|
788
1201
|
operation: { path: op.path, method: op.method, operationId: op.operationId },
|
|
789
1202
|
request_signature: `${op.method.toUpperCase()} ${op.path}`,
|
|
790
|
-
response_summary: { status: 0 },
|
|
1203
|
+
response_summary: { status: outcome.responseStatus ?? 0 },
|
|
791
1204
|
message: outcome.message,
|
|
792
1205
|
evidence: outcome.evidence,
|
|
793
1206
|
recommended_action: recommendForCheck(check.id),
|
|
@@ -801,6 +1214,7 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
801
1214
|
});
|
|
802
1215
|
for (const o of opReports) {
|
|
803
1216
|
summary.cases += 1;
|
|
1217
|
+
perCheckCases.set(check.id, (perCheckCases.get(check.id) ?? 0) + 1);
|
|
804
1218
|
if (o.kind === "fail") pushStateful(o.finding);
|
|
805
1219
|
else if (o.kind === "skip") {
|
|
806
1220
|
const key = `${check.id}: ${o.reason}`;
|
|
@@ -808,31 +1222,86 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
808
1222
|
}
|
|
809
1223
|
}
|
|
810
1224
|
} else {
|
|
811
|
-
const
|
|
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);
|
|
812
1243
|
// ARV-154: mirror the auth-phase observability — count CRUD groups
|
|
813
1244
|
// attempted and record skip reasons, not just failures.
|
|
814
1245
|
type StatefulOutcome =
|
|
815
1246
|
| { kind: "fail"; finding: CheckFinding }
|
|
816
1247
|
| { kind: "skip"; reason: string }
|
|
817
1248
|
| { kind: "pass" };
|
|
1249
|
+
const crudHarness = harnessFor(check.id, "stateful_crud");
|
|
818
1250
|
const groupReports = await runPool<typeof applicable[number], StatefulOutcome>(
|
|
819
1251
|
applicable,
|
|
820
1252
|
statefulWorkers,
|
|
821
1253
|
async (group): Promise<StatefulOutcome> => {
|
|
822
1254
|
let outcome;
|
|
823
1255
|
try {
|
|
824
|
-
outcome = await check.run(group,
|
|
1256
|
+
outcome = await check.run(group, crudHarness);
|
|
825
1257
|
} catch (err) {
|
|
826
1258
|
outcome = { kind: "skip" as const, reason: `error: ${(err as Error).message}` };
|
|
827
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
|
+
}
|
|
828
1284
|
if (outcome.kind === "fail") {
|
|
829
|
-
|
|
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.
|
|
830
1299
|
const finding: CheckFinding = {
|
|
831
1300
|
check: check.id,
|
|
832
|
-
severity: check.severity,
|
|
833
|
-
operation:
|
|
834
|
-
request_signature: `${
|
|
835
|
-
response_summary: { status: 0 },
|
|
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 },
|
|
836
1305
|
message: outcome.message,
|
|
837
1306
|
evidence: outcome.evidence,
|
|
838
1307
|
recommended_action: recommendForCheck(check.id),
|
|
@@ -846,6 +1315,7 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
846
1315
|
});
|
|
847
1316
|
for (const o of groupReports) {
|
|
848
1317
|
summary.cases += 1;
|
|
1318
|
+
perCheckCases.set(check.id, (perCheckCases.get(check.id) ?? 0) + 1);
|
|
849
1319
|
if (o.kind === "fail") pushStateful(o.finding);
|
|
850
1320
|
else if (o.kind === "skip") {
|
|
851
1321
|
const key = `${check.id}: ${o.reason}`;
|
|
@@ -857,18 +1327,135 @@ export async function runChecks(opts: RunChecksOptions): Promise<RunChecksResult
|
|
|
857
1327
|
findings.push(...collected);
|
|
858
1328
|
}
|
|
859
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
|
+
|
|
860
1356
|
const highOrCritical = findings.filter(
|
|
861
1357
|
(f) => f.severity === "high" || f.severity === "critical",
|
|
862
1358
|
).length;
|
|
863
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
|
+
|
|
864
1399
|
// ARV-10: terminal event so downstream consumers know the run wrapped
|
|
865
1400
|
// (vs. the producer crashing). Mirrors what the JSON envelope's
|
|
866
1401
|
// `summary` field carries, just delivered as the final NDJSON line.
|
|
867
1402
|
if (opts.onEvent) opts.onEvent({ type: "summary", ts: nowIso(), summary });
|
|
868
1403
|
|
|
869
1404
|
return {
|
|
870
|
-
data: { findings, summary },
|
|
1405
|
+
data: { findings, summary, spec_findings },
|
|
871
1406
|
selection,
|
|
872
1407
|
high_or_critical: highOrCritical,
|
|
873
1408
|
};
|
|
874
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
|
+
}
|