@kirrosh/zond 0.23.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +53 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. package/src/core/generator/create-body.ts +0 -89
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `zond secrets set <key> <value>` — write a raw secret into
3
+ * `apis/<name>/.secrets.yaml` (ARV-377). Before this, rotating an expired
4
+ * token meant hand-editing the gitignored file (or a python script), since
5
+ * `zond fixtures add` only writes `.env.yaml` and the iron rules forbid an
6
+ * agent reading `.secrets.yaml` directly.
7
+ *
8
+ * Two safety properties:
9
+ * • the value is NEVER echoed back (stdout, stderr, or JSON envelope) —
10
+ * only the key name and whether a Bearer prefix was stripped;
11
+ * • a `.secrets.yaml.bak` backup is written before the file is touched,
12
+ * mirroring `fixtures add --apply`.
13
+ *
14
+ * Bearer trap (ARV-367/AC3): `.secrets.yaml` holds the RAW token — zond's
15
+ * runner adds the scheme prefix itself (`Authorization: Bearer <token>`).
16
+ * A pasted `Bearer eyJ…` is therefore always a mistake, so we strip a
17
+ * leading `Bearer ` (case-insensitive) and warn once. `--literal` keeps the
18
+ * value verbatim for the rare case someone really means to store it.
19
+ */
20
+ import type { Command } from "commander";
21
+ import { join } from "node:path";
22
+ import { copyFile } from "node:fs/promises";
23
+
24
+ import { getApi, MISSING_API_MESSAGE } from "../util/api-context.ts";
25
+ import { resolveApiCollection } from "../resolve.ts";
26
+ import { upsertEnvLine } from "./discover.ts";
27
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
28
+ import { printError, printSuccess } from "../output.ts";
29
+ import { globalJson } from "../resolve.ts";
30
+
31
+ interface SecretsSetOptions {
32
+ api?: string;
33
+ literal?: boolean;
34
+ json?: boolean;
35
+ }
36
+
37
+ const BEARER_PREFIX_RE = /^\s*Bearer\s+/i;
38
+
39
+ /** Normalise a pasted secret value: strip a leading `Bearer ` prefix (unless
40
+ * `literal`) so `.secrets.yaml` holds the raw token zond's runner expects.
41
+ * Exported for unit tests. */
42
+ export function normalizeSecretValue(raw: string, literal?: boolean): { value: string; bearerStripped: boolean } {
43
+ if (!literal && BEARER_PREFIX_RE.test(raw)) {
44
+ return { value: raw.replace(BEARER_PREFIX_RE, ""), bearerStripped: true };
45
+ }
46
+ return { value: raw, bearerStripped: false };
47
+ }
48
+
49
+ /** Upsert one `key: "value"` line into `.secrets.yaml`, backing up first.
50
+ * Exported for unit tests. */
51
+ export async function applySecretWrite(
52
+ secretsPath: string,
53
+ key: string,
54
+ value: string,
55
+ ): Promise<{ backup: string | null }> {
56
+ const file = Bun.file(secretsPath);
57
+ const exists = await file.exists();
58
+ let text = exists ? await file.text() : "";
59
+ let backup: string | null = `${secretsPath}.bak`;
60
+ if (exists) {
61
+ try { await copyFile(secretsPath, backup); } catch { backup = null; }
62
+ } else {
63
+ backup = null;
64
+ }
65
+ text = upsertEnvLine(text, key, value);
66
+ if (!text.endsWith("\n")) text += "\n";
67
+ await Bun.write(secretsPath, text);
68
+ return { backup };
69
+ }
70
+
71
+ async function setAction(key: string, valueParts: string[], cmd: Command): Promise<void> {
72
+ const opts = cmd.opts<SecretsSetOptions>();
73
+ const json = opts.json === true || globalJson(cmd);
74
+
75
+ const fail = (m: string, code = 2) => {
76
+ if (json) printJson(jsonError("secrets set", [m])); else printError(m);
77
+ process.exit(code);
78
+ };
79
+
80
+ const apiName = getApi(cmd, { api: opts.api } as Record<string, unknown>);
81
+ if (!apiName) return fail(MISSING_API_MESSAGE);
82
+ const col = resolveApiCollection(apiName, undefined);
83
+ if ("error" in col) return fail(col.error);
84
+ if (!col.baseDir) return fail(`API '${apiName}' has no base_dir registered.`);
85
+
86
+ const k = key.trim();
87
+ if (!k) return fail("Secret key must not be empty. Usage: zond secrets set <key> <value>");
88
+
89
+ // Join variadic value parts so an unquoted `Bearer eyJ…` paste (two argv
90
+ // tokens) still lands as one value instead of dropping the token.
91
+ const rawValue = valueParts.join(" ");
92
+ const { value, bearerStripped } = normalizeSecretValue(rawValue, opts.literal);
93
+ const warnings: string[] = [];
94
+ if (bearerStripped) {
95
+ warnings.push(
96
+ "Stripped a leading 'Bearer ' prefix — .secrets.yaml stores the raw token; zond adds the scheme prefix itself. Pass --literal to keep it verbatim.",
97
+ );
98
+ }
99
+
100
+ const secretsPath = join(col.baseDir, ".secrets.yaml");
101
+ const { backup } = await applySecretWrite(secretsPath, k, value);
102
+
103
+ // NEVER echo the value — key + metadata only.
104
+ if (json) {
105
+ printJson(jsonOk("secrets set", {
106
+ api: apiName,
107
+ secrets: secretsPath,
108
+ key: k,
109
+ bearer_stripped: bearerStripped,
110
+ backup,
111
+ }, warnings));
112
+ } else {
113
+ printSuccess(`Set secret '${k}' in ${secretsPath}` + (backup ? ` (backup: ${backup})` : ""));
114
+ process.stdout.write(" (value not echoed)\n");
115
+ for (const w of warnings) process.stderr.write(`Warning: ${w}\n`);
116
+ }
117
+ process.exit(0);
118
+ }
119
+
120
+ export function registerSecrets(program: Command): void {
121
+ const secrets = program
122
+ .command("secrets")
123
+ .description("Manage apis/<name>/.secrets.yaml (gitignored raw secret values, TASK-170). Values are never echoed back.");
124
+
125
+ secrets
126
+ .command("set <key> <value...>")
127
+ .description("Write a secret to .secrets.yaml (with .secrets.yaml.bak backup). Auto-strips a pasted 'Bearer ' prefix unless --literal. Never prints the value.")
128
+ .option("--api <name>", "Registered API (apis/<name>/.secrets.yaml). Falls back to ZOND_API / .zond/current-api.")
129
+ .option("--literal", "Store the value verbatim; do not strip a leading 'Bearer ' prefix.")
130
+ .action(async (key: string, value: string[], _opts, cmd: Command) => {
131
+ await setAction(key, value, cmd);
132
+ });
133
+ }
@@ -106,23 +106,3 @@ export function writeEnvelope<T>(command: string, result: EnvelopeResult<T>): nu
106
106
  printJson(jsonError(command, result.errors, result.warnings, exit));
