@kirrosh/zond 0.22.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/CHANGELOG.md +811 -0
  2. package/README.md +59 -6
  3. package/package.json +9 -7
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/argv.ts +122 -0
  6. package/src/cli/commands/add-api.ts +146 -0
  7. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  8. package/src/cli/commands/api/annotate/index.ts +880 -0
  9. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  10. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  11. package/src/cli/commands/api/annotate/pagination.ts +64 -0
  12. package/src/cli/commands/api/annotate/prompts.ts +220 -0
  13. package/src/cli/commands/api/annotate/readback.ts +58 -0
  14. package/src/cli/commands/api/annotate/resources.ts +91 -0
  15. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  16. package/src/cli/commands/audit.ts +786 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +361 -0
  19. package/src/cli/commands/checks.ts +1072 -0
  20. package/src/cli/commands/ci-init.ts +43 -0
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +236 -0
  23. package/src/cli/commands/completions.ts +16 -0
  24. package/src/cli/commands/coverage.ts +823 -132
  25. package/src/cli/commands/db.ts +486 -12
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1356 -0
  28. package/src/cli/commands/doctor.ts +661 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +438 -47
  31. package/src/cli/commands/init/bootstrap.ts +34 -2
  32. package/src/cli/commands/{init.ts → init/index.ts} +99 -5
  33. package/src/cli/commands/init/skills.ts +99 -3
  34. package/src/cli/commands/init/templates/agents.md +77 -64
  35. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  36. package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
  37. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +802 -125
  40. package/src/cli/commands/init/templates/zond-config.yml +8 -9
  41. package/src/cli/commands/prepare-fixtures.ts +97 -0
  42. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  43. package/src/cli/commands/probe/mass-assignment.ts +594 -0
  44. package/src/cli/commands/probe/security.ts +537 -0
  45. package/src/cli/commands/probe/static.ts +255 -0
  46. package/src/cli/commands/probe/webhooks.ts +163 -0
  47. package/src/cli/commands/probe.ts +535 -0
  48. package/src/cli/commands/reference.ts +87 -0
  49. package/src/cli/commands/refresh-api.ts +227 -0
  50. package/src/cli/commands/remove-api.ts +150 -0
  51. package/src/cli/commands/report-bundle.ts +310 -0
  52. package/src/cli/commands/report.ts +241 -0
  53. package/src/cli/commands/request.ts +495 -4
  54. package/src/cli/commands/run.ts +870 -53
  55. package/src/cli/commands/schema-from-runs.ts +128 -0
  56. package/src/cli/commands/secrets.ts +133 -0
  57. package/src/cli/commands/session.ts +244 -0
  58. package/src/cli/commands/use.ts +18 -1
  59. package/src/cli/index.ts +20 -3
  60. package/src/cli/json-envelope.ts +92 -3
  61. package/src/cli/json-schemas.ts +314 -0
  62. package/src/cli/output.ts +17 -1
  63. package/src/cli/program.ts +199 -635
  64. package/src/cli/resolve.ts +105 -0
  65. package/src/cli/safe-live.ts +24 -0
  66. package/src/cli/status-filter.ts +114 -0
  67. package/src/cli/util/api-context.ts +85 -0
  68. package/src/cli/version.ts +5 -0
  69. package/src/core/audit/persist.ts +183 -0
  70. package/src/core/checks/budget.ts +59 -0
  71. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  72. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  73. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  74. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  75. package/src/core/checks/checks/cross_call_references.ts +147 -0
  76. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  77. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  78. package/src/core/checks/checks/idempotency_replay.ts +242 -0
  79. package/src/core/checks/checks/ignored_auth.ts +254 -0
  80. package/src/core/checks/checks/index.ts +68 -0
  81. package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
  82. package/src/core/checks/checks/missing_required_header.ts +40 -0
  83. package/src/core/checks/checks/negative_data_rejection.ts +148 -0
  84. package/src/core/checks/checks/not_a_server_error.ts +35 -0
  85. package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
  86. package/src/core/checks/checks/pagination_invariants.ts +419 -0
  87. package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
  88. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  89. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  90. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  91. package/src/core/checks/checks/status_code_conformance.ts +132 -0
  92. package/src/core/checks/checks/unsupported_method.ts +63 -0
  93. package/src/core/checks/checks/use_after_free.ts +78 -0
  94. package/src/core/checks/index.ts +30 -0
  95. package/src/core/checks/mode.ts +82 -0
  96. package/src/core/checks/recommended-action.ts +68 -0
  97. package/src/core/checks/registry.ts +78 -0
  98. package/src/core/checks/runner.ts +1461 -0
  99. package/src/core/checks/sarif.ts +230 -0
  100. package/src/core/checks/spec-findings.ts +308 -0
  101. package/src/core/checks/stateful.ts +121 -0
  102. package/src/core/checks/types.ts +305 -0
  103. package/src/core/checks/zond-extensions.ts +73 -0
  104. package/src/core/classifier/recommended-action.ts +251 -0
  105. package/src/core/context/current.ts +22 -6
  106. package/src/core/context/session.ts +78 -0
  107. package/src/core/coverage/loader.ts +216 -0
  108. package/src/core/coverage/reasons.ts +300 -0
  109. package/src/core/diagnostics/db-analysis.ts +293 -59
  110. package/src/core/diagnostics/failure-class.ts +140 -0
  111. package/src/core/diagnostics/failure-hints.ts +88 -89
  112. package/src/core/diagnostics/spec-pointer.ts +99 -0
  113. package/src/core/diagnostics/suggested-fixes.ts +155 -0
  114. package/src/core/exporter/case-study/index.ts +270 -0
  115. package/src/core/exporter/curl.ts +40 -0
  116. package/src/core/exporter/exporter.ts +48 -0
  117. package/src/core/exporter/html-report/escape.ts +24 -0
  118. package/src/core/exporter/html-report/index.ts +479 -0
  119. package/src/core/exporter/html-report/script.ts +100 -0
  120. package/src/core/exporter/html-report/styles.ts +408 -0
  121. package/src/core/generator/chunker.ts +38 -19
  122. package/src/core/generator/coverage-phase.ts +0 -0
  123. package/src/core/generator/data-factory.ts +586 -22
  124. package/src/core/generator/describe.ts +1 -1
  125. package/src/core/generator/fixtures-builder.ts +332 -0
  126. package/src/core/generator/index.ts +5 -5
  127. package/src/core/generator/openapi-reader.ts +135 -7
  128. package/src/core/generator/path-param-disambig.ts +140 -0
  129. package/src/core/generator/resources-builder.ts +898 -0
  130. package/src/core/generator/schema-utils.ts +33 -3
  131. package/src/core/generator/serializer.ts +103 -13
  132. package/src/core/generator/suite-generator.ts +583 -122
  133. package/src/core/generator/types.ts +14 -0
  134. package/src/core/identity/identity-file.ts +0 -0
  135. package/src/core/lint/affects.ts +28 -0
  136. package/src/core/lint/config.ts +96 -0
  137. package/src/core/lint/format.ts +42 -0
  138. package/src/core/lint/index.ts +94 -0
  139. package/src/core/lint/reporter.ts +128 -0
  140. package/src/core/lint/rules/consistency.ts +158 -0
  141. package/src/core/lint/rules/heuristics.ts +97 -0
  142. package/src/core/lint/rules/strictness.ts +109 -0
  143. package/src/core/lint/types.ts +96 -0
  144. package/src/core/lint/walker.ts +248 -0
  145. package/src/core/meta/meta-store.ts +6 -73
  146. package/src/core/output/README.md +73 -0
  147. package/src/core/output/index.ts +13 -0
  148. package/src/core/output/run.ts +91 -0
  149. package/src/core/output/types.ts +122 -0
  150. package/src/core/parser/dynamic-values.ts +160 -0
  151. package/src/core/parser/env-interpolation.ts +104 -0
  152. package/src/core/parser/filter.ts +57 -0
  153. package/src/core/parser/schema.ts +129 -4
  154. package/src/core/parser/types.ts +19 -1
  155. package/src/core/parser/variables.ts +0 -0
  156. package/src/core/parser/yaml-parser.ts +58 -12
  157. package/src/core/probe/bootstrap.ts +34 -0
  158. package/src/core/probe/dry-run-envelope.ts +61 -0
  159. package/src/core/probe/mass-assignment/classify.ts +175 -0
  160. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  161. package/src/core/probe/mass-assignment/digest.ts +114 -0
  162. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  163. package/src/core/probe/mass-assignment/regression.ts +141 -0
  164. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  165. package/src/core/probe/mass-assignment/types.ts +135 -0
  166. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  167. package/src/core/probe/mass-assignment-probe.ts +27 -0
  168. package/src/core/probe/mass-assignment-template.ts +240 -0
  169. package/src/core/probe/method-probe.ts +43 -76
  170. package/src/core/probe/method-shared.ts +69 -0
  171. package/src/core/probe/negative-probe.ts +183 -149
  172. package/src/core/probe/orphan-tracker.ts +188 -0
  173. package/src/core/probe/path-discovery.ts +439 -0
  174. package/src/core/probe/probe-harness.ts +119 -0
  175. package/src/core/probe/registry.ts +89 -0
  176. package/src/core/probe/runner.ts +136 -0
  177. package/src/core/probe/security/baseline.ts +174 -0
  178. package/src/core/probe/security/classify.ts +341 -0
  179. package/src/core/probe/security/cleanup.ts +125 -0
  180. package/src/core/probe/security/detectors.ts +71 -0
  181. package/src/core/probe/security/digest.ts +104 -0
  182. package/src/core/probe/security/orchestrator.ts +398 -0
  183. package/src/core/probe/security/regression.ts +103 -0
  184. package/src/core/probe/security/types.ts +151 -0
  185. package/src/core/probe/security-probe-class.ts +207 -0
  186. package/src/core/probe/security-probe.ts +32 -0
  187. package/src/core/probe/shared.ts +531 -0
  188. package/src/core/probe/static-probe-class.ts +125 -0
  189. package/src/core/probe/types.ts +165 -0
  190. package/src/core/probe/verdict-aggregator.ts +33 -0
  191. package/src/core/probe/webhooks-probe.ts +282 -0
  192. package/src/core/reporter/console.ts +41 -2
  193. package/src/core/reporter/index.ts +2 -3
  194. package/src/core/reporter/json.ts +11 -1
  195. package/src/core/reporter/junit.ts +27 -12
  196. package/src/core/reporter/ndjson.ts +37 -0
  197. package/src/core/reporter/types.ts +3 -0
  198. package/src/core/runner/assertions.ts +59 -2
  199. package/src/core/runner/async-pool.ts +108 -0
  200. package/src/core/runner/auth-path.ts +8 -0
  201. package/src/core/runner/ci-context.ts +72 -0
  202. package/src/core/runner/executor.ts +265 -36
  203. package/src/core/runner/form-encode.ts +41 -0
  204. package/src/core/runner/http-client.ts +112 -2
  205. package/src/core/runner/learn-drift.ts +293 -0
  206. package/src/core/runner/preflight-vars.ts +153 -0
  207. package/src/core/runner/progress-tracker.ts +73 -0
  208. package/src/core/runner/rate-limiter.ts +87 -33
  209. package/src/core/runner/run-kind.ts +45 -0
  210. package/src/core/runner/schema-validator.ts +308 -0
  211. package/src/core/runner/send-request.ts +158 -20
  212. package/src/core/runner/types.ts +44 -0
  213. package/src/core/secrets/registry.ts +164 -0
  214. package/src/core/secrets/secrets-file.ts +115 -0
  215. package/src/core/selectors/operation-filter.ts +144 -0
  216. package/src/core/setup-api.ts +457 -20
  217. package/src/core/severity/category.ts +94 -0
  218. package/src/core/severity/index.ts +58 -0
  219. package/src/core/spec/infer-schema.ts +102 -0
  220. package/src/core/spec/layers.ts +154 -0
  221. package/src/core/spec/merge-specs.ts +156 -0
  222. package/src/core/spec/schema-from-runs.ts +117 -0
  223. package/src/core/spec/schema-overlay.ts +130 -0
  224. package/src/core/util/ajv.ts +13 -0
  225. package/src/core/util/format-eta.ts +21 -0
  226. package/src/core/util/headers.ts +9 -0
  227. package/src/core/util/url.ts +24 -0
  228. package/src/core/utils.ts +5 -1
  229. package/src/core/workspace/config.ts +129 -0
  230. package/src/core/workspace/fixture-gap-report.ts +84 -0
  231. package/src/core/workspace/fixture-gaps.ts +71 -0
  232. package/src/core/workspace/manifest.ts +283 -0
  233. package/src/core/workspace/output-rotation.ts +62 -0
  234. package/src/core/workspace/root.ts +13 -11
  235. package/src/core/workspace/triage-path.ts +87 -0
  236. package/src/db/lint-runs.ts +47 -0
  237. package/src/db/migrate.ts +128 -0
  238. package/src/db/migrations/0001_run_kind.sql +25 -0
  239. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  240. package/src/db/migrations/sql.d.ts +4 -0
  241. package/src/db/queries/collections.ts +133 -0
  242. package/src/db/queries/coverage.ts +9 -0
  243. package/src/db/queries/dashboard.ts +59 -0
  244. package/src/db/queries/results.ts +216 -0
  245. package/src/db/queries/runs.ts +289 -0
  246. package/src/db/queries/sessions.ts +42 -0
  247. package/src/db/queries/settings.ts +28 -0
  248. package/src/db/queries/types.ts +172 -0
  249. package/src/db/queries.ts +75 -802
  250. package/src/db/schema.ts +178 -50
  251. package/src/cli/commands/export.ts +0 -144
  252. package/src/cli/commands/guide.ts +0 -127
  253. package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
  254. package/src/cli/commands/probe-methods.ts +0 -108
  255. package/src/cli/commands/probe-validation.ts +0 -124
  256. package/src/cli/commands/serve.ts +0 -114
  257. package/src/cli/commands/sync.ts +0 -268
  258. package/src/cli/commands/update.ts +0 -189
  259. package/src/cli/commands/validate.ts +0 -34
  260. package/src/core/diagnostics/render-md.ts +0 -112
  261. package/src/core/exporter/postman.ts +0 -963
  262. package/src/core/generator/guide-builder.ts +0 -253
  263. package/src/core/meta/types.ts +0 -19
  264. package/src/core/parser/index.ts +0 -21
  265. package/src/core/runner/execute-run.ts +0 -132
  266. package/src/core/runner/index.ts +0 -12
  267. package/src/core/sync/spec-differ.ts +0 -38
  268. package/src/web/data/collection-state.ts +0 -362
  269. package/src/web/routes/api.ts +0 -314
  270. package/src/web/routes/dashboard.ts +0 -350
  271. package/src/web/routes/runs.ts +0 -64
  272. package/src/web/schemas.ts +0 -121
  273. package/src/web/server.ts +0 -134
  274. package/src/web/static/htmx.min.cjs +0 -1
  275. package/src/web/static/style.css +0 -1148
  276. package/src/web/views/endpoints-tab.ts +0 -174
  277. package/src/web/views/explorer-tab.ts +0 -402
  278. package/src/web/views/health-strip.ts +0 -92
  279. package/src/web/views/layout.ts +0 -48
  280. package/src/web/views/results.ts +0 -210
  281. package/src/web/views/runs-tab.ts +0 -126
  282. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,230 @@
