@kirrosh/zond 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +758 -3
- package/README.md +78 -15
- package/package.json +17 -10
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +55 -6
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +192 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +180 -8
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -47
- package/src/cli/commands/init/agents-md.ts +61 -0
- package/src/cli/commands/init/bootstrap.ts +108 -0
- package/src/cli/commands/init/index.ts +244 -0
- package/src/cli/commands/init/skills.ts +98 -0
- package/src/cli/commands/init/templates/agents.md +77 -0
- package/src/cli/commands/init/templates/markdown.d.ts +4 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +651 -0
- package/src/cli/commands/init/templates/zond-config.yml +14 -0
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +911 -33
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +74 -0
- package/src/cli/index.ts +36 -607
- package/src/cli/json-envelope.ts +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +218 -0
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +8 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +61 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +51 -0
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +161 -12
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +53 -15
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +490 -33
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +55 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +114 -15
- package/src/core/generator/suite-generator.ts +484 -77
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +132 -5
- package/src/core/parser/types.ts +29 -2
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +108 -13
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -0
- package/src/core/probe/method-probe.ts +164 -0
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +691 -0
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +284 -0
- package/src/core/reporter/console.ts +69 -4
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +15 -2
- 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 +62 -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 +391 -52
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +115 -7
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +203 -0
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +419 -17
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +94 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +128 -0
- package/src/db/queries/runs.ts +235 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +72 -802
- package/src/db/schema.ts +179 -48
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init.ts +0 -57
- package/src/cli/commands/serve.ts +0 -81
- package/src/cli/commands/sync.ts +0 -269
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -21
- 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,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negative-input probe generator (T49).
|
|
3
|
+
*
|
|
4
|
+
* Goal: catch the class of bugs where an API returns 5xx (unhandled exception)
|
|
5
|
+
* instead of 4xx (validation error) when given malformed input. The contract
|
|
6
|
+
* is simple: any client-supplied invalid input MUST produce a 4xx, never a 5xx.
|
|
7
|
+
*
|
|
8
|
+
* For each endpoint we generate a suite of probe steps. Each step expects a
|
|
9
|
+
* "no 5xx" response (status in [400, 401, 403, 404, 405, 409, 415, 422, 429]).
|
|
10
|
+
* If the API returns 500/502/503 — the test fails and the runner logs it as
|
|
11
|
+
* a bug candidate via the regular reporter / `zond db diagnose` flow.
|
|
12
|
+
*
|
|
13
|
+
* ARV-34: 429 is in the allow-set because rate-limiting is itself a valid
|
|
14
|
+
* server-side rejection of the request — the API refused to process invalid
|
|
15
|
+
* input, the contract is satisfied. Throttled probe runs were producing
|
|
16
|
+
* hundreds of false failures with the warning "N requests hit rate limit"
|
|
17
|
+
* already saying the same thing.
|
|
18
|
+
*
|
|
19
|
+
* The probes are deterministic — same spec → same suites — so the generated
|
|
20
|
+
* YAML can be committed as a regression test.
|
|
21
|
+
*/
|
|
22
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
23
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
|
|
24
|
+
import type { RawSuite, RawStep } from "../generator/serializer.ts";
|
|
25
|
+
import { generateFromSchema } from "../generator/data-factory.ts";
|
|
26
|
+
import {
|
|
27
|
+
convertPath,
|
|
28
|
+
endpointStem,
|
|
29
|
+
getAuthHeaders,
|
|
30
|
+
renderPath,
|
|
31
|
+
isMutating,
|
|
32
|
+
findDeleteCounterpart,
|
|
33
|
+
captureFieldFor,
|
|
34
|
+
headersEqual,
|
|
35
|
+
hasJsonBody,
|
|
36
|
+
} from "./shared.ts";
|
|
37
|
+
|
|
38
|
+
// ──────────────────────────────────────────────
|
|
39
|
+
// Constants
|
|
40
|
+
// ──────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Statuses we consider an *acceptable* response to invalid input. Anything
|
|
43
|
+
* outside this set (notably 5xx, but also 200/201 which would mean the API
|
|
44
|
+
* silently accepted the bad input) is a probe failure. ARV-34: 429 stays in
|
|
45
|
+
* the allow-set — server-side throttling is a valid rejection. */
|
|
46
|
+
const ACCEPTABLE_4XX = [400, 401, 403, 404, 405, 409, 415, 422, 429];
|
|
47
|
+
|
|
48
|
+
/** Long string for boundary probes — 10_000 chars. */
|
|
49
|
+
const LONG_STRING = "a".repeat(10_000);
|
|
50
|
+
|
|
51
|
+
/** Mixed unicode + emoji + RTL for charset probes. */
|
|
52
|
+
const UNICODE_MIX = "Mix🌐مرحبا\u200B";
|
|
53
|
+
|
|
54
|
+
/** Sentinel non-UUID inputs for path/UUID probes. */
|
|
55
|
+
export const INVALID_UUID_SENTINELS = [
|
|
56
|
+
"not-a-uuid",
|
|
57
|
+
"12345",
|
|
58
|
+
"00000000",
|
|
59
|
+
"../../etc/passwd",
|
|
60
|
+
] as const;
|
|
61
|
+
const INVALID_UUID_VALUES = INVALID_UUID_SENTINELS;
|
|
62
|
+
|
|
63
|
+
/** Sentinel invalid emails. */
|
|
64
|
+
const INVALID_EMAIL_VALUES = [
|
|
65
|
+
"not-an-email",
|
|
66
|
+
"@no-local.example.com",
|
|
67
|
+
"spaces in@email.com",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/** Sentinel invalid URIs. */
|
|
71
|
+
const INVALID_URI_VALUES = [
|
|
72
|
+
"not a url",
|
|
73
|
+
"javascript:alert(1)",
|
|
74
|
+
"ftp:/missing-slash",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** Sentinel invalid date-time strings. */
|
|
78
|
+
const INVALID_DATETIME_VALUES = [
|
|
79
|
+
"yesterday",
|
|
80
|
+
"2023-13-45T99:99:99Z",
|
|
81
|
+
"2023-10-06:23:47:56.678Z", // colon-instead-of-T (real bug we caught)
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// ──────────────────────────────────────────────
|
|
85
|
+
// Types
|
|
86
|
+
// ──────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export interface ProbeOptions {
|
|
89
|
+
endpoints: EndpointInfo[];
|
|
90
|
+
securitySchemes: SecuritySchemeInfo[];
|
|
91
|
+
/** Cap probes per endpoint (default 50). Hard cutoff for huge schemas. */
|
|
92
|
+
maxProbesPerEndpoint?: number;
|
|
93
|
+
/**
|
|
94
|
+
* Skip emission of follow-up DELETE cleanup steps for mutating probes
|
|
95
|
+
* (POST/PUT/PATCH). Useful for namespace-isolated test environments
|
|
96
|
+
* (staging dump-and-reset) where cleanup is handled out of band.
|
|
97
|
+
*/
|
|
98
|
+
noCleanup?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* TASK-135: when true (default), non-attacked path-params are emitted as
|
|
101
|
+
* runtime placeholders `{{name}}` so `zond run` substitutes them from
|
|
102
|
+
* `.env.yaml`. This avoids the short-circuit where every probe targeting a
|
|
103
|
+
* nested path resolved `{org}=nonexistent-zzzzz` and got 404 before the
|
|
104
|
+
* leaf validator ever fired. Set to false to keep the legacy behaviour
|
|
105
|
+
* (synthetic-by-type for every param).
|
|
106
|
+
*/
|
|
107
|
+
useRealParents?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ProbeResult {
|
|
111
|
+
suites: RawSuite[];
|
|
112
|
+
/** Number of endpoints that received probes. */
|
|
113
|
+
probedEndpoints: number;
|
|
114
|
+
/** Endpoints we skipped (no body & no UUID path params). */
|
|
115
|
+
skippedEndpoints: number;
|
|
116
|
+
/** Total generated probe steps. */
|
|
117
|
+
totalProbes: number;
|
|
118
|
+
/**
|
|
119
|
+
* Generation-time warnings — typically about mutating endpoints whose
|
|
120
|
+
* probes might leak resources because the spec defines no DELETE
|
|
121
|
+
* counterpart. CLI surfaces these to the user.
|
|
122
|
+
*/
|
|
123
|
+
warnings: string[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ──────────────────────────────────────────────
|
|
127
|
+
// Helpers
|
|
128
|
+
// ──────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
function findUuidPathParams(ep: EndpointInfo): OpenAPIV3.ParameterObject[] {
|
|
131
|
+
return ep.parameters.filter((p) => {
|
|
132
|
+
if (p.in !== "path") return false;
|
|
133
|
+
const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
|
|
134
|
+
if (!schema) return false;
|
|
135
|
+
if (schema.format === "uuid") return true;
|
|
136
|
+
// also probe path params named like *_id / *_uuid
|
|
137
|
+
const lower = p.name.toLowerCase();
|
|
138
|
+
return lower === "id" || lower.endsWith("_id") || lower === "uuid";
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Walk schema and collect required-field paths up to depth 1 with their schema. */
|
|
143
|
+
function collectRequiredFields(
|
|
144
|
+
schema: OpenAPIV3.SchemaObject | undefined,
|
|
145
|
+
): Array<{ name: string; schema: OpenAPIV3.SchemaObject }> {
|
|
146
|
+
if (!schema || !schema.properties) return [];
|
|
147
|
+
const required = new Set(schema.required ?? []);
|
|
148
|
+
const out: Array<{ name: string; schema: OpenAPIV3.SchemaObject }> = [];
|
|
149
|
+
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
|
150
|
+
if (required.has(name)) {
|
|
151
|
+
out.push({ name, schema: propSchema as OpenAPIV3.SchemaObject });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Walk schema (depth 1) and collect all properties with their schema. */
|
|
158
|
+
function collectAllProps(
|
|
159
|
+
schema: OpenAPIV3.SchemaObject | undefined,
|
|
160
|
+
): Array<{ name: string; schema: OpenAPIV3.SchemaObject; required: boolean }> {
|
|
161
|
+
if (!schema || !schema.properties) return [];
|
|
162
|
+
const required = new Set(schema.required ?? []);
|
|
163
|
+
return Object.entries(schema.properties).map(([name, s]) => ({
|
|
164
|
+
name,
|
|
165
|
+
schema: s as OpenAPIV3.SchemaObject,
|
|
166
|
+
required: required.has(name),
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ──────────────────────────────────────────────
|
|
171
|
+
// Probe generators
|
|
172
|
+
// ──────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build a step that targets `endpoint`, but with an arbitrary body override.
|
|
176
|
+
* Authentication and required path params are populated with valid placeholders
|
|
177
|
+
* so the request reaches the body-validation layer.
|
|
178
|
+
*
|
|
179
|
+
* `pathOverride` (if provided) is treated as already-final — no further
|
|
180
|
+
* placeholder conversion is applied. Otherwise the path is rendered via
|
|
181
|
+
* `renderPath` with no attack target (so all path-params become runtime
|
|
182
|
+
* placeholders / synthetic sentinels depending on `useRealParents`).
|
|
183
|
+
*/
|
|
184
|
+
function buildStep(
|
|
185
|
+
ep: EndpointInfo,
|
|
186
|
+
schemes: SecuritySchemeInfo[],
|
|
187
|
+
opts: {
|
|
188
|
+
name: string;
|
|
189
|
+
json?: unknown;
|
|
190
|
+
pathOverride?: string;
|
|
191
|
+
expectStatusOk?: number[];
|
|
192
|
+
useRealParents: boolean;
|
|
193
|
+
},
|
|
194
|
+
): RawStep {
|
|
195
|
+
const method = ep.method.toUpperCase();
|
|
196
|
+
const path = opts.pathOverride ?? renderPath(ep, null, { useRealParents: opts.useRealParents });
|
|
197
|
+
const headers = getAuthHeaders(ep, schemes);
|
|
198
|
+
|
|
199
|
+
const expectedStatus = opts.expectStatusOk ?? ACCEPTABLE_4XX;
|
|
200
|
+
const responseBranch = Array.isArray(expectedStatus) ? expectedStatus.map(String).join("|") : String(expectedStatus);
|
|
201
|
+
const step: RawStep = {
|
|
202
|
+
name: opts.name,
|
|
203
|
+
source: {
|
|
204
|
+
generator: "negative-probe",
|
|
205
|
+
endpoint: `${method} ${ep.path}`,
|
|
206
|
+
response_branch: responseBranch,
|
|
207
|
+
},
|
|
208
|
+
[method]: path,
|
|
209
|
+
expect: {
|
|
210
|
+
status: expectedStatus,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
if (headers) step.headers = headers;
|
|
214
|
+
if (opts.json !== undefined) (step as any).json = opts.json;
|
|
215
|
+
return step;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function probeEmptyBody(
|
|
219
|
+
ep: EndpointInfo,
|
|
220
|
+
schemes: SecuritySchemeInfo[],
|
|
221
|
+
useRealParents: boolean,
|
|
222
|
+
): RawStep | null {
|
|
223
|
+
if (!hasJsonBody(ep)) return null;
|
|
224
|
+
const required = collectRequiredFields(ep.requestBodySchema);
|
|
225
|
+
// Only meaningful when there *is* required data — otherwise {} is valid.
|
|
226
|
+
if (required.length === 0) return null;
|
|
227
|
+
return buildStep(ep, schemes, {
|
|
228
|
+
name: "empty body — must reject (no 5xx)",
|
|
229
|
+
json: {},
|
|
230
|
+
useRealParents,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function probeMissingRequired(
|
|
235
|
+
ep: EndpointInfo,
|
|
236
|
+
schemes: SecuritySchemeInfo[],
|
|
237
|
+
budget: number,
|
|
238
|
+
useRealParents: boolean,
|
|
239
|
+
): RawStep[] {
|
|
240
|
+
if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
|
|
241
|
+
const required = collectRequiredFields(ep.requestBodySchema);
|
|
242
|
+
if (required.length === 0) return [];
|
|
243
|
+
|
|
244
|
+
// Build a baseline valid object, then drop one required field at a time.
|
|
245
|
+
const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
|
|
246
|
+
if (typeof baseline !== "object" || baseline === null) return [];
|
|
247
|
+
|
|
248
|
+
const out: RawStep[] = [];
|
|
249
|
+
for (const field of required) {
|
|
250
|
+
if (out.length >= budget) break;
|
|
251
|
+
const variant = { ...baseline };
|
|
252
|
+
delete variant[field.name];
|
|
253
|
+
out.push(
|
|
254
|
+
buildStep(ep, schemes, {
|
|
255
|
+
name: `missing required field "${field.name}" — must reject (no 5xx)`,
|
|
256
|
+
json: variant,
|
|
257
|
+
useRealParents,
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function probeBoundaryString(
|
|
265
|
+
ep: EndpointInfo,
|
|
266
|
+
schemes: SecuritySchemeInfo[],
|
|
267
|
+
budget: number,
|
|
268
|
+
useRealParents: boolean,
|
|
269
|
+
): RawStep[] {
|
|
270
|
+
if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
|
|
271
|
+
const props = collectAllProps(ep.requestBodySchema).filter(
|
|
272
|
+
(p) => p.schema.type === "string",
|
|
273
|
+
);
|
|
274
|
+
if (props.length === 0) return [];
|
|
275
|
+
|
|
276
|
+
const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
|
|
277
|
+
if (typeof baseline !== "object" || baseline === null) return [];
|
|
278
|
+
|
|
279
|
+
const out: RawStep[] = [];
|
|
280
|
+
// Only probe the first N string fields to stay within budget
|
|
281
|
+
for (const field of props.slice(0, Math.max(1, Math.floor(budget / 3)))) {
|
|
282
|
+
if (out.length + 3 > budget) break;
|
|
283
|
+
out.push(
|
|
284
|
+
buildStep(ep, schemes, {
|
|
285
|
+
name: `${field.name}: empty string — must reject (no 5xx)`,
|
|
286
|
+
json: { ...baseline, [field.name]: "" },
|
|
287
|
+
useRealParents,
|
|
288
|
+
}),
|
|
289
|
+
buildStep(ep, schemes, {
|
|
290
|
+
name: `${field.name}: 10000-char string — must reject or accept (no 5xx)`,
|
|
291
|
+
json: { ...baseline, [field.name]: LONG_STRING },
|
|
292
|
+
useRealParents,
|
|
293
|
+
}),
|
|
294
|
+
buildStep(ep, schemes, {
|
|
295
|
+
name: `${field.name}: unicode/emoji/RTL — must not 5xx`,
|
|
296
|
+
json: { ...baseline, [field.name]: UNICODE_MIX },
|
|
297
|
+
useRealParents,
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function probeTypeConfusion(
|
|
305
|
+
ep: EndpointInfo,
|
|
306
|
+
schemes: SecuritySchemeInfo[],
|
|
307
|
+
budget: number,
|
|
308
|
+
useRealParents: boolean,
|
|
309
|
+
): RawStep[] {
|
|
310
|
+
if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
|
|
311
|
+
const props = collectAllProps(ep.requestBodySchema);
|
|
312
|
+
if (props.length === 0) return [];
|
|
313
|
+
|
|
314
|
+
const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
|
|
315
|
+
if (typeof baseline !== "object" || baseline === null) return [];
|
|
316
|
+
|
|
317
|
+
const out: RawStep[] = [];
|
|
318
|
+
for (const field of props) {
|
|
319
|
+
if (out.length >= budget) break;
|
|
320
|
+
const wrongValue = pickWrongType(field.schema);
|
|
321
|
+
if (wrongValue === undefined) continue;
|
|
322
|
+
out.push(
|
|
323
|
+
buildStep(ep, schemes, {
|
|
324
|
+
name: `${field.name}: wrong type (${describeType(field.schema)} → ${typeof wrongValue}) — must reject (no 5xx)`,
|
|
325
|
+
json: { ...baseline, [field.name]: wrongValue },
|
|
326
|
+
useRealParents,
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function probeInvalidFormat(
|
|
334
|
+
ep: EndpointInfo,
|
|
335
|
+
schemes: SecuritySchemeInfo[],
|
|
336
|
+
budget: number,
|
|
337
|
+
useRealParents: boolean,
|
|
338
|
+
): RawStep[] {
|
|
339
|
+
if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
|
|
340
|
+
const props = collectAllProps(ep.requestBodySchema);
|
|
341
|
+
const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
|
|
342
|
+
if (typeof baseline !== "object" || baseline === null) return [];
|
|
343
|
+
|
|
344
|
+
const out: RawStep[] = [];
|
|
345
|
+
for (const field of props) {
|
|
346
|
+
if (out.length >= budget) break;
|
|
347
|
+
const fmt = field.schema.format;
|
|
348
|
+
let badValue: string | undefined;
|
|
349
|
+
if (fmt === "email") badValue = INVALID_EMAIL_VALUES[0];
|
|
350
|
+
else if (fmt === "uri" || fmt === "url") badValue = INVALID_URI_VALUES[0];
|
|
351
|
+
else if (fmt === "date-time") badValue = INVALID_DATETIME_VALUES[0];
|
|
352
|
+
else if (fmt === "uuid") badValue = INVALID_UUID_VALUES[0];
|
|
353
|
+
if (badValue === undefined) continue;
|
|
354
|
+
out.push(
|
|
355
|
+
buildStep(ep, schemes, {
|
|
356
|
+
name: `${field.name}: invalid ${fmt} (${JSON.stringify(badValue)}) — must reject (no 5xx)`,
|
|
357
|
+
json: { ...baseline, [field.name]: badValue },
|
|
358
|
+
useRealParents,
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function probeInvalidEnum(
|
|
366
|
+
ep: EndpointInfo,
|
|
367
|
+
schemes: SecuritySchemeInfo[],
|
|
368
|
+
budget: number,
|
|
369
|
+
useRealParents: boolean,
|
|
370
|
+
): RawStep[] {
|
|
371
|
+
if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
|
|
372
|
+
const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
|
|
373
|
+
if (typeof baseline !== "object" || baseline === null) return [];
|
|
374
|
+
|
|
375
|
+
const out: RawStep[] = [];
|
|
376
|
+
// Walk depth 1 for plain enum strings
|
|
377
|
+
for (const field of collectAllProps(ep.requestBodySchema)) {
|
|
378
|
+
if (out.length >= budget) break;
|
|
379
|
+
if (Array.isArray(field.schema.enum) && field.schema.enum.length > 0) {
|
|
380
|
+
out.push(
|
|
381
|
+
buildStep(ep, schemes, {
|
|
382
|
+
name: `${field.name}: unknown enum value "zond_invalid_value" — must reject (no 5xx)`,
|
|
383
|
+
json: { ...baseline, [field.name]: "zond_invalid_value" },
|
|
384
|
+
useRealParents,
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
// enum-of-strings inside an array (e.g. webhooks.events: [enum])
|
|
389
|
+
if (field.schema.type === "array" && field.schema.items) {
|
|
390
|
+
const items = field.schema.items as OpenAPIV3.SchemaObject;
|
|
391
|
+
const enumLike = Array.isArray(items.enum) && items.enum.length > 0;
|
|
392
|
+
const isStringArray = items.type === "string";
|
|
393
|
+
if (enumLike || isStringArray) {
|
|
394
|
+
// even when no enum is declared, names like "events"/"types"/"channels"
|
|
395
|
+
// strongly imply a backing whitelist — bug #05B
|
|
396
|
+
out.push(
|
|
397
|
+
buildStep(ep, schemes, {
|
|
398
|
+
name: `${field.name}: array with unknown value ["zond.nonexistent.event"] — must reject (no 5xx)`,
|
|
399
|
+
json: { ...baseline, [field.name]: ["zond.nonexistent.event"] },
|
|
400
|
+
useRealParents,
|
|
401
|
+
}),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function probeInvalidPathId(
|
|
410
|
+
ep: EndpointInfo,
|
|
411
|
+
schemes: SecuritySchemeInfo[],
|
|
412
|
+
budget: number,
|
|
413
|
+
useRealParents: boolean,
|
|
414
|
+
): RawStep[] {
|
|
415
|
+
const params = findUuidPathParams(ep);
|
|
416
|
+
if (params.length === 0) return [];
|
|
417
|
+
// Skip POST /resource (no path id) — covered by body probes
|
|
418
|
+
const out: RawStep[] = [];
|
|
419
|
+
for (const param of params) {
|
|
420
|
+
for (const bad of INVALID_UUID_VALUES) {
|
|
421
|
+
if (out.length >= budget) break;
|
|
422
|
+
const path = renderPath(ep, { name: param.name, value: bad }, { useRealParents });
|
|
423
|
+
out.push(
|
|
424
|
+
buildStep(ep, schemes, {
|
|
425
|
+
name: `path param ${param.name}=${JSON.stringify(bad)} — must reject (no 5xx)`,
|
|
426
|
+
pathOverride: path,
|
|
427
|
+
useRealParents,
|
|
428
|
+
}),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** TASK-67: numeric coercion probes for integer/number params (query + path).
|
|
436
|
+
* Round-2 found `GET /emails?limit=1.5` → 500 — the bug class probe-validation
|
|
437
|
+
* exists for. T49 covered numeric type-confusion in body, this extends to
|
|
438
|
+
* query/path. */
|
|
439
|
+
const NUMERIC_BAD_VALUES: Array<{ value: string; label: string }> = [
|
|
440
|
+
{ value: "1.5", label: "float on integer" },
|
|
441
|
+
{ value: "-1", label: "negative" },
|
|
442
|
+
{ value: "0", label: "zero" },
|
|
443
|
+
{ value: "abc", label: "non-numeric" },
|
|
444
|
+
{ value: "", label: "empty string" },
|
|
445
|
+
// null on a query param means literal "null" string — most parsers treat it as bad input
|
|
446
|
+
{ value: "null", label: "literal null" },
|
|
447
|
+
// Number.MAX_SAFE_INTEGER + 1 = 9007199254740992
|
|
448
|
+
{ value: "9007199254740992", label: "MAX_SAFE_INTEGER+1" },
|
|
449
|
+
];
|
|
450
|
+
|
|
451
|
+
function isNumericSchema(schema: OpenAPIV3.SchemaObject | undefined): boolean {
|
|
452
|
+
return schema?.type === "integer" || schema?.type === "number";
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function probeNumericQueryParams(
|
|
456
|
+
ep: EndpointInfo,
|
|
457
|
+
schemes: SecuritySchemeInfo[],
|
|
458
|
+
budget: number,
|
|
459
|
+
useRealParents: boolean,
|
|
460
|
+
): RawStep[] {
|
|
461
|
+
const numericQuery = ep.parameters.filter(
|
|
462
|
+
(p) => p.in === "query" && isNumericSchema(p.schema as OpenAPIV3.SchemaObject | undefined),
|
|
463
|
+
);
|
|
464
|
+
if (numericQuery.length === 0) return [];
|
|
465
|
+
|
|
466
|
+
const out: RawStep[] = [];
|
|
467
|
+
for (const param of numericQuery) {
|
|
468
|
+
for (const { value, label } of NUMERIC_BAD_VALUES) {
|
|
469
|
+
if (out.length >= budget) break;
|
|
470
|
+
const basePath = renderPath(ep, null, { useRealParents });
|
|
471
|
+
const sep = basePath.includes("?") ? "&" : "?";
|
|
472
|
+
const pathWithQuery = `${basePath}${sep}${param.name}=${encodeURIComponent(value)}`;
|
|
473
|
+
out.push(
|
|
474
|
+
buildStep(ep, schemes, {
|
|
475
|
+
name: `query ${param.name}=${JSON.stringify(value)} (${label}) — must reject (no 5xx)`,
|
|
476
|
+
pathOverride: pathWithQuery,
|
|
477
|
+
useRealParents,
|
|
478
|
+
}),
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return out;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function probeNumericPathParams(
|
|
486
|
+
ep: EndpointInfo,
|
|
487
|
+
schemes: SecuritySchemeInfo[],
|
|
488
|
+
budget: number,
|
|
489
|
+
useRealParents: boolean,
|
|
490
|
+
): RawStep[] {
|
|
491
|
+
const numericPath = ep.parameters.filter(
|
|
492
|
+
(p) => p.in === "path" && isNumericSchema(p.schema as OpenAPIV3.SchemaObject | undefined),
|
|
493
|
+
);
|
|
494
|
+
if (numericPath.length === 0) return [];
|
|
495
|
+
|
|
496
|
+
const out: RawStep[] = [];
|
|
497
|
+
for (const param of numericPath) {
|
|
498
|
+
for (const { value, label } of NUMERIC_BAD_VALUES) {
|
|
499
|
+
if (value === "") continue; // empty path segment yields a different endpoint
|
|
500
|
+
if (out.length >= budget) break;
|
|
501
|
+
const overriddenPath = renderPath(ep, { name: param.name, value }, { useRealParents });
|
|
502
|
+
out.push(
|
|
503
|
+
buildStep(ep, schemes, {
|
|
504
|
+
name: `path param ${param.name}=${JSON.stringify(value)} (${label}) — must reject (no 5xx)`,
|
|
505
|
+
pathOverride: overriddenPath,
|
|
506
|
+
useRealParents,
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ──────────────────────────────────────────────
|
|
515
|
+
// Type-confusion helpers
|
|
516
|
+
// ──────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
function pickWrongType(schema: OpenAPIV3.SchemaObject): unknown | undefined {
|
|
519
|
+
switch (schema.type) {
|
|
520
|
+
case "string":
|
|
521
|
+
return 12345; // number where string expected
|
|
522
|
+
case "integer":
|
|
523
|
+
case "number":
|
|
524
|
+
return "five"; // string where number expected
|
|
525
|
+
case "boolean":
|
|
526
|
+
return "true"; // string where boolean expected
|
|
527
|
+
case "array":
|
|
528
|
+
return { not: "an-array" }; // object where array expected
|
|
529
|
+
case "object":
|
|
530
|
+
return ["not", "an", "object"]; // array where object expected
|
|
531
|
+
default:
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function describeType(schema: OpenAPIV3.SchemaObject): string {
|
|
537
|
+
if (schema.format) return `${schema.type ?? "any"}/${schema.format}`;
|
|
538
|
+
return schema.type ?? "any";
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Build a cleanup-DELETE step for a single mutating probe. The capture var
|
|
542
|
+
* must come from the paired probe step. If the probe didn't capture (e.g.
|
|
543
|
+
* the API correctly returned 4xx and no resource was created), the runner
|
|
544
|
+
* skips this step via the standard "missing capture" path — exactly the
|
|
545
|
+
* semantics we want. */
|
|
546
|
+
function buildCleanupStep(
|
|
547
|
+
deleteEp: EndpointInfo,
|
|
548
|
+
schemes: SecuritySchemeInfo[],
|
|
549
|
+
captureVar: string,
|
|
550
|
+
probeStepName: string,
|
|
551
|
+
): RawStep {
|
|
552
|
+
// Replace the DELETE's path-id placeholder with our captured var.
|
|
553
|
+
const path = convertPath(deleteEp.path).replace(/\{\{[^}]+\}\}/, `{{${captureVar}}}`);
|
|
554
|
+
const headers = getAuthHeaders(deleteEp, schemes);
|
|
555
|
+
const step: RawStep = {
|
|
556
|
+
name: `cleanup leaked resource from "${probeStepName}"`,
|
|
557
|
+
source: {
|
|
558
|
+
generator: "negative-probe-cleanup",
|
|
559
|
+
endpoint: `DELETE ${deleteEp.path}`,
|
|
560
|
+
},
|
|
561
|
+
always: true,
|
|
562
|
+
DELETE: path,
|
|
563
|
+
expect: {
|
|
564
|
+
status: [200, 202, 204, 404],
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
if (headers) step.headers = headers;
|
|
568
|
+
return step;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ──────────────────────────────────────────────
|
|
572
|
+
// Public API
|
|
573
|
+
// ──────────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
export function generateNegativeProbes(opts: ProbeOptions): ProbeResult {
|
|
576
|
+
const { endpoints, securitySchemes } = opts;
|
|
577
|
+
const cap = opts.maxProbesPerEndpoint ?? 50;
|
|
578
|
+
const noCleanup = opts.noCleanup === true;
|
|
579
|
+
// TASK-135: default ON. Non-attacked path-params are emitted as runtime
|
|
580
|
+
// placeholders `{{name}}` and resolved from `.env.yaml` by `zond run`.
|
|
581
|
+
const useRealParents = opts.useRealParents !== false;
|
|
582
|
+
|
|
583
|
+
const suites: RawSuite[] = [];
|
|
584
|
+
const warnings: string[] = [];
|
|
585
|
+
let probedEndpoints = 0;
|
|
586
|
+
let skippedEndpoints = 0;
|
|
587
|
+
let totalProbes = 0;
|
|
588
|
+
|
|
589
|
+
for (const ep of endpoints) {
|
|
590
|
+
if (ep.deprecated) continue;
|
|
591
|
+
|
|
592
|
+
const steps: RawStep[] = [];
|
|
593
|
+
const remaining = () => Math.max(0, cap - steps.length);
|
|
594
|
+
|
|
595
|
+
// 1. Path-id probes (cheap, deterministic)
|
|
596
|
+
steps.push(...probeInvalidPathId(ep, securitySchemes, remaining(), useRealParents));
|
|
597
|
+
|
|
598
|
+
// 1b. Numeric query / path coercion probes (T67) — float-on-integer,
|
|
599
|
+
// negative, non-numeric, etc. Catches `GET /x?limit=1.5` → 500.
|
|
600
|
+
const numericQueryProbes = probeNumericQueryParams(ep, securitySchemes, remaining(), useRealParents);
|
|
601
|
+
steps.push(...numericQueryProbes);
|
|
602
|
+
const numericPathProbes = probeNumericPathParams(ep, securitySchemes, remaining(), useRealParents);
|
|
603
|
+
steps.push(...numericPathProbes);
|
|
604
|
+
const hasNumericCoercion = numericQueryProbes.length + numericPathProbes.length > 0;
|
|
605
|
+
|
|
606
|
+
// 2. Body probes (only for body-bearing methods)
|
|
607
|
+
const empty = probeEmptyBody(ep, securitySchemes, useRealParents);
|
|
608
|
+
if (empty && steps.length < cap) steps.push(empty);
|
|
609
|
+
|
|
610
|
+
steps.push(...probeMissingRequired(ep, securitySchemes, remaining(), useRealParents));
|
|
611
|
+
steps.push(...probeTypeConfusion(ep, securitySchemes, remaining(), useRealParents));
|
|
612
|
+
steps.push(...probeInvalidFormat(ep, securitySchemes, remaining(), useRealParents));
|
|
613
|
+
steps.push(...probeBoundaryString(ep, securitySchemes, remaining(), useRealParents));
|
|
614
|
+
steps.push(...probeInvalidEnum(ep, securitySchemes, remaining(), useRealParents));
|
|
615
|
+
|
|
616
|
+
if (steps.length === 0) {
|
|
617
|
+
skippedEndpoints++;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// T79: Cleanup for mutating probes. If a probe accidentally returns 2xx
|
|
622
|
+
// (the bug class probe-validation hunts for), the resource sticks around
|
|
623
|
+
// unless we follow up with a DELETE. We pair each probe step with a
|
|
624
|
+
// cleanup-DELETE marked `always: true`; the runner skips the DELETE
|
|
625
|
+
// automatically when no id was captured (i.e. the probe correctly got 4xx).
|
|
626
|
+
const cleanupSteps: RawStep[] = [];
|
|
627
|
+
if (isMutating(ep.method) && !noCleanup) {
|
|
628
|
+
const deleteEp = findDeleteCounterpart(ep, endpoints);
|
|
629
|
+
if (deleteEp) {
|
|
630
|
+
const idField = captureFieldFor(ep);
|
|
631
|
+
for (let i = 0; i < steps.length; i++) {
|
|
632
|
+
const probeStep = steps[i]!;
|
|
633
|
+
const captureVar = `leaked_id_${i}`;
|
|
634
|
+
const probeExpect = probeStep.expect as { body?: Record<string, unknown> };
|
|
635
|
+
if (!probeExpect.body) probeExpect.body = {};
|
|
636
|
+
// capture-only rule: doesn't add an assertion, just extracts the id
|
|
637
|
+
// when the response body has one. extractCaptures is a no-op when
|
|
638
|
+
// the field is absent (the typical 4xx case).
|
|
639
|
+
probeExpect.body[idField] = { capture: captureVar };
|
|
640
|
+
cleanupSteps.push(buildCleanupStep(deleteEp, securitySchemes, captureVar, probeStep.name));
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
warnings.push(
|
|
644
|
+
`${ep.method.toUpperCase()} ${ep.path}: probe-validation may create resources but spec defines no DELETE counterpart — manual cleanup required if any probe unexpectedly returns 2xx`,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (cleanupSteps.length > 0) {
|
|
649
|
+
steps.push(...cleanupSteps);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
probedEndpoints++;
|
|
653
|
+
totalProbes += steps.length;
|
|
654
|
+
|
|
655
|
+
// Hoist auth headers to suite level — every probe in this suite hits the
|
|
656
|
+
// same endpoint, so per-step headers are pure duplication. Dropping them
|
|
657
|
+
// here keeps generated YAML small and makes suite-level overrides
|
|
658
|
+
// (e.g. switching auth tokens) work as expected.
|
|
659
|
+
const suiteHeaders = getAuthHeaders(ep, securitySchemes);
|
|
660
|
+
if (suiteHeaders) {
|
|
661
|
+
for (const step of steps) {
|
|
662
|
+
if (step.headers && headersEqual(step.headers as Record<string, string>, suiteHeaders)) {
|
|
663
|
+
delete (step as { headers?: unknown }).headers;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const stem = endpointStem(ep);
|
|
669
|
+
const suite: RawSuite = {
|
|
670
|
+
name: `probe ${ep.method} ${ep.path}`,
|
|
671
|
+
tags: [
|
|
672
|
+
"probe-validation",
|
|
673
|
+
"negative-input",
|
|
674
|
+
"no-5xx",
|
|
675
|
+
...(hasNumericCoercion ? ["query-coercion"] : []),
|
|
676
|
+
],
|
|
677
|
+
source: {
|
|
678
|
+
type: "probe-suite",
|
|
679
|
+
generator: "negative-probe",
|
|
680
|
+
endpoint: `${ep.method.toUpperCase()} ${ep.path}`,
|
|
681
|
+
},
|
|
682
|
+
fileStem: `probe-${stem}`,
|
|
683
|
+
base_url: "{{base_url}}",
|
|
684
|
+
...(suiteHeaders ? { headers: suiteHeaders } : {}),
|
|
685
|
+
tests: steps,
|
|
686
|
+
};
|
|
687
|
+
suites.push(suite);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return { suites, probedEndpoints, skippedEndpoints, totalProbes, warnings };
|
|
691
|
+
}
|