@kirrosh/zond 0.21.0 → 0.22.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 +110 -3
- package/README.md +26 -15
- package/package.json +10 -6
- package/src/cli/commands/ci-init.ts +12 -6
- package/src/cli/commands/completions.ts +176 -0
- package/src/cli/commands/db.ts +2 -1
- package/src/cli/commands/generate.ts +0 -1
- package/src/cli/commands/init/agents-md.ts +61 -0
- package/src/cli/commands/init/bootstrap.ts +79 -0
- package/src/cli/commands/init/skills.ts +45 -0
- package/src/cli/commands/init/templates/agents.md +73 -0
- package/src/cli/commands/init/templates/markdown.d.ts +4 -0
- package/src/cli/commands/init/templates/skills/scenarios.md +97 -0
- package/src/cli/commands/init/templates/skills/zond.md +184 -0
- package/src/cli/commands/init/templates/zond-config.yml +15 -0
- package/src/cli/commands/init.ts +124 -31
- package/src/cli/commands/probe-methods.ts +108 -0
- package/src/cli/commands/probe-validation.ts +124 -0
- package/src/cli/commands/run.ts +99 -10
- package/src/cli/commands/serve.ts +52 -19
- package/src/cli/commands/sync.ts +0 -1
- package/src/cli/commands/update.ts +1 -1
- package/src/cli/commands/use.ts +57 -0
- package/src/cli/index.ts +21 -609
- package/src/cli/program.ts +655 -0
- package/src/cli/version.ts +3 -0
- package/src/core/context/current.ts +35 -0
- package/src/core/diagnostics/db-analysis.ts +11 -2
- package/src/core/diagnostics/render-md.ts +112 -0
- package/src/core/generator/chunker.ts +14 -2
- package/src/core/generator/data-factory.ts +50 -19
- package/src/core/generator/guide-builder.ts +1 -1
- package/src/core/generator/openapi-reader.ts +18 -0
- package/src/core/generator/serializer.ts +11 -2
- package/src/core/generator/suite-generator.ts +106 -7
- package/src/core/meta/types.ts +0 -2
- package/src/core/parser/schema.ts +3 -1
- package/src/core/parser/types.ts +10 -1
- package/src/core/parser/variables.ts +90 -2
- package/src/core/parser/yaml-parser.ts +50 -1
- package/src/core/probe/method-probe.ts +197 -0
- package/src/core/probe/negative-probe.ts +657 -0
- package/src/core/reporter/console.ts +29 -3
- package/src/core/reporter/index.ts +2 -2
- package/src/core/reporter/json.ts +5 -2
- package/src/core/runner/assertions.ts +4 -1
- package/src/core/runner/executor.ts +132 -37
- package/src/core/runner/http-client.ts +40 -5
- package/src/core/runner/rate-limiter.ts +131 -0
- package/src/core/setup-api.ts +4 -1
- package/src/core/workspace/root.ts +94 -0
- package/src/db/schema.ts +4 -1
|
@@ -0,0 +1,657 @@
|
|
|
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]).
|
|
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
|
+
* The probes are deterministic — same spec → same suites — so the generated
|
|
14
|
+
* YAML can be committed as a regression test.
|
|
15
|
+
*/
|
|
16
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
17
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
|
|
18
|
+
import type { RawSuite, RawStep } from "../generator/serializer.ts";
|
|
19
|
+
import { generateFromSchema } from "../generator/data-factory.ts";
|
|
20
|
+
|
|
21
|
+
// ──────────────────────────────────────────────
|
|
22
|
+
// Constants
|
|
23
|
+
// ──────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** Statuses we consider an *acceptable* response to invalid input. Anything
|
|
26
|
+
* outside this set (notably 5xx, but also 200/201 which would mean the API
|
|
27
|
+
* silently accepted the bad input) is a probe failure. */
|
|
28
|
+
const ACCEPTABLE_4XX = [400, 401, 403, 404, 405, 409, 415, 422];
|
|
29
|
+
|
|
30
|
+
/** Long string for boundary probes — 10_000 chars. */
|
|
31
|
+
const LONG_STRING = "a".repeat(10_000);
|
|
32
|
+
|
|
33
|
+
/** Mixed unicode + emoji + RTL for charset probes. */
|
|
34
|
+
const UNICODE_MIX = "Mix🌐مرحبا\u200B";
|
|
35
|
+
|
|
36
|
+
/** Sentinel non-UUID inputs for path/UUID probes. */
|
|
37
|
+
const INVALID_UUID_VALUES = [
|
|
38
|
+
"not-a-uuid",
|
|
39
|
+
"12345",
|
|
40
|
+
"00000000",
|
|
41
|
+
"../../etc/passwd",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Sentinel invalid emails. */
|
|
45
|
+
const INVALID_EMAIL_VALUES = [
|
|
46
|
+
"not-an-email",
|
|
47
|
+
"@no-local.example.com",
|
|
48
|
+
"spaces in@email.com",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Sentinel invalid URIs. */
|
|
52
|
+
const INVALID_URI_VALUES = [
|
|
53
|
+
"not a url",
|
|
54
|
+
"javascript:alert(1)",
|
|
55
|
+
"ftp:/missing-slash",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** Sentinel invalid date-time strings. */
|
|
59
|
+
const INVALID_DATETIME_VALUES = [
|
|
60
|
+
"yesterday",
|
|
61
|
+
"2023-13-45T99:99:99Z",
|
|
62
|
+
"2023-10-06:23:47:56.678Z", // colon-instead-of-T (real bug we caught)
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// ──────────────────────────────────────────────
|
|
66
|
+
// Types
|
|
67
|
+
// ──────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export interface ProbeOptions {
|
|
70
|
+
endpoints: EndpointInfo[];
|
|
71
|
+
securitySchemes: SecuritySchemeInfo[];
|
|
72
|
+
/** Cap probes per endpoint (default 50). Hard cutoff for huge schemas. */
|
|
73
|
+
maxProbesPerEndpoint?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Skip emission of follow-up DELETE cleanup steps for mutating probes
|
|
76
|
+
* (POST/PUT/PATCH). Useful for namespace-isolated test environments
|
|
77
|
+
* (staging dump-and-reset) where cleanup is handled out of band.
|
|
78
|
+
*/
|
|
79
|
+
noCleanup?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ProbeResult {
|
|
83
|
+
suites: RawSuite[];
|
|
84
|
+
/** Number of endpoints that received probes. */
|
|
85
|
+
probedEndpoints: number;
|
|
86
|
+
/** Endpoints we skipped (no body & no UUID path params). */
|
|
87
|
+
skippedEndpoints: number;
|
|
88
|
+
/** Total generated probe steps. */
|
|
89
|
+
totalProbes: number;
|
|
90
|
+
/**
|
|
91
|
+
* Generation-time warnings — typically about mutating endpoints whose
|
|
92
|
+
* probes might leak resources because the spec defines no DELETE
|
|
93
|
+
* counterpart. CLI surfaces these to the user.
|
|
94
|
+
*/
|
|
95
|
+
warnings: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ──────────────────────────────────────────────
|
|
99
|
+
// Helpers
|
|
100
|
+
// ──────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function convertPath(path: string): string {
|
|
103
|
+
return path.replace(/\{([^}]+)\}/g, "{{$1}}");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function slugify(s: string): string {
|
|
107
|
+
return s
|
|
108
|
+
.toLowerCase()
|
|
109
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
110
|
+
.replace(/^-|-$/g, "");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function endpointStem(ep: EndpointInfo): string {
|
|
114
|
+
const path = ep.path
|
|
115
|
+
.replace(/\{[^}]+\}/g, "by-id")
|
|
116
|
+
.replace(/^\//, "")
|
|
117
|
+
.replace(/\//g, "-");
|
|
118
|
+
return slugify(`${ep.method.toLowerCase()}-${path}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getAuthHeaders(
|
|
122
|
+
ep: EndpointInfo,
|
|
123
|
+
schemes: SecuritySchemeInfo[],
|
|
124
|
+
): Record<string, string> | undefined {
|
|
125
|
+
if (ep.security.length === 0) return undefined;
|
|
126
|
+
for (const secName of ep.security) {
|
|
127
|
+
const scheme = schemes.find((s) => s.name === secName);
|
|
128
|
+
if (!scheme) continue;
|
|
129
|
+
if (scheme.type === "http") {
|
|
130
|
+
if (scheme.scheme === "bearer" || !scheme.scheme) {
|
|
131
|
+
return { Authorization: "Bearer {{auth_token}}" };
|
|
132
|
+
}
|
|
133
|
+
if (scheme.scheme === "basic") {
|
|
134
|
+
return { Authorization: "Basic {{auth_token}}" };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (scheme.type === "apiKey" && scheme.in === "header" && scheme.apiKeyName) {
|
|
138
|
+
if (scheme.apiKeyName === "Authorization") {
|
|
139
|
+
return { Authorization: "Bearer {{auth_token}}" };
|
|
140
|
+
}
|
|
141
|
+
return { [scheme.apiKeyName]: "{{api_key}}" };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Path with placeholders replaced by valid-but-nonexistent IDs (for body probes
|
|
148
|
+
* on PUT/PATCH/DELETE — we don't want path validation to mask body errors). */
|
|
149
|
+
function pathWithPlaceholders(ep: EndpointInfo, badId: string): string {
|
|
150
|
+
return ep.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
151
|
+
const param = ep.parameters.find((p) => p.name === name && p.in === "path");
|
|
152
|
+
const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
153
|
+
if (badId === "valid-shape") {
|
|
154
|
+
if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
|
|
155
|
+
if (schema?.type === "integer" || schema?.type === "number") return "999999999";
|
|
156
|
+
return "nonexistent-zzzzz";
|
|
157
|
+
}
|
|
158
|
+
return badId;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function findUuidPathParams(ep: EndpointInfo): OpenAPIV3.ParameterObject[] {
|
|
163
|
+
return ep.parameters.filter((p) => {
|
|
164
|
+
if (p.in !== "path") return false;
|
|
165
|
+
const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
|
|
166
|
+
if (!schema) return false;
|
|
167
|
+
if (schema.format === "uuid") return true;
|
|
168
|
+
// also probe path params named like *_id / *_uuid
|
|
169
|
+
const lower = p.name.toLowerCase();
|
|
170
|
+
return lower === "id" || lower.endsWith("_id") || lower === "uuid";
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Walk schema and collect required-field paths up to depth 1 with their schema. */
|
|
175
|
+
function collectRequiredFields(
|
|
176
|
+
schema: OpenAPIV3.SchemaObject | undefined,
|
|
177
|
+
): Array<{ name: string; schema: OpenAPIV3.SchemaObject }> {
|
|
178
|
+
if (!schema || !schema.properties) return [];
|
|
179
|
+
const required = new Set(schema.required ?? []);
|
|
180
|
+
const out: Array<{ name: string; schema: OpenAPIV3.SchemaObject }> = [];
|
|
181
|
+
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
|
182
|
+
if (required.has(name)) {
|
|
183
|
+
out.push({ name, schema: propSchema as OpenAPIV3.SchemaObject });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Walk schema (depth 1) and collect all properties with their schema. */
|
|
190
|
+
function collectAllProps(
|
|
191
|
+
schema: OpenAPIV3.SchemaObject | undefined,
|
|
192
|
+
): Array<{ name: string; schema: OpenAPIV3.SchemaObject; required: boolean }> {
|
|
193
|
+
if (!schema || !schema.properties) return [];
|
|
194
|
+
const required = new Set(schema.required ?? []);
|
|
195
|
+
return Object.entries(schema.properties).map(([name, s]) => ({
|
|
196
|
+
name,
|
|
197
|
+
schema: s as OpenAPIV3.SchemaObject,
|
|
198
|
+
required: required.has(name),
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ──────────────────────────────────────────────
|
|
203
|
+
// Probe generators
|
|
204
|
+
// ──────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build a step that targets `endpoint`, but with an arbitrary body override.
|
|
208
|
+
* Authentication and required path params are populated with valid placeholders
|
|
209
|
+
* so the request reaches the body-validation layer.
|
|
210
|
+
*/
|
|
211
|
+
function buildStep(
|
|
212
|
+
ep: EndpointInfo,
|
|
213
|
+
schemes: SecuritySchemeInfo[],
|
|
214
|
+
opts: {
|
|
215
|
+
name: string;
|
|
216
|
+
json?: unknown;
|
|
217
|
+
pathOverride?: string;
|
|
218
|
+
expectStatusOk?: number[];
|
|
219
|
+
},
|
|
220
|
+
): RawStep {
|
|
221
|
+
const method = ep.method.toUpperCase();
|
|
222
|
+
const path = opts.pathOverride ?? pathWithPlaceholders(ep, "valid-shape");
|
|
223
|
+
const headers = getAuthHeaders(ep, schemes);
|
|
224
|
+
|
|
225
|
+
const step: RawStep = {
|
|
226
|
+
name: opts.name,
|
|
227
|
+
[method]: convertPath(path),
|
|
228
|
+
expect: {
|
|
229
|
+
status: opts.expectStatusOk ?? ACCEPTABLE_4XX,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
if (headers) step.headers = headers;
|
|
233
|
+
if (opts.json !== undefined) (step as any).json = opts.json;
|
|
234
|
+
return step;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function probeEmptyBody(ep: EndpointInfo, schemes: SecuritySchemeInfo[]): RawStep | null {
|
|
238
|
+
if (!hasJsonBody(ep)) return null;
|
|
239
|
+
const required = collectRequiredFields(ep.requestBodySchema);
|
|
240
|
+
// Only meaningful when there *is* required data — otherwise {} is valid.
|
|
241
|
+
if (required.length === 0) return null;
|
|
242
|
+
return buildStep(ep, schemes, {
|
|
243
|
+
name: "empty body — must reject (no 5xx)",
|
|
244
|
+
json: {},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function probeMissingRequired(
|
|
249
|
+
ep: EndpointInfo,
|
|
250
|
+
schemes: SecuritySchemeInfo[],
|
|
251
|
+
budget: number,
|
|
252
|
+
): RawStep[] {
|
|
253
|
+
if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
|
|
254
|
+
const required = collectRequiredFields(ep.requestBodySchema);
|
|
255
|
+
if (required.length === 0) return [];
|
|
256
|
+
|
|
257
|
+
// Build a baseline valid object, then drop one required field at a time.
|
|
258
|
+
const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
|
|
259
|
+
if (typeof baseline !== "object" || baseline === null) return [];
|
|
260
|
+
|
|
261
|
+
const out: RawStep[] = [];
|
|
262
|
+
for (const field of required) {
|
|
263
|
+
if (out.length >= budget) break;
|
|
264
|
+
const variant = { ...baseline };
|
|
265
|
+
delete variant[field.name];
|
|
266
|
+
out.push(
|
|
267
|
+
buildStep(ep, schemes, {
|
|
268
|
+
name: `missing required field "${field.name}" — must reject (no 5xx)`,
|
|
269
|
+
json: variant,
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function probeBoundaryString(
|
|
277
|
+
ep: EndpointInfo,
|
|
278
|
+
schemes: SecuritySchemeInfo[],
|
|
279
|
+
budget: number,
|
|
280
|
+
): RawStep[] {
|
|
281
|
+
if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
|
|
282
|
+
const props = collectAllProps(ep.requestBodySchema).filter(
|
|
283
|
+
(p) => p.schema.type === "string",
|
|
284
|
+
);
|
|
285
|
+
if (props.length === 0) return [];
|
|
286
|
+
|
|
287
|
+
const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
|
|
288
|
+
if (typeof baseline !== "object" || baseline === null) return [];
|
|
289
|
+
|
|
290
|
+
const out: RawStep[] = [];
|
|
291
|
+
// Only probe the first N string fields to stay within budget
|
|
292
|
+
for (const field of props.slice(0, Math.max(1, Math.floor(budget / 3)))) {
|
|
293
|
+
if (out.length + 3 > budget) break;
|
|
294
|
+
out.push(
|
|
295
|
+
buildStep(ep, schemes, {
|
|
296
|
+
name: `${field.name}: empty string — must reject (no 5xx)`,
|
|
297
|
+
json: { ...baseline, [field.name]: "" },
|
|
298
|
+
}),
|
|
299
|
+
buildStep(ep, schemes, {
|
|
300
|
+
name: `${field.name}: 10000-char string — must reject or accept (no 5xx)`,
|
|
301
|
+
json: { ...baseline, [field.name]: LONG_STRING },
|
|
302
|
+
}),
|
|
303
|
+
buildStep(ep, schemes, {
|
|
304
|
+
name: `${field.name}: unicode/emoji/RTL — must not 5xx`,
|
|
305
|
+
json: { ...baseline, [field.name]: UNICODE_MIX },
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function probeTypeConfusion(
|
|
313
|
+
ep: EndpointInfo,
|
|
314
|
+
schemes: SecuritySchemeInfo[],
|
|
315
|
+
budget: number,
|
|
316
|
+
): RawStep[] {
|
|
317
|
+
if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
|
|
318
|
+
const props = collectAllProps(ep.requestBodySchema);
|
|
319
|
+
if (props.length === 0) return [];
|
|
320
|
+
|
|
321
|
+
const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
|
|
322
|
+
if (typeof baseline !== "object" || baseline === null) return [];
|
|
323
|
+
|
|
324
|
+
const out: RawStep[] = [];
|
|
325
|
+
for (const field of props) {
|
|
326
|
+
if (out.length >= budget) break;
|
|
327
|
+
const wrongValue = pickWrongType(field.schema);
|
|
328
|
+
if (wrongValue === undefined) continue;
|
|
329
|
+
out.push(
|
|
330
|
+
buildStep(ep, schemes, {
|
|
331
|
+
name: `${field.name}: wrong type (${describeType(field.schema)} → ${typeof wrongValue}) — must reject (no 5xx)`,
|
|
332
|
+
json: { ...baseline, [field.name]: wrongValue },
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function probeInvalidFormat(
|
|
340
|
+
ep: EndpointInfo,
|
|
341
|
+
schemes: SecuritySchemeInfo[],
|
|
342
|
+
budget: number,
|
|
343
|
+
): RawStep[] {
|
|
344
|
+
if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
|
|
345
|
+
const props = collectAllProps(ep.requestBodySchema);
|
|
346
|
+
const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
|
|
347
|
+
if (typeof baseline !== "object" || baseline === null) return [];
|
|
348
|
+
|
|
349
|
+
const out: RawStep[] = [];
|
|
350
|
+
for (const field of props) {
|
|
351
|
+
if (out.length >= budget) break;
|
|
352
|
+
const fmt = field.schema.format;
|
|
353
|
+
let badValue: string | undefined;
|
|
354
|
+
if (fmt === "email") badValue = INVALID_EMAIL_VALUES[0];
|
|
355
|
+
else if (fmt === "uri" || fmt === "url") badValue = INVALID_URI_VALUES[0];
|
|
356
|
+
else if (fmt === "date-time") badValue = INVALID_DATETIME_VALUES[0];
|
|
357
|
+
else if (fmt === "uuid") badValue = INVALID_UUID_VALUES[0];
|
|
358
|
+
if (badValue === undefined) continue;
|
|
359
|
+
out.push(
|
|
360
|
+
buildStep(ep, schemes, {
|
|
361
|
+
name: `${field.name}: invalid ${fmt} (${JSON.stringify(badValue)}) — must reject (no 5xx)`,
|
|
362
|
+
json: { ...baseline, [field.name]: badValue },
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function probeInvalidEnum(
|
|
370
|
+
ep: EndpointInfo,
|
|
371
|
+
schemes: SecuritySchemeInfo[],
|
|
372
|
+
budget: number,
|
|
373
|
+
): RawStep[] {
|
|
374
|
+
if (!hasJsonBody(ep) || !ep.requestBodySchema) return [];
|
|
375
|
+
const baseline = generateFromSchema(ep.requestBodySchema) as Record<string, unknown>;
|
|
376
|
+
if (typeof baseline !== "object" || baseline === null) return [];
|
|
377
|
+
|
|
378
|
+
const out: RawStep[] = [];
|
|
379
|
+
// Walk depth 1 for plain enum strings
|
|
380
|
+
for (const field of collectAllProps(ep.requestBodySchema)) {
|
|
381
|
+
if (out.length >= budget) break;
|
|
382
|
+
if (Array.isArray(field.schema.enum) && field.schema.enum.length > 0) {
|
|
383
|
+
out.push(
|
|
384
|
+
buildStep(ep, schemes, {
|
|
385
|
+
name: `${field.name}: unknown enum value "zond_invalid_value" — must reject (no 5xx)`,
|
|
386
|
+
json: { ...baseline, [field.name]: "zond_invalid_value" },
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
// enum-of-strings inside an array (e.g. webhooks.events: [enum])
|
|
391
|
+
if (field.schema.type === "array" && field.schema.items) {
|
|
392
|
+
const items = field.schema.items as OpenAPIV3.SchemaObject;
|
|
393
|
+
const enumLike = Array.isArray(items.enum) && items.enum.length > 0;
|
|
394
|
+
const isStringArray = items.type === "string";
|
|
395
|
+
if (enumLike || isStringArray) {
|
|
396
|
+
// even when no enum is declared, names like "events"/"types"/"channels"
|
|
397
|
+
// strongly imply a backing whitelist — bug #05B
|
|
398
|
+
out.push(
|
|
399
|
+
buildStep(ep, schemes, {
|
|
400
|
+
name: `${field.name}: array with unknown value ["zond.nonexistent.event"] — must reject (no 5xx)`,
|
|
401
|
+
json: { ...baseline, [field.name]: ["zond.nonexistent.event"] },
|
|
402
|
+
}),
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function probeInvalidPathId(
|
|
411
|
+
ep: EndpointInfo,
|
|
412
|
+
schemes: SecuritySchemeInfo[],
|
|
413
|
+
budget: number,
|
|
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 = ep.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
423
|
+
if (name === param.name) return bad;
|
|
424
|
+
const other = ep.parameters.find((p) => p.name === name && p.in === "path");
|
|
425
|
+
const schema = other?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
426
|
+
if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
|
|
427
|
+
if (schema?.type === "integer" || schema?.type === "number") return "999999999";
|
|
428
|
+
return "nonexistent-zzzzz";
|
|
429
|
+
});
|
|
430
|
+
out.push(
|
|
431
|
+
buildStep(ep, schemes, {
|
|
432
|
+
name: `path param ${param.name}=${JSON.stringify(bad)} — must reject (no 5xx)`,
|
|
433
|
+
pathOverride: path,
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ──────────────────────────────────────────────
|
|
442
|
+
// Type-confusion helpers
|
|
443
|
+
// ──────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
function pickWrongType(schema: OpenAPIV3.SchemaObject): unknown | undefined {
|
|
446
|
+
switch (schema.type) {
|
|
447
|
+
case "string":
|
|
448
|
+
return 12345; // number where string expected
|
|
449
|
+
case "integer":
|
|
450
|
+
case "number":
|
|
451
|
+
return "five"; // string where number expected
|
|
452
|
+
case "boolean":
|
|
453
|
+
return "true"; // string where boolean expected
|
|
454
|
+
case "array":
|
|
455
|
+
return { not: "an-array" }; // object where array expected
|
|
456
|
+
case "object":
|
|
457
|
+
return ["not", "an", "object"]; // array where object expected
|
|
458
|
+
default:
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function describeType(schema: OpenAPIV3.SchemaObject): string {
|
|
464
|
+
if (schema.format) return `${schema.type ?? "any"}/${schema.format}`;
|
|
465
|
+
return schema.type ?? "any";
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function hasJsonBody(ep: EndpointInfo): boolean {
|
|
469
|
+
return (
|
|
470
|
+
ep.method !== "GET" &&
|
|
471
|
+
ep.method !== "DELETE" &&
|
|
472
|
+
ep.requestBodyContentType === "application/json" &&
|
|
473
|
+
ep.requestBodySchema !== undefined
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function escapeRegex(s: string): string {
|
|
478
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function isMutating(method: string): boolean {
|
|
482
|
+
const m = method.toUpperCase();
|
|
483
|
+
return m === "POST" || m === "PUT" || m === "PATCH";
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Find the DELETE endpoint that owns resources created by `ep`:
|
|
488
|
+
* - POST /collection → DELETE /collection/{id}
|
|
489
|
+
* - PUT /collection/{id} → DELETE /collection/{id} (same path)
|
|
490
|
+
* - PATCH /collection/{id} → DELETE /collection/{id} (same path)
|
|
491
|
+
*/
|
|
492
|
+
function findDeleteCounterpart(
|
|
493
|
+
ep: EndpointInfo,
|
|
494
|
+
all: EndpointInfo[],
|
|
495
|
+
): EndpointInfo | undefined {
|
|
496
|
+
const m = ep.method.toUpperCase();
|
|
497
|
+
if (m === "POST") {
|
|
498
|
+
const re = new RegExp(`^${escapeRegex(ep.path)}/\\{[^}]+\\}$`);
|
|
499
|
+
return all.find(e => e.method.toUpperCase() === "DELETE" && !e.deprecated && re.test(e.path));
|
|
500
|
+
}
|
|
501
|
+
if (m === "PUT" || m === "PATCH") {
|
|
502
|
+
return all.find(e => e.method.toUpperCase() === "DELETE" && !e.deprecated && e.path === ep.path);
|
|
503
|
+
}
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Pick the response field that holds the new resource's id. Defaults to "id";
|
|
509
|
+
* falls back to the first integer / uuid property when no `id` field exists.
|
|
510
|
+
*/
|
|
511
|
+
function captureFieldFor(ep: EndpointInfo): string {
|
|
512
|
+
const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300 && r.schema);
|
|
513
|
+
const schema = success?.schema;
|
|
514
|
+
if (schema?.properties) {
|
|
515
|
+
if ("id" in schema.properties) return "id";
|
|
516
|
+
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
|
517
|
+
const s = propSchema as OpenAPIV3.SchemaObject;
|
|
518
|
+
if (s.type === "integer" || s.format === "uuid") return name;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return "id";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Build a cleanup-DELETE step for a single mutating probe. The capture var
|
|
525
|
+
* must come from the paired probe step. If the probe didn't capture (e.g.
|
|
526
|
+
* the API correctly returned 4xx and no resource was created), the runner
|
|
527
|
+
* skips this step via the standard "missing capture" path — exactly the
|
|
528
|
+
* semantics we want. */
|
|
529
|
+
function buildCleanupStep(
|
|
530
|
+
deleteEp: EndpointInfo,
|
|
531
|
+
schemes: SecuritySchemeInfo[],
|
|
532
|
+
captureVar: string,
|
|
533
|
+
probeStepName: string,
|
|
534
|
+
): RawStep {
|
|
535
|
+
// Replace the DELETE's path-id placeholder with our captured var.
|
|
536
|
+
const path = convertPath(deleteEp.path).replace(/\{\{[^}]+\}\}/, `{{${captureVar}}}`);
|
|
537
|
+
const headers = getAuthHeaders(deleteEp, schemes);
|
|
538
|
+
const step: RawStep = {
|
|
539
|
+
name: `cleanup leaked resource from "${probeStepName}"`,
|
|
540
|
+
always: true,
|
|
541
|
+
DELETE: path,
|
|
542
|
+
expect: {
|
|
543
|
+
status: [200, 202, 204, 404],
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
if (headers) step.headers = headers;
|
|
547
|
+
return step;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function headersEqual(a: Record<string, string>, b: Record<string, string>): boolean {
|
|
551
|
+
const ka = Object.keys(a);
|
|
552
|
+
const kb = Object.keys(b);
|
|
553
|
+
if (ka.length !== kb.length) return false;
|
|
554
|
+
for (const k of ka) if (a[k] !== b[k]) return false;
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ──────────────────────────────────────────────
|
|
559
|
+
// Public API
|
|
560
|
+
// ──────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
export function generateNegativeProbes(opts: ProbeOptions): ProbeResult {
|
|
563
|
+
const { endpoints, securitySchemes } = opts;
|
|
564
|
+
const cap = opts.maxProbesPerEndpoint ?? 50;
|
|
565
|
+
const noCleanup = opts.noCleanup === true;
|
|
566
|
+
|
|
567
|
+
const suites: RawSuite[] = [];
|
|
568
|
+
const warnings: string[] = [];
|
|
569
|
+
let probedEndpoints = 0;
|
|
570
|
+
let skippedEndpoints = 0;
|
|
571
|
+
let totalProbes = 0;
|
|
572
|
+
|
|
573
|
+
for (const ep of endpoints) {
|
|
574
|
+
if (ep.deprecated) continue;
|
|
575
|
+
|
|
576
|
+
const steps: RawStep[] = [];
|
|
577
|
+
const remaining = () => Math.max(0, cap - steps.length);
|
|
578
|
+
|
|
579
|
+
// 1. Path-id probes (cheap, deterministic)
|
|
580
|
+
steps.push(...probeInvalidPathId(ep, securitySchemes, remaining()));
|
|
581
|
+
|
|
582
|
+
// 2. Body probes (only for body-bearing methods)
|
|
583
|
+
const empty = probeEmptyBody(ep, securitySchemes);
|
|
584
|
+
if (empty && steps.length < cap) steps.push(empty);
|
|
585
|
+
|
|
586
|
+
steps.push(...probeMissingRequired(ep, securitySchemes, remaining()));
|
|
587
|
+
steps.push(...probeTypeConfusion(ep, securitySchemes, remaining()));
|
|
588
|
+
steps.push(...probeInvalidFormat(ep, securitySchemes, remaining()));
|
|
589
|
+
steps.push(...probeBoundaryString(ep, securitySchemes, remaining()));
|
|
590
|
+
steps.push(...probeInvalidEnum(ep, securitySchemes, remaining()));
|
|
591
|
+
|
|
592
|
+
if (steps.length === 0) {
|
|
593
|
+
skippedEndpoints++;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// T79: Cleanup for mutating probes. If a probe accidentally returns 2xx
|
|
598
|
+
// (the bug class probe-validation hunts for), the resource sticks around
|
|
599
|
+
// unless we follow up with a DELETE. We pair each probe step with a
|
|
600
|
+
// cleanup-DELETE marked `always: true`; the runner skips the DELETE
|
|
601
|
+
// automatically when no id was captured (i.e. the probe correctly got 4xx).
|
|
602
|
+
const cleanupSteps: RawStep[] = [];
|
|
603
|
+
if (isMutating(ep.method) && !noCleanup) {
|
|
604
|
+
const deleteEp = findDeleteCounterpart(ep, endpoints);
|
|
605
|
+
if (deleteEp) {
|
|
606
|
+
const idField = captureFieldFor(ep);
|
|
607
|
+
for (let i = 0; i < steps.length; i++) {
|
|
608
|
+
const probeStep = steps[i]!;
|
|
609
|
+
const captureVar = `leaked_id_${i}`;
|
|
610
|
+
const probeExpect = probeStep.expect as { body?: Record<string, unknown> };
|
|
611
|
+
if (!probeExpect.body) probeExpect.body = {};
|
|
612
|
+
// capture-only rule: doesn't add an assertion, just extracts the id
|
|
613
|
+
// when the response body has one. extractCaptures is a no-op when
|
|
614
|
+
// the field is absent (the typical 4xx case).
|
|
615
|
+
probeExpect.body[idField] = { capture: captureVar };
|
|
616
|
+
cleanupSteps.push(buildCleanupStep(deleteEp, securitySchemes, captureVar, probeStep.name));
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
warnings.push(
|
|
620
|
+
`${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`,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (cleanupSteps.length > 0) {
|
|
625
|
+
steps.push(...cleanupSteps);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
probedEndpoints++;
|
|
629
|
+
totalProbes += steps.length;
|
|
630
|
+
|
|
631
|
+
// Hoist auth headers to suite level — every probe in this suite hits the
|
|
632
|
+
// same endpoint, so per-step headers are pure duplication. Dropping them
|
|
633
|
+
// here keeps generated YAML small and makes suite-level overrides
|
|
634
|
+
// (e.g. switching auth tokens) work as expected.
|
|
635
|
+
const suiteHeaders = getAuthHeaders(ep, securitySchemes);
|
|
636
|
+
if (suiteHeaders) {
|
|
637
|
+
for (const step of steps) {
|
|
638
|
+
if (step.headers && headersEqual(step.headers as Record<string, string>, suiteHeaders)) {
|
|
639
|
+
delete (step as { headers?: unknown }).headers;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const stem = endpointStem(ep);
|
|
645
|
+
const suite: RawSuite = {
|
|
646
|
+
name: `probe ${ep.method} ${ep.path}`,
|
|
647
|
+
tags: ["probe-validation", "negative-input", "no-5xx"],
|
|
648
|
+
fileStem: `probe-${stem}`,
|
|
649
|
+
base_url: "{{base_url}}",
|
|
650
|
+
...(suiteHeaders ? { headers: suiteHeaders } : {}),
|
|
651
|
+
tests: steps,
|
|
652
|
+
};
|
|
653
|
+
suites.push(suite);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return { suites, probedEndpoints, skippedEndpoints, totalProbes, warnings };
|
|
657
|
+
}
|