@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,1453 @@
|
|
|
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
|
+
* Why a CLI command rather than the markdown templates the audit skill
|
|
10
|
+
* shipped with: the templates produced one HIGH (stored CRLF in one real-world API) in
|
|
11
|
+
* 5 minutes — but it was hand-copied per endpoint. Spec-driven autodetection
|
|
12
|
+
* + a baseline-OK gate (TASK-138) turns that into a one-liner.
|
|
13
|
+
*/
|
|
14
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
15
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
|
|
16
|
+
import type { RecommendedAction } from "../diagnostics/failure-hints.ts";
|
|
17
|
+
import { classify as classifyRecommendedAction } from "../classifier/recommended-action.ts";
|
|
18
|
+
import type { RawSuite, RawStep } from "../generator/serializer.ts";
|
|
19
|
+
import { generateFromSchema } from "../generator/data-factory.ts";
|
|
20
|
+
import { executeRequest } from "../runner/http-client.ts";
|
|
21
|
+
import {
|
|
22
|
+
convertPath,
|
|
23
|
+
endpointStem,
|
|
24
|
+
findDeleteCounterpart,
|
|
25
|
+
findGetByIdCounterpart,
|
|
26
|
+
captureFieldFor,
|
|
27
|
+
hasJsonBody,
|
|
28
|
+
liveAuthHeaders,
|
|
29
|
+
getAuthHeaders,
|
|
30
|
+
pathTouchesSeededVar,
|
|
31
|
+
classifyPostSemantics,
|
|
32
|
+
} from "./shared.ts";
|
|
33
|
+
import { hasProbeBody, buildBodyAuthHeaders, serializeProbeBody } from "./probe-harness.ts";
|
|
34
|
+
import {
|
|
35
|
+
buildProbeUrl,
|
|
36
|
+
buildJsonAuthHeaders,
|
|
37
|
+
buildBaselineFromSpec,
|
|
38
|
+
} from "./probe-harness.ts";
|
|
39
|
+
import { applyAntiFp } from "../anti-fp/index.ts";
|
|
40
|
+
import type { BaselineEchoCtx } from "../anti-fp/rules/baseline-echo.ts";
|
|
41
|
+
|
|
42
|
+
// ──────────────────────────────────────────────
|
|
43
|
+
// Types
|
|
44
|
+
// ──────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export type SecurityClass = "ssrf" | "crlf" | "open-redirect";
|
|
47
|
+
|
|
48
|
+
export const SECURITY_CLASSES: SecurityClass[] = ["ssrf", "crlf", "open-redirect"];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Security-probe severity ladder. Includes 'info' (ARV-253) for
|
|
52
|
+
* sanitization-only signals (CRLF accept-without-reflection) and
|
|
53
|
+
* 'medium' (ARV-254) for SSRF accept on endpoints declaring delivery
|
|
54
|
+
* semantics. The full m-21 matrix governs the cap: HIGH requires
|
|
55
|
+
* evidence_chain proof, OOB-backed SSRF lands here only when ARV-177
|
|
56
|
+
* lifts.
|
|
57
|
+
*/
|
|
58
|
+
export type SecuritySeverity =
|
|
59
|
+
| "high"
|
|
60
|
+
| "medium"
|
|
61
|
+
| "low"
|
|
62
|
+
| "info"
|
|
63
|
+
| "inconclusive"
|
|
64
|
+
| "inconclusive-baseline"
|
|
65
|
+
| "ok"
|
|
66
|
+
| "skipped";
|
|
67
|
+
|
|
68
|
+
export interface SecurityFieldHit {
|
|
69
|
+
/** Field name in the request body. */
|
|
70
|
+
field: string;
|
|
71
|
+
/** Class that triggered (a field can hit multiple — we record all). */
|
|
72
|
+
class: SecurityClass;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SecurityFinding {
|
|
76
|
+
field: string;
|
|
77
|
+
class: SecurityClass;
|
|
78
|
+
payload: string;
|
|
79
|
+
/** Raw HTTP status of the attack request. */
|
|
80
|
+
status: number;
|
|
81
|
+
/** Whether the response body echoes the payload (suggesting stored injection). */
|
|
82
|
+
echoed: boolean;
|
|
83
|
+
/** PASS / FAIL classification per finding. */
|
|
84
|
+
severity: SecuritySeverity;
|
|
85
|
+
reason: string;
|
|
86
|
+
/** TASK-294: agent-routable action. FAIL/WARN → `report_backend_bug`;
|
|
87
|
+
* PASS → undefined (no action needed). */
|
|
88
|
+
recommended_action?: RecommendedAction;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SecurityVerdict {
|
|
92
|
+
method: string;
|
|
93
|
+
path: string;
|
|
94
|
+
/** Most-severe finding wins. */
|
|
95
|
+
severity: SecuritySeverity;
|
|
96
|
+
summary: string;
|
|
97
|
+
/** Field hits detected on this endpoint (some may have produced no findings). */
|
|
98
|
+
detectedFields: SecurityFieldHit[];
|
|
99
|
+
/** All attempted attacks. Empty for SKIPPED endpoints. */
|
|
100
|
+
findings: SecurityFinding[];
|
|
101
|
+
baseline?: { status: number };
|
|
102
|
+
cleanup?: {
|
|
103
|
+
attempted: boolean;
|
|
104
|
+
status?: number;
|
|
105
|
+
error?: string;
|
|
106
|
+
/** TASK-278: created resource id (slug/uuid/...) so `zond cleanup --orphans`
|
|
107
|
+
* can retry DELETE without re-running the probe. */
|
|
108
|
+
id?: string | number;
|
|
109
|
+
/** TASK-278: concrete DELETE URL path with the id substituted. */
|
|
110
|
+
deletePath?: string;
|
|
111
|
+
};
|
|
112
|
+
skipReason?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SecurityProbeOptions {
|
|
116
|
+
endpoints: EndpointInfo[];
|
|
117
|
+
securitySchemes: SecuritySchemeInfo[];
|
|
118
|
+
vars: Record<string, string>;
|
|
119
|
+
classes: SecurityClass[];
|
|
120
|
+
noCleanup?: boolean;
|
|
121
|
+
timeoutMs?: number;
|
|
122
|
+
/** When true, only print which endpoints/fields would be attacked. */
|
|
123
|
+
dryRun?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* DELETE-cleanup retry delays in ms (round-5: handles eventual
|
|
126
|
+
* consistency between write replica and read replica). Default
|
|
127
|
+
* `[200, 1000]` — two retries on 404, total worst-case ~1.2s. Tests
|
|
128
|
+
* pass `[]` to disable; ops can pass longer for laggier replicas.
|
|
129
|
+
*/
|
|
130
|
+
cleanupRetryDelaysMs?: number[];
|
|
131
|
+
/** TASK-264: when true, refuse to attack PUT/PATCH/DELETE endpoints whose
|
|
132
|
+
* path-params are filled from `.env.yaml` (a.k.a. seeded fixtures). The
|
|
133
|
+
* trade-off: lower coverage (those endpoints get SKIPPED), but a
|
|
134
|
+
* guaranteed «probe doesn't mutate fixtures the user spent time
|
|
135
|
+
* bootstrapping» property. POST endpoints still run — they create their
|
|
136
|
+
* own resources, so isolation is automatic, with cleanup falling back to
|
|
137
|
+
* the existing DELETE-counterpart + orphan-tracker flow (TASK-278). */
|
|
138
|
+
isolated?: boolean;
|
|
139
|
+
/** ARV-140: opt-in to attacks that have no cleanup path (POSTs without a
|
|
140
|
+
* DELETE counterpart). By default we now skip them — round-01/02 Sentry
|
|
141
|
+
* runs left ~18 manually-cleanable orphans in prod because the probe
|
|
142
|
+
* happily POSTed to `/teams/`, `/symbol-sources/`, etc., where the spec
|
|
143
|
+
* has no DELETE. The pre-flight feasibility map drops these unless the
|
|
144
|
+
* caller explicitly accepts the leak. */
|
|
145
|
+
allowLeaks?: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** ARV-140: cleanup-feasibility map. Built once before the live loop so
|
|
149
|
+
* every POST verdict can see whether the spec has a DELETE counterpart;
|
|
150
|
+
* the summary digest also reports counts for skipped/forced endpoints.
|
|
151
|
+
*
|
|
152
|
+
* ARV-153 extends the status enum with "action": POSTs whose last path
|
|
153
|
+
* segment is a known action verb (`/capture`, `/verify`, `/cancel`, …)
|
|
154
|
+
* operate on an existing resource and never allocate a new one, so a
|
|
155
|
+
* DELETE counterpart isn't meaningful. These are attacked the same way
|
|
156
|
+
* as POSTs with a real DELETE — without `--allow-leaks` — because there
|
|
157
|
+
* is no resource to leak. */
|
|
158
|
+
export interface CleanupFeasibility {
|
|
159
|
+
status: Record<string, "has-delete" | "no-delete-counterpart" | "action">;
|
|
160
|
+
skippedNoCleanup: number;
|
|
161
|
+
forcedNoCleanup: number;
|
|
162
|
+
/** ARV-153: POSTs we attacked even though no DELETE counterpart exists,
|
|
163
|
+
* because the operation is semantically an action (no resource created). */
|
|
164
|
+
actionNoCleanupNeeded: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface SecurityProbeResult {
|
|
168
|
+
classes: SecurityClass[];
|
|
169
|
+
totalEndpoints: number;
|
|
170
|
+
specProbed: number;
|
|
171
|
+
verdicts: SecurityVerdict[];
|
|
172
|
+
warnings: string[];
|
|
173
|
+
/** ARV-140: cleanup-feasibility digest (POSTs without DELETE counterpart). */
|
|
174
|
+
cleanupFeasibility?: CleanupFeasibility;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ──────────────────────────────────────────────
|
|
178
|
+
// Field detectors
|
|
179
|
+
// ──────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
const SSRF_NAME_RE =
|
|
182
|
+
/(^url$|url$|webhook|callback|^redirect_uri$|^endpoint$|^uri$|^href$)/i;
|
|
183
|
+
const CRLF_NAME_RE =
|
|
184
|
+
/(^subject$|prefix$|^name$|^title$|^description$|^tag$|^message_subject$)/i;
|
|
185
|
+
const OPEN_REDIRECT_NAME_RE =
|
|
186
|
+
/(^redirect$|^next$|^return_to$|^redirect_url$|^redirect_to$|^redirectTo$)/i;
|
|
187
|
+
|
|
188
|
+
function matchesClass(
|
|
189
|
+
cls: SecurityClass,
|
|
190
|
+
name: string,
|
|
191
|
+
schema: OpenAPIV3.SchemaObject,
|
|
192
|
+
): boolean {
|
|
193
|
+
// Skip enum-bounded fields — payload would obviously fail validation
|
|
194
|
+
// and we'd just waste requests on guaranteed-4xx attempts.
|
|
195
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) return false;
|
|
196
|
+
if (schema.type !== "string" && schema.type !== undefined) return false;
|
|
197
|
+
switch (cls) {
|
|
198
|
+
case "ssrf":
|
|
199
|
+
return SSRF_NAME_RE.test(name) || schema.format === "uri" || schema.format === "url";
|
|
200
|
+
case "crlf":
|
|
201
|
+
return CRLF_NAME_RE.test(name);
|
|
202
|
+
case "open-redirect":
|
|
203
|
+
return OPEN_REDIRECT_NAME_RE.test(name);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function detectFields(
|
|
208
|
+
ep: EndpointInfo,
|
|
209
|
+
classes: SecurityClass[],
|
|
210
|
+
): SecurityFieldHit[] {
|
|
211
|
+
const out: SecurityFieldHit[] = [];
|
|
212
|
+
const schema = ep.requestBodySchema;
|
|
213
|
+
if (!schema || !schema.properties) return out;
|
|
214
|
+
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
|
215
|
+
const s = propSchema as OpenAPIV3.SchemaObject;
|
|
216
|
+
for (const cls of classes) {
|
|
217
|
+
if (matchesClass(cls, name, s)) out.push({ field: name, class: cls });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ──────────────────────────────────────────────
|
|
224
|
+
// Payloads
|
|
225
|
+
// ──────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
const PAYLOADS: Record<SecurityClass, string[]> = {
|
|
228
|
+
ssrf: [
|
|
229
|
+
"http://127.0.0.1:80/",
|
|
230
|
+
"http://169.254.169.254/latest/meta-data/",
|
|
231
|
+
"file:///etc/passwd",
|
|
232
|
+
],
|
|
233
|
+
crlf: [
|
|
234
|
+
"zond-safe\r\nX-Zond-Injected: yes",
|
|
235
|
+
"zond-safe%0d%0aX-Zond-Injected: yes",
|
|
236
|
+
],
|
|
237
|
+
"open-redirect": [
|
|
238
|
+
"https://zond-attacker.example.tld",
|
|
239
|
+
"//zond-attacker.example.tld",
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// ──────────────────────────────────────────────
|
|
244
|
+
// Live probe runner
|
|
245
|
+
// ──────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
interface ProbeStepOpts {
|
|
248
|
+
noCleanup: boolean;
|
|
249
|
+
timeoutMs?: number;
|
|
250
|
+
cleanupRetryDelaysMs?: number[];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function runSecurityProbes(
|
|
254
|
+
opts: SecurityProbeOptions,
|
|
255
|
+
): Promise<SecurityProbeResult> {
|
|
256
|
+
const verdicts: SecurityVerdict[] = [];
|
|
257
|
+
const warnings: string[] = [];
|
|
258
|
+
let totalEndpoints = 0;
|
|
259
|
+
|
|
260
|
+
// ARV-140: pre-flight cleanup-feasibility scan. For each POST target, look
|
|
261
|
+
// up the DELETE counterpart in the spec once. Without --allow-leaks any
|
|
262
|
+
// attack against a POST-without-DELETE is dropped — orphan tracker can't
|
|
263
|
+
// clean it (no DELETE path to retry) so it would linger in the user's
|
|
264
|
+
// tenant indefinitely (feedback round-01/02 Sentry: 18 manual cleanups).
|
|
265
|
+
const feasibility: CleanupFeasibility = {
|
|
266
|
+
status: {},
|
|
267
|
+
skippedNoCleanup: 0,
|
|
268
|
+
forcedNoCleanup: 0,
|
|
269
|
+
actionNoCleanupNeeded: 0,
|
|
270
|
+
};
|
|
271
|
+
for (const ep of opts.endpoints) {
|
|
272
|
+
if (ep.deprecated) continue;
|
|
273
|
+
if (ep.method.toUpperCase() !== "POST") continue;
|
|
274
|
+
const key = `POST ${ep.path}`;
|
|
275
|
+
// ARV-153: action POSTs (`/capture`, `/verify`, `/cancel`, …) don't
|
|
276
|
+
// allocate a new resource — there is nothing to DELETE. Attacking them
|
|
277
|
+
// without `--allow-leaks` is safe; classifying them up front prevents
|
|
278
|
+
// the feasibility pre-flight from masking 18/22 Stripe action endpoints.
|
|
279
|
+
const semantics = classifyPostSemantics(ep);
|
|
280
|
+
if (semantics === "action") {
|
|
281
|
+
feasibility.status[key] = "action";
|
|
282
|
+
feasibility.actionNoCleanupNeeded += 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const hasDelete = findDeleteCounterpart(ep, opts.endpoints) !== undefined;
|
|
286
|
+
feasibility.status[key] = hasDelete ? "has-delete" : "no-delete-counterpart";
|
|
287
|
+
if (!hasDelete) {
|
|
288
|
+
if (opts.allowLeaks) feasibility.forcedNoCleanup += 1;
|
|
289
|
+
else feasibility.skippedNoCleanup += 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const ep of opts.endpoints) {
|
|
294
|
+
if (ep.deprecated) continue;
|
|
295
|
+
const m = ep.method.toUpperCase();
|
|
296
|
+
if (m !== "POST" && m !== "PUT" && m !== "PATCH") continue;
|
|
297
|
+
totalEndpoints++;
|
|
298
|
+
|
|
299
|
+
// ARV-140: cleanup-feasibility gate. POST without a DELETE counterpart
|
|
300
|
+
// (and without --allow-leaks) is dropped before any live request fires.
|
|
301
|
+
// PUT/PATCH have snapshot/restore so they're unaffected here.
|
|
302
|
+
if (m === "POST" && !opts.allowLeaks) {
|
|
303
|
+
const status = feasibility.status[`POST ${ep.path}`];
|
|
304
|
+
if (status === "no-delete-counterpart") {
|
|
305
|
+
verdicts.push(skipped(ep, "skipped: no DELETE counterpart in spec (cleanup-feasibility pre-flight; pass --allow-leaks to override)"));
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// TASK-264: --isolated guard. Mutation on a seeded fixture would corrupt
|
|
311
|
+
// user data the next `zond run` depends on; skip the endpoint instead.
|
|
312
|
+
if (opts.isolated && (m === "PUT" || m === "PATCH") && pathTouchesSeededVar(ep.path, opts.vars)) {
|
|
313
|
+
verdicts.push(skipped(ep, "skipped: --isolated mode protects seeded fixtures (PUT/PATCH on seeded path-params)"));
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ARV-161 (round-08 F18): parity with mass-assignment — accept
|
|
318
|
+
// application/x-www-form-urlencoded endpoints too. Stripe v1 declares
|
|
319
|
+
// user-controlled URL fields (webhook url, return_url, ...) only on
|
|
320
|
+
// form-encoded bodies; the previous JSON-only gate hid 78+ POSTs from
|
|
321
|
+
// SSRF/CRLF/open-redirect probing.
|
|
322
|
+
if (!hasProbeBody(ep)) {
|
|
323
|
+
verdicts.push(skipped(ep, "no JSON or form-urlencoded request body"));
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const detected = detectFields(ep, opts.classes);
|
|
328
|
+
if (detected.length === 0) {
|
|
329
|
+
verdicts.push(skipped(ep, `no fields matched classes: ${opts.classes.join(",")}`));
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (opts.dryRun) {
|
|
334
|
+
verdicts.push({
|
|
335
|
+
method: m,
|
|
336
|
+
path: ep.path,
|
|
337
|
+
severity: "skipped",
|
|
338
|
+
summary: "dry-run: would attack " + detected.map(d => `${d.field}/${d.class}`).join(", "),
|
|
339
|
+
detectedFields: detected,
|
|
340
|
+
findings: [],
|
|
341
|
+
skipReason: "dry-run",
|
|
342
|
+
});
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const verdict = await probeOneEndpoint(
|
|
347
|
+
ep,
|
|
348
|
+
opts.endpoints,
|
|
349
|
+
opts.securitySchemes,
|
|
350
|
+
opts.vars,
|
|
351
|
+
detected,
|
|
352
|
+
{
|
|
353
|
+
noCleanup: opts.noCleanup === true,
|
|
354
|
+
timeoutMs: opts.timeoutMs,
|
|
355
|
+
cleanupRetryDelaysMs: opts.cleanupRetryDelaysMs,
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
verdicts.push(verdict);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
classes: opts.classes,
|
|
363
|
+
totalEndpoints,
|
|
364
|
+
specProbed: verdicts.length,
|
|
365
|
+
verdicts,
|
|
366
|
+
warnings,
|
|
367
|
+
cleanupFeasibility: feasibility,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
interface Snapshot {
|
|
372
|
+
/** Original GET-response body, used to restore state via PUT/PATCH. */
|
|
373
|
+
body: Record<string, unknown>;
|
|
374
|
+
/** ETag (if API uses optimistic locking) — sent back as `If-Match` on restore. */
|
|
375
|
+
etag?: string;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function probeOneEndpoint(
|
|
379
|
+
ep: EndpointInfo,
|
|
380
|
+
allEndpoints: EndpointInfo[],
|
|
381
|
+
schemes: SecuritySchemeInfo[],
|
|
382
|
+
vars: Record<string, string>,
|
|
383
|
+
detected: SecurityFieldHit[],
|
|
384
|
+
opts: ProbeStepOpts,
|
|
385
|
+
): Promise<SecurityVerdict> {
|
|
386
|
+
const m = ep.method.toUpperCase();
|
|
387
|
+
const verdict: SecurityVerdict = {
|
|
388
|
+
method: m,
|
|
389
|
+
path: ep.path,
|
|
390
|
+
severity: "ok",
|
|
391
|
+
summary: "",
|
|
392
|
+
detectedFields: detected,
|
|
393
|
+
findings: [],
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// Build baseline body. Same recipe as mass-assignment: spec → generators → vars.
|
|
397
|
+
const baseline = buildBaselineFromSpec(ep, vars);
|
|
398
|
+
if (baseline === null) {
|
|
399
|
+
return skipped(ep, "request body not a JSON object");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const { url, unresolved } = buildProbeUrl(ep, vars);
|
|
403
|
+
if (unresolved.length > 0) {
|
|
404
|
+
return skipped(ep, `cannot resolve path placeholders: ${unresolved.join(", ")}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ARV-161: Content-Type follows the spec — form-urlencoded for Stripe v1,
|
|
408
|
+
// JSON otherwise. All outbound payloads in this function (baseline, per-
|
|
409
|
+
// attack, restore-PUT) flow through serializeProbeBody for matching wire
|
|
410
|
+
// encoding.
|
|
411
|
+
const headers = buildBodyAuthHeaders(ep, schemes, vars);
|
|
412
|
+
|
|
413
|
+
// ── Snapshot original state (TASK-151) ────────────────────────────────
|
|
414
|
+
// For PUT/PATCH we MUST capture original state before any mutation. The
|
|
415
|
+
// old DELETE-cleanup is wrong for rename'ы — it can't undo a renamed
|
|
416
|
+
// DSN-key / team-name / webhook URL. Snapshot first, restore after each
|
|
417
|
+
// 2xx. POST falls back to DELETE-cleanup (correct semantics there).
|
|
418
|
+
const isUpdate = m === "PUT" || m === "PATCH";
|
|
419
|
+
const snapshot = isUpdate && !opts.noCleanup
|
|
420
|
+
? await snapshotOriginal(ep, allEndpoints, schemes, vars, opts)
|
|
421
|
+
: null;
|
|
422
|
+
|
|
423
|
+
// ── Baseline-OK gate ────────────────────────────────────────────────────
|
|
424
|
+
// Eliminates the "5 × 404" output the markdown template produced in the
|
|
425
|
+
// audit. If baseline isn't 2xx, attacks would just hit the same 4xx
|
|
426
|
+
// wall and tell us nothing.
|
|
427
|
+
const fullBaseline = await sendBaseline(ep, m, url, headers,baseline, opts);
|
|
428
|
+
if (fullBaseline.kind === "network") {
|
|
429
|
+
verdict.severity = "high";
|
|
430
|
+
verdict.summary = `baseline network error: ${fullBaseline.reason}`;
|
|
431
|
+
return verdict;
|
|
432
|
+
}
|
|
433
|
+
verdict.baseline = { status: fullBaseline.status };
|
|
434
|
+
|
|
435
|
+
// ── Partial-body fallback (TASK-152) ──────────────────────────────────
|
|
436
|
+
// common SaaS-style APIs accept partial PUT — full bodies
|
|
437
|
+
// generated from spec get rejected (422 / 400). Walking each detected
|
|
438
|
+
// field with a single-key body recovers the proven-HIGH cases that
|
|
439
|
+
// otherwise fall into INCONCLUSIVE-BASELINE.
|
|
440
|
+
let fullOk = fullBaseline.kind === "ok" && fullBaseline.status >= 200 && fullBaseline.status < 300;
|
|
441
|
+
const perFieldBaseline = new Map<string, Record<string, unknown>>();
|
|
442
|
+
if (!fullOk && isUpdate && fullBaseline.kind === "ok") {
|
|
443
|
+
for (const hit of detected) {
|
|
444
|
+
// Reuse spec value when present; otherwise fall back to the substituted
|
|
445
|
+
// generator output for the field. Either way the partial body has
|
|
446
|
+
// exactly one key, which is what partial-PUT APIs accept.
|
|
447
|
+
const partial: Record<string, unknown> = {};
|
|
448
|
+
if (hit.field in baseline) partial[hit.field] = baseline[hit.field];
|
|
449
|
+
else partial[hit.field] = "";
|
|
450
|
+
const partResp = await sendBaseline(ep, m, url, headers,partial, opts);
|
|
451
|
+
if (partResp.kind === "ok" && partResp.status >= 200 && partResp.status < 300) {
|
|
452
|
+
perFieldBaseline.set(hit.field, partial);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (perFieldBaseline.size > 0 && snapshot) {
|
|
456
|
+
// Each successful partial baseline mutated only its single key; restore
|
|
457
|
+
// exactly those before attacks start.
|
|
458
|
+
await restoreOriginal(
|
|
459
|
+
ep, snapshot, headers, schemes, vars, opts, verdict,
|
|
460
|
+
perFieldBaseline.keys(),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!fullOk && perFieldBaseline.size === 0) {
|
|
466
|
+
// fullBaseline.kind === "network" was already returned above; here it
|
|
467
|
+
// must be "ok" with non-2xx status.
|
|
468
|
+
const status = fullBaseline.kind === "ok" ? fullBaseline.status : 0;
|
|
469
|
+
verdict.severity = "inconclusive-baseline";
|
|
470
|
+
verdict.summary = isUpdate
|
|
471
|
+
? `baseline ${status} on full body; partial-body per-field also rejected — fixture/scope issue`
|
|
472
|
+
: `baseline ${status} — endpoint unreachable or fixture invalid; skipping attacks`;
|
|
473
|
+
return verdict;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Cleanup state mutated by the (full) baseline, before issuing attacks.
|
|
477
|
+
// With a snapshot → restore PUT (full baseline mutated every key).
|
|
478
|
+
// Without snapshot → DELETE-counterpart (POST flow).
|
|
479
|
+
if (fullOk && fullBaseline.kind === "ok" && !opts.noCleanup) {
|
|
480
|
+
if (snapshot) {
|
|
481
|
+
await restoreOriginal(
|
|
482
|
+
ep, snapshot, headers, schemes, vars, opts, verdict,
|
|
483
|
+
Object.keys(baseline),
|
|
484
|
+
);
|
|
485
|
+
} else {
|
|
486
|
+
await tryCleanup(
|
|
487
|
+
ep, allEndpoints, schemes, vars,
|
|
488
|
+
fullBaseline.body, verdict, opts,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── Attacks ──────────────────────────────────────────────────────────────
|
|
494
|
+
for (const hit of detected) {
|
|
495
|
+
// Pick the body shape that this endpoint actually accepts.
|
|
496
|
+
let baseBody: Record<string, unknown> | undefined;
|
|
497
|
+
let mode: "full" | "partial" | "none" = "none";
|
|
498
|
+
if (fullOk) {
|
|
499
|
+
baseBody = baseline;
|
|
500
|
+
mode = "full";
|
|
501
|
+
} else if (perFieldBaseline.has(hit.field)) {
|
|
502
|
+
baseBody = perFieldBaseline.get(hit.field)!;
|
|
503
|
+
mode = "partial";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (mode === "none" || !baseBody) {
|
|
507
|
+
// Field doesn't have a usable baseline body shape — record one
|
|
508
|
+
// INCONCLUSIVE per payload so the digest still exposes the field.
|
|
509
|
+
for (const payload of PAYLOADS[hit.class]) {
|
|
510
|
+
verdict.findings.push({
|
|
511
|
+
field: hit.field,
|
|
512
|
+
class: hit.class,
|
|
513
|
+
payload,
|
|
514
|
+
status: 0,
|
|
515
|
+
echoed: false,
|
|
516
|
+
severity: "inconclusive",
|
|
517
|
+
reason: "no baseline body shape accepted (full+partial both rejected)",
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
for (const payload of PAYLOADS[hit.class]) {
|
|
524
|
+
const body = { ...baseBody, [hit.field]: payload };
|
|
525
|
+
let resp;
|
|
526
|
+
try {
|
|
527
|
+
resp = await executeRequest(
|
|
528
|
+
{ method: m, url, headers, body: serializeProbeBody(ep, body).content },
|
|
529
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
530
|
+
);
|
|
531
|
+
} catch (err) {
|
|
532
|
+
verdict.findings.push({
|
|
533
|
+
field: hit.field,
|
|
534
|
+
class: hit.class,
|
|
535
|
+
payload,
|
|
536
|
+
status: 0,
|
|
537
|
+
echoed: false,
|
|
538
|
+
severity: "inconclusive",
|
|
539
|
+
reason: `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
540
|
+
});
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const finding = classify(hit, payload, resp, { endpoint: ep });
|
|
544
|
+
// ARV-126: route the 2xx-no-echo low-severity classification
|
|
545
|
+
// through the anti-FP registry. When the response body deeply
|
|
546
|
+
// equals the baseline body, the server ignored the attack
|
|
547
|
+
// payload entirely — no side-effect to verify — and the
|
|
548
|
+
// `baseline-echo` rule downgrades the finding to OK with a
|
|
549
|
+
// wontfix banner. Only relevant for `mode === "full"` (we don't
|
|
550
|
+
// retain per-field baseline response bodies).
|
|
551
|
+
if (
|
|
552
|
+
finding.severity === "low"
|
|
553
|
+
&& !finding.echoed
|
|
554
|
+
&& mode === "full"
|
|
555
|
+
&& fullBaseline.kind === "ok"
|
|
556
|
+
) {
|
|
557
|
+
const ctx: BaselineEchoCtx = {
|
|
558
|
+
responseBody: resp.body_parsed ?? resp.body,
|
|
559
|
+
baselineBody: fullBaseline.body,
|
|
560
|
+
};
|
|
561
|
+
const suppression = applyAntiFp(ctx, "probe:security");
|
|
562
|
+
if (suppression) {
|
|
563
|
+
finding.severity = "ok";
|
|
564
|
+
finding.reason = `${suppression.reason} (${suppression.ruleId})`;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// Annotate which body shape was used for this attack — useful for
|
|
568
|
+
// case-studies and emit-tests.
|
|
569
|
+
finding.reason = mode === "partial"
|
|
570
|
+
? `${finding.reason} [partial-body]`
|
|
571
|
+
: finding.reason;
|
|
572
|
+
verdict.findings.push(finding);
|
|
573
|
+
|
|
574
|
+
// Per-finding cleanup. Snapshot path takes precedence — DELETE on a
|
|
575
|
+
// PUT-rename'd resource would wipe a live entity, restore-PUT puts
|
|
576
|
+
// it back to the captured original. Only restore the single field
|
|
577
|
+
// this attack mutated — sending a multi-key body trips
|
|
578
|
+
// `422 use partial PUT` on common SaaS-shaped APIs.
|
|
579
|
+
if (resp.status >= 200 && resp.status < 300 && !opts.noCleanup) {
|
|
580
|
+
if (snapshot) {
|
|
581
|
+
await restoreOriginal(
|
|
582
|
+
ep, snapshot, headers, schemes, vars, opts, verdict,
|
|
583
|
+
[hit.field],
|
|
584
|
+
);
|
|
585
|
+
} else {
|
|
586
|
+
await tryCleanup(
|
|
587
|
+
ep, allEndpoints, schemes, vars,
|
|
588
|
+
resp.body_parsed ?? resp.body, verdict, opts,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Roll up to the worst severity. ARV-253: "info" sits below "low"
|
|
596
|
+
// (single_signal sanitization-only). ARV-254: "medium" sits between
|
|
597
|
+
// "high" and "low" (SSRF accept on endpoint declaring delivery).
|
|
598
|
+
const severities: SecuritySeverity[] = verdict.findings.map(f => f.severity);
|
|
599
|
+
if (severities.includes("high")) verdict.severity = "high";
|
|
600
|
+
else if (severities.includes("inconclusive")) verdict.severity = "inconclusive";
|
|
601
|
+
else if (severities.includes("medium")) verdict.severity = "medium";
|
|
602
|
+
else if (severities.includes("low")) verdict.severity = "low";
|
|
603
|
+
else if (severities.includes("info")) verdict.severity = "info";
|
|
604
|
+
else verdict.severity = "ok";
|
|
605
|
+
|
|
606
|
+
verdict.summary = summaryLine(verdict);
|
|
607
|
+
return verdict;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ──────────────────────────────────────────────
|
|
611
|
+
// Baseline send — wraps executeRequest with shape that distinguishes a real
|
|
612
|
+
// HTTP response from a network error (so the caller can decide whether to
|
|
613
|
+
// retry partial-body / mark the endpoint unreachable).
|
|
614
|
+
// ──────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
type BaselineResult =
|
|
617
|
+
| { kind: "ok"; status: number; body: unknown; headers: Record<string, string> }
|
|
618
|
+
| { kind: "network"; reason: string };
|
|
619
|
+
|
|
620
|
+
async function sendBaseline(
|
|
621
|
+
ep: EndpointInfo,
|
|
622
|
+
method: string,
|
|
623
|
+
url: string,
|
|
624
|
+
headers: Record<string, string>,
|
|
625
|
+
body: unknown,
|
|
626
|
+
opts: ProbeStepOpts,
|
|
627
|
+
): Promise<BaselineResult> {
|
|
628
|
+
try {
|
|
629
|
+
// ARV-161: serialize via serializeProbeBody so form-encoded endpoints
|
|
630
|
+
// get x-www-form-urlencoded payload matching Content-Type.
|
|
631
|
+
const wire = body && typeof body === "object" && !Array.isArray(body)
|
|
632
|
+
? serializeProbeBody(ep, body as Record<string, unknown>).content
|
|
633
|
+
: JSON.stringify(body);
|
|
634
|
+
const resp = await executeRequest(
|
|
635
|
+
{ method, url, headers, body: wire },
|
|
636
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
637
|
+
);
|
|
638
|
+
return {
|
|
639
|
+
kind: "ok",
|
|
640
|
+
status: resp.status,
|
|
641
|
+
body: resp.body_parsed ?? resp.body,
|
|
642
|
+
headers: resp.headers ?? {},
|
|
643
|
+
};
|
|
644
|
+
} catch (err) {
|
|
645
|
+
return {
|
|
646
|
+
kind: "network",
|
|
647
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ──────────────────────────────────────────────
|
|
653
|
+
// TASK-151: snapshot + restore for stateful PUT/PATCH endpoints.
|
|
654
|
+
// ──────────────────────────────────────────────
|
|
655
|
+
|
|
656
|
+
async function snapshotOriginal(
|
|
657
|
+
ep: EndpointInfo,
|
|
658
|
+
allEndpoints: EndpointInfo[],
|
|
659
|
+
schemes: SecuritySchemeInfo[],
|
|
660
|
+
vars: Record<string, string>,
|
|
661
|
+
opts: ProbeStepOpts,
|
|
662
|
+
): Promise<Snapshot | null> {
|
|
663
|
+
const getEp = findGetByIdCounterpart(ep, allEndpoints);
|
|
664
|
+
if (!getEp) return null;
|
|
665
|
+
const { url, unresolved } = buildProbeUrl(getEp, vars);
|
|
666
|
+
if (unresolved.length > 0) return null;
|
|
667
|
+
const reqHeaders: Record<string, string> = {
|
|
668
|
+
accept: "application/json",
|
|
669
|
+
...liveAuthHeaders(getEp, schemes, vars),
|
|
670
|
+
};
|
|
671
|
+
let resp;
|
|
672
|
+
try {
|
|
673
|
+
resp = await executeRequest(
|
|
674
|
+
{ method: "GET", url, headers: reqHeaders },
|
|
675
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
676
|
+
);
|
|
677
|
+
} catch {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
if (resp.status < 200 || resp.status >= 300) return null;
|
|
681
|
+
const body = resp.body_parsed ?? resp.body;
|
|
682
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
683
|
+
|
|
684
|
+
const respHeaders = resp.headers ?? {};
|
|
685
|
+
const etag =
|
|
686
|
+
respHeaders["etag"] ??
|
|
687
|
+
respHeaders["ETag"] ??
|
|
688
|
+
respHeaders["Etag"];
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
body: body as Record<string, unknown>,
|
|
692
|
+
etag: typeof etag === "string" ? etag : undefined,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Restore the original state captured by `snapshotOriginal`. Sends a
|
|
698
|
+
* minimal PUT/PATCH containing only the fields the probe mutated —
|
|
699
|
+
* sending the full snapshot body trips `422 use partial PUT` on
|
|
700
|
+
* SaaS-shaped APIs (round-4 regression), so we replay each
|
|
701
|
+
* dirty field as its own single-key request.
|
|
702
|
+
*
|
|
703
|
+
* `verdict.cleanup.error` is **accumulated** across calls (not
|
|
704
|
+
* overwritten) so a single restore failure during the run is still
|
|
705
|
+
* visible in the digest.
|
|
706
|
+
*/
|
|
707
|
+
async function restoreOriginal(
|
|
708
|
+
ep: EndpointInfo,
|
|
709
|
+
snapshot: Snapshot,
|
|
710
|
+
baseHeaders: Record<string, string>,
|
|
711
|
+
_schemes: SecuritySchemeInfo[],
|
|
712
|
+
vars: Record<string, string>,
|
|
713
|
+
opts: ProbeStepOpts,
|
|
714
|
+
verdict: SecurityVerdict,
|
|
715
|
+
dirtyFields: Iterable<string>,
|
|
716
|
+
): Promise<void> {
|
|
717
|
+
const m = ep.method.toUpperCase();
|
|
718
|
+
const { url, unresolved } = buildProbeUrl(ep, vars);
|
|
719
|
+
if (unresolved.length > 0) return;
|
|
720
|
+
const headers: Record<string, string> = { ...baseHeaders };
|
|
721
|
+
if (snapshot.etag && ep.requiresEtag) {
|
|
722
|
+
headers["If-Match"] = snapshot.etag;
|
|
723
|
+
}
|
|
724
|
+
// Filter out fields the API will reject as read-only.
|
|
725
|
+
const READ_ONLY = new Set([
|
|
726
|
+
"id", "created_at", "createdAt", "updated_at", "updatedAt",
|
|
727
|
+
]);
|
|
728
|
+
const fields = Array.from(new Set(Array.from(dirtyFields))).filter(
|
|
729
|
+
f => !READ_ONLY.has(f) && f in snapshot.body,
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
// Per-field PUT — works for both partial-PUT APIs and
|
|
733
|
+
// full-PUT APIs (the body just carries one of the legal keys).
|
|
734
|
+
const failures: string[] = [];
|
|
735
|
+
let lastSuccessStatus = 0;
|
|
736
|
+
let attempted = false;
|
|
737
|
+
for (const field of fields) {
|
|
738
|
+
attempted = true;
|
|
739
|
+
const body: Record<string, unknown> = { [field]: snapshot.body[field] };
|
|
740
|
+
let resp;
|
|
741
|
+
try {
|
|
742
|
+
resp = await executeRequest(
|
|
743
|
+
{ method: m, url, headers, body: serializeProbeBody(ep, body).content },
|
|
744
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
745
|
+
);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
failures.push(
|
|
748
|
+
`restore.${field} network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
749
|
+
);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
753
|
+
failures.push(`restore.${field} failed: ${resp.status}`);
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
lastSuccessStatus = resp.status;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Merge with any prior cleanup state on this verdict.
|
|
760
|
+
const prior = verdict.cleanup ?? { attempted: false };
|
|
761
|
+
const allErrors = [
|
|
762
|
+
...(prior.error ? [prior.error] : []),
|
|
763
|
+
...failures,
|
|
764
|
+
];
|
|
765
|
+
verdict.cleanup = {
|
|
766
|
+
attempted: attempted || prior.attempted,
|
|
767
|
+
...(lastSuccessStatus ? { status: lastSuccessStatus } : prior.status ? { status: prior.status } : {}),
|
|
768
|
+
...(allErrors.length > 0 ? { error: allErrors.join(" | ") } : {}),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** ARV-56: route through the single classifier. */
|
|
773
|
+
function stampAction(f: SecurityFinding): SecurityFinding {
|
|
774
|
+
const action = classifyRecommendedAction({
|
|
775
|
+
finding_class: "probe:security",
|
|
776
|
+
severity: f.severity as Parameters<typeof classifyRecommendedAction>[0]["severity"],
|
|
777
|
+
});
|
|
778
|
+
if (action) f.recommended_action = action;
|
|
779
|
+
return f;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
interface ClassifyResp {
|
|
783
|
+
status: number;
|
|
784
|
+
body?: unknown;
|
|
785
|
+
body_parsed?: unknown;
|
|
786
|
+
headers?: Record<string, string>;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function classify(
|
|
790
|
+
hit: SecurityFieldHit,
|
|
791
|
+
payload: string,
|
|
792
|
+
resp: ClassifyResp,
|
|
793
|
+
ctx: { endpoint?: EndpointInfo } = {},
|
|
794
|
+
): SecurityFinding {
|
|
795
|
+
return stampAction(classifyInner(hit, payload, resp, ctx));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* ARV-254: detect whether an endpoint declares delivery semantics for
|
|
800
|
+
* a URL field — i.e. the server is documented to actually hit the URL
|
|
801
|
+
* (webhook receiver, push subscription, callback).
|
|
802
|
+
*
|
|
803
|
+
* Without OOB infrastructure (interactsh / Burp Collaborator —
|
|
804
|
+
* deferred to ARV-177 post-pivot), zond can't prove the server fetched
|
|
805
|
+
* the URL. So SSRF "accept" lands as LOW by default. But if the spec
|
|
806
|
+
* declares delivery, we know the URL gets fetched on some schedule,
|
|
807
|
+
* which raises the stakes — surface as MEDIUM with an explicit
|
|
808
|
+
* disclaimer that OOB verification is still required for HIGH.
|
|
809
|
+
*
|
|
810
|
+
* Heuristic: path or tag contains "webhook" / "callback" / "subscription"
|
|
811
|
+
* (case-insensitive). When ARV-189 lands, this also reads
|
|
812
|
+
* `x-zond-delivery: true` from the spec.
|
|
813
|
+
*/
|
|
814
|
+
function endpointDeclaresDelivery(ep: EndpointInfo | undefined): boolean {
|
|
815
|
+
if (!ep) return false;
|
|
816
|
+
const haystacks: string[] = [ep.path.toLowerCase()];
|
|
817
|
+
if (Array.isArray(ep.tags)) {
|
|
818
|
+
for (const t of ep.tags) haystacks.push(String(t).toLowerCase());
|
|
819
|
+
}
|
|
820
|
+
return haystacks.some((h) => /webhook|callback|subscription/.test(h));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Check whether the CRLF payload reflects into any response header
|
|
825
|
+
* value. ARV-253: header reflection is the smoking gun for CRLF —
|
|
826
|
+
* response splitting / header injection becomes exploitable as soon as
|
|
827
|
+
* the server emits attacker-controlled bytes in headers.
|
|
828
|
+
*
|
|
829
|
+
* We check raw payload AND its URL-decoded form so encodings like
|
|
830
|
+
* `%0d%0a` survive the comparison.
|
|
831
|
+
*/
|
|
832
|
+
function reflectsInHeaders(payload: string, headers: Record<string, string> | undefined): string | null {
|
|
833
|
+
if (!headers || !payload) return null;
|
|
834
|
+
const decoded = safeDecodeURI(payload);
|
|
835
|
+
const variants = [payload, decoded].filter((v) => v && v.length >= 3);
|
|
836
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
837
|
+
for (const v of variants) {
|
|
838
|
+
if (value.includes(v)) return name;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function isHtmlContentType(headers: Record<string, string> | undefined): boolean {
|
|
845
|
+
const ct = headers?.["content-type"] ?? headers?.["Content-Type"] ?? "";
|
|
846
|
+
return /text\/html|application\/xhtml/i.test(ct);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function classifyInner(
|
|
850
|
+
hit: SecurityFieldHit,
|
|
851
|
+
payload: string,
|
|
852
|
+
resp: ClassifyResp,
|
|
853
|
+
ctx: { endpoint?: EndpointInfo } = {},
|
|
854
|
+
): SecurityFinding {
|
|
855
|
+
const status = resp.status;
|
|
856
|
+
const echo = classifyEcho(resp.body_parsed ?? resp.body, payload, hit.class);
|
|
857
|
+
const echoed = echo.matched;
|
|
858
|
+
|
|
859
|
+
if (status >= 500) {
|
|
860
|
+
// ARV-250: 5xx on attack payload is a reliability signal, not a
|
|
861
|
+
// proven security issue. Single-signal proof (one crashed response)
|
|
862
|
+
// caps severity at LOW per the m-21 severity matrix. ARV-251
|
|
863
|
+
// relocates this signal to the reliability category; the existing
|
|
864
|
+
// `not_a_server_error` check already tracks 5xx on positive input,
|
|
865
|
+
// so the security probe here is a secondary signal at best.
|
|
866
|
+
return {
|
|
867
|
+
field: hit.field,
|
|
868
|
+
class: hit.class,
|
|
869
|
+
payload,
|
|
870
|
+
status,
|
|
871
|
+
echoed,
|
|
872
|
+
severity: "low",
|
|
873
|
+
reason: `5xx unhandled — server crashed on ${hit.class} payload (reliability signal; see also not_a_server_error check)`,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
if (status >= 200 && status < 300) {
|
|
877
|
+
// ARV-253: CRLF severity now keyed on reflection context, not on
|
|
878
|
+
// raw echo. The pivot principle: HIGH requires evidence the stored
|
|
879
|
+
// payload reaches a dangerous rendering context (header value /
|
|
880
|
+
// unescaped HTML). Echo in a JSON body alone is single_signal —
|
|
881
|
+
// storage is real, exploit pathway is not. Caps at LOW.
|
|
882
|
+
if (hit.class === "crlf") {
|
|
883
|
+
const headerName = reflectsInHeaders(payload, resp.headers);
|
|
884
|
+
if (headerName) {
|
|
885
|
+
return {
|
|
886
|
+
field: hit.field,
|
|
887
|
+
class: hit.class,
|
|
888
|
+
payload,
|
|
889
|
+
status,
|
|
890
|
+
echoed: true,
|
|
891
|
+
severity: "high",
|
|
892
|
+
reason: `payload reflected in response header \`${headerName}\` — response-splitting / header-injection candidate (evidence_chain)`,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
if (echoed && isHtmlContentType(resp.headers)) {
|
|
896
|
+
return {
|
|
897
|
+
field: hit.field,
|
|
898
|
+
class: hit.class,
|
|
899
|
+
payload,
|
|
900
|
+
status,
|
|
901
|
+
echoed,
|
|
902
|
+
severity: "high",
|
|
903
|
+
reason: `payload echoed (${echo.kind}) in text/html response — unescaped reflection candidate (evidence_chain)`,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
if (echoed) {
|
|
907
|
+
return {
|
|
908
|
+
field: hit.field,
|
|
909
|
+
class: hit.class,
|
|
910
|
+
payload,
|
|
911
|
+
status,
|
|
912
|
+
echoed,
|
|
913
|
+
severity: "low",
|
|
914
|
+
reason: `payload echoed (${echo.kind}) in JSON body — storage observed, no dangerous-context reflection. Manual follow-up: check whether the stored value reaches a downstream renderer (HTML page, RSS, custom header).`,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
return {
|
|
918
|
+
field: hit.field,
|
|
919
|
+
class: hit.class,
|
|
920
|
+
payload,
|
|
921
|
+
status,
|
|
922
|
+
echoed: false,
|
|
923
|
+
severity: "info",
|
|
924
|
+
reason: `${status} accepted ${hit.class} payload but no reflection observed — sanitization may be missing but no exploit pathway proven`,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
// ARV-254: SSRF / open-redirect severity rebalance.
|
|
928
|
+
//
|
|
929
|
+
// Without an out-of-band (OOB) channel zond can't prove the server
|
|
930
|
+
// actually fetched the injected URL. "API accepted 169.254" is
|
|
931
|
+
// single_signal proof — caps at LOW per the m-21 matrix.
|
|
932
|
+
//
|
|
933
|
+
// Stake-raising signal: when the spec declares delivery semantics
|
|
934
|
+
// (path/tag mentions webhook / callback / subscription), the server
|
|
935
|
+
// is documented to fetch the URL — surface MEDIUM. Full HIGH is
|
|
936
|
+
// gated on OOB confirmation which lands with ARV-177 (deferred-
|
|
937
|
+
// post-pivot, out of scope for now).
|
|
938
|
+
const declaresDelivery = endpointDeclaresDelivery(ctx.endpoint);
|
|
939
|
+
const oobDisclaimer = "no OOB channel — accept ≠ proven fetch. Verify with Burp Collaborator / interactsh manually for HIGH severity.";
|
|
940
|
+
if (echoed) {
|
|
941
|
+
const label = echo.kind === "verbatim"
|
|
942
|
+
? "payload echoed verbatim"
|
|
943
|
+
: `payload echoed (${echo.kind})`;
|
|
944
|
+
if (declaresDelivery) {
|
|
945
|
+
return {
|
|
946
|
+
field: hit.field,
|
|
947
|
+
class: hit.class,
|
|
948
|
+
payload,
|
|
949
|
+
status,
|
|
950
|
+
echoed,
|
|
951
|
+
severity: "low",
|
|
952
|
+
reason: `${label}; ${hit.class}: endpoint declares delivery (webhook/callback) but ${oobDisclaimer}`,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
field: hit.field,
|
|
957
|
+
class: hit.class,
|
|
958
|
+
payload,
|
|
959
|
+
status,
|
|
960
|
+
echoed,
|
|
961
|
+
severity: "low",
|
|
962
|
+
reason: `${label} — stored ${hit.class} candidate; ${oobDisclaimer}`,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
if (declaresDelivery) {
|
|
966
|
+
return {
|
|
967
|
+
field: hit.field,
|
|
968
|
+
class: hit.class,
|
|
969
|
+
payload,
|
|
970
|
+
status,
|
|
971
|
+
echoed,
|
|
972
|
+
severity: "medium",
|
|
973
|
+
reason: `2xx accepted ${hit.class} payload on endpoint declaring delivery semantics (webhook/callback). ${oobDisclaimer}`,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
field: hit.field,
|
|
978
|
+
class: hit.class,
|
|
979
|
+
payload,
|
|
980
|
+
status,
|
|
981
|
+
echoed,
|
|
982
|
+
severity: "low",
|
|
983
|
+
reason: `2xx accepted ${hit.class} payload but no echo observed. ${oobDisclaimer}`,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
if (status >= 400) {
|
|
987
|
+
return {
|
|
988
|
+
field: hit.field,
|
|
989
|
+
class: hit.class,
|
|
990
|
+
payload,
|
|
991
|
+
status,
|
|
992
|
+
echoed,
|
|
993
|
+
severity: "ok",
|
|
994
|
+
reason: `${status} rejected — ${hit.class} payload refused`,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
field: hit.field,
|
|
999
|
+
class: hit.class,
|
|
1000
|
+
payload,
|
|
1001
|
+
status,
|
|
1002
|
+
echoed,
|
|
1003
|
+
severity: "inconclusive",
|
|
1004
|
+
reason: `unexpected status ${status}`,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function bodyToString(body: unknown): string {
|
|
1009
|
+
if (!body) return "";
|
|
1010
|
+
if (typeof body === "string") return body;
|
|
1011
|
+
// Walk object/array, concatenating raw string leaves so CR/LF chars aren't
|
|
1012
|
+
// hidden behind JSON escape sequences (\r → "\\r" after JSON.stringify).
|
|
1013
|
+
const parts: string[] = [];
|
|
1014
|
+
const seen = new WeakSet<object>();
|
|
1015
|
+
const visit = (v: unknown): void => {
|
|
1016
|
+
if (typeof v === "string") parts.push(v);
|
|
1017
|
+
else if (v && typeof v === "object") {
|
|
1018
|
+
if (seen.has(v as object)) return;
|
|
1019
|
+
seen.add(v as object);
|
|
1020
|
+
if (Array.isArray(v)) v.forEach(visit);
|
|
1021
|
+
else for (const k of Object.keys(v as object)) visit((v as Record<string, unknown>)[k]);
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
try {
|
|
1025
|
+
visit(body);
|
|
1026
|
+
} catch {
|
|
1027
|
+
return "";
|
|
1028
|
+
}
|
|
1029
|
+
return parts.join("\n");
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function safeDecodeURI(s: string): string {
|
|
1033
|
+
try {
|
|
1034
|
+
return decodeURIComponent(s);
|
|
1035
|
+
} catch {
|
|
1036
|
+
return s;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
type EchoKind =
|
|
1041
|
+
| "verbatim"
|
|
1042
|
+
| "url-decoded"
|
|
1043
|
+
| "CR stripped"
|
|
1044
|
+
| "LF stripped"
|
|
1045
|
+
| "CRLF→LF"
|
|
1046
|
+
| "CRLF→CR"
|
|
1047
|
+
| "tail after CRLF";
|
|
1048
|
+
|
|
1049
|
+
interface EchoResult {
|
|
1050
|
+
matched: boolean;
|
|
1051
|
+
kind: EchoKind | "none";
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export function classifyEcho(body: unknown, payload: string, cls: SecurityClass): EchoResult {
|
|
1055
|
+
if (!payload) return { matched: false, kind: "none" };
|
|
1056
|
+
const haystackRaw = bodyToString(body);
|
|
1057
|
+
if (!haystackRaw) return { matched: false, kind: "none" };
|
|
1058
|
+
|
|
1059
|
+
// SSRF / open-redirect: verbatim only — URLs are usually preserved as-is.
|
|
1060
|
+
if (cls !== "crlf") {
|
|
1061
|
+
return haystackRaw.includes(payload)
|
|
1062
|
+
? { matched: true, kind: "verbatim" }
|
|
1063
|
+
: { matched: false, kind: "none" };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// CRLF: try verbatim → URL-decode pairs → CR/LF normalization variants → tail.
|
|
1067
|
+
if (haystackRaw.includes(payload)) return { matched: true, kind: "verbatim" };
|
|
1068
|
+
|
|
1069
|
+
const haystackDecoded = safeDecodeURI(haystackRaw);
|
|
1070
|
+
const payloadDecoded = safeDecodeURI(payload);
|
|
1071
|
+
|
|
1072
|
+
if (
|
|
1073
|
+
(payloadDecoded !== payload && haystackRaw.includes(payloadDecoded)) ||
|
|
1074
|
+
(haystackDecoded !== haystackRaw && haystackDecoded.includes(payload)) ||
|
|
1075
|
+
(payloadDecoded !== payload && haystackDecoded !== haystackRaw && haystackDecoded.includes(payloadDecoded))
|
|
1076
|
+
) {
|
|
1077
|
+
return { matched: true, kind: "url-decoded" };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Normalize: try variants of payload where backend stripped CR or LF.
|
|
1081
|
+
const variants: Array<[string, EchoKind]> = [];
|
|
1082
|
+
if (payloadDecoded.includes("\r\n")) {
|
|
1083
|
+
variants.push([payloadDecoded.replace(/\r\n/g, "\n"), "CRLF→LF"]);
|
|
1084
|
+
variants.push([payloadDecoded.replace(/\r\n/g, "\r"), "CRLF→CR"]);
|
|
1085
|
+
variants.push([payloadDecoded.replace(/\r\n/g, ""), "CRLF→LF"]);
|
|
1086
|
+
}
|
|
1087
|
+
if (payloadDecoded.includes("\r")) variants.push([payloadDecoded.replace(/\r/g, ""), "CR stripped"]);
|
|
1088
|
+
if (payloadDecoded.includes("\n")) variants.push([payloadDecoded.replace(/\n/g, ""), "LF stripped"]);
|
|
1089
|
+
|
|
1090
|
+
for (const [variant, kind] of variants) {
|
|
1091
|
+
if (variant && variant !== payloadDecoded && (haystackRaw.includes(variant) || haystackDecoded.includes(variant))) {
|
|
1092
|
+
return { matched: true, kind };
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Tail-substring: parser truncated at newline, only suffix landed in storage.
|
|
1097
|
+
const splitMatch = payloadDecoded.match(/(?:\r\n|%0d%0a|%0a|%0d|\r|\n)(.+)$/i);
|
|
1098
|
+
const tail = splitMatch?.[1];
|
|
1099
|
+
if (tail && tail.length >= 3 && (haystackRaw.includes(tail) || haystackDecoded.includes(tail))) {
|
|
1100
|
+
return { matched: true, kind: "tail after CRLF" };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
return { matched: false, kind: "none" };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function summaryLine(v: SecurityVerdict): string {
|
|
1107
|
+
const counts: Record<SecuritySeverity, number> = {
|
|
1108
|
+
high: 0, medium: 0, low: 0, info: 0, inconclusive: 0, "inconclusive-baseline": 0, ok: 0, skipped: 0,
|
|
1109
|
+
};
|
|
1110
|
+
for (const f of v.findings) counts[f.severity]++;
|
|
1111
|
+
const fields = Array.from(new Set(v.detectedFields.map(d => d.field))).join(", ");
|
|
1112
|
+
return `fields=[${fields}] · HIGH=${counts.high} MED=${counts.medium} LOW=${counts.low} INFO=${counts.info} INCONCLUSIVE=${counts.inconclusive} OK=${counts.ok}`;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ──────────────────────────────────────────────
|
|
1116
|
+
// Cleanup helper — best-effort DELETE on stateful endpoints.
|
|
1117
|
+
// ──────────────────────────────────────────────
|
|
1118
|
+
|
|
1119
|
+
async function tryCleanup(
|
|
1120
|
+
ep: EndpointInfo,
|
|
1121
|
+
allEndpoints: EndpointInfo[],
|
|
1122
|
+
schemes: SecuritySchemeInfo[],
|
|
1123
|
+
vars: Record<string, string>,
|
|
1124
|
+
responseBody: unknown,
|
|
1125
|
+
verdict: SecurityVerdict,
|
|
1126
|
+
opts: ProbeStepOpts,
|
|
1127
|
+
): Promise<void> {
|
|
1128
|
+
const delEp = findDeleteCounterpart(ep, allEndpoints);
|
|
1129
|
+
if (!delEp) {
|
|
1130
|
+
// Surface the gap. Round-4 dogfooding: 3 DSN keys leaked from
|
|
1131
|
+
// POST /keys/ silently because the spec didn't expose a DELETE
|
|
1132
|
+
// counterpart — flagging it in the digest gives the operator a
|
|
1133
|
+
// chance to clean up by hand instead of finding out later.
|
|
1134
|
+
accumulateCleanupError(verdict, `no DELETE counterpart for ${ep.method.toUpperCase()} ${ep.path}; possible leaked resource`);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const idField = captureFieldFor(ep);
|
|
1138
|
+
const id = pickId(responseBody, idField);
|
|
1139
|
+
if (!id) {
|
|
1140
|
+
accumulateCleanupError(verdict, `cleanup skipped: response had no usable id for ${ep.method.toUpperCase()} ${ep.path}`);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
// DELETE path has one path-param at the end; replace it with the captured id.
|
|
1144
|
+
const concretePath = delEp.path.replace(/\{[^}]+\}/, encodeURIComponent(String(id)));
|
|
1145
|
+
const url = `${(vars["base_url"] ?? "").replace(/\/+$/, "")}${concretePath}`;
|
|
1146
|
+
const headers = liveAuthHeaders(delEp, schemes, vars);
|
|
1147
|
+
|
|
1148
|
+
// TASK-278: stash id + deletePath on the verdict so the orphan tracker
|
|
1149
|
+
// (and `zond cleanup --orphans`) can replay this DELETE without re-running
|
|
1150
|
+
// the probe. Done before retries so even an aborted run leaves a trace.
|
|
1151
|
+
{
|
|
1152
|
+
const prior = verdict.cleanup ?? { attempted: false };
|
|
1153
|
+
verdict.cleanup = {
|
|
1154
|
+
...prior,
|
|
1155
|
+
attempted: prior.attempted || true,
|
|
1156
|
+
id,
|
|
1157
|
+
deletePath: concretePath,
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Eventual-consistency retry (round-5 follow-up): POST creates on the
|
|
1162
|
+
// write replica, immediate DELETE hits a read replica that hasn't seen
|
|
1163
|
+
// the new id yet → 404. Two short backoffs swallow that transient
|
|
1164
|
+
// 404; a 404 that survives the backoff is a real leak and lands in
|
|
1165
|
+
// verdict.cleanup.error. Only 404 is retried — 5xx, network errors,
|
|
1166
|
+
// 401/403 fail fast (the situation isn't going to improve).
|
|
1167
|
+
const RETRY_DELAYS_MS = opts.cleanupRetryDelaysMs ?? [200, 1000];
|
|
1168
|
+
let lastResp: { status: number } | null = null;
|
|
1169
|
+
let lastNetErr: string | null = null;
|
|
1170
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
|
1171
|
+
if (attempt > 0) await new Promise(r => setTimeout(r, RETRY_DELAYS_MS[attempt - 1]!));
|
|
1172
|
+
try {
|
|
1173
|
+
const resp = await executeRequest(
|
|
1174
|
+
{ method: "DELETE", url, headers },
|
|
1175
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
1176
|
+
);
|
|
1177
|
+
lastResp = { status: resp.status };
|
|
1178
|
+
if (resp.status >= 200 && resp.status < 300) {
|
|
1179
|
+
const prior = verdict.cleanup ?? { attempted: false };
|
|
1180
|
+
verdict.cleanup = {
|
|
1181
|
+
attempted: true,
|
|
1182
|
+
status: resp.status,
|
|
1183
|
+
...(prior.error ? { error: prior.error } : {}),
|
|
1184
|
+
...(prior.id !== undefined ? { id: prior.id } : {}),
|
|
1185
|
+
...(prior.deletePath ? { deletePath: prior.deletePath } : {}),
|
|
1186
|
+
};
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
// Only retry transient 404 (eventual-consistency window).
|
|
1190
|
+
if (resp.status !== 404) break;
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
lastNetErr = err instanceof Error ? err.message : String(err);
|
|
1193
|
+
// Network errors are not retried — they're not transient in the
|
|
1194
|
+
// eventual-consistency sense (they're config/connectivity issues).
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
if (lastNetErr) {
|
|
1200
|
+
accumulateCleanupError(verdict, `DELETE ${delEp.path} network error: ${lastNetErr}`);
|
|
1201
|
+
} else if (lastResp) {
|
|
1202
|
+
const tail = lastResp.status === 404 ? " (persisted across retries — likely real leak)" : "";
|
|
1203
|
+
accumulateCleanupError(verdict, `DELETE ${delEp.path} → ${lastResp.status} (id=${id})${tail}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function accumulateCleanupError(verdict: SecurityVerdict, msg: string): void {
|
|
1208
|
+
const prior = verdict.cleanup ?? { attempted: false };
|
|
1209
|
+
const errors = prior.error ? `${prior.error} | ${msg}` : msg;
|
|
1210
|
+
verdict.cleanup = {
|
|
1211
|
+
attempted: true,
|
|
1212
|
+
...(prior.status ? { status: prior.status } : {}),
|
|
1213
|
+
...(prior.id !== undefined ? { id: prior.id } : {}),
|
|
1214
|
+
...(prior.deletePath ? { deletePath: prior.deletePath } : {}),
|
|
1215
|
+
error: errors,
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function pickId(body: unknown, field: string): string | number | undefined {
|
|
1220
|
+
if (!body || typeof body !== "object") return undefined;
|
|
1221
|
+
const obj = body as Record<string, unknown>;
|
|
1222
|
+
for (const key of [field, "id", "slug", "uuid", "key"]) {
|
|
1223
|
+
const v = obj[key];
|
|
1224
|
+
if (typeof v === "string" || typeof v === "number") return v;
|
|
1225
|
+
}
|
|
1226
|
+
return undefined;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function skipped(ep: EndpointInfo, reason: string): SecurityVerdict {
|
|
1230
|
+
return {
|
|
1231
|
+
method: ep.method.toUpperCase(),
|
|
1232
|
+
path: ep.path,
|
|
1233
|
+
severity: "skipped",
|
|
1234
|
+
summary: `skipped: ${reason}`,
|
|
1235
|
+
detectedFields: [],
|
|
1236
|
+
findings: [],
|
|
1237
|
+
skipReason: reason,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// ──────────────────────────────────────────────
|
|
1242
|
+
// Markdown digest
|
|
1243
|
+
// ──────────────────────────────────────────────
|
|
1244
|
+
|
|
1245
|
+
/** TASK-154 §N: clip noisy payloads (some SSRF/CRLF/redirect strings are URL-
|
|
1246
|
+
* encoded blobs > 60 chars). Keep the leading prefix users recognise plus an
|
|
1247
|
+
* ellipsis, so the digest line stays readable. */
|
|
1248
|
+
/** ARV-245 (R-04/F16): percent-encode unsafe characters per path segment
|
|
1249
|
+
* for paste-ready manual repro lines in the digest. Mirrors the encoding
|
|
1250
|
+
* rules used by `cleanup --orphans` so the printed command works against
|
|
1251
|
+
* the same APIs the probe targeted. */
|
|
1252
|
+
function encodeDeletePathForRepro(deletePath: string): string {
|
|
1253
|
+
const SAFE = /[A-Za-z0-9._~!$&'()*+,;=:@-]/;
|
|
1254
|
+
return deletePath
|
|
1255
|
+
.split("/")
|
|
1256
|
+
.map((segment) => {
|
|
1257
|
+
if (segment.length === 0) return segment;
|
|
1258
|
+
let out = "";
|
|
1259
|
+
for (let i = 0; i < segment.length; i++) {
|
|
1260
|
+
const ch = segment.charAt(i);
|
|
1261
|
+
if (ch === "%" && /^[0-9A-Fa-f]{2}$/.test(segment.slice(i + 1, i + 3))) {
|
|
1262
|
+
out += segment.slice(i, i + 3);
|
|
1263
|
+
i += 2;
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
out += SAFE.test(ch) ? ch : encodeURIComponent(ch);
|
|
1267
|
+
}
|
|
1268
|
+
return out;
|
|
1269
|
+
})
|
|
1270
|
+
.join("/");
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function truncatePayload(payload: string, max: number): string {
|
|
1274
|
+
if (payload.length <= max) return payload;
|
|
1275
|
+
return payload.slice(0, max - 1) + "…";
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
export function formatSecurityDigest(
|
|
1279
|
+
result: SecurityProbeResult,
|
|
1280
|
+
specPath: string,
|
|
1281
|
+
): string {
|
|
1282
|
+
const lines: string[] = [];
|
|
1283
|
+
lines.push(`# zond probe-security digest`);
|
|
1284
|
+
lines.push("");
|
|
1285
|
+
lines.push(`Spec: \`${specPath}\``);
|
|
1286
|
+
lines.push(`Classes: ${result.classes.join(", ")}`);
|
|
1287
|
+
lines.push(`Endpoints scanned: ${result.totalEndpoints} · probed: ${result.specProbed}`);
|
|
1288
|
+
// ARV-140 AC#4: surface the cleanup-feasibility outcome up front so a
|
|
1289
|
+
// green run doesn't hide "we attacked 14 leak-prone POSTs anyway".
|
|
1290
|
+
if (result.cleanupFeasibility) {
|
|
1291
|
+
const f = result.cleanupFeasibility;
|
|
1292
|
+
if (f.skippedNoCleanup > 0) {
|
|
1293
|
+
lines.push(`Cleanup pre-flight: ${f.skippedNoCleanup} endpoint(s) skipped (no DELETE counterpart). Pass \`--allow-leaks\` to attack anyway.`);
|
|
1294
|
+
} else if (f.forcedNoCleanup > 0) {
|
|
1295
|
+
lines.push(`Cleanup pre-flight: ${f.forcedNoCleanup} endpoint(s) attacked despite no DELETE counterpart (--allow-leaks).`);
|
|
1296
|
+
}
|
|
1297
|
+
// ARV-153: surface action-verb POSTs we now attack without a DELETE
|
|
1298
|
+
// counterpart so green runs make the recall win visible.
|
|
1299
|
+
if (f.actionNoCleanupNeeded > 0) {
|
|
1300
|
+
lines.push(`Cleanup pre-flight: ${f.actionNoCleanupNeeded} action POST(s) attacked (no resource created — DELETE counterpart not needed).`);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
lines.push("");
|
|
1304
|
+
|
|
1305
|
+
// Cleanup failures section is mandatory and goes FIRST when present —
|
|
1306
|
+
// round-4 dogfooding: a "green" run (HIGH=0) silently leaked DSN keys
|
|
1307
|
+
// and left renamed projects, because cleanup failures were buried in
|
|
1308
|
+
// per-verdict objects. Surface them prominently so a green probe is a
|
|
1309
|
+
// signal the org is clean, not just that nothing crashed.
|
|
1310
|
+
const cleanupFailures = result.verdicts.filter(v => v.cleanup?.error);
|
|
1311
|
+
if (cleanupFailures.length > 0) {
|
|
1312
|
+
lines.push(`## ⚠️ Cleanup failures (${cleanupFailures.length}) — manual remediation may be required`);
|
|
1313
|
+
lines.push("");
|
|
1314
|
+
for (const v of cleanupFailures) {
|
|
1315
|
+
lines.push(`- **${v.method} ${v.path}** — ${v.cleanup!.error}`);
|
|
1316
|
+
// ARV-245 (R-04/F16): paste-ready manual repro when we have a
|
|
1317
|
+
// deletePath. Auto-encode the path so operators dealing with
|
|
1318
|
+
// CRLF-poisoned ids (round-4 GitHub labels) don't have to remember
|
|
1319
|
+
// to percent-encode `\r`/`\n`/spaces themselves.
|
|
1320
|
+
const dp = v.cleanup?.deletePath;
|
|
1321
|
+
if (dp) {
|
|
1322
|
+
const encoded = encodeDeletePathForRepro(dp);
|
|
1323
|
+
const note = /[\r\n\t ]/.test(dp) ? " (note: id contains whitespace/CRLF — percent-encoded)" : "";
|
|
1324
|
+
lines.push(` - Manual repro: \`zond request DELETE ${encoded} --api <name>\`${note}`);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
lines.push("");
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const buckets: Record<SecuritySeverity, SecurityVerdict[]> = {
|
|
1331
|
+
high: [], medium: [], low: [], info: [], inconclusive: [], "inconclusive-baseline": [], ok: [], skipped: [],
|
|
1332
|
+
};
|
|
1333
|
+
for (const v of result.verdicts) buckets[v.severity].push(v);
|
|
1334
|
+
|
|
1335
|
+
const ordered: SecuritySeverity[] = ["high", "inconclusive", "inconclusive-baseline", "medium", "low", "info", "ok", "skipped"];
|
|
1336
|
+
const titles: Record<SecuritySeverity, string> = {
|
|
1337
|
+
high: "🚨 HIGH — header-reflection / HTML reflection / 5xx",
|
|
1338
|
+
medium: "⚠️ MEDIUM — SSRF accept on endpoint declaring delivery (no OOB confirmation)",
|
|
1339
|
+
low: "🟡 LOW — storage observed, no dangerous-context reflection (verify manually)",
|
|
1340
|
+
info: "· INFO — accepted, no reflection observed (sanitization signal only)",
|
|
1341
|
+
inconclusive: "❓ INCONCLUSIVE — could not classify",
|
|
1342
|
+
"inconclusive-baseline": "⚠️ INCONCLUSIVE-BASELINE — baseline 4xx, attacks not run",
|
|
1343
|
+
ok: "✅ OK — payloads rejected with 4xx",
|
|
1344
|
+
skipped: "⏭️ SKIPPED — no detected fields / no body",
|
|
1345
|
+
};
|
|
1346
|
+
for (const sev of ordered) {
|
|
1347
|
+
const list = buckets[sev];
|
|
1348
|
+
if (list.length === 0) continue;
|
|
1349
|
+
lines.push(`## ${titles[sev]} (${list.length})`);
|
|
1350
|
+
lines.push("");
|
|
1351
|
+
for (const v of list) {
|
|
1352
|
+
const cleanupTag = v.cleanup?.error ? " 🧹 cleanup-failure" : "";
|
|
1353
|
+
lines.push(`- **${v.method} ${v.path}**${cleanupTag} — ${v.summary}`);
|
|
1354
|
+
for (const f of v.findings) {
|
|
1355
|
+
// TASK-154 §N: surface the actual payload that triggered the finding
|
|
1356
|
+
// — without it the digest is useless for case-study writing (which
|
|
1357
|
+
// SSRF target? which CRLF shape?). Truncate long payloads so the
|
|
1358
|
+
// line stays readable.
|
|
1359
|
+
const payload = truncatePayload(f.payload, 60);
|
|
1360
|
+
lines.push(` - \`${f.field}\` / ${f.class} [\`${payload}\`] → ${f.status} (${f.severity}) — ${f.reason}`);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
lines.push("");
|
|
1364
|
+
}
|
|
1365
|
+
return lines.join("\n");
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// ──────────────────────────────────────────────
|
|
1369
|
+
// Regression suite emission
|
|
1370
|
+
// ──────────────────────────────────────────────
|
|
1371
|
+
|
|
1372
|
+
const ATTACK_EXPECTED_STATUS = [400, 403, 404, 405, 409, 415, 422];
|
|
1373
|
+
|
|
1374
|
+
export function emitSecurityRegressionSuites(
|
|
1375
|
+
result: SecurityProbeResult,
|
|
1376
|
+
endpoints: EndpointInfo[],
|
|
1377
|
+
schemes: SecuritySchemeInfo[],
|
|
1378
|
+
): RawSuite[] {
|
|
1379
|
+
const suites: RawSuite[] = [];
|
|
1380
|
+
for (const v of result.verdicts) {
|
|
1381
|
+
// ARV-247 (R-04/F18): `high` findings are 2xx-with-echoed-payload — the
|
|
1382
|
+
// strongest regression signal we have. Skipping them meant CI had nothing
|
|
1383
|
+
// to gate on for confirmed stored injections. Treat them like `ok` here:
|
|
1384
|
+
// the suite locks in the *expected* state (attack rejected) once the API
|
|
1385
|
+
// owner ships a fix, and fails loud while it's still broken.
|
|
1386
|
+
if (v.severity !== "ok" && v.severity !== "low" && v.severity !== "high") continue;
|
|
1387
|
+
const ep = endpoints.find(
|
|
1388
|
+
e => e.path === v.path && e.method.toUpperCase() === v.method,
|
|
1389
|
+
);
|
|
1390
|
+
if (!ep) continue;
|
|
1391
|
+
const suiteHeaders = getAuthHeaders(ep, schemes);
|
|
1392
|
+
const tests: RawStep[] = [];
|
|
1393
|
+
for (const f of v.findings) {
|
|
1394
|
+
// `ok` = attack already rejected; `high` = attack accepted+echoed (the
|
|
1395
|
+
// regression target is rejection, same expected set as `ok`). `low` =
|
|
1396
|
+
// attack accepted but no echo — lock in the 2xx-without-echo shape.
|
|
1397
|
+
const expected = (f.severity === "ok" || f.severity === "high") ? ATTACK_EXPECTED_STATUS : [200, 201, 202, 204];
|
|
1398
|
+
const body = ep.requestBodySchema ? generateFromSchema(ep.requestBodySchema) : {};
|
|
1399
|
+
if (typeof body === "object" && body !== null && !Array.isArray(body)) {
|
|
1400
|
+
(body as Record<string, unknown>)[f.field] = f.payload;
|
|
1401
|
+
}
|
|
1402
|
+
const step: RawStep = {
|
|
1403
|
+
name: `${f.class}: ${f.field}=${shortPayload(f.payload)} must ${f.severity === "ok" ? "be rejected" : "not echo"}`,
|
|
1404
|
+
source: {
|
|
1405
|
+
generator: "probe-security",
|
|
1406
|
+
endpoint: `${v.method} ${v.path}`,
|
|
1407
|
+
response_branch: expected.map(String).join("|"),
|
|
1408
|
+
},
|
|
1409
|
+
[v.method]: convertPath(ep.path),
|
|
1410
|
+
json: body,
|
|
1411
|
+
expect: { status: expected },
|
|
1412
|
+
};
|
|
1413
|
+
tests.push(step);
|
|
1414
|
+
}
|
|
1415
|
+
if (tests.length === 0) continue;
|
|
1416
|
+
// Attach a generic cleanup step keyed off `created_id` (only fires when
|
|
1417
|
+
// a previous step captured one — same `always:true` semantics other
|
|
1418
|
+
// probes use).
|
|
1419
|
+
const delEp = findDeleteCounterpart(ep, endpoints);
|
|
1420
|
+
if (delEp) {
|
|
1421
|
+
const idField = captureFieldFor(ep);
|
|
1422
|
+
tests[0]!.expect.body = { ...(tests[0]!.expect.body ?? {}), [idField]: { capture: "created_id" } };
|
|
1423
|
+
const idParam = (delEp.path.match(/\{([^}]+)\}/) ?? [])[1] ?? "id";
|
|
1424
|
+
const delStep: RawStep = {
|
|
1425
|
+
name: "cleanup",
|
|
1426
|
+
source: { generator: "probe-security-cleanup", endpoint: `DELETE ${delEp.path}` },
|
|
1427
|
+
always: true,
|
|
1428
|
+
DELETE: convertPath(delEp.path).replace(`{{${idParam}}}`, "{{created_id}}"),
|
|
1429
|
+
expect: { status: [200, 202, 204, 404] },
|
|
1430
|
+
} as RawStep & { always: boolean };
|
|
1431
|
+
tests.push(delStep);
|
|
1432
|
+
}
|
|
1433
|
+
suites.push({
|
|
1434
|
+
name: `probe-security ${v.method} ${v.path}`,
|
|
1435
|
+
tags: ["probe-security", ...result.classes],
|
|
1436
|
+
source: {
|
|
1437
|
+
type: "probe-suite",
|
|
1438
|
+
generator: "probe-security",
|
|
1439
|
+
endpoint: `${v.method} ${v.path}`,
|
|
1440
|
+
},
|
|
1441
|
+
fileStem: `probe-security-${endpointStem(ep)}`,
|
|
1442
|
+
base_url: "{{base_url}}",
|
|
1443
|
+
...(suiteHeaders ? { headers: suiteHeaders } : {}),
|
|
1444
|
+
tests,
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
return suites;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function shortPayload(s: string): string {
|
|
1451
|
+
return s.length > 40 ? s.slice(0, 37) + "…" : s;
|
|
1452
|
+
}
|
|
1453
|
+
|