@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.
Files changed (158) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +53 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. 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
- tests.push({
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
- } as unknown as RawStep);
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") step.json = privilegedBody;
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 baseUrl = (opts.vars["base_url"] ?? "").replace(/\/+$/, "");
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 baseUrl = (opts.vars["base_url"] ?? "").replace(/\/+$/, "");
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 baseUrl = (vars["base_url"] ?? "").replace(/\/+$/, "");
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: JSON content-type/accept plus the resolved
74
- * auth header for the endpoint. Empty `liveAuthHeaders` is fine — the
75
- * spread is a no-op for unauthenticated endpoints.
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 = ep.requestBodySchema ? generateFromSchema(ep.requestBodySchema) : {};
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
+ }