@kirrosh/zond 0.23.0 → 0.26.1
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 +176 -1
- package/README.md +8 -7
- package/package.json +2 -3
- package/src/CLAUDE.md +112 -0
- package/src/cli/commands/add-api.ts +19 -7
- package/src/cli/commands/api/annotate/index.ts +359 -4
- package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
- package/src/cli/commands/api/annotate/overlay.ts +1 -1
- package/src/cli/commands/api/annotate/pagination.ts +10 -6
- package/src/cli/commands/api/annotate/prompts.ts +39 -2
- package/src/cli/commands/audit.ts +360 -54
- package/src/cli/commands/check.ts +15 -2
- package/src/cli/commands/checks.ts +352 -36
- package/src/cli/commands/cleanup.ts +4 -30
- package/src/cli/commands/coverage.ts +275 -57
- package/src/cli/commands/db.ts +311 -8
- package/src/cli/commands/discover.ts +281 -161
- package/src/cli/commands/doctor.ts +57 -3
- package/src/cli/commands/fixtures.ts +1 -1
- package/src/cli/commands/generate.ts +24 -7
- package/src/cli/commands/init/bootstrap.ts +4 -1
- package/src/cli/commands/init/index.ts +2 -2
- package/src/cli/commands/init/skills.ts +43 -0
- package/src/cli/commands/init/templates/agents.md +12 -3
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
- package/src/cli/commands/init/templates/skills/zond.md +274 -64
- package/src/cli/commands/init/templates/zond-config.yml +1 -1
- package/src/cli/commands/prepare-fixtures.ts +14 -52
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +101 -10
- package/src/cli/commands/probe/security.ts +95 -12
- package/src/cli/commands/probe/webhooks.ts +2 -0
- package/src/cli/commands/probe.ts +87 -11
- package/src/cli/commands/refresh-api.ts +59 -1
- package/src/cli/commands/report-bundle.ts +3 -11
- package/src/cli/commands/request.ts +116 -0
- package/src/cli/commands/run.ts +53 -5
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/json-envelope.ts +0 -20
- package/src/cli/json-schemas.ts +51 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +5 -4
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +0 -10
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/cross_call_references.ts +17 -4
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/idempotency_replay.ts +1 -5
- package/src/core/checks/checks/ignored_auth.ts +44 -1
- package/src/core/checks/checks/index.ts +3 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
- package/src/core/checks/checks/negative_data_rejection.ts +119 -16
- package/src/core/checks/checks/not_a_server_error.ts +8 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
- package/src/core/checks/checks/pagination_invariants.ts +298 -117
- package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
- package/src/core/checks/checks/status_code_conformance.ts +78 -7
- package/src/core/checks/mode.ts +3 -0
- package/src/core/checks/recommended-action.ts +5 -1
- package/src/core/checks/runner.ts +614 -27
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/types.ts +117 -1
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +35 -6
- package/src/core/coverage/loader.ts +31 -0
- package/src/core/diagnostics/db-analysis.ts +200 -106
- package/src/core/diagnostics/failure-class.ts +21 -1
- package/src/core/diagnostics/failure-hints.ts +4 -208
- package/src/core/diagnostics/suggested-fixes.ts +2 -3
- package/src/core/generator/chunker.ts +1 -8
- package/src/core/generator/data-factory.ts +199 -61
- package/src/core/generator/fixtures-builder.ts +38 -31
- package/src/core/generator/index.ts +0 -2
- package/src/core/generator/openapi-reader.ts +98 -4
- package/src/core/generator/path-param-disambig.ts +30 -4
- package/src/core/generator/resources-builder.ts +276 -26
- package/src/core/generator/schema-utils.ts +22 -0
- package/src/core/generator/suite-generator.ts +168 -15
- package/src/core/generator/types.ts +6 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/output/README.md +11 -29
- package/src/core/output/index.ts +1 -1
- package/src/core/output/run.ts +0 -35
- package/src/core/output/types.ts +0 -7
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/variables.ts +0 -0
- package/src/core/probe/dry-run-envelope.ts +4 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe.ts +23 -1118
- package/src/core/probe/mass-assignment-template.ts +32 -4
- package/src/core/probe/path-discovery.ts +3 -4
- package/src/core/probe/probe-harness.ts +21 -22
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +8 -2
- package/src/core/probe/security-probe.ts +28 -1449
- package/src/core/probe/shared.ts +26 -0
- package/src/core/probe/webhooks-probe.ts +5 -7
- package/src/core/runner/assertions.ts +1 -1
- package/src/core/runner/executor.ts +3 -18
- package/src/core/runner/form-encode.ts +8 -18
- package/src/core/runner/http-client.ts +38 -1
- package/src/core/runner/preflight-vars.ts +19 -15
- package/src/core/runner/rate-limiter.ts +11 -29
- package/src/core/runner/run-kind.ts +7 -1
- package/src/core/runner/schema-validator.ts +2 -6
- package/src/core/runner/send-request.ts +11 -6
- package/src/core/runner/types.ts +6 -0
- package/src/core/setup-api.ts +53 -15
- package/src/core/severity/index.ts +0 -63
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/db/migrate.ts +2 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/queries/collections.ts +2 -2
- package/src/db/queries/results.ts +88 -0
- package/src/db/queries/runs.ts +56 -2
- package/src/db/queries.ts +3 -0
- package/src/db/schema.ts +7 -7
- package/src/cli/commands/bootstrap.ts +0 -710
- package/src/core/anti-fp/bootstrap.ts +0 -34
- package/src/core/anti-fp/index.ts +0 -33
- package/src/core/anti-fp/registry.ts +0 -44
- package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
- package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
- package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
- package/src/core/anti-fp/types.ts +0 -68
- package/src/core/generator/create-body.ts +0 -89
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zond probe-security <classes>` — live SSRF / CRLF / open-redirect probes.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the `probe-mass-assignment` shape: live runner, optional regression
|
|
5
|
+
* YAML emission, idempotent cleanup. Where mass-assignment injects extra
|
|
6
|
+
* suspect fields, this probe replaces a single benign field with a security
|
|
7
|
+
* payload (SSRF / CRLF / open-redirect) and classifies the response.
|
|
8
|
+
*/
|
|
9
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
|
|
10
|
+
import { executeRequest } from "../../runner/http-client.ts";
|
|
11
|
+
import {
|
|
12
|
+
classifyPostSemantics,
|
|
13
|
+
findDeleteCounterpart,
|
|
14
|
+
pathTouchesSeededVar,
|
|
15
|
+
} from "../shared.ts";
|
|
16
|
+
import {
|
|
17
|
+
buildBaselineFromSpec,
|
|
18
|
+
buildBodyAuthHeaders,
|
|
19
|
+
buildProbeUrl,
|
|
20
|
+
hasProbeBody,
|
|
21
|
+
serializeProbeBody,
|
|
22
|
+
} from "../probe-harness.ts";
|
|
23
|
+
import { detectFields, PAYLOADS } from "./detectors.ts";
|
|
24
|
+
import {
|
|
25
|
+
restoreOriginal,
|
|
26
|
+
sendBaseline,
|
|
27
|
+
snapshotOriginal,
|
|
28
|
+
} from "./baseline.ts";
|
|
29
|
+
import { classify } from "./classify.ts";
|
|
30
|
+
import { tryCleanup } from "./cleanup.ts";
|
|
31
|
+
import type {
|
|
32
|
+
CleanupFeasibility,
|
|
33
|
+
ProbeStepOpts,
|
|
34
|
+
SecurityFieldHit,
|
|
35
|
+
SecurityProbeOptions,
|
|
36
|
+
SecurityProbeResult,
|
|
37
|
+
SecuritySeverity,
|
|
38
|
+
SecurityVerdict,
|
|
39
|
+
} from "./types.ts";
|
|
40
|
+
|
|
41
|
+
export async function runSecurityProbes(
|
|
42
|
+
opts: SecurityProbeOptions,
|
|
43
|
+
): Promise<SecurityProbeResult> {
|
|
44
|
+
const verdicts: SecurityVerdict[] = [];
|
|
45
|
+
const warnings: string[] = [];
|
|
46
|
+
let totalEndpoints = 0;
|
|
47
|
+
|
|
48
|
+
// ARV-140: pre-flight cleanup-feasibility scan. For each POST target, look
|
|
49
|
+
// up the DELETE counterpart in the spec once. Without --allow-leaks any
|
|
50
|
+
// attack against a POST-without-DELETE is dropped — orphan tracker can't
|
|
51
|
+
// clean it (no DELETE path to retry) so it would linger in the user's
|
|
52
|
+
// tenant indefinitely (feedback round-01/02 Sentry: 18 manual cleanups).
|
|
53
|
+
const feasibility: CleanupFeasibility = {
|
|
54
|
+
status: {},
|
|
55
|
+
skippedNoCleanup: 0,
|
|
56
|
+
forcedNoCleanup: 0,
|
|
57
|
+
actionNoCleanupNeeded: 0,
|
|
58
|
+
};
|
|
59
|
+
for (const ep of opts.endpoints) {
|
|
60
|
+
if (ep.deprecated) continue;
|
|
61
|
+
if (ep.method.toUpperCase() !== "POST") continue;
|
|
62
|
+
const key = `POST ${ep.path}`;
|
|
63
|
+
// ARV-153: action POSTs (`/capture`, `/verify`, `/cancel`, …) don't
|
|
64
|
+
// allocate a new resource — there is nothing to DELETE. Attacking them
|
|
65
|
+
// without `--allow-leaks` is safe; classifying them up front prevents
|
|
66
|
+
// the feasibility pre-flight from masking 18/22 Stripe action endpoints.
|
|
67
|
+
const semantics = classifyPostSemantics(ep);
|
|
68
|
+
if (semantics === "action") {
|
|
69
|
+
feasibility.status[key] = "action";
|
|
70
|
+
feasibility.actionNoCleanupNeeded += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const hasDelete = findDeleteCounterpart(ep, opts.endpoints) !== undefined;
|
|
74
|
+
feasibility.status[key] = hasDelete ? "has-delete" : "no-delete-counterpart";
|
|
75
|
+
if (!hasDelete) {
|
|
76
|
+
if (opts.allowLeaks) feasibility.forcedNoCleanup += 1;
|
|
77
|
+
else feasibility.skippedNoCleanup += 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const ep of opts.endpoints) {
|
|
82
|
+
if (ep.deprecated) continue;
|
|
83
|
+
const m = ep.method.toUpperCase();
|
|
84
|
+
if (m !== "POST" && m !== "PUT" && m !== "PATCH") continue;
|
|
85
|
+
totalEndpoints++;
|
|
86
|
+
|
|
87
|
+
// ARV-140: cleanup-feasibility gate. POST without a DELETE counterpart
|
|
88
|
+
// (and without --allow-leaks) is dropped before any live request fires.
|
|
89
|
+
// PUT/PATCH have snapshot/restore so they're unaffected here.
|
|
90
|
+
if (m === "POST" && !opts.allowLeaks) {
|
|
91
|
+
const status = feasibility.status[`POST ${ep.path}`];
|
|
92
|
+
if (status === "no-delete-counterpart") {
|
|
93
|
+
verdicts.push(skipped(ep, "skipped: no DELETE counterpart in spec (cleanup-feasibility pre-flight; pass --allow-leaks to override)"));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// TASK-264: --isolated guard. Mutation on a seeded fixture would corrupt
|
|
99
|
+
// user data the next `zond run` depends on; skip the endpoint instead.
|
|
100
|
+
if (opts.isolated && (m === "PUT" || m === "PATCH") && pathTouchesSeededVar(ep.path, opts.vars)) {
|
|
101
|
+
verdicts.push(skipped(ep, "skipped: --isolated mode protects seeded fixtures (PUT/PATCH on seeded path-params)"));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ARV-161 (round-08 F18): parity with mass-assignment — accept
|
|
106
|
+
// application/x-www-form-urlencoded endpoints too. Stripe v1 declares
|
|
107
|
+
// user-controlled URL fields (webhook url, return_url, ...) only on
|
|
108
|
+
// form-encoded bodies; the previous JSON-only gate hid 78+ POSTs from
|
|
109
|
+
// SSRF/CRLF/open-redirect probing.
|
|
110
|
+
if (!hasProbeBody(ep)) {
|
|
111
|
+
verdicts.push(skipped(ep, "no JSON or form-urlencoded request body"));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const detected = detectFields(ep, opts.classes);
|
|
116
|
+
if (detected.length === 0) {
|
|
117
|
+
verdicts.push(skipped(ep, `no fields matched classes: ${opts.classes.join(",")}`));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (opts.dryRun) {
|
|
122
|
+
verdicts.push({
|
|
123
|
+
method: m,
|
|
124
|
+
path: ep.path,
|
|
125
|
+
severity: "skipped",
|
|
126
|
+
summary: "dry-run: would attack " + detected.map(d => `${d.field}/${d.class}`).join(", "),
|
|
127
|
+
detectedFields: detected,
|
|
128
|
+
findings: [],
|
|
129
|
+
skipReason: "dry-run",
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const verdict = await probeOneEndpoint(
|
|
135
|
+
ep,
|
|
136
|
+
opts.endpoints,
|
|
137
|
+
opts.securitySchemes,
|
|
138
|
+
opts.vars,
|
|
139
|
+
detected,
|
|
140
|
+
{
|
|
141
|
+
noCleanup: opts.noCleanup === true,
|
|
142
|
+
timeoutMs: opts.timeoutMs,
|
|
143
|
+
cleanupRetryDelaysMs: opts.cleanupRetryDelaysMs,
|
|
144
|
+
seedBody: opts.seedBodies?.get(`${ep.method.toUpperCase()} ${ep.path}`),
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
verdicts.push(verdict);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
classes: opts.classes,
|
|
152
|
+
totalEndpoints,
|
|
153
|
+
specProbed: verdicts.length,
|
|
154
|
+
verdicts,
|
|
155
|
+
warnings,
|
|
156
|
+
cleanupFeasibility: feasibility,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function probeOneEndpoint(
|
|
161
|
+
ep: EndpointInfo,
|
|
162
|
+
allEndpoints: EndpointInfo[],
|
|
163
|
+
schemes: SecuritySchemeInfo[],
|
|
164
|
+
vars: Record<string, string>,
|
|
165
|
+
detected: SecurityFieldHit[],
|
|
166
|
+
opts: ProbeStepOpts,
|
|
167
|
+
): Promise<SecurityVerdict> {
|
|
168
|
+
const m = ep.method.toUpperCase();
|
|
169
|
+
const verdict: SecurityVerdict = {
|
|
170
|
+
method: m,
|
|
171
|
+
path: ep.path,
|
|
172
|
+
severity: "ok",
|
|
173
|
+
summary: "",
|
|
174
|
+
detectedFields: detected,
|
|
175
|
+
findings: [],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Build baseline body. Same recipe as mass-assignment: spec → generators → vars.
|
|
179
|
+
// ARV-269: agent-authored `seed_body` overlay wins when present.
|
|
180
|
+
const baseline = buildBaselineFromSpec(ep, vars, opts.seedBody);
|
|
181
|
+
if (baseline === null) {
|
|
182
|
+
return skipped(ep, "request body not a JSON object");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { url, unresolved } = buildProbeUrl(ep, vars);
|
|
186
|
+
if (unresolved.length > 0) {
|
|
187
|
+
return skipped(ep, `cannot resolve path placeholders: ${unresolved.join(", ")}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ARV-161: Content-Type follows the spec — form-urlencoded for Stripe v1,
|
|
191
|
+
// JSON otherwise. All outbound payloads in this function (baseline, per-
|
|
192
|
+
// attack, restore-PUT) flow through serializeProbeBody for matching wire
|
|
193
|
+
// encoding.
|
|
194
|
+
const headers = buildBodyAuthHeaders(ep, schemes, vars);
|
|
195
|
+
|
|
196
|
+
// ── Snapshot original state (TASK-151) ────────────────────────────────
|
|
197
|
+
// For PUT/PATCH we MUST capture original state before any mutation. The
|
|
198
|
+
// old DELETE-cleanup is wrong for rename'ы — it can't undo a renamed
|
|
199
|
+
// DSN-key / team-name / webhook URL. Snapshot first, restore after each
|
|
200
|
+
// 2xx. POST falls back to DELETE-cleanup (correct semantics there).
|
|
201
|
+
const isUpdate = m === "PUT" || m === "PATCH";
|
|
202
|
+
const snapshot = isUpdate && !opts.noCleanup
|
|
203
|
+
? await snapshotOriginal(ep, allEndpoints, schemes, vars, opts)
|
|
204
|
+
: null;
|
|
205
|
+
|
|
206
|
+
// ── Baseline-OK gate ────────────────────────────────────────────────────
|
|
207
|
+
// Eliminates the "5 × 404" output the markdown template produced in the
|
|
208
|
+
// audit. If baseline isn't 2xx, attacks would just hit the same 4xx
|
|
209
|
+
// wall and tell us nothing.
|
|
210
|
+
const fullBaseline = await sendBaseline(ep, m, url, headers, baseline, opts);
|
|
211
|
+
if (fullBaseline.kind === "network") {
|
|
212
|
+
verdict.severity = "high";
|
|
213
|
+
verdict.summary = `baseline network error: ${fullBaseline.reason}`;
|
|
214
|
+
return verdict;
|
|
215
|
+
}
|
|
216
|
+
verdict.baseline = { status: fullBaseline.status };
|
|
217
|
+
|
|
218
|
+
// ── Partial-body fallback (TASK-152) ──────────────────────────────────
|
|
219
|
+
// common SaaS-style APIs accept partial PUT — full bodies
|
|
220
|
+
// generated from spec get rejected (422 / 400). Walking each detected
|
|
221
|
+
// field with a single-key body recovers the proven-HIGH cases that
|
|
222
|
+
// otherwise fall into INCONCLUSIVE-BASELINE.
|
|
223
|
+
let fullOk = fullBaseline.kind === "ok" && fullBaseline.status >= 200 && fullBaseline.status < 300;
|
|
224
|
+
const perFieldBaseline = new Map<string, Record<string, unknown>>();
|
|
225
|
+
if (!fullOk && isUpdate && fullBaseline.kind === "ok") {
|
|
226
|
+
for (const hit of detected) {
|
|
227
|
+
// Reuse spec value when present; otherwise fall back to the substituted
|
|
228
|
+
// generator output for the field. Either way the partial body has
|
|
229
|
+
// exactly one key, which is what partial-PUT APIs accept.
|
|
230
|
+
const partial: Record<string, unknown> = {};
|
|
231
|
+
if (hit.field in baseline) partial[hit.field] = baseline[hit.field];
|
|
232
|
+
else partial[hit.field] = "";
|
|
233
|
+
const partResp = await sendBaseline(ep, m, url, headers, partial, opts);
|
|
234
|
+
if (partResp.kind === "ok" && partResp.status >= 200 && partResp.status < 300) {
|
|
235
|
+
perFieldBaseline.set(hit.field, partial);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (perFieldBaseline.size > 0 && snapshot) {
|
|
239
|
+
// Each successful partial baseline mutated only its single key; restore
|
|
240
|
+
// exactly those before attacks start.
|
|
241
|
+
await restoreOriginal(
|
|
242
|
+
ep, snapshot, headers, schemes, vars, opts, verdict,
|
|
243
|
+
perFieldBaseline.keys(),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!fullOk && perFieldBaseline.size === 0) {
|
|
249
|
+
// fullBaseline.kind === "network" was already returned above; here it
|
|
250
|
+
// must be "ok" with non-2xx status.
|
|
251
|
+
const status = fullBaseline.kind === "ok" ? fullBaseline.status : 0;
|
|
252
|
+
verdict.severity = "inconclusive-baseline";
|
|
253
|
+
verdict.summary = isUpdate
|
|
254
|
+
? `baseline ${status} on full body; partial-body per-field also rejected — fixture/scope issue`
|
|
255
|
+
: `baseline ${status} — endpoint unreachable or fixture invalid; skipping attacks`;
|
|
256
|
+
return verdict;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Cleanup state mutated by the (full) baseline, before issuing attacks.
|
|
260
|
+
// With a snapshot → restore PUT (full baseline mutated every key).
|
|
261
|
+
// Without snapshot → DELETE-counterpart (POST flow).
|
|
262
|
+
if (fullOk && fullBaseline.kind === "ok" && !opts.noCleanup) {
|
|
263
|
+
if (snapshot) {
|
|
264
|
+
await restoreOriginal(
|
|
265
|
+
ep, snapshot, headers, schemes, vars, opts, verdict,
|
|
266
|
+
Object.keys(baseline),
|
|
267
|
+
);
|
|
268
|
+
} else {
|
|
269
|
+
await tryCleanup(
|
|
270
|
+
ep, allEndpoints, schemes, vars,
|
|
271
|
+
fullBaseline.body, verdict, opts,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Attacks ──────────────────────────────────────────────────────────────
|
|
277
|
+
for (const hit of detected) {
|
|
278
|
+
// Pick the body shape that this endpoint actually accepts.
|
|
279
|
+
let baseBody: Record<string, unknown> | undefined;
|
|
280
|
+
let mode: "full" | "partial" | "none" = "none";
|
|
281
|
+
if (fullOk) {
|
|
282
|
+
baseBody = baseline;
|
|
283
|
+
mode = "full";
|
|
284
|
+
} else if (perFieldBaseline.has(hit.field)) {
|
|
285
|
+
baseBody = perFieldBaseline.get(hit.field)!;
|
|
286
|
+
mode = "partial";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (mode === "none" || !baseBody) {
|
|
290
|
+
// Field doesn't have a usable baseline body shape — record one
|
|
291
|
+
// INCONCLUSIVE per payload so the digest still exposes the field.
|
|
292
|
+
for (const payload of PAYLOADS[hit.class]) {
|
|
293
|
+
verdict.findings.push({
|
|
294
|
+
field: hit.field,
|
|
295
|
+
class: hit.class,
|
|
296
|
+
payload,
|
|
297
|
+
status: 0,
|
|
298
|
+
echoed: false,
|
|
299
|
+
severity: "inconclusive",
|
|
300
|
+
reason: "no baseline body shape accepted (full+partial both rejected)",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const payload of PAYLOADS[hit.class]) {
|
|
307
|
+
const body = { ...baseBody, [hit.field]: payload };
|
|
308
|
+
let resp;
|
|
309
|
+
try {
|
|
310
|
+
resp = await executeRequest(
|
|
311
|
+
{ method: m, url, headers, body: serializeProbeBody(ep, body).content },
|
|
312
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
313
|
+
);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
verdict.findings.push({
|
|
316
|
+
field: hit.field,
|
|
317
|
+
class: hit.class,
|
|
318
|
+
payload,
|
|
319
|
+
status: 0,
|
|
320
|
+
echoed: false,
|
|
321
|
+
severity: "inconclusive",
|
|
322
|
+
reason: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
323
|
+
});
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const finding = classify(hit, payload, resp, { endpoint: ep });
|
|
327
|
+
// Annotate which body shape was used for this attack — useful for
|
|
328
|
+
// case-studies and emit-tests.
|
|
329
|
+
finding.reason = mode === "partial"
|
|
330
|
+
? `${finding.reason} [partial-body]`
|
|
331
|
+
: finding.reason;
|
|
332
|
+
verdict.findings.push(finding);
|
|
333
|
+
|
|
334
|
+
// Per-finding cleanup. Snapshot path takes precedence — DELETE on a
|
|
335
|
+
// PUT-rename'd resource would wipe a live entity, restore-PUT puts
|
|
336
|
+
// it back to the captured original. Only restore the single field
|
|
337
|
+
// this attack mutated — sending a multi-key body trips
|
|
338
|
+
// `422 use partial PUT` on common SaaS-shaped APIs.
|
|
339
|
+
if (resp.status >= 200 && resp.status < 300 && !opts.noCleanup) {
|
|
340
|
+
if (snapshot) {
|
|
341
|
+
await restoreOriginal(
|
|
342
|
+
ep, snapshot, headers, schemes, vars, opts, verdict,
|
|
343
|
+
[hit.field],
|
|
344
|
+
);
|
|
345
|
+
} else {
|
|
346
|
+
await tryCleanup(
|
|
347
|
+
ep, allEndpoints, schemes, vars,
|
|
348
|
+
resp.body_parsed ?? resp.body, verdict, opts,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
verdict.severity = rollupSecuritySeverity(verdict.findings);
|
|
356
|
+
verdict.summary = summaryLine(verdict);
|
|
357
|
+
return verdict;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Roll up per-finding severities to the worst verdict-level severity.
|
|
362
|
+
* ARV-253: "info" sits below "low" (single_signal sanitization-only).
|
|
363
|
+
* ARV-254: "medium" sits between "high" and "low" (SSRF accept on an
|
|
364
|
+
* endpoint declaring delivery). Exported so callers can recompute the
|
|
365
|
+
* verdict rollup after adjusting individual finding severities.
|
|
366
|
+
*/
|
|
367
|
+
export function rollupSecuritySeverity(
|
|
368
|
+
findings: readonly { severity: SecuritySeverity }[],
|
|
369
|
+
): SecuritySeverity {
|
|
370
|
+
const severities = findings.map((f) => f.severity);
|
|
371
|
+
if (severities.includes("high")) return "high";
|
|
372
|
+
if (severities.includes("inconclusive")) return "inconclusive";
|
|
373
|
+
if (severities.includes("medium")) return "medium";
|
|
374
|
+
if (severities.includes("low")) return "low";
|
|
375
|
+
if (severities.includes("info")) return "info";
|
|
376
|
+
return "ok";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function summaryLine(v: SecurityVerdict): string {
|
|
380
|
+
const counts: Record<SecuritySeverity, number> = {
|
|
381
|
+
high: 0, medium: 0, low: 0, info: 0, inconclusive: 0, "inconclusive-baseline": 0, ok: 0, skipped: 0,
|
|
382
|
+
};
|
|
383
|
+
for (const f of v.findings) counts[f.severity]++;
|
|
384
|
+
const fields = Array.from(new Set(v.detectedFields.map(d => d.field))).join(", ");
|
|
385
|
+
return `fields=[${fields}] · HIGH=${counts.high} MED=${counts.medium} LOW=${counts.low} INFO=${counts.info} INCONCLUSIVE=${counts.inconclusive} OK=${counts.ok}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function skipped(ep: EndpointInfo, reason: string): SecurityVerdict {
|
|
389
|
+
return {
|
|
390
|
+
method: ep.method.toUpperCase(),
|
|
391
|
+
path: ep.path,
|
|
392
|
+
severity: "skipped",
|
|
393
|
+
summary: `skipped: ${reason}`,
|
|
394
|
+
detectedFields: [],
|
|
395
|
+
findings: [],
|
|
396
|
+
skipReason: reason,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
|
|
2
|
+
import { generateFromSchema } from "../../generator/data-factory.ts";
|
|
3
|
+
import type { RawStep, RawSuite } from "../../generator/serializer.ts";
|
|
4
|
+
import { flattenToFormFields } from "../../runner/form-encode.ts";
|
|
5
|
+
import {
|
|
6
|
+
captureFieldFor,
|
|
7
|
+
convertPath,
|
|
8
|
+
endpointStem,
|
|
9
|
+
findDeleteCounterpart,
|
|
10
|
+
getAuthHeaders,
|
|
11
|
+
} from "../shared.ts";
|
|
12
|
+
import type { SecurityProbeResult } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
const ATTACK_EXPECTED_STATUS = [400, 403, 404, 405, 409, 415, 422];
|
|
15
|
+
|
|
16
|
+
function shortPayload(s: string): string {
|
|
17
|
+
return s.length > 40 ? s.slice(0, 37) + "…" : s;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function emitSecurityRegressionSuites(
|
|
21
|
+
result: SecurityProbeResult,
|
|
22
|
+
endpoints: EndpointInfo[],
|
|
23
|
+
schemes: SecuritySchemeInfo[],
|
|
24
|
+
): RawSuite[] {
|
|
25
|
+
const suites: RawSuite[] = [];
|
|
26
|
+
for (const v of result.verdicts) {
|
|
27
|
+
// ARV-247 (R-04/F18): `high` findings are 2xx-with-echoed-payload — the
|
|
28
|
+
// strongest regression signal we have. Skipping them meant CI had nothing
|
|
29
|
+
// to gate on for confirmed stored injections. Treat them like `ok` here:
|
|
30
|
+
// the suite locks in the *expected* state (attack rejected) once the API
|
|
31
|
+
// owner ships a fix, and fails loud while it's still broken.
|
|
32
|
+
if (v.severity !== "ok" && v.severity !== "low" && v.severity !== "high") continue;
|
|
33
|
+
const ep = endpoints.find(
|
|
34
|
+
e => e.path === v.path && e.method.toUpperCase() === v.method,
|
|
35
|
+
);
|
|
36
|
+
if (!ep) continue;
|
|
37
|
+
const suiteHeaders = getAuthHeaders(ep, schemes);
|
|
38
|
+
const tests: RawStep[] = [];
|
|
39
|
+
for (const f of v.findings) {
|
|
40
|
+
// `ok` = attack already rejected; `high` = attack accepted+echoed (the
|
|
41
|
+
// regression target is rejection, same expected set as `ok`). `low` =
|
|
42
|
+
// attack accepted but no echo — lock in the 2xx-without-echo shape.
|
|
43
|
+
const expected = (f.severity === "ok" || f.severity === "high") ? ATTACK_EXPECTED_STATUS : [200, 201, 202, 204];
|
|
44
|
+
const body = ep.requestBodySchema ? generateFromSchema(ep.requestBodySchema) : {};
|
|
45
|
+
if (typeof body === "object" && body !== null && !Array.isArray(body)) {
|
|
46
|
+
(body as Record<string, unknown>)[f.field] = f.payload;
|
|
47
|
+
}
|
|
48
|
+
// ARV-161: mirror mass-assignment-probe — emit `form:` instead of
|
|
49
|
+
// `json:` for form-urlencoded endpoints (Stripe v1), otherwise the
|
|
50
|
+
// regression suite gets rejected with 400 "check that your POST
|
|
51
|
+
// content type is application/x-www-form-urlencoded" and never
|
|
52
|
+
// exercises the actual attack vector.
|
|
53
|
+
const bodyField =
|
|
54
|
+
ep.requestBodyContentType === "application/x-www-form-urlencoded"
|
|
55
|
+
? { form: flattenToFormFields(body) }
|
|
56
|
+
: { json: body };
|
|
57
|
+
const step: RawStep = {
|
|
58
|
+
name: `${f.class}: ${f.field}=${shortPayload(f.payload)} must ${f.severity === "ok" ? "be rejected" : "not echo"}`,
|
|
59
|
+
source: {
|
|
60
|
+
generator: "probe-security",
|
|
61
|
+
endpoint: `${v.method} ${v.path}`,
|
|
62
|
+
response_branch: expected.map(String).join("|"),
|
|
63
|
+
},
|
|
64
|
+
[v.method]: convertPath(ep.path),
|
|
65
|
+
...bodyField,
|
|
66
|
+
expect: { status: expected },
|
|
67
|
+
};
|
|
68
|
+
tests.push(step);
|
|
69
|
+
}
|
|
70
|
+
if (tests.length === 0) continue;
|
|
71
|
+
// Attach a generic cleanup step keyed off `created_id` (only fires when
|
|
72
|
+
// a previous step captured one — same `always:true` semantics other
|
|
73
|
+
// probes use).
|
|
74
|
+
const delEp = findDeleteCounterpart(ep, endpoints);
|
|
75
|
+
if (delEp) {
|
|
76
|
+
const idField = captureFieldFor(ep);
|
|
77
|
+
tests[0]!.expect.body = { ...(tests[0]!.expect.body ?? {}), [idField]: { capture: "created_id" } };
|
|
78
|
+
const idParam = (delEp.path.match(/\{([^}]+)\}/) ?? [])[1] ?? "id";
|
|
79
|
+
const delStep: RawStep = {
|
|
80
|
+
name: "cleanup",
|
|
81
|
+
source: { generator: "probe-security-cleanup", endpoint: `DELETE ${delEp.path}` },
|
|
82
|
+
always: true,
|
|
83
|
+
DELETE: convertPath(delEp.path).replace(`{{${idParam}}}`, "{{created_id}}"),
|
|
84
|
+
expect: { status: [200, 202, 204, 404] },
|
|
85
|
+
} as RawStep & { always: boolean };
|
|
86
|
+
tests.push(delStep);
|
|
87
|
+
}
|
|
88
|
+
suites.push({
|
|
89
|
+
name: `probe-security ${v.method} ${v.path}`,
|
|
90
|
+
tags: ["probe-security", ...result.classes],
|
|
91
|
+
source: {
|
|
92
|
+
type: "probe-suite",
|
|
93
|
+
generator: "probe-security",
|
|
94
|
+
endpoint: `${v.method} ${v.path}`,
|
|
95
|
+
},
|
|
96
|
+
fileStem: `probe-security-${endpointStem(ep)}`,
|
|
97
|
+
base_url: "{{base_url}}",
|
|
98
|
+
...(suiteHeaders ? { headers: suiteHeaders } : {}),
|
|
99
|
+
tests,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return suites;
|
|
103
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
|
|
2
|
+
import type { SeedBodyConfig } from "../../generator/resources-builder.ts";
|
|
3
|
+
import type { RecommendedAction } from "../../diagnostics/failure-hints.ts";
|
|
4
|
+
|
|
5
|
+
export type SecurityClass = "ssrf" | "crlf" | "open-redirect";
|
|
6
|
+
|
|
7
|
+
export const SECURITY_CLASSES: SecurityClass[] = ["ssrf", "crlf", "open-redirect"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Security-probe severity ladder. Includes 'info' (ARV-253) for
|
|
11
|
+
* sanitization-only signals (CRLF accept-without-reflection) and
|
|
12
|
+
* 'medium' (ARV-254) for SSRF accept on endpoints declaring delivery
|
|
13
|
+
* semantics. The full m-21 matrix governs the cap: HIGH requires
|
|
14
|
+
* evidence_chain proof, OOB-backed SSRF lands here only when ARV-177
|
|
15
|
+
* lifts.
|
|
16
|
+
*/
|
|
17
|
+
export type SecuritySeverity =
|
|
18
|
+
| "high"
|
|
19
|
+
| "medium"
|
|
20
|
+
| "low"
|
|
21
|
+
| "info"
|
|
22
|
+
| "inconclusive"
|
|
23
|
+
| "inconclusive-baseline"
|
|
24
|
+
| "ok"
|
|
25
|
+
| "skipped";
|
|
26
|
+
|
|
27
|
+
export interface SecurityFieldHit {
|
|
28
|
+
/** Field name in the request body. */
|
|
29
|
+
field: string;
|
|
30
|
+
/** Class that triggered (a field can hit multiple — we record all). */
|
|
31
|
+
class: SecurityClass;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SecurityFinding {
|
|
35
|
+
field: string;
|
|
36
|
+
class: SecurityClass;
|
|
37
|
+
payload: string;
|
|
38
|
+
/** Raw HTTP status of the attack request. */
|
|
39
|
+
status: number;
|
|
40
|
+
/** Whether the response body echoes the payload (suggesting stored injection). */
|
|
41
|
+
echoed: boolean;
|
|
42
|
+
/** PASS / FAIL classification per finding. */
|
|
43
|
+
severity: SecuritySeverity;
|
|
44
|
+
reason: string;
|
|
45
|
+
/** TASK-294: agent-routable action. FAIL/WARN → `report_backend_bug`;
|
|
46
|
+
* PASS → undefined (no action needed). */
|
|
47
|
+
recommended_action?: RecommendedAction;
|
|
48
|
+
/** Reserved suppression trace (audit-trail). Currently unset — the
|
|
49
|
+
* agent triages severity from the raw finding. */
|
|
50
|
+
suppressed_by?: { source: string; rule_index: number; reason: string };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SecurityVerdict {
|
|
54
|
+
method: string;
|
|
55
|
+
path: string;
|
|
56
|
+
/** Most-severe finding wins. */
|
|
57
|
+
severity: SecuritySeverity;
|
|
58
|
+
summary: string;
|
|
59
|
+
/** Field hits detected on this endpoint (some may have produced no findings). */
|
|
60
|
+
detectedFields: SecurityFieldHit[];
|
|
61
|
+
/** All attempted attacks. Empty for SKIPPED endpoints. */
|
|
62
|
+
findings: SecurityFinding[];
|
|
63
|
+
baseline?: { status: number };
|
|
64
|
+
cleanup?: {
|
|
65
|
+
attempted: boolean;
|
|
66
|
+
status?: number;
|
|
67
|
+
error?: string;
|
|
68
|
+
/** TASK-278: created resource id (slug/uuid/...) so `zond cleanup --orphans`
|
|
69
|
+
* can retry DELETE without re-running the probe. */
|
|
70
|
+
id?: string | number;
|
|
71
|
+
/** TASK-278: concrete DELETE URL path with the id substituted. */
|
|
72
|
+
deletePath?: string;
|
|
73
|
+
};
|
|
74
|
+
skipReason?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SecurityProbeOptions {
|
|
78
|
+
endpoints: EndpointInfo[];
|
|
79
|
+
securitySchemes: SecuritySchemeInfo[];
|
|
80
|
+
vars: Record<string, string>;
|
|
81
|
+
classes: SecurityClass[];
|
|
82
|
+
noCleanup?: boolean;
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
/** When true, only print which endpoints/fields would be attacked. */
|
|
85
|
+
dryRun?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* DELETE-cleanup retry delays in ms (round-5: handles eventual
|
|
88
|
+
* consistency between write replica and read replica). Default
|
|
89
|
+
* `[200, 1000]` — two retries on 404, total worst-case ~1.2s. Tests
|
|
90
|
+
* pass `[]` to disable; ops can pass longer for laggier replicas.
|
|
91
|
+
*/
|
|
92
|
+
cleanupRetryDelaysMs?: number[];
|
|
93
|
+
/** TASK-264: when true, refuse to attack PUT/PATCH/DELETE endpoints whose
|
|
94
|
+
* path-params are filled from `.env.yaml` (a.k.a. seeded fixtures). The
|
|
95
|
+
* trade-off: lower coverage (those endpoints get SKIPPED), but a
|
|
96
|
+
* guaranteed «probe doesn't mutate fixtures the user spent time
|
|
97
|
+
* bootstrapping» property. POST endpoints still run — they create their
|
|
98
|
+
* own resources, so isolation is automatic, with cleanup falling back to
|
|
99
|
+
* the existing DELETE-counterpart + orphan-tracker flow (TASK-278). */
|
|
100
|
+
isolated?: boolean;
|
|
101
|
+
/** ARV-140: opt-in to attacks that have no cleanup path (POSTs without a
|
|
102
|
+
* DELETE counterpart). By default we now skip them — round-01/02 Sentry
|
|
103
|
+
* runs left ~18 manually-cleanable orphans in prod because the probe
|
|
104
|
+
* happily POSTed to `/teams/`, `/symbol-sources/`, etc., where the spec
|
|
105
|
+
* has no DELETE. The pre-flight feasibility map drops these unless the
|
|
106
|
+
* caller explicitly accepts the leak. */
|
|
107
|
+
allowLeaks?: boolean;
|
|
108
|
+
/** ARV-269: agent-authored `seed_body` overlays from `.api-resources.local.yaml`,
|
|
109
|
+
* keyed by `"METHOD /path"`. Wins over `generateFromSchema` when the
|
|
110
|
+
* endpoint matches — see `MassAssignmentOptions.seedBodies` for the
|
|
111
|
+
* rationale (strict APIs reject random-scalar baselines). */
|
|
112
|
+
seedBodies?: Map<string, SeedBodyConfig>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** ARV-140: cleanup-feasibility map. Built once before the live loop so
|
|
116
|
+
* every POST verdict can see whether the spec has a DELETE counterpart;
|
|
117
|
+
* the summary digest also reports counts for skipped/forced endpoints.
|
|
118
|
+
*
|
|
119
|
+
* ARV-153 extends the status enum with "action": POSTs whose last path
|
|
120
|
+
* segment is a known action verb (`/capture`, `/verify`, `/cancel`, …)
|
|
121
|
+
* operate on an existing resource and never allocate a new one, so a
|
|
122
|
+
* DELETE counterpart isn't meaningful. These are attacked the same way
|
|
123
|
+
* as POSTs with a real DELETE — without `--allow-leaks` — because there
|
|
124
|
+
* is no resource to leak. */
|
|
125
|
+
export interface CleanupFeasibility {
|
|
126
|
+
status: Record<string, "has-delete" | "no-delete-counterpart" | "action">;
|
|
127
|
+
skippedNoCleanup: number;
|
|
128
|
+
forcedNoCleanup: number;
|
|
129
|
+
/** ARV-153: POSTs we attacked even though no DELETE counterpart exists,
|
|
130
|
+
* because the operation is semantically an action (no resource created). */
|
|
131
|
+
actionNoCleanupNeeded: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface SecurityProbeResult {
|
|
135
|
+
classes: SecurityClass[];
|
|
136
|
+
totalEndpoints: number;
|
|
137
|
+
specProbed: number;
|
|
138
|
+
verdicts: SecurityVerdict[];
|
|
139
|
+
warnings: string[];
|
|
140
|
+
/** ARV-140: cleanup-feasibility digest (POSTs without DELETE counterpart). */
|
|
141
|
+
cleanupFeasibility?: CleanupFeasibility;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Internal: per-step options shared between orchestrator/baseline/cleanup. */
|
|
145
|
+
export interface ProbeStepOpts {
|
|
146
|
+
noCleanup: boolean;
|
|
147
|
+
timeoutMs?: number;
|
|
148
|
+
cleanupRetryDelaysMs?: number[];
|
|
149
|
+
/** ARV-269: optional `seed_body` overlay for this endpoint. */
|
|
150
|
+
seedBody?: SeedBodyConfig;
|
|
151
|
+
}
|
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
type SecurityProbeResult,
|
|
19
19
|
type SecurityVerdict,
|
|
20
20
|
} from "./security-probe.ts";
|
|
21
|
-
import {
|
|
21
|
+
import { pathTouchesSeededVar } from "./shared.ts";
|
|
22
|
+
import { hasProbeBody } from "./probe-harness.ts";
|
|
22
23
|
|
|
23
24
|
const FLAGS: ProbeFlags = {
|
|
24
25
|
api: true,
|
|
@@ -55,7 +56,12 @@ function planForSecurity(
|
|
|
55
56
|
continue;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
// ARV-313: parity with the live orchestrator (which uses hasProbeBody)
|
|
60
|
+
// and the mass-assignment planner — accept application/x-www-form-urlencoded
|
|
61
|
+
// bodies, not just JSON. The old hasJsonBody gate made the dry-run
|
|
62
|
+
// inventory a no-op on form-encoded APIs (Stripe v1), so `probe security
|
|
63
|
+
// --dry-run` planned 0/291 while mass-assignment planned 290/291.
|
|
64
|
+
if (!hasProbeBody(ep)) {
|
|
59
65
|
out.push({
|
|
60
66
|
path: ep.path,
|
|
61
67
|
method: m,
|