107
107
  return exit;
108
108
  }
109
-
110
- /**
111
- * High-order wrapper for `--json` action handlers. Accepts an async
112
- * producer and renders its return value (or thrown error) as an
113
- * envelope. Errors thrown synchronously or asynchronously become
114
- * `{ ok: false, errors: [{code: "unknown_error", message}] }` with
115
- * `exitCode = 2`.
116
- */
117
- export async function withEnvelope<T>(
118
- command: string,
119
- produce: () => Promise<{ data: T; warnings?: string[] }>,
120
- ): Promise<number> {
121
- try {
122
- const { data, warnings } = await produce();
123
- return writeEnvelope(command, { ok: true, data, warnings });
124
- } catch (err) {
125
- const message = err instanceof Error ? err.message : String(err);
126
- return writeEnvelope(command, { ok: false, errors: [message] });
127
- }
128
- }
@@ -128,11 +128,46 @@ export const CheckRunSummarySchema = z.object({
128
128
  // never produced a checkable response (e.g. probe got 4xx, schema only on
129
129
  // 200) so "0 findings" doesn't read as "all green".
130
130
  skipped_outcomes: z.record(z.string(), z.number().int().nonnegative()),
131
+ // ARV-83: structured counterpart to skipped_outcomes — split into
132
+ // {check, reason, count} so consumers don't have to colon-tokenise a
133
+ // reason that may itself contain colons. Sorted by count desc.
134
+ skipped_outcomes_grouped: z.array(z.object({
135
+ check: z.string(),
136
+ reason: z.string(),
137
+ count: z.number().int().nonnegative(),
138
+ })),
139
+ // ARV-283 / ARV-68: count of findings the severity matrix demoted to
140
+ // `info-suppressed`. Excluded from `findings` / `by_severity` so CI
141
+ // gates ignore them, but exposed here so consumers (ajv validators
142
+ // included) accept the field — the runner emits it unconditionally.
143
+ suppressed: z.number().int().nonnegative().optional(),
144
+ });
145
+
146
+ /** ARV-60: spec-level rollup row. Emitted when ≥80% of a check's
147
+ * applicable operations share the same root cause (status drift,
148
+ * missing-declaration skip cluster, or no-detector zero-case). Lives in
149
+ * `data.spec_findings` and as its own `spec_finding` NDJSON event. */
150
+ export const SpecFindingSchema = z.object({
151
+ check: z.string(),
152
+ kind: z.enum(["status_drift", "missing_declaration", "no_detector", "broken_baseline", "other"]),
153
+ severity: SeveritySchema,
154
+ category: CategorySchema.optional(),
155
+ reason: z.string(),
156
+ fix_hint: z.string(),
157
+ affected_operations: z.array(z.object({
158
+ path: z.string(),
159
+ method: z.string(),
160
+ operationId: z.string().optional(),
161
+ })),
162
+ count: z.number().int().nonnegative(),
163
+ applicable: z.number().int().nonnegative(),
131
164
  });
