@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,786 @@
1
+ /**
2
+ * `zond audit --api X` — macro-команда для полного pipeline (TASK-262).
3
+ *
4
+ * Оборачивает 8-10 ручных шагов (prepare-fixtures → generate → probes
5
+ * → session-wrapped run → coverage → HTML report) в одну команду:
6
+ *
7
+ * 1. `prepare-fixtures --apply` — single-pass, заполняет `.env.yaml`
8
+ * FK-идентификаторами, что нашлись детерминированно.
9
+ * 2. `generate` — пропускается если `apis/<name>/tests/` свежее, чем
10
+ * `spec.json` (mtime-эвристика; `--force` отключает skip).
11
+ * 3. `probe static` (validation+methods, всегда). `mass-assignment` и
12
+ * `security` — за `--with-mass-assignment` / `--with-security`.
13
+ * 4. `session start` → `run apis/<name>/tests` + `run apis/<name>/probes`
14
+ * → `session end`. Все runs наследуют один session_id.
15
+ * 5. `coverage --api X --union session --json` для embed'a в репорт.
16
+ * 6. Запись `audit-report.html` (или `--out`) с таблицей stages,
17
+ * coverage-сводкой и подсказками для drill-down.
18
+ *
19
+ * Каждая stage спавнится как отдельный subprocess `zond ...`. Failure
20
+ * любой stage НЕ останавливает pipeline — финальный exit 1 если хоть одна
21
+ * упала, 0 если все ok. `--dry-run` печатает план без выполнения.
22
+ */
23
+
24
+ import { existsSync, statSync } from "node:fs";
25
+ import { writeFile } from "node:fs/promises";
26
+ import { join, resolve } from "node:path";
27
+ import type { Command } from "commander";
28
+ import { globalJson } from "../resolve.ts";
29
+ import { getDb } from "../../db/schema.ts";
30
+ import { findCollectionByNameOrId, listSessions, listRunsBySession } from "../../db/queries.ts";
31
+ import { resolveCollectionSpec } from "../../core/setup-api.ts";
32
+ import { printSuccess, printWarning, printError } from "../output.ts";
33
+ import { getApi, MISSING_API_MESSAGE } from "../util/api-context.ts";
34
+ import { jsonOk, printJson } from "../json-envelope.ts";
35
+ import { VERSION } from "../version.ts";
36
+ import { diagnoseRun, type DiagnoseResult } from "../../core/diagnostics/db-analysis.ts";
37
+ import { readCurrentSession } from "../../core/context/session.ts";
38
+ import { resolveBudget, isBudget, BUDGETS, type Budget } from "../../core/checks/budget.ts";
39
+
40
+ interface Stage {
41
+ key: string;
42
+ name: string;
43
+ args: string[];
44
+ /** If returns string, stage is skipped with that reason. */
45
+ skip?: () => string | null;
46
+ }
47
+
48
+ interface StageResult {
49
+ key: string;
50
+ name: string;
51
+ status: "ok" | "failed" | "skipped";
52
+ exit_code: number | null;
53
+ duration_ms: number;
54
+ reason?: string;
55
+ }
56
+
57
+ export interface AuditOptions {
58
+ api: string;
59
+ dbPath?: string;
60
+ withMassAssignment?: boolean;
61
+ withSecurity?: boolean;
62
+ out?: string;
63
+ dryRun?: boolean;
64
+ force?: boolean;
65
+ json?: boolean;
66
+ /** ARV-264: opt-in to the full pipeline against a real-traffic API.
67
+ * When false (default), audit runs in safe mode — no mass-assignment /
68
+ * security probes, no destructive probes. */
69
+ live?: boolean;
70
+ /** ARV-292: adaptive cap and stateful gating tier. Translates to
71
+ * `--max-requests N` on every spawned `run` stage. */
72
+ budget?: Budget;
73
+ }
74
+
75
+ /**
76
+ * Build the prefix for self-spawning `zond ...`. When the binary is
77
+ * compiled, `process.execPath` IS the zond binary. In dev, `bun` runs the
78
+ * script directly — fall back to `[bun, src/cli/index.ts]`.
79
+ */
80
+ function zondInvoker(): string[] {
81
+ const exec = process.execPath;
82
+ const base = exec.replace(/\\/g, "/");
83
+ if (base.endsWith("/zond") || base.endsWith("/zond.exe")) return [exec];
84
+ const script = process.argv[1] || "src/cli/index.ts";
85
+ return [exec, script];
86
+ }
87
+
88
+ function buildStages(opts: AuditOptions, apiDir: string, specPath: string | null): Stage[] {
89
+ const api = opts.api;
90
+ const stages: Stage[] = [];
91
+ const budgetResolved = resolveBudget(opts.budget, undefined);
92
+ const runMaxRequestsArgs: string[] = budgetResolved.maxRequests !== undefined
93
+ ? ["--max-requests", String(budgetResolved.maxRequests)]
94
+ // placeholder to keep the binding shape stable
95
+ : [];
96
+ // ARV-302: probes don't share `zond run`'s --max-requests vocabulary
97
+ // (probes scan endpoint-by-endpoint, not request-by-request). Map the
98
+ // budget tier to a coarse `--max-endpoints` cap so probe stages stay
99
+ // inside the same wall-clock budget. `full` keeps the legacy
100
+ // uncapped behaviour.
101
+ const probeMaxEndpointsByTier: Record<string, number | undefined> = {
102
+ quick: 10,
103
+ standard: 50,
104
+ full: undefined,
105
+ };
106
+ const probeMaxEndpoints = opts.budget ? probeMaxEndpointsByTier[opts.budget] : undefined;
107
+ const probeMaxEndpointsArgs: string[] = probeMaxEndpoints !== undefined
108
+ ? ["--max-endpoints", String(probeMaxEndpoints)]
109
+ : [];
110
+
111
+ // ARV-336: prep is always a single-pass `prepare-fixtures --apply`. It
112
+ // fills what discover resolves deterministically and reports the rest as
113
+ // gaps for the agent/user to fill by hand — no autonomous seed/cascade.
114
+ stages.push({
115
+ key: "prepare-fixtures",
116
+ name: "prepare-fixtures (path-FK fixtures)",
117
+ args: ["prepare-fixtures", "--api", api, "--apply"],
118
+ });
119
+
120
+ stages.push({
121
+ key: "generate",
122
+ name: "generate (smoke + crud)",
123
+ args: ["generate", "--api", api, "--output", join(apiDir, "tests")],
124
+ skip: () => {
125
+ if (opts.force) return null;
126
+ if (!specPath || !existsSync(specPath)) return null;
127
+ const testsDir = join(apiDir, "tests");
128
+ if (!existsSync(testsDir)) return null;
129
+ try {
130
+ const specMtime = statSync(specPath).mtimeMs;
131
+ const testsMtime = statSync(testsDir).mtimeMs;
132
+ if (testsMtime > specMtime) return "tests/ newer than spec — pass --force to regenerate";
133
+ } catch {
134
+ // ignore — fall through to running generate
135
+ }
136
+ return null;
137
+ },
138
+ });
139
+
140
+ stages.push({
141
+ key: "probe-static",
142
+ name: "probe static (validation+methods)",
143
+ args: ["probe", "static", "--api", api, "--output", join(apiDir, "probes", "static")],
144
+ });
145
+
146
+ // ARV-264: mass-assignment + security probes send real POST/PUT/DELETE
147
+ // traffic. Gate them behind --live so the default (safe) audit can't
148
+ // pollute a production API.
149
+ const liveProbesEnabled = opts.live === true;
150
+ if (opts.withMassAssignment && liveProbesEnabled) {
151
+ stages.push({
152
+ key: "probe-mass-assignment",
153
+ name: "probe mass-assignment",
154
+ args: [
155
+ "probe", "mass-assignment", "--api", api,
156
+ "--output", join(apiDir, "probes", "mass-assignment-digest.md"),
157
+ "--emit-tests", join(apiDir, "probes", "mass-assignment"),
158
+ "--overwrite",
159
+ ...probeMaxEndpointsArgs,
160
+ ],
161
+ });
162
+ }
163
+ if (opts.withSecurity && liveProbesEnabled) {
164
+ stages.push({
165
+ key: "probe-security",
166
+ name: "probe security (ssrf,crlf,open-redirect)",
167
+ args: [
168
+ "probe", "security", "ssrf,crlf,open-redirect", "--api", api,
169
+ "--output", join(apiDir, "probes", "security-digest.md"),
170
+ "--emit-tests", join(apiDir, "probes", "security"),
171
+ "--overwrite",
172
+ ...probeMaxEndpointsArgs,
173
+ ],
174
+ });
175
+ }
176
+
177
+ // ARV-65: if the user already has an active session, REUSE it — don't
178
+ // clobber their .zond/current-session by spawning audit's own. Skipping
179
+ // session-end is the critical half: otherwise audit clears the user's
180
+ // session even when start was a no-op.
181
+ const existingSession = readCurrentSession();
182
+ const sessionLabel = `audit-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}`;
183
+ if (existingSession) {
184
+ const reuseReason = `reusing active session ${existingSession.id}${existingSession.label ? ` (${existingSession.label})` : ""}`;
185
+ stages.push({
186
+ key: "session-start",
187
+ name: "session start (reused)",
188
+ args: ["session", "start", "--label", sessionLabel],
189
+ skip: () => reuseReason,
190
+ });
191
+ stages.push({ key: "run-tests", name: "run tests", args: ["run", join(apiDir, "tests"), "--api", api, ...runMaxRequestsArgs] });
192
+ stages.push({ key: "run-probes", name: "run probes", args: ["run", join(apiDir, "probes"), "--api", api, ...runMaxRequestsArgs] });
193
+ stages.push({
194
+ key: "session-end",
195
+ name: "session end (reused — kept active)",
196
+ args: ["session", "end"],
197
+ skip: () => reuseReason,
198
+ });
199
+ } else {
200
+ stages.push({ key: "session-start", name: `session start (${sessionLabel})`, args: ["session", "start", "--label", sessionLabel] });
201
+ stages.push({ key: "run-tests", name: "run tests", args: ["run", join(apiDir, "tests"), "--api", api, ...runMaxRequestsArgs] });
202
+ stages.push({ key: "run-probes", name: "run probes", args: ["run", join(apiDir, "probes"), "--api", api, ...runMaxRequestsArgs] });
203
+ stages.push({ key: "session-end", name: "session end", args: ["session", "end"] });
204
+ }
205
+ // ARV-108: surface the post-stage coverage capture in the plan so the
206
+ // dry-run listing matches the actual pipeline. The stage is special-cased
207
+ // in auditCommand — we keep stdout for JSON parsing rather than inheriting.
208
+ stages.push({
209
+ key: "coverage",
210
+ name: "coverage (session union)",
211
+ args: ["coverage", "--api", api, "--union", "session", "--json"],
212
+ });
213
+
214
+ return stages;
215
+ }
216
+
217
+ async function runStage(stage: Stage, idx: number, total: number, json: boolean): Promise<StageResult> {
218
+ const skipReason = stage.skip?.();
219
+ if (skipReason) {
220
+ if (!json) console.log(`==> Stage ${idx}/${total}: ${stage.name} — skipped (${skipReason})`);
221
+ return { key: stage.key, name: stage.name, status: "skipped", exit_code: null, duration_ms: 0, reason: skipReason };
222
+ }
223
+ if (!json) console.log(`==> Stage ${idx}/${total}: ${stage.name}`);
224
+ const t0 = Date.now();
225
+ const cmd = [...zondInvoker(), ...stage.args];
226
+ const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" });
227
+ const code = await proc.exited;
228
+ const ms = Date.now() - t0;
229
+ const status: StageResult["status"] = code === 0 ? "ok" : "failed";
230
+ // ARV-66: print a per-stage completion line so the user sees OK/FAIL inline
231
+ // next to "Stage N/M" instead of inferring from the final summary. Subprocess
232
+ // stdio is inherited above, so this line lands AFTER the stage's own output.
233
+ if (!json) {
234
+ const tag = status === "ok" ? "OK" : `FAIL (exit ${code})`;
235
+ console.log(` └─ ${tag} · ${(ms / 1000).toFixed(1)}s`);
236
+ }
237
+ return { key: stage.key, name: stage.name, status, exit_code: code, duration_ms: ms };
238
+ }
239
+
240
+ interface CoverageCapture {
241
+ data: unknown | null;
242
+ exitCode: number | null;
243
+ parseError: string | null;
244
+ durationMs: number;
245
+ }
246
+
247
+ /**
248
+ * ARV-301: build the coverage-stage CLI args. Exported so unit tests
249
+ * can assert that audit pins `--session-id <id>` rather than
250
+ * `--union session` whenever a session id was captured ahead of
251
+ * `session end` (the latter selector rejects closed sessions).
252
+ */
253
+ export function buildCoverageStageArgs(api: string, sessionId?: string): string[] {
254
+ const sel = sessionId ? ["--session-id", sessionId] : ["--union", "session"];
255
+ return ["coverage", "--api", api, ...sel, "--json"];
256
+ }
257
+
258
+ /**
259
+ * ARV-301 follow-up: judge audit's coverage stage on the JSON envelope's
260
+ * `ok` field, not the subprocess exit code.
261
+ *
262
+ * `zond coverage` exits 1 when there are uncovered endpoints (legacy
263
+ * CI-gate behaviour) — that is a normal report state for audit's
264
+ * purposes, the envelope is well-formed and carries valid coverage
265
+ * data. Only exit 2 / non-JSON output / `ok:false` envelopes mean a
266
+ * real coverage failure.
267
+ *
268
+ * Exported pure function so unit tests can lock the contract without
269
+ * spawning a subprocess.
270
+ */
271
+ export function interpretCoverageOutput(
272
+ stdout: string,
273
+ exitCode: number | null,
274
+ ): { data: unknown | null; parseError: string | null } {
275
+ let parsed: unknown = null;
276
+ let parseError: string | null = null;
277
+ try {
278
+ parsed = stdout.trim() ? JSON.parse(stdout) : null;
279
+ } catch (e) {
280
+ parseError = (e as Error).message;
281
+ }
282
+ if (parsed && typeof parsed === "object" && (parsed as { ok?: boolean }).ok === true) {
283
+ // Envelope says everything is fine; treat coverage as captured
284
+ // even if exit was 1 due to "has uncovered endpoints".
285
+ return { data: parsed, parseError: null };
286
+ }
287
+ // ok:false, or no envelope at all — propagate the failure signal.
288
+ // exitCode is intentionally not consulted here; callers attach it
289
+ // to the stage result for the user.
290
+ void exitCode;
291
+ return { data: null, parseError };
292
+ }
293
+
294
+ async function captureCoverage(api: string, sessionId?: string): Promise<CoverageCapture> {
295
+ const t0 = Date.now();
296
+ try {
297
+ const cmd = [...zondInvoker(), ...buildCoverageStageArgs(api, sessionId)];
298
+ const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
299
+ const stdout = await new Response(proc.stdout).text();
300
+ const code = await proc.exited;
301
+ const ms = Date.now() - t0;
302
+ const { data, parseError } = interpretCoverageOutput(stdout, code);
303
+ return { data, exitCode: code, parseError, durationMs: ms };
304
+ } catch (e) {
305
+ return { data: null, exitCode: null, parseError: (e as Error).message, durationMs: Date.now() - t0 };
306
+ }
307
+ }
308
+
309
+ export async function auditCommand(options: AuditOptions): Promise<number> {
310
+ // Like bootstrap: fall back to apis/<name>/ when no DB/collection — the
311
+ // workspace-on-disk shape is enough for the macro to drive subprocesses.
312
+ let apiDir = `apis/${options.api}`;
313
+ let specPath: string | null = null;
314
+ try {
315
+ getDb(options.dbPath);
316
+ const col = findCollectionByNameOrId(options.api);
317
+ if (col) {
318
+ apiDir = col.base_dir ?? apiDir;
319
+ specPath = col.openapi_spec ? resolveCollectionSpec(col.openapi_spec) : null;
320
+ }
321
+ } catch {
322
+ // No DB — keep filesystem-only fallback.
323
+ }
324
+ if (!specPath) {
325
+ const guess = join(apiDir, "spec.json");
326
+ if (existsSync(guess)) specPath = guess;
327
+ }
328
+
329
+ // ARV-264: surface what safe mode silently drops and run pre-flight
330
+ // checks (auth + security schemes) before any subprocess fires.
331
+ const preflightWarnings = runSafePreflight(options, specPath, apiDir);
332
+ if (!options.json) {
333
+ for (const w of preflightWarnings) printWarning(w);
334
+ }
335
+
336
+ const stages = buildStages(options, apiDir, specPath);
337
+ const out = options.out ?? "audit-report.html";
338
+
339
+ if (options.dryRun) {
340
+ if (options.json) {
341
+ printJson(jsonOk("audit", {
342
+ plan: stages.map((s) => ({ key: s.key, name: s.name, args: s.args })),
343
+ out,
344
+ }));
345
+ } else {
346
+ console.log(`Plan: zond audit --api ${options.api} (${stages.length} stages)`);
347
+ stages.forEach((s, i) => {
348
+ console.log(` ${(i + 1).toString().padStart(2)}. ${s.name}`);
349
+ console.log(` zond ${s.args.join(" ")}`);
350
+ });
351
+ console.log(`\nReport will be written to: ${out}`);
352
+ }
353
+ return 0;
354
+ }
355
+
356
+ const t0 = Date.now();
357
+ const results: StageResult[] = [];
358
+ let coverageJson: unknown = null;
359
+ let coverageCapture: CoverageCapture | null = null;
360
+ // ARV-301: capture the active session id BEFORE session-end runs, so
361
+ // coverage can target it explicitly (--session-id) instead of via
362
+ // --union session, which would reject the now-closed session.
363
+ let pinnedSessionId: string | undefined;
364
+ for (let i = 0; i < stages.length; i++) {
365
+ const stage = stages[i]!;
366
+ if (stage.key === "session-end" && !pinnedSessionId) {
367
+ pinnedSessionId = readCurrentSession()?.id;
368
+ }
369
+ if (stage.key === "coverage") {
370
+ // ARV-108: coverage runs via captureCoverage so we keep stdout JSON.
371
+ if (!options.json) console.log(`==> Stage ${i + 1}/${stages.length}: ${stage.name}`);
372
+ coverageCapture = await captureCoverage(options.api, pinnedSessionId);
373
+ coverageJson = coverageCapture.data;
374
+ const status: StageResult["status"] = coverageCapture.data
375
+ ? "ok"
376
+ : coverageCapture.exitCode === 0 && coverageCapture.parseError
377
+ ? "failed"
378
+ : coverageCapture.exitCode === 0
379
+ ? "skipped"
380
+ : "failed";
381
+ results.push({
382
+ key: stage.key,
383
+ name: stage.name,
384
+ status,
385
+ exit_code: coverageCapture.exitCode,
386
+ duration_ms: coverageCapture.durationMs,
387
+ reason: status === "skipped"
388
+ ? "no runs in session"
389
+ : coverageCapture.parseError
390
+ ? `non-JSON output: ${coverageCapture.parseError}`
391
+ : status === "failed"
392
+ ? `coverage exited ${coverageCapture.exitCode}`
393
+ : undefined,
394
+ });
395
+ // ARV-66: per-stage completion line for the coverage special-case too.
396
+ if (!options.json) {
397
+ const tag = status === "ok" ? "OK" : status === "skipped" ? "SKIPPED" : `FAIL (exit ${coverageCapture.exitCode})`;
398
+ console.log(` └─ ${tag} · ${(coverageCapture.durationMs / 1000).toFixed(1)}s`);
399
+ }
400
+ continue;
401
+ }
402
+ results.push(await runStage(stage, i + 1, stages.length, options.json === true));
403
+ }
404
+ const totalMs = Date.now() - t0;
405
+
406
+ // ARV-158: collect per-run drill-down from the audit session so the HTML
407
+ // can answer "WHICH 271 findings" instead of just "3 failed stages".
408
+ // The session was started by `session-start` and closed by `session-end`,
409
+ // so it's the most recent session in the DB at this point. listSessions(1)
410
+ // surfaces it; we then diagnose each run with failures > 0 (passed-only
411
+ // runs need no triage).
412
+ const drilldown: Array<{ run: { id: number; failed: number; total: number; passed: number }; diagnose: DiagnoseResult }> = [];
413
+ try {
414
+ const recent = listSessions(1);
415
+ const session = recent[0];
416
+ if (session?.session_id) {
417
+ const runs = listRunsBySession(session.session_id);
418
+ for (const r of runs) {
419
+ if (r.failed <= 0) continue;
420
+ try {
421
+ const diag = diagnoseRun(r.id, false, options.dbPath);
422
+ drilldown.push({
423
+ run: { id: r.id, failed: r.failed, total: r.total, passed: r.passed },
424
+ diagnose: diag,
425
+ });
426
+ } catch {
427
+ // diagnose of a single run failing should not break the report —
428
+ // skip and keep the rest.
429
+ }
430
+ }
431
+ }
432
+ } catch {
433
+ // DB unreachable / no sessions — leave drilldown empty; HTML degrades
434
+ // to the previous summary-only form.
435
+ }
436
+
437
+ await writeAuditReport(out, {
438
+ api: options.api,
439
+ apiDir,
440
+ stages: results,
441
+ totalMs,
442
+ coverage: coverageJson,
443
+ coverageStage: results.find((r) => r.key === "coverage") ?? null,
444
+ options,
445
+ drilldown,
446
+ });
447
+
448
+ // ARV-108: coverage is informational — keep it out of the fail count so we
449
+ // don't regress the "non-fatal coverage" contract.
450
+ const failedStages = results.filter((r) => r.status === "failed" && r.key !== "coverage");
451
+ const failed = failedStages.length;
452
+
453
+ if (options.json) {
454
+ printJson(jsonOk("audit", {
455
+ api: options.api,
456
+ stages: results,
457
+ total_ms: totalMs,
458
+ failed_stages: failed,
459
+ report: out,
460
+ coverage: coverageJson,
461
+ }));
462
+ } else {
463
+ console.log("");
464
+ // ARV-80: print absolute path so the user can open the HTML report
465
+ // without traversing — `audit-report.html` (relative) hid the location
466
+ // behind cwd, especially when audit was invoked from a parent dir.
467
+ const summary = `Audit complete (${results.length} stages, ${(totalMs / 1000).toFixed(1)}s) → ${resolve(out)}`;
468
+ if (failed === 0) {
469
+ printSuccess(summary);
470
+ } else {
471
+ printWarning(`${summary} — ${failed} failed: ${failedStages.map((s) => s.key).join(", ")}`);
472
+ }
473
+ }
474
+ return failed === 0 ? 0 : 1;
475
+ }
476
+
477
+ export interface ReportInput {
478
+ api: string;
479
+ apiDir: string;
480
+ stages: StageResult[];
481
+ totalMs: number;
482
+ coverage: unknown;
483
+ /** ARV-108: outcome of the post-stage coverage capture, so the HTML can
484
+ * distinguish "no session runs" from "coverage subcommand failed". */
485
+ coverageStage: StageResult | null;
486
+ options: AuditOptions;
487
+ /** ARV-158: per-run diagnose envelopes for runs in this audit's session
488
+ * that had failures. Each entry feeds a collapsible drill-down block
489
+ * in the HTML so the report answers "WHICH findings" inline. Empty
490
+ * array when no runs failed or when DB lookup couldn't reach the
491
+ * session. */
492
+ drilldown: Array<{
493
+ run: { id: number; failed: number; total: number; passed: number };
494
+ diagnose: DiagnoseResult;
495
+ }>;
496
+ }
497
+
498
+ function escapeHtml(s: string): string {
499
+ return s.replace(/[&<>"']/g, (c) => {
500
+ const map: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
501
+ return map[c]!;
502
+ });
503
+ }
504
+
505
+ interface CoverageEnvelope {
506
+ data?: {
507
+ totals?: { all?: number; covered2xx?: number; coveredButNon2xx?: number; unhit?: number };
508
+ pass_coverage?: { ratio?: number };
509
+ hit_coverage?: { ratio?: number };
510
+ coveredButNon2xxEndpoints?: Array<{ endpoint?: string }>;
511
+ unhitEndpoints?: Array<{ endpoint?: string }>;
512
+ };
513
+ }
514
+
515
+ /** ARV-158: exported for regression-test on HTML markup (drill-down
516
+ * sections per failed run). Same signature as before — the export
517
+ * doesn't expose anything CLI-internal. */
518
+ export async function writeAuditReport(outPath: string, data: ReportInput): Promise<void> {
519
+ const cov = data.coverage as CoverageEnvelope | null;
520
+ const totals = cov?.data?.totals;
521
+ const pass = cov?.data?.pass_coverage?.ratio;
522
+ const hit = cov?.data?.hit_coverage?.ratio;
523
+
524
+ const stageRows = data.stages.map((s) => {
525
+ const cls = s.status === "ok" ? "ok" : s.status === "failed" ? "fail" : "skip";
526
+ const ms = s.duration_ms === 0 ? "—" : `${(s.duration_ms / 1000).toFixed(1)}s`;
527
+ return `<tr class="${cls}"><td>${escapeHtml(s.name)}</td><td>${s.status}</td><td>${s.exit_code ?? "—"}</td><td>${ms}</td><td>${escapeHtml(s.reason ?? "")}</td></tr>`;
528
+ }).join("\n");
529
+
530
+ const reruncmd = `zond audit --api ${data.api}`
531
+ + (data.options.withMassAssignment ? " --with-mass-assignment" : "")
532
+ + (data.options.withSecurity ? " --with-security" : "");
533
+
534
+ const covStage = data.coverageStage;
535
+ // ARV-108: tailor the warning to what actually happened so the HTML stops
536
+ // misreporting "stage failed" when the stage was skipped (no runs in the
537
+ // session) or simply produced unparseable output.
538
+ const coverageWarning = covStage
539
+ ? covStage.status === "skipped"
540
+ ? "No session runs to summarise. Add `--with-mass-assignment` / `--with-security`, or run tests/probes that succeed."
541
+ : covStage.reason
542
+ ? `Coverage stage ${covStage.status}: ${escapeHtml(covStage.reason)}.`
543
+ : `Coverage stage ${covStage.status} (exit ${covStage.exit_code ?? "?"}).`
544
+ : "Coverage stage was not part of this audit (older binary?).";
545
+
546
+ // ARV-158: per-run drill-down — collapsible sections, one per failed
547
+ // run, surfacing by_recommended_action buckets (count + first example).
548
+ // Concrete commands replace the previous "type <run-id> manually" hint.
549
+ const drilldownBlocks = data.drilldown.length === 0
550
+ ? ""
551
+ : `<h2>Failures by run</h2>\n` + data.drilldown.map(({ run, diagnose }) => {
552
+ const buckets = diagnose.by_recommended_action ?? {};
553
+ // Sort buckets by priority (report_backend_bug first, then by count)
554
+ // — matches the zond-triage skill priority order.
555
+ const priorityOrder = [
556
+ "report_backend_bug",
557
+ "fix_spec",
558
+ "fix_auth_config",
559
+ "fix_env",
560
+ "fix_fixture",
561
+ "fix_network_config",
562
+ "regenerate_suite",
563
+ "tighten_validation",
564
+ "add_required_header",
565
+ "fix_test_logic",
566
+ "wontfix_known_limitation",
567
+ ];
568
+ const sortedKeys = Object.keys(buckets).sort((a, b) => {
569
+ const ai = priorityOrder.indexOf(a);
570
+ const bi = priorityOrder.indexOf(b);
571
+ if (ai === -1 && bi === -1) return (buckets[b]!.count) - (buckets[a]!.count);
572
+ if (ai === -1) return 1;
573
+ if (bi === -1) return -1;
574
+ return ai - bi;
575
+ });
576
+ const bucketsHtml = sortedKeys.length === 0
577
+ ? `<p class="muted">No <code>recommended_action</code> buckets — see raw failures via the commands below.</p>`
578
+ : `<ul class="buckets">` + sortedKeys.map((key) => {
579
+ const b = buckets[key]!;
580
+ const first = b.examples[0];
581
+ const sample = first
582
+ ? `<code>${escapeHtml(first.method)} ${escapeHtml(first.path)}</code> → <strong>${first.status}</strong>${first.reason ? ` — ${escapeHtml(first.reason)}` : ""}`
583
+ : "";
584
+ const moreExamples = b.examples.length > 1
585
+ ? ` <span class="muted">(+${b.examples.length - 1} more)</span>`
586
+ : "";
587
+ return `<li><strong>${escapeHtml(key)}</strong> ×${b.count}${sample ? ` — ${sample}${moreExamples}` : ""}</li>`;
588
+ }).join("\n") + `</ul>`;
589
+ return `<details>
590
+ <summary>Run #${run.id} — ${run.failed}/${run.total} failed (${run.passed} passed)</summary>
591
+ ${bucketsHtml}
592
+ <p class="cmds">
593
+ Drill in: <code>zond db diagnose --run-id ${run.id} --json</code> ·
594
+ <code>zond report export ${run.id}</code>
595
+ </p>
596
+ </details>`;
597
+ }).join("\n");
598
+
599
+ const coverageBlock = totals
600
+ ? `<h2>Coverage (session union)</h2>
601
+ <div class="cov">
602
+ <div><div class="num">${totals.covered2xx ?? 0}/${totals.all ?? 0}</div><div class="lbl">covered2xx</div></div>
603
+ <div><div class="num">${totals.coveredButNon2xx ?? 0}</div><div class="lbl">covered but non-2xx</div></div>
604
+ <div><div class="num">${totals.unhit ?? 0}</div><div class="lbl">unhit</div></div>
605
+ ${typeof pass === "number" ? `<div><div class="num">${(pass * 100).toFixed(0)}%</div><div class="lbl">pass coverage</div></div>` : ""}
606
+ ${typeof hit === "number" ? `<div><div class="num">${(hit * 100).toFixed(0)}%</div><div class="lbl">hit coverage</div></div>` : ""}
607
+ </div>`
608
+ : `<h2>Coverage</h2><div class="warn">${coverageWarning}</div>`;
609
+
610
+ const html = `<!doctype html>
611
+ <html lang="en"><head><meta charset="utf-8"><title>zond audit — ${escapeHtml(data.api)}</title>
612
+ <style>
613
+ body { font: 14px -apple-system, system-ui, sans-serif; max-width: 960px; margin: 2em auto; padding: 0 1em; color: #222; }
614
+ h1 { font-size: 1.4em; margin-bottom: 0.2em; }
615
+ h2 { font-size: 1.05em; margin-top: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
616
+ .meta { color: #666; font-size: 0.9em; margin-bottom: 1em; }
617
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 0.92em; }
618
+ th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid #eee; }
619
+ th { background: #f7f7f7; }
620
+ tr.ok td:nth-child(2) { color: #0a7; }
621
+ tr.fail td:nth-child(2) { color: #c33; font-weight: 600; }
622
+ tr.skip td:nth-child(2) { color: #888; font-style: italic; }
623
+ .cov { display: flex; gap: 2em; margin: 1em 0; flex-wrap: wrap; }
624
+ .cov .num { font-size: 1.6em; font-weight: 600; }
625
+ .cov .lbl { font-size: 0.8em; color: #666; }
626
+ .warn { background: #fef9e7; padding: 8px 12px; border-left: 3px solid #f0c040; margin: 1em 0; }
627
+ code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: 0.9em; }
628
+ ul { line-height: 1.6; }
629
+ details { margin: 0.8em 0; border: 1px solid #eee; border-radius: 4px; padding: 0.3em 0.8em; }
630
+ details summary { cursor: pointer; font-weight: 600; padding: 0.3em 0; }
631
+ details[open] summary { border-bottom: 1px solid #eee; margin-bottom: 0.6em; }
632
+ ul.buckets { padding-left: 1.2em; margin: 0.4em 0; }
633
+ ul.buckets li { margin: 0.25em 0; }
634
+ p.cmds { font-size: 0.88em; color: #555; margin-top: 0.8em; }
635
+ .muted { color: #888; }
636
+ </style></head>
637
+ <body>
638
+ <h1>zond audit — ${escapeHtml(data.api)}</h1>
639
+ <div class="meta">
640
+ zond ${escapeHtml(VERSION)} · ${new Date().toISOString()} · total ${(data.totalMs / 1000).toFixed(1)}s · apiDir <code>${escapeHtml(data.apiDir)}</code>
641
+ </div>
642
+
643
+ <h2>Stages</h2>
644
+ <table><thead><tr><th>Stage</th><th>Status</th><th>Exit</th><th>Duration</th><th>Note</th></tr></thead><tbody>
645
+ ${stageRows}
646
+ </tbody></table>
647
+
648
+ ${coverageBlock}
649
+
650
+ ${drilldownBlocks}
651
+
652
+ <h2>Drill-down</h2>
653
+ <ul>
654
+ <li>Re-run audit: <code>${escapeHtml(reruncmd)}</code></li>
655
+ <li>List recent runs: <code>zond db runs --limit 20</code></li>
656
+ <li>Per-run report: <code>zond report export &lt;run-id&gt;</code></li>
657
+ </ul>
658
+ </body></html>`;
659
+
660
+ await writeFile(outPath, html, "utf-8");
661
+ }
662
+
663
+ /**
664
+ * ARV-264: pre-flight checks for safe-mode auditing.
665
+ *
666
+ * 1. Warn when the user passed `--seed` / `--with-mass-assignment` /
667
+ * `--with-security` without `--live` — those stages are silently
668
+ * dropped in safe mode and the user deserves to know.
669
+ * 2. Warn when the spec declares `components.securitySchemes` but the
670
+ * workspace has no `auth_token` set — every probe will skip with
671
+ * 401 and the report will read as "all green" misleadingly.
672
+ * 3. Warn when no security schemes are declared at all (open API or
673
+ * incomplete spec) — same misleading-green risk on auth gates.
674
+ */
675
+ export function runSafePreflight(
676
+ opts: AuditOptions,
677
+ specPath: string | null,
678
+ apiDir: string,
679
+ ): string[] {
680
+ const out: string[] = [];
681
+ const safe = opts.live !== true;
682
+
683
+ if (safe) {
684
+ if (opts.withMassAssignment) {
685
+ out.push(
686
+ "audit --safe (default): --with-mass-assignment ignored. Mass-assignment probes POST mutated " +
687
+ "bodies and follow up with GET/DELETE — high blast radius. Re-run with --live to enable.",
688
+ );
689
+ }
690
+ if (opts.withSecurity) {
691
+ out.push(
692
+ "audit --safe (default): --with-security ignored. Security probes send live SSRF/CRLF/redirect " +
693
+ "payloads to real endpoints. Re-run with --live to enable.",
694
+ );
695
+ }
696
+ }
697
+
698
+ // Spec-level checks (cheap synchronous read; spec.json is JSON).
699
+ if (specPath && existsSync(specPath)) {
700
+ try {
701
+ const spec = JSON.parse(require("node:fs").readFileSync(specPath, "utf-8")) as {
702
+ components?: { securitySchemes?: Record<string, unknown> };
703
+ };
704
+ const hasSchemes = Boolean(
705
+ spec.components?.securitySchemes && Object.keys(spec.components.securitySchemes).length > 0,
706
+ );
707
+ const envPath = join(apiDir, ".env.yaml");
708
+ let authTokenSet = false;
709
+ if (existsSync(envPath)) {
710
+ const env = require("node:fs").readFileSync(envPath, "utf-8") as string;
711
+ // Match `auth_token: ...` lines whose value isn't an unfilled placeholder.
712
+ authTokenSet = /^\s*auth_token\s*:\s*(?!["']?\s*(<|UNSET|TODO|REPLACE|null|~)\b)\S/m.test(env);
713
+ }
714
+ if (hasSchemes && !authTokenSet) {
715
+ out.push(
716
+ "spec declares securitySchemes but auth_token is unset in .env.yaml — probes will likely hit " +
717
+ "401/403 and the report can read misleadingly green. Run `zond doctor --api " + opts.api + "` to fix.",
718
+ );
719
+ } else if (!hasSchemes) {
720
+ out.push(
721
+ "spec declares no securitySchemes — either the API is open or the spec is incomplete. " +
722
+ "Auth-related checks (ignored_auth, open_cors_on_sensitive) will skip; verify before sharing the report.",
723
+ );
724
+ }
725
+ } catch {
726
+ // Spec unreadable — separate failure mode, surfaced by other stages.
727
+ }
728
+ }
729
+
730
+ return out;
731
+ }
732
+
733
+ export function registerAudit(program: Command): void {
734
+ program
735
+ .command("audit")
736
+ .description("Smoke + breadth-coverage macro: prepare-fixtures → generate → probe static → session-wrapped run → coverage → HTML report. For depth-checks / security probes / stateful invariants, drive the `zond` skill — `audit` is the breadth pass, depth is the skill's job.")
737
+ // ARV-29: not `requiredOption` — same regression that hit prepare-fixtures
738
+ // (TASK-20) and checks run (TASK-17). Commander routes `--api` to the
739
+ // program-level option, so the subcommand's opts.api ends up undefined and
740
+ // requiredOption rejects every form (`--api foo`, `--api=foo`, even
741
+ // `zond --api foo audit`). Fall back the same way: explicit > program-level
742
+ // mirror > .zond/current-api.
743
+ .option("--api <name>", "Registered API to audit. Falls back to ZOND_API / .zond/current-api.")
744
+ .option("--db <path>", "Path to SQLite database file")
745
+ .option("--with-mass-assignment", "Include 'probe mass-assignment' as an extra stage. Requires --live.")
746
+ .option("--with-security", "Include 'probe security ssrf,crlf,open-redirect' as an extra stage. Requires --live.")
747
+ .option("--live", "ARV-264: opt into the full pipeline against a real-traffic API. Without this flag, audit runs in safe mode: no mass-assignment / security probes, no destructive traffic. Use only against throwaway/sandbox accounts.")
748
+ .option("--out <path>", "HTML report output path (default: audit-report.html)")
749
+ .option("--dry-run", "Print the stage plan without executing anything")
750
+ .option("--force", "Disable mtime-based skip (always regenerate, even if tests/ newer than spec)")
751
+ .option(
752
+ "--budget <tier>",
753
+ "ARV-292: adaptive request cap for spawned `run` stages. `quick` (50 req, ~60-sec gate), `standard` (500 req), `full` (uncapped). Omitted ⇒ legacy uncapped pipeline.",
754
+ )
755
+ .action(async (opts, cmd: Command) => {
756
+ // ARV-53.
757
+ const apiName = getApi(cmd, opts);
758
+ if (!apiName) {
759
+ printError(MISSING_API_MESSAGE);
760
+ process.exitCode = 2;
761
+ return;
762
+ }
763
+ opts.api = apiName;
764
+ let budget: Budget | undefined;
765
+ if (opts.budget !== undefined) {
766
+ if (!isBudget(opts.budget)) {
767
+ printError(`--budget must be one of: ${BUDGETS.join(", ")}; got '${opts.budget}'`);
768
+ process.exitCode = 2;
769
+ return;
770
+ }
771
+ budget = opts.budget;
772
+ }
773
+ process.exitCode = await auditCommand({
774
+ api: opts.api,
775
+ dbPath: opts.db,
776
+ withMassAssignment: opts.withMassAssignment === true,
777
+ withSecurity: opts.withSecurity === true,
778
+ out: opts.out,
779
+ dryRun: opts.dryRun === true,
780
+ force: opts.force === true,
781
+ json: globalJson(cmd),
782
+ live: opts.live === true,
783
+ budget,
784
+ });
785
+ });
786
+ }