@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.
Files changed (158) hide show
  1. package/CHANGELOG.md +164 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +33 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. package/src/core/generator/create-body.ts +0 -89
@@ -0,0 +1,308 @@
1
+ /**
2
+ * ARV-60: spec-level rollup of systemic gaps.
3
+ *
4
+ * Many findings are a single spec-level fact (no 401 declared anywhere,
5
+ * no response schemas, no DELETE+GET pair detectable) smeared across N
6
+ * operations. The flat finding list reads as "83 problems" when it's
7
+ * really "1 problem × 83 sites". Small teams reading the report can't
8
+ * tell whether to act on the spec or on the server — and the actionable
9
+ * line tends to be the same for every row.
10
+ *
11
+ * This module computes `SpecFinding[]` from the runner's primary outputs:
12
+ *
13
+ * 1. **status_drift** — group existing findings by (check, status); if
14
+ * a group covers ≥80% of the check's applicable operations, emit
15
+ * one rollup row. Per-op findings stay in `data.findings` (so SARIF
16
+ * and `--verbose` keep the long form).
17
+ * 2. **missing_declaration** — a single skipped_outcome reason covers
18
+ * ≥80% of the check's applicable cases. Typical: response schema /
19
+ * header schema not declared on this API.
20
+ * 3. **no_detector** — check is applicable to ≥5 operations but ran
21
+ * zero cases. Typical: `use_after_free` without DELETE+GET pair.
22
+ * Different from skip — the check itself produced no cases.
23
+ *
24
+ * Threshold is hard-coded at 0.8 (AC #1). Lower would create false
25
+ * rollups on small (<10-op) APIs where a 3/4 incidental cluster doesn't
26
+ * indicate a systemic gap.
27
+ */
28
+ import type {
29
+ CheckFinding,
30
+ SpecFinding,
31
+ } from "./types.ts";
32
+ import { categoryFor } from "../severity/category.ts";
33
+
34
+ export interface PerCheckObservations {
35
+ /** Distinct operations where `check.applies(op) === true`. */
36
+ applicable: number;
37
+ /** Count of cases the check actually ran (passed + failed + skipped). */
38
+ cases: number;
39
+ /** ARV-26-style "check: reason" → count, restricted to this check. */
40
+ skipped: Record<string, number>;
41
+ }
42
+
43
+ const SPEC_CLUSTER_RATIO = 0.8;
44
+ const NO_DETECTOR_FLOOR = 5;
45
+
46
+ /** Mapping table: check id + response status → human reason + actionable
47
+ * fix hint. Centralised so a future check that opts into rollup can just
48
+ * register its hint here. */
49
+ function explainStatusDrift(checkId: string, status: number): { reason: string; fix: string } {
50
+ if (checkId === "status_code_conformance") {
51
+ return {
52
+ reason: `Status ${status} not declared in spec`,
53
+ fix: `Add ${status} to the response declarations for the affected operations, or pass --tolerate-undeclared ${status}.`,
54
+ };
55
+ }
56
+ if (checkId === "ignored_auth") {
57
+ return {
58
+ reason: `Auth probes did not produce ${status >= 400 ? "the expected rejection" : "a 4xx"} (got ${status})`,
59
+ fix: `Verify the security scheme is enforced server-side, or relax with --strict-401=false.`,
60
+ };
61
+ }
62
+ if (checkId === "negative_data_rejection") {
63
+ return {
64
+ reason: `Negative payloads accepted with ${status} on most operations`,
65
+ fix: `Server is not validating inputs — fix request-body validation, or downgrade by adjusting tolerated statuses in your gate.`,
66
+ };
67
+ }
68
+ if (checkId === "unsupported_method") {
69
+ return {
70
+ reason: `Undeclared methods returned ${status} instead of 405`,
71
+ fix: `Configure the gateway to emit 405 for undeclared verbs, or pass --strict-405=false.`,
72
+ };
73
+ }
74
+ if (checkId === "missing_required_header") {
75
+ return {
76
+ reason: `Required-header omission returned ${status}`,
77
+ fix: `Server should reject with 400/415 when required headers are missing.`,
78
+ };
79
+ }
80
+ // Generic fallback — better than "(unknown reason)".
81
+ return {
82
+ reason: `Response status ${status} clustered across most operations for ${checkId}`,
83
+ fix: `Inspect a sample finding for context, or run with --verbose for per-op detail.`,
84
+ };
85
+ }
86
+
87
+ function explainSkipCluster(checkId: string, reason: string): { reason: string; fix: string } | null {
88
+ if (checkId === "response_schema_conformance") {
89
+ return {
90
+ reason: `Response schemas not declared on this API (${reason})`,
91
+ fix: `Add response schemas to spec.json, or run \`zond api annotate dump readback\` to capture them from live runs.`,
92
+ };
93
+ }
94
+ if (checkId === "response_headers_conformance") {
95
+ return {
96
+ reason: `Response headers not declared on this API (${reason})`,
97
+ fix: `Add response header declarations to spec.json — without them this check is a no-op.`,
98
+ };
99
+ }
100
+ if (checkId === "not_a_server_error" && /skipped|max_requests/.test(reason)) {
101
+ return null; // budget-skip, not a spec gap
102
+ }
103
+ if (/max_requests|max-requests/.test(reason)) {
104
+ return null; // ARV-227 budget cap is not a spec finding
105
+ }
106
+ // Other skip clusters fall through — surfaced as `other` kind.
107
+ return {
108
+ reason: `Most cases for ${checkId} skipped (${reason})`,
109
+ fix: `Inspect one sample (zond db diagnose --run-id <id>) to confirm the gap is intentional.`,
110
+ };
111
+ }
112
+
113
+ function explainNoDetector(checkId: string): { reason: string; fix: string } {
114
+ if (checkId === "use_after_free") {
115
+ return {
116
+ reason: `No DELETE+GET pair detectable from spec — check ran 0 cases`,
117
+ fix: `Annotate resources (\`zond api annotate dump readback\`) or add explicit lifecycle declarations.`,
118
+ };
119
+ }
120
+ if (checkId === "ensure_resource_availability") {
121
+ return {
122
+ reason: `No CRUD pair detected — check ran 0 cases`,
123
+ fix: `Run \`zond api annotate dump readback\` to capture resource boundaries.`,
124
+ };
125
+ }
126
+ if (checkId === "cross_call_references") {
127
+ return {
128
+ reason: `No POST→GET follow-up pair detected — check ran 0 cases`,
129
+ fix: `Annotate resources with readback_diff in .api-resources.yaml.`,
130
+ };
131
+ }
132
+ return {
133
+ reason: `${checkId} applicable but produced 0 cases on this API`,
134
+ fix: `Inspect the case-generator for this check or add resource annotations.`,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * ARV-307: broken-baseline guard for conformance checks.
140
+ *
141
+ * On a degenerate baseline (e.g. a fully auth-rejected scan where every
142
+ * response is 401/404, zero 2xx), `status_code_conformance` and
143
+ * `content_type_conformance` fire on every undeclared status/content-type
144
+ * and emit thousands of findings that are pure baseline artifacts — the
145
+ * status_drift rollup in computeSpecFindings can't collapse them because the
146
+ * undeclared statuses are diverse (401 here, 404 there), so no single
147
+ * (check,status) group crosses the 80% threshold.
148
+ *
149
+ * Stateful checks already skip on a per-case broken-baseline guard; this is
150
+ * the run-level equivalent for the conformance family: if the positive
151
+ * (expected-success) probes overwhelmingly failed, the whole conformance
152
+ * signal is untrustworthy, so we roll it up into one `broken_baseline`
153
+ * spec_finding and drop the per-op conformance findings.
154
+ *
155
+ * We gate on the POSITIVE-probe baseline, not all responses — negative /
156
+ * boundary cases legitimately return 4xx on a healthy API, so counting them
157
+ * would false-trip the guard. `positiveTwoxx / positiveTotal` is the success
158
+ * rate of the probes that are supposed to succeed.
159
+ *
160
+ * Threshold: >90% of positive probes non-2xx, with ≥10 positive probes so a
161
+ * tiny run doesn't trip on a single failure. When fewer than 10 positive
162
+ * probes ran (e.g. negative-only mode) the baseline can't be judged and the
163
+ * guard is a no-op.
164
+ */
165
+ export const BASELINE_GATED_CHECKS: ReadonlySet<string> = new Set([
166
+ "status_code_conformance",
167
+ "content_type_conformance",
168
+ ]);
169
+ export const BROKEN_BASELINE_NON2XX_RATIO = 0.9;
170
+ export const BROKEN_BASELINE_MIN_POSITIVE = 10;
171
+
172
+ export function applyBrokenBaselineGuard(input: {
173
+ findings: CheckFinding[];
174
+ positiveTotal: number;
175
+ positiveTwoxx: number;
176
+ }): { kept: CheckFinding[]; removed: CheckFinding[]; specFinding: SpecFinding | null } {
177
+ const { findings, positiveTotal, positiveTwoxx } = input;
178
+ const noop = { kept: findings, removed: [] as CheckFinding[], specFinding: null };
179
+ if (positiveTotal < BROKEN_BASELINE_MIN_POSITIVE) return noop;
180
+ const nonTwoxxRatio = (positiveTotal - positiveTwoxx) / positiveTotal;
181
+ if (nonTwoxxRatio < BROKEN_BASELINE_NON2XX_RATIO) return noop;
182
+
183
+ const kept: CheckFinding[] = [];
184
+ const removed: CheckFinding[] = [];
185
+ for (const f of findings) {
186
+ // Suppressed findings are already out of the CI-gating tallies; leave
187
+ // them in the audit trail untouched.
188
+ if (!f.suppressed_by && BASELINE_GATED_CHECKS.has(f.check)) removed.push(f);
189
+ else kept.push(f);
190
+ }
191
+ if (removed.length === 0) return noop;
192
+
193
+ const affected = new Map<string, { path: string; method: string; operationId?: string }>();
194
+ for (const f of removed) {
195
+ affected.set(`${f.operation.method} ${f.operation.path}`, f.operation);
196
+ }
197
+ const pct = Math.round(nonTwoxxRatio * 100);
198
+ const specFinding: SpecFinding = {
199
+ check: "status_code_conformance",
200
+ kind: "broken_baseline",
201
+ severity: "info",
202
+ category: categoryFor("status_code_conformance"),
203
+ reason:
204
+ `Degenerate baseline: ${pct}% of ${positiveTotal} positive probes returned non-2xx ` +
205
+ `(only ${positiveTwoxx} succeeded). ${removed.length} conformance finding(s) suppressed as ` +
206
+ `baseline artifacts.`,
207
+ fix_hint:
208
+ `Fix the baseline first — supply valid auth (--auth-header / apis/<name>/.env.yaml) and ` +
209
+ `seed path-param fixtures (\`zond prepare-fixtures\`) so positive probes reach 2xx, then re-run ` +
210
+ `conformance. Undeclared statuses on an all-4xx scan are not real spec drift.`,
211
+ affected_operations: [...affected.values()],
212
+ count: removed.length,
213
+ applicable: positiveTotal,
214
+ };
215
+ return { kept, removed, specFinding };
216
+ }
217
+
218
+ export function computeSpecFindings(
219
+ findings: CheckFinding[],
220
+ perCheck: Map<string, PerCheckObservations>,
221
+ ): SpecFinding[] {
222
+ const out: SpecFinding[] = [];
223
+
224
+ // --- 1. status_drift: cluster findings by (check, status). -----------
225
+ type Group = {
226
+ severity: CheckFinding["severity"];
227
+ check: string;
228
+ status: number;
229
+ ops: Map<string, CheckFinding["operation"]>;
230
+ };
231
+ const groups = new Map<string, Group>();
232
+ for (const f of findings) {
233
+ const status = f.response_summary?.status ?? 0;
234
+ if (status <= 0) continue; // network errors etc — not a status drift
235
+ const key = `${f.check}|${status}`;
236
+ let g = groups.get(key);
237
+ if (!g) {
238
+ g = { severity: f.severity, check: f.check, status, ops: new Map() };
239
+ groups.set(key, g);
240
+ }
241
+ const opKey = `${f.operation.method} ${f.operation.path}`;
242
+ if (!g.ops.has(opKey)) g.ops.set(opKey, f.operation);
243
+ }
244
+ for (const g of groups.values()) {
245
+ const obs = perCheck.get(g.check);
246
+ const applicable = obs?.applicable ?? g.ops.size;
247
+ if (g.ops.size < 2) continue; // single-op rows aren't a rollup
248
+ if (g.ops.size / Math.max(applicable, 1) < SPEC_CLUSTER_RATIO) continue;
249
+ const { reason, fix } = explainStatusDrift(g.check, g.status);
250
+ out.push({
251
+ check: g.check,
252
+ kind: "status_drift",
253
+ severity: g.severity,
254
+ category: categoryFor(g.check),
255
+ reason,
256
+ fix_hint: fix,
257
+ affected_operations: [...g.ops.values()],
258
+ count: g.ops.size,
259
+ applicable,
260
+ });
261
+ }
262
+
263
+ // --- 2. missing_declaration: skip cluster. ---------------------------
264
+ for (const [checkId, obs] of perCheck) {
265
+ if (obs.cases <= 0) continue;
266
+ for (const [rawReason, count] of Object.entries(obs.skipped)) {
267
+ if (count / obs.cases < SPEC_CLUSTER_RATIO) continue;
268
+ // skipped key is "<checkId>: <reason>" — strip the prefix when it
269
+ // matches; otherwise treat the whole thing as the reason.
270
+ const reason = rawReason.startsWith(`${checkId}: `) ? rawReason.slice(checkId.length + 2) : rawReason;
271
+ const expl = explainSkipCluster(checkId, reason);
272
+ if (!expl) continue;
273
+ out.push({
274
+ check: checkId,
275
+ kind: /not declared|not declar/i.test(expl.reason) || /\bschema\b|\bheaders?\b/i.test(reason)
276
+ ? "missing_declaration"
277
+ : "other",
278
+ severity: "info",
279
+ category: categoryFor(checkId),
280
+ reason: expl.reason,
281
+ fix_hint: expl.fix,
282
+ affected_operations: [],
283
+ count,
284
+ applicable: obs.applicable,
285
+ });
286
+ }
287
+ }
288
+
289
+ // --- 3. no_detector: applicable ≥5 but 0 cases. ----------------------
290
+ for (const [checkId, obs] of perCheck) {
291
+ if (obs.cases > 0) continue;
292
+ if (obs.applicable < NO_DETECTOR_FLOOR) continue;
293
+ const expl = explainNoDetector(checkId);
294
+ out.push({
295
+ check: checkId,
296
+ kind: "no_detector",
297
+ severity: "info",
298
+ category: categoryFor(checkId),
299
+ reason: expl.reason,
300
+ fix_hint: expl.fix,
301
+ affected_operations: [],
302
+ count: 0,
303
+ applicable: obs.applicable,
304
+ });
305
+ }
306
+
307
+ return out;
308
+ }
@@ -111,7 +111,31 @@ export interface CheckContext {
111
111
  export type CheckOutcome =
112
112
  | { kind: "pass" }
113
113
  | { kind: "skip"; reason: string }
114
- | { kind: "fail"; message: string; evidence?: Record<string, unknown> };
114
+ | {
115
+ kind: "fail";
116
+ message: string;
117
+ evidence?: Record<string, unknown>;
118
+ /** ARV-284: per-finding severity override. When set, runner uses
119
+ * this in place of `Check.severity` — lets a check emit different
120
+ * severities based on context (e.g. `negative_data_rejection`
121
+ * with `additionalProperties-violation` evidence → LOW, with
122
+ * `pattern-violation` → MEDIUM, with 5xx response → HIGH). The
123
+ * declared `Check.severity` stays as the natural fallback /
124
+ * documentation tier. The agent re-severitizes from the raw
125
+ * evidence downstream. */
126
+ severity?: Severity;
127
+ /** ARV-310: attribute the finding to a specific operation instead of
128
+ * the CRUD group's canonical create/read op. cursor_boundary_fuzzing
129
+ * probes the GET list endpoint — without this the finding lands on the
130
+ * POST create and reads as "a create endpoint that doesn't paginate". */
131
+ operation?: { path: string; method: string; operationId?: string };
132
+ /** ARV-312: observed HTTP status of the response the check acted on.
133
+ * Auth/stateful checks send their own requests, so the runner has no
134
+ * response to summarize and otherwise records `status: 0` — a phantom
135
+ * that reads as "no response captured". Set this so the finding carries
136
+ * the real status (and severity gating can key off it). */
137
+ responseStatus?: number;
138
+ };
115
139
 
116
140
  export interface Check {
117
141
  /** Stable identifier — must match schemathesis name where possible. */
@@ -151,6 +175,16 @@ export interface CheckFinding {
151
175
  * check id (and response status for `network_error`). Optional
152
176
  * because synthetic findings (e.g. unit-test fakes) may skip it. */
153
177
  recommended_action?: RecommendedAction;
178
+ /** Suppression trace — present when a finding was removed from the
179
+ * gate counts (today only the deterministic broken-baseline guard,
180
+ * ARV-307, marks findings this way). CI summary excludes such findings
181
+ * from gate counts via this field's presence; presence of
182
+ * `suppressed_by` is the canonical "suppressed" signal. */
183
+ suppressed_by?: {
184
+ source: string;
185
+ rule_index: number;
186
+ reason: string;
187
+ };
154
188
  }
155
189
 
156
190
  export interface CheckRunSummary {
@@ -169,11 +203,71 @@ export interface CheckRunSummary {
169
203
  * response branch ×2` tells the user why "0 findings" doesn't mean "all
170
204
  * green" (probe got 4xx, response schema only declared on 2xx). */
171
205
  skipped_outcomes: Record<string, number>;
206
+ /** ARV-83: same data as `skipped_outcomes`, but split into `{check, reason,
207
+ * count}` so consumers don't have to colon-tokenise a key whose reason
208
+ * text may itself contain colons. Sorted by count descending. The legacy
209
+ * `skipped_outcomes` field is kept for back-compat with existing parsers
210
+ * / NDJSON readers. */
211
+ skipped_outcomes_grouped: Array<{ check: string; reason: string; count: number }>;
212
+ /** Count of findings suppressed by the deterministic broken-baseline
213
+ * guard (ARV-307). Excluded from `findings`/`by_severity` so CI gates
214
+ * ignore them, but surfaced here for audit-trail reconciliation. */
215
+ suppressed?: number;
216
+ }
217
+
218
+ /** ARV-60: spec-level rollup of a systemic gap that manifests on N
219
+ * operations. When ≥80% of a check's applicable operations share the same
220
+ * root cause (same response status undeclared, same missing-schema skip
221
+ * reason, or zero cases when a detector finds no pair), the runner emits
222
+ * a single `SpecFinding` instead of (or in addition to) the N per-op rows.
223
+ *
224
+ * Consumed by the CLI to print one summary line; surfaced verbatim in the
225
+ * JSON envelope and as a dedicated `spec_finding` NDJSON event. Per-op
226
+ * findings are NOT removed from `findings[]` — agents that prefer per-op
227
+ * triage keep their existing surface; agents that triage by spec hit just
228
+ * one row per drift. */
229
+ export interface SpecFinding {
230
+ check: string;
231
+ /** Classifier so consumers can branch:
232
+ * - `status_drift`: response status code clustered across operations
233
+ * (status_code_conformance / negative_data_rejection / ignored_auth).
234
+ * - `missing_declaration`: every applicable case skipped for the same
235
+ * "spec didn't declare X" reason (response_schema_conformance,
236
+ * response_headers_conformance).
237
+ * - `no_detector`: check is applicable to ≥5 operations but ran 0 cases
238
+ * (use_after_free without DELETE+GET pair, cross_call_references
239
+ * without followups in scope).
240
+ * - `broken_baseline`: ARV-307 — the positive/success baseline was
241
+ * degenerate (>90% of positive probes returned non-2xx, e.g. a
242
+ * fully auth-rejected scan). The conformance checks' per-op findings
243
+ * are baseline artifacts, so they're rolled up into this one row and
244
+ * removed from `findings[]` (mirrors the stateful broken-baseline skip).
245
+ * - `other`: skip-cluster that doesn't fit the above. */
246
+ kind: "status_drift" | "missing_declaration" | "no_detector" | "broken_baseline" | "other";
247
+ /** Severity inherited from the underlying findings (status_drift) or
248
+ * fixed to "info" for missing_declaration / no_detector — those signal
249
+ * "spec gap, not server bug" so the team knows where to act. */
250
+ severity: Severity;
251
+ category?: Category;
252
+ /** One-line root cause statement — surfaces what zond observed. */
253
+ reason: string;
254
+ /** Actionable next step. References a spec edit, a tolerate flag, or
255
+ * another zond command. Empty string when no automatic suggestion. */
256
+ fix_hint: string;
257
+ /** Operations the rollup covers (path + method). count = length. */
258
+ affected_operations: Array<{ path: string; method: string; operationId?: string }>;
259
+ count: number;
260
+ /** Applicable population the cluster was measured against. ratio =
261
+ * count / applicable. Lets consumers re-threshold without re-running. */
262
+ applicable: number;
172
263
  }
173
264
 
174
265
  export interface CheckRunData {
175
266
  findings: CheckFinding[];
176
267
  summary: CheckRunSummary;
268
+ /** ARV-60: spec-level rollup, see SpecFinding. Always present (empty
269
+ * array when no clusters cross the 80% threshold). */
270
+ spec_findings: SpecFinding[];
177
271
  }
178
272
 
179
273
  export function emptySummary(): CheckRunSummary {
@@ -185,5 +279,27 @@ export function emptySummary(): CheckRunSummary {
185
279
  by_severity: emptySeverityBuckets(),
186
280
  by_category: emptyCategoryBuckets(),
187
281
  skipped_outcomes: {},
282
+ skipped_outcomes_grouped: [],
283
+ suppressed: 0,
188
284
  };
189
285
  }
286
+
287
+ /** ARV-83: turn the legacy `<check>: <reason>` keys into a structured
288
+ * array. The split parses the colon-separator at the first occurrence; if
289
+ * the reason itself contains colons, only the LEADING `check_id:` is
290
+ * stripped, preserving the rest verbatim. */
291
+ export function groupSkippedOutcomes(
292
+ outcomes: Record<string, number>,
293
+ ): Array<{ check: string; reason: string; count: number }> {
294
+ const out: Array<{ check: string; reason: string; count: number }> = [];
295
+ for (const [key, count] of Object.entries(outcomes)) {
296
+ const idx = key.indexOf(": ");
297
+ if (idx > 0) {
298
+ out.push({ check: key.slice(0, idx), reason: key.slice(idx + 2), count });
299
+ } else {
300
+ out.push({ check: key, reason: "", count });
301
+ }
302
+ }
303
+ out.sort((a, b) => b.count - a.count);
304
+ return out;
305
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `x-zond-*` OpenAPI vendor-extension policy (ARV-189, m-21).
3
+ *
4
+ * Lets spec authors declare per-operation rules directly in the spec
5
+ * without a sidecar yaml file. This is the low-friction alternative
6
+ * to `.api-resources.local.yaml` for one-off endpoints — typical
7
+ * cases:
8
+ * - public route that shouldn't be flagged for missing auth headers
9
+ * - debugging endpoint whose 500s aren't real bugs
10
+ * - one-off skip during incident triage
11
+ *
12
+ * Resolution priority (highest first):
13
+ * 1. `.api-resources.local.yaml` overlay (explicit operator override)
14
+ * 2. `x-zond-*` extensions (in-spec policy — this module)
15
+ * 3. `.api-resources.yaml` (auto-generated baseline)
16
+ * 4. built-in defaults
17
+ *
18
+ * Supported extensions in this milestone:
19
+ * x-zond-skip: string | string[] — check ids to skip for this op
20
+ * x-zond-public: boolean — shortcut: skip auth-class checks
21
+ *
22
+ * Deliberately NOT implemented yet (deferred to a follow-up task):
23
+ * x-zond-resource / x-zond-idempotent / x-zond-lifecycle-field —
24
+ * these require deeper m-20 overlay wiring (proper integration with
25
+ * resource-config maps), tracked separately to keep this MVP focused
26
+ * on the universally-useful skip rules.
27
+ */
28
+
29
+ import type { EndpointInfo } from "../generator/types.ts";
30
+
31
+ /** Auth-related check ids that `x-zond-public: true` should suppress.
32
+ * Kept in sync with the auth-class check registry in mode.ts. */
33
+ const AUTH_CHECK_IDS = new Set<string>([
34
+ "ignored_auth",
35
+ "missing_required_header",
36
+ ]);
37
+
38
+ /** True when the endpoint declares the check id should be skipped via
39
+ * any `x-zond-*` extension. The check id is matched case-sensitively
40
+ * against `x-zond-skip` entries; `x-zond-public: true` is expanded to
41
+ * the auth-id set before comparison. */
42
+ export function endpointSkipsCheck(op: EndpointInfo, checkId: string): boolean {
43
+ const ext = op.extensions;
44
+ if (!ext) return false;
45
+
46
+ // x-zond-public: true → implicit skip for every auth-class check.
47
+ if (ext["x-zond-public"] === true && AUTH_CHECK_IDS.has(checkId)) {
48
+ return true;
49
+ }
50
+
51
+ // x-zond-skip: <string> | <string[]>
52
+ const skip = ext["x-zond-skip"];
53
+ if (typeof skip === "string") {
54
+ return skip === checkId;
55
+ }
56
+ if (Array.isArray(skip)) {
57
+ for (const entry of skip) {
58
+ if (typeof entry === "string" && entry === checkId) return true;
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /** Reason string surfaced in the skipped-outcomes summary. Mirrors the
65
+ * `<check>: <reason>` convention used elsewhere in the runner. */
66
+ export function reasonForSkip(op: EndpointInfo, checkId: string): string {
67
+ const ext = op.extensions;
68
+ if (!ext) return "x-zond extension"; // shouldn't happen — caller gates on endpointSkipsCheck
69
+ if (ext["x-zond-public"] === true && AUTH_CHECK_IDS.has(checkId)) {
70
+ return "x-zond-public: true (auth check suppressed at the spec level)";
71
+ }
72
+ return `x-zond-skip listed "${checkId}" at the spec level`;
73
+ }
@@ -57,6 +57,7 @@ export type FindingClass =
57
57
  | "check:pagination_invariants"
58
58
  | "check:lifecycle_transitions"
59
59
  | "check:open_cors_on_sensitive"
60
+ | "check:cursor_boundary_fuzzing"
60
61
  | "check:rate_limit_headers_absent"
61
62
  | "check:network_error"
62
63
 
@@ -105,6 +106,13 @@ export interface ClassifierContext {
105
106
  * fix_test_logic. Producers (db-analysis) set this after walking the
106
107
  * step's assertions array. */
107
108
  schema_violation?: boolean;
109
+ /** ARV-324: true when `prepare-fixtures`/`discover` already confirmed
110
+ * this exact operation returns a client error / empty list while
111
+ * hunting for fixture values (`.fixture-gaps.yaml`) — e.g. an empty
112
+ * test account. Same shape of problem `probe:mass_assignment`'s
113
+ * `inconclusive-baseline` branch already solves below: a finding
114
+ * caused by our own missing test data isn't a backend bug. */
115
+ unresolved_fixture?: boolean;
108
116
  }
109
117
 
110
118
  /**
@@ -152,14 +160,35 @@ export function classify(ctx: ClassifierContext): RecommendedAction | undefined
152
160
  return "fix_spec";
153
161
 
154
162
  case "check:not_a_server_error":
155
- case "check:unsupported_method":
163
+ // A 5xx on this operation is worth flagging regardless of fixture
164
+ // state — even a synthetic/garbage id shouldn't crash the server.
165
+ return "report_backend_bug";
166
+
156
167
  case "check:positive_data_acceptance":
168
+ // ARV-324: a known unresolved fixture gap (garbage path-id → 400)
169
+ // is our own missing test data, not a backend or spec defect.
170
+ if (ctx.unresolved_fixture) return "fix_fixture";
171
+ // ARV-341: otherwise this check fires ONLY when the server rejects a
172
+ // body *we* generated as valid (400/422). The server is
173
+ // authoritative — our generated body or the spec was wrong, not the
174
+ // backend. Route to fix_spec, not report_backend_bug, so 100s of
175
+ // generator/spec-drift cases don't spam the API owner with false
176
+ // bug reports.
177
+ return "fix_spec";
178
+
179
+ case "check:unsupported_method":
157
180
  case "check:use_after_free":
158
181
  case "check:ensure_resource_availability":
159
182
  case "check:cross_call_references":
160
183
  case "check:idempotency_replay":
161
184
  case "check:pagination_invariants":
162
185
  case "check:lifecycle_transitions":
186
+ case "check:cursor_boundary_fuzzing":
187
+ // ARV-324: same treatment as probe:mass_assignment's
188
+ // inconclusive-baseline branch below — a finding on an operation
189
+ // we already know is an unresolved fixture gap isn't new evidence
190
+ // of a backend defect.
191
+ if (ctx.unresolved_fixture) return "fix_fixture";
163
192
  return "report_backend_bug";
164
193
 
165
194
  case "check:negative_data_rejection":
@@ -173,11 +202,11 @@ export function classify(ctx: ClassifierContext): RecommendedAction | undefined
173
202
  return "fix_auth_config";
174
203
 
175
204
  case "check:rate_limit_headers_absent":
176
- // ARV-256: missing rate-limit on write endpoints is an
177
- // infrastructure-config gap closest existing action is
178
- // "fix_auth_config" (server-side hardening) rather than report-
179
- // backend-bug (the spec doesn't say rate-limit must exist).
180
- return "fix_auth_config";
205
+ // ARV-304: this is a server-side hygiene gap (RFC-9239 /
206
+ // OWASP-API-04 expect rate-limit metadata on writes), not a
207
+ // caller-side auth problem. Agents triaging on `fix_auth_config`
208
+ // would chase the wrong fix; surface it as a backend-team task.
209
+ return "report_backend_bug";
181
210
 
182
211
  case "check:network_error":
183
212
  if (ctx.status === 401 || ctx.status === 403) return "fix_auth_config";
@@ -24,6 +24,14 @@ import {
24
24
 
25
25
  export interface CoverageLoadOptions {
26
26
  apiName: string;
27
+ /** ARV-265: which `run_kind`s contribute to the matrix.
28
+ * - 'test' (default, back-compat) — runs whose `run_kind` is
29
+ * 'regular' OR 'probe' (i.e. anything `zond run` produced). This
30
+ * is what `pass-coverage` / `hit-coverage` have always measured.
31
+ * - 'audit' — every kind (regular | probe | check | request |
32
+ * fixture). Used by `audit-coverage` to answer "did this scan
33
+ * reach the API?" regardless of producer. */
34
+ scope?: "test" | "audit";
27
35
  runId?: number;
28
36
  /** TASK-255: union across multiple runs (e.g. tests-run + probes-run from
29
37
  * the same session). Loader concatenates results from each run before
@@ -66,6 +74,9 @@ export interface CoverageLoadResult {
66
74
  profile: "safe" | "full";
67
75
  tagFilter: string[];
68
76
  ephemeralCount: number;
77
+ /** ARV-265: scope this matrix was built under. Echoes `options.scope`
78
+ * (default 'test') so dual-metric callers can sanity-check the result. */
79
+ scope: "test" | "audit";
69
80
  }
70
81
 
71
82
  async function readFixturesAffected(baseDir: string): Promise<BuildMatrixInput["fixturesAffected"]> {
@@ -112,6 +123,14 @@ export async function loadCoverage(options: CoverageLoadOptions): Promise<Covera
112
123
  // sessionId > sinceIso > tag > runIds > runId > latest. since:/tag: are
113
124
  // filtered to this collection only — the user has named an API, so they
114
125
  // want runs of that API, not every collection that happens to share a tag.
126
+ // ARV-265: `scope` toggles which run_kinds survive the post-resolution
127
+ // filter (see CoverageLoadOptions.scope). Default 'test' keeps the
128
+ // pre-ARV-265 semantics (regular + probe rows contribute) so existing
129
+ // CI consumers see no surprise.
130
+ const scope = options.scope ?? "test";
131
+ const allowedKinds: ReadonlySet<string> = scope === "audit"
132
+ ? new Set(["regular", "probe", "check", "request", "fixture"])
133
+ : new Set(["regular", "probe"]);
115
134
  let runs: RunRecord[] = [];
116
135
  let unionMode: "session" | "since" | "tag" | "runs" | null = null;
117
136
  if (options.sessionId) {
@@ -140,10 +159,21 @@ export async function loadCoverage(options: CoverageLoadOptions): Promise<Covera
140
159
  } else if (options.runId != null) {
141
160
  const r = getRunById(options.runId);
142
161
  if (r) runs = [r];
162
+ } else if (scope === "audit") {
163
+ // ARV-265 audit mode latest-fallback: pick the most recent run of any
164
+ // kind so a scan that only ran `checks run` (no `zond run`) still
165
+ // produces a meaningful audit metric.
166
+ const latest = getLatestRunByCollection(collection.id, { runKind: "any" });
167
+ if (latest) runs = [latest];
143
168
  } else {
144
169
  const latest = getLatestRunByCollection(collection.id);
145
170
  if (latest) runs = [latest];
146
171
  }
172
+ // ARV-265: post-resolution kind filter. Session/since/tag may have
173
+ // pulled in audit-only kinds (check/request/fixture); the test-scope
174
+ // filter strips them so `pass-coverage` keeps the "curated suites
175
+ // passed" semantics. Audit scope keeps everything.
176
+ runs = runs.filter((r) => allowedKinds.has(r.run_kind));
147
177
  const results = runs.flatMap((r) => getResultsByRunId(r.id));
148
178
  // `run` (singular) reflects the latest contributing run for back-compat
149
179
  // with consumers that only care about a single run label.
@@ -176,6 +206,7 @@ export async function loadCoverage(options: CoverageLoadOptions): Promise<Covera
176
206
  profile,
177
207
  tagFilter,
178
208
  ephemeralCount: ephemeralEndpoints.size,
209
+ scope,
179
210
  };
180
211
  }
181
212