@kirrosh/zond 0.21.0 → 0.23.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 (256) hide show
  1. package/CHANGELOG.md +758 -3
  2. package/README.md +78 -15
  3. package/package.json +17 -10
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -0
  20. package/src/cli/commands/ci-init.ts +55 -6
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +262 -0
  23. package/src/cli/commands/completions.ts +192 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +180 -8
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -47
  31. package/src/cli/commands/init/agents-md.ts +61 -0
  32. package/src/cli/commands/init/bootstrap.ts +108 -0
  33. package/src/cli/commands/init/index.ts +244 -0
  34. package/src/cli/commands/init/skills.ts +98 -0
  35. package/src/cli/commands/init/templates/agents.md +77 -0
  36. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  37. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +651 -0
  40. package/src/cli/commands/init/templates/zond-config.yml +14 -0
  41. package/src/cli/commands/prepare-fixtures.ts +135 -0
  42. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  43. package/src/cli/commands/probe/security.ts +454 -0
  44. package/src/cli/commands/probe/static.ts +255 -0
  45. package/src/cli/commands/probe/webhooks.ts +161 -0
  46. package/src/cli/commands/probe.ts +459 -0
  47. package/src/cli/commands/reference.ts +87 -0
  48. package/src/cli/commands/refresh-api.ts +169 -0
  49. package/src/cli/commands/remove-api.ts +150 -0
  50. package/src/cli/commands/report-bundle.ts +318 -0
  51. package/src/cli/commands/report.ts +241 -0
  52. package/src/cli/commands/request.ts +379 -4
  53. package/src/cli/commands/run.ts +911 -33
  54. package/src/cli/commands/session.ts +244 -0
  55. package/src/cli/commands/use.ts +74 -0
  56. package/src/cli/index.ts +36 -607
  57. package/src/cli/json-envelope.ts +112 -3
  58. package/src/cli/json-schemas.ts +263 -0
  59. package/src/cli/program.ts +218 -0
  60. package/src/cli/resolve.ts +105 -0
  61. package/src/cli/status-filter.ts +124 -0
  62. package/src/cli/util/api-context.ts +85 -0
  63. package/src/cli/version.ts +8 -0
  64. package/src/core/anti-fp/bootstrap.ts +34 -0
  65. package/src/core/anti-fp/index.ts +33 -0
  66. package/src/core/anti-fp/registry.ts +44 -0
  67. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  68. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  69. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  70. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  71. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  72. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  73. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  74. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  75. package/src/core/anti-fp/types.ts +68 -0
  76. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  77. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  78. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  79. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  80. package/src/core/checks/checks/cross_call_references.ts +134 -0
  81. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  82. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  83. package/src/core/checks/checks/ignored_auth.ts +211 -0
  84. package/src/core/checks/checks/index.ts +65 -0
  85. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  86. package/src/core/checks/checks/missing_required_header.ts +40 -0
  87. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  88. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  89. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  90. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  91. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  92. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  93. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  94. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  95. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  96. package/src/core/checks/checks/unsupported_method.ts +63 -0
  97. package/src/core/checks/checks/use_after_free.ts +78 -0
  98. package/src/core/checks/index.ts +30 -0
  99. package/src/core/checks/mode.ts +79 -0
  100. package/src/core/checks/recommended-action.ts +64 -0
  101. package/src/core/checks/registry.ts +78 -0
  102. package/src/core/checks/runner.ts +874 -0
  103. package/src/core/checks/sarif.ts +230 -0
  104. package/src/core/checks/stateful.ts +121 -0
  105. package/src/core/checks/types.ts +189 -0
  106. package/src/core/classifier/recommended-action.ts +222 -0
  107. package/src/core/context/current.ts +51 -0
  108. package/src/core/context/session.ts +78 -0
  109. package/src/core/coverage/loader.ts +185 -0
  110. package/src/core/coverage/reasons.ts +300 -0
  111. package/src/core/diagnostics/db-analysis.ts +161 -12
  112. package/src/core/diagnostics/failure-class.ts +120 -0
  113. package/src/core/diagnostics/failure-hints.ts +212 -9
  114. package/src/core/diagnostics/spec-pointer.ts +99 -0
  115. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  116. package/src/core/exporter/case-study/index.ts +270 -0
  117. package/src/core/exporter/curl.ts +40 -0
  118. package/src/core/exporter/exporter.ts +48 -0
  119. package/src/core/exporter/html-report/escape.ts +24 -0
  120. package/src/core/exporter/html-report/index.ts +479 -0
  121. package/src/core/exporter/html-report/script.ts +100 -0
  122. package/src/core/exporter/html-report/styles.ts +408 -0
  123. package/src/core/generator/chunker.ts +53 -15
  124. package/src/core/generator/coverage-phase.ts +0 -0
  125. package/src/core/generator/create-body.ts +89 -0
  126. package/src/core/generator/data-factory.ts +490 -33
  127. package/src/core/generator/describe.ts +1 -1
  128. package/src/core/generator/fixtures-builder.ts +325 -0
  129. package/src/core/generator/index.ts +7 -5
  130. package/src/core/generator/openapi-reader.ts +55 -3
  131. package/src/core/generator/path-param-disambig.ts +114 -0
  132. package/src/core/generator/resources-builder.ts +648 -0
  133. package/src/core/generator/schema-utils.ts +11 -3
  134. package/src/core/generator/serializer.ts +114 -15
  135. package/src/core/generator/suite-generator.ts +484 -77
  136. package/src/core/generator/types.ts +8 -0
  137. package/src/core/identity/identity-file.ts +129 -0
  138. package/src/core/lint/affects.ts +28 -0
  139. package/src/core/lint/config.ts +96 -0
  140. package/src/core/lint/format.ts +42 -0
  141. package/src/core/lint/index.ts +94 -0
  142. package/src/core/lint/reporter.ts +128 -0
  143. package/src/core/lint/rules/consistency.ts +158 -0
  144. package/src/core/lint/rules/heuristics.ts +97 -0
  145. package/src/core/lint/rules/strictness.ts +109 -0
  146. package/src/core/lint/types.ts +96 -0
  147. package/src/core/lint/walker.ts +248 -0
  148. package/src/core/meta/meta-store.ts +6 -73
  149. package/src/core/output/README.md +91 -0
  150. package/src/core/output/index.ts +13 -0
  151. package/src/core/output/run.ts +126 -0
  152. package/src/core/output/types.ts +129 -0
  153. package/src/core/parser/env-interpolation.ts +104 -0
  154. package/src/core/parser/filter.ts +57 -0
  155. package/src/core/parser/schema.ts +132 -5
  156. package/src/core/parser/types.ts +29 -2
  157. package/src/core/parser/variables.ts +0 -0
  158. package/src/core/parser/yaml-parser.ts +108 -13
  159. package/src/core/probe/bootstrap.ts +34 -0
  160. package/src/core/probe/dry-run-envelope.ts +57 -0
  161. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  162. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  163. package/src/core/probe/mass-assignment-template.ts +212 -0
  164. package/src/core/probe/method-probe.ts +164 -0
  165. package/src/core/probe/method-shared.ts +69 -0
  166. package/src/core/probe/negative-probe.ts +691 -0
  167. package/src/core/probe/orphan-tracker.ts +188 -0
  168. package/src/core/probe/path-discovery.ts +440 -0
  169. package/src/core/probe/probe-harness.ts +120 -0
  170. package/src/core/probe/registry.ts +89 -0
  171. package/src/core/probe/runner.ts +136 -0
  172. package/src/core/probe/security-probe-class.ts +201 -0
  173. package/src/core/probe/security-probe.ts +1453 -0
  174. package/src/core/probe/shared.ts +505 -0
  175. package/src/core/probe/static-probe-class.ts +125 -0
  176. package/src/core/probe/types.ts +165 -0
  177. package/src/core/probe/verdict-aggregator.ts +33 -0
  178. package/src/core/probe/webhooks-probe.ts +284 -0
  179. package/src/core/reporter/console.ts +69 -4
  180. package/src/core/reporter/index.ts +2 -3
  181. package/src/core/reporter/json.ts +15 -2
  182. package/src/core/reporter/junit.ts +27 -12
  183. package/src/core/reporter/ndjson.ts +37 -0
  184. package/src/core/reporter/types.ts +3 -0
  185. package/src/core/runner/assertions.ts +62 -2
  186. package/src/core/runner/async-pool.ts +108 -0
  187. package/src/core/runner/auth-path.ts +8 -0
  188. package/src/core/runner/ci-context.ts +72 -0
  189. package/src/core/runner/executor.ts +391 -52
  190. package/src/core/runner/form-encode.ts +51 -0
  191. package/src/core/runner/http-client.ts +115 -7
  192. package/src/core/runner/learn-drift.ts +293 -0
  193. package/src/core/runner/preflight-vars.ts +149 -0
  194. package/src/core/runner/progress-tracker.ts +73 -0
  195. package/src/core/runner/rate-limiter.ts +203 -0
  196. package/src/core/runner/run-kind.ts +39 -0
  197. package/src/core/runner/schema-validator.ts +312 -0
  198. package/src/core/runner/send-request.ts +153 -20
  199. package/src/core/runner/types.ts +38 -0
  200. package/src/core/secrets/registry.ts +164 -0
  201. package/src/core/secrets/secrets-file.ts +115 -0
  202. package/src/core/selectors/operation-filter.ts +144 -0
  203. package/src/core/setup-api.ts +419 -17
  204. package/src/core/severity/category.ts +94 -0
  205. package/src/core/severity/index.ts +121 -0
  206. package/src/core/spec/layers.ts +154 -0
  207. package/src/core/util/format-eta.ts +21 -0
  208. package/src/core/utils.ts +5 -1
  209. package/src/core/workspace/config.ts +129 -0
  210. package/src/core/workspace/manifest.ts +283 -0
  211. package/src/core/workspace/output-rotation.ts +62 -0
  212. package/src/core/workspace/root.ts +94 -0
  213. package/src/core/workspace/triage-path.ts +87 -0
  214. package/src/db/lint-runs.ts +47 -0
  215. package/src/db/migrate.ts +126 -0
  216. package/src/db/migrations/0001_run_kind.sql +25 -0
  217. package/src/db/migrations/sql.d.ts +4 -0
  218. package/src/db/queries/collections.ts +133 -0
  219. package/src/db/queries/coverage.ts +9 -0
  220. package/src/db/queries/dashboard.ts +59 -0
  221. package/src/db/queries/results.ts +128 -0
  222. package/src/db/queries/runs.ts +235 -0
  223. package/src/db/queries/sessions.ts +42 -0
  224. package/src/db/queries/settings.ts +28 -0
  225. package/src/db/queries/types.ts +172 -0
  226. package/src/db/queries.ts +72 -802
  227. package/src/db/schema.ts +179 -48
  228. package/src/cli/commands/export.ts +0 -144
  229. package/src/cli/commands/guide.ts +0 -127
  230. package/src/cli/commands/init.ts +0 -57
  231. package/src/cli/commands/serve.ts +0 -81
  232. package/src/cli/commands/sync.ts +0 -269
  233. package/src/cli/commands/update.ts +0 -189
  234. package/src/cli/commands/validate.ts +0 -34
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -21
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Probe contract (m-17 / ARV-49).
3
+ *
4
+ * `zond probe <class>` originally grew as three independent commands —
5
+ * static, mass-assignment, security — that ad-hoc-агree on flags and
6
+ * output shape. The agent-readable contract started drifting (security
7
+ * has --dry-run, mass-assignment doesn't; security --json packages
8
+ * markdown into `data.digest.stdout`, run --report json returns
9
+ * structured per-endpoint findings; ARV-9 AC#6 deferred --include/--exclude
10
+ * for the probe family). m-17 raises this from "convention" to
11
+ * "TS-interface validated at boot".
12
+ *
13
+ * `Probe` is the contract every registered probe class MUST satisfy.
14
+ * `commonFlags` is a declarative slot table — the harness uses it both
15
+ * for boot-validation (registry refuses to start if a slot is missing)
16
+ * and for help/feature-detection. dry-run and run return DIFFERENT
17
+ * shapes on purpose (ARV-50): dry-run answers "what would I attack",
18
+ * run answers "what did I find". Severity is undefined in dry-run.
19
+ */
20
+ import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
21
+
22
+ /**
23
+ * Common-flag manifest. Boot-validator checks each registered probe
24
+ * declares every slot — `true` means "this probe's CLI exposes the
25
+ * flag", `false` means "intentionally not supported". We don't allow
26
+ * undefined: forcing a boolean makes "we forgot to wire it" obvious in
27
+ * code review (ARV-9 AC#6, F2-15).
28
+ */
29
+ export interface ProbeFlags {
30
+ /** `--api <name>` */
31
+ api: boolean;
32
+ /** `--tag <tag>` */
33
+ tag: boolean;
34
+ /** Repeatable `--include <selector:value>` (m-15 ARV-9 grammar). */
35
+ include: boolean;
36
+ /** Repeatable `--exclude <selector:value>`. */
37
+ exclude: boolean;
38
+ /** `--dry-run` — list planned attacks without sending requests. */
39
+ dryRun: boolean;
40
+ /** `--list-tags` — print spec tags and exit. */
41
+ listTags: boolean;
42
+ /** `--json` — emit single JSON envelope on stdout. */
43
+ json: boolean;
44
+ /** `--output <file>` — write markdown / SARIF digest to file. */
45
+ output: boolean;
46
+ /** `--report <markdown|json|sarif>` — choose the structured report
47
+ * format (m-17 ARV-51). */
48
+ report: boolean;
49
+ }
50
+
51
+ /**
52
+ * Per-endpoint dry-run record. Returned from `Probe.dryRun()`. Severity
53
+ * is intentionally absent: nothing has been classified yet (ARV-50).
54
+ *
55
+ * `planned: true` means the probe would send live traffic at this
56
+ * endpoint; `planned: false` + `skip_reason` means we identified the
57
+ * endpoint but won't probe it (no body, isolated path-param, …).
58
+ */
59
+ export interface EndpointPlan {
60
+ path: string;
61
+ method: string;
62
+ planned: boolean;
63
+ /** Probe-class IDs we'd run (e.g. ["ssrf","crlf"] or ["mass-assignment"]). */
64
+ classes_planned: string[];
65
+ /** Suspect fields the probe would touch (mass-assignment / security only). */
66
+ fields_planned: string[];
67
+ /** Null when planned, populated when planned:false. Closed string set
68
+ * per-probe (security: 'no-body'|'no-matched-field'|'isolated-protected'|
69
+ * 'unresolved-path'; mass-assignment: 'no-body'|'isolated-protected'). */
70
+ skip_reason: string | null;
71
+ }
72
+
73
+ /**
74
+ * Severity classifier outcome for a finding. `inconclusive` covers both
75
+ * baseline-failure and 5xx-on-attack — sub-classes carry the detail in
76
+ * `evidence`. Mirrors the existing union in security-probe.ts so we
77
+ * don't double-up on enums.
78
+ */
79
+ export type ProbeFindingSeverity =
80
+ | "high"
81
+ | "low"
82
+ | "inconclusive"
83
+ | "ok";
84
+
85
+ export interface ProbeFinding {
86
+ /** Probe-class id (e.g. "ssrf", "open-redirect", "mass-assignment"). */
87
+ class: string;
88
+ severity: ProbeFindingSeverity;
89
+ /** Free-form evidence: request signature, response signature, baseline
90
+ * diff, etc. Schema is per-probe, but stays structured (no markdown). */
91
+ evidence: Record<string, unknown>;
92
+ }
93
+
94
+ export type ProbeEndpointStatus = "ok" | "high" | "low" | "inconclusive" | "skipped";
95
+
96
+ export interface ProbeEndpointResult {
97
+ path: string;
98
+ method: string;
99
+ /** Probe classes that actually ran on this endpoint. */
100
+ classes_run: string[];
101
+ findings: ProbeFinding[];
102
+ status: ProbeEndpointStatus;
103
+ skip_reason?: string;
104
+ }
105
+
106
+ export interface ProbeRunSummary {
107
+ totalEndpoints: number;
108
+ probed: number;
109
+ /** Per-status tally; identical to existing severity buckets, but with
110
+ * closed shape (ARV-51). */
111
+ by_status: Record<ProbeEndpointStatus, number>;
112
+ }
113
+
114
+ /**
115
+ * Result of a live `Probe.run()`. `endpoints[]` is the agent-routable
116
+ * structure; markdown digest is rendered separately by `Probe.report()`.
117
+ */
118
+ export interface ProbeResult {
119
+ endpoints: ProbeEndpointResult[];
120
+ summary: ProbeRunSummary;
121
+ warnings: string[];
122
+ /** Optional probe-specific extras (e.g. orphans, emittedTests) that
123
+ * don't fit the per-endpoint shape but agents still need access to. */
124
+ extras?: Record<string, unknown>;
125
+ }
126
+
127
+ export type ProbeReportFormat = "markdown" | "json";
128
+
129
+ export interface ProbeContext {
130
+ specPath: string;
131
+ /** Pre-loaded endpoints (probe harness loads spec once and shares). */
132
+ endpoints: EndpointInfo[];
133
+ securitySchemes: SecuritySchemeInfo[];
134
+ /** Resolved env vars (`base_url`, `auth_token`, fixture vars). Empty
135
+ * for dry-run when the env file is absent. */
136
+ vars: Record<string, string>;
137
+ /** Selector strings (m-15 ARV-9 grammar: `path:`, `method:`, `tag:`,
138
+ * `operation-id:`). Pre-applied to `endpoints` before this context
139
+ * reaches the probe. Carried for diagnostics. */
140
+ filter?: { includes: string[]; excludes: string[] };
141
+ /** Probe-class subset (e.g. for security: ["ssrf","crlf"]). */
142
+ classes?: string[];
143
+ /** Probe-specific options bag — kept opaque so the harness doesn't
144
+ * need to know each probe's flag inventory. */
145
+ options: Record<string, unknown>;
146
+ }
147
+
148
+ /**
149
+ * The contract. Every registered probe MUST implement all four
150
+ * required methods; missing one trips boot-validation in
151
+ * `registry.ts`. listTags is optional — most probes share the same
152
+ * loadSpecForProbe shortcut via the harness, so we don't force it.
153
+ */
154
+ export interface Probe {
155
+ readonly name: string;
156
+ readonly description: string;
157
+ readonly commonFlags: ProbeFlags;
158
+ /** List endpoints + classes the probe would attack (no live traffic). */
159
+ dryRun(ctx: ProbeContext): Promise<EndpointPlan[]>;
160
+ /** Run the probe live and return structured per-endpoint findings. */
161
+ run(ctx: ProbeContext): Promise<ProbeResult>;
162
+ /** Render a structured (json) or human (markdown) digest. */
163
+ report(format: ProbeReportFormat, result: ProbeResult): string | object;
164
+ }
165
+
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Tally verdicts by severity into a caller-shaped bucket object and
3
+ * format a one-line summary. Extracted from probe-mass-assignment and
4
+ * probe-security CLI commands which had near-identical countBuckets +
5
+ * Summary printers — they differ only in the severity vocabulary
6
+ * ("medium" vs "inconclusive") and the label/order of the summary line.
7
+ *
8
+ * Generic over the bucket shape so each command keeps its own JSON-
9
+ * envelope keys (camelCase) without rewriting the rest of the pipeline.
10
+ */
11
+
12
+ export function tallyBySeverity<T extends object>(
13
+ verdicts: ReadonlyArray<{ severity: string }>,
14
+ mapping: ReadonlyArray<readonly [severity: string, bucket: keyof T & string]>,
15
+ zero: T,
16
+ ): T {
17
+ const out: Record<string, number> = { ...(zero as Record<string, number>) };
18
+ const lookup = new Map<string, string>(mapping);
19
+ for (const v of verdicts) {
20
+ const bucket = lookup.get(v.severity);
21
+ if (bucket !== undefined && bucket in out) out[bucket]! += 1;
22
+ }
23
+ return out as T;
24
+ }
25
+
26
+ export function formatSummaryLine<T extends object>(
27
+ counts: T,
28
+ pairs: ReadonlyArray<readonly [label: string, bucket: keyof T & string]>,
29
+ ): string {
30
+ const indexed = counts as Record<string, number>;
31
+ const parts = pairs.map(([label, key]) => `${label} ${indexed[key] ?? 0}`);
32
+ return `Summary: ${parts.join(" · ")}`;
33
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * `zond probe webhooks` (m-20 ARV-173) — webhook shape-conformance.
3
+ *
4
+ * The probe is **offline**: it reads an ndjson event log captured by
5
+ * the recipe (`docs/recipes/webhook-receiver.md`, e.g. via
6
+ * `stripe listen --print-json`) and validates each event payload
7
+ * against the schema declared in `spec.webhooks.<event>.post.requestBody`.
8
+ *
9
+ * Why offline? m-20 explicitly puts live HTTP infrastructure (tunnels,
10
+ * port binding, receiver servers) in recipes, not core zond:
11
+ *
12
+ * • A live receiver requires a public URL — that's a recipe concern
13
+ * (Stripe CLI, ngrok, smee.io are all out-of-band).
14
+ * • Capture is bursty (events trickle in seconds-to-minutes after a
15
+ * trigger); the probe can't reliably wait for them inside a CLI
16
+ * invocation without nasty timeout knobs.
17
+ * • Decoupling capture from verification lets the same probe run
18
+ * against logs from prod tap, mitm-proxy dumps, CI artifacts, etc.
19
+ *
20
+ * Recipe captures; probe verifies. Same pattern as quicktype (capture
21
+ * → schema infer) and interactsh (capture → OOB-detect) in m-18.
22
+ *
23
+ * Event log format: ndjson, one event per line. Two recognised shapes:
24
+ *
25
+ * • Stripe-style — `{type, data: {object: {...}}}` (the payload is
26
+ * `data.object`; everything else is envelope metadata).
27
+ * • Generic — `{type|event, body|payload, ...}` (the payload is
28
+ * whichever of `body`/`payload` is an object; falls back to the
29
+ * event itself when neither is present).
30
+ *
31
+ * Severity policy: HIGH on shape drift (server announced an event
32
+ * shape via `webhooks:` and is now sending something else). Unknown
33
+ * event types and missing payloads surface at LOW — they're noise
34
+ * categories, not contract bugs the API owner promised against.
35
+ */
36
+ import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
37
+ import Ajv2020 from "ajv/dist/2020.js";
38
+ import Ajv from "ajv";
39
+ import addFormats from "ajv-formats";
40
+ import type { ValidateFunction, ErrorObject } from "ajv";
41
+
42
+ interface SingleSchemaValidator {
43
+ validate(value: unknown): boolean;
44
+ errors: ErrorObject[] | null;
45
+ }
46
+
47
+ /** Compile a single JSON-Schema-shaped object into a callable validator.
48
+ * 3.1-flavour by default (`webhooks:` is OpenAPI 3.1) but tolerates
49
+ * pre-3.1 specs using `x-webhooks` — they ship Draft-7-ish schemas. */
50
+ function compileSingleSchema(schema: OpenAPIV3.SchemaObject, isV31: boolean): SingleSchemaValidator {
51
+ const ajv = isV31
52
+ ? new (Ajv2020 as unknown as typeof Ajv)({ strict: false, allErrors: true })
53
+ : new Ajv({ strict: false, allErrors: true });
54
+ addFormats(ajv);
55
+ const validate: ValidateFunction = ajv.compile(schema);
56
+ return {
57
+ validate(value) { return validate(value) as boolean; },
58
+ get errors() { return validate.errors ?? null; },
59
+ };
60
+ }
61
+
62
+ export type WebhookFindingKind =
63
+ | "shape_drift"
64
+ | "unknown_event_type"
65
+ | "missing_payload"
66
+ | "malformed_event";
67
+
68
+ export interface WebhookFinding {
69
+ /** Line number in the event log (1-indexed) for traceability. */
70
+ line: number;
71
+ kind: WebhookFindingKind;
72
+ severity: "high" | "low";
73
+ event_type: string | null;
74
+ message: string;
75
+ evidence: Record<string, unknown>;
76
+ }
77
+
78
+ export interface WebhookProbeResult {
79
+ total_events: number;
80
+ by_type: Record<string, { ok: number; drift: number; unknown: number }>;
81
+ declared_events: string[];
82
+ findings: WebhookFinding[];
83
+ /** Reason the probe short-circuited without inspecting any event,
84
+ * e.g. spec has no webhooks block. Empty string when normal. */
85
+ skip_reason: string;
86
+ }
87
+
88
+ /** Extract the webhooks block. Tries OpenAPI 3.1 `webhooks:` first
89
+ * (the canonical location), falls back to `x-webhooks` for specs
90
+ * shipped before OpenAPI 3.1 had ratified the field. Returns an
91
+ * empty object when neither exists. */
92
+ export function readWebhooksMap(spec: unknown): Record<string, OpenAPIV3.PathItemObject> {
93
+ if (!spec || typeof spec !== "object") return {};
94
+ const s = spec as Record<string, unknown>;
95
+ const candidate = (s.webhooks ?? s["x-webhooks"]) as Record<string, OpenAPIV3.PathItemObject> | undefined;
96
+ if (!candidate || typeof candidate !== "object") return {};
97
+ return candidate;
98
+ }
99
+
100
+ /** Pull the request-body schema for the POST operation under a
101
+ * webhook entry. Returns null when the entry doesn't declare a POST,
102
+ * or when the POST doesn't carry a JSON requestBody schema. */
103
+ function schemaForEvent(item: OpenAPIV3.PathItemObject | OpenAPIV3_1.PathItemObject): OpenAPIV3.SchemaObject | null {
104
+ const post = item.post;
105
+ if (!post) return null;
106
+ const rb = post.requestBody;
107
+ if (!rb || (rb as OpenAPIV3.ReferenceObject).$ref) return null;
108
+ const content = (rb as OpenAPIV3.RequestBodyObject).content ?? {};
109
+ const json = content["application/json"];
110
+ if (!json?.schema) return null;
111
+ return json.schema as OpenAPIV3.SchemaObject;
112
+ }
113
+
114
+ /** Extract `type` from an event in a tolerant way: try `type` first
115
+ * (Stripe / GitHub style), then `event` (legacy / SaaS-style), then
116
+ * give up. Numbers are coerced to strings so an integer-valued
117
+ * `type` field doesn't masquerade as null. */
118
+ function readEventType(event: Record<string, unknown>): string | null {
119
+ for (const k of ["type", "event", "event_type"]) {
120
+ const v = event[k];
121
+ if (typeof v === "string" && v.length > 0) return v;
122
+ if (typeof v === "number") return String(v);
123
+ }
124
+ return null;
125
+ }
126
+
127
+ /** Extract the payload from an event. Recognised envelopes (in
128
+ * priority order): `data.object` (Stripe), `body`, `payload`. Returns
129
+ * null when none of those carry an object — missing_payload then
130
+ * surfaces as a LOW finding so the operator can fix the capture
131
+ * step (and the probe doesn't validate envelope metadata as payload). */
132
+ function readEventPayload(event: Record<string, unknown>): unknown {
133
+ if (event.data && typeof event.data === "object" && !Array.isArray(event.data)) {
134
+ const inner = (event.data as Record<string, unknown>).object;
135
+ if (inner && typeof inner === "object") return inner;
136
+ }
137
+ for (const k of ["body", "payload"]) {
138
+ const v = event[k];
139
+ if (v && typeof v === "object") return v;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ export interface RunOptions {
145
+ /** Pre-parsed events. One Record per line; non-object lines should
146
+ * surface as malformed_event findings before reaching this layer. */
147
+ events: Array<{ line: number; event: Record<string, unknown> }>;
148
+ spec: unknown;
149
+ /** Optional restriction — only validate events whose `type` is in
150
+ * this list. Empty/undefined ⇒ validate everything declared. */
151
+ onlyTypes?: string[];
152
+ }
153
+
154
+ export function runWebhooksProbe(opts: RunOptions): WebhookProbeResult {
155
+ const webhooksMap = readWebhooksMap(opts.spec);
156
+ const declared = Object.keys(webhooksMap).sort();
157
+
158
+ const out: WebhookProbeResult = {
159
+ total_events: opts.events.length,
160
+ by_type: {},
161
+ declared_events: declared,
162
+ findings: [],
163
+ skip_reason: "",
164
+ };
165
+
166
+ if (declared.length === 0) {
167
+ out.skip_reason = "spec declares no `webhooks:` (or `x-webhooks`) entries — nothing to validate against";
168
+ return out;
169
+ }
170
+
171
+ const isV31 = typeof (opts.spec as { openapi?: string })?.openapi === "string"
172
+ && (opts.spec as { openapi: string }).openapi.startsWith("3.1");
173
+ // Compile each schema once; events sharing a type reuse the validator.
174
+ const validators = new Map<string, SingleSchemaValidator | null>();
175
+ for (const [name, item] of Object.entries(webhooksMap)) {
176
+ const schema = schemaForEvent(item);
177
+ if (!schema) { validators.set(name, null); continue; }
178
+ try {
179
+ validators.set(name, compileSingleSchema(schema, isV31));
180
+ } catch {
181
+ validators.set(name, null);
182
+ }
183
+ }
184
+
185
+ const onlyTypes = opts.onlyTypes && opts.onlyTypes.length > 0 ? new Set(opts.onlyTypes) : null;
186
+
187
+ for (const { line, event } of opts.events) {
188
+ const type = readEventType(event);
189
+ if (!type) {
190
+ out.findings.push({
191
+ line, kind: "malformed_event", severity: "low",
192
+ event_type: null,
193
+ message: `event has no recognisable type field (tried "type", "event", "event_type")`,
194
+ evidence: { event_keys: Object.keys(event) },
195
+ });
196
+ continue;
197
+ }
198
+ if (onlyTypes && !onlyTypes.has(type)) continue;
199
+ const bucket = out.by_type[type] ?? (out.by_type[type] = { ok: 0, drift: 0, unknown: 0 });
200
+ const validator = validators.get(type);
201
+ if (validator === undefined) {
202
+ bucket.unknown += 1;
203
+ out.findings.push({
204
+ line, kind: "unknown_event_type", severity: "low",
205
+ event_type: type,
206
+ message: `event type "${type}" is not declared in spec.webhooks (${declared.length} declared)`,
207
+ evidence: { declared_sample: declared.slice(0, 5) },
208
+ });
209
+ continue;
210
+ }
211
+ if (validator === null) {
212
+ // Declared but no schema to validate against → silent pass for
213
+ // that event type; surfacing every such case would be noise.
214
+ bucket.ok += 1;
215
+ continue;
216
+ }
217
+ const payload = readEventPayload(event);
218
+ if (payload == null || typeof payload !== "object" || Array.isArray(payload)) {
219
+ out.findings.push({
220
+ line, kind: "missing_payload", severity: "low",
221
+ event_type: type,
222
+ message: `event "${type}" carries no object payload (data.object / body / payload)`,
223
+ evidence: { event_keys: Object.keys(event) },
224
+ });
225
+ continue;
226
+ }
227
+ const valid = validator.validate(payload);
228
+ if (valid) { bucket.ok += 1; continue; }
229
+ bucket.drift += 1;
230
+ const errs = validator.errors ?? [];
231
+ out.findings.push({
232
+ line, kind: "shape_drift", severity: "high",
233
+ event_type: type,
234
+ message: `event "${type}" does not conform to declared schema (${errs.length} error(s))`,
235
+ evidence: {
236
+ errors: errs.slice(0, 5).map((e) => ({
237
+ path: e.instancePath ?? "",
238
+ keyword: e.keyword ?? "",
239
+ message: e.message ?? "",
240
+ params: e.params ?? {},
241
+ })),
242
+ },
243
+ });
244
+ }
245
+ return out;
246
+ }
247
+
248
+ /** Parse an ndjson event log. Each line that is non-empty and parses
249
+ * to an object yields one `{line, event}`. Bad lines surface as
250
+ * malformed_event findings in the result so the operator gets
251
+ * pinpointed feedback. */
252
+ export function parseEventLog(text: string): {
253
+ events: Array<{ line: number; event: Record<string, unknown> }>;
254
+ malformed: WebhookFinding[];
255
+ } {
256
+ const events: Array<{ line: number; event: Record<string, unknown> }> = [];
257
+ const malformed: WebhookFinding[] = [];
258
+ const lines = text.split(/\r?\n/);
259
+ for (let i = 0; i < lines.length; i++) {
260
+ const raw = lines[i]!.trim();
261
+ if (raw.length === 0) continue;
262
+ try {
263
+ const parsed = JSON.parse(raw);
264
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
265
+ events.push({ line: i + 1, event: parsed as Record<string, unknown> });
266
+ } else {
267
+ malformed.push({
268
+ line: i + 1, kind: "malformed_event", severity: "low",
269
+ event_type: null,
270
+ message: `event line is not a JSON object`,
271
+ evidence: { sample: raw.slice(0, 60) },
272
+ });
273
+ }
274
+ } catch (e) {
275
+ malformed.push({
276
+ line: i + 1, kind: "malformed_event", severity: "low",
277
+ event_type: null,
278
+ message: `ndjson parse failed: ${(e as Error).message}`,
279
+ evidence: { sample: raw.slice(0, 60) },
280
+ });
281
+ }
282
+ }
283
+ return { events, malformed };
284
+ }
@@ -8,11 +8,25 @@ const DIM = "\x1b[2m";
8
8
  const GREEN = "\x1b[32m";
9
9
  const RED = "\x1b[31m";
10
10
  const GRAY = "\x1b[90m";
11
+ const YELLOW = "\x1b[33m";
11
12
 
12
13
  const PASS_ICON = "\u2713"; // ✓
13
14
  const FAIL_ICON = "\u2717"; // ✗
14
15
  const SKIP_ICON = "\u25CB"; // ○
15
16
 
17
+ export function is5xx(step: StepResult): boolean {
18
+ const status = step.response?.status;
19
+ return typeof status === "number" && status >= 500 && status < 600;
20
+ }
21
+
22
+ export function count5xx(steps: StepResult[]): number {
23
+ let n = 0;
24
+ for (const s of steps) {
25
+ if ((s.status === "fail" || s.status === "error") && is5xx(s)) n++;
26
+ }
27
+ return n;
28
+ }
29
+
16
30
  export function formatDuration(ms: number): string {
17
31
  if (ms < 1000) return `${Math.round(ms)}ms`;
18
32
  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
@@ -33,11 +47,13 @@ export function formatStep(step: StepResult, color: boolean): string {
33
47
  case "fail": {
34
48
  const icon = color ? `${RED}${FAIL_ICON}${RESET}` : FAIL_ICON;
35
49
  const dim = color ? `${DIM}(${duration})${RESET}` : `(${duration})`;
36
- return ` ${icon} ${step.name} ${dim}`;
50
+ const tag = is5xx(step) ? (color ? ` ${BOLD}${YELLOW}[5xx ${step.response?.status}]${RESET}` : ` [5xx ${step.response?.status}]`) : "";
51
+ return ` ${icon} ${step.name}${tag} ${dim}`;
37
52
  }
38
53
  case "skip": {
39
54
  const icon = color ? `${GRAY}${SKIP_ICON}${RESET}` : SKIP_ICON;
40
- const label = color ? `${GRAY}(skipped)${RESET}` : "(skipped)";
55
+ const reason = step.error ? `skipped: ${step.error}` : "skipped";
56
+ const label = color ? `${GRAY}(${reason})${RESET}` : `(${reason})`;
41
57
  return ` ${icon} ${step.name} ${label}`;
42
58
  }
43
59
  case "error": {
@@ -59,16 +75,39 @@ export function formatFailures(step: StepResult, color: boolean): string {
59
75
 
60
76
  const failed = step.assertions.filter((a) => !a.passed);
61
77
  for (const a of failed) {
62
- const msg = `${a.field}: expected ${a.rule} but got ${formatValue(a.actual)}`;
78
+ const msg = formatAssertion(a);
63
79
  lines.push(color ? ` ${RED}${msg}${RESET}` : ` ${msg}`);
64
80
  }
65
81
  return lines.join("\n");
66
82
  }
67
83
 
84
+ function formatAssertion(a: { field: string; rule: string; actual: unknown; expected: unknown; kind?: string }): string {
85
+ // Schema assertions already carry a humanised `expected` string ("missing
86
+ // required field …", "type integer", …). Use it directly — interpolating
87
+ // the actual subtree via String() turns into "[object Object]" and buries
88
+ // the actionable detail (TASK-277).
89
+ //
90
+ // ARV-27: when `actual` is a primitive (string/number/bool/null), append it
91
+ // to the message so format/type/enum/const failures show the offending value
92
+ // — same shape as runtime asserts ("expected equals 200 but got 422").
93
+ // Skipped for objects/arrays to avoid burying the message under JSON dumps
94
+ // (the original TASK-277 concern).
95
+ if (a.kind === "schema" && typeof a.expected === "string") {
96
+ if (isPrimitive(a.actual)) return `${a.field}: ${a.expected} (got ${formatValue(a.actual)})`;
97
+ return `${a.field}: ${a.expected}`;
98
+ }
99
+ return `${a.field}: expected ${a.rule} but got ${formatValue(a.actual)}`;
100
+ }
101
+
102
+ function isPrimitive(v: unknown): boolean {
103
+ return v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean";
104
+ }
105
+
68
106
  function formatValue(value: unknown): string {
69
107
  if (value === undefined) return "undefined";
70
108
  if (value === null) return "null";
71
109
  if (typeof value === "string") return `"${value}"`;
110
+ if (typeof value === "object") return JSON.stringify(value);
72
111
  return String(value);
73
112
  }
74
113
 
@@ -105,6 +144,15 @@ export function formatSuiteResult(result: TestRunResult, color: boolean): string
105
144
  if (result.failed > 0) {
106
145
  parts.push(color ? `${RED}${result.failed} failed${RESET}` : `${result.failed} failed`);
107
146
  }
147
+ const fiveXx = count5xx(result.steps);
148
+ if (fiveXx > 0) {
149
+ const label = `${fiveXx} 5xx`;
150
+ parts.push(color ? `${BOLD}${YELLOW}${label}${RESET}` : label);
151
+ }
152
+ const errored = result.steps.filter(s => s.status === "error").length;
153
+ if (errored > 0) {
154
+ parts.push(color ? `${RED}${errored} errored${RESET}` : `${errored} errored`);
155
+ }
108
156
  if (result.skipped > 0) {
109
157
  parts.push(color ? `${GRAY}${result.skipped} skipped${RESET}` : `${result.skipped} skipped`);
110
158
  }
@@ -119,7 +167,7 @@ export function formatSuiteResult(result: TestRunResult, color: boolean): string
119
167
  }
120
168
 
121
169
  export function formatGrandTotal(results: TestRunResult[], color: boolean): string {
122
- const totals = { passed: 0, failed: 0, skipped: 0, total: 0 };
170
+ const totals = { passed: 0, failed: 0, skipped: 0, errored: 0, total: 0, fiveXx: 0 };
123
171
  let minStart = Infinity;
124
172
  let maxEnd = -Infinity;
125
173
 
@@ -127,7 +175,9 @@ export function formatGrandTotal(results: TestRunResult[], color: boolean): stri
127
175
  totals.passed += r.passed;
128
176
  totals.failed += r.failed;
129
177
  totals.skipped += r.skipped;
178
+ totals.errored += r.steps.filter(s => s.status === "error").length;
130
179
  totals.total += r.total;
180
+ totals.fiveXx += count5xx(r.steps);
131
181
  const start = Date.parse(r.started_at);
132
182
  const end = Date.parse(r.finished_at);
133
183
  if (start < minStart) minStart = start;
@@ -144,6 +194,13 @@ export function formatGrandTotal(results: TestRunResult[], color: boolean): stri
144
194
  if (totals.failed > 0) {
145
195
  parts.push(color ? `${RED}${totals.failed} failed${RESET}` : `${totals.failed} failed`);
146
196
  }
197
+ if (totals.fiveXx > 0) {
198
+ const label = `${totals.fiveXx} 5xx`;
199
+ parts.push(color ? `${BOLD}${YELLOW}${label}${RESET}` : label);
200
+ }
201
+ if (totals.errored > 0) {
202
+ parts.push(color ? `${RED}${totals.errored} errored${RESET}` : `${totals.errored} errored`);
203
+ }
147
204
  if (totals.skipped > 0) {
148
205
  parts.push(color ? `${GRAY}${totals.skipped} skipped${RESET}` : `${totals.skipped} skipped`);
149
206
  }
@@ -161,6 +218,14 @@ export const consoleReporter: Reporter = {
161
218
  return;
162
219
  }
163
220
 
221
+ // TASK-265: --quiet collapses output to one summary line. Exit code
222
+ // still differentiates pass/fail; this is for CI logs and `run --watch`
223
+ // where per-test detail is noise between iterations.
224
+ if (options?.quiet) {
225
+ console.log(formatGrandTotal(results, color));
226
+ return;
227
+ }
228
+
164
229
  const blocks: string[] = [];
165
230
  for (const result of results) {
166
231
  blocks.push(formatSuiteResult(result, color));
@@ -1,7 +1,6 @@
1
1
  export type { Reporter, ReporterOptions, ReporterName } from "./types.ts";
2
- export { consoleReporter, formatDuration, formatStep, formatFailures, formatSuiteResult, formatGrandTotal } from "./console.ts";
3
- export { jsonReporter } from "./json.ts";
4
- export { junitReporter } from "./junit.ts";
2
+ export { generateJsonReport } from "./json.ts";
3
+ export { generateJunitXml } from "./junit.ts";
5
4
 
6
5
  import type { Reporter, ReporterName } from "./types.ts";
7
6
  import { consoleReporter } from "./console.ts";
@@ -1,9 +1,22 @@
1
1
  import type { TestRunResult } from "../runner/types.ts";
2
2
  import type { Reporter, ReporterOptions } from "./types.ts";
3
+ import { type Exporter, runExporter } from "../exporter/exporter.ts";
4
+
5
+ const jsonExporter: Exporter<TestRunResult[]> = {
6
+ name: "json",
7
+ mime: "application/json",
8
+ render(results: TestRunResult[]): string {
9
+ return JSON.stringify(results, null, 2);
10
+ },
11
+ };
12
+
13
+ /** TASK-186: pure render → sanitizer pipeline; redaction lives in runExporter. */
14
+ export function generateJsonReport(results: TestRunResult[]): string {
15
+ return runExporter(jsonExporter, results);
16
+ }
3
17
 
4
18
  export const jsonReporter: Reporter = {
5
19
  report(results: TestRunResult[], _options?: ReporterOptions): void {
6
- const json = JSON.stringify(results, null, 2);
7
- console.log(json);
20
+ console.log(generateJsonReport(results));
8
21
  },
9
22
  };