@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
|
@@ -124,6 +124,12 @@ function mergePrivileged(
|
|
|
124
124
|
return merged;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** ARV-198: declared content type signals whether the create step needs
|
|
128
|
+
* `form:` (Stripe v1, Rails/PHP-style APIs) or the default `json:` body. */
|
|
129
|
+
function isFormEndpoint(ep: EndpointInfo): boolean {
|
|
130
|
+
return ep.requestBodyContentType === "application/x-www-form-urlencoded";
|
|
131
|
+
}
|
|
132
|
+
|
|
127
133
|
function buildFullChain(
|
|
128
134
|
create: EndpointInfo,
|
|
129
135
|
read: EndpointInfo,
|
|
@@ -134,12 +140,24 @@ function buildFullChain(
|
|
|
134
140
|
const idVar = captureFieldFor(create) || "created_id";
|
|
135
141
|
const tests: RawStep[] = [];
|
|
136
142
|
|
|
137
|
-
|
|
143
|
+
// ARV-198: when the spec declares only application/x-www-form-urlencoded
|
|
144
|
+
// for this mutating endpoint, emit a `form:` block + Content-Type header.
|
|
145
|
+
// The serializer force-quotes form values (ARV-162) so booleans/numbers
|
|
146
|
+
// round-trip as strings on the wire; the template is paste-ready against
|
|
147
|
+
// Stripe-style APIs without any post-processing.
|
|
148
|
+
const createIsForm = isFormEndpoint(create);
|
|
149
|
+
const createStep: Record<string, unknown> = {
|
|
138
150
|
name: "create with privileged fields",
|
|
139
151
|
[create.method.toUpperCase()]: create.path,
|
|
140
|
-
json: privilegedBody,
|
|
141
152
|
expect: { status: [200, 201], body: { id: { capture: idVar } } },
|
|
142
|
-
}
|
|
153
|
+
};
|
|
154
|
+
if (createIsForm) {
|
|
155
|
+
createStep.headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
156
|
+
createStep.form = privilegedBody;
|
|
157
|
+
} else {
|
|
158
|
+
createStep.json = privilegedBody;
|
|
159
|
+
}
|
|
160
|
+
tests.push(createStep as unknown as RawStep);
|
|
143
161
|
|
|
144
162
|
// Canonical assertion vocabulary: only `not_equals` is supported (no `not`,
|
|
145
163
|
// no `not_starts_with`). For protected fields we assert the exact attacker
|
|
@@ -187,7 +205,17 @@ function buildSingleStep(
|
|
|
187
205
|
[method]: ep.path,
|
|
188
206
|
expect: { status: [400, 422] },
|
|
189
207
|
};
|
|
190
|
-
if (method !== "GET" && method !== "DELETE")
|
|
208
|
+
if (method !== "GET" && method !== "DELETE") {
|
|
209
|
+
// ARV-198: same form/json branching as the full-chain create — see
|
|
210
|
+
// buildFullChain for rationale (Stripe-style mutators declare only
|
|
211
|
+
// application/x-www-form-urlencoded).
|
|
212
|
+
if (isFormEndpoint(ep)) {
|
|
213
|
+
step.headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
214
|
+
step.form = privilegedBody;
|
|
215
|
+
} else {
|
|
216
|
+
step.json = privilegedBody;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
191
219
|
tests.push(step as unknown as RawStep);
|
|
192
220
|
return {
|
|
193
221
|
name: `ma ${slugFromPath(ep.path)}`,
|
|
@@ -18,6 +18,7 @@ import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
|
|
|
18
18
|
import { executeRequest } from "../runner/http-client.ts";
|
|
19
19
|
import { substituteString } from "../parser/variables.ts";
|
|
20
20
|
import { convertPath, captureFieldFor, liveAuthHeaders } from "./shared.ts";
|
|
21
|
+
import { joinBaseAndPath } from "../util/url.ts";
|
|
21
22
|
|
|
22
23
|
export type DiscoveryHit = { kind: "hit"; values: Record<string, string> };
|
|
23
24
|
export type DiscoveryMiss = { kind: "miss"; reason: string };
|
|
@@ -103,8 +104,7 @@ async function discoverFromList(opts: DiscoverFromListOpts): Promise<DiscoveryRe
|
|
|
103
104
|
opts.cache.inFlight.add(cacheKey);
|
|
104
105
|
try {
|
|
105
106
|
// Resolve listEp's own path placeholders (nested-collection case).
|
|
106
|
-
const
|
|
107
|
-
const templated = `${baseUrl}${convertPath(listEp.path)}`;
|
|
107
|
+
const templated = joinBaseAndPath(opts.vars["base_url"], convertPath(listEp.path));
|
|
108
108
|
let urlVars = opts.vars;
|
|
109
109
|
let urlSubstituted = String(substituteString(templated, urlVars));
|
|
110
110
|
let stillUnresolved = collectUnresolved(urlSubstituted);
|
|
@@ -326,8 +326,7 @@ export async function discoverBodyFkVars(
|
|
|
326
326
|
continue;
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
const
|
|
330
|
-
const url = `${baseUrl}${listEp.path}`;
|
|
329
|
+
const url = joinBaseAndPath(opts.vars["base_url"], listEp.path);
|
|
331
330
|
const headers: Record<string, string> = {
|
|
332
331
|
accept: "application/json",
|
|
333
332
|
...liveAuthHeaders(listEp, opts.schemes, opts.vars),
|
|
@@ -15,7 +15,9 @@ import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
|
|
|
15
15
|
import { generateFromSchema } from "../generator/data-factory.ts";
|
|
16
16
|
import { substituteDeep, substituteString } from "../parser/variables.ts";
|
|
17
17
|
import { convertPath, liveAuthHeaders } from "./shared.ts";
|
|
18
|
+
import { joinBaseAndPath } from "../util/url.ts";
|
|
18
19
|
import { encodeFormBody } from "../runner/form-encode.ts";
|
|
20
|
+
import type { SeedBodyConfig } from "../generator/resources-builder.ts";
|
|
19
21
|
|
|
20
22
|
/** ARV-150: form-encoded mutating endpoint (Stripe v1 pattern).
|
|
21
23
|
* Stripe and other Rails/PHP APIs declare requestBody.content with ONLY
|
|
@@ -62,34 +64,20 @@ export function buildProbeUrl(
|
|
|
62
64
|
ep: EndpointInfo,
|
|
63
65
|
vars: Record<string, string>,
|
|
64
66
|
): { url: string; unresolved: string[] } {
|
|
65
|
-
const
|
|
66
|
-
const templated = `${baseUrl}${convertPath(ep.path)}`;
|
|
67
|
+
const templated = joinBaseAndPath(vars["base_url"], convertPath(ep.path));
|
|
67
68
|
const url = String(substituteString(templated, vars));
|
|
68
69
|
const unresolved = Array.from(url.matchAll(/\{\{([^}]+)\}\}/g)).map(m => m[1]!);
|
|
69
70
|
return { url, unresolved };
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
/**
|
|
73
|
-
* Standard probe headers:
|
|
74
|
-
*
|
|
75
|
-
*
|
|
74
|
+
* Standard probe headers: content-type from the endpoint's spec
|
|
75
|
+
* (form-urlencoded for Stripe v1, JSON otherwise) plus the resolved
|
|
76
|
+
* auth header for the endpoint. Accept stays JSON — the server still
|
|
77
|
+
* answers in JSON even when the body is form-encoded. Empty
|
|
78
|
+
* `liveAuthHeaders` is fine — the spread is a no-op for unauthenticated
|
|
79
|
+
* endpoints.
|
|
76
80
|
*/
|
|
77
|
-
export function buildJsonAuthHeaders(
|
|
78
|
-
ep: EndpointInfo,
|
|
79
|
-
schemes: SecuritySchemeInfo[],
|
|
80
|
-
vars: Record<string, string>,
|
|
81
|
-
): Record<string, string> {
|
|
82
|
-
return {
|
|
83
|
-
"content-type": "application/json",
|
|
84
|
-
accept: "application/json",
|
|
85
|
-
...liveAuthHeaders(ep, schemes, vars),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** ARV-150: like buildJsonAuthHeaders but picks the Content-Type from the
|
|
90
|
-
* endpoint's spec (form-urlencoded for Stripe v1, JSON otherwise). Accept
|
|
91
|
-
* stays JSON — the server still answers in JSON even when the body is
|
|
92
|
-
* form-encoded. */
|
|
93
81
|
export function buildBodyAuthHeaders(
|
|
94
82
|
ep: EndpointInfo,
|
|
95
83
|
schemes: SecuritySchemeInfo[],
|
|
@@ -108,12 +96,23 @@ export function buildBodyAuthHeaders(
|
|
|
108
96
|
* substitute live vars. Returns null when the result isn't a JSON
|
|
109
97
|
* object (array / scalar / null) — both probes treat that as a skip
|
|
110
98
|
* reason ("request body not a JSON object").
|
|
99
|
+
*
|
|
100
|
+
* ARV-269: when `seedBody` is provided (agent-authored overlay from
|
|
101
|
+
* `.api-resources.local.yaml`), it wins over `generateFromSchema`.
|
|
102
|
+
* Strict-validating APIs (Stripe required-field XORs, `expand[]` arrays)
|
|
103
|
+
* reject random scalars from the generator and the whole verdict becomes
|
|
104
|
+
* INCONCLUSIVE-baseline; the overlay carries a payload the API actually
|
|
105
|
+
* accepts. Mirrors the path stateful checks took via `resolveCreateBody`
|
|
106
|
+
* (`core/checks/checks/_crud-helpers.ts`).
|
|
111
107
|
*/
|
|
112
108
|
export function buildBaselineFromSpec(
|
|
113
109
|
ep: EndpointInfo,
|
|
114
110
|
vars: Record<string, string>,
|
|
111
|
+
seedBody?: SeedBodyConfig,
|
|
115
112
|
): Record<string, unknown> | null {
|
|
116
|
-
const raw =
|
|
113
|
+
const raw = seedBody && seedBody.body && typeof seedBody.body === "object"
|
|
114
|
+
? seedBody.body
|
|
115
|
+
: (ep.requestBodySchema ? generateFromSchema(ep.requestBodySchema) : {});
|
|
117
116
|
const sub = substituteDeep(raw, vars);
|
|
118
117
|
if (typeof sub !== "object" || sub === null || Array.isArray(sub)) return null;
|
|
119
118
|
return sub as Record<string, unknown>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
|
|
2
|
+
import { executeRequest } from "../../runner/http-client.ts";
|
|
3
|
+
import { findGetByIdCounterpart, liveAuthHeaders } from "../shared.ts";
|
|
4
|
+
import { buildProbeUrl, serializeProbeBody } from "../probe-harness.ts";
|
|
5
|
+
import type { ProbeStepOpts, SecurityVerdict } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export interface Snapshot {
|
|
8
|
+
/** Original GET-response body, used to restore state via PUT/PATCH. */
|
|
9
|
+
body: Record<string, unknown>;
|
|
10
|
+
/** ETag (if API uses optimistic locking) — sent back as `If-Match` on restore. */
|
|
11
|
+
etag?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type BaselineResult =
|
|
15
|
+
| { kind: "ok"; status: number; body: unknown; headers: Record<string, string> }
|
|
16
|
+
| { kind: "network"; reason: string };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Baseline send — wraps executeRequest with shape that distinguishes a real
|
|
20
|
+
* HTTP response from a network error (so the caller can decide whether to
|
|
21
|
+
* retry partial-body / mark the endpoint unreachable).
|
|
22
|
+
*/
|
|
23
|
+
export async function sendBaseline(
|
|
24
|
+
ep: EndpointInfo,
|
|
25
|
+
method: string,
|
|
26
|
+
url: string,
|
|
27
|
+
headers: Record<string, string>,
|
|
28
|
+
body: unknown,
|
|
29
|
+
opts: ProbeStepOpts,
|
|
30
|
+
): Promise<BaselineResult> {
|
|
31
|
+
try {
|
|
32
|
+
// ARV-161: serialize via serializeProbeBody so form-encoded endpoints
|
|
33
|
+
// get x-www-form-urlencoded payload matching Content-Type.
|
|
34
|
+
const wire = body && typeof body === "object" && !Array.isArray(body)
|
|
35
|
+
? serializeProbeBody(ep, body as Record<string, unknown>).content
|
|
36
|
+
: JSON.stringify(body);
|
|
37
|
+
const resp = await executeRequest(
|
|
38
|
+
{ method, url, headers, body: wire },
|
|
39
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
40
|
+
);
|
|
41
|
+
return {
|
|
42
|
+
kind: "ok",
|
|
43
|
+
status: resp.status,
|
|
44
|
+
body: resp.body_parsed ?? resp.body,
|
|
45
|
+
headers: resp.headers ?? {},
|
|
46
|
+
};
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return {
|
|
49
|
+
kind: "network",
|
|
50
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* TASK-151: snapshot original state via GET-by-id counterpart so PUT/PATCH
|
|
57
|
+
* attacks can be undone. Returns null when there's no usable counterpart
|
|
58
|
+
* or the response isn't a JSON object.
|
|
59
|
+
*/
|
|
60
|
+
export async function snapshotOriginal(
|
|
61
|
+
ep: EndpointInfo,
|
|
62
|
+
allEndpoints: EndpointInfo[],
|
|
63
|
+
schemes: SecuritySchemeInfo[],
|
|
64
|
+
vars: Record<string, string>,
|
|
65
|
+
opts: ProbeStepOpts,
|
|
66
|
+
): Promise<Snapshot | null> {
|
|
67
|
+
const getEp = findGetByIdCounterpart(ep, allEndpoints);
|
|
68
|
+
if (!getEp) return null;
|
|
69
|
+
const { url, unresolved } = buildProbeUrl(getEp, vars);
|
|
70
|
+
if (unresolved.length > 0) return null;
|
|
71
|
+
const reqHeaders: Record<string, string> = {
|
|
72
|
+
accept: "application/json",
|
|
73
|
+
...liveAuthHeaders(getEp, schemes, vars),
|
|
74
|
+
};
|
|
75
|
+
let resp;
|
|
76
|
+
try {
|
|
77
|
+
resp = await executeRequest(
|
|
78
|
+
{ method: "GET", url, headers: reqHeaders },
|
|
79
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
80
|
+
);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (resp.status < 200 || resp.status >= 300) return null;
|
|
85
|
+
const body = resp.body_parsed ?? resp.body;
|
|
86
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
87
|
+
|
|
88
|
+
const respHeaders = resp.headers ?? {};
|
|
89
|
+
const etag =
|
|
90
|
+
respHeaders["etag"] ??
|
|
91
|
+
respHeaders["ETag"] ??
|
|
92
|
+
respHeaders["Etag"];
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
body: body as Record<string, unknown>,
|
|
96
|
+
etag: typeof etag === "string" ? etag : undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Restore the original state captured by `snapshotOriginal`. Sends a
|
|
102
|
+
* minimal PUT/PATCH containing only the fields the probe mutated —
|
|
103
|
+
* sending the full snapshot body trips `422 use partial PUT` on
|
|
104
|
+
* SaaS-shaped APIs (round-4 regression), so we replay each
|
|
105
|
+
* dirty field as its own single-key request.
|
|
106
|
+
*
|
|
107
|
+
* `verdict.cleanup.error` is **accumulated** across calls (not
|
|
108
|
+
* overwritten) so a single restore failure during the run is still
|
|
109
|
+
* visible in the digest.
|
|
110
|
+
*/
|
|
111
|
+
export async function restoreOriginal(
|
|
112
|
+
ep: EndpointInfo,
|
|
113
|
+
snapshot: Snapshot,
|
|
114
|
+
baseHeaders: Record<string, string>,
|
|
115
|
+
_schemes: SecuritySchemeInfo[],
|
|
116
|
+
vars: Record<string, string>,
|
|
117
|
+
opts: ProbeStepOpts,
|
|
118
|
+
verdict: SecurityVerdict,
|
|
119
|
+
dirtyFields: Iterable<string>,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const m = ep.method.toUpperCase();
|
|
122
|
+
const { url, unresolved } = buildProbeUrl(ep, vars);
|
|
123
|
+
if (unresolved.length > 0) return;
|
|
124
|
+
const headers: Record<string, string> = { ...baseHeaders };
|
|
125
|
+
if (snapshot.etag && ep.requiresEtag) {
|
|
126
|
+
headers["If-Match"] = snapshot.etag;
|
|
127
|
+
}
|
|
128
|
+
// Filter out fields the API will reject as read-only.
|
|
129
|
+
const READ_ONLY = new Set([
|
|
130
|
+
"id", "created_at", "createdAt", "updated_at", "updatedAt",
|
|
131
|
+
]);
|
|
132
|
+
const fields = Array.from(new Set(Array.from(dirtyFields))).filter(
|
|
133
|
+
f => !READ_ONLY.has(f) && f in snapshot.body,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Per-field PUT — works for both partial-PUT APIs and
|
|
137
|
+
// full-PUT APIs (the body just carries one of the legal keys).
|
|
138
|
+
const failures: string[] = [];
|
|
139
|
+
let lastSuccessStatus = 0;
|
|
140
|
+
let attempted = false;
|
|
141
|
+
for (const field of fields) {
|
|
142
|
+
attempted = true;
|
|
143
|
+
const body: Record<string, unknown> = { [field]: snapshot.body[field] };
|
|
144
|
+
let resp;
|
|
145
|
+
try {
|
|
146
|
+
resp = await executeRequest(
|
|
147
|
+
{ method: m, url, headers, body: serializeProbeBody(ep, body).content },
|
|
148
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
149
|
+
);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
failures.push(
|
|
152
|
+
`restore.${field} network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
153
|
+
);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
157
|
+
failures.push(`restore.${field} failed: ${resp.status}`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
lastSuccessStatus = resp.status;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Merge with any prior cleanup state on this verdict.
|
|
164
|
+
const prior = verdict.cleanup ?? { attempted: false };
|
|
165
|
+
const allErrors = [
|
|
166
|
+
...(prior.error ? [prior.error] : []),
|
|
167
|
+
...failures,
|
|
168
|
+
];
|
|
169
|
+
verdict.cleanup = {
|
|
170
|
+
attempted: attempted || prior.attempted,
|
|
171
|
+
...(lastSuccessStatus ? { status: lastSuccessStatus } : prior.status ? { status: prior.status } : {}),
|
|
172
|
+
...(allErrors.length > 0 ? { error: allErrors.join(" | ") } : {}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import type { EndpointInfo } from "../../generator/types.ts";
|
|
2
|
+
import { classify as classifyRecommendedAction } from "../../classifier/recommended-action.ts";
|
|
3
|
+
import type {
|
|
4
|
+
SecurityClass,
|
|
5
|
+
SecurityFieldHit,
|
|
6
|
+
SecurityFinding,
|
|
7
|
+
} from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** ARV-56: route through the single classifier. */
|
|
10
|
+
function stampAction(f: SecurityFinding): SecurityFinding {
|
|
11
|
+
const action = classifyRecommendedAction({
|
|
12
|
+
finding_class: "probe:security",
|
|
13
|
+
severity: f.severity as Parameters<typeof classifyRecommendedAction>[0]["severity"],
|
|
14
|
+
});
|
|
15
|
+
if (action) f.recommended_action = action;
|
|
16
|
+
return f;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ClassifyResp {
|
|
20
|
+
status: number;
|
|
21
|
+
body?: unknown;
|
|
22
|
+
body_parsed?: unknown;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function classify(
|
|
27
|
+
hit: SecurityFieldHit,
|
|
28
|
+
payload: string,
|
|
29
|
+
resp: ClassifyResp,
|
|
30
|
+
ctx: { endpoint?: EndpointInfo } = {},
|
|
31
|
+
): SecurityFinding {
|
|
32
|
+
return stampAction(classifyInner(hit, payload, resp, ctx));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ARV-254: detect whether an endpoint declares delivery semantics for
|
|
37
|
+
* a URL field — i.e. the server is documented to actually hit the URL
|
|
38
|
+
* (webhook receiver, push subscription, callback).
|
|
39
|
+
*
|
|
40
|
+
* Without OOB infrastructure (interactsh / Burp Collaborator —
|
|
41
|
+
* deferred to ARV-177 post-pivot), zond can't prove the server fetched
|
|
42
|
+
* the URL. So SSRF "accept" lands as LOW by default. But if the spec
|
|
43
|
+
* declares delivery, we know the URL gets fetched on some schedule,
|
|
44
|
+
* which raises the stakes — surface as MEDIUM with an explicit
|
|
45
|
+
* disclaimer that OOB verification is still required for HIGH.
|
|
46
|
+
*
|
|
47
|
+
* Heuristic: path or tag contains "webhook" / "callback" / "subscription"
|
|
48
|
+
* (case-insensitive). When ARV-189 lands, this also reads
|
|
49
|
+
* `x-zond-delivery: true` from the spec.
|
|
50
|
+
*/
|
|
51
|
+
function endpointDeclaresDelivery(ep: EndpointInfo | undefined): boolean {
|
|
52
|
+
if (!ep) return false;
|
|
53
|
+
const haystacks: string[] = [ep.path.toLowerCase()];
|
|
54
|
+
if (Array.isArray(ep.tags)) {
|
|
55
|
+
for (const t of ep.tags) haystacks.push(String(t).toLowerCase());
|
|
56
|
+
}
|
|
57
|
+
return haystacks.some((h) => /webhook|callback|subscription/.test(h));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check whether the CRLF payload reflects into any response header
|
|
62
|
+
* value. ARV-253: header reflection is the smoking gun for CRLF —
|
|
63
|
+
* response splitting / header injection becomes exploitable as soon as
|
|
64
|
+
* the server emits attacker-controlled bytes in headers.
|
|
65
|
+
*
|
|
66
|
+
* We check raw payload AND its URL-decoded form so encodings like
|
|
67
|
+
* `%0d%0a` survive the comparison.
|
|
68
|
+
*/
|
|
69
|
+
function reflectsInHeaders(payload: string, headers: Record<string, string> | undefined): string | null {
|
|
70
|
+
if (!headers || !payload) return null;
|
|
71
|
+
const decoded = safeDecodeURI(payload);
|
|
72
|
+
const variants = [payload, decoded].filter((v) => v && v.length >= 3);
|
|
73
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
74
|
+
for (const v of variants) {
|
|
75
|
+
if (value.includes(v)) return name;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isHtmlContentType(headers: Record<string, string> | undefined): boolean {
|
|
82
|
+
const ct = headers?.["content-type"] ?? headers?.["Content-Type"] ?? "";
|
|
83
|
+
return /text\/html|application\/xhtml/i.test(ct);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function classifyInner(
|
|
87
|
+
hit: SecurityFieldHit,
|
|
88
|
+
payload: string,
|
|
89
|
+
resp: ClassifyResp,
|
|
90
|
+
ctx: { endpoint?: EndpointInfo } = {},
|
|
91
|
+
): SecurityFinding {
|
|
92
|
+
const status = resp.status;
|
|
93
|
+
const echo = classifyEcho(resp.body_parsed ?? resp.body, payload, hit.class);
|
|
94
|
+
const echoed = echo.matched;
|
|
95
|
+
|
|
96
|
+
if (status >= 500) {
|
|
97
|
+
// ARV-250: 5xx on attack payload is a reliability signal, not a
|
|
98
|
+
// proven security issue. Single-signal proof (one crashed response)
|
|
99
|
+
// caps severity at LOW per the m-21 severity matrix. ARV-251
|
|
100
|
+
// relocates this signal to the reliability category; the existing
|
|
101
|
+
// `not_a_server_error` check already tracks 5xx on positive input,
|
|
102
|
+
// so the security probe here is a secondary signal at best.
|
|
103
|
+
return {
|
|
104
|
+
field: hit.field,
|
|
105
|
+
class: hit.class,
|
|
106
|
+
payload,
|
|
107
|
+
status,
|
|
108
|
+
echoed,
|
|
109
|
+
severity: "low",
|
|
110
|
+
reason: `5xx unhandled — server crashed on ${hit.class} payload (reliability signal; see also not_a_server_error check)`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (status >= 200 && status < 300) {
|
|
114
|
+
// ARV-253: CRLF severity now keyed on reflection context, not on
|
|
115
|
+
// raw echo. The pivot principle: HIGH requires evidence the stored
|
|
116
|
+
// payload reaches a dangerous rendering context (header value /
|
|
117
|
+
// unescaped HTML). Echo in a JSON body alone is single_signal —
|
|
118
|
+
// storage is real, exploit pathway is not. Caps at LOW.
|
|
119
|
+
if (hit.class === "crlf") {
|
|
120
|
+
const headerName = reflectsInHeaders(payload, resp.headers);
|
|
121
|
+
if (headerName) {
|
|
122
|
+
return {
|
|
123
|
+
field: hit.field,
|
|
124
|
+
class: hit.class,
|
|
125
|
+
payload,
|
|
126
|
+
status,
|
|
127
|
+
echoed: true,
|
|
128
|
+
severity: "high",
|
|
129
|
+
reason: `payload reflected in response header \`${headerName}\` — response-splitting / header-injection candidate (evidence_chain)`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (echoed && isHtmlContentType(resp.headers)) {
|
|
133
|
+
return {
|
|
134
|
+
field: hit.field,
|
|
135
|
+
class: hit.class,
|
|
136
|
+
payload,
|
|
137
|
+
status,
|
|
138
|
+
echoed,
|
|
139
|
+
severity: "high",
|
|
140
|
+
reason: `payload echoed (${echo.kind}) in text/html response — unescaped reflection candidate (evidence_chain)`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (echoed) {
|
|
144
|
+
return {
|
|
145
|
+
field: hit.field,
|
|
146
|
+
class: hit.class,
|
|
147
|
+
payload,
|
|
148
|
+
status,
|
|
149
|
+
echoed,
|
|
150
|
+
severity: "low",
|
|
151
|
+
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).`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
field: hit.field,
|
|
156
|
+
class: hit.class,
|
|
157
|
+
payload,
|
|
158
|
+
status,
|
|
159
|
+
echoed: false,
|
|
160
|
+
severity: "info",
|
|
161
|
+
reason: `${status} accepted ${hit.class} payload but no reflection observed — sanitization may be missing but no exploit pathway proven`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// ARV-254: SSRF / open-redirect severity rebalance.
|
|
165
|
+
//
|
|
166
|
+
// Without an out-of-band (OOB) channel zond can't prove the server
|
|
167
|
+
// actually fetched the injected URL. "API accepted 169.254" is
|
|
168
|
+
// single_signal proof — caps at LOW per the m-21 matrix.
|
|
169
|
+
//
|
|
170
|
+
// Stake-raising signal: when the spec declares delivery semantics
|
|
171
|
+
// (path/tag mentions webhook / callback / subscription), the server
|
|
172
|
+
// is documented to fetch the URL — surface MEDIUM. Full HIGH is
|
|
173
|
+
// gated on OOB confirmation which lands with ARV-177 (deferred-
|
|
174
|
+
// post-pivot, out of scope for now).
|
|
175
|
+
const declaresDelivery = endpointDeclaresDelivery(ctx.endpoint);
|
|
176
|
+
const oobDisclaimer = "no OOB channel — accept ≠ proven fetch. Verify with Burp Collaborator / interactsh manually for HIGH severity.";
|
|
177
|
+
if (echoed) {
|
|
178
|
+
const label = echo.kind === "verbatim"
|
|
179
|
+
? "payload echoed verbatim"
|
|
180
|
+
: `payload echoed (${echo.kind})`;
|
|
181
|
+
if (declaresDelivery) {
|
|
182
|
+
return {
|
|
183
|
+
field: hit.field,
|
|
184
|
+
class: hit.class,
|
|
185
|
+
payload,
|
|
186
|
+
status,
|
|
187
|
+
echoed,
|
|
188
|
+
severity: "low",
|
|
189
|
+
reason: `${label}; ${hit.class}: endpoint declares delivery (webhook/callback) but ${oobDisclaimer}`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
field: hit.field,
|
|
194
|
+
class: hit.class,
|
|
195
|
+
payload,
|
|
196
|
+
status,
|
|
197
|
+
echoed,
|
|
198
|
+
severity: "low",
|
|
199
|
+
reason: `${label} — stored ${hit.class} candidate; ${oobDisclaimer}`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (declaresDelivery) {
|
|
203
|
+
return {
|
|
204
|
+
field: hit.field,
|
|
205
|
+
class: hit.class,
|
|
206
|
+
payload,
|
|
207
|
+
status,
|
|
208
|
+
echoed,
|
|
209
|
+
severity: "medium",
|
|
210
|
+
reason: `2xx accepted ${hit.class} payload on endpoint declaring delivery semantics (webhook/callback). ${oobDisclaimer}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
field: hit.field,
|
|
215
|
+
class: hit.class,
|
|
216
|
+
payload,
|
|
217
|
+
status,
|
|
218
|
+
echoed,
|
|
219
|
+
severity: "low",
|
|
220
|
+
reason: `2xx accepted ${hit.class} payload but no echo observed. ${oobDisclaimer}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (status >= 400) {
|
|
224
|
+
return {
|
|
225
|
+
field: hit.field,
|
|
226
|
+
class: hit.class,
|
|
227
|
+
payload,
|
|
228
|
+
status,
|
|
229
|
+
echoed,
|
|
230
|
+
severity: "ok",
|
|
231
|
+
reason: `${status} rejected — ${hit.class} payload refused`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
field: hit.field,
|
|
236
|
+
class: hit.class,
|
|
237
|
+
payload,
|
|
238
|
+
status,
|
|
239
|
+
echoed,
|
|
240
|
+
severity: "inconclusive",
|
|
241
|
+
reason: `unexpected status ${status}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function bodyToString(body: unknown): string {
|
|
246
|
+
if (!body) return "";
|
|
247
|
+
if (typeof body === "string") return body;
|
|
248
|
+
// Walk object/array, concatenating raw string leaves so CR/LF chars aren't
|
|
249
|
+
// hidden behind JSON escape sequences (\r → "\\r" after JSON.stringify).
|
|
250
|
+
const parts: string[] = [];
|
|
251
|
+
const seen = new WeakSet<object>();
|
|
252
|
+
const visit = (v: unknown): void => {
|
|
253
|
+
if (typeof v === "string") parts.push(v);
|
|
254
|
+
else if (v && typeof v === "object") {
|
|
255
|
+
if (seen.has(v as object)) return;
|
|
256
|
+
seen.add(v as object);
|
|
257
|
+
if (Array.isArray(v)) v.forEach(visit);
|
|
258
|
+
else for (const k of Object.keys(v as object)) visit((v as Record<string, unknown>)[k]);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
try {
|
|
262
|
+
visit(body);
|
|
263
|
+
} catch {
|
|
264
|
+
return "";
|
|
265
|
+
}
|
|
266
|
+
return parts.join("\n");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function safeDecodeURI(s: string): string {
|
|
270
|
+
try {
|
|
271
|
+
return decodeURIComponent(s);
|
|
272
|
+
} catch {
|
|
273
|
+
return s;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
type EchoKind =
|
|
278
|
+
| "verbatim"
|
|
279
|
+
| "url-decoded"
|
|
280
|
+
| "CR stripped"
|
|
281
|
+
| "LF stripped"
|
|
282
|
+
| "CRLF→LF"
|
|
283
|
+
| "CRLF→CR"
|
|
284
|
+
| "tail after CRLF";
|
|
285
|
+
|
|
286
|
+
export interface EchoResult {
|
|
287
|
+
matched: boolean;
|
|
288
|
+
kind: EchoKind | "none";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function classifyEcho(body: unknown, payload: string, cls: SecurityClass): EchoResult {
|
|
292
|
+
if (!payload) return { matched: false, kind: "none" };
|
|
293
|
+
const haystackRaw = bodyToString(body);
|
|
294
|
+
if (!haystackRaw) return { matched: false, kind: "none" };
|
|
295
|
+
|
|
296
|
+
// SSRF / open-redirect: verbatim only — URLs are usually preserved as-is.
|
|
297
|
+
if (cls !== "crlf") {
|
|
298
|
+
return haystackRaw.includes(payload)
|
|
299
|
+
? { matched: true, kind: "verbatim" }
|
|
300
|
+
: { matched: false, kind: "none" };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// CRLF: try verbatim → URL-decode pairs → CR/LF normalization variants → tail.
|
|
304
|
+
if (haystackRaw.includes(payload)) return { matched: true, kind: "verbatim" };
|
|
305
|
+
|
|
306
|
+
const haystackDecoded = safeDecodeURI(haystackRaw);
|
|
307
|
+
const payloadDecoded = safeDecodeURI(payload);
|
|
308
|
+
|
|
309
|
+
if (
|
|
310
|
+
(payloadDecoded !== payload && haystackRaw.includes(payloadDecoded)) ||
|
|
311
|
+
(haystackDecoded !== haystackRaw && haystackDecoded.includes(payload)) ||
|
|
312
|
+
(payloadDecoded !== payload && haystackDecoded !== haystackRaw && haystackDecoded.includes(payloadDecoded))
|
|
313
|
+
) {
|
|
314
|
+
return { matched: true, kind: "url-decoded" };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Normalize: try variants of payload where backend stripped CR or LF.
|
|
318
|
+
const variants: Array<[string, EchoKind]> = [];
|
|
319
|
+
if (payloadDecoded.includes("\r\n")) {
|
|
320
|
+
variants.push([payloadDecoded.replace(/\r\n/g, "\n"), "CRLF→LF"]);
|
|
321
|
+
variants.push([payloadDecoded.replace(/\r\n/g, "\r"), "CRLF→CR"]);
|
|
322
|
+
variants.push([payloadDecoded.replace(/\r\n/g, ""), "CRLF→LF"]);
|
|
323
|
+
}
|
|
324
|
+
if (payloadDecoded.includes("\r")) variants.push([payloadDecoded.replace(/\r/g, ""), "CR stripped"]);
|
|
325
|
+
if (payloadDecoded.includes("\n")) variants.push([payloadDecoded.replace(/\n/g, ""), "LF stripped"]);
|
|
326
|
+
|
|
327
|
+
for (const [variant, kind] of variants) {
|
|
328
|
+
if (variant && variant !== payloadDecoded && (haystackRaw.includes(variant) || haystackDecoded.includes(variant))) {
|
|
329
|
+
return { matched: true, kind };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Tail-substring: parser truncated at newline, only suffix landed in storage.
|
|
334
|
+
const splitMatch = payloadDecoded.match(/(?:\r\n|%0d%0a|%0a|%0d|\r|\n)(.+)$/i);
|
|
335
|
+
const tail = splitMatch?.[1];
|
|
336
|
+
if (tail && tail.length >= 3 && (haystackRaw.includes(tail) || haystackDecoded.includes(tail))) {
|
|
337
|
+
return { matched: true, kind: "tail after CRLF" };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return { matched: false, kind: "none" };
|
|
341
|
+
}
|