@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
@@ -0,0 +1,175 @@
1
+ import { classify as classifyRecommendedAction } from "../../classifier/recommended-action.ts";
2
+ import type { EndpointInfo } from "../../generator/types.ts";
3
+ import type { EndpointVerdict } from "./types.ts";
4
+
5
+ /** Lower-cased anchored fragments for the SaaS-flavoured 403 wordings we
6
+ * encounter in the wild (paid plan / role-scope / feature-flag gates).
7
+ * Each entry is one independent signal โ€” a body matching any one of them
8
+ * is treated as subscription-gated. Formerly the anti-FP registry rule
9
+ * `subscription-gated/paid-plan-403` (ARV-125); now inlined as a plain
10
+ * evidence signal on the baseline summary โ€” NOT a suppressor. */
11
+ const SUBSCRIPTION_GATED_PATTERNS: RegExp[] = [
12
+ /\bpaid plan\b/i,
13
+ /\bsubscription (?:required|needed)\b/i,
14
+ /\bnot (?:available|enabled) (?:on|for) your\b/i,
15
+ /\bplan (?:does not include|doesn['']?t include)\b/i,
16
+ /\brequires? (?:the )?[\w:-]+ scope\b/i,
17
+ /\bmissing (?:the )?[\w:-]+ scope\b/i,
18
+ /\bfeature (?:is )?(?:not enabled|disabled|not available)\b/i,
19
+ /\binsufficient (?:permissions?|scope)\b/i,
20
+ ];
21
+
22
+ /** Reason text surfaced on the baseline summary when a 403 body names a
23
+ * subscription/scope gate โ€” signals to the triage agent that fixture
24
+ * edits won't help. */
25
+ const SUBSCRIPTION_GATED_REASON =
26
+ "endpoint is env/subscription-gated (paid plan, role/scope, feature flag); " +
27
+ "not a fixture issue โ€” wontfix unless scope changes";
28
+
29
+ function matchesSubscriptionGated(message: string): boolean {
30
+ for (const re of SUBSCRIPTION_GATED_PATTERNS) {
31
+ if (re.test(message)) return true;
32
+ }
33
+ return false;
34
+ }
35
+
36
+ /** ARV-56: route through the single classifier instead of carrying the
37
+ * severityโ†’action switch inline. */
38
+ export function stampRecommendedAction(verdict: EndpointVerdict): void {
39
+ const action = classifyRecommendedAction({
40
+ finding_class: "probe:mass_assignment",
41
+ severity: verdict.severity as Parameters<typeof classifyRecommendedAction>[0]["severity"],
42
+ });
43
+ if (action) verdict.recommended_action = action;
44
+ }
45
+
46
+ /**
47
+ * Build a one-line summary for INCONCLUSIVE-baseline verdicts. We surface
48
+ * the server's error code/name when present so the user can immediately
49
+ * see *which* FK / scope / fixture failed and fix it before re-probing.
50
+ */
51
+ export function inconclusiveBaselineSummary(
52
+ status: number,
53
+ body: unknown,
54
+ bodyFkMisses?: Array<{ field: string; reason: string }>,
55
+ ): string {
56
+ const hint = extractBaselineHint(body);
57
+ const base = `baseline body invalid โ€” server returned ${status}`;
58
+ // ARV-104 (F9): when status is 403 and the response body names a
59
+ // subscription/scope gate (paid plan, feature flag, role/scope
60
+ // insufficient), the right answer isn't "fix fixture" โ€” there's
61
+ // nothing to fix. Surface a wontfix reason on the summary as raw
62
+ // evidence for the triage agent (this is a hint, NOT a suppressor).
63
+ const gated = status === 403 && hint !== undefined && matchesSubscriptionGated(hint);
64
+ const tail = gated
65
+ ? ` โ€” ${SUBSCRIPTION_GATED_REASON}`
66
+ : " โ€” fix fixture / FK value / path-params and re-probe";
67
+ // TASK-137: if body-FK auto-discovery couldn't fill required FK fields, name
68
+ // them in the summary so the user knows exactly what to add to env (or
69
+ // why discover-fk missed โ€” e.g. nested list endpoint, 403 from scope).
70
+ let fkClause = "";
71
+ if (bodyFkMisses && bodyFkMisses.length > 0) {
72
+ const names = bodyFkMisses.map(m => m.field).join(", ");
73
+ fkClause = ` โ€” unresolved body FKs: ${names}`;
74
+ }
75
+ return hint
76
+ ? `${base} (${hint})${fkClause}${tail}`
77
+ : `${base}${fkClause}${tail}`;
78
+ }
79
+
80
+ /** ARV-104 (F9): quick yes/no on whether a 403 body names a
81
+ * subscription/scope gate. Public predicate for callers (and tests)
82
+ * that want the signal without composing a summary string. */
83
+ export const isSubscriptionGated = matchesSubscriptionGated;
84
+
85
+ function extractBaselineHint(body: unknown): string | undefined {
86
+ if (typeof body === "string") {
87
+ const trimmed = body.trim();
88
+ if (trimmed.length === 0) return undefined;
89
+ return trimmed.length > 120 ? `${trimmed.slice(0, 120)}โ€ฆ` : trimmed;
90
+ }
91
+ if (typeof body !== "object" || body === null) return undefined;
92
+ const obj = body as Record<string, unknown>;
93
+ // Common error-envelope fields across SaaS APIs.
94
+ const candidates = [
95
+ obj.message,
96
+ obj.error,
97
+ (obj.error as Record<string, unknown> | undefined)?.message,
98
+ obj.detail,
99
+ obj.title,
100
+ obj.name,
101
+ obj.code,
102
+ ];
103
+ for (const c of candidates) {
104
+ if (typeof c === "string" && c.length > 0) {
105
+ return c.length > 120 ? `${c.slice(0, 120)}โ€ฆ` : c;
106
+ }
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ export function needsFollowUp(verdict: EndpointVerdict): boolean {
112
+ return verdict.fields.some(f => f.outcome === "absent" || f.outcome === "unknown");
113
+ }
114
+
115
+ export function classifyFromBody(
116
+ verdict: EndpointVerdict,
117
+ body: Record<string, unknown> | undefined,
118
+ fromGet = false,
119
+ ): void {
120
+ if (!body) return;
121
+ for (const field of verdict.fields) {
122
+ // Once a field is decisively classified (applied/echoed-overwritten),
123
+ // don't downgrade. But "absent" on POST may still flip to applied/ignored
124
+ // after GET โ€” so only re-check those.
125
+ if (field.outcome === "applied" || field.outcome === "echoed-overwritten") continue;
126
+ if (!(field.field in body)) {
127
+ // GET also missing โ†’ ignored. POST missing โ†’ keep "absent" so we GET later.
128
+ field.outcome = fromGet ? "ignored" : "absent";
129
+ continue;
130
+ }
131
+ const observed = body[field.field];
132
+ field.observed = observed;
133
+ if (Bun.deepEquals(observed, field.injected)) {
134
+ field.outcome = "applied";
135
+ } else if (fromGet) {
136
+ field.outcome = "ignored";
137
+ } else {
138
+ field.outcome = "echoed-overwritten";
139
+ }
140
+ }
141
+ }
142
+
143
+ export function findIdParam(ep: EndpointInfo): string {
144
+ const m = ep.path.match(/\{([^}]+)\}/);
145
+ return m ? m[1]! : "id";
146
+ }
147
+
148
+ export function finaliseSeverity(v: EndpointVerdict, strict: boolean): void {
149
+ const applied = v.fields.filter(f => f.outcome === "applied");
150
+ const absent = v.fields.filter(f => f.outcome === "absent");
151
+
152
+ if (applied.length > 0) {
153
+ v.severity = "high";
154
+ v.summary = `accepted-and-applied: ${applied.map(f => f.field).join(", ")}`;
155
+ return;
156
+ }
157
+ if (absent.length > 0) {
158
+ // ARV-252: absent-but-unverifiable carries single_signal proof.
159
+ // Surfaced as INFO and only shown under --verbose so the report
160
+ // stays clean; the verdict still travels through the JSON envelope
161
+ // for agents that want to triage it explicitly.
162
+ v.severity = "info";
163
+ v.summary = `inconclusive โ€” could not verify via follow-up GET (${absent.map(f => f.field).join(", ")})`;
164
+ return;
165
+ }
166
+ // ARV-252: silently-ignored = correct framework behaviour (Rails
167
+ // strong params / FastAPI extra=ignore). Severity stays INFO so it
168
+ // never gates CI, AND the CLI display layer suppresses it entirely
169
+ // (even under --verbose). Reports must not be noise-floored by
170
+ // correct behaviour. Verdicts still travel through the JSON envelope
171
+ // for agents that explicitly want to inspect them.
172
+ v.severity = "info";
173
+ const status = v.response?.status ?? 0;
174
+ v.summary = `accepted ${status} but extras silently ignored${strict ? " (despite additionalProperties:false โ€” server should reject)" : ""}`;
175
+ }
@@ -0,0 +1,52 @@
1
+ import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
2
+ import { executeRequest } from "../../runner/http-client.ts";
3
+ import {
4
+ captureFieldFor,
5
+ findDeleteCounterpart,
6
+ liveAuthHeaders,
7
+ } from "../shared.ts";
8
+ import { buildProbeUrl } from "../probe-harness.ts";
9
+ import { findIdParam } from "./classify.ts";
10
+
11
+ /**
12
+ * Best-effort DELETE on the baseline (no-extras) probe so the injected
13
+ * POST doesn't trip a unique-constraint and we don't leak resources.
14
+ */
15
+ export async function tryCleanupBaseline(
16
+ ep: EndpointInfo,
17
+ allEndpoints: EndpointInfo[],
18
+ schemes: SecuritySchemeInfo[],
19
+ vars: Record<string, string>,
20
+ baselineBody: unknown,
21
+ opts: { timeoutMs?: number },
22
+ ): Promise<void> {
23
+ const body =
24
+ typeof baselineBody === "object" && baselineBody !== null
25
+ ? (baselineBody as Record<string, unknown>)
26
+ : undefined;
27
+ if (!body) return;
28
+ const idField = captureFieldFor(ep);
29
+ const id = body[idField];
30
+ if (id === undefined) return;
31
+ const delEp = findDeleteCounterpart(ep, allEndpoints);
32
+ if (!delEp) return;
33
+ const delVars = { ...vars, [findIdParam(delEp)]: String(id), id: String(id) };
34
+ const delUrl = buildProbeUrl(delEp, delVars);
35
+ if (delUrl.unresolved.length > 0) return;
36
+ try {
37
+ await executeRequest(
38
+ {
39
+ method: "DELETE",
40
+ url: delUrl.url,
41
+ headers: {
42
+ accept: "application/json",
43
+ ...liveAuthHeaders(delEp, schemes, vars),
44
+ },
45
+ },
46
+ { timeout: opts.timeoutMs ?? 30000, retries: 0 },
47
+ );
48
+ } catch {
49
+ // best-effort โ€” if cleanup fails we'll leak a baseline resource, but
50
+ // that's a deployment problem, not a probe bug.
51
+ }
52
+ }
@@ -0,0 +1,114 @@
1
+ import { SUSPECTED_FIELDS } from "./suspects.ts";
2
+ import type {
3
+ EndpointVerdict,
4
+ MassAssignmentResult,
5
+ Severity,
6
+ } from "./types.ts";
7
+
8
+ const SEVERITY_ORDER: Severity[] = [
9
+ "high",
10
+ "inconclusive-baseline",
11
+ "inconclusive-5xx",
12
+ "medium",
13
+ "low",
14
+ "info",
15
+ "ok",
16
+ "skipped",
17
+ ];
18
+
19
+ const SEVERITY_HEADER: Record<Severity, string> = {
20
+ high: "๐Ÿšจ HIGH โ€” privilege escalation candidates",
21
+ "inconclusive-baseline": "โš ๏ธ INCONCLUSIVE โ€” baseline body invalid (fix fixture / FK / scope and re-probe)",
22
+ "inconclusive-5xx": "โš ๏ธ INCONCLUSIVE โ€” baseline 5xx (endpoint crashes โ€” likely duplicate of validation-probe)",
23
+ medium: "โš ๏ธ MEDIUM โ€” inconclusive (no follow-up GET available)",
24
+ low: "โ„น๏ธ LOW โ€” inconclusive (single-signal, follow-up GET unavailable)",
25
+ info: "ยท INFO โ€” accepted-and-ignored (correct framework behaviour, often ineligible to report)",
26
+ ok: "โœ… OK โ€” rejected 4xx (best behaviour)",
27
+ skipped: "โญ๏ธ SKIPPED",
28
+ };
29
+
30
+ export function formatDigestMarkdown(
31
+ result: MassAssignmentResult,
32
+ specPath: string,
33
+ ): string {
34
+ const lines: string[] = [];
35
+ lines.push(`# Mass-assignment probe digest`);
36
+ lines.push("");
37
+ lines.push(`**Spec:** \`${specPath}\``);
38
+ lines.push(`**Endpoints probed:** ${result.specProbed} of ${result.totalEndpoints} mutating endpoints`);
39
+ lines.push("");
40
+ lines.push(`**Suspected fields tested:** ${Object.keys(SUSPECTED_FIELDS).join(", ")}`);
41
+ lines.push("");
42
+
43
+ const buckets = groupBySeverity(result.verdicts);
44
+ for (const sev of SEVERITY_ORDER) {
45
+ const items = buckets[sev];
46
+ if (!items || items.length === 0) continue;
47
+ lines.push(`## ${SEVERITY_HEADER[sev]} (${items.length})`);
48
+ lines.push("");
49
+ for (const v of items) {
50
+ lines.push(`### ${v.method} ${v.path}`);
51
+ lines.push("");
52
+ if (v.severity === "skipped") {
53
+ lines.push(`- Skipped: ${v.skipReason ?? v.summary}`);
54
+ lines.push("");
55
+ continue;
56
+ }
57
+ lines.push(`- ${v.summary}`);
58
+ lines.push(`- Injected: ${v.request.injectedFields.map(n => `\`${n}\``).join(", ")}`);
59
+ if (v.baseline) {
60
+ lines.push(`- Baseline (no extras): ${v.baseline.status}`);
61
+ }
62
+ if (v.response) {
63
+ lines.push(`- With extras: ${v.response.status}`);
64
+ }
65
+ if (v.followUpGet) {
66
+ lines.push(`- Follow-up GET โ†’ ${v.followUpGet.status}`);
67
+ }
68
+ const interesting = v.fields.filter(f => f.outcome !== "ignored" && f.outcome !== "absent");
69
+ if (interesting.length > 0) {
70
+ lines.push(`- Per-field outcomes:`);
71
+ for (const f of interesting) {
72
+ const obs = f.observed === undefined ? "n/a" : JSON.stringify(f.observed);
73
+ lines.push(` - \`${f.field}\` โ†’ **${f.outcome}** (injected ${JSON.stringify(f.injected)}, observed ${obs})`);
74
+ }
75
+ }
76
+ if (v.cleanup) {
77
+ if (v.cleanup.attempted) {
78
+ lines.push(`- Cleanup DELETE: ${v.cleanup.status ?? "errored"}${v.cleanup.error ? ` โ€” ${v.cleanup.error}` : ""}`);
79
+ } else {
80
+ lines.push(`- Cleanup skipped: ${v.cleanup.error ?? "unknown"}`);
81
+ }
82
+ }
83
+ if (v.notes && v.notes.length > 0) {
84
+ for (const n of v.notes) lines.push(`- Note: ${n}`);
85
+ }
86
+ if (v.severity === "high") {
87
+ lines.push(`- **Action:** treat as P0 โ€” server should reject or strip these fields.`);
88
+ }
89
+ if (v.severity === "inconclusive-baseline") {
90
+ lines.push(
91
+ `- **Action:** the baseline POST itself failed โ€” set the right fixture / FK / path-params in your env (e.g. \`domain_id\`, \`account_id\`) and re-run.`,
92
+ );
93
+ }
94
+ if (v.severity === "inconclusive-5xx") {
95
+ lines.push(
96
+ `- **Action:** baseline crashed with 5xx โ€” fix the underlying server bug (validation-probe likely reported it for the same endpoint) before mass-assignment can be observed here.`,
97
+ );
98
+ }
99
+ lines.push("");
100
+ }
101
+ }
102
+
103
+ if (result.warnings.length > 0) {
104
+ lines.push(`## Warnings`);
105
+ lines.push("");
106
+ for (const w of result.warnings) lines.push(`- ${w}`);
107
+ lines.push("");
108
+ }
109
+ return lines.join("\n");
110
+ }
111
+
112
+ function groupBySeverity(verdicts: EndpointVerdict[]): Partial<Record<Severity, EndpointVerdict[]>> {
113
+ return Object.groupBy(verdicts, (v) => v.severity);
114
+ }