1
+ /**
2
+ * SARIF v2.1.0 reporter for `zond checks` (m-15 ARV-5).
3
+ *
4
+ * Maps `CheckFinding[]` into a SARIF log that GitHub Code Scanning can
5
+ * ingest via `github/codeql-action/upload-sarif@v3`. Two invariants the
6
+ * format relies on:
7
+ *
8
+ * - `tool.driver.rules` — descriptors for every registered check, so
9
+ * even a finding-less run carries the catalog. ruleId follows the
10
+ * `<category>-<check_id>` form that oasdiff uses.
11
+ * - `partialFingerprints.primary` — sha1(ruleId + jsonPointer +
12
+ * spec_hash). Stable across re-runs of the same spec, so GitHub
13
+ * dedupes rather than re-opening alerts every push (42Crunch-style).
14
+ *
15
+ * The reporter is deliberately schema-only — it builds the JSON
16
+ * document, the CLI handles writing it to disk.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+
20
+ import { listChecks } from "./registry.ts";
21
+ import { listStatefulChecks } from "./stateful.ts";
22
+ import type { CheckFinding, Severity } from "./types.ts";
23
+ import { categoryFor, type Category } from "../severity/category.ts";
24
+ import { severityToSarifLevel } from "../severity/index.ts";
25
+
26
+ export { categoryFor };
27
+
28
+ export function ruleIdFor(checkId: string): string {
29
+ return `${categoryFor(checkId)}-${checkId}`;
30
+ }
31
+
32
+ const severityToLevel = severityToSarifLevel;
33
+
34
+ /** RFC 6901 JSON Pointer for the operation: `/paths/<escaped>/<method>`.
35
+ * Escapes `~` → `~0` and `/` → `~1` so paths like `/users/{id}` survive
36
+ * serialization. */
37
+ export function jsonPointerForOperation(path: string, method: string): string {
38
+ const escaped = path.replace(/~/g, "~0").replace(/\//g, "~1");
39
+ return `/paths/${escaped}/${method.toLowerCase()}`;
40
+ }
41
+
42
+ function sha1(s: string): string {
43
+ return createHash("sha1").update(s).digest("hex");
44
+ }
45
+
46
+ export function specHashOf(specContent: string): string {
47
+ return sha1(specContent);
48
+ }
49
+
50
+ export function partialFingerprintFor(ruleId: string, jsonPointer: string, specHash: string): string {
51
+ return sha1(`${ruleId}\n${jsonPointer}\n${specHash}`);
52
+ }
53
+
54
+ interface SarifReportingDescriptor {
55
+ id: string;
56
+ name: string;
57
+ shortDescription: { text: string };
58
+ defaultConfiguration: { level: "error" | "warning" | "note" };
59
+ helpUri?: string;
60
+ properties: {
61
+ category: Category;
62
+ severity: Severity;
63
+ references: string[];
64
+ tags: string[];
65
+ };
66
+ }
67
+
68
+ interface SarifResult {
69
+ ruleId: string;
70
+ ruleIndex: number;
71
+ level: "error" | "warning" | "note";
72
+ message: { text: string };
73
+ locations: Array<{
74
+ physicalLocation: {
75
+ artifactLocation: { uri: string; uriBaseId?: string };
76
+ // SARIF requires region to specify at least one of startLine/charOffset/
77
+ // byteOffset. We don't parse the spec into lines — startLine: 1 keeps
78
+ // GitHub Code Scanning happy and the JSON Pointer travels in the
79
+ // logicalLocations + properties below.
80
+ region: { startLine: number; snippet: { text: string } };
81
+ };
82
+ logicalLocations: Array<{ fullyQualifiedName: string; kind: string }>;
83
+ }>;
84
+ partialFingerprints: { primary: string };
85
+ properties: Record<string, unknown>;
86
+ }
87
+
88
+ export interface SarifLog {
89
+ $schema: string;
90
+ version: "2.1.0";
91
+ runs: Array<{
92
+ tool: {
93
+ driver: {
94
+ name: string;
95
+ version: string;
96
+ informationUri: string;
97
+ rules: SarifReportingDescriptor[];
98
+ };
99
+ };
100
+ results: SarifResult[];
101
+ }>;
102
+ }
103
+
104
+ export interface SarifReportOptions {
105
+ findings: CheckFinding[];
106
+ /** Raw spec text — fed into `sha1` for the partial-fingerprint salt
107
+ * so two runs against the same spec produce identical fingerprints. */
108
+ specContent: string;
109
+ /** SARIF artifactLocation.uri. Defaults to "spec.json"; CLI sets the
110
+ * spec's relative path so GitHub Code Scanning links findings to the
111
+ * spec source file. */
112
+ specUri?: string;
113
+ toolVersion: string;
114
+ toolInformationUri?: string;
115
+ }
116
+
117
+ function buildRules(): SarifReportingDescriptor[] {
118
+ const all = [
119
+ ...listChecks().map((c) => ({
120
+ id: c.id,
121
+ severity: c.severity,
122
+ defaultExpected: c.defaultExpected,
123
+ references: c.references,
124
+ })),
125
+ ...listStatefulChecks().map((c) => ({
126
+ id: c.id,
127
+ severity: c.severity,
128
+ defaultExpected: c.defaultExpected,
129
+ references: c.references,
130
+ })),
131
+ ];
132
+ const seen = new Set<string>();
133
+ const rules: SarifReportingDescriptor[] = [];
134
+ for (const c of all) {
135
+ const ruleId = ruleIdFor(c.id);
136
+ if (seen.has(ruleId)) continue;
137
+ seen.add(ruleId);
138
+ const cat = categoryFor(c.id);
139
+ const helpUri = c.references.find((r) => r.url)?.url;
140
+ const descriptor: SarifReportingDescriptor = {
141
+ id: ruleId,
142
+ name: c.id,
143
+ shortDescription: { text: c.defaultExpected },
144
+ defaultConfiguration: { level: severityToLevel(c.severity) },
145
+ properties: {
146
+ category: cat,
147
+ severity: c.severity,
148
+ references: c.references.map((r) => r.id),
149
+ tags: [cat, "openapi", "zond"],
150
+ },
151
+ };
152
+ if (helpUri) descriptor.helpUri = helpUri;
153
+ rules.push(descriptor);
154
+ }
155
+ return rules.sort((a, b) => a.id.localeCompare(b.id));
156
+ }
157
+
158
+ export function generateSarifReport(opts: SarifReportOptions): SarifLog {
159
+ const specHash = specHashOf(opts.specContent);
160
+ const specUri = opts.specUri ?? "spec.json";
161
+ const rules = buildRules();
162
+ const ruleIndex = new Map(rules.map((r, i) => [r.id, i] as const));
163
+
164
+ // Sort findings deterministically so two runs over the same spec emit
165
+ // byte-identical SARIF — GitHub diffs the file across pushes and any
166
+ // reordering churn would re-open and re-close alerts spuriously.
167
+ const sorted = [...opts.findings].sort((a, b) => {
168
+ const aId = ruleIdFor(a.check);
169
+ const bId = ruleIdFor(b.check);
170
+ if (aId !== bId) return aId.localeCompare(bId);
171
+ if (a.operation.path !== b.operation.path) return a.operation.path.localeCompare(b.operation.path);
172
+ if (a.operation.method !== b.operation.method) return a.operation.method.localeCompare(b.operation.method);
173
+ return a.message.localeCompare(b.message);
174
+ });
175
+
176
+ const results: SarifResult[] = sorted.map((f) => {
177
+ const ruleId = ruleIdFor(f.check);
178
+ const ptr = jsonPointerForOperation(f.operation.path, f.operation.method);
179
+ const idx = ruleIndex.get(ruleId);
180
+ if (idx === undefined) {
181
+ throw new Error(`SARIF: no rule descriptor registered for check "${f.check}" (ruleId "${ruleId}")`);
182
+ }
183
+ const properties: Record<string, unknown> = {
184
+ severity: f.severity,
185
+ method: f.operation.method,
186
+ path: f.operation.path,
187
+ request_signature: f.request_signature,
188
+ response_status: f.response_summary.status,
189
+ };
190
+ if (f.operation.operationId) properties.operationId = f.operation.operationId;
191
+ if (f.response_summary.content_type) properties.response_content_type = f.response_summary.content_type;
192
+ if (f.evidence) properties.evidence = f.evidence;
193
+ if (f.recommended_action) properties.recommendedAction = f.recommended_action;
194
+ return {
195
+ ruleId,
196
+ ruleIndex: idx,
197
+ level: severityToLevel(f.severity),
198
+ message: { text: f.message },
199
+ locations: [
200
+ {
201
+ physicalLocation: {
202
+ artifactLocation: { uri: specUri },
203
+ region: { startLine: 1, snippet: { text: ptr } },
204
+ },
205
+ logicalLocations: [{ fullyQualifiedName: ptr, kind: "object" }],
206
+ },
207
+ ],
208
+ partialFingerprints: { primary: partialFingerprintFor(ruleId, ptr, specHash) },
209
+ properties,
210
+ };
211
+ });
212
+
213
+ return {
214
+ $schema: "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json",
215
+ version: "2.1.0",
216
+ runs: [
217
+ {
218
+ tool: {
219
+ driver: {
220
+ name: "zond",
221
+ version: opts.toolVersion,
222
+ informationUri: opts.toolInformationUri ?? "https://github.com/kirrosh/zond",
223
+ rules,
224
+ },
225
+ },
226
+ results,
227
+ },
228
+ ],
229
+ };
230
+ }
@@ -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
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Stateful checks (m-15 ARV-3) — security-flavored checks that need
3
+ * to orchestrate multiple HTTP requests against a single operation
4
+ * (auth probes) or a CRUD chain (use-after-free / availability).
5
+ *
6
+ * Kept in a parallel registry from the per-response `Check`s so the
7
+ * single-response runner stays simple. `runChecks` calls
8
+ * `runStateful(...)` after the per-op response phase.
9
+ */
10
+ import type { OpenAPIV3 } from "openapi-types";
11
+ import type { CrudGroup, EndpointInfo } from "../generator/types.ts";
12
+ import type { HttpRequest, HttpResponse } from "../runner/types.ts";
13
+ import { executeRequest } from "../runner/http-client.ts";
14
+ import type { CheckOutcome, CheckReference, CheckRuntimeOptions, Severity } from "./types.ts";
15
+
16
+ export interface StatefulHarness {
17
+ baseUrl: string;
18
+ doc: OpenAPIV3.Document;
19
+ /** Headers that constitute "real auth" for the run. Empty when the
20
+ * caller didn't pass --auth-header / no env vars. */
21
+ authHeaders: Record<string, string>;
22
+ /** When true, security checks should skip with a warning (ARV-3 AC #6). */
23
+ bootstrapCleanupFailed: boolean;
24
+ /** ARV-181: real path-param fixtures from `.env.yaml`. Mirrors what
25
+ * the per-response runner already does via ARV-141 — without this
26
+ * the stateful harness rebuilds URLs with literal `{event_id}`
27
+ * placeholders, gets routed to 403/404, and the broken-baseline
28
+ * guard silently skips real auth checks. Optional so unit tests
29
+ * can stub without it; production callers always pass them. */
30
+ pathVars?: Record<string, string>;
31
+ /** ARV-181: per-run knobs (e.g. strict401). Mirrors CheckContext.options
32
+ * for stateful checks so they can read the same flags as per-response
33
+ * ones. */
34
+ options?: CheckRuntimeOptions;
35
+ /** ARV-169 (m-20): per-resource overrides for cross-call probes,
36
+ * keyed by `resource` name from `.api-resources.yaml`. Today only
37
+ * `cross_call_references` reads `readbackDiff`; future m-20 probes
38
+ * (idempotency, pagination, lifecycle) will append their own keys
39
+ * to the per-resource entry. Optional — when absent each probe
40
+ * falls back to its built-in defaults. */
41
+ resourceConfigs?: Map<string, {
42
+ readbackDiff?: import("../generator/resources-builder.ts").ReadbackDiffConfig;
43
+ idempotency?: import("../generator/resources-builder.ts").IdempotencyConfig;
44
+ pagination?: import("../generator/resources-builder.ts").PaginationConfig;
45
+ lifecycle?: import("../generator/resources-builder.ts").LifecycleConfig;
46
+ /** ARV-187: LLM-authored example POST body — preferred over
47
+ * generateFromSchema(create) by stateful CRUD checks. */
48
+ seedBody?: import("../generator/resources-builder.ts").SeedBodyConfig;
49
+ }>;
50
+ send(req: HttpRequest, opts?: { timeoutMs?: number }): Promise<HttpResponse>;
51
+ }
52
+
53
+ export interface BaseStatefulCheck {
54
+ id: string;
55
+ severity: Severity;
56
+ defaultExpected: string;
57
+ references: CheckReference[];
58
+ }
59
+
60
+ export interface AuthStatefulCheck extends BaseStatefulCheck {
61
+ phase: "auth";
62
+ applies(op: EndpointInfo): boolean;
63
+ run(op: EndpointInfo, h: StatefulHarness): Promise<CheckOutcome>;
64
+ }
65
+
66
+ export interface CrudStatefulCheck extends BaseStatefulCheck {
67
+ phase: "crud";
68
+ applies(group: CrudGroup): boolean;
69
+ run(group: CrudGroup, h: StatefulHarness): Promise<CheckOutcome>;
70
+ }
71
+
72
+ export type StatefulCheck = AuthStatefulCheck | CrudStatefulCheck;
73
+
74
+ const STATEFUL_REGISTRY = new Map<string, StatefulCheck>();
75
+
76
+ export function registerStatefulCheck(c: StatefulCheck): void {
77
+ if (STATEFUL_REGISTRY.has(c.id)) throw new Error(`Stateful check "${c.id}" already registered`);
78
+ STATEFUL_REGISTRY.set(c.id, c);
79
+ }
80
+
81
+ export function listStatefulChecks(): StatefulCheck[] {
82
+ return [...STATEFUL_REGISTRY.values()].sort((a, b) => a.id.localeCompare(b.id));
83
+ }
84
+
85
+ export function makeHarness(
86
+ baseUrl: string,
87
+ doc: OpenAPIV3.Document,
88
+ opts: {
89
+ authHeaders?: Record<string, string>;
90
+ bootstrapCleanupFailed?: boolean;
91
+ timeoutMs?: number;
92
+ pathVars?: Record<string, string>;
93
+ options?: CheckRuntimeOptions;
94
+ resourceConfigs?: StatefulHarness["resourceConfigs"];
95
+ /** ARV-227: shared with the per-response phase so a single
96
+ * `--max-requests` cap bounds the whole run. Throws (caught by
97
+ * the runner's per-check try/catch and converted to skip) once
98
+ * the budget is exhausted, so a stateful probe mid-chain doesn't
99
+ * silently spin past the cap. */
100
+ requestBudget?: import("../runner/executor.ts").RequestBudget;
101
+ } = {},
102
+ ): StatefulHarness {
103
+ return {
104
+ baseUrl,
105
+ doc,
106
+ authHeaders: opts.authHeaders ?? {},
107
+ bootstrapCleanupFailed: opts.bootstrapCleanupFailed ?? false,
108
+ pathVars: opts.pathVars,
109
+ options: opts.options,
110
+ resourceConfigs: opts.resourceConfigs,
111
+ send: async (req, sendOpts) => {
112
+ if (opts.requestBudget) {
113
+ const { reserveRequest, MAX_REQUESTS_SKIP_REASON } = await import("../runner/executor.ts");
114
+ if (!reserveRequest(opts.requestBudget)) {
115
+ throw new Error(MAX_REQUESTS_SKIP_REASON);
116
+ }
117
+ }
118
+ return executeRequest(req, { timeout: sendOpts?.timeoutMs ?? opts.timeoutMs ?? 30000 });
119
+ },
120
+ };
121
+ }