132
165
 
133
166
  export const ChecksRunDataSchema = z.object({
134
167
  findings: z.array(CheckFindingSchema),
135
168
  summary: CheckRunSummarySchema,
169
+ // ARV-60: always present (empty array when no clusters cross 80%).
170
+ spec_findings: z.array(SpecFindingSchema),
136
171
  });
137
172
 
138
173
  /** ARV-10 (m-15): NDJSON streaming events emitted by `zond checks run
@@ -157,6 +192,10 @@ export const NdjsonCheckResultEventSchema = z.object({
157
192
  type: z.literal("check_result"),
158
193
  ts: z.string(),
159
194
  check: z.string(),
195
+ // ARV-215: severity from the registry (mirrors `zond checks list`
196
+ // [high|medium|low]). Lets consumers group NDJSON by severity without
197
+ // re-joining against the registry.
198
+ severity: z.enum(["info", "low", "medium", "high", "critical"]),
160
199
  verdict: z.enum(["pass", "fail"]),
161
200
  operation: OperationRefSchema,
162
201
  request_signature: z.string(),
@@ -184,10 +223,22 @@ export const NdjsonSummaryEventSchema = z.object({
184
223
  summary: CheckRunSummarySchema,
185
224
  });
186
225
 
226
+ /** ARV-60: rolled-up spec-level finding event. Emitted before the
227
+ * terminal `summary` event so streaming consumers see clusters in the
228
+ * same order the CLI renders them. Per-op `finding` events are NOT
229
+ * removed — both layers coexist. */
230
+ export const NdjsonSpecFindingEventSchema = z.object({
231
+ type: z.literal("spec_finding"),
232
+ ts: z.string(),
233
+ check: z.string(),
234
+ spec_finding: SpecFindingSchema,
235
+ });
236
+
187
237
  export const NdjsonEventSchema = z.discriminatedUnion("type", [
188
238
  NdjsonCheckStartEventSchema,
189
239
  NdjsonCheckResultEventSchema,
190
240
  NdjsonFindingEventSchema,
241
+ NdjsonSpecFindingEventSchema,
191
242
  NdjsonSummaryEventSchema,
192
243
  ]);
