@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,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TASK-146: emit-template generator.
|
|
3
|
+
*
|
|
4
|
+
* Builds a ready-to-edit YAML probe template for a single endpoint, so the
|
|
5
|
+
* user doesn't have to copy-paste the boilerplate from the skill (Phase 5.1).
|
|
6
|
+
* Used when the auto-prober marked an endpoint INCONCLUSIVE / INCONCLUSIVE-5XX
|
|
7
|
+
* and the user wants to drop down to manual catch-up.
|
|
8
|
+
*
|
|
9
|
+
* Heuristics:
|
|
10
|
+
* - Suspected fields: classic mass-assignment vectors (is_admin, role,
|
|
11
|
+
* owner_id, account_id, ...).
|
|
12
|
+
* - readOnly: true / x-zond-protected fields lifted from the request body
|
|
13
|
+
* schema — these MUST NOT round-trip back from the server.
|
|
14
|
+
* - For POST endpoints with discoverable item path (GET-by-id / DELETE
|
|
15
|
+
* counterpart) we emit a full create → verify → cleanup chain.
|
|
16
|
+
*/
|
|
17
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
18
|
+
import type { EndpointInfo } from "../generator/types.ts";
|
|
19
|
+
import type { RawSuite, RawStep } from "../generator/serializer.ts";
|
|
20
|
+
import { serializeSuite } from "../generator/serializer.ts";
|
|
21
|
+
import { readOpenApiSpec, extractEndpoints } from "../generator/openapi-reader.ts";
|
|
22
|
+
import { findDeleteCounterpart, findGetByIdCounterpart, captureFieldFor } from "./shared.ts";
|
|
23
|
+
import { SUSPECTED_FIELDS } from "./mass-assignment-probe.ts";
|
|
24
|
+
|
|
25
|
+
export interface EmitTemplateOptions {
|
|
26
|
+
specPath: string;
|
|
27
|
+
method: string;
|
|
28
|
+
path: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type EmitTemplateResult =
|
|
32
|
+
| { kind: "ok"; yaml: string; chain: "full" | "single"; protectedFields: string[] }
|
|
33
|
+
| { kind: "endpoint-not-found"; method: string; path: string; nearest: string[] };
|
|
34
|
+
|
|
35
|
+
export async function buildMassAssignmentTemplate(
|
|
36
|
+
opts: EmitTemplateOptions,
|
|
37
|
+
): Promise<EmitTemplateResult> {
|
|
38
|
+
const doc = await readOpenApiSpec(opts.specPath);
|
|
39
|
+
const all = extractEndpoints(doc);
|
|
40
|
+
|
|
41
|
+
const wantMethod = opts.method.toUpperCase();
|
|
42
|
+
const target = all.find(
|
|
43
|
+
e => e.method.toUpperCase() === wantMethod && pathsEqual(e.path, opts.path),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (!target) {
|
|
47
|
+
const nearest = all
|
|
48
|
+
.filter(e => e.method.toUpperCase() === wantMethod)
|
|
49
|
+
.map(e => e.path)
|
|
50
|
+
.filter(p => similar(p, opts.path))
|
|
51
|
+
.slice(0, 5);
|
|
52
|
+
return { kind: "endpoint-not-found", method: wantMethod, path: opts.path, nearest };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const protectedFields = collectProtectedFields(target.requestBodySchema);
|
|
56
|
+
const baselineBody = buildBaselineSkeleton(target.requestBodySchema);
|
|
57
|
+
const privilegedBody = mergePrivileged(baselineBody, protectedFields);
|
|
58
|
+
|
|
59
|
+
const isMutatingCreateLike = wantMethod === "POST";
|
|
60
|
+
const readSibling = isMutatingCreateLike ? findGetByIdCounterpart(target, all) : undefined;
|
|
61
|
+
const deleteSibling = findDeleteCounterpart(target, all);
|
|
62
|
+
|
|
63
|
+
const suite = isMutatingCreateLike && readSibling
|
|
64
|
+
? buildFullChain(target, readSibling, deleteSibling, privilegedBody, protectedFields)
|
|
65
|
+
: buildSingleStep(target, privilegedBody, protectedFields);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
kind: "ok",
|
|
69
|
+
yaml: serializeSuite(suite),
|
|
70
|
+
chain: isMutatingCreateLike && readSibling ? "full" : "single",
|
|
71
|
+
protectedFields,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function collectProtectedFields(schema?: OpenAPIV3.SchemaObject): string[] {
|
|
76
|
+
if (!schema || !schema.properties) return [];
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for (const [name, raw] of Object.entries(schema.properties)) {
|
|
79
|
+
const prop = raw as OpenAPIV3.SchemaObject & { "x-zond-protected"?: boolean };
|
|
80
|
+
if (prop.readOnly === true || prop["x-zond-protected"] === true) out.push(name);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildBaselineSkeleton(schema?: OpenAPIV3.SchemaObject): Record<string, unknown> {
|
|
86
|
+
// Skeleton is intentionally minimal — `# …real create body sourced from
|
|
87
|
+
// fixtures…` placeholder shows up in the YAML so the user fills it in.
|
|
88
|
+
if (!schema || !schema.properties) return {};
|
|
89
|
+
const out: Record<string, unknown> = {};
|
|
90
|
+
for (const [name, raw] of Object.entries(schema.properties)) {
|
|
91
|
+
const prop = raw as OpenAPIV3.SchemaObject;
|
|
92
|
+
if (prop.readOnly === true) continue;
|
|
93
|
+
if (schema.required?.includes(name)) {
|
|
94
|
+
out[name] = placeholderForType(prop);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function placeholderForType(prop: OpenAPIV3.SchemaObject): unknown {
|
|
101
|
+
if (prop.example !== undefined) return prop.example;
|
|
102
|
+
switch (prop.type) {
|
|
103
|
+
case "integer":
|
|
104
|
+
case "number": return 1;
|
|
105
|
+
case "boolean": return false;
|
|
106
|
+
case "array": return [];
|
|
107
|
+
case "object": return {};
|
|
108
|
+
default: return `ma-test-{{$randomString}}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function mergePrivileged(
|
|
113
|
+
baseline: Record<string, unknown>,
|
|
114
|
+
protectedFields: string[],
|
|
115
|
+
): Record<string, unknown> {
|
|
116
|
+
const merged: Record<string, unknown> = { ...baseline };
|
|
117
|
+
// Suspected fields always added.
|
|
118
|
+
for (const [k, v] of Object.entries(SUSPECTED_FIELDS)) merged[k] = v;
|
|
119
|
+
// readOnly / x-zond-protected fields: inject distinctive sentinel values
|
|
120
|
+
// so we can detect server-side echo vs server-side regeneration.
|
|
121
|
+
for (const f of protectedFields) {
|
|
122
|
+
if (!(f in merged)) merged[f] = `attacker-${f}-{{$uuid}}`;
|
|
123
|
+
}
|
|
124
|
+
return merged;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildFullChain(
|
|
128
|
+
create: EndpointInfo,
|
|
129
|
+
read: EndpointInfo,
|
|
130
|
+
del: EndpointInfo | undefined,
|
|
131
|
+
privilegedBody: Record<string, unknown>,
|
|
132
|
+
protectedFields: string[],
|
|
133
|
+
): RawSuite {
|
|
134
|
+
const idVar = captureFieldFor(create) || "created_id";
|
|
135
|
+
const tests: RawStep[] = [];
|
|
136
|
+
|
|
137
|
+
tests.push({
|
|
138
|
+
name: "create with privileged fields",
|
|
139
|
+
[create.method.toUpperCase()]: create.path,
|
|
140
|
+
json: privilegedBody,
|
|
141
|
+
expect: { status: [200, 201], body: { id: { capture: idVar } } },
|
|
142
|
+
} as unknown as RawStep);
|
|
143
|
+
|
|
144
|
+
// Canonical assertion vocabulary: only `not_equals` is supported (no `not`,
|
|
145
|
+
// no `not_starts_with`). For protected fields we assert the exact attacker
|
|
146
|
+
// sentinel value did NOT round-trip back from the server.
|
|
147
|
+
const verifyBody: Record<string, Record<string, unknown>> = {};
|
|
148
|
+
for (const k of Object.keys(SUSPECTED_FIELDS)) {
|
|
149
|
+
verifyBody[k] = { not_equals: SUSPECTED_FIELDS[k] };
|
|
150
|
+
}
|
|
151
|
+
for (const f of protectedFields) {
|
|
152
|
+
if (!(f in verifyBody)) verifyBody[f] = { not_contains: "attacker-" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
tests.push({
|
|
156
|
+
name: "verify privileged fields not echoed",
|
|
157
|
+
[read.method.toUpperCase()]: read.path.replace(/\{[^}]+\}/, `{{${idVar}}}`),
|
|
158
|
+
expect: { status: 200, body: verifyBody as unknown as Record<string, Record<string, string>> },
|
|
159
|
+
} as unknown as RawStep);
|
|
160
|
+
|
|
161
|
+
if (del) {
|
|
162
|
+
tests.push({
|
|
163
|
+
name: "cleanup",
|
|
164
|
+
[del.method.toUpperCase()]: del.path.replace(/\{[^}]+\}/, `{{${idVar}}}`),
|
|
165
|
+
always: true,
|
|
166
|
+
expect: { status: [200, 202, 204] },
|
|
167
|
+
} as unknown as RawStep);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
name: `ma ${slugFromPath(create.path)}`,
|
|
172
|
+
base_url: "{{base_url}}",
|
|
173
|
+
headers: { Authorization: "Bearer {{auth_token}}" },
|
|
174
|
+
tests,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildSingleStep(
|
|
179
|
+
ep: EndpointInfo,
|
|
180
|
+
privilegedBody: Record<string, unknown>,
|
|
181
|
+
_protectedFields: string[],
|
|
182
|
+
): RawSuite {
|
|
183
|
+
const method = ep.method.toUpperCase();
|
|
184
|
+
const tests: RawStep[] = [];
|
|
185
|
+
const step: Record<string, unknown> = {
|
|
186
|
+
name: `mass-assignment ${method} ${ep.path}`,
|
|
187
|
+
[method]: ep.path,
|
|
188
|
+
expect: { status: [400, 422] },
|
|
189
|
+
};
|
|
190
|
+
if (method !== "GET" && method !== "DELETE") step.json = privilegedBody;
|
|
191
|
+
tests.push(step as unknown as RawStep);
|
|
192
|
+
return {
|
|
193
|
+
name: `ma ${slugFromPath(ep.path)}`,
|
|
194
|
+
base_url: "{{base_url}}",
|
|
195
|
+
headers: { Authorization: "Bearer {{auth_token}}" },
|
|
196
|
+
tests,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function pathsEqual(a: string, b: string): boolean {
|
|
201
|
+
return a.replace(/\/$/, "") === b.replace(/\/$/, "");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function similar(a: string, b: string): boolean {
|
|
205
|
+
const aSeg = a.split("/").filter(Boolean);
|
|
206
|
+
const bSeg = b.split("/").filter(Boolean);
|
|
207
|
+
return aSeg.some(s => bSeg.includes(s));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function slugFromPath(p: string): string {
|
|
211
|
+
return p.replace(/^\//, "").replace(/\/?\{[^}]+\}/g, "").replace(/\//g, "-") || "endpoint";
|
|
212
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP method completeness probe (T48).
|
|
3
|
+
*
|
|
4
|
+
* Goal: catch the class of bugs where an API responds to *unsupported* HTTP
|
|
5
|
+
* methods with anything other than 405 / 404. A 500 here means an unhandled
|
|
6
|
+
* exception in the routing layer; a 200/201 means a forgotten or shadowed
|
|
7
|
+
* route; both are bug candidates.
|
|
8
|
+
*
|
|
9
|
+
* For every path declared in the spec, we look at which of {GET, POST, PUT,
|
|
10
|
+
* PATCH, DELETE} are *not* declared and emit one probe step per missing
|
|
11
|
+
* method. Each probe expects status in [404, 405, 401, 403] — anything else
|
|
12
|
+
* (notably 5xx, 200, 201) is a regular test failure surfaced via the
|
|
13
|
+
* existing runner / reporter / `zond db diagnose` flow.
|
|
14
|
+
*
|
|
15
|
+
* The probes are deterministic — same spec → same suites — so the generated
|
|
16
|
+
* YAML can be committed as a regression test.
|
|
17
|
+
*/
|
|
18
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
19
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
|
|
20
|
+
import type { RawSuite, RawStep } from "../generator/serializer.ts";
|
|
21
|
+
import { pathWithByAliases, getAuthHeaders } from "./shared.ts";
|
|
22
|
+
import {
|
|
23
|
+
ALL_METHODS,
|
|
24
|
+
ACCEPTABLE_UNSUPPORTED_STATUSES,
|
|
25
|
+
bucketEndpointsByPath,
|
|
26
|
+
pathWithMethodPlaceholders,
|
|
27
|
+
type Method,
|
|
28
|
+
} from "./method-shared.ts";
|
|
29
|
+
|
|
30
|
+
// 405-or-equivalent statuses for an *undeclared* method probe. ARV-2
|
|
31
|
+
// (m-15) extracted this list to method-shared.ts so the live
|
|
32
|
+
// `unsupported_method` check stays in lock-step with the offline probe.
|
|
33
|
+
const ACCEPTABLE_STATUSES = [...ACCEPTABLE_UNSUPPORTED_STATUSES];
|
|
34
|
+
|
|
35
|
+
// ──────────────────────────────────────────────
|
|
36
|
+
// Types
|
|
37
|
+
// ──────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface MethodProbeOptions {
|
|
40
|
+
endpoints: EndpointInfo[];
|
|
41
|
+
securitySchemes: SecuritySchemeInfo[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MethodProbeResult {
|
|
45
|
+
suites: RawSuite[];
|
|
46
|
+
/** Number of distinct paths probed. */
|
|
47
|
+
probedPaths: number;
|
|
48
|
+
/** Paths skipped because every method in {GET,POST,PUT,PATCH,DELETE} is declared. */
|
|
49
|
+
skippedPaths: number;
|
|
50
|
+
/** Total generated probe steps. */
|
|
51
|
+
totalProbes: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ──────────────────────────────────────────────
|
|
55
|
+
// Helpers
|
|
56
|
+
// ──────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function convertPath(path: string): string {
|
|
59
|
+
return path.replace(/\{([^}]+)\}/g, "{{$1}}");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function slugify(s: string): string {
|
|
63
|
+
return s
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
66
|
+
.replace(/^-|-$/g, "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function pathStem(path: string): string {
|
|
70
|
+
// TASK-159 (m-9 P3): preserve placeholder name (`by-org`, `by-proj`)
|
|
71
|
+
// instead of collapsing every `{x}` to a generic `by-id`.
|
|
72
|
+
const cleaned = pathWithByAliases(path)
|
|
73
|
+
.replace(/^\//, "")
|
|
74
|
+
.replace(/\//g, "-");
|
|
75
|
+
return slugify(cleaned) || "root";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// pathWithPlaceholders + bucketByPath moved to ./method-shared.ts for
|
|
79
|
+
// reuse by the live `unsupported_method` check (m-15 ARV-2).
|
|
80
|
+
const pathWithPlaceholders = pathWithMethodPlaceholders;
|
|
81
|
+
const bucketByPath = (endpoints: EndpointInfo[]): Array<{
|
|
82
|
+
path: string;
|
|
83
|
+
declared: Set<string>;
|
|
84
|
+
sample: EndpointInfo;
|
|
85
|
+
}> => Array.from(bucketEndpointsByPath(endpoints).values());
|
|
86
|
+
|
|
87
|
+
// ──────────────────────────────────────────────
|
|
88
|
+
// Public API
|
|
89
|
+
// ──────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export function generateMethodProbes(opts: MethodProbeOptions): MethodProbeResult {
|
|
92
|
+
const { endpoints, securitySchemes } = opts;
|
|
93
|
+
const methodSet: readonly Method[] = ALL_METHODS;
|
|
94
|
+
|
|
95
|
+
const buckets = bucketByPath(endpoints);
|
|
96
|
+
const suites: RawSuite[] = [];
|
|
97
|
+
let probedPaths = 0;
|
|
98
|
+
let skippedPaths = 0;
|
|
99
|
+
let totalProbes = 0;
|
|
100
|
+
|
|
101
|
+
for (const bucket of buckets) {
|
|
102
|
+
const missing = methodSet.filter((m) => !bucket.declared.has(m));
|
|
103
|
+
if (missing.length === 0) {
|
|
104
|
+
skippedPaths++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const concretePath = pathWithPlaceholders(
|
|
109
|
+
bucket.path,
|
|
110
|
+
bucket.sample.parameters,
|
|
111
|
+
);
|
|
112
|
+
const headers = getAuthHeaders(bucket.sample, securitySchemes);
|
|
113
|
+
|
|
114
|
+
const steps: RawStep[] = missing.map((method) => {
|
|
115
|
+
// ARV-179: OPTIONS on an undeclared path is legitimately handled
|
|
116
|
+
// by most stacks (CORS preflight, 200/204 with Allow header). Let
|
|
117
|
+
// the probe accept 2xx for OPTIONS only; everything else keeps
|
|
118
|
+
// the strict "no 2xx, no 5xx" expectation.
|
|
119
|
+
const acceptable = method === "OPTIONS"
|
|
120
|
+
? [200, 204, ...ACCEPTABLE_STATUSES]
|
|
121
|
+
: ACCEPTABLE_STATUSES;
|
|
122
|
+
const expectLabel = method === "OPTIONS"
|
|
123
|
+
? `${method} ${bucket.path} — undeclared method must not 5xx (OPTIONS may legitimately succeed)`
|
|
124
|
+
: `${method} ${bucket.path} — undeclared method must reject (no 5xx, no 2xx)`;
|
|
125
|
+
const step: RawStep = {
|
|
126
|
+
name: expectLabel,
|
|
127
|
+
source: {
|
|
128
|
+
generator: "method-probe",
|
|
129
|
+
endpoint: `${method} ${bucket.path}`,
|
|
130
|
+
response_branch: acceptable.map(String).join("|"),
|
|
131
|
+
},
|
|
132
|
+
[method]: convertPath(concretePath),
|
|
133
|
+
expect: { status: acceptable },
|
|
134
|
+
};
|
|
135
|
+
// Body-bearing methods on an undeclared route — send a minimal valid
|
|
136
|
+
// JSON object to provoke any body-parsing path while the router is
|
|
137
|
+
// still expected to reject the method first.
|
|
138
|
+
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
|
139
|
+
(step as any).json = {};
|
|
140
|
+
}
|
|
141
|
+
return step;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
probedPaths++;
|
|
145
|
+
totalProbes += steps.length;
|
|
146
|
+
|
|
147
|
+
const stem = pathStem(bucket.path);
|
|
148
|
+
suites.push({
|
|
149
|
+
name: `probe methods ${bucket.path}`,
|
|
150
|
+
tags: ["probe-methods", "negative-method", "no-5xx", "smoke"],
|
|
151
|
+
source: {
|
|
152
|
+
type: "probe-suite",
|
|
153
|
+
generator: "method-probe",
|
|
154
|
+
endpoint: bucket.path,
|
|
155
|
+
},
|
|
156
|
+
fileStem: `probe-methods-${stem}`,
|
|
157
|
+
base_url: "{{base_url}}",
|
|
158
|
+
...(headers ? { headers } : {}),
|
|
159
|
+
tests: steps,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { suites, probedPaths, skippedPaths, totalProbes };
|
|
164
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared bits between the offline `method-probe` (which emits YAML
|
|
3
|
+
* suites) and the live `unsupported_method` check from `core/checks`
|
|
4
|
+
* (m-15 ARV-2). Both ask the same question — "which HTTP methods aren't
|
|
5
|
+
* declared on this path?" — so the constants and helpers live here so
|
|
6
|
+
* the two stay in lock-step (ARV-2 AC #4).
|
|
7
|
+
*/
|
|
8
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
9
|
+
import type { EndpointInfo } from "../generator/types.ts";
|
|
10
|
+
|
|
11
|
+
/** ARV-179: full HTTP method complement used for `unsupported_method`
|
|
12
|
+
* enumeration. Matches schemathesis V4 `DEFAULT_UNEXPECTED_METHODS`
|
|
13
|
+
* minus the WebDAV-style `query` (not a REST norm) and `CONNECT`
|
|
14
|
+
* (irrelevant to API routing). `HEAD` is intentionally excluded
|
|
15
|
+
* because most stacks auto-derive it from `GET`, so a 2xx response is
|
|
16
|
+
* expected behaviour rather than a leak. */
|
|
17
|
+
export const ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"] as const;
|
|
18
|
+
export type Method = (typeof ALL_METHODS)[number];
|
|
19
|
+
|
|
20
|
+
/** Statuses we accept for an *undeclared* method on a path. 405 is
|
|
21
|
+
* canonical, 404 is a common fallback (path not registered for that
|
|
22
|
+
* method), 401/403 are acceptable when auth is checked before routing. */
|
|
23
|
+
export const ACCEPTABLE_UNSUPPORTED_STATUSES = [401, 403, 404, 405] as const;
|
|
24
|
+
|
|
25
|
+
/** ARV-179: OPTIONS is special — it's legitimately implemented by most
|
|
26
|
+
* stacks for CORS preflight and may legitimately return 2xx with
|
|
27
|
+
* `Allow`/`Access-Control-*` headers. A 2xx OPTIONS on an undeclared
|
|
28
|
+
* path is the spec-compliant outcome, not a finding. Use this helper
|
|
29
|
+
* in both the offline probe and the live check to keep the policy in
|
|
30
|
+
* one place. */
|
|
31
|
+
export function isPermissibleOptionsResponse(method: string, status: number): boolean {
|
|
32
|
+
return method.toUpperCase() === "OPTIONS" && status >= 200 && status < 300;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Replace `{name}` segments with valid-shape placeholders so the
|
|
37
|
+
* request can reach the routing layer without being rejected purely on
|
|
38
|
+
* path syntax. Used by both the offline probe and the live check.
|
|
39
|
+
*/
|
|
40
|
+
export function pathWithMethodPlaceholders(
|
|
41
|
+
path: string,
|
|
42
|
+
parameters: OpenAPIV3.ParameterObject[],
|
|
43
|
+
): string {
|
|
44
|
+
return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
45
|
+
const param = parameters.find((p) => p.name === name && p.in === "path");
|
|
46
|
+
const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
47
|
+
if (schema?.format === "uuid") return "00000000-0000-0000-0000-000000000000";
|
|
48
|
+
if (schema?.type === "integer" || schema?.type === "number") return "999999999";
|
|
49
|
+
return "nonexistent-zzzzz";
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function bucketEndpointsByPath(endpoints: EndpointInfo[]): Map<string, {
|
|
54
|
+
path: string;
|
|
55
|
+
declared: Set<string>;
|
|
56
|
+
sample: EndpointInfo;
|
|
57
|
+
}> {
|
|
58
|
+
const map = new Map<string, { path: string; declared: Set<string>; sample: EndpointInfo }>();
|
|
59
|
+
for (const ep of endpoints) {
|
|
60
|
+
if (ep.deprecated) continue;
|
|
61
|
+
let bucket = map.get(ep.path);
|
|
62
|
+
if (!bucket) {
|
|
63
|
+
bucket = { path: ep.path, declared: new Set(), sample: ep };
|
|
64
|
+
map.set(ep.path, bucket);
|
|
65
|
+
}
|
|
66
|
+
bucket.declared.add(ep.method.toUpperCase());
|
|
67
|
+
}
|
|
68
|
+
return map;
|
|
69
|
+
}
|