@kirrosh/zond 0.22.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 +811 -0
- package/README.md +59 -6
- package/package.json +9 -7
- package/src/CLAUDE.md +112 -0
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +146 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +880 -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 +64 -0
- package/src/cli/commands/api/annotate/prompts.ts +220 -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 +786 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +361 -0
- package/src/cli/commands/checks.ts +1072 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +236 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +823 -132
- package/src/cli/commands/db.ts +486 -12
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1356 -0
- package/src/cli/commands/doctor.ts +661 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +438 -47
- package/src/cli/commands/init/bootstrap.ts +34 -2
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +99 -3
- package/src/cli/commands/init/templates/agents.md +77 -64
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
- package/src/cli/commands/init/templates/skills/zond.md +802 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +97 -0
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +594 -0
- package/src/cli/commands/probe/security.ts +537 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +163 -0
- package/src/cli/commands/probe.ts +535 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +227 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +310 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +495 -4
- package/src/cli/commands/run.ts +870 -53
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- 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 +92 -3
- package/src/cli/json-schemas.ts +314 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +199 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +114 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -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 +147 -0
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +242 -0
- package/src/core/checks/checks/ignored_auth.ts +254 -0
- package/src/core/checks/checks/index.ts +68 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +148 -0
- package/src/core/checks/checks/not_a_server_error.ts +35 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
- package/src/core/checks/checks/pagination_invariants.ts +419 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +33 -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 +132 -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 +82 -0
- package/src/core/checks/recommended-action.ts +68 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +1461 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +305 -0
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +251 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +216 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +293 -59
- package/src/core/diagnostics/failure-class.ts +140 -0
- package/src/core/diagnostics/failure-hints.ts +88 -89
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +155 -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 +38 -19
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/data-factory.ts +586 -22
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +332 -0
- package/src/core/generator/index.ts +5 -5
- package/src/core/generator/openapi-reader.ts +135 -7
- package/src/core/generator/path-param-disambig.ts +140 -0
- package/src/core/generator/resources-builder.ts +898 -0
- package/src/core/generator/schema-utils.ts +33 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +583 -122
- package/src/core/generator/types.ts +14 -0
- package/src/core/identity/identity-file.ts +0 -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 +73 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +91 -0
- package/src/core/output/types.ts +122 -0
- package/src/core/parser/dynamic-values.ts +160 -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 +61 -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-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +27 -0
- package/src/core/probe/mass-assignment-template.ts +240 -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 +439 -0
- package/src/core/probe/probe-harness.ts +119 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- 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 +207 -0
- package/src/core/probe/security-probe.ts +32 -0
- package/src/core/probe/shared.ts +531 -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 +282 -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 +59 -2
- 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 +265 -36
- package/src/core/runner/form-encode.ts +41 -0
- package/src/core/runner/http-client.ts +112 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +153 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +87 -33
- package/src/core/runner/run-kind.ts +45 -0
- package/src/core/runner/schema-validator.ts +308 -0
- package/src/core/runner/send-request.ts +158 -20
- package/src/core/runner/types.ts +44 -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 +457 -20
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +58 -0
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/layers.ts +154 -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/format-eta.ts +21 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +128 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -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 +216 -0
- package/src/db/queries/runs.ts +289 -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 +75 -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,786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zond audit --api X` — macro-команда для полного pipeline (TASK-262).
|
|
3
|
+
*
|
|
4
|
+
* Оборачивает 8-10 ручных шагов (prepare-fixtures → generate → probes
|
|
5
|
+
* → session-wrapped run → coverage → HTML report) в одну команду:
|
|
6
|
+
*
|
|
7
|
+
* 1. `prepare-fixtures --apply` — single-pass, заполняет `.env.yaml`
|
|
8
|
+
* FK-идентификаторами, что нашлись детерминированно.
|
|
9
|
+
* 2. `generate` — пропускается если `apis/<name>/tests/` свежее, чем
|
|
10
|
+
* `spec.json` (mtime-эвристика; `--force` отключает skip).
|
|
11
|
+
* 3. `probe static` (validation+methods, всегда). `mass-assignment` и
|
|
12
|
+
* `security` — за `--with-mass-assignment` / `--with-security`.
|
|
13
|
+
* 4. `session start` → `run apis/<name>/tests` + `run apis/<name>/probes`
|
|
14
|
+
* → `session end`. Все runs наследуют один session_id.
|
|
15
|
+
* 5. `coverage --api X --union session --json` для embed'a в репорт.
|
|
16
|
+
* 6. Запись `audit-report.html` (или `--out`) с таблицей stages,
|
|
17
|
+
* coverage-сводкой и подсказками для drill-down.
|
|
18
|
+
*
|
|
19
|
+
* Каждая stage спавнится как отдельный subprocess `zond ...`. Failure
|
|
20
|
+
* любой stage НЕ останавливает pipeline — финальный exit 1 если хоть одна
|
|
21
|
+
* упала, 0 если все ok. `--dry-run` печатает план без выполнения.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, statSync } from "node:fs";
|
|
25
|
+
import { writeFile } from "node:fs/promises";
|
|
26
|
+
import { join, resolve } from "node:path";
|
|
27
|
+
import type { Command } from "commander";
|
|
28
|
+
import { globalJson } from "../resolve.ts";
|
|
29
|
+
import { getDb } from "../../db/schema.ts";
|
|
30
|
+
import { findCollectionByNameOrId, listSessions, listRunsBySession } from "../../db/queries.ts";
|
|
31
|
+
import { resolveCollectionSpec } from "../../core/setup-api.ts";
|
|
32
|
+
import { printSuccess, printWarning, printError } from "../output.ts";
|
|
33
|
+
import { getApi, MISSING_API_MESSAGE } from "../util/api-context.ts";
|
|
34
|
+
import { jsonOk, printJson } from "../json-envelope.ts";
|
|
35
|
+
import { VERSION } from "../version.ts";
|
|
36
|
+
import { diagnoseRun, type DiagnoseResult } from "../../core/diagnostics/db-analysis.ts";
|
|
37
|
+
import { readCurrentSession } from "../../core/context/session.ts";
|
|
38
|
+
import { resolveBudget, isBudget, BUDGETS, type Budget } from "../../core/checks/budget.ts";
|
|
39
|
+
|
|
40
|
+
interface Stage {
|
|
41
|
+
key: string;
|
|
42
|
+
name: string;
|
|
43
|
+
args: string[];
|
|
44
|
+
/** If returns string, stage is skipped with that reason. */
|
|
45
|
+
skip?: () => string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface StageResult {
|
|
49
|
+
key: string;
|
|
50
|
+
name: string;
|
|
51
|
+
status: "ok" | "failed" | "skipped";
|
|
52
|
+
exit_code: number | null;
|
|
53
|
+
duration_ms: number;
|
|
54
|
+
reason?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AuditOptions {
|
|
58
|
+
api: string;
|
|
59
|
+
dbPath?: string;
|
|
60
|
+
withMassAssignment?: boolean;
|
|
61
|
+
withSecurity?: boolean;
|
|
62
|
+
out?: string;
|
|
63
|
+
dryRun?: boolean;
|
|
64
|
+
force?: boolean;
|
|
65
|
+
json?: boolean;
|
|
66
|
+
/** ARV-264: opt-in to the full pipeline against a real-traffic API.
|
|
67
|
+
* When false (default), audit runs in safe mode — no mass-assignment /
|
|
68
|
+
* security probes, no destructive probes. */
|
|
69
|
+
live?: boolean;
|
|
70
|
+
/** ARV-292: adaptive cap and stateful gating tier. Translates to
|
|
71
|
+
* `--max-requests N` on every spawned `run` stage. */
|
|
72
|
+
budget?: Budget;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the prefix for self-spawning `zond ...`. When the binary is
|
|
77
|
+
* compiled, `process.execPath` IS the zond binary. In dev, `bun` runs the
|
|
78
|
+
* script directly — fall back to `[bun, src/cli/index.ts]`.
|
|
79
|
+
*/
|
|
80
|
+
function zondInvoker(): string[] {
|
|
81
|
+
const exec = process.execPath;
|
|
82
|
+
const base = exec.replace(/\\/g, "/");
|
|
83
|
+
if (base.endsWith("/zond") || base.endsWith("/zond.exe")) return [exec];
|
|
84
|
+
const script = process.argv[1] || "src/cli/index.ts";
|
|
85
|
+
return [exec, script];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildStages(opts: AuditOptions, apiDir: string, specPath: string | null): Stage[] {
|
|
89
|
+
const api = opts.api;
|
|
90
|
+
const stages: Stage[] = [];
|
|
91
|
+
const budgetResolved = resolveBudget(opts.budget, undefined);
|
|
92
|
+
const runMaxRequestsArgs: string[] = budgetResolved.maxRequests !== undefined
|
|
93
|
+
? ["--max-requests", String(budgetResolved.maxRequests)]
|
|
94
|
+
// placeholder to keep the binding shape stable
|
|
95
|
+
: [];
|
|
96
|
+
// ARV-302: probes don't share `zond run`'s --max-requests vocabulary
|
|
97
|
+
// (probes scan endpoint-by-endpoint, not request-by-request). Map the
|
|
98
|
+
// budget tier to a coarse `--max-endpoints` cap so probe stages stay
|
|
99
|
+
// inside the same wall-clock budget. `full` keeps the legacy
|
|
100
|
+
// uncapped behaviour.
|
|
101
|
+
const probeMaxEndpointsByTier: Record<string, number | undefined> = {
|
|
102
|
+
quick: 10,
|
|
103
|
+
standard: 50,
|
|
104
|
+
full: undefined,
|
|
105
|
+
};
|
|
106
|
+
const probeMaxEndpoints = opts.budget ? probeMaxEndpointsByTier[opts.budget] : undefined;
|
|
107
|
+
const probeMaxEndpointsArgs: string[] = probeMaxEndpoints !== undefined
|
|
108
|
+
? ["--max-endpoints", String(probeMaxEndpoints)]
|
|
109
|
+
: [];
|
|
110
|
+
|
|
111
|
+
// ARV-336: prep is always a single-pass `prepare-fixtures --apply`. It
|
|
112
|
+
// fills what discover resolves deterministically and reports the rest as
|
|
113
|
+
// gaps for the agent/user to fill by hand — no autonomous seed/cascade.
|
|
114
|
+
stages.push({
|
|
115
|
+
key: "prepare-fixtures",
|
|
116
|
+
name: "prepare-fixtures (path-FK fixtures)",
|
|
117
|
+
args: ["prepare-fixtures", "--api", api, "--apply"],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
stages.push({
|
|
121
|
+
key: "generate",
|
|
122
|
+
name: "generate (smoke + crud)",
|
|
123
|
+
args: ["generate", "--api", api, "--output", join(apiDir, "tests")],
|
|
124
|
+
skip: () => {
|
|
125
|
+
if (opts.force) return null;
|
|
126
|
+
if (!specPath || !existsSync(specPath)) return null;
|
|
127
|
+
const testsDir = join(apiDir, "tests");
|
|
128
|
+
if (!existsSync(testsDir)) return null;
|
|
129
|
+
try {
|
|
130
|
+
const specMtime = statSync(specPath).mtimeMs;
|
|
131
|
+
const testsMtime = statSync(testsDir).mtimeMs;
|
|
132
|
+
if (testsMtime > specMtime) return "tests/ newer than spec — pass --force to regenerate";
|
|
133
|
+
} catch {
|
|
134
|
+
// ignore — fall through to running generate
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
stages.push({
|
|
141
|
+
key: "probe-static",
|
|
142
|
+
name: "probe static (validation+methods)",
|
|
143
|
+
args: ["probe", "static", "--api", api, "--output", join(apiDir, "probes", "static")],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ARV-264: mass-assignment + security probes send real POST/PUT/DELETE
|
|
147
|
+
// traffic. Gate them behind --live so the default (safe) audit can't
|
|
148
|
+
// pollute a production API.
|
|
149
|
+
const liveProbesEnabled = opts.live === true;
|
|
150
|
+
if (opts.withMassAssignment && liveProbesEnabled) {
|
|
151
|
+
stages.push({
|
|
152
|
+
key: "probe-mass-assignment",
|
|
153
|
+
name: "probe mass-assignment",
|
|
154
|
+
args: [
|
|
155
|
+
"probe", "mass-assignment", "--api", api,
|
|
156
|
+
"--output", join(apiDir, "probes", "mass-assignment-digest.md"),
|
|
157
|
+
"--emit-tests", join(apiDir, "probes", "mass-assignment"),
|
|
158
|
+
"--overwrite",
|
|
159
|
+
...probeMaxEndpointsArgs,
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (opts.withSecurity && liveProbesEnabled) {
|
|
164
|
+
stages.push({
|
|
165
|
+
key: "probe-security",
|
|
166
|
+
name: "probe security (ssrf,crlf,open-redirect)",
|
|
167
|
+
args: [
|
|
168
|
+
"probe", "security", "ssrf,crlf,open-redirect", "--api", api,
|
|
169
|
+
"--output", join(apiDir, "probes", "security-digest.md"),
|
|
170
|
+
"--emit-tests", join(apiDir, "probes", "security"),
|
|
171
|
+
"--overwrite",
|
|
172
|
+
...probeMaxEndpointsArgs,
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ARV-65: if the user already has an active session, REUSE it — don't
|
|
178
|
+
// clobber their .zond/current-session by spawning audit's own. Skipping
|
|
179
|
+
// session-end is the critical half: otherwise audit clears the user's
|
|
180
|
+
// session even when start was a no-op.
|
|
181
|
+
const existingSession = readCurrentSession();
|
|
182
|
+
const sessionLabel = `audit-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}`;
|
|
183
|
+
if (existingSession) {
|
|
184
|
+
const reuseReason = `reusing active session ${existingSession.id}${existingSession.label ? ` (${existingSession.label})` : ""}`;
|
|
185
|
+
stages.push({
|
|
186
|
+
key: "session-start",
|
|
187
|
+
name: "session start (reused)",
|
|
188
|
+
args: ["session", "start", "--label", sessionLabel],
|
|
189
|
+
skip: () => reuseReason,
|
|
190
|
+
});
|
|
191
|
+
stages.push({ key: "run-tests", name: "run tests", args: ["run", join(apiDir, "tests"), "--api", api, ...runMaxRequestsArgs] });
|
|
192
|
+
stages.push({ key: "run-probes", name: "run probes", args: ["run", join(apiDir, "probes"), "--api", api, ...runMaxRequestsArgs] });
|
|
193
|
+
stages.push({
|
|
194
|
+
key: "session-end",
|
|
195
|
+
name: "session end (reused — kept active)",
|
|
196
|
+
args: ["session", "end"],
|
|
197
|
+
skip: () => reuseReason,
|
|
198
|
+
});
|
|
199
|
+
} else {
|
|
200
|
+
stages.push({ key: "session-start", name: `session start (${sessionLabel})`, args: ["session", "start", "--label", sessionLabel] });
|
|
201
|
+
stages.push({ key: "run-tests", name: "run tests", args: ["run", join(apiDir, "tests"), "--api", api, ...runMaxRequestsArgs] });
|
|
202
|
+
stages.push({ key: "run-probes", name: "run probes", args: ["run", join(apiDir, "probes"), "--api", api, ...runMaxRequestsArgs] });
|
|
203
|
+
stages.push({ key: "session-end", name: "session end", args: ["session", "end"] });
|
|
204
|
+
}
|
|
205
|
+
// ARV-108: surface the post-stage coverage capture in the plan so the
|
|
206
|
+
// dry-run listing matches the actual pipeline. The stage is special-cased
|
|
207
|
+
// in auditCommand — we keep stdout for JSON parsing rather than inheriting.
|
|
208
|
+
stages.push({
|
|
209
|
+
key: "coverage",
|
|
210
|
+
name: "coverage (session union)",
|
|
211
|
+
args: ["coverage", "--api", api, "--union", "session", "--json"],
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return stages;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function runStage(stage: Stage, idx: number, total: number, json: boolean): Promise<StageResult> {
|
|
218
|
+
const skipReason = stage.skip?.();
|
|
219
|
+
if (skipReason) {
|
|
220
|
+
if (!json) console.log(`==> Stage ${idx}/${total}: ${stage.name} — skipped (${skipReason})`);
|
|
221
|
+
return { key: stage.key, name: stage.name, status: "skipped", exit_code: null, duration_ms: 0, reason: skipReason };
|
|
222
|
+
}
|
|
223
|
+
if (!json) console.log(`==> Stage ${idx}/${total}: ${stage.name}`);
|
|
224
|
+
const t0 = Date.now();
|
|
225
|
+
const cmd = [...zondInvoker(), ...stage.args];
|
|
226
|
+
const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" });
|
|
227
|
+
const code = await proc.exited;
|
|
228
|
+
const ms = Date.now() - t0;
|
|
229
|
+
const status: StageResult["status"] = code === 0 ? "ok" : "failed";
|
|
230
|
+
// ARV-66: print a per-stage completion line so the user sees OK/FAIL inline
|
|
231
|
+
// next to "Stage N/M" instead of inferring from the final summary. Subprocess
|
|
232
|
+
// stdio is inherited above, so this line lands AFTER the stage's own output.
|
|
233
|
+
if (!json) {
|
|
234
|
+
const tag = status === "ok" ? "OK" : `FAIL (exit ${code})`;
|
|
235
|
+
console.log(` └─ ${tag} · ${(ms / 1000).toFixed(1)}s`);
|
|
236
|
+
}
|
|
237
|
+
return { key: stage.key, name: stage.name, status, exit_code: code, duration_ms: ms };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface CoverageCapture {
|
|
241
|
+
data: unknown | null;
|
|
242
|
+
exitCode: number | null;
|
|
243
|
+
parseError: string | null;
|
|
244
|
+
durationMs: number;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* ARV-301: build the coverage-stage CLI args. Exported so unit tests
|
|
249
|
+
* can assert that audit pins `--session-id <id>` rather than
|
|
250
|
+
* `--union session` whenever a session id was captured ahead of
|
|
251
|
+
* `session end` (the latter selector rejects closed sessions).
|
|
252
|
+
*/
|
|
253
|
+
export function buildCoverageStageArgs(api: string, sessionId?: string): string[] {
|
|
254
|
+
const sel = sessionId ? ["--session-id", sessionId] : ["--union", "session"];
|
|
255
|
+
return ["coverage", "--api", api, ...sel, "--json"];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* ARV-301 follow-up: judge audit's coverage stage on the JSON envelope's
|
|
260
|
+
* `ok` field, not the subprocess exit code.
|
|
261
|
+
*
|
|
262
|
+
* `zond coverage` exits 1 when there are uncovered endpoints (legacy
|
|
263
|
+
* CI-gate behaviour) — that is a normal report state for audit's
|
|
264
|
+
* purposes, the envelope is well-formed and carries valid coverage
|
|
265
|
+
* data. Only exit 2 / non-JSON output / `ok:false` envelopes mean a
|
|
266
|
+
* real coverage failure.
|
|
267
|
+
*
|
|
268
|
+
* Exported pure function so unit tests can lock the contract without
|
|
269
|
+
* spawning a subprocess.
|
|
270
|
+
*/
|
|
271
|
+
export function interpretCoverageOutput(
|
|
272
|
+
stdout: string,
|
|
273
|
+
exitCode: number | null,
|
|
274
|
+
): { data: unknown | null; parseError: string | null } {
|
|
275
|
+
let parsed: unknown = null;
|
|
276
|
+
let parseError: string | null = null;
|
|
277
|
+
try {
|
|
278
|
+
parsed = stdout.trim() ? JSON.parse(stdout) : null;
|
|
279
|
+
} catch (e) {
|
|
280
|
+
parseError = (e as Error).message;
|
|
281
|
+
}
|
|
282
|
+
if (parsed && typeof parsed === "object" && (parsed as { ok?: boolean }).ok === true) {
|
|
283
|
+
// Envelope says everything is fine; treat coverage as captured
|
|
284
|
+
// even if exit was 1 due to "has uncovered endpoints".
|
|
285
|
+
return { data: parsed, parseError: null };
|
|
286
|
+
}
|
|
287
|
+
// ok:false, or no envelope at all — propagate the failure signal.
|
|
288
|
+
// exitCode is intentionally not consulted here; callers attach it
|
|
289
|
+
// to the stage result for the user.
|
|
290
|
+
void exitCode;
|
|
291
|
+
return { data: null, parseError };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function captureCoverage(api: string, sessionId?: string): Promise<CoverageCapture> {
|
|
295
|
+
const t0 = Date.now();
|
|
296
|
+
try {
|
|
297
|
+
const cmd = [...zondInvoker(), ...buildCoverageStageArgs(api, sessionId)];
|
|
298
|
+
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
299
|
+
const stdout = await new Response(proc.stdout).text();
|
|
300
|
+
const code = await proc.exited;
|
|
301
|
+
const ms = Date.now() - t0;
|
|
302
|
+
const { data, parseError } = interpretCoverageOutput(stdout, code);
|
|
303
|
+
return { data, exitCode: code, parseError, durationMs: ms };
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return { data: null, exitCode: null, parseError: (e as Error).message, durationMs: Date.now() - t0 };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function auditCommand(options: AuditOptions): Promise<number> {
|
|
310
|
+
// Like bootstrap: fall back to apis/<name>/ when no DB/collection — the
|
|
311
|
+
// workspace-on-disk shape is enough for the macro to drive subprocesses.
|
|
312
|
+
let apiDir = `apis/${options.api}`;
|
|
313
|
+
let specPath: string | null = null;
|
|
314
|
+
try {
|
|
315
|
+
getDb(options.dbPath);
|
|
316
|
+
const col = findCollectionByNameOrId(options.api);
|
|
317
|
+
if (col) {
|
|
318
|
+
apiDir = col.base_dir ?? apiDir;
|
|
319
|
+
specPath = col.openapi_spec ? resolveCollectionSpec(col.openapi_spec) : null;
|
|
320
|
+
}
|
|
321
|
+
} catch {
|
|
322
|
+
// No DB — keep filesystem-only fallback.
|
|
323
|
+
}
|
|
324
|
+
if (!specPath) {
|
|
325
|
+
const guess = join(apiDir, "spec.json");
|
|
326
|
+
if (existsSync(guess)) specPath = guess;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ARV-264: surface what safe mode silently drops and run pre-flight
|
|
330
|
+
// checks (auth + security schemes) before any subprocess fires.
|
|
331
|
+
const preflightWarnings = runSafePreflight(options, specPath, apiDir);
|
|
332
|
+
if (!options.json) {
|
|
333
|
+
for (const w of preflightWarnings) printWarning(w);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const stages = buildStages(options, apiDir, specPath);
|
|
337
|
+
const out = options.out ?? "audit-report.html";
|
|
338
|
+
|
|
339
|
+
if (options.dryRun) {
|
|
340
|
+
if (options.json) {
|
|
341
|
+
printJson(jsonOk("audit", {
|
|
342
|
+
plan: stages.map((s) => ({ key: s.key, name: s.name, args: s.args })),
|
|
343
|
+
out,
|
|
344
|
+
}));
|
|
345
|
+
} else {
|
|
346
|
+
console.log(`Plan: zond audit --api ${options.api} (${stages.length} stages)`);
|
|
347
|
+
stages.forEach((s, i) => {
|
|
348
|
+
console.log(` ${(i + 1).toString().padStart(2)}. ${s.name}`);
|
|
349
|
+
console.log(` zond ${s.args.join(" ")}`);
|
|
350
|
+
});
|
|
351
|
+
console.log(`\nReport will be written to: ${out}`);
|
|
352
|
+
}
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const t0 = Date.now();
|
|
357
|
+
const results: StageResult[] = [];
|
|
358
|
+
let coverageJson: unknown = null;
|
|
359
|
+
let coverageCapture: CoverageCapture | null = null;
|
|
360
|
+
// ARV-301: capture the active session id BEFORE session-end runs, so
|
|
361
|
+
// coverage can target it explicitly (--session-id) instead of via
|
|
362
|
+
// --union session, which would reject the now-closed session.
|
|
363
|
+
let pinnedSessionId: string | undefined;
|
|
364
|
+
for (let i = 0; i < stages.length; i++) {
|
|
365
|
+
const stage = stages[i]!;
|
|
366
|
+
if (stage.key === "session-end" && !pinnedSessionId) {
|
|
367
|
+
pinnedSessionId = readCurrentSession()?.id;
|
|
368
|
+
}
|
|
369
|
+
if (stage.key === "coverage") {
|
|
370
|
+
// ARV-108: coverage runs via captureCoverage so we keep stdout JSON.
|
|
371
|
+
if (!options.json) console.log(`==> Stage ${i + 1}/${stages.length}: ${stage.name}`);
|
|
372
|
+
coverageCapture = await captureCoverage(options.api, pinnedSessionId);
|
|
373
|
+
coverageJson = coverageCapture.data;
|
|
374
|
+
const status: StageResult["status"] = coverageCapture.data
|
|
375
|
+
? "ok"
|
|
376
|
+
: coverageCapture.exitCode === 0 && coverageCapture.parseError
|
|
377
|
+
? "failed"
|
|
378
|
+
: coverageCapture.exitCode === 0
|
|
379
|
+
? "skipped"
|
|
380
|
+
: "failed";
|
|
381
|
+
results.push({
|
|
382
|
+
key: stage.key,
|
|
383
|
+
name: stage.name,
|
|
384
|
+
status,
|
|
385
|
+
exit_code: coverageCapture.exitCode,
|
|
386
|
+
duration_ms: coverageCapture.durationMs,
|
|
387
|
+
reason: status === "skipped"
|
|
388
|
+
? "no runs in session"
|
|
389
|
+
: coverageCapture.parseError
|
|
390
|
+
? `non-JSON output: ${coverageCapture.parseError}`
|
|
391
|
+
: status === "failed"
|
|
392
|
+
? `coverage exited ${coverageCapture.exitCode}`
|
|
393
|
+
: undefined,
|
|
394
|
+
});
|
|
395
|
+
// ARV-66: per-stage completion line for the coverage special-case too.
|
|
396
|
+
if (!options.json) {
|
|
397
|
+
const tag = status === "ok" ? "OK" : status === "skipped" ? "SKIPPED" : `FAIL (exit ${coverageCapture.exitCode})`;
|
|
398
|
+
console.log(` └─ ${tag} · ${(coverageCapture.durationMs / 1000).toFixed(1)}s`);
|
|
399
|
+
}
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
results.push(await runStage(stage, i + 1, stages.length, options.json === true));
|
|
403
|
+
}
|
|
404
|
+
const totalMs = Date.now() - t0;
|
|
405
|
+
|
|
406
|
+
// ARV-158: collect per-run drill-down from the audit session so the HTML
|
|
407
|
+
// can answer "WHICH 271 findings" instead of just "3 failed stages".
|
|
408
|
+
// The session was started by `session-start` and closed by `session-end`,
|
|
409
|
+
// so it's the most recent session in the DB at this point. listSessions(1)
|
|
410
|
+
// surfaces it; we then diagnose each run with failures > 0 (passed-only
|
|
411
|
+
// runs need no triage).
|
|
412
|
+
const drilldown: Array<{ run: { id: number; failed: number; total: number; passed: number }; diagnose: DiagnoseResult }> = [];
|
|
413
|
+
try {
|
|
414
|
+
const recent = listSessions(1);
|
|
415
|
+
const session = recent[0];
|
|
416
|
+
if (session?.session_id) {
|
|
417
|
+
const runs = listRunsBySession(session.session_id);
|
|
418
|
+
for (const r of runs) {
|
|
419
|
+
if (r.failed <= 0) continue;
|
|
420
|
+
try {
|
|
421
|
+
const diag = diagnoseRun(r.id, false, options.dbPath);
|
|
422
|
+
drilldown.push({
|
|
423
|
+
run: { id: r.id, failed: r.failed, total: r.total, passed: r.passed },
|
|
424
|
+
diagnose: diag,
|
|
425
|
+
});
|
|
426
|
+
} catch {
|
|
427
|
+
// diagnose of a single run failing should not break the report —
|
|
428
|
+
// skip and keep the rest.
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
} catch {
|
|
433
|
+
// DB unreachable / no sessions — leave drilldown empty; HTML degrades
|
|
434
|
+
// to the previous summary-only form.
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
await writeAuditReport(out, {
|
|
438
|
+
api: options.api,
|
|
439
|
+
apiDir,
|
|
440
|
+
stages: results,
|
|
441
|
+
totalMs,
|
|
442
|
+
coverage: coverageJson,
|
|
443
|
+
coverageStage: results.find((r) => r.key === "coverage") ?? null,
|
|
444
|
+
options,
|
|
445
|
+
drilldown,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// ARV-108: coverage is informational — keep it out of the fail count so we
|
|
449
|
+
// don't regress the "non-fatal coverage" contract.
|
|
450
|
+
const failedStages = results.filter((r) => r.status === "failed" && r.key !== "coverage");
|
|
451
|
+
const failed = failedStages.length;
|
|
452
|
+
|
|
453
|
+
if (options.json) {
|
|
454
|
+
printJson(jsonOk("audit", {
|
|
455
|
+
api: options.api,
|
|
456
|
+
stages: results,
|
|
457
|
+
total_ms: totalMs,
|
|
458
|
+
failed_stages: failed,
|
|
459
|
+
report: out,
|
|
460
|
+
coverage: coverageJson,
|
|
461
|
+
}));
|
|
462
|
+
} else {
|
|
463
|
+
console.log("");
|
|
464
|
+
// ARV-80: print absolute path so the user can open the HTML report
|
|
465
|
+
// without traversing — `audit-report.html` (relative) hid the location
|
|
466
|
+
// behind cwd, especially when audit was invoked from a parent dir.
|
|
467
|
+
const summary = `Audit complete (${results.length} stages, ${(totalMs / 1000).toFixed(1)}s) → ${resolve(out)}`;
|
|
468
|
+
if (failed === 0) {
|
|
469
|
+
printSuccess(summary);
|
|
470
|
+
} else {
|
|
471
|
+
printWarning(`${summary} — ${failed} failed: ${failedStages.map((s) => s.key).join(", ")}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return failed === 0 ? 0 : 1;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export interface ReportInput {
|
|
478
|
+
api: string;
|
|
479
|
+
apiDir: string;
|
|
480
|
+
stages: StageResult[];
|
|
481
|
+
totalMs: number;
|
|
482
|
+
coverage: unknown;
|
|
483
|
+
/** ARV-108: outcome of the post-stage coverage capture, so the HTML can
|
|
484
|
+
* distinguish "no session runs" from "coverage subcommand failed". */
|
|
485
|
+
coverageStage: StageResult | null;
|
|
486
|
+
options: AuditOptions;
|
|
487
|
+
/** ARV-158: per-run diagnose envelopes for runs in this audit's session
|
|
488
|
+
* that had failures. Each entry feeds a collapsible drill-down block
|
|
489
|
+
* in the HTML so the report answers "WHICH findings" inline. Empty
|
|
490
|
+
* array when no runs failed or when DB lookup couldn't reach the
|
|
491
|
+
* session. */
|
|
492
|
+
drilldown: Array<{
|
|
493
|
+
run: { id: number; failed: number; total: number; passed: number };
|
|
494
|
+
diagnose: DiagnoseResult;
|
|
495
|
+
}>;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function escapeHtml(s: string): string {
|
|
499
|
+
return s.replace(/[&<>"']/g, (c) => {
|
|
500
|
+
const map: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
501
|
+
return map[c]!;
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
interface CoverageEnvelope {
|
|
506
|
+
data?: {
|
|
507
|
+
totals?: { all?: number; covered2xx?: number; coveredButNon2xx?: number; unhit?: number };
|
|
508
|
+
pass_coverage?: { ratio?: number };
|
|
509
|
+
hit_coverage?: { ratio?: number };
|
|
510
|
+
coveredButNon2xxEndpoints?: Array<{ endpoint?: string }>;
|
|
511
|
+
unhitEndpoints?: Array<{ endpoint?: string }>;
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** ARV-158: exported for regression-test on HTML markup (drill-down
|
|
516
|
+
* sections per failed run). Same signature as before — the export
|
|
517
|
+
* doesn't expose anything CLI-internal. */
|
|
518
|
+
export async function writeAuditReport(outPath: string, data: ReportInput): Promise<void> {
|
|
519
|
+
const cov = data.coverage as CoverageEnvelope | null;
|
|
520
|
+
const totals = cov?.data?.totals;
|
|
521
|
+
const pass = cov?.data?.pass_coverage?.ratio;
|
|
522
|
+
const hit = cov?.data?.hit_coverage?.ratio;
|
|
523
|
+
|
|
524
|
+
const stageRows = data.stages.map((s) => {
|
|
525
|
+
const cls = s.status === "ok" ? "ok" : s.status === "failed" ? "fail" : "skip";
|
|
526
|
+
const ms = s.duration_ms === 0 ? "—" : `${(s.duration_ms / 1000).toFixed(1)}s`;
|
|
527
|
+
return `<tr class="${cls}"><td>${escapeHtml(s.name)}</td><td>${s.status}</td><td>${s.exit_code ?? "—"}</td><td>${ms}</td><td>${escapeHtml(s.reason ?? "")}</td></tr>`;
|
|
528
|
+
}).join("\n");
|
|
529
|
+
|
|
530
|
+
const reruncmd = `zond audit --api ${data.api}`
|
|
531
|
+
+ (data.options.withMassAssignment ? " --with-mass-assignment" : "")
|
|
532
|
+
+ (data.options.withSecurity ? " --with-security" : "");
|
|
533
|
+
|
|
534
|
+
const covStage = data.coverageStage;
|
|
535
|
+
// ARV-108: tailor the warning to what actually happened so the HTML stops
|
|
536
|
+
// misreporting "stage failed" when the stage was skipped (no runs in the
|
|
537
|
+
// session) or simply produced unparseable output.
|
|
538
|
+
const coverageWarning = covStage
|
|
539
|
+
? covStage.status === "skipped"
|
|
540
|
+
? "No session runs to summarise. Add `--with-mass-assignment` / `--with-security`, or run tests/probes that succeed."
|
|
541
|
+
: covStage.reason
|
|
542
|
+
? `Coverage stage ${covStage.status}: ${escapeHtml(covStage.reason)}.`
|
|
543
|
+
: `Coverage stage ${covStage.status} (exit ${covStage.exit_code ?? "?"}).`
|
|
544
|
+
: "Coverage stage was not part of this audit (older binary?).";
|
|
545
|
+
|
|
546
|
+
// ARV-158: per-run drill-down — collapsible sections, one per failed
|
|
547
|
+
// run, surfacing by_recommended_action buckets (count + first example).
|
|
548
|
+
// Concrete commands replace the previous "type <run-id> manually" hint.
|
|
549
|
+
const drilldownBlocks = data.drilldown.length === 0
|
|
550
|
+
? ""
|
|
551
|
+
: `<h2>Failures by run</h2>\n` + data.drilldown.map(({ run, diagnose }) => {
|
|
552
|
+
const buckets = diagnose.by_recommended_action ?? {};
|
|
553
|
+
// Sort buckets by priority (report_backend_bug first, then by count)
|
|
554
|
+
// — matches the zond-triage skill priority order.
|
|
555
|
+
const priorityOrder = [
|
|
556
|
+
"report_backend_bug",
|
|
557
|
+
"fix_spec",
|
|
558
|
+
"fix_auth_config",
|
|
559
|
+
"fix_env",
|
|
560
|
+
"fix_fixture",
|
|
561
|
+
"fix_network_config",
|
|
562
|
+
"regenerate_suite",
|
|
563
|
+
"tighten_validation",
|
|
564
|
+
"add_required_header",
|
|
565
|
+
"fix_test_logic",
|
|
566
|
+
"wontfix_known_limitation",
|
|
567
|
+
];
|
|
568
|
+
const sortedKeys = Object.keys(buckets).sort((a, b) => {
|
|
569
|
+
const ai = priorityOrder.indexOf(a);
|
|
570
|
+
const bi = priorityOrder.indexOf(b);
|
|
571
|
+
if (ai === -1 && bi === -1) return (buckets[b]!.count) - (buckets[a]!.count);
|
|
572
|
+
if (ai === -1) return 1;
|
|
573
|
+
if (bi === -1) return -1;
|
|
574
|
+
return ai - bi;
|
|
575
|
+
});
|
|
576
|
+
const bucketsHtml = sortedKeys.length === 0
|
|
577
|
+
? `<p class="muted">No <code>recommended_action</code> buckets — see raw failures via the commands below.</p>`
|
|
578
|
+
: `<ul class="buckets">` + sortedKeys.map((key) => {
|
|
579
|
+
const b = buckets[key]!;
|
|
580
|
+
const first = b.examples[0];
|
|
581
|
+
const sample = first
|
|
582
|
+
? `<code>${escapeHtml(first.method)} ${escapeHtml(first.path)}</code> → <strong>${first.status}</strong>${first.reason ? ` — ${escapeHtml(first.reason)}` : ""}`
|
|
583
|
+
: "";
|
|
584
|
+
const moreExamples = b.examples.length > 1
|
|
585
|
+
? ` <span class="muted">(+${b.examples.length - 1} more)</span>`
|
|
586
|
+
: "";
|
|
587
|
+
return `<li><strong>${escapeHtml(key)}</strong> ×${b.count}${sample ? ` — ${sample}${moreExamples}` : ""}</li>`;
|
|
588
|
+
}).join("\n") + `</ul>`;
|
|
589
|
+
return `<details>
|
|
590
|
+
<summary>Run #${run.id} — ${run.failed}/${run.total} failed (${run.passed} passed)</summary>
|
|
591
|
+
${bucketsHtml}
|
|
592
|
+
<p class="cmds">
|
|
593
|
+
Drill in: <code>zond db diagnose --run-id ${run.id} --json</code> ·
|
|
594
|
+
<code>zond report export ${run.id}</code>
|
|
595
|
+
</p>
|
|
596
|
+
</details>`;
|
|
597
|
+
}).join("\n");
|
|
598
|
+
|
|
599
|
+
const coverageBlock = totals
|
|
600
|
+
? `<h2>Coverage (session union)</h2>
|
|
601
|
+
<div class="cov">
|
|
602
|
+
<div><div class="num">${totals.covered2xx ?? 0}/${totals.all ?? 0}</div><div class="lbl">covered2xx</div></div>
|
|
603
|
+
<div><div class="num">${totals.coveredButNon2xx ?? 0}</div><div class="lbl">covered but non-2xx</div></div>
|
|
604
|
+
<div><div class="num">${totals.unhit ?? 0}</div><div class="lbl">unhit</div></div>
|
|
605
|
+
${typeof pass === "number" ? `<div><div class="num">${(pass * 100).toFixed(0)}%</div><div class="lbl">pass coverage</div></div>` : ""}
|
|
606
|
+
${typeof hit === "number" ? `<div><div class="num">${(hit * 100).toFixed(0)}%</div><div class="lbl">hit coverage</div></div>` : ""}
|
|
607
|
+
</div>`
|
|
608
|
+
: `<h2>Coverage</h2><div class="warn">${coverageWarning}</div>`;
|
|
609
|
+
|
|
610
|
+
const html = `<!doctype html>
|
|
611
|
+
<html lang="en"><head><meta charset="utf-8"><title>zond audit — ${escapeHtml(data.api)}</title>
|
|
612
|
+
<style>
|
|
613
|
+
body { font: 14px -apple-system, system-ui, sans-serif; max-width: 960px; margin: 2em auto; padding: 0 1em; color: #222; }
|
|
614
|
+
h1 { font-size: 1.4em; margin-bottom: 0.2em; }
|
|
615
|
+
h2 { font-size: 1.05em; margin-top: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
|
616
|
+
.meta { color: #666; font-size: 0.9em; margin-bottom: 1em; }
|
|
617
|
+
table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 0.92em; }
|
|
618
|
+
th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid #eee; }
|
|
619
|
+
th { background: #f7f7f7; }
|
|
620
|
+
tr.ok td:nth-child(2) { color: #0a7; }
|
|
621
|
+
tr.fail td:nth-child(2) { color: #c33; font-weight: 600; }
|
|
622
|
+
tr.skip td:nth-child(2) { color: #888; font-style: italic; }
|
|
623
|
+
.cov { display: flex; gap: 2em; margin: 1em 0; flex-wrap: wrap; }
|
|
624
|
+
.cov .num { font-size: 1.6em; font-weight: 600; }
|
|
625
|
+
.cov .lbl { font-size: 0.8em; color: #666; }
|
|
626
|
+
.warn { background: #fef9e7; padding: 8px 12px; border-left: 3px solid #f0c040; margin: 1em 0; }
|
|
627
|
+
code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: 0.9em; }
|
|
628
|
+
ul { line-height: 1.6; }
|
|
629
|
+
details { margin: 0.8em 0; border: 1px solid #eee; border-radius: 4px; padding: 0.3em 0.8em; }
|
|
630
|
+
details summary { cursor: pointer; font-weight: 600; padding: 0.3em 0; }
|
|
631
|
+
details[open] summary { border-bottom: 1px solid #eee; margin-bottom: 0.6em; }
|
|
632
|
+
ul.buckets { padding-left: 1.2em; margin: 0.4em 0; }
|
|
633
|
+
ul.buckets li { margin: 0.25em 0; }
|
|
634
|
+
p.cmds { font-size: 0.88em; color: #555; margin-top: 0.8em; }
|
|
635
|
+
.muted { color: #888; }
|
|
636
|
+
</style></head>
|
|
637
|
+
<body>
|
|
638
|
+
<h1>zond audit — ${escapeHtml(data.api)}</h1>
|
|
639
|
+
<div class="meta">
|
|
640
|
+
zond ${escapeHtml(VERSION)} · ${new Date().toISOString()} · total ${(data.totalMs / 1000).toFixed(1)}s · apiDir <code>${escapeHtml(data.apiDir)}</code>
|
|
641
|
+
</div>
|
|
642
|
+
|
|
643
|
+
<h2>Stages</h2>
|
|
644
|
+
<table><thead><tr><th>Stage</th><th>Status</th><th>Exit</th><th>Duration</th><th>Note</th></tr></thead><tbody>
|
|
645
|
+
${stageRows}
|
|
646
|
+
</tbody></table>
|
|
647
|
+
|
|
648
|
+
${coverageBlock}
|
|
649
|
+
|
|
650
|
+
${drilldownBlocks}
|
|
651
|
+
|
|
652
|
+
<h2>Drill-down</h2>
|
|
653
|
+
<ul>
|
|
654
|
+
<li>Re-run audit: <code>${escapeHtml(reruncmd)}</code></li>
|
|
655
|
+
<li>List recent runs: <code>zond db runs --limit 20</code></li>
|
|
656
|
+
<li>Per-run report: <code>zond report export <run-id></code></li>
|
|
657
|
+
</ul>
|
|
658
|
+
</body></html>`;
|
|
659
|
+
|
|
660
|
+
await writeFile(outPath, html, "utf-8");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* ARV-264: pre-flight checks for safe-mode auditing.
|
|
665
|
+
*
|
|
666
|
+
* 1. Warn when the user passed `--seed` / `--with-mass-assignment` /
|
|
667
|
+
* `--with-security` without `--live` — those stages are silently
|
|
668
|
+
* dropped in safe mode and the user deserves to know.
|
|
669
|
+
* 2. Warn when the spec declares `components.securitySchemes` but the
|
|
670
|
+
* workspace has no `auth_token` set — every probe will skip with
|
|
671
|
+
* 401 and the report will read as "all green" misleadingly.
|
|
672
|
+
* 3. Warn when no security schemes are declared at all (open API or
|
|
673
|
+
* incomplete spec) — same misleading-green risk on auth gates.
|
|
674
|
+
*/
|
|
675
|
+
export function runSafePreflight(
|
|
676
|
+
opts: AuditOptions,
|
|
677
|
+
specPath: string | null,
|
|
678
|
+
apiDir: string,
|
|
679
|
+
): string[] {
|
|
680
|
+
const out: string[] = [];
|
|
681
|
+
const safe = opts.live !== true;
|
|
682
|
+
|
|
683
|
+
if (safe) {
|
|
684
|
+
if (opts.withMassAssignment) {
|
|
685
|
+
out.push(
|
|
686
|
+
"audit --safe (default): --with-mass-assignment ignored. Mass-assignment probes POST mutated " +
|
|
687
|
+
"bodies and follow up with GET/DELETE — high blast radius. Re-run with --live to enable.",
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
if (opts.withSecurity) {
|
|
691
|
+
out.push(
|
|
692
|
+
"audit --safe (default): --with-security ignored. Security probes send live SSRF/CRLF/redirect " +
|
|
693
|
+
"payloads to real endpoints. Re-run with --live to enable.",
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Spec-level checks (cheap synchronous read; spec.json is JSON).
|
|
699
|
+
if (specPath && existsSync(specPath)) {
|
|
700
|
+
try {
|
|
701
|
+
const spec = JSON.parse(require("node:fs").readFileSync(specPath, "utf-8")) as {
|
|
702
|
+
components?: { securitySchemes?: Record<string, unknown> };
|
|
703
|
+
};
|
|
704
|
+
const hasSchemes = Boolean(
|
|
705
|
+
spec.components?.securitySchemes && Object.keys(spec.components.securitySchemes).length > 0,
|
|
706
|
+
);
|
|
707
|
+
const envPath = join(apiDir, ".env.yaml");
|
|
708
|
+
let authTokenSet = false;
|
|
709
|
+
if (existsSync(envPath)) {
|
|
710
|
+
const env = require("node:fs").readFileSync(envPath, "utf-8") as string;
|
|
711
|
+
// Match `auth_token: ...` lines whose value isn't an unfilled placeholder.
|
|
712
|
+
authTokenSet = /^\s*auth_token\s*:\s*(?!["']?\s*(<|UNSET|TODO|REPLACE|null|~)\b)\S/m.test(env);
|
|
713
|
+
}
|
|
714
|
+
if (hasSchemes && !authTokenSet) {
|
|
715
|
+
out.push(
|
|
716
|
+
"spec declares securitySchemes but auth_token is unset in .env.yaml — probes will likely hit " +
|
|
717
|
+
"401/403 and the report can read misleadingly green. Run `zond doctor --api " + opts.api + "` to fix.",
|
|
718
|
+
);
|
|
719
|
+
} else if (!hasSchemes) {
|
|
720
|
+
out.push(
|
|
721
|
+
"spec declares no securitySchemes — either the API is open or the spec is incomplete. " +
|
|
722
|
+
"Auth-related checks (ignored_auth, open_cors_on_sensitive) will skip; verify before sharing the report.",
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
} catch {
|
|
726
|
+
// Spec unreadable — separate failure mode, surfaced by other stages.
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export function registerAudit(program: Command): void {
|
|
734
|
+
program
|
|
735
|
+
.command("audit")
|
|
736
|
+
.description("Smoke + breadth-coverage macro: prepare-fixtures → generate → probe static → session-wrapped run → coverage → HTML report. For depth-checks / security probes / stateful invariants, drive the `zond` skill — `audit` is the breadth pass, depth is the skill's job.")
|
|
737
|
+
// ARV-29: not `requiredOption` — same regression that hit prepare-fixtures
|
|
738
|
+
// (TASK-20) and checks run (TASK-17). Commander routes `--api` to the
|
|
739
|
+
// program-level option, so the subcommand's opts.api ends up undefined and
|
|
740
|
+
// requiredOption rejects every form (`--api foo`, `--api=foo`, even
|
|
741
|
+
// `zond --api foo audit`). Fall back the same way: explicit > program-level
|
|
742
|
+
// mirror > .zond/current-api.
|
|
743
|
+
.option("--api <name>", "Registered API to audit. Falls back to ZOND_API / .zond/current-api.")
|
|
744
|
+
.option("--db <path>", "Path to SQLite database file")
|
|
745
|
+
.option("--with-mass-assignment", "Include 'probe mass-assignment' as an extra stage. Requires --live.")
|
|
746
|
+
.option("--with-security", "Include 'probe security ssrf,crlf,open-redirect' as an extra stage. Requires --live.")
|
|
747
|
+
.option("--live", "ARV-264: opt into the full pipeline against a real-traffic API. Without this flag, audit runs in safe mode: no mass-assignment / security probes, no destructive traffic. Use only against throwaway/sandbox accounts.")
|
|
748
|
+
.option("--out <path>", "HTML report output path (default: audit-report.html)")
|
|
749
|
+
.option("--dry-run", "Print the stage plan without executing anything")
|
|
750
|
+
.option("--force", "Disable mtime-based skip (always regenerate, even if tests/ newer than spec)")
|
|
751
|
+
.option(
|
|
752
|
+
"--budget <tier>",
|
|
753
|
+
"ARV-292: adaptive request cap for spawned `run` stages. `quick` (50 req, ~60-sec gate), `standard` (500 req), `full` (uncapped). Omitted ⇒ legacy uncapped pipeline.",
|
|
754
|
+
)
|
|
755
|
+
.action(async (opts, cmd: Command) => {
|
|
756
|
+
// ARV-53.
|
|
757
|
+
const apiName = getApi(cmd, opts);
|
|
758
|
+
if (!apiName) {
|
|
759
|
+
printError(MISSING_API_MESSAGE);
|
|
760
|
+
process.exitCode = 2;
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
opts.api = apiName;
|
|
764
|
+
let budget: Budget | undefined;
|
|
765
|
+
if (opts.budget !== undefined) {
|
|
766
|
+
if (!isBudget(opts.budget)) {
|
|
767
|
+
printError(`--budget must be one of: ${BUDGETS.join(", ")}; got '${opts.budget}'`);
|
|
768
|
+
process.exitCode = 2;
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
budget = opts.budget;
|
|
772
|
+
}
|
|
773
|
+
process.exitCode = await auditCommand({
|
|
774
|
+
api: opts.api,
|
|
775
|
+
dbPath: opts.db,
|
|
776
|
+
withMassAssignment: opts.withMassAssignment === true,
|
|
777
|
+
withSecurity: opts.withSecurity === true,
|
|
778
|
+
out: opts.out,
|
|
779
|
+
dryRun: opts.dryRun === true,
|
|
780
|
+
force: opts.force === true,
|
|
781
|
+
json: globalJson(cmd),
|
|
782
|
+
live: opts.live === true,
|
|
783
|
+
budget,
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
}
|