@kirrosh/zond 0.22.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.
- package/CHANGELOG.md +648 -0
- package/README.md +58 -6
- package/package.json +9 -6
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +178 -7
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -46
- package/src/cli/commands/init/bootstrap.ts +30 -1
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +56 -3
- package/src/cli/commands/init/templates/agents.md +65 -61
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +592 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +842 -53
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +18 -1
- package/src/cli/index.ts +20 -3
- package/src/cli/json-envelope.ts +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +198 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +61 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +151 -11
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +42 -16
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +445 -19
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +37 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +419 -111
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +129 -4
- package/src/core/parser/types.ts +19 -1
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +58 -12
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -0
- package/src/core/probe/method-probe.ts +43 -76
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +183 -149
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +284 -0
- package/src/core/reporter/console.ts +41 -2
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +11 -1
- package/src/core/reporter/junit.ts +27 -12
- package/src/core/reporter/ndjson.ts +37 -0
- package/src/core/reporter/types.ts +3 -0
- package/src/core/runner/assertions.ts +58 -1
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +264 -20
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +75 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +89 -17
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +415 -16
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +128 -0
- package/src/db/queries/runs.ts +235 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +72 -802
- package/src/db/schema.ts +178 -50
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
- package/src/cli/commands/probe-methods.ts +0 -108
- package/src/cli/commands/probe-validation.ts +0 -124
- package/src/cli/commands/serve.ts +0 -114
- package/src/cli/commands/sync.ts +0 -268
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/diagnostics/render-md.ts +0 -112
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -19
- package/src/core/parser/index.ts +0 -21
- package/src/core/runner/execute-run.ts +0 -132
- package/src/core/runner/index.ts +0 -12
- package/src/core/sync/spec-differ.ts +0 -38
- package/src/web/data/collection-state.ts +0 -362
- package/src/web/routes/api.ts +0 -314
- package/src/web/routes/dashboard.ts +0 -350
- package/src/web/routes/runs.ts +0 -64
- package/src/web/schemas.ts +0 -121
- package/src/web/server.ts +0 -134
- package/src/web/static/htmx.min.cjs +0 -1
- package/src/web/static/style.css +0 -1148
- package/src/web/views/endpoints-tab.ts +0 -174
- package/src/web/views/explorer-tab.ts +0 -402
- package/src/web/views/health-strip.ts +0 -92
- package/src/web/views/layout.ts +0 -48
- package/src/web/views/results.ts +0 -210
- package/src/web/views/runs-tab.ts +0 -126
- package/src/web/views/suites-tab.ts +0 -181
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zond checks` umbrella — schemathesis-style depth checks framework
|
|
3
|
+
* (m-15 ARV-1). Two subcommands today:
|
|
4
|
+
*
|
|
5
|
+
* zond checks list — emit the registered check catalog so
|
|
6
|
+
* agents can discover what's available.
|
|
7
|
+
* zond checks run — execute the active checks against a
|
|
8
|
+
* live API and emit findings.
|
|
9
|
+
*
|
|
10
|
+
* Built-in checks register themselves on import via `core/checks` —
|
|
11
|
+
* adding a new check (ARV-2/3/4) doesn't require touching this file.
|
|
12
|
+
*/
|
|
13
|
+
import { writeFileSync, readFileSync, openSync, writeSync, closeSync } from "node:fs";
|
|
14
|
+
import { resolve as resolvePath, relative as relativePath } from "node:path";
|
|
15
|
+
import type { Command } from "commander";
|
|
16
|
+
|
|
17
|
+
import { listChecks, runChecks } from "../../core/checks/index.ts";
|
|
18
|
+
import { listStatefulChecks } from "../../core/checks/stateful.ts";
|
|
19
|
+
import { generateSarifReport } from "../../core/checks/sarif.ts";
|
|
20
|
+
import { emitToStdout } from "../../core/reporter/ndjson.ts";
|
|
21
|
+
import { parseWorkers } from "../../core/runner/async-pool.ts";
|
|
22
|
+
import { createAdaptiveRateLimiter, createRateLimiter, type RateLimiter } from "../../core/runner/rate-limiter.ts";
|
|
23
|
+
import { compileOperationFilter } from "../../core/selectors/operation-filter.ts";
|
|
24
|
+
import { resolveSpecArg, globalJson, resolveApiCollection } from "../resolve.ts";
|
|
25
|
+
import { readResourceMap } from "./discover.ts";
|
|
26
|
+
import type { ReadbackDiffConfig, IdempotencyConfig, PaginationConfig, LifecycleConfig, SeedBodyConfig } from "../../core/generator/resources-builder.ts";
|
|
27
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
28
|
+
import { printError, printSuccess } from "../output.ts";
|
|
29
|
+
import { loadEnvironment } from "../../core/parser/variables.ts";
|
|
30
|
+
import { getApi } from "../util/api-context.ts";
|
|
31
|
+
import { VERSION } from "../version.ts";
|
|
32
|
+
import { resolveOutput, OutputSpecError, type OutputSpec, type ResolvedOutput } from "../../core/output/index.ts";
|
|
33
|
+
import type { RunChecksResult } from "../../core/checks/index.ts";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ARV-118 (m-19): typed declaration of every `--report` / `--output` /
|
|
37
|
+
* `--json` combination `zond checks run` supports. Replaces the inline
|
|
38
|
+
* `--report sarif|ndjson` parser + the legacy `--ndjson` boolean + the
|
|
39
|
+
* mutual-exclusion checks that produced ARV-63, ARV-83, ARV-97.
|
|
40
|
+
*
|
|
41
|
+
* - `console` (default) — human-readable text on stdout.
|
|
42
|
+
* - `json` — `{ok, command, data}` envelope (`--json` opts into this).
|
|
43
|
+
* - `ndjson` — streamed events on stdout, one JSON object per line.
|
|
44
|
+
* `--report ndjson --output <path>` redirects the stream to file
|
|
45
|
+
* (ARV-97 — no more silent drop).
|
|
46
|
+
* - `sarif` — SARIF v2.1.0 for GitHub Code Scanning, default file
|
|
47
|
+
* `zond-checks.sarif` when `--output` is omitted.
|
|
48
|
+
* - `markdown` — short human-readable summary (file via `--output`
|
|
49
|
+
* or stdout otherwise).
|
|
50
|
+
*/
|
|
51
|
+
export const CHECKS_OUTPUT_SPEC: OutputSpec<RunChecksResult> = {
|
|
52
|
+
command: "checks run",
|
|
53
|
+
defaultFormat: "console",
|
|
54
|
+
formats: {
|
|
55
|
+
console: { defaultChannel: "stdout", description: "Human-readable summary (default)" },
|
|
56
|
+
json: { defaultChannel: "stdout", envelopeWrap: true, envelopeSchemaFile: "checksRunData.schema.json", description: "JSON envelope ({ok, command, data})" },
|
|
57
|
+
ndjson: { defaultChannel: "stdout", description: "Stream events on stdout (check_start | check_result | finding | summary)" },
|
|
58
|
+
sarif: { defaultChannel: "file", defaultFilename: "zond-checks.sarif", description: "SARIF v2.1.0 for GitHub Code Scanning" },
|
|
59
|
+
markdown: { defaultChannel: "stdout", description: "Short markdown summary of findings" },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
interface ChecksListOptions {
|
|
64
|
+
json?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function checksListAction(_args: unknown, cmd: Command): Promise<void> {
|
|
68
|
+
const opts = cmd.opts<ChecksListOptions>();
|
|
69
|
+
const json = opts.json === true || globalJson(cmd);
|
|
70
|
+
const catalog = [
|
|
71
|
+
...listChecks().map((c) => ({
|
|
72
|
+
id: c.id,
|
|
73
|
+
severity: c.severity,
|
|
74
|
+
default_expected: c.defaultExpected,
|
|
75
|
+
references: c.references,
|
|
76
|
+
phase: "response" as const,
|
|
77
|
+
})),
|
|
78
|
+
...listStatefulChecks().map((c) => ({
|
|
79
|
+
id: c.id,
|
|
80
|
+
severity: c.severity,
|
|
81
|
+
default_expected: c.defaultExpected,
|
|
82
|
+
references: c.references,
|
|
83
|
+
phase: c.phase,
|
|
84
|
+
})),
|
|
85
|
+
].sort((a, b) => a.id.localeCompare(b.id));
|
|
86
|
+
|
|
87
|
+
if (json) {
|
|
88
|
+
printJson(jsonOk("checks list", { checks: catalog }));
|
|
89
|
+
} else {
|
|
90
|
+
printSuccess(`${catalog.length} check(s) registered`);
|
|
91
|
+
for (const c of catalog) {
|
|
92
|
+
console.log(` ${c.id} [${c.severity}] — ${c.default_expected}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ChecksRunOptions {
|
|
99
|
+
api?: string;
|
|
100
|
+
spec?: string;
|
|
101
|
+
baseUrl?: string;
|
|
102
|
+
check?: string[];
|
|
103
|
+
excludeCheck?: string[];
|
|
104
|
+
timeout?: number;
|
|
105
|
+
db?: string;
|
|
106
|
+
json?: boolean;
|
|
107
|
+
authHeader?: string[];
|
|
108
|
+
bootstrapCleanupFailed?: boolean;
|
|
109
|
+
report?: string;
|
|
110
|
+
output?: string;
|
|
111
|
+
phase?: string;
|
|
112
|
+
allowX00?: boolean;
|
|
113
|
+
mode?: string;
|
|
114
|
+
include?: string[];
|
|
115
|
+
exclude?: string[];
|
|
116
|
+
workers?: string;
|
|
117
|
+
rateLimit?: string;
|
|
118
|
+
verbose?: boolean;
|
|
119
|
+
strict405?: boolean;
|
|
120
|
+
strict401?: boolean;
|
|
121
|
+
maxRequests?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseAuthHeaders(values: string[] | undefined): Record<string, string> {
|
|
125
|
+
const out: Record<string, string> = {};
|
|
126
|
+
for (const raw of values ?? []) {
|
|
127
|
+
const idx = raw.indexOf(":");
|
|
128
|
+
if (idx <= 0) continue;
|
|
129
|
+
const name = raw.slice(0, idx).trim();
|
|
130
|
+
const value = raw.slice(idx + 1).trim();
|
|
131
|
+
if (name) out[name] = value;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** ARV-141: lift filled fixtures from `apis/<name>/.env.yaml` so the runner
|
|
137
|
+
* can substitute them into path-params. Keeps the result string-only (drops
|
|
138
|
+
* numeric/object values silently — they can't be URL-encoded path segments
|
|
139
|
+
* anyway) and skips obvious placeholders so a TODO-string doesn't masquerade
|
|
140
|
+
* as a real id and produce phantom-200s. */
|
|
141
|
+
/**
|
|
142
|
+
* ARV-169: load per-resource overrides for stateful checks. Reads
|
|
143
|
+
* `apis/<name>/.api-resources.yaml` (+ `.local.yaml` overlay through
|
|
144
|
+
* `readResourceMap`) and surfaces each resource's `readback_diff`
|
|
145
|
+
* block keyed by resource name. Returns undefined when no API context
|
|
146
|
+
* is in scope (raw `--spec` invocation without a registered API) so
|
|
147
|
+
* the runner falls back to default ignore patterns.
|
|
148
|
+
*/
|
|
149
|
+
async function deriveResourceConfigsFromApi(
|
|
150
|
+
apiName: string | undefined,
|
|
151
|
+
dbPath: string | undefined,
|
|
152
|
+
): Promise<Map<string, ResourceConfigEntry> | undefined> {
|
|
153
|
+
if (!apiName) return undefined;
|
|
154
|
+
const col = resolveApiCollection(apiName, dbPath);
|
|
155
|
+
if ("error" in col) return undefined;
|
|
156
|
+
if (!col.baseDir) return undefined;
|
|
157
|
+
const map = await readResourceMap(col.baseDir);
|
|
158
|
+
if (!map) return undefined;
|
|
159
|
+
const out = new Map<string, ResourceConfigEntry>();
|
|
160
|
+
for (const r of map.resources) {
|
|
161
|
+
if (!r.readback_diff && !r.idempotency && !r.pagination && !r.lifecycle && !r.seed_body) continue;
|
|
162
|
+
const entry: ResourceConfigEntry = {};
|
|
163
|
+
if (r.seed_body) {
|
|
164
|
+
entry.seedBody = {
|
|
165
|
+
contentType: r.seed_body.content_type,
|
|
166
|
+
body: r.seed_body.body,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (r.readback_diff) {
|
|
170
|
+
entry.readbackDiff = {
|
|
171
|
+
ignoreFields: r.readback_diff.ignore_fields,
|
|
172
|
+
writeToReadMap: r.readback_diff.write_to_read_map,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (r.idempotency) {
|
|
176
|
+
entry.idempotency = {
|
|
177
|
+
header: r.idempotency.header,
|
|
178
|
+
scope: r.idempotency.scope,
|
|
179
|
+
ignoreResponseFields: r.idempotency.ignore_response_fields,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (r.pagination) {
|
|
183
|
+
entry.pagination = {
|
|
184
|
+
type: r.pagination.type,
|
|
185
|
+
cursorParam: r.pagination.cursor_param,
|
|
186
|
+
cursorField: r.pagination.cursor_field,
|
|
187
|
+
hasMoreField: r.pagination.has_more_field,
|
|
188
|
+
limitParam: r.pagination.limit_param,
|
|
189
|
+
defaultLimit: r.pagination.default_limit,
|
|
190
|
+
itemsField: r.pagination.items_field,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (r.lifecycle) {
|
|
194
|
+
entry.lifecycle = {
|
|
195
|
+
field: r.lifecycle.field,
|
|
196
|
+
states: r.lifecycle.states,
|
|
197
|
+
transitions: r.lifecycle.transitions,
|
|
198
|
+
actions: Object.fromEntries(
|
|
199
|
+
Object.entries(r.lifecycle.actions).map(([name, a]) => [name, {
|
|
200
|
+
endpoint: a.endpoint,
|
|
201
|
+
expectedState: a.expected_state,
|
|
202
|
+
body: a.body,
|
|
203
|
+
}]),
|
|
204
|
+
),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
out.set(r.resource, entry);
|
|
208
|
+
}
|
|
209
|
+
return out.size > 0 ? out : undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
type ResourceConfigEntry = {
|
|
213
|
+
readbackDiff?: ReadbackDiffConfig;
|
|
214
|
+
idempotency?: IdempotencyConfig;
|
|
215
|
+
pagination?: PaginationConfig;
|
|
216
|
+
lifecycle?: LifecycleConfig;
|
|
217
|
+
seedBody?: SeedBodyConfig;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
async function derivePathVarsFromApi(apiName: string | undefined, dbPath: string | undefined): Promise<Record<string, string>> {
|
|
221
|
+
if (!apiName) return {};
|
|
222
|
+
const col = resolveApiCollection(apiName, dbPath);
|
|
223
|
+
if ("error" in col || !col.baseDir) return {};
|
|
224
|
+
try {
|
|
225
|
+
const env = await loadEnvironment(undefined, col.baseDir);
|
|
226
|
+
const out: Record<string, string> = {};
|
|
227
|
+
for (const [k, v] of Object.entries(env)) {
|
|
228
|
+
if (typeof v !== "string" || v.length === 0) continue;
|
|
229
|
+
if (k === "base_url" || k === "auth_token" || k === "api_key") continue;
|
|
230
|
+
const trimmed = v.trim().toLowerCase();
|
|
231
|
+
// Mirror prepare-fixtures' placeholder filter — a "string"/"example"
|
|
232
|
+
// value would routinely 404 and undo the whole reactivity point.
|
|
233
|
+
if (trimmed === "" || trimmed === "string" || trimmed === "example") continue;
|
|
234
|
+
if (trimmed.startsWith("todo") || trimmed.startsWith("<")) continue;
|
|
235
|
+
out[k] = v;
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
} catch {
|
|
239
|
+
return {};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function deriveAuthHeadersFromApi(apiName: string | undefined, dbPath: string | undefined): Promise<Record<string, string>> {
|
|
244
|
+
if (!apiName) return {};
|
|
245
|
+
const col = resolveApiCollection(apiName, dbPath);
|
|
246
|
+
if ("error" in col || !col.baseDir) return {};
|
|
247
|
+
try {
|
|
248
|
+
const env = await loadEnvironment(undefined, col.baseDir);
|
|
249
|
+
const out: Record<string, string> = {};
|
|
250
|
+
if (typeof env.auth_token === "string" && env.auth_token.length > 0) {
|
|
251
|
+
out["Authorization"] = `Bearer ${env.auth_token}`;
|
|
252
|
+
}
|
|
253
|
+
if (typeof env.api_key === "string" && env.api_key.length > 0) {
|
|
254
|
+
out["X-API-Key"] = env.api_key;
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
} catch {
|
|
258
|
+
return {};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* ARV-26: render the per-(check, reason) skip tally as a single line so
|
|
264
|
+
* "0 findings" doesn't read as "all green" when half the probes never got
|
|
265
|
+
* a checkable response (e.g. no auth → 4xx → no schema on that branch).
|
|
266
|
+
*
|
|
267
|
+
* Top-3 reasons are inlined; if more exist, append "; +N more". Returns
|
|
268
|
+
* empty string when nothing was skipped.
|
|
269
|
+
*/
|
|
270
|
+
function formatSkippedOutcomes(skipped: Record<string, number> | undefined): string {
|
|
271
|
+
if (!skipped) return "";
|
|
272
|
+
const entries = Object.entries(skipped).sort((a, b) => b[1] - a[1]);
|
|
273
|
+
if (entries.length === 0) return "";
|
|
274
|
+
const total = entries.reduce((acc, [, n]) => acc + n, 0);
|
|
275
|
+
const top = entries.slice(0, 3).map(([k, n]) => `${k} ×${n}`);
|
|
276
|
+
const tail = entries.length > 3 ? `; +${entries.length - 3} more` : "";
|
|
277
|
+
return `(${total} check outcome(s) skipped: ${top.join("; ")}${tail})`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* ARV-118: minimal markdown render of a `checks run` result. Mirrors the
|
|
282
|
+
* console summary line + a grouped findings list. Kept deliberately small —
|
|
283
|
+
* SARIF / JSON envelope remain the canonical machine-readable artifacts.
|
|
284
|
+
*/
|
|
285
|
+
function renderMarkdownReport(
|
|
286
|
+
data: RunChecksResult["data"],
|
|
287
|
+
warnings: string[],
|
|
288
|
+
): string {
|
|
289
|
+
const s = data.summary;
|
|
290
|
+
const lines: string[] = [];
|
|
291
|
+
lines.push(`# zond checks report`);
|
|
292
|
+
lines.push("");
|
|
293
|
+
lines.push(
|
|
294
|
+
`**${s.findings} finding(s)** across ${s.cases} case(s) on ${s.operations} operation(s) — ${s.checks_run} check(s) active`,
|
|
295
|
+
);
|
|
296
|
+
// ARV-251: per-category roll-up. Small teams use this to triage —
|
|
297
|
+
// "0 security, 12 reliability" is a clear starting point compared to
|
|
298
|
+
// a flat severity pile.
|
|
299
|
+
if (s.findings > 0) {
|
|
300
|
+
const c = s.by_category;
|
|
301
|
+
lines.push("");
|
|
302
|
+
lines.push(
|
|
303
|
+
`🛡 security: ${c.security} · ⚙ reliability: ${c.reliability} · 📜 contract: ${c.contract} · · hygiene: ${c.hygiene}`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
const skipLine = formatSkippedOutcomes(s.skipped_outcomes);
|
|
307
|
+
if (skipLine) {
|
|
308
|
+
lines.push("");
|
|
309
|
+
lines.push(skipLine);
|
|
310
|
+
}
|
|
311
|
+
if (warnings.length > 0) {
|
|
312
|
+
lines.push("");
|
|
313
|
+
lines.push(`## Warnings`);
|
|
314
|
+
for (const w of warnings) lines.push(`- ${w}`);
|
|
315
|
+
}
|
|
316
|
+
if (data.findings.length > 0) {
|
|
317
|
+
lines.push("");
|
|
318
|
+
lines.push(`## Findings`);
|
|
319
|
+
for (const f of data.findings) {
|
|
320
|
+
const cat = f.category ? ` _${f.category}_` : "";
|
|
321
|
+
lines.push(
|
|
322
|
+
`- **[${f.severity}]**${cat} \`${f.check}\` ${f.operation.method} ${f.operation.path} — ${f.message}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return lines.join("\n") + "\n";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function splitList(values: string[] | undefined): string[] | undefined {
|
|
330
|
+
if (!values || values.length === 0) return undefined;
|
|
331
|
+
return values.flatMap((v) => v.split(",")).map((s) => s.trim()).filter(Boolean);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ARV-211 (R13/F15): expand the `stateful` keyword in --check / --exclude-check
|
|
335
|
+
// into the full set of stateful check ids registered in core/checks/stateful.ts.
|
|
336
|
+
// This lets users (and the zond-checks SKILL.md DEPTH-PASS step) write
|
|
337
|
+
// zond checks run --check stateful
|
|
338
|
+
// instead of hand-listing cross_call_references, idempotency_replay, … —
|
|
339
|
+
// matching the prior `--phase stateful` UX promise without overloading the
|
|
340
|
+
// case-generation `--phase` flag.
|
|
341
|
+
function expandStatefulAlias(ids: string[] | undefined): string[] | undefined {
|
|
342
|
+
if (!ids) return ids;
|
|
343
|
+
const statefulIds = listStatefulChecks().map((c) => c.id);
|
|
344
|
+
const out: string[] = [];
|
|
345
|
+
for (const id of ids) {
|
|
346
|
+
if (id === "stateful") out.push(...statefulIds);
|
|
347
|
+
else out.push(id);
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function resolveBaseUrl(
|
|
353
|
+
apiName: string | undefined,
|
|
354
|
+
baseUrlFlag: string | undefined,
|
|
355
|
+
dbPath: string | undefined,
|
|
356
|
+
): Promise<{ baseUrl: string } | { error: string }> {
|
|
357
|
+
if (typeof baseUrlFlag === "string" && baseUrlFlag.length > 0) {
|
|
358
|
+
return { baseUrl: baseUrlFlag };
|
|
359
|
+
}
|
|
360
|
+
// ARV-53: caller already resolved --api through cli/util/api-context.ts;
|
|
361
|
+
// we only widen here when nothing reached us (allows --base-url to be the
|
|
362
|
+
// sole input). Keep readCurrentApi() inline-free.
|
|
363
|
+
const effectiveApi = apiName;
|
|
364
|
+
if (!effectiveApi) {
|
|
365
|
+
return { error: "Need --base-url <url> (or --api <name> with base_url in apis/<name>/.env.yaml)" };
|
|
366
|
+
}
|
|
367
|
+
const col = resolveApiCollection(effectiveApi, dbPath);
|
|
368
|
+
if ("error" in col) return col;
|
|
369
|
+
if (!col.baseDir) {
|
|
370
|
+
return { error: `API '${effectiveApi}' has no base_dir registered — pass --base-url <url>` };
|
|
371
|
+
}
|
|
372
|
+
const env = await loadEnvironment(undefined, col.baseDir);
|
|
373
|
+
const v = env.base_url;
|
|
374
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
375
|
+
return { error: `base_url not set in ${col.baseDir}/.env.yaml — pass --base-url <url>` };
|
|
376
|
+
}
|
|
377
|
+
return { baseUrl: v };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function checksRunAction(_args: unknown, cmd: Command): Promise<void> {
|
|
381
|
+
const opts = cmd.opts<ChecksRunOptions>();
|
|
382
|
+
const json = opts.json === true || globalJson(cmd);
|
|
383
|
+
|
|
384
|
+
// ARV-118 (m-19): single source of truth for `--report` / `--output` /
|
|
385
|
+
// `--json` resolution. resolveOutput enforces mutual exclusion of
|
|
386
|
+
// `--json` and `--report`, validates the format name against the spec
|
|
387
|
+
// (ARV-97 — no silent acceptance), and computes the channel + path.
|
|
388
|
+
// The legacy `--ndjson` boolean and the inline alias rewrite are gone —
|
|
389
|
+
// `--report ndjson` is now a first-class format key.
|
|
390
|
+
let resolved: ResolvedOutput;
|
|
391
|
+
try {
|
|
392
|
+
resolved = resolveOutput(CHECKS_OUTPUT_SPEC, {
|
|
393
|
+
report: opts.report,
|
|
394
|
+
output: opts.output,
|
|
395
|
+
json,
|
|
396
|
+
});
|
|
397
|
+
} catch (err) {
|
|
398
|
+
if (err instanceof OutputSpecError) {
|
|
399
|
+
if (json) printJson(jsonError("checks run", [err.message]));
|
|
400
|
+
else printError(err.message);
|
|
401
|
+
process.exit(2);
|
|
402
|
+
}
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
405
|
+
const ndjson = resolved.format === "ndjson";
|
|
406
|
+
|
|
407
|
+
// ARV-53: one resolver for --api across all of `checks run`'s sub-lookups
|
|
408
|
+
// (spec, base_url, auth-header derivation).
|
|
409
|
+
const apiName = getApi(cmd, opts as unknown as Record<string, unknown>);
|
|
410
|
+
const specRes = resolveSpecArg(opts.spec, apiName, opts.db);
|
|
411
|
+
if ("error" in specRes) {
|
|
412
|
+
if (json) printJson(jsonError("checks run", [specRes.error]));
|
|
413
|
+
else printError(specRes.error);
|
|
414
|
+
process.exit(2);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const baseRes = await resolveBaseUrl(apiName, opts.baseUrl, opts.db);
|
|
418
|
+
if ("error" in baseRes) {
|
|
419
|
+
if (json) printJson(jsonError("checks run", [baseRes.error]));
|
|
420
|
+
else printError(baseRes.error);
|
|
421
|
+
process.exit(2);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ARV-3: lift auth headers from --auth-header (wins) and/or the
|
|
425
|
+
// resolved --api's .env.yaml (auth_token / api_key conventions).
|
|
426
|
+
const fromEnv = await deriveAuthHeadersFromApi(apiName, opts.db);
|
|
427
|
+
const fromFlags = parseAuthHeaders(opts.authHeader);
|
|
428
|
+
const authHeaders = { ...fromEnv, ...fromFlags };
|
|
429
|
+
|
|
430
|
+
// ARV-141: feed filled fixtures into path-params so the run reacts to
|
|
431
|
+
// fixture-pack growth (otherwise findings/skips are pixel-identical across
|
|
432
|
+
// rounds and CI can't distinguish "spec stable" from "checks ignored deltas").
|
|
433
|
+
const pathVars = await derivePathVarsFromApi(apiName, opts.db);
|
|
434
|
+
const resourceConfigs = await deriveResourceConfigsFromApi(apiName, opts.db);
|
|
435
|
+
|
|
436
|
+
const phaseRaw = typeof opts.phase === "string" ? opts.phase : "examples";
|
|
437
|
+
if (phaseRaw !== "examples" && phaseRaw !== "coverage" && phaseRaw !== "all") {
|
|
438
|
+
// ARV-211: redirect users typing --phase stateful (a common skill drift)
|
|
439
|
+
// to the canonical alias `--check stateful`.
|
|
440
|
+
const hint = phaseRaw === "stateful"
|
|
441
|
+
? " — stateful checks are a separate family; run them with `--check stateful` (or list individual ids)"
|
|
442
|
+
: "";
|
|
443
|
+
const msg = `Unknown --phase: "${phaseRaw}". Available: examples, coverage, all${hint}`;
|
|
444
|
+
if (json) printJson(jsonError("checks run", [msg]));
|
|
445
|
+
else printError(msg);
|
|
446
|
+
process.exit(2);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const modeRaw = typeof opts.mode === "string" ? opts.mode : "all";
|
|
450
|
+
if (modeRaw !== "positive" && modeRaw !== "negative" && modeRaw !== "all") {
|
|
451
|
+
const msg = `Unknown --mode: "${modeRaw}". Available: positive, negative, all`;
|
|
452
|
+
if (json) printJson(jsonError("checks run", [msg]));
|
|
453
|
+
else printError(msg);
|
|
454
|
+
process.exit(2);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ARV-9: parse the unified --include/--exclude filter specs. Bad
|
|
458
|
+
// specs surface as a friendly multi-line error (not a stack trace) and
|
|
459
|
+
// exit 2 — the same code as other CLI-input failures here.
|
|
460
|
+
const compiled = compileOperationFilter({ includes: opts.include, excludes: opts.exclude });
|
|
461
|
+
if (compiled.errors.length > 0) {
|
|
462
|
+
if (json) printJson(jsonError("checks run", compiled.errors));
|
|
463
|
+
else for (const e of compiled.errors) printError(e);
|
|
464
|
+
process.exit(2);
|
|
465
|
+
}
|
|
466
|
+
const operationFilter = (opts.include?.length || opts.exclude?.length) ? compiled.filter : undefined;
|
|
467
|
+
|
|
468
|
+
// ARV-8: --workers <n|auto>. Errors here are CLI-input failures (exit
|
|
469
|
+
// 2) — we don't want a stack trace for a typo.
|
|
470
|
+
let workers: number;
|
|
471
|
+
try {
|
|
472
|
+
workers = parseWorkers(opts.workers);
|
|
473
|
+
} catch (err) {
|
|
474
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
475
|
+
if (json) printJson(jsonError("checks run", [msg]));
|
|
476
|
+
else printError(msg);
|
|
477
|
+
process.exit(2);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ARV-8: --rate-limit <rps|auto>. `auto` = adaptive (reacts to
|
|
481
|
+
// RateLimit-* response headers); numeric = fixed RPS budget.
|
|
482
|
+
let rateLimiter: RateLimiter | undefined;
|
|
483
|
+
if (typeof opts.rateLimit === "string" && opts.rateLimit.length > 0) {
|
|
484
|
+
const v = opts.rateLimit.trim().toLowerCase();
|
|
485
|
+
if (v === "auto") {
|
|
486
|
+
rateLimiter = createAdaptiveRateLimiter();
|
|
487
|
+
} else {
|
|
488
|
+
const rps = Number.parseFloat(v);
|
|
489
|
+
if (!Number.isFinite(rps) || rps <= 0) {
|
|
490
|
+
const msg = `Invalid --rate-limit value: "${opts.rateLimit}" (expected positive number or "auto")`;
|
|
491
|
+
if (json) printJson(jsonError("checks run", [msg]));
|
|
492
|
+
else printError(msg);
|
|
493
|
+
process.exit(2);
|
|
494
|
+
}
|
|
495
|
+
rateLimiter = createRateLimiter(rps);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ARV-97 (F2 / m-19): when `ndjson` lands in the file channel, open a
|
|
500
|
+
// write fd up front and pipe events into it; otherwise emit on stdout.
|
|
501
|
+
// Re-running with the same --output truncates (mirrors SARIF) so a
|
|
502
|
+
// stale artifact can't leak across runs.
|
|
503
|
+
const ndjsonOutputPath: string | undefined = ndjson && resolved.channel === "file" ? resolved.path : undefined;
|
|
504
|
+
let ndjsonFd: number | undefined;
|
|
505
|
+
let ndjsonEventCount = 0;
|
|
506
|
+
if (ndjsonOutputPath) {
|
|
507
|
+
ndjsonFd = openSync(ndjsonOutputPath, "w");
|
|
508
|
+
}
|
|
509
|
+
const ndjsonOnEvent = ndjson
|
|
510
|
+
? (ndjsonFd !== undefined
|
|
511
|
+
? (ev: import("../../core/reporter/ndjson.ts").NdjsonEvent) => {
|
|
512
|
+
ndjsonEventCount += 1;
|
|
513
|
+
writeSync(ndjsonFd!, `${JSON.stringify(ev)}\n`);
|
|
514
|
+
}
|
|
515
|
+
: emitToStdout)
|
|
516
|
+
: undefined;
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
const result = await runChecks({
|
|
520
|
+
specPath: specRes.spec,
|
|
521
|
+
baseUrl: baseRes.baseUrl,
|
|
522
|
+
include: expandStatefulAlias(splitList(opts.check)),
|
|
523
|
+
exclude: expandStatefulAlias(splitList(opts.excludeCheck)),
|
|
524
|
+
timeoutMs: typeof opts.timeout === "number" ? opts.timeout : undefined,
|
|
525
|
+
authHeaders: Object.keys(authHeaders).length > 0 ? authHeaders : undefined,
|
|
526
|
+
pathVars: Object.keys(pathVars).length > 0 ? pathVars : undefined,
|
|
527
|
+
resourceConfigs,
|
|
528
|
+
bootstrapCleanupFailed: opts.bootstrapCleanupFailed === true,
|
|
529
|
+
phase: phaseRaw as "examples" | "coverage" | "all",
|
|
530
|
+
allowX00: opts.allowX00 === true,
|
|
531
|
+
strict405: opts.strict405 === true,
|
|
532
|
+
strict401: opts.strict401 === true,
|
|
533
|
+
mode: modeRaw as "positive" | "negative" | "all",
|
|
534
|
+
operationFilter,
|
|
535
|
+
onEvent: ndjsonOnEvent,
|
|
536
|
+
// ARV-8: bounded concurrency at op-level + optional rate-limiter
|
|
537
|
+
// gating. workers=1 (default) preserves the pre-ARV-8 sequential
|
|
538
|
+
// path inside runPool — same observable behaviour.
|
|
539
|
+
workers,
|
|
540
|
+
rateLimiter,
|
|
541
|
+
maxRequests: typeof opts.maxRequests === "number" && opts.maxRequests > 0 ? opts.maxRequests : undefined,
|
|
542
|
+
});
|
|
543
|
+
const warnings: string[] = [];
|
|
544
|
+
for (const id of result.selection.unknown) {
|
|
545
|
+
warnings.push(`Unknown check: "${id}" — ignored. Run \`zond checks list\` to see registered ids.`);
|
|
546
|
+
}
|
|
547
|
+
// ARV-5 / ARV-118: SARIF v2.1.0 sidecar — file channel always (default
|
|
548
|
+
// filename `zond-checks.sarif` is set by the OutputSpec). Written before
|
|
549
|
+
// any text output so a partial-write failure surfaces before the success
|
|
550
|
+
// line and the exit code.
|
|
551
|
+
if (resolved.format === "sarif") {
|
|
552
|
+
const out = resolved.path!;
|
|
553
|
+
const absSpec = resolvePath(specRes.spec);
|
|
554
|
+
const specContent = readFileSync(absSpec, "utf8");
|
|
555
|
+
// Make spec uri repo-relative when possible — GitHub Code Scanning
|
|
556
|
+
// links findings to a file in the repo, absolute paths break that.
|
|
557
|
+
const specUri = relativePath(process.cwd(), absSpec) || "spec.json";
|
|
558
|
+
const sarif = generateSarifReport({
|
|
559
|
+
findings: result.data.findings,
|
|
560
|
+
specContent,
|
|
561
|
+
specUri,
|
|
562
|
+
toolVersion: VERSION,
|
|
563
|
+
});
|
|
564
|
+
writeFileSync(out, JSON.stringify(sarif, null, 2));
|
|
565
|
+
console.error(`SARIF report written to ${out}`);
|
|
566
|
+
for (const w of warnings) console.error(w);
|
|
567
|
+
} else if (resolved.format === "markdown") {
|
|
568
|
+
const body = renderMarkdownReport(result.data, warnings);
|
|
569
|
+
if (resolved.channel === "file") {
|
|
570
|
+
writeFileSync(resolved.path!, body);
|
|
571
|
+
console.error(`Markdown report written to ${resolved.path}`);
|
|
572
|
+
} else {
|
|
573
|
+
process.stdout.write(body);
|
|
574
|
+
}
|
|
575
|
+
} else if (resolved.format === "json") {
|
|
576
|
+
printJson(jsonOk("checks run", result.data, warnings.length > 0 ? warnings : undefined));
|
|
577
|
+
} else if (ndjson) {
|
|
578
|
+
// ARV-10: stdout already carries the NDJSON stream (events were
|
|
579
|
+
// flushed inside runChecks via onEvent). Warnings ride on stderr
|
|
580
|
+
// so a `| jq` consumer never sees them; the human one-liner is
|
|
581
|
+
// also routed to stderr to keep stdout discipline (AC #5).
|
|
582
|
+
// ARV-97: when events were redirected to a file via --output, the
|
|
583
|
+
// stdout-discipline rationale doesn't apply, but routing the summary
|
|
584
|
+
// to stderr keeps the contract uniform across the two ndjson modes.
|
|
585
|
+
for (const w of warnings) console.error(w);
|
|
586
|
+
const s = result.data.summary;
|
|
587
|
+
console.error(
|
|
588
|
+
`${s.findings} finding(s) across ${s.cases} case(s) on ${s.operations} operation(s) — ${s.checks_run} check(s) active`,
|
|
589
|
+
);
|
|
590
|
+
const skipLine = formatSkippedOutcomes(s.skipped_outcomes);
|
|
591
|
+
if (skipLine) console.error(skipLine);
|
|
592
|
+
if (ndjsonOutputPath) {
|
|
593
|
+
// Mirror the SARIF branch's "written to" line. Use process.stderr
|
|
594
|
+
// directly (not console.error) so test harnesses that mock the
|
|
595
|
+
// streams without intercepting console pick this up.
|
|
596
|
+
process.stderr.write(`NDJSON report written to ${ndjsonOutputPath} (${ndjsonEventCount} events)\n`);
|
|
597
|
+
}
|
|
598
|
+
} else {
|
|
599
|
+
for (const w of warnings) console.error(w);
|
|
600
|
+
const s = result.data.summary;
|
|
601
|
+
printSuccess(
|
|
602
|
+
`${s.findings} finding(s) across ${s.cases} case(s) on ${s.operations} operation(s) — ${s.checks_run} check(s) active`,
|
|
603
|
+
);
|
|
604
|
+
// ARV-251: per-category roll-up. Surfaces "0 security, 12
|
|
605
|
+
// reliability" so a triager sees where the volume sits before
|
|
606
|
+
// scrolling the finding list.
|
|
607
|
+
if (s.findings > 0) {
|
|
608
|
+
const c = s.by_category;
|
|
609
|
+
console.log(
|
|
610
|
+
` 🛡 security: ${c.security} ⚙ reliability: ${c.reliability} 📜 contract: ${c.contract} · hygiene: ${c.hygiene}`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
const skipLine = formatSkippedOutcomes(s.skipped_outcomes);
|
|
614
|
+
if (skipLine) console.log(` ${skipLine}`);
|
|
615
|
+
// ARV-18: aggregate identical findings (same check + same response
|
|
616
|
+
// status + same severity) so a 30-operation 401-not-in-spec sweep
|
|
617
|
+
// collapses to one row instead of drowning out single-shot findings.
|
|
618
|
+
// Per-operation detail is restored under --verbose; the JSON envelope
|
|
619
|
+
// and SARIF sidecar always carry the full unaggregated list.
|
|
620
|
+
if (opts.verbose) {
|
|
621
|
+
for (const f of result.data.findings) {
|
|
622
|
+
console.log(` [${f.severity}] ${f.check} ${f.operation.method} ${f.operation.path} — ${f.message}`);
|
|
623
|
+
}
|
|
624
|
+
} else {
|
|
625
|
+
const groups = new Map<string, { severity: string; check: string; status: number; ops: Set<string>; sample: string }>();
|
|
626
|
+
for (const f of result.data.findings) {
|
|
627
|
+
const status = f.response_summary?.status ?? 0;
|
|
628
|
+
const key = `${f.severity}|${f.check}|${status}`;
|
|
629
|
+
const opKey = `${f.operation.method} ${f.operation.path}`;
|
|
630
|
+
let g = groups.get(key);
|
|
631
|
+
if (!g) {
|
|
632
|
+
g = { severity: f.severity, check: f.check, status, ops: new Set(), sample: f.message };
|
|
633
|
+
groups.set(key, g);
|
|
634
|
+
}
|
|
635
|
+
g.ops.add(opKey);
|
|
636
|
+
}
|
|
637
|
+
for (const g of groups.values()) {
|
|
638
|
+
if (g.ops.size <= 1) {
|
|
639
|
+
const op = [...g.ops][0] ?? "(unknown op)";
|
|
640
|
+
console.log(` [${g.severity}] ${g.check} ${op} — ${g.sample}`);
|
|
641
|
+
} else {
|
|
642
|
+
const stem = g.status > 0
|
|
643
|
+
? `Status ${g.status} not declared / unexpected`
|
|
644
|
+
: g.sample.replace(/ for [A-Z]+ .+$/, "");
|
|
645
|
+
console.log(` [${g.severity}] ${g.check} — ${stem} (${g.ops.size} operation${g.ops.size === 1 ? "" : "s"} affected; --verbose for per-op detail)`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// Exit-code rule: 0 when no HIGH/CRITICAL findings, 1 otherwise. LOW/MEDIUM
|
|
651
|
+
// findings are reported but don't gate CI by default — agents that want
|
|
652
|
+
// strict gating can post-process the JSON envelope.
|
|
653
|
+
if (ndjsonFd !== undefined) closeSync(ndjsonFd);
|
|
654
|
+
process.exit(result.high_or_critical > 0 ? 1 : 0);
|
|
655
|
+
} catch (err) {
|
|
656
|
+
if (ndjsonFd !== undefined) {
|
|
657
|
+
try { closeSync(ndjsonFd); } catch { /* fd may already be invalid */ }
|
|
658
|
+
}
|
|
659
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
660
|
+
if (json) printJson(jsonError("checks run", [msg]));
|
|
661
|
+
else printError(msg);
|
|
662
|
+
process.exit(2);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function defineList(parent: Command): void {
|
|
667
|
+
parent
|
|
668
|
+
.command("list")
|
|
669
|
+
.description("List all registered checks (id, severity, default expected, references)")
|
|
670
|
+
.action(checksListAction);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function defineRun(parent: Command): void {
|
|
674
|
+
parent
|
|
675
|
+
.command("run")
|
|
676
|
+
.description("Run active checks against a live API and emit findings")
|
|
677
|
+
.option("--api <name>", "Use the registered API's spec + .env.yaml")
|
|
678
|
+
.option("--spec <path>", "Explicit OpenAPI spec path (overrides --api)")
|
|
679
|
+
.option("--base-url <url>", "Base URL for requests (overrides --api env file)")
|
|
680
|
+
.option("--check <ids...>", "Only run these checks (comma-separated or repeated)")
|
|
681
|
+
.option("--exclude-check <ids...>", "Skip these checks (comma-separated or repeated)")
|
|
682
|
+
.option("--timeout <ms>", "Per-request timeout in ms", (v) => Number.parseInt(v, 10))
|
|
683
|
+
.option("--db <path>", "SQLite path (for --api lookup)")
|
|
684
|
+
.option(
|
|
685
|
+
"--auth-header <header...>",
|
|
686
|
+
"ARV-3: feed real-auth headers into stateful security checks. Format: 'Name: value'. Repeat for multiple headers. Auto-derived from apis/<name>/.env.yaml (auth_token, api_key) when --api is set.",
|
|
687
|
+
)
|
|
688
|
+
.option(
|
|
689
|
+
"--bootstrap-cleanup-failed",
|
|
690
|
+
"ARV-3: signal that bootstrap-cleanup failed before this run. Stateful security checks (ignored_auth, use_after_free, ensure_resource_availability) skip with a warning to avoid false positives on stale data.",
|
|
691
|
+
)
|
|
692
|
+
.option(
|
|
693
|
+
"--report <format>",
|
|
694
|
+
"ARV-118: output format. Available: console (default — human summary), json (envelope; equivalent to --json), ndjson (stream events on stdout — check_start | check_result | finding | summary), sarif (SARIF v2.1.0 for GitHub Code Scanning), markdown (short summary).",
|
|
695
|
+
)
|
|
696
|
+
.option(
|
|
697
|
+
"--output <path>",
|
|
698
|
+
"ARV-118: write the report to this file. With --report sarif, defaults to zond-checks.sarif when omitted. With --report ndjson, redirects the event stream from stdout into the file (one JSON event per line).",
|
|
699
|
+
)
|
|
700
|
+
.option(
|
|
701
|
+
"--phase <phase>",
|
|
702
|
+
"ARV-6: which case-generation phase to run. examples (default — one positive + single-site negative mutation), coverage (deterministic boundary-value enumeration), all (both).",
|
|
703
|
+
"examples",
|
|
704
|
+
)
|
|
705
|
+
.option(
|
|
706
|
+
"--allow-x00",
|
|
707
|
+
"ARV-6: include the NUL byte (\\x00) in string boundaries during coverage phase. Off by default — some HTTP/JSON stacks panic on it.",
|
|
708
|
+
)
|
|
709
|
+
.option(
|
|
710
|
+
"--mode <mode>",
|
|
711
|
+
"ARV-7: positive (contract verification only — drops checks/cases that send malicious input), negative (only malicious-input probes), all (default — both).",
|
|
712
|
+
"all",
|
|
713
|
+
)
|
|
714
|
+
.option(
|
|
715
|
+
"--include <spec...>",
|
|
716
|
+
"ARV-9: keep only operations matching <selector>:<value>. Selectors: path:<regex>, method:<csv>, tag:<csv>, operation-id:<regex>. Repeat the flag for OR semantics.",
|
|
717
|
+
)
|
|
718
|
+
.option(
|
|
719
|
+
"--exclude <spec...>",
|
|
720
|
+
"ARV-9: drop operations matching <selector>:<value>. Same grammar as --include. Excludes evaluated after includes.",
|
|
721
|
+
)
|
|
722
|
+
.option(
|
|
723
|
+
"--workers <n>",
|
|
724
|
+
"ARV-8: bounded concurrency at the operation level. <n> = positive integer (clamped 1..64) or `auto` (= min(cpus, 8)). Default 1 (sequential, byte-for-byte the pre-ARV-8 behaviour). Cases inside one operation always run sequentially regardless — only ops are parallelized.",
|
|
725
|
+
)
|
|
726
|
+
.option(
|
|
727
|
+
"--rate-limit <rps>",
|
|
728
|
+
"ARV-8: cap outbound RPS — positive number (fixed budget) or `auto` (adaptive — paces from RateLimit-* response headers, RFC 9568). Combined with --workers, the limiter gates *all* workers globally so N workers never exceed <rps>.",
|
|
729
|
+
)
|
|
730
|
+
.option(
|
|
731
|
+
"--verbose",
|
|
732
|
+
"ARV-18: emit one stdout row per finding instead of aggregating identical findings (same check + same response status). JSON / NDJSON / SARIF outputs always carry the unaggregated list; this flag only controls the human summary.",
|
|
733
|
+
)
|
|
734
|
+
.option(
|
|
735
|
+
"--strict-405",
|
|
736
|
+
"ARV-179: require exactly 405 for `unsupported_method` (mirrors schemathesis V4 default). Off by default — zond's pragmatic policy also accepts 401/403/404 as valid rejections of an undeclared method.",
|
|
737
|
+
)
|
|
738
|
+
.option(
|
|
739
|
+
"--strict-401",
|
|
740
|
+
"ARV-181: require exactly 401 for `ignored_auth` no-auth / bogus-auth probes (mirrors schemathesis V4). Off by default — zond's pragmatic policy accepts any 4xx as a valid auth-reject.",
|
|
741
|
+
)
|
|
742
|
+
.option(
|
|
743
|
+
"--max-requests <n>",
|
|
744
|
+
"ARV-227: hard cap on outbound HTTP requests for the whole run (per-response + stateful share the same budget). Once reached, remaining cases short-circuit with `max-requests-cap-reached` in summary.skipped_outcomes. Use to keep coverage runs against large specs (github, kubernetes) bounded.",
|
|
745
|
+
(v) => Number.parseInt(v, 10),
|
|
746
|
+
)
|
|
747
|
+
.action(checksRunAction);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
export function registerChecks(program: Command): void {
|
|
751
|
+
const cmd = program
|
|
752
|
+
.command("checks")
|
|
753
|
+
.description("Run schemathesis-style conformance/security checks against an API");
|
|
754
|
+
defineList(cmd);
|
|
755
|
+
defineRun(cmd);
|
|
756
|
+
}
|