193
244
 
package/src/cli/output.ts CHANGED
@@ -7,9 +7,25 @@ function useColor(): boolean {
7
7
  return process.stderr.isTTY ?? false;
8
8
  }
9
9
 
10
- export function printError(message: string): void {
10
+ let debugHintShown = false;
11
+
12
+ // ARV-203: when a command catches an unexpected throw and calls
13
+ // `printError(msg, err)`, gate the stack trace behind ZOND_DEBUG=1.
14
+ // Without the env var, append a one-time hint so the user knows the
15
+ // switch exists — the previous behaviour silently dropped TypeError
16
+ // stacks and forced reverse-engineering from the bare message.
17
+ export function printError(message: string, err?: unknown): void {
11
18
  const msg = useColor() ? `${RED}Error: ${message}${RESET}` : `Error: ${message}`;
12
19
  process.stderr.write(msg + "\n");
20
+ if (err === undefined) return;
21
+ if (process.env.ZOND_DEBUG === "1" && err instanceof Error && err.stack) {
22
+ process.stderr.write(err.stack + "\n");
23
+ return;
24
+ }
25
+ if (!debugHintShown) {
26
+ debugHintShown = true;
27
+ process.stderr.write(" hint: set ZOND_DEBUG=1 to print the full stack trace.\n");
28
+ }
13
29
  }
14
30
 
15
31
  export function printSuccess(message: string): void {
@@ -14,9 +14,9 @@ import { registerRequest } from "./commands/request.ts";
14
14
  import { registerGenerate } from "./commands/generate.ts";
15
15
  import { registerPrepareFixtures } from "./commands/prepare-fixtures.ts";
16
16
  import { registerFixtures } from "./commands/fixtures.ts";
17
+ import { registerSecrets } from "./commands/secrets.ts";
17
18
  import { registerProbes } from "./commands/probe.ts";
18
19
  import { bootstrapProbes } from "../core/probe/bootstrap.ts";
19
- import { bootstrapAntiFp } from "../core/anti-fp/bootstrap.ts";
20
20
  import { registerReport } from "./commands/report.ts";
21
21
  import { registerCatalog } from "./commands/catalog.ts";
22
22
  import { registerCompletions } from "./commands/completions.ts";
@@ -24,6 +24,7 @@ import { registerUse } from "./commands/use.ts";
24
24
  import { registerSession } from "./commands/session.ts";
25
25
  import { registerDoctor } from "./commands/doctor.ts";
26
26
  import { registerRefreshApi } from "./commands/refresh-api.ts";
27
+ import { registerSchemaFromRuns } from "./commands/schema-from-runs.ts";
27
28
  import { registerAdd } from "./commands/add-api.ts";
28
29
  import { registerRemove } from "./commands/remove-api.ts";
29
30
  import { registerAudit } from "./commands/audit.ts";
@@ -83,6 +84,7 @@ export function buildProgram(): Command {
83
84
 
84
85
  registerUse(program);
85
86
  registerRefreshApi(program);
87
+ registerSchemaFromRuns(program);
86
88
  registerDoctor(program);
87
89
 
88
90
  registerSession(program);
@@ -102,15 +104,13 @@ export function buildProgram(): Command {
102
104
  registerGenerate(program);
103
105
  registerPrepareFixtures(program);
104
106
  registerFixtures(program);
107
+ registerSecrets(program);
105
108
  registerAudit(program);
106
109
 
107
110
  // m-17 / ARV-49: validate registered probes implement the Probe
108
111
  // contract before commander gets to wire them up. Boot-throws on a
109
112
  // missing slot, so a regression can't slip past CI.
110
113
  bootstrapProbes();
111
- // ARV-123/124: populate the anti-FP rule registry before checks run
112
- // — checks call applyAntiFp() and need every shipped rule present.
113
- bootstrapAntiFp();
114
114
  registerProbes(program);
115
115
 
116
116
  registerCatalog(program);
@@ -130,6 +130,7 @@ export function buildProgram(): Command {
130
130
  "add": "Setup:",
131
131
  "remove": "Setup:",
132
132
  "use": "Setup:",
133
+ "secrets": "Setup:",
133
134
  "refresh-api": "Setup:",
134
135
  "doctor": "Setup:",
135
136
  "clean": "Setup:",
@@ -0,0 +1,24 @@
1
+ /**
2
+ * ARV-299: one safe/live vocabulary across `audit`, `checks run`, and the
3
+ * mutating `probe` subcommands so a developer has a single button —
4
+ * `--safe` (default) means "no destructive traffic", `--live` opts in.
5
+ *
6
+ * The help strings live here so all three commands read identically
7
+ * (ARV-299 AC#3). `audit` predates this module and keeps its own richer
8
+ * --live text (it gates several stages); the shorter pair here is for
9
+ * checks/probe.
10
+ */
11
+
12
+ export const SAFE_HELP =
13
+ "Safe mode (DEFAULT): no destructive/mutating traffic — no create/update/delete " +
14
+ "chains, no live attack payloads. Explicit inverse of --live; safe if neither is given.";
15
+
16
+ export const LIVE_HELP =
17
+ "Opt into destructive/mutating operations (create/update/delete chains, live " +
18
+ "attack payloads) against a real API. Use only against a throwaway/sandbox account.";
19
+
20
+ /** Resolve the safe/live pair to a single boolean. Default is safe;
21
+ * `--live` wins if both are somehow passed. */
22
+ export function resolveLive(opts: { safe?: boolean; live?: boolean }): boolean {
23
+ return opts.live === true;
24
+ }
@@ -89,16 +89,6 @@ export function parseStatusFilter(raw: string): StatusMatcher {
89
89
  return out;
90
90
  }
91
91
 
92
- /** True if `code` matches the parsed filter. Useful for in-memory filtering. */
93
- export function statusMatches(matcher: StatusMatcher, code: number | null | undefined): boolean {
94
- if (code == null) return false;
95
- if (matcher.exacts.includes(code)) return true;
96
- for (const [lo, hi] of matcher.ranges) {
97
- if (code >= lo && code <= hi) return true;
98
- }
99
- return false;
100
- }
101
-
102
92
  /**
103
93
  * Compile a `StatusMatcher` to a SQL `WHERE` fragment + bound parameters.
104
94
  * The fragment is wrapped in parentheses; combine with other conditions via
@@ -0,0 +1,183 @@
1
+ /**
2
+ * ARV-265: shared helpers for "any HTTP touch by zond should be visible
3
+ * to `audit-coverage`". `zond run` was the only producer that wrote into
4
+ * `runs` / `results`; `checks run`, `probe`, `request`, and
5
+ * `prepare-fixtures --cascade` all emitted ndjson/stdout and left no
6
+ * persistent trace. This module gives those producers a uniform path:
7
+ *
8
+ * 1. `beginAuditRun(opts)` — INSERT into `runs` with the right
9
+ * `run_kind` and return the row id.
10
+ * 2. accumulate `AuditCaseRecord` entries as the work runs.
11
+ * 3. `finalizeAuditRun(runId, suiteName, cases)` — group cases into a
12
+ * single synthetic `TestRunResult`, call `saveResults`, then
13
+ * `finalizeRun` so the row carries totals.
14
+ *
15
+ * Suite/test naming is *pseudo* (`checks/response`, `probe/security`,
16
+ * `request/ad-hoc`) — these rows aren't meant to drive failure triage,
17
+ * just to keep the audit-coverage matrix honest. The coverage engine
18
+ * matches results to endpoints via (request_method, request_url) regex,
19
+ * so the suite name is purely cosmetic.
20
+ *
21
+ * Error handling: every write is wrapped in try/catch by the CLI caller
22
+ * and degrades to a warning — failing to persist must not break the
23
+ * primary command (a `checks run` that exits 0 with no DB row is still
24
+ * a successful checks run for the user; audit-coverage just won't pick
25
+ * it up).
26
+ */
27
+ import type { HttpRequest, HttpResponse, TestRunResult, StepResult, StepStatus } from "../runner/types.ts";
28
+ import type { RunKind } from "../runner/run-kind.ts";
29
+ import { createRun, saveResults, finalizeRun } from "../../db/queries.ts";
30
+ import { setHttpAuditRecorder, type AuditRecord } from "../runner/http-client.ts";
31
+
32
+ export interface BeginAuditRunOpts {
33
+ runKind: RunKind;
34
+ collectionId?: number;
35
+ sessionId?: string | null;
36
+ environment?: string;
37
+ trigger?: string;
38
+ tags?: string[];
39
+ commitSha?: string;
40
+ branch?: string;
41
+ startedAt?: string;
42
+ }
43
+
44
+ export interface AuditCaseRecord {
45
+ suiteName: string;
46
+ testName: string;
47
+ status: StepStatus;
48
+ request: HttpRequest;
49
+ response?: HttpResponse;
50
+ durationMs: number;
51
+ error?: string;
52
+ /** Optional `suite_file` value — surfaces in the diagnose output and
53
+ * helps the legacy detectRunKind heuristic understand the row's
54
+ * origin even if `run_kind` ever drifts. */
55
+ suiteFile?: string;
56
+ }
57
+
58
+ export function beginAuditRun(opts: BeginAuditRunOpts): number {
59
+ return createRun({
60
+ started_at: opts.startedAt ?? new Date().toISOString(),
61
+ environment: opts.environment,
62
+ trigger: opts.trigger ?? "manual",
63
+ commit_sha: opts.commitSha,
64
+ branch: opts.branch,
65
+ collection_id: opts.collectionId,
66
+ session_id: opts.sessionId ?? undefined,
67
+ tags: opts.tags && opts.tags.length > 0 ? opts.tags : undefined,
68
+ run_kind: opts.runKind,
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Group flat case records into one `TestRunResult` per `suite_file` (or
74
+ * per `suiteName` when no file is set) and write them. Returns the number
75
+ * of `results` rows persisted so the caller can sanity-check the totals.
76
+ */
77
+ export function finalizeAuditRun(
78
+ runId: number,
79
+ cases: AuditCaseRecord[],
80
+ ): { rows: number } {
81
+ if (cases.length === 0) {
82
+ // Nothing executed — still close the run so coverage queries can
83
+ // see an empty audit row instead of an open-ended row dangling
84
+ // without finished_at.
85
+ finalizeRun(runId, []);
86
+ return { rows: 0 };
87
+ }
88
+ const bySuite = new Map<string, AuditCaseRecord[]>();
89
+ for (const c of cases) {
90
+ const key = c.suiteFile ?? c.suiteName;
91
+ const list = bySuite.get(key) ?? [];
92
+ list.push(c);
93
+ bySuite.set(key, list);
94
+ }
95
+ const now = new Date().toISOString();
96
+ const results: TestRunResult[] = [];
97
+ for (const [, group] of bySuite) {
98
+ const first = group[0]!;
99
+ const steps: StepResult[] = group.map((c) => ({
100
+ name: c.testName,
101
+ status: c.status,
102
+ duration_ms: c.durationMs,
103
+ request: c.request,
104
+ response: c.response,
105
+ assertions: [],
106
+ captures: {},
107
+ ...(c.error ? { error: c.error } : {}),
108
+ }));
109
+ let passed = 0, failed = 0, skipped = 0;
110
+ for (const s of steps) {
111
+ if (s.status === "pass") passed += 1;
112
+ else if (s.status === "skip") skipped += 1;
113
+ else failed += 1; // fail | error both bucket as failed for the row totals
114
+ }
115
+ results.push({
116
+ suite_name: first.suiteName,
117
+ ...(first.suiteFile ? { suite_file: first.suiteFile } : {}),
118
+ started_at: now,
119
+ finished_at: now,
120
+ total: steps.length,
121
+ passed,
122
+ failed,
123
+ skipped,
124
+ steps,
125
+ });
126
+ }
127
+ saveResults(runId, results);
128
+ finalizeRun(runId, results);
129
+ return { rows: cases.length };
130
+ }
131
+
132
+ /** ARV-265: ZOND_CHECKS_PERSIST opt-out. Default ON — the task's whole
133
+ * point is that `checks run` contributes to audit-coverage out of the
134
+ * box. Setting the env var to "0" / "false" disables it (for tests, or
135
+ * for users who want the pre-ARV-265 behaviour back). */
136
+ export function checksPersistEnabled(): boolean {
137
+ const v = (process.env.ZOND_CHECKS_PERSIST ?? "").trim().toLowerCase();
138
+ if (v === "0" || v === "false" || v === "off" || v === "no") return false;
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * ARV-265: run `fn` with the HTTP audit recorder set; return both the
144
+ * function's value and the collected records. Convenience for the live
145
+ * probe commands (probe security / probe mass-assignment) which can't
146
+ * easily hook into their own internals but route every request through
147
+ * `executeRequest`.
148
+ *
149
+ * The recorder is always cleared in `finally`, even if `fn` throws.
150
+ */
151
+ export async function withHttpAudit<T>(
152
+ fn: () => Promise<T>,
153
+ ): Promise<{ value: T; records: AuditRecord[] }> {
154
+ const records: AuditRecord[] = [];
155
+ setHttpAuditRecorder((rec) => records.push(rec));
156
+ try {
157
+ const value = await fn();
158
+ return { value, records };
159
+ } finally {
160
+ setHttpAuditRecorder(null);
161
+ }
162
+ }
163
+
164
+ export function auditRecordToCase(
165
+ rec: AuditRecord,
166
+ meta: { suiteName: string; suiteFile: string; testName: string },
167
+ ): AuditCaseRecord {
168
+ const status: StepStatus = rec.error
169
+ ? "error"
170
+ : rec.response && rec.response.status >= 200 && rec.response.status < 400 ? "pass" : "fail";
171
+ return {
172
+ suiteName: meta.suiteName,
173
+ suiteFile: meta.suiteFile,
174
+ testName: meta.testName,
175
+ status,
176
+ request: rec.request,
177
+ ...(rec.response ? { response: rec.response } : {}),
178
+ durationMs: rec.durationMs,
179
+ ...(rec.error ? { error: rec.error } : {}),
180
+ };
181
+ }
182
+
183
+ export type { AuditRecord } from "../runner/http-client.ts";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `--budget quick|standard|full` (ARV-292): adaptive cap for `zond audit` and
3
+ * `zond checks run`, designed so small-team CI gets a "60-sec gate" by default
4
+ * without having to remember `--max-requests` for huge specs.
5
+ *
6
+ * Tiers:
7
+ * quick — 50 req cap, skip stateful (CRUD) checks. ~60-sec wall-clock.
8
+ * standard — 500 req cap, all checks (sampling kept as a Phase-B knob).
9
+ * full — uncapped, all checks.
10
+ *
11
+ * Omitted `--budget` keeps the legacy uncapped behaviour so existing CI
12
+ * pipelines don't suddenly trip `max-requests-cap-reached`. `--max-requests`
13
+ * always wins over the tier-seeded cap.
14
+ */
15
+
16
+ export type Budget = "quick" | "standard" | "full";
17
+
18
+ export const BUDGETS: readonly Budget[] = ["quick", "standard", "full"];
19
+
20
+ export interface ResolvedBudget {
21
+ maxRequests?: number;
22
+ skipStateful: boolean;
23
+ }
24
+
25
+ interface TierDefaults {
26
+ maxRequests?: number;
27
+ skipStateful: boolean;
28
+ }
29
+
30
+ const TIERS: Record<Budget, TierDefaults> = {
31
+ quick: { maxRequests: 50, skipStateful: true },
32
+ standard: { maxRequests: 500, skipStateful: false },
33
+ full: { skipStateful: false },
34
+ };
35
+
36
+ export function isBudget(value: unknown): value is Budget {
37
+ return typeof value === "string" && (BUDGETS as readonly string[]).includes(value);
38
+ }
39
+
40
+ /** Resolve final budget given an explicit budget tier and an optional
41
+ * `--max-requests` override. When `--max-requests` is set it always wins,
42
+ * even if it relaxes a tighter tier cap (e.g. `--budget quick --max-requests
43
+ * 200` → cap=200). Stateful-skip is tier-driven only; pass an explicit
44
+ * include list via `forceStatefulIfIncluded` to opt back in (e.g. when the
45
+ * user typed `--check stateful`). */
46
+ export function resolveBudget(
47
+ budget: Budget | undefined,
48
+ maxRequestsOverride: number | undefined,
49
+ opts?: { forceStatefulIfIncluded?: boolean },
50
+ ): ResolvedBudget {
51
+ const tier = budget ? TIERS[budget] : undefined;
52
+ const skipStateful = tier?.skipStateful === true && opts?.forceStatefulIfIncluded !== true;
53
+ const cap = typeof maxRequestsOverride === "number" && maxRequestsOverride > 0
54
+ ? maxRequestsOverride
55
+ : tier?.maxRequests;
56
+ const result: ResolvedBudget = { skipStateful };
57
+ if (cap !== undefined) result.maxRequests = cap;
58
+ return result;
59
+ }
@@ -7,9 +7,15 @@
7
7
  * echoed) but the read didn't return surface as drift findings — the
8
8
  * server is silently dropping state.
9
9
  *
10
- * Severity policy:
11
- * • `state_not_persisted` (POST echoed, GET dropped) is the high-
12
- * signal class, so the check is registered as HIGH.
10
+ * Severity policy (ARV-287, follow-up to ARV-284):
11
+ * • Declared check severity is LOW (proof-cap baseline per ARV-250:
12
+ * single-signal checks cap at LOW until evidence confirms higher).
13
+ * • Per-finding dispatch in the fail branch:
14
+ * - state_not_persisted non-empty → HIGH
15
+ * (POST echoed field, GET dropped it: two-signal evidence chain —
16
+ * the server promised state it later discarded)
17
+ * - write-only-only (state_not_persisted=[], writeOnly non-empty) → MEDIUM
18
+ * (single-signal contract gap per ARV-250)
13
19
  * • `write_only` (POST accepted, GET never returned) is also surfaced
14
20
  * in the same finding's evidence. Anti-FP: write-only fields the
15
21
  * spec explicitly declares are *not* present on GET (e.g. password
@@ -48,7 +54,9 @@ function safeParse(v: unknown): unknown {
48
54
 
49
55
  export const crossCallReferences: CrudStatefulCheck = {
50
56
  id: "cross_call_references",
51
- severity: "high",
57
+ // ARV-287: proof-cap baseline per ARV-250 (single-signal cap → LOW).
58
+ // Per-finding dispatch in run() escalates to HIGH / MEDIUM by evidence.
59
+ severity: "low",
52
60
  defaultExpected: "Fields accepted or echoed by POST must be readable via GET on the same resource",
53
61
  references: [{ id: "ARV-169" }, { id: "OWASP-API-3-2023" }],
54
62
  phase: "crud",
@@ -117,6 +125,11 @@ export const crossCallReferences: CrudStatefulCheck = {
117
125
  ];
118
126
  return {
119
127
  kind: "fail",
128
+ // ARV-287: per-finding severity dispatch (follow-up to ARV-284).
129
+ // state_not_persisted → HIGH: two-signal evidence chain (POST echoed,
130
+ // GET dropped) confirms the server is discarding persisted state.
131
+ // write-only-only → MEDIUM: single-signal contract gap per ARV-250.
132
+ severity: stateNotPersisted.length > 0 ? "high" : "medium",
120
133
  message:
121
134
  stateNotPersisted.length > 0
122
135
  ? `POST→GET drift on ${g.resource}: ${stateNotPersisted.length} state-not-persisted field(s)` +