@kirrosh/zond 0.26.1 → 0.27.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 (261) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +22 -21
  3. package/bin/zond.mjs +23 -0
  4. package/package.json +13 -16
  5. package/scripts/npm/postinstall.mjs +76 -0
  6. package/src/CLAUDE.md +0 -112
  7. package/src/bun-types.d.ts +0 -5
  8. package/src/cli/argv.ts +0 -122
  9. package/src/cli/commands/add-api.ts +0 -146
  10. package/src/cli/commands/api/annotate/idempotency.ts +0 -59
  11. package/src/cli/commands/api/annotate/index.ts +0 -880
  12. package/src/cli/commands/api/annotate/lifecycle.ts +0 -74
  13. package/src/cli/commands/api/annotate/overlay.ts +0 -206
  14. package/src/cli/commands/api/annotate/pagination.ts +0 -64
  15. package/src/cli/commands/api/annotate/prompts.ts +0 -220
  16. package/src/cli/commands/api/annotate/readback.ts +0 -58
  17. package/src/cli/commands/api/annotate/resources.ts +0 -91
  18. package/src/cli/commands/api/annotate/seed-bodies.ts +0 -61
  19. package/src/cli/commands/audit.ts +0 -786
  20. package/src/cli/commands/catalog.ts +0 -97
  21. package/src/cli/commands/check.ts +0 -361
  22. package/src/cli/commands/checks.ts +0 -1072
  23. package/src/cli/commands/ci-init.ts +0 -223
  24. package/src/cli/commands/clean.ts +0 -212
  25. package/src/cli/commands/cleanup.ts +0 -236
  26. package/src/cli/commands/completions.ts +0 -192
  27. package/src/cli/commands/coverage.ts +0 -872
  28. package/src/cli/commands/db.ts +0 -611
  29. package/src/cli/commands/describe.ts +0 -120
  30. package/src/cli/commands/discover.ts +0 -1356
  31. package/src/cli/commands/doctor.ts +0 -661
  32. package/src/cli/commands/fixtures.ts +0 -402
  33. package/src/cli/commands/generate.ts +0 -577
  34. package/src/cli/commands/init/agents-md.ts +0 -61
  35. package/src/cli/commands/init/bootstrap.ts +0 -111
  36. package/src/cli/commands/init/index.ts +0 -244
  37. package/src/cli/commands/init/skills.ts +0 -141
  38. package/src/cli/commands/init/templates/agents.md +0 -86
  39. package/src/cli/commands/init/templates/markdown.d.ts +0 -4
  40. package/src/cli/commands/init/templates/skills/warm-up-target.md +0 -122
  41. package/src/cli/commands/init/templates/skills/zond-checks.md +0 -621
  42. package/src/cli/commands/init/templates/skills/zond-seed.md +0 -114
  43. package/src/cli/commands/init/templates/skills/zond-triage.md +0 -272
  44. package/src/cli/commands/init/templates/skills/zond.md +0 -861
  45. package/src/cli/commands/init/templates/zond-config.yml +0 -14
  46. package/src/cli/commands/prepare-fixtures.ts +0 -97
  47. package/src/cli/commands/probe/_seed-bodies.ts +0 -52
  48. package/src/cli/commands/probe/mass-assignment.ts +0 -594
  49. package/src/cli/commands/probe/security.ts +0 -537
  50. package/src/cli/commands/probe/static.ts +0 -255
  51. package/src/cli/commands/probe/webhooks.ts +0 -163
  52. package/src/cli/commands/probe.ts +0 -535
  53. package/src/cli/commands/reference.ts +0 -87
  54. package/src/cli/commands/refresh-api.ts +0 -227
  55. package/src/cli/commands/remove-api.ts +0 -150
  56. package/src/cli/commands/report-bundle.ts +0 -310
  57. package/src/cli/commands/report.ts +0 -241
  58. package/src/cli/commands/request.ts +0 -548
  59. package/src/cli/commands/run.ts +0 -1162
  60. package/src/cli/commands/schema-from-runs.ts +0 -128
  61. package/src/cli/commands/secrets.ts +0 -133
  62. package/src/cli/commands/session.ts +0 -244
  63. package/src/cli/commands/use.ts +0 -74
  64. package/src/cli/index.ts +0 -55
  65. package/src/cli/json-envelope.ts +0 -108
  66. package/src/cli/json-schemas.ts +0 -314
  67. package/src/cli/output.ts +0 -40
  68. package/src/cli/program.ts +0 -219
  69. package/src/cli/resolve.ts +0 -105
  70. package/src/cli/runtime.ts +0 -7
  71. package/src/cli/safe-live.ts +0 -24
  72. package/src/cli/status-filter.ts +0 -114
  73. package/src/cli/util/api-context.ts +0 -85
  74. package/src/cli/version.ts +0 -8
  75. package/src/core/audit/persist.ts +0 -183
  76. package/src/core/checks/budget.ts +0 -59
  77. package/src/core/checks/checks/_crud-helpers.ts +0 -133
  78. package/src/core/checks/checks/_negative_mutator.ts +0 -133
  79. package/src/core/checks/checks/_readback-helpers.ts +0 -133
  80. package/src/core/checks/checks/content_type_conformance.ts +0 -39
  81. package/src/core/checks/checks/cross_call_references.ts +0 -147
  82. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +0 -219
  83. package/src/core/checks/checks/ensure_resource_availability.ts +0 -62
  84. package/src/core/checks/checks/idempotency_replay.ts +0 -242
  85. package/src/core/checks/checks/ignored_auth.ts +0 -254
  86. package/src/core/checks/checks/index.ts +0 -68
  87. package/src/core/checks/checks/lifecycle_transitions.ts +0 -416
  88. package/src/core/checks/checks/missing_required_header.ts +0 -40
  89. package/src/core/checks/checks/negative_data_rejection.ts +0 -148
  90. package/src/core/checks/checks/not_a_server_error.ts +0 -35
  91. package/src/core/checks/checks/open_cors_on_sensitive.ts +0 -160
  92. package/src/core/checks/checks/pagination_invariants.ts +0 -419
  93. package/src/core/checks/checks/positive_data_acceptance.ts +0 -33
  94. package/src/core/checks/checks/rate_limit_headers_absent.ts +0 -77
  95. package/src/core/checks/checks/response_headers_conformance.ts +0 -74
  96. package/src/core/checks/checks/response_schema_conformance.ts +0 -30
  97. package/src/core/checks/checks/status_code_conformance.ts +0 -132
  98. package/src/core/checks/checks/unsupported_method.ts +0 -63
  99. package/src/core/checks/checks/use_after_free.ts +0 -78
  100. package/src/core/checks/index.ts +0 -30
  101. package/src/core/checks/mode.ts +0 -82
  102. package/src/core/checks/recommended-action.ts +0 -68
  103. package/src/core/checks/registry.ts +0 -78
  104. package/src/core/checks/runner.ts +0 -1461
  105. package/src/core/checks/sarif.ts +0 -230
  106. package/src/core/checks/spec-findings.ts +0 -308
  107. package/src/core/checks/stateful.ts +0 -121
  108. package/src/core/checks/types.ts +0 -305
  109. package/src/core/checks/zond-extensions.ts +0 -73
  110. package/src/core/classifier/recommended-action.ts +0 -251
  111. package/src/core/context/current.ts +0 -51
  112. package/src/core/context/session.ts +0 -78
  113. package/src/core/coverage/loader.ts +0 -216
  114. package/src/core/coverage/reasons.ts +0 -300
  115. package/src/core/diagnostics/db-analysis.ts +0 -666
  116. package/src/core/diagnostics/failure-class.ts +0 -140
  117. package/src/core/diagnostics/failure-hints.ts +0 -112
  118. package/src/core/diagnostics/spec-pointer.ts +0 -99
  119. package/src/core/diagnostics/suggested-fixes.ts +0 -155
  120. package/src/core/exporter/case-study/index.ts +0 -270
  121. package/src/core/exporter/curl.ts +0 -40
  122. package/src/core/exporter/exporter.ts +0 -48
  123. package/src/core/exporter/html-report/escape.ts +0 -24
  124. package/src/core/exporter/html-report/index.ts +0 -479
  125. package/src/core/exporter/html-report/script.ts +0 -100
  126. package/src/core/exporter/html-report/styles.ts +0 -408
  127. package/src/core/generator/catalog-builder.ts +0 -179
  128. package/src/core/generator/chunker.ts +0 -78
  129. package/src/core/generator/coverage-phase.ts +0 -0
  130. package/src/core/generator/coverage-scanner.ts +0 -87
  131. package/src/core/generator/data-factory.ts +0 -759
  132. package/src/core/generator/describe.ts +0 -302
  133. package/src/core/generator/endpoint-warnings.ts +0 -52
  134. package/src/core/generator/fixtures-builder.ts +0 -332
  135. package/src/core/generator/index.ts +0 -14
  136. package/src/core/generator/openapi-reader.ts +0 -297
  137. package/src/core/generator/path-param-disambig.ts +0 -140
  138. package/src/core/generator/resources-builder.ts +0 -898
  139. package/src/core/generator/schema-utils.ts +0 -112
  140. package/src/core/generator/serializer.ts +0 -343
  141. package/src/core/generator/suite-generator.ts +0 -1301
  142. package/src/core/generator/types.ts +0 -63
  143. package/src/core/identity/identity-file.ts +0 -0
  144. package/src/core/lint/affects.ts +0 -28
  145. package/src/core/lint/config.ts +0 -96
  146. package/src/core/lint/format.ts +0 -42
  147. package/src/core/lint/index.ts +0 -94
  148. package/src/core/lint/reporter.ts +0 -128
  149. package/src/core/lint/rules/consistency.ts +0 -158
  150. package/src/core/lint/rules/heuristics.ts +0 -97
  151. package/src/core/lint/rules/strictness.ts +0 -109
  152. package/src/core/lint/types.ts +0 -96
  153. package/src/core/lint/walker.ts +0 -248
  154. package/src/core/meta/meta-store.ts +0 -11
  155. package/src/core/output/README.md +0 -73
  156. package/src/core/output/index.ts +0 -13
  157. package/src/core/output/run.ts +0 -91
  158. package/src/core/output/types.ts +0 -122
  159. package/src/core/parser/dynamic-values.ts +0 -160
  160. package/src/core/parser/env-interpolation.ts +0 -104
  161. package/src/core/parser/filter.ts +0 -97
  162. package/src/core/parser/schema.ts +0 -359
  163. package/src/core/parser/types.ts +0 -117
  164. package/src/core/parser/variables.ts +0 -0
  165. package/src/core/parser/yaml-parser.ts +0 -181
  166. package/src/core/probe/bootstrap.ts +0 -34
  167. package/src/core/probe/dry-run-envelope.ts +0 -61
  168. package/src/core/probe/mass-assignment/classify.ts +0 -175
  169. package/src/core/probe/mass-assignment/cleanup.ts +0 -52
  170. package/src/core/probe/mass-assignment/digest.ts +0 -114
  171. package/src/core/probe/mass-assignment/orchestrator.ts +0 -459
  172. package/src/core/probe/mass-assignment/regression.ts +0 -141
  173. package/src/core/probe/mass-assignment/suspects.ts +0 -92
  174. package/src/core/probe/mass-assignment/types.ts +0 -135
  175. package/src/core/probe/mass-assignment-probe-class.ts +0 -198
  176. package/src/core/probe/mass-assignment-probe.ts +0 -27
  177. package/src/core/probe/mass-assignment-template.ts +0 -240
  178. package/src/core/probe/method-probe.ts +0 -164
  179. package/src/core/probe/method-shared.ts +0 -69
  180. package/src/core/probe/negative-probe.ts +0 -691
  181. package/src/core/probe/orphan-tracker.ts +0 -188
  182. package/src/core/probe/path-discovery.ts +0 -439
  183. package/src/core/probe/probe-harness.ts +0 -119
  184. package/src/core/probe/registry.ts +0 -89
  185. package/src/core/probe/runner.ts +0 -136
  186. package/src/core/probe/security/baseline.ts +0 -174
  187. package/src/core/probe/security/classify.ts +0 -341
  188. package/src/core/probe/security/cleanup.ts +0 -125
  189. package/src/core/probe/security/detectors.ts +0 -71
  190. package/src/core/probe/security/digest.ts +0 -104
  191. package/src/core/probe/security/orchestrator.ts +0 -398
  192. package/src/core/probe/security/regression.ts +0 -103
  193. package/src/core/probe/security/types.ts +0 -151
  194. package/src/core/probe/security-probe-class.ts +0 -207
  195. package/src/core/probe/security-probe.ts +0 -32
  196. package/src/core/probe/shared.ts +0 -531
  197. package/src/core/probe/static-probe-class.ts +0 -125
  198. package/src/core/probe/types.ts +0 -165
  199. package/src/core/probe/verdict-aggregator.ts +0 -33
  200. package/src/core/probe/webhooks-probe.ts +0 -282
  201. package/src/core/reporter/console.ts +0 -240
  202. package/src/core/reporter/index.ts +0 -22
  203. package/src/core/reporter/json.ts +0 -22
  204. package/src/core/reporter/junit.ts +0 -93
  205. package/src/core/reporter/ndjson.ts +0 -37
  206. package/src/core/reporter/types.ts +0 -15
  207. package/src/core/runner/assertions.ts +0 -383
  208. package/src/core/runner/async-pool.ts +0 -108
  209. package/src/core/runner/auth-path.ts +0 -8
  210. package/src/core/runner/ci-context.ts +0 -72
  211. package/src/core/runner/executor.ts +0 -652
  212. package/src/core/runner/expr-eval.ts +0 -41
  213. package/src/core/runner/form-encode.ts +0 -41
  214. package/src/core/runner/http-client.ts +0 -224
  215. package/src/core/runner/learn-drift.ts +0 -293
  216. package/src/core/runner/preflight-vars.ts +0 -153
  217. package/src/core/runner/progress-tracker.ts +0 -73
  218. package/src/core/runner/rate-limiter.ts +0 -185
  219. package/src/core/runner/run-kind.ts +0 -45
  220. package/src/core/runner/schema-validator.ts +0 -308
  221. package/src/core/runner/send-request.ts +0 -232
  222. package/src/core/runner/transforms.ts +0 -65
  223. package/src/core/runner/types.ts +0 -94
  224. package/src/core/secrets/registry.ts +0 -164
  225. package/src/core/secrets/secrets-file.ts +0 -115
  226. package/src/core/selectors/operation-filter.ts +0 -144
  227. package/src/core/setup-api.ts +0 -590
  228. package/src/core/severity/category.ts +0 -94
  229. package/src/core/severity/index.ts +0 -58
  230. package/src/core/spec/infer-schema.ts +0 -102
  231. package/src/core/spec/layers.ts +0 -154
  232. package/src/core/spec/merge-specs.ts +0 -156
  233. package/src/core/spec/schema-from-runs.ts +0 -117
  234. package/src/core/spec/schema-overlay.ts +0 -130
  235. package/src/core/util/ajv.ts +0 -13
  236. package/src/core/util/format-eta.ts +0 -21
  237. package/src/core/util/headers.ts +0 -9
  238. package/src/core/util/url.ts +0 -24
  239. package/src/core/utils.ts +0 -13
  240. package/src/core/workspace/config.ts +0 -129
  241. package/src/core/workspace/fixture-gap-report.ts +0 -84
  242. package/src/core/workspace/fixture-gaps.ts +0 -71
  243. package/src/core/workspace/manifest.ts +0 -283
  244. package/src/core/workspace/output-rotation.ts +0 -62
  245. package/src/core/workspace/root.ts +0 -96
  246. package/src/core/workspace/triage-path.ts +0 -87
  247. package/src/db/lint-runs.ts +0 -47
  248. package/src/db/migrate.ts +0 -128
  249. package/src/db/migrations/0001_run_kind.sql +0 -25
  250. package/src/db/migrations/0002_run_kind_request.sql +0 -59
  251. package/src/db/migrations/sql.d.ts +0 -4
  252. package/src/db/queries/collections.ts +0 -133
  253. package/src/db/queries/coverage.ts +0 -9
  254. package/src/db/queries/dashboard.ts +0 -59
  255. package/src/db/queries/results.ts +0 -216
  256. package/src/db/queries/runs.ts +0 -289
  257. package/src/db/queries/sessions.ts +0 -42
  258. package/src/db/queries/settings.ts +0 -28
  259. package/src/db/queries/types.ts +0 -172
  260. package/src/db/queries.ts +0 -75
  261. package/src/db/schema.ts +0 -298
@@ -1,786 +0,0 @@
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
- }