@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
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import type { TestSuite, TestStep, AssertionRule, TestStepExpect, SuiteConfig, RetryUntil, ForEach, MultipartField } from "./types.ts";
|
|
2
|
+
import type { TestSuite, TestStep, AssertionRule, TestStepExpect, SuiteConfig, RetryUntil, ForEach, MultipartField, SourceMetadata } from "./types.ts";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// ARV-223 (R16/F28): include OPTIONS / HEAD / TRACE so probe-method generated
|
|
5
|
+
// suites (which emit one step per missing-method per path) parse and run.
|
|
6
|
+
// Without this, `zond probe static --emit-tests → zond run` breaks end-to-end
|
|
7
|
+
// on every API where these methods aren't already declared (= almost always).
|
|
8
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "TRACE"] as const;
|
|
5
9
|
|
|
6
10
|
function extractMethodAndPath(raw: unknown): unknown {
|
|
7
11
|
if (typeof raw !== "object" || raw === null) return raw;
|
|
@@ -120,6 +124,44 @@ const TestStepExpectSchema: z.ZodType<TestStepExpect> = z.preprocess(
|
|
|
120
124
|
(val) => {
|
|
121
125
|
if (typeof val !== "object" || val === null) return val;
|
|
122
126
|
const obj = val as Record<string, unknown>;
|
|
127
|
+
// Reject `expect.capture: {...}` — non-canonical syntax some users
|
|
128
|
+
// reach for. zond captures live INSIDE body-rules
|
|
129
|
+
// (`body: { "path.to.field": { capture: var_name } }`); a top-level
|
|
130
|
+
// `capture:` block inside `expect:` is silently dropped, leaving the
|
|
131
|
+
// test green with no captured values. Throw with a clear pointer.
|
|
132
|
+
// (TASK-247)
|
|
133
|
+
if ("capture" in obj && typeof obj.capture === "object" && obj.capture !== null && !Array.isArray(obj.capture)) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`'expect.capture: {...}' is not a valid step shape. Captures are defined per-field: ` +
|
|
136
|
+
`\`expect.body: { "<path>": { capture: <var_name> } }\`. ` +
|
|
137
|
+
`Top-level 'capture' inside 'expect' is silently ignored — the test would pass with no captured values.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
// expect.status: spot-message for common wrong shapes.
|
|
141
|
+
// Schema accepts `number | number[]`. Users reaching from other tools often
|
|
142
|
+
// write `oneOf: [...]`, `any: [...]`, a string `"200"`, or an array
|
|
143
|
+
// containing strings. The raw zod-issue path (`tests.N.expect.status.0`)
|
|
144
|
+
// is hard to read — surface a single-line hint here. (TASK-249, feedback-13#F1)
|
|
145
|
+
if ("status" in obj && obj.status !== undefined && obj.status !== null) {
|
|
146
|
+
const s = obj.status;
|
|
147
|
+
const STATUS_HINT =
|
|
148
|
+
"expect.status: use a number (200), an array of numbers ([200, 404]), or omit. " +
|
|
149
|
+
"oneOf/any/anyOf are not supported.";
|
|
150
|
+
if (typeof s === "object" && !Array.isArray(s)) {
|
|
151
|
+
const keys = Object.keys(s as Record<string, unknown>);
|
|
152
|
+
const wrong = keys.find((k) => ["oneOf", "anyOf", "any", "in", "one_of"].includes(k));
|
|
153
|
+
if (wrong) {
|
|
154
|
+
throw new Error(`'expect.status' got '${wrong}: [...]' — ${STATUS_HINT}`);
|
|
155
|
+
}
|
|
156
|
+
throw new Error(`'expect.status' got an object — ${STATUS_HINT}`);
|
|
157
|
+
}
|
|
158
|
+
if (typeof s === "string") {
|
|
159
|
+
throw new Error(`'expect.status' got string "${s}" — ${STATUS_HINT}`);
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(s) && s.some((v) => typeof v !== "number")) {
|
|
162
|
+
throw new Error(`'expect.status' array must contain only numbers — ${STATUS_HINT}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
123
165
|
// body: null → remove it
|
|
124
166
|
if (obj.body === null) {
|
|
125
167
|
const { body: _, ...rest } = obj;
|
|
@@ -158,12 +200,57 @@ const MultipartFileFieldSchema = z.object({
|
|
|
158
200
|
|
|
159
201
|
const MultipartFieldSchema: z.ZodType<MultipartField> = z.union([z.string(), MultipartFileFieldSchema]);
|
|
160
202
|
|
|
203
|
+
// Provenance metadata: passthrough — все поля optional, неизвестные пропускаем без warning
|
|
204
|
+
const SourceMetadataSchema: z.ZodType<SourceMetadata> = z.object({
|
|
205
|
+
type: z.enum(["openapi-generated", "manual", "probe-suite"]).optional(),
|
|
206
|
+
spec: z.string().optional(),
|
|
207
|
+
generator: z.string().optional(),
|
|
208
|
+
generated_at: z.string().optional(),
|
|
209
|
+
endpoint: z.string().optional(),
|
|
210
|
+
response_branch: z.string().optional(),
|
|
211
|
+
schema_pointer: z.string().optional(),
|
|
212
|
+
}).passthrough() as z.ZodType<SourceMetadata>;
|
|
213
|
+
|
|
214
|
+
const KNOWN_STEP_KEYS = new Set([
|
|
215
|
+
"name", "source", "method", "path", "headers",
|
|
216
|
+
"json", "form", "multipart", "query", "expect",
|
|
217
|
+
"skip_if", "retry_until", "for_each", "set", "always",
|
|
218
|
+
// raw HTTP method keys are folded into method/path by extractMethodAndPath
|
|
219
|
+
...HTTP_METHODS,
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
// Common typo / wrong-name body keys we detect explicitly to emit an
|
|
223
|
+
// actionable error instead of silently dropping. Real APIs reject the empty
|
|
224
|
+
// POST that follows, but the user spends 10+ minutes debugging — this hint
|
|
225
|
+
// turns it into a one-line fix. (TASK-244)
|
|
226
|
+
const BODY_KEY_HINTS: Record<string, string> = {
|
|
227
|
+
body: "json (for application/json), form (urlencoded), or multipart (file upload)",
|
|
228
|
+
data: "json (for application/json) or form (urlencoded)",
|
|
229
|
+
payload: "json",
|
|
230
|
+
// TASK-257: previous hint pointed only at `form:` which is x-www-form-urlencoded
|
|
231
|
+
// and useless for file uploads. Surface `multipart:` explicitly so users with
|
|
232
|
+
// file-upload endpoints (file-upload endpoints, etc.) find it.
|
|
233
|
+
raw: "json for raw JSON, multipart: { field: { file: <path> } } for file upload, or form for urlencoded — raw bodies are not parsed",
|
|
234
|
+
};
|
|
235
|
+
|
|
161
236
|
const TestStepSchema: z.ZodType<TestStep> = z.preprocess(
|
|
162
237
|
(raw) => {
|
|
163
238
|
const obj = extractMethodAndPath(raw);
|
|
164
|
-
// Make expect optional for set-only steps
|
|
165
239
|
if (typeof obj === "object" && obj !== null) {
|
|
166
240
|
const o = obj as Record<string, unknown>;
|
|
241
|
+
|
|
242
|
+
// Reject silently-dropped body-shaped keys with a clear suggestion.
|
|
243
|
+
for (const [bad, hint] of Object.entries(BODY_KEY_HINTS)) {
|
|
244
|
+
if (bad in o) {
|
|
245
|
+
const stepName = typeof o.name === "string" ? ` in step "${o.name}"` : "";
|
|
246
|
+
throw new Error(
|
|
247
|
+
`Unknown step key '${bad}'${stepName}. Did you mean '${hint}'? ` +
|
|
248
|
+
`(zond does not recognize '${bad}:' and would silently drop the body)`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Make expect optional for set-only steps
|
|
167
254
|
if (o.set && !o.expect) {
|
|
168
255
|
o.expect = {};
|
|
169
256
|
}
|
|
@@ -172,6 +259,7 @@ const TestStepSchema: z.ZodType<TestStep> = z.preprocess(
|
|
|
172
259
|
},
|
|
173
260
|
z.object({
|
|
174
261
|
name: z.string(),
|
|
262
|
+
source: SourceMetadataSchema.optional(),
|
|
175
263
|
method: z.enum(HTTP_METHODS),
|
|
176
264
|
path: z.string(),
|
|
177
265
|
headers: z.record(z.string(), z.string()).optional(),
|
|
@@ -219,6 +307,7 @@ const TestSuiteSchema = z.preprocess(
|
|
|
219
307
|
description: z.string().optional(),
|
|
220
308
|
setup: z.boolean().optional(),
|
|
221
309
|
tags: z.array(z.string()).optional(),
|
|
310
|
+
source: SourceMetadataSchema.optional(),
|
|
222
311
|
base_url: z.string().optional(),
|
|
223
312
|
headers: z.record(z.string(), z.string()).optional(),
|
|
224
313
|
parameterize: z.record(z.string(), z.array(z.unknown()).min(1)).optional(),
|
|
@@ -231,4 +320,40 @@ export function validateSuite(raw: unknown): TestSuite {
|
|
|
231
320
|
return TestSuiteSchema.parse(raw) as TestSuite;
|
|
232
321
|
}
|
|
233
322
|
|
|
234
|
-
|
|
323
|
+
/** Render a zod path array (`["tests", 0, "expect", "status"]`) as
|
|
324
|
+
* `tests[0].expect.status`. Numeric segments become bracket-indices, string
|
|
325
|
+
* segments dot-join. */
|
|
326
|
+
function pathToHuman(path: ReadonlyArray<string | number>): string {
|
|
327
|
+
let out = "";
|
|
328
|
+
for (const seg of path) {
|
|
329
|
+
if (typeof seg === "number") out += `[${seg}]`;
|
|
330
|
+
else out += out ? `.${seg}` : seg;
|
|
331
|
+
}
|
|
332
|
+
return out || "(root)";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Format a {@link z.ZodError} as a compact, multi-line, human-readable list:
|
|
337
|
+
*
|
|
338
|
+
* N validation issue(s):
|
|
339
|
+
* <path>: <message>
|
|
340
|
+
* ...
|
|
341
|
+
*
|
|
342
|
+
* The default `ZodError.message` is a JSON dump of the full issue list with
|
|
343
|
+
* internal field names (`_def`, deeply numeric paths, "Invalid input" prefix).
|
|
344
|
+
* The wrapper that callers used to surface ("Validation error in <file>:
|
|
345
|
+
* [{...}]") was unreadable for tester users — they had to mentally parse the
|
|
346
|
+
* stack to find the real path. (TASK-249)
|
|
347
|
+
*/
|
|
348
|
+
export function formatZodError(err: z.ZodError): string {
|
|
349
|
+
const lines = err.issues.map((i) => {
|
|
350
|
+
const path = pathToHuman(i.path as ReadonlyArray<string | number>);
|
|
351
|
+
// zod v4 messages are already readable; strip the redundant "Invalid input: "
|
|
352
|
+
// prefix that adds noise without info.
|
|
353
|
+
const msg = i.message.replace(/^Invalid input:\s*/, "");
|
|
354
|
+
return ` ${path}: ${msg}`;
|
|
355
|
+
});
|
|
356
|
+
const header = `${err.issues.length} validation issue${err.issues.length === 1 ? "" : "s"}:`;
|
|
357
|
+
return `${header}\n${lines.join("\n")}`;
|
|
358
|
+
}
|
|
359
|
+
|
package/src/core/parser/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
1
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD" | "TRACE";
|
|
2
2
|
|
|
3
3
|
export interface AssertionRule {
|
|
4
4
|
capture?: string;
|
|
@@ -41,6 +41,22 @@ export interface ForEach {
|
|
|
41
41
|
in: unknown;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Provenance metadata: «откуда этот test/suite». Optional, не участвует в
|
|
46
|
+
* matching/dedup/validation. Suite-level задаёт общие поля; step-level
|
|
47
|
+
* наследует через shallow merge `{ ...suite.source, ...step.source }`.
|
|
48
|
+
*/
|
|
49
|
+
export interface SourceMetadata {
|
|
50
|
+
type?: "openapi-generated" | "manual" | "probe-suite";
|
|
51
|
+
spec?: string;
|
|
52
|
+
generator?: string;
|
|
53
|
+
generated_at?: string;
|
|
54
|
+
endpoint?: string;
|
|
55
|
+
response_branch?: string;
|
|
56
|
+
schema_pointer?: string;
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
export interface MultipartFileField {
|
|
45
61
|
file: string;
|
|
46
62
|
filename?: string;
|
|
@@ -51,6 +67,7 @@ export type MultipartField = string | MultipartFileField;
|
|
|
51
67
|
|
|
52
68
|
export interface TestStep {
|
|
53
69
|
name: string;
|
|
70
|
+
source?: SourceMetadata;
|
|
54
71
|
method: HttpMethod;
|
|
55
72
|
path: string;
|
|
56
73
|
headers?: Record<string, string>;
|
|
@@ -85,6 +102,7 @@ export interface TestSuite {
|
|
|
85
102
|
/** If true, this suite runs before all regular suites and its captures are shared into their env */
|
|
86
103
|
setup?: boolean;
|
|
87
104
|
tags?: string[];
|
|
105
|
+
source?: SourceMetadata;
|
|
88
106
|
base_url?: string;
|
|
89
107
|
headers?: Record<string, string>;
|
|
90
108
|
/** Cross-product parameterisation: each key contributes one variable
|
|
Binary file
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { Glob } from "bun";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
|
-
import {
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { validateSuite, formatZodError } from "./schema.ts";
|
|
5
6
|
import type { TestSuite } from "./types.ts";
|
|
6
7
|
|
|
8
|
+
export interface ParseOptions {
|
|
9
|
+
/** Surface raw `ZodError.message` (the JSON-formatted issue stack) instead
|
|
10
|
+
* of the human-friendly summary. Useful for filing zod bugs / debugging the
|
|
11
|
+
* schema itself; default output is human-readable. (TASK-249) */
|
|
12
|
+
verbose?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
/** Convert a 0-based byte offset into a 1-based (line, col) position. */
|
|
8
16
|
function offsetToLineCol(text: string, offset: number): { line: number; col: number } {
|
|
9
17
|
let line = 1;
|
|
@@ -41,7 +49,7 @@ export function formatYamlParseError(filePath: string, text: string, primary: Er
|
|
|
41
49
|
return new Error(`Invalid YAML in ${filePath}: ${primary.message}`);
|
|
42
50
|
}
|
|
43
51
|
|
|
44
|
-
export async function parseFile(filePath: string): Promise<TestSuite> {
|
|
52
|
+
export async function parseFile(filePath: string, opts: ParseOptions = {}): Promise<TestSuite> {
|
|
45
53
|
let text: string;
|
|
46
54
|
try {
|
|
47
55
|
text = await Bun.file(filePath).text();
|
|
@@ -72,22 +80,40 @@ export async function parseFile(filePath: string): Promise<TestSuite> {
|
|
|
72
80
|
suite.filePath = resolve(filePath);
|
|
73
81
|
return suite;
|
|
74
82
|
} catch (err) {
|
|
83
|
+
if (err instanceof z.ZodError && !opts.verbose) {
|
|
84
|
+
throw new Error(`Validation error in ${filePath}:\n${formatZodError(err)}`);
|
|
85
|
+
}
|
|
75
86
|
throw new Error(`Validation error in ${filePath}: ${(err as Error).message}`);
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
|
|
79
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Files that live alongside test suites but aren't suites themselves. The
|
|
92
|
+
* yaml-parser scans recursively from the workspace root, so picking these up
|
|
93
|
+
* would surface spurious "Validation error: missing field name" noise.
|
|
94
|
+
*/
|
|
95
|
+
function isNonSuiteYaml(file: string): boolean {
|
|
96
|
+
if (file.match(/\.env(\..+)?\.ya?ml$/)) return true;
|
|
97
|
+
// Workspace marker — present at the root of every zond workspace.
|
|
98
|
+
if (file === "zond.config.yml" || file === "zond.config.yaml") return true;
|
|
99
|
+
// Per-API artifact files written by `zond add api` / `zond refresh-api`.
|
|
100
|
+
// Match the basename so it works for files at any depth (apis/<name>/...).
|
|
101
|
+
const basename = file.split("/").pop() ?? file;
|
|
102
|
+
if (/^\.api-[a-z0-9-]+\.ya?ml$/i.test(basename)) return true;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function parseDirectory(dirPath: string, opts: ParseOptions = {}): Promise<TestSuite[]> {
|
|
80
107
|
const glob = new Glob("**/*.{yaml,yml}");
|
|
81
108
|
const suites: TestSuite[] = [];
|
|
82
109
|
|
|
83
110
|
for await (const file of glob.scan({ cwd: dirPath, absolute: false })) {
|
|
84
|
-
|
|
85
|
-
if (file.match(/\.env(\..+)?\.yaml$/) || file.match(/\.env(\..+)?\.yml$/)) {
|
|
111
|
+
if (isNonSuiteYaml(file)) {
|
|
86
112
|
continue;
|
|
87
113
|
}
|
|
88
114
|
const fullPath = `${dirPath}/${file}`;
|
|
89
115
|
try {
|
|
90
|
-
suites.push(await parseFile(fullPath));
|
|
116
|
+
suites.push(await parseFile(fullPath, opts));
|
|
91
117
|
} catch {
|
|
92
118
|
// Skip files that fail to parse (e.g. invalid AI-generated YAML)
|
|
93
119
|
// so one bad file doesn't block the entire directory
|
|
@@ -102,18 +128,18 @@ export interface ParseDirectoryResult {
|
|
|
102
128
|
errors: { file: string; error: string }[];
|
|
103
129
|
}
|
|
104
130
|
|
|
105
|
-
export async function parseDirectorySafe(dirPath: string): Promise<ParseDirectoryResult> {
|
|
131
|
+
export async function parseDirectorySafe(dirPath: string, opts: ParseOptions = {}): Promise<ParseDirectoryResult> {
|
|
106
132
|
const glob = new Glob("**/*.{yaml,yml}");
|
|
107
133
|
const suites: TestSuite[] = [];
|
|
108
134
|
const errors: { file: string; error: string }[] = [];
|
|
109
135
|
|
|
110
136
|
for await (const file of glob.scan({ cwd: dirPath, absolute: false })) {
|
|
111
|
-
if (
|
|
137
|
+
if (isNonSuiteYaml(file)) {
|
|
112
138
|
continue;
|
|
113
139
|
}
|
|
114
140
|
const fullPath = `${dirPath}/${file}`;
|
|
115
141
|
try {
|
|
116
|
-
suites.push(await parseFile(fullPath));
|
|
142
|
+
suites.push(await parseFile(fullPath, opts));
|
|
117
143
|
} catch (err) {
|
|
118
144
|
errors.push({ file, error: (err as Error).message });
|
|
119
145
|
}
|
|
@@ -122,14 +148,34 @@ export async function parseDirectorySafe(dirPath: string): Promise<ParseDirector
|
|
|
122
148
|
return { suites, errors };
|
|
123
149
|
}
|
|
124
150
|
|
|
125
|
-
export async function parse(path: string): Promise<TestSuite[]> {
|
|
151
|
+
export async function parse(path: string, opts: ParseOptions = {}): Promise<TestSuite[]> {
|
|
126
152
|
const file = Bun.file(path);
|
|
127
153
|
const exists = await file.exists();
|
|
128
154
|
|
|
129
155
|
if (exists) {
|
|
130
|
-
return [await parseFile(path)];
|
|
156
|
+
return [await parseFile(path, opts)];
|
|
131
157
|
}
|
|
132
158
|
|
|
133
159
|
// Not a file, try as directory
|
|
134
|
-
return parseDirectory(path);
|
|
160
|
+
return parseDirectory(path, opts);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Like {@link parse}, but never silently drops files. Returns both successfully
|
|
165
|
+
* parsed suites and per-file parse errors so callers (run, validate, tag-filter)
|
|
166
|
+
* can surface failures instead of pretending the file did not exist.
|
|
167
|
+
*/
|
|
168
|
+
export async function parseSafe(path: string, opts: ParseOptions = {}): Promise<ParseDirectoryResult> {
|
|
169
|
+
const file = Bun.file(path);
|
|
170
|
+
const exists = await file.exists();
|
|
171
|
+
|
|
172
|
+
if (exists) {
|
|
173
|
+
try {
|
|
174
|
+
return { suites: [await parseFile(path, opts)], errors: [] };
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return { suites: [], errors: [{ file: path, error: (err as Error).message }] };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return parseDirectorySafe(path, opts);
|
|
135
181
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe registry bootstrap (m-17 / ARV-49).
|
|
3
|
+
*
|
|
4
|
+
* Called once from the CLI program init. Imports each Probe class and
|
|
5
|
+
* runs `registerProbe`, which validates the contract from `types.ts`
|
|
6
|
+
* and throws if a slot is missing. Boot-time failure is louder than
|
|
7
|
+
* runtime — adding a new probe class without --dry-run / --report
|
|
8
|
+
* support won't ship; that's the whole point of the m-17 contract.
|
|
9
|
+
*
|
|
10
|
+
* Idempotent: repeated calls are no-ops (matters for unit tests that
|
|
11
|
+
* run the bootstrap multiple times).
|
|
12
|
+
*/
|
|
13
|
+
import { listProbes, registerProbe } from "./registry.ts";
|
|
14
|
+
import { SecurityProbe } from "./security-probe-class.ts";
|
|
15
|
+
import { MassAssignmentProbe } from "./mass-assignment-probe-class.ts";
|
|
16
|
+
import { StaticProbe } from "./static-probe-class.ts";
|
|
17
|
+
|
|
18
|
+
let bootstrapped = false;
|
|
19
|
+
|
|
20
|
+
export function bootstrapProbes(): void {
|
|
21
|
+
if (bootstrapped) return;
|
|
22
|
+
if (listProbes().length === 0) {
|
|
23
|
+
registerProbe(new StaticProbe());
|
|
24
|
+
registerProbe(new MassAssignmentProbe());
|
|
25
|
+
registerProbe(new SecurityProbe());
|
|
26
|
+
}
|
|
27
|
+
bootstrapped = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Test helper — resets the singleton so the next `bootstrapProbes()`
|
|
31
|
+
* re-registers from scratch. Pair with `clearProbes()` from registry. */
|
|
32
|
+
export function resetBootstrap(): void {
|
|
33
|
+
bootstrapped = false;
|
|
34
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dry-run envelope helpers (m-17 / ARV-50).
|
|
3
|
+
*
|
|
4
|
+
* `--dry-run` answers "what would I attack" — severity is undefined
|
|
5
|
+
* because nothing was classified. Both probe-security and
|
|
6
|
+
* probe-mass-assignment write this shape into `data` instead of the
|
|
7
|
+
* legacy severity-bucket structure that conflated planned attacks
|
|
8
|
+
* with skipped endpoints (F1-15).
|
|
9
|
+
*/
|
|
10
|
+
import type { EndpointPlan } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
export interface DryRunSummary {
|
|
13
|
+
totalEndpoints: number;
|
|
14
|
+
planned: number;
|
|
15
|
+
skipped: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DryRunEnvelopeData {
|
|
19
|
+
endpoints: EndpointPlan[];
|
|
20
|
+
summary: DryRunSummary;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function summarizeDryRun(plans: EndpointPlan[]): DryRunEnvelopeData {
|
|
24
|
+
let planned = 0;
|
|
25
|
+
let skipped = 0;
|
|
26
|
+
for (const p of plans) {
|
|
27
|
+
if (p.planned) planned++;
|
|
28
|
+
else skipped++;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
endpoints: plans,
|
|
32
|
+
summary: {
|
|
33
|
+
totalEndpoints: plans.length,
|
|
34
|
+
planned,
|
|
35
|
+
skipped,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Render a short human digest for the dry-run plan (used by non-json
|
|
41
|
+
* output). One line per endpoint, planned/skipped counts at the end. */
|
|
42
|
+
export function formatDryRunDigest(plans: EndpointPlan[]): string {
|
|
43
|
+
const lines: string[] = [];
|
|
44
|
+
for (const p of plans) {
|
|
45
|
+
if (p.planned) {
|
|
46
|
+
const fields = p.fields_planned.length > 0 ? ` fields=${p.fields_planned.join(",")}` : "";
|
|
47
|
+
const cls = p.classes_planned.length > 0 ? ` classes=${p.classes_planned.join(",")}` : "";
|
|
48
|
+
lines.push(` + ${p.method} ${p.path}${cls}${fields}`);
|
|
49
|
+
} else {
|
|
50
|
+
lines.push(` - ${p.method} ${p.path} (skipped: ${p.skip_reason ?? "unknown"})`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const summary = summarizeDryRun(plans).summary;
|
|
54
|
+
lines.push("");
|
|
55
|
+
lines.push(`Plan: ${summary.planned} planned · ${summary.skipped} skipped · ${summary.totalEndpoints} total`);
|
|
56
|
+
// ARV-309: the plan above lists what *would* be attacked — no traffic was
|
|
57
|
+
// sent. Without this line a reader can't tell "ran, found nothing" from
|
|
58
|
+
// "never fired" (the plan reads like a findings list). State it explicitly.
|
|
59
|
+
lines.push("Dry-run: mutation probes NOT executed — no requests sent. Re-run without --dry-run to attack live.");
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { classify as classifyRecommendedAction } from "../../classifier/recommended-action.ts";
|
|
2
|
+
import type { EndpointInfo } from "../../generator/types.ts";
|
|
3
|
+
import type { EndpointVerdict } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/** Lower-cased anchored fragments for the SaaS-flavoured 403 wordings we
|
|
6
|
+
* encounter in the wild (paid plan / role-scope / feature-flag gates).
|
|
7
|
+
* Each entry is one independent signal — a body matching any one of them
|
|
8
|
+
* is treated as subscription-gated. Formerly the anti-FP registry rule
|
|
9
|
+
* `subscription-gated/paid-plan-403` (ARV-125); now inlined as a plain
|
|
10
|
+
* evidence signal on the baseline summary — NOT a suppressor. */
|
|
11
|
+
const SUBSCRIPTION_GATED_PATTERNS: RegExp[] = [
|
|
12
|
+
/\bpaid plan\b/i,
|
|
13
|
+
/\bsubscription (?:required|needed)\b/i,
|
|
14
|
+
/\bnot (?:available|enabled) (?:on|for) your\b/i,
|
|
15
|
+
/\bplan (?:does not include|doesn['']?t include)\b/i,
|
|
16
|
+
/\brequires? (?:the )?[\w:-]+ scope\b/i,
|
|
17
|
+
/\bmissing (?:the )?[\w:-]+ scope\b/i,
|
|
18
|
+
/\bfeature (?:is )?(?:not enabled|disabled|not available)\b/i,
|
|
19
|
+
/\binsufficient (?:permissions?|scope)\b/i,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** Reason text surfaced on the baseline summary when a 403 body names a
|
|
23
|
+
* subscription/scope gate — signals to the triage agent that fixture
|
|
24
|
+
* edits won't help. */
|
|
25
|
+
const SUBSCRIPTION_GATED_REASON =
|
|
26
|
+
"endpoint is env/subscription-gated (paid plan, role/scope, feature flag); " +
|
|
27
|
+
"not a fixture issue — wontfix unless scope changes";
|
|
28
|
+
|
|
29
|
+
function matchesSubscriptionGated(message: string): boolean {
|
|
30
|
+
for (const re of SUBSCRIPTION_GATED_PATTERNS) {
|
|
31
|
+
if (re.test(message)) return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** ARV-56: route through the single classifier instead of carrying the
|
|
37
|
+
* severity→action switch inline. */
|
|
38
|
+
export function stampRecommendedAction(verdict: EndpointVerdict): void {
|
|
39
|
+
const action = classifyRecommendedAction({
|
|
40
|
+
finding_class: "probe:mass_assignment",
|
|
41
|
+
severity: verdict.severity as Parameters<typeof classifyRecommendedAction>[0]["severity"],
|
|
42
|
+
});
|
|
43
|
+
if (action) verdict.recommended_action = action;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a one-line summary for INCONCLUSIVE-baseline verdicts. We surface
|
|
48
|
+
* the server's error code/name when present so the user can immediately
|
|
49
|
+
* see *which* FK / scope / fixture failed and fix it before re-probing.
|
|
50
|
+
*/
|
|
51
|
+
export function inconclusiveBaselineSummary(
|
|
52
|
+
status: number,
|
|
53
|
+
body: unknown,
|
|
54
|
+
bodyFkMisses?: Array<{ field: string; reason: string }>,
|
|
55
|
+
): string {
|
|
56
|
+
const hint = extractBaselineHint(body);
|
|
57
|
+
const base = `baseline body invalid — server returned ${status}`;
|
|
58
|
+
// ARV-104 (F9): when status is 403 and the response body names a
|
|
59
|
+
// subscription/scope gate (paid plan, feature flag, role/scope
|
|
60
|
+
// insufficient), the right answer isn't "fix fixture" — there's
|
|
61
|
+
// nothing to fix. Surface a wontfix reason on the summary as raw
|
|
62
|
+
// evidence for the triage agent (this is a hint, NOT a suppressor).
|
|
63
|
+
const gated = status === 403 && hint !== undefined && matchesSubscriptionGated(hint);
|
|
64
|
+
const tail = gated
|
|
65
|
+
? ` — ${SUBSCRIPTION_GATED_REASON}`
|
|
66
|
+
: " — fix fixture / FK value / path-params and re-probe";
|
|
67
|
+
// TASK-137: if body-FK auto-discovery couldn't fill required FK fields, name
|
|
68
|
+
// them in the summary so the user knows exactly what to add to env (or
|
|
69
|
+
// why discover-fk missed — e.g. nested list endpoint, 403 from scope).
|
|
70
|
+
let fkClause = "";
|
|
71
|
+
if (bodyFkMisses && bodyFkMisses.length > 0) {
|
|
72
|
+
const names = bodyFkMisses.map(m => m.field).join(", ");
|
|
73
|
+
fkClause = ` — unresolved body FKs: ${names}`;
|
|
74
|
+
}
|
|
75
|
+
return hint
|
|
76
|
+
? `${base} (${hint})${fkClause}${tail}`
|
|
77
|
+
: `${base}${fkClause}${tail}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ARV-104 (F9): quick yes/no on whether a 403 body names a
|
|
81
|
+
* subscription/scope gate. Public predicate for callers (and tests)
|
|
82
|
+
* that want the signal without composing a summary string. */
|
|
83
|
+
export const isSubscriptionGated = matchesSubscriptionGated;
|
|
84
|
+
|
|
85
|
+
function extractBaselineHint(body: unknown): string | undefined {
|
|
86
|
+
if (typeof body === "string") {
|
|
87
|
+
const trimmed = body.trim();
|
|
88
|
+
if (trimmed.length === 0) return undefined;
|
|
89
|
+
return trimmed.length > 120 ? `${trimmed.slice(0, 120)}…` : trimmed;
|
|
90
|
+
}
|
|
91
|
+
if (typeof body !== "object" || body === null) return undefined;
|
|
92
|
+
const obj = body as Record<string, unknown>;
|
|
93
|
+
// Common error-envelope fields across SaaS APIs.
|
|
94
|
+
const candidates = [
|
|
95
|
+
obj.message,
|
|
96
|
+
obj.error,
|
|
97
|
+
(obj.error as Record<string, unknown> | undefined)?.message,
|
|
98
|
+
obj.detail,
|
|
99
|
+
obj.title,
|
|
100
|
+
obj.name,
|
|
101
|
+
obj.code,
|
|
102
|
+
];
|
|
103
|
+
for (const c of candidates) {
|
|
104
|
+
if (typeof c === "string" && c.length > 0) {
|
|
105
|
+
return c.length > 120 ? `${c.slice(0, 120)}…` : c;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function needsFollowUp(verdict: EndpointVerdict): boolean {
|
|
112
|
+
return verdict.fields.some(f => f.outcome === "absent" || f.outcome === "unknown");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function classifyFromBody(
|
|
116
|
+
verdict: EndpointVerdict,
|
|
117
|
+
body: Record<string, unknown> | undefined,
|
|
118
|
+
fromGet = false,
|
|
119
|
+
): void {
|
|
120
|
+
if (!body) return;
|
|
121
|
+
for (const field of verdict.fields) {
|
|
122
|
+
// Once a field is decisively classified (applied/echoed-overwritten),
|
|
123
|
+
// don't downgrade. But "absent" on POST may still flip to applied/ignored
|
|
124
|
+
// after GET — so only re-check those.
|
|
125
|
+
if (field.outcome === "applied" || field.outcome === "echoed-overwritten") continue;
|
|
126
|
+
if (!(field.field in body)) {
|
|
127
|
+
// GET also missing → ignored. POST missing → keep "absent" so we GET later.
|
|
128
|
+
field.outcome = fromGet ? "ignored" : "absent";
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const observed = body[field.field];
|
|
132
|
+
field.observed = observed;
|
|
133
|
+
if (Bun.deepEquals(observed, field.injected)) {
|
|
134
|
+
field.outcome = "applied";
|
|
135
|
+
} else if (fromGet) {
|
|
136
|
+
field.outcome = "ignored";
|
|
137
|
+
} else {
|
|
138
|
+
field.outcome = "echoed-overwritten";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function findIdParam(ep: EndpointInfo): string {
|
|
144
|
+
const m = ep.path.match(/\{([^}]+)\}/);
|
|
145
|
+
return m ? m[1]! : "id";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function finaliseSeverity(v: EndpointVerdict, strict: boolean): void {
|
|
149
|
+
const applied = v.fields.filter(f => f.outcome === "applied");
|
|
150
|
+
const absent = v.fields.filter(f => f.outcome === "absent");
|
|
151
|
+
|
|
152
|
+
if (applied.length > 0) {
|
|
153
|
+
v.severity = "high";
|
|
154
|
+
v.summary = `accepted-and-applied: ${applied.map(f => f.field).join(", ")}`;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (absent.length > 0) {
|
|
158
|
+
// ARV-252: absent-but-unverifiable carries single_signal proof.
|
|
159
|
+
// Surfaced as INFO and only shown under --verbose so the report
|
|
160
|
+
// stays clean; the verdict still travels through the JSON envelope
|
|
161
|
+
// for agents that want to triage it explicitly.
|
|
162
|
+
v.severity = "info";
|
|
163
|
+
v.summary = `inconclusive — could not verify via follow-up GET (${absent.map(f => f.field).join(", ")})`;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// ARV-252: silently-ignored = correct framework behaviour (Rails
|
|
167
|
+
// strong params / FastAPI extra=ignore). Severity stays INFO so it
|
|
168
|
+
// never gates CI, AND the CLI display layer suppresses it entirely
|
|
169
|
+
// (even under --verbose). Reports must not be noise-floored by
|
|
170
|
+
// correct behaviour. Verdicts still travel through the JSON envelope
|
|
171
|
+
// for agents that explicitly want to inspect them.
|
|
172
|
+
v.severity = "info";
|
|
173
|
+
const status = v.response?.status ?? 0;
|
|
174
|
+
v.summary = `accepted ${status} but extras silently ignored${strict ? " (despite additionalProperties:false — server should reject)" : ""}`;
|
|
175
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
|
|
2
|
+
import { executeRequest } from "../../runner/http-client.ts";
|
|
3
|
+
import {
|
|
4
|
+
captureFieldFor,
|
|
5
|
+
findDeleteCounterpart,
|
|
6
|
+
liveAuthHeaders,
|
|
7
|
+
} from "../shared.ts";
|
|
8
|
+
import { buildProbeUrl } from "../probe-harness.ts";
|
|
9
|
+
import { findIdParam } from "./classify.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Best-effort DELETE on the baseline (no-extras) probe so the injected
|
|
13
|
+
* POST doesn't trip a unique-constraint and we don't leak resources.
|
|
14
|
+
*/
|
|
15
|
+
export async function tryCleanupBaseline(
|
|
16
|
+
ep: EndpointInfo,
|
|
17
|
+
allEndpoints: EndpointInfo[],
|
|
18
|
+
schemes: SecuritySchemeInfo[],
|
|
19
|
+
vars: Record<string, string>,
|
|
20
|
+
baselineBody: unknown,
|
|
21
|
+
opts: { timeoutMs?: number },
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const body =
|
|
24
|
+
typeof baselineBody === "object" && baselineBody !== null
|
|
25
|
+
? (baselineBody as Record<string, unknown>)
|
|
26
|
+
: undefined;
|
|
27
|
+
if (!body) return;
|
|
28
|
+
const idField = captureFieldFor(ep);
|
|
29
|
+
const id = body[idField];
|
|
30
|
+
if (id === undefined) return;
|
|
31
|
+
const delEp = findDeleteCounterpart(ep, allEndpoints);
|
|
32
|
+
if (!delEp) return;
|
|
33
|
+
const delVars = { ...vars, [findIdParam(delEp)]: String(id), id: String(id) };
|
|
34
|
+
const delUrl = buildProbeUrl(delEp, delVars);
|
|
35
|
+
if (delUrl.unresolved.length > 0) return;
|
|
36
|
+
try {
|
|
37
|
+
await executeRequest(
|
|
38
|
+
{
|
|
39
|
+
method: "DELETE",
|
|
40
|
+
url: delUrl.url,
|
|
41
|
+
headers: {
|
|
42
|
+
accept: "application/json",
|
|
43
|
+
...liveAuthHeaders(delEp, schemes, vars),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
47
|
+
);
|
|
48
|
+
} catch {
|
|
49
|
+
// best-effort — if cleanup fails we'll leak a baseline resource, but
|
|
50
|
+
// that's a deployment problem, not a probe bug.
|
|
51
|
+
}
|
|
52
|
+
}
|