@kirrosh/zond 0.23.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +164 -1
- package/README.md +8 -7
- package/package.json +2 -3
- package/src/CLAUDE.md +112 -0
- package/src/cli/commands/add-api.ts +19 -7
- package/src/cli/commands/api/annotate/index.ts +359 -4
- package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
- package/src/cli/commands/api/annotate/overlay.ts +1 -1
- package/src/cli/commands/api/annotate/pagination.ts +10 -6
- package/src/cli/commands/api/annotate/prompts.ts +39 -2
- package/src/cli/commands/audit.ts +360 -54
- package/src/cli/commands/check.ts +15 -2
- package/src/cli/commands/checks.ts +352 -36
- package/src/cli/commands/cleanup.ts +4 -30
- package/src/cli/commands/coverage.ts +275 -57
- package/src/cli/commands/db.ts +311 -8
- package/src/cli/commands/discover.ts +281 -161
- package/src/cli/commands/doctor.ts +57 -3
- package/src/cli/commands/fixtures.ts +1 -1
- package/src/cli/commands/generate.ts +24 -7
- package/src/cli/commands/init/bootstrap.ts +4 -1
- package/src/cli/commands/init/index.ts +2 -2
- package/src/cli/commands/init/skills.ts +43 -0
- package/src/cli/commands/init/templates/agents.md +12 -3
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
- package/src/cli/commands/init/templates/skills/zond.md +274 -64
- package/src/cli/commands/init/templates/zond-config.yml +1 -1
- package/src/cli/commands/prepare-fixtures.ts +14 -52
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +101 -10
- package/src/cli/commands/probe/security.ts +95 -12
- package/src/cli/commands/probe/webhooks.ts +2 -0
- package/src/cli/commands/probe.ts +87 -11
- package/src/cli/commands/refresh-api.ts +59 -1
- package/src/cli/commands/report-bundle.ts +3 -11
- package/src/cli/commands/request.ts +116 -0
- package/src/cli/commands/run.ts +33 -5
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/json-envelope.ts +0 -20
- package/src/cli/json-schemas.ts +51 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +5 -4
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +0 -10
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/cross_call_references.ts +17 -4
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/idempotency_replay.ts +1 -5
- package/src/core/checks/checks/ignored_auth.ts +44 -1
- package/src/core/checks/checks/index.ts +3 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
- package/src/core/checks/checks/negative_data_rejection.ts +119 -16
- package/src/core/checks/checks/not_a_server_error.ts +8 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
- package/src/core/checks/checks/pagination_invariants.ts +298 -117
- package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
- package/src/core/checks/checks/status_code_conformance.ts +78 -7
- package/src/core/checks/mode.ts +3 -0
- package/src/core/checks/recommended-action.ts +5 -1
- package/src/core/checks/runner.ts +614 -27
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/types.ts +117 -1
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +35 -6
- package/src/core/coverage/loader.ts +31 -0
- package/src/core/diagnostics/db-analysis.ts +200 -106
- package/src/core/diagnostics/failure-class.ts +21 -1
- package/src/core/diagnostics/failure-hints.ts +4 -208
- package/src/core/diagnostics/suggested-fixes.ts +2 -3
- package/src/core/generator/chunker.ts +1 -8
- package/src/core/generator/data-factory.ts +199 -61
- package/src/core/generator/fixtures-builder.ts +38 -31
- package/src/core/generator/index.ts +0 -2
- package/src/core/generator/openapi-reader.ts +98 -4
- package/src/core/generator/path-param-disambig.ts +30 -4
- package/src/core/generator/resources-builder.ts +276 -26
- package/src/core/generator/schema-utils.ts +22 -0
- package/src/core/generator/suite-generator.ts +168 -15
- package/src/core/generator/types.ts +6 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/output/README.md +11 -29
- package/src/core/output/index.ts +1 -1
- package/src/core/output/run.ts +0 -35
- package/src/core/output/types.ts +0 -7
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/variables.ts +0 -0
- package/src/core/probe/dry-run-envelope.ts +4 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe.ts +23 -1118
- package/src/core/probe/mass-assignment-template.ts +32 -4
- package/src/core/probe/path-discovery.ts +3 -4
- package/src/core/probe/probe-harness.ts +21 -22
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +8 -2
- package/src/core/probe/security-probe.ts +28 -1449
- package/src/core/probe/shared.ts +26 -0
- package/src/core/probe/webhooks-probe.ts +5 -7
- package/src/core/runner/assertions.ts +1 -1
- package/src/core/runner/executor.ts +3 -18
- package/src/core/runner/form-encode.ts +8 -18
- package/src/core/runner/http-client.ts +38 -1
- package/src/core/runner/preflight-vars.ts +19 -15
- package/src/core/runner/rate-limiter.ts +11 -29
- package/src/core/runner/run-kind.ts +7 -1
- package/src/core/runner/schema-validator.ts +2 -6
- package/src/core/runner/send-request.ts +11 -6
- package/src/core/runner/types.ts +6 -0
- package/src/core/setup-api.ts +53 -15
- package/src/core/severity/index.ts +0 -63
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/db/migrate.ts +2 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/queries/collections.ts +2 -2
- package/src/db/queries/results.ts +88 -0
- package/src/db/queries/runs.ts +56 -2
- package/src/db/queries.ts +3 -0
- package/src/db/schema.ts +7 -7
- package/src/cli/commands/bootstrap.ts +0 -710
- package/src/core/anti-fp/bootstrap.ts +0 -34
- package/src/core/anti-fp/index.ts +0 -33
- package/src/core/anti-fp/registry.ts +0 -44
- package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
- package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
- package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
- package/src/core/anti-fp/types.ts +0 -68
- package/src/core/generator/create-body.ts +0 -89
package/src/cli/json-envelope.ts
CHANGED
|
@@ -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
|
-
}
|
package/src/cli/json-schemas.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/cli/program.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/cli/status-filter.ts
CHANGED
|
@@ -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
|
-
* •
|
|
12
|
-
* signal
|
|
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
|
-
|
|
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)` +
|