@kirrosh/zond 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +758 -3
- package/README.md +78 -15
- package/package.json +17 -10
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +55 -6
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +192 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +180 -8
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -47
- package/src/cli/commands/init/agents-md.ts +61 -0
- package/src/cli/commands/init/bootstrap.ts +108 -0
- package/src/cli/commands/init/index.ts +244 -0
- package/src/cli/commands/init/skills.ts +98 -0
- package/src/cli/commands/init/templates/agents.md +77 -0
- package/src/cli/commands/init/templates/markdown.d.ts +4 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +651 -0
- package/src/cli/commands/init/templates/zond-config.yml +14 -0
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +911 -33
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +74 -0
- package/src/cli/index.ts +36 -607
- package/src/cli/json-envelope.ts +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +218 -0
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +8 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +61 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +51 -0
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +161 -12
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +53 -15
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +490 -33
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +55 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +114 -15
- package/src/core/generator/suite-generator.ts +484 -77
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +132 -5
- package/src/core/parser/types.ts +29 -2
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +108 -13
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -0
- package/src/core/probe/method-probe.ts +164 -0
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +691 -0
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +284 -0
- package/src/core/reporter/console.ts +69 -4
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +15 -2
- package/src/core/reporter/junit.ts +27 -12
- package/src/core/reporter/ndjson.ts +37 -0
- package/src/core/reporter/types.ts +3 -0
- package/src/core/runner/assertions.ts +62 -2
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +391 -52
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +115 -7
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +203 -0
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +419 -17
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +94 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +128 -0
- package/src/db/queries/runs.ts +235 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +72 -802
- package/src/db/schema.ts +179 -48
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init.ts +0 -57
- package/src/cli/commands/serve.ts +0 -81
- package/src/cli/commands/sync.ts +0 -269
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -21
- package/src/core/parser/index.ts +0 -21
- package/src/core/runner/execute-run.ts +0 -132
- package/src/core/runner/index.ts +0 -12
- package/src/core/sync/spec-differ.ts +0 -38
- package/src/web/data/collection-state.ts +0 -362
- package/src/web/routes/api.ts +0 -314
- package/src/web/routes/dashboard.ts +0 -350
- package/src/web/routes/runs.ts +0 -64
- package/src/web/schemas.ts +0 -121
- package/src/web/server.ts +0 -134
- package/src/web/static/htmx.min.cjs +0 -1
- package/src/web/static/style.css +0 -1148
- package/src/web/views/endpoints-tab.ts +0 -174
- package/src/web/views/explorer-tab.ts +0 -402
- package/src/web/views/health-strip.ts +0 -92
- package/src/web/views/layout.ts +0 -48
- package/src/web/views/results.ts +0 -210
- package/src/web/views/runs-tab.ts +0 -126
- package/src/web/views/suites-tab.ts +0 -181
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mass-assignment probe (T58).
|
|
3
|
+
*
|
|
4
|
+
* For each POST endpoint we craft a JSON body augmented with "suspected" extra
|
|
5
|
+
* fields (is_admin, role, account_id, …) plus server-assigned fields lifted
|
|
6
|
+
* from the response schema (id, created_at, …). We send the request live,
|
|
7
|
+
* read the response, and — when the API returned 2xx — issue a follow-up GET
|
|
8
|
+
* to differentiate two outcomes:
|
|
9
|
+
*
|
|
10
|
+
* • accepted-and-applied — the suspicious value persisted ⇒ privilege
|
|
11
|
+
* escalation candidate (HIGH severity).
|
|
12
|
+
* • accepted-and-ignored — the suspicious value was silently dropped
|
|
13
|
+
* (LOW severity, soft-warn).
|
|
14
|
+
*
|
|
15
|
+
* Rejected (4xx) is the desired behaviour. 5xx is a separate bug class
|
|
16
|
+
* (negative-probe territory).
|
|
17
|
+
*
|
|
18
|
+
* Auth is loaded from a `.env.yaml`-style file — same surface as `zond run`
|
|
19
|
+
* uses via `loadEnvironment`. `base_url`, `auth_token`, `api_key` and any
|
|
20
|
+
* path-param placeholders supplied in env are substituted into URLs.
|
|
21
|
+
*
|
|
22
|
+
* Optionally emits a YAML regression suite (`--emit-tests`) that locks in
|
|
23
|
+
* the observed safe behaviour (rejected / ignored) so CI catches drift.
|
|
24
|
+
*/
|
|
25
|
+
import type { OpenAPIV3 } from "openapi-types";
|
|
26
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
|
|
27
|
+
import type { RecommendedAction } from "../diagnostics/failure-hints.ts";
|
|
28
|
+
import { classify } from "../classifier/recommended-action.ts";
|
|
29
|
+
import { applyAntiFp } from "../anti-fp/index.ts";
|
|
30
|
+
import { matchesSubscriptionGated as matchesPaidPlan403 } from "../anti-fp/rules/subscription-gated/paid-plan-403.ts";
|
|
31
|
+
import type { RawSuite, RawStep } from "../generator/serializer.ts";
|
|
32
|
+
import { executeRequest } from "../runner/http-client.ts";
|
|
33
|
+
import { flattenToFormFields } from "../runner/form-encode.ts";
|
|
34
|
+
import type { HttpRequest } from "../runner/types.ts";
|
|
35
|
+
import {
|
|
36
|
+
convertPath,
|
|
37
|
+
endpointStem,
|
|
38
|
+
findDeleteCounterpart,
|
|
39
|
+
findGetByIdCounterpart,
|
|
40
|
+
captureFieldFor,
|
|
41
|
+
liveAuthHeaders,
|
|
42
|
+
getAuthHeaders,
|
|
43
|
+
classifyPostSemantics,
|
|
44
|
+
} from "./shared.ts";
|
|
45
|
+
import {
|
|
46
|
+
buildProbeUrl,
|
|
47
|
+
buildBodyAuthHeaders,
|
|
48
|
+
buildBaselineFromSpec,
|
|
49
|
+
hasProbeBody,
|
|
50
|
+
serializeProbeBody,
|
|
51
|
+
} from "./probe-harness.ts";
|
|
52
|
+
import {
|
|
53
|
+
createDiscoveryCache,
|
|
54
|
+
discoverPathParams,
|
|
55
|
+
discoverBodyFkVars,
|
|
56
|
+
type DiscoveryCache,
|
|
57
|
+
} from "./path-discovery.ts";
|
|
58
|
+
|
|
59
|
+
// ──────────────────────────────────────────────
|
|
60
|
+
// Suspected fields (the "classic" mass-assignment vectors)
|
|
61
|
+
// ──────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Sentinel values are deliberately distinctive so that — if they appear in a
|
|
65
|
+
* follow-up GET response — we can be confident the server actually persisted
|
|
66
|
+
* them rather than coincidentally generating the same value.
|
|
67
|
+
*/
|
|
68
|
+
export const SUSPECTED_FIELDS: Record<string, unknown> = {
|
|
69
|
+
is_admin: true,
|
|
70
|
+
is_system: true,
|
|
71
|
+
verified: true,
|
|
72
|
+
role: "admin",
|
|
73
|
+
account_id: "00000000-0000-0000-0000-00000000beef",
|
|
74
|
+
owner_id: "00000000-0000-0000-0000-00000000beef",
|
|
75
|
+
user_id: "00000000-0000-0000-0000-00000000beef",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Sentinel values for server-assigned fields lifted from response schema. */
|
|
79
|
+
const SERVER_FIELD_SENTINEL = {
|
|
80
|
+
uuid: "00000000-0000-0000-0000-00000000dead",
|
|
81
|
+
isoDate: "2000-01-01T00:00:00.000Z",
|
|
82
|
+
string: "zond-injected",
|
|
83
|
+
integer: -424242,
|
|
84
|
+
number: -424242,
|
|
85
|
+
boolean: false,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ──────────────────────────────────────────────
|
|
89
|
+
// Types
|
|
90
|
+
// ──────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Mass-assignment local severity. Includes the unified severity ladder
|
|
94
|
+
* (critical/high/medium/low/info — see core/severity) plus two
|
|
95
|
+
* outcome-style states specific to this probe (inconclusive-baseline,
|
|
96
|
+
* inconclusive-5xx) and probe-lifecycle markers (ok, skipped).
|
|
97
|
+
*
|
|
98
|
+
* 'medium' is retained in the type for backwards compat but ARV-250
|
|
99
|
+
* stopped emitting it — single-signal proof on absent-fields now caps
|
|
100
|
+
* to 'low' per the m-21 severity matrix.
|
|
101
|
+
*/
|
|
102
|
+
export type Severity =
|
|
103
|
+
| "high"
|
|
104
|
+
| "medium"
|
|
105
|
+
/** Baseline POST itself failed — we never reached extras-validation, so the
|
|
106
|
+
* 4xx-with-extras was a false signal. User must fix fixture / FK / scope
|
|
107
|
+
* before this endpoint can be probed (TASK-91). */
|
|
108
|
+
| "inconclusive-baseline"
|
|
109
|
+
/** Baseline POST returned ≥500 — the endpoint just crashes, mass-assignment
|
|
110
|
+
* semantics aren't observable here. Likely a duplicate of validation-probe's
|
|
111
|
+
* finding for the same endpoint (TASK-276). */
|
|
112
|
+
| "inconclusive-5xx"
|
|
113
|
+
| "low"
|
|
114
|
+
| "info"
|
|
115
|
+
| "ok"
|
|
116
|
+
| "skipped";
|
|
117
|
+
|
|
118
|
+
export interface FieldVerdict {
|
|
119
|
+
field: string;
|
|
120
|
+
injected: unknown;
|
|
121
|
+
/** "applied" | "ignored" | "echoed-but-overwritten" | "absent" | "unknown" */
|
|
122
|
+
outcome: "applied" | "ignored" | "echoed-overwritten" | "absent" | "unknown";
|
|
123
|
+
/** Value as seen in the response body (or follow-up GET if applicable). */
|
|
124
|
+
observed?: unknown;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface EndpointVerdict {
|
|
128
|
+
method: string;
|
|
129
|
+
path: string;
|
|
130
|
+
severity: Severity;
|
|
131
|
+
/** Canonical short reason (used in markdown header). */
|
|
132
|
+
summary: string;
|
|
133
|
+
request: {
|
|
134
|
+
url: string;
|
|
135
|
+
body: unknown;
|
|
136
|
+
injectedFields: string[];
|
|
137
|
+
};
|
|
138
|
+
response?: {
|
|
139
|
+
status: number;
|
|
140
|
+
body?: unknown;
|
|
141
|
+
};
|
|
142
|
+
followUpGet?: {
|
|
143
|
+
url: string;
|
|
144
|
+
status: number;
|
|
145
|
+
body?: unknown;
|
|
146
|
+
};
|
|
147
|
+
/** Result of the baseline (no-extras) probe — present whenever we sent it
|
|
148
|
+
* (always, except for skipped endpoints). Used to disambiguate
|
|
149
|
+
* «extras refused» from «baseline body invalid» (TASK-91). */
|
|
150
|
+
baseline?: {
|
|
151
|
+
status: number;
|
|
152
|
+
body?: unknown;
|
|
153
|
+
};
|
|
154
|
+
fields: FieldVerdict[];
|
|
155
|
+
/** True when request schema has additionalProperties:false (strict). */
|
|
156
|
+
strictContract: boolean;
|
|
157
|
+
cleanup?: {
|
|
158
|
+
attempted: boolean;
|
|
159
|
+
status?: number;
|
|
160
|
+
error?: string;
|
|
161
|
+
};
|
|
162
|
+
/** Reason this endpoint was skipped (only set when severity === "skipped"). */
|
|
163
|
+
skipReason?: string;
|
|
164
|
+
notes?: string[];
|
|
165
|
+
/** TASK-294: agent-routable action.
|
|
166
|
+
* high/medium → `report_backend_bug` (privilege escalation).
|
|
167
|
+
* inconclusive-baseline → `fix_fixture` (broken request body, retry).
|
|
168
|
+
* inconclusive-5xx → `report_backend_bug` (server crashed).
|
|
169
|
+
* low/ok/skipped → undefined. */
|
|
170
|
+
recommended_action?: RecommendedAction;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface MassAssignmentOptions {
|
|
174
|
+
endpoints: EndpointInfo[];
|
|
175
|
+
securitySchemes: SecuritySchemeInfo[];
|
|
176
|
+
/** Substituted variables (base_url, auth_token, api_key, path params). */
|
|
177
|
+
vars: Record<string, string>;
|
|
178
|
+
/** When true, do not issue cleanup-DELETE after 2xx responses. */
|
|
179
|
+
noCleanup?: boolean;
|
|
180
|
+
/** Per-request fetch timeout (ms). */
|
|
181
|
+
timeoutMs?: number;
|
|
182
|
+
/** When false, skip auto-discovery of path-param fixtures via GET-on-list (TASK-92).
|
|
183
|
+
* TASK-137: this flag now also controls body-FK discovery (required body
|
|
184
|
+
* fields named `*_id` / `*_slug` / `*_uuid` get filled from the matching
|
|
185
|
+
* collection list endpoint, eliminating most INCONCLUSIVE-baseline noise). */
|
|
186
|
+
discover?: boolean;
|
|
187
|
+
/** ARV-252: per-run extension to SUSPECTED_FIELDS (curated list of
|
|
188
|
+
* classic mass-assignment vectors). CLI surfaces this as repeatable
|
|
189
|
+
* `--suspect-field name=value`. Full per-api spec-extension support
|
|
190
|
+
* (x-zond-suspect-fields) is tracked in ARV-189. */
|
|
191
|
+
extraSuspectFields?: Record<string, unknown>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface MassAssignmentResult {
|
|
195
|
+
specProbed: number;
|
|
196
|
+
totalEndpoints: number;
|
|
197
|
+
verdicts: EndpointVerdict[];
|
|
198
|
+
warnings: string[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ──────────────────────────────────────────────
|
|
202
|
+
// Schema helpers
|
|
203
|
+
// ──────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function deepClone<T>(v: T): T {
|
|
206
|
+
return JSON.parse(JSON.stringify(v));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function requestPropertyNames(schema?: OpenAPIV3.SchemaObject): Set<string> {
|
|
210
|
+
const out = new Set<string>();
|
|
211
|
+
if (!schema) return out;
|
|
212
|
+
if (schema.properties) {
|
|
213
|
+
for (const k of Object.keys(schema.properties)) out.add(k);
|
|
214
|
+
}
|
|
215
|
+
for (const composite of [schema.allOf, schema.oneOf, schema.anyOf]) {
|
|
216
|
+
if (Array.isArray(composite)) {
|
|
217
|
+
for (const sub of composite) {
|
|
218
|
+
const s = sub as OpenAPIV3.SchemaObject;
|
|
219
|
+
if (s.properties) for (const k of Object.keys(s.properties)) out.add(k);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isStrictContract(schema?: OpenAPIV3.SchemaObject): boolean {
|
|
227
|
+
if (!schema) return false;
|
|
228
|
+
return schema.additionalProperties === false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function pickServerFieldSentinel(s: OpenAPIV3.SchemaObject): unknown {
|
|
232
|
+
if (s.format === "uuid") return SERVER_FIELD_SENTINEL.uuid;
|
|
233
|
+
if (s.format === "date-time" || s.format === "date") return SERVER_FIELD_SENTINEL.isoDate;
|
|
234
|
+
switch (s.type) {
|
|
235
|
+
case "string": return SERVER_FIELD_SENTINEL.string;
|
|
236
|
+
case "integer": return SERVER_FIELD_SENTINEL.integer;
|
|
237
|
+
case "number": return SERVER_FIELD_SENTINEL.number;
|
|
238
|
+
case "boolean": return SERVER_FIELD_SENTINEL.boolean;
|
|
239
|
+
default: return SERVER_FIELD_SENTINEL.string;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Server-assigned fields = response 2xx schema props that don't appear in request schema. */
|
|
244
|
+
function serverAssignedExtras(ep: EndpointInfo): Record<string, unknown> {
|
|
245
|
+
const reqProps = requestPropertyNames(ep.requestBodySchema);
|
|
246
|
+
const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300 && r.schema);
|
|
247
|
+
const respProps = success?.schema?.properties;
|
|
248
|
+
const out: Record<string, unknown> = {};
|
|
249
|
+
if (!respProps) return out;
|
|
250
|
+
for (const [name, schema] of Object.entries(respProps)) {
|
|
251
|
+
if (reqProps.has(name)) continue;
|
|
252
|
+
out[name] = pickServerFieldSentinel(schema as OpenAPIV3.SchemaObject);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Extra fields that aren't legitimate request-body properties. */
|
|
258
|
+
function suspectedExtras(
|
|
259
|
+
ep: EndpointInfo,
|
|
260
|
+
extra: Record<string, unknown> = {},
|
|
261
|
+
): Record<string, unknown> {
|
|
262
|
+
const reqProps = requestPropertyNames(ep.requestBodySchema);
|
|
263
|
+
const out: Record<string, unknown> = {};
|
|
264
|
+
// ARV-252: per-run extras (CLI --suspect-field) compose with the
|
|
265
|
+
// curated SUSPECTED_FIELDS list. Later additions win on key collision
|
|
266
|
+
// so a user can override a sentinel value if needed.
|
|
267
|
+
const merged: Record<string, unknown> = { ...SUSPECTED_FIELDS, ...extra };
|
|
268
|
+
for (const [name, value] of Object.entries(merged)) {
|
|
269
|
+
if (!reqProps.has(name)) out[name] = value;
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ──────────────────────────────────────────────
|
|
275
|
+
// URL building / auth
|
|
276
|
+
// ──────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
// ──────────────────────────────────────────────
|
|
279
|
+
// Live probe execution
|
|
280
|
+
// ──────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
export async function runMassAssignmentProbes(
|
|
283
|
+
opts: MassAssignmentOptions,
|
|
284
|
+
): Promise<MassAssignmentResult> {
|
|
285
|
+
const { endpoints, securitySchemes, vars, noCleanup, timeoutMs } = opts;
|
|
286
|
+
const discover = opts.discover !== false;
|
|
287
|
+
const cache: DiscoveryCache = createDiscoveryCache();
|
|
288
|
+
const verdicts: EndpointVerdict[] = [];
|
|
289
|
+
const warnings: string[] = [];
|
|
290
|
+
let totalEndpoints = 0;
|
|
291
|
+
|
|
292
|
+
for (const ep of endpoints) {
|
|
293
|
+
if (ep.deprecated) continue;
|
|
294
|
+
const m = ep.method.toUpperCase();
|
|
295
|
+
if (m !== "POST" && m !== "PATCH" && m !== "PUT") continue;
|
|
296
|
+
totalEndpoints++;
|
|
297
|
+
|
|
298
|
+
// ARV-150: accept form-urlencoded endpoints in addition to JSON. Stripe
|
|
299
|
+
// v1 declares only application/x-www-form-urlencoded for every mutating
|
|
300
|
+
// operation — 265 endpoints were SKIPPED before this loosening.
|
|
301
|
+
if (!hasProbeBody(ep)) {
|
|
302
|
+
verdicts.push(skipped(ep, "no JSON or form-urlencoded request body"));
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Resolve path placeholders, attempting auto-discovery when env doesn't
|
|
307
|
+
// supply them and the spec has a sibling list endpoint (TASK-92).
|
|
308
|
+
let effectiveVars = vars;
|
|
309
|
+
const probe = buildProbeUrl(ep, vars);
|
|
310
|
+
if (probe.unresolved.length > 0) {
|
|
311
|
+
if (!discover) {
|
|
312
|
+
const reason =
|
|
313
|
+
m === "POST"
|
|
314
|
+
? `cannot resolve path placeholders: ${probe.unresolved.join(", ")} (set them in --env file)`
|
|
315
|
+
: `${m} requires existing resource id; missing env vars: ${probe.unresolved.join(", ")}`;
|
|
316
|
+
verdicts.push(skipped(ep, reason));
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const discovered = await discoverPathParams({
|
|
320
|
+
ep,
|
|
321
|
+
unresolved: probe.unresolved,
|
|
322
|
+
allEndpoints: endpoints,
|
|
323
|
+
schemes: securitySchemes,
|
|
324
|
+
vars,
|
|
325
|
+
cache,
|
|
326
|
+
timeoutMs,
|
|
327
|
+
});
|
|
328
|
+
if (discovered.kind === "miss") {
|
|
329
|
+
verdicts.push(
|
|
330
|
+
skipped(
|
|
331
|
+
ep,
|
|
332
|
+
`cannot resolve path placeholders: ${probe.unresolved.join(", ")} — auto-discover failed (${discovered.reason})`,
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
effectiveVars = { ...vars, ...discovered.values };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// TASK-137: body-FK discovery. Required body fields named `audience_id`,
|
|
341
|
+
// `project_slug`, `team_uuid`… get filled from sibling collection
|
|
342
|
+
// endpoints. Without this, baseline POST hits 4xx because the random
|
|
343
|
+
// string we'd otherwise send fails FK validation, and the verdict
|
|
344
|
+
// becomes INCONCLUSIVE-baseline — a noise class that buried 51 verdicts
|
|
345
|
+
// in the dogfooding audit (m-8 feedback §B).
|
|
346
|
+
const bodyFkMisses: Array<{ field: string; reason: string }> = [];
|
|
347
|
+
if (discover) {
|
|
348
|
+
const bodyDiscovery = await discoverBodyFkVars({
|
|
349
|
+
ep,
|
|
350
|
+
allEndpoints: endpoints,
|
|
351
|
+
schemes: securitySchemes,
|
|
352
|
+
vars: effectiveVars,
|
|
353
|
+
cache,
|
|
354
|
+
timeoutMs,
|
|
355
|
+
});
|
|
356
|
+
if (Object.keys(bodyDiscovery.values).length > 0) {
|
|
357
|
+
effectiveVars = { ...effectiveVars, ...bodyDiscovery.values };
|
|
358
|
+
}
|
|
359
|
+
bodyFkMisses.push(...bodyDiscovery.misses);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Body-FK overlays. discoverBodyFkVars wrote into effectiveVars but the
|
|
363
|
+
// baseline body is generated from spec via fake UUIDs / random strings —
|
|
364
|
+
// substituteDeep only handles literal `{{var}}` markers, not field-name
|
|
365
|
+
// matches. So we pass the resolved field→value map separately and the
|
|
366
|
+
// probe overlays it onto baseline directly.
|
|
367
|
+
let bodyFkOverlay: Record<string, string> | undefined;
|
|
368
|
+
if (discover) {
|
|
369
|
+
bodyFkOverlay = {};
|
|
370
|
+
for (const k of Object.keys(effectiveVars)) {
|
|
371
|
+
if (vars[k] === undefined && k.includes("_") && /(_id|_slug|_uuid|_key)$/.test(k)) {
|
|
372
|
+
bodyFkOverlay[k] = effectiveVars[k]!;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (Object.keys(bodyFkOverlay).length === 0) bodyFkOverlay = undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const verdict = await probeEndpoint(ep, endpoints, securitySchemes, effectiveVars, {
|
|
379
|
+
noCleanup: noCleanup === true,
|
|
380
|
+
timeoutMs,
|
|
381
|
+
bodyFkMisses,
|
|
382
|
+
bodyFkOverlay,
|
|
383
|
+
extraSuspectFields: opts.extraSuspectFields,
|
|
384
|
+
});
|
|
385
|
+
stampRecommendedAction(verdict);
|
|
386
|
+
verdicts.push(verdict);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
specProbed: verdicts.length,
|
|
391
|
+
totalEndpoints,
|
|
392
|
+
verdicts,
|
|
393
|
+
warnings,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function skipped(ep: EndpointInfo, reason: string): EndpointVerdict {
|
|
398
|
+
return {
|
|
399
|
+
method: ep.method.toUpperCase(),
|
|
400
|
+
path: ep.path,
|
|
401
|
+
severity: "skipped",
|
|
402
|
+
summary: reason,
|
|
403
|
+
request: { url: "", body: undefined, injectedFields: [] },
|
|
404
|
+
fields: [],
|
|
405
|
+
strictContract: isStrictContract(ep.requestBodySchema),
|
|
406
|
+
skipReason: reason,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function probeEndpoint(
|
|
411
|
+
ep: EndpointInfo,
|
|
412
|
+
allEndpoints: EndpointInfo[],
|
|
413
|
+
schemes: SecuritySchemeInfo[],
|
|
414
|
+
vars: Record<string, string>,
|
|
415
|
+
opts: {
|
|
416
|
+
noCleanup: boolean;
|
|
417
|
+
timeoutMs?: number;
|
|
418
|
+
bodyFkMisses?: Array<{ field: string; reason: string }>;
|
|
419
|
+
/** TASK-137: field→value pairs from body-FK discovery. Overlaid on baseline
|
|
420
|
+
* after generation so a real id/slug replaces the random sentinel. */
|
|
421
|
+
bodyFkOverlay?: Record<string, string>;
|
|
422
|
+
/** ARV-252: per-run extras for the suspect-fields list. */
|
|
423
|
+
extraSuspectFields?: Record<string, unknown>;
|
|
424
|
+
},
|
|
425
|
+
): Promise<EndpointVerdict> {
|
|
426
|
+
const m = ep.method.toUpperCase();
|
|
427
|
+
const strict = isStrictContract(ep.requestBodySchema);
|
|
428
|
+
|
|
429
|
+
// Build baseline payload from spec then substitute generators ({{$uuid}}, …).
|
|
430
|
+
const baseline = buildBaselineFromSpec(ep, vars);
|
|
431
|
+
if (baseline === null) {
|
|
432
|
+
return skipped(ep, "request body not a JSON object");
|
|
433
|
+
}
|
|
434
|
+
// TASK-137: overlay discovered FK values directly by field name so the
|
|
435
|
+
// baseline body actually carries the real audience_id / project_slug / …
|
|
436
|
+
// instead of the random UUID generateFromSchema synthesised.
|
|
437
|
+
if (opts.bodyFkOverlay) {
|
|
438
|
+
for (const [k, v] of Object.entries(opts.bodyFkOverlay)) {
|
|
439
|
+
if (k in baseline) baseline[k] = v;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const suspects = suspectedExtras(ep, opts.extraSuspectFields);
|
|
444
|
+
const serverFields = serverAssignedExtras(ep);
|
|
445
|
+
// Suspects win over server-assigned: if a field is both (e.g. `is_admin`
|
|
446
|
+
// appears in the response schema AND is in our suspect list), the suspect
|
|
447
|
+
// sentinel must be sent so we can detect privilege escalation.
|
|
448
|
+
const injectedSet = { ...serverFields, ...suspects };
|
|
449
|
+
const injectedNames = Object.keys(injectedSet);
|
|
450
|
+
if (injectedNames.length === 0) {
|
|
451
|
+
return skipped(ep, "no extra fields to inject (request schema covers everything)");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const body = { ...baseline, ...injectedSet };
|
|
455
|
+
const { url, unresolved } = buildProbeUrl(ep, vars);
|
|
456
|
+
if (unresolved.length > 0) {
|
|
457
|
+
return skipped(
|
|
458
|
+
ep,
|
|
459
|
+
`cannot resolve path placeholders: ${unresolved.join(", ")} (set them in --env file)`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ARV-150: Content-Type follows the spec — form-urlencoded for Stripe v1,
|
|
464
|
+
// JSON otherwise. `serializeProbeBody` encodes the actual wire payload.
|
|
465
|
+
const headers = buildBodyAuthHeaders(ep, schemes, vars);
|
|
466
|
+
|
|
467
|
+
const verdict: EndpointVerdict = {
|
|
468
|
+
method: m,
|
|
469
|
+
path: ep.path,
|
|
470
|
+
severity: "ok",
|
|
471
|
+
summary: "",
|
|
472
|
+
request: { url, body, injectedFields: injectedNames },
|
|
473
|
+
fields: injectedNames.map(name => ({
|
|
474
|
+
field: name,
|
|
475
|
+
injected: injectedSet[name],
|
|
476
|
+
outcome: "unknown",
|
|
477
|
+
})),
|
|
478
|
+
strictContract: strict,
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// ── Baseline probe (TASK-91) ─────────────────────────────────────────────
|
|
482
|
+
// Send the *clean* baseline body first. Without this, a 4xx caused by FK
|
|
483
|
+
// miss / bad fixture / scope mismatch is indistinguishable from a 4xx that
|
|
484
|
+
// actually rejected our extras — false-OK on FK-heavy SaaS APIs (Stripe /
|
|
485
|
+
// Linear / GitHub-shaped). The baseline lets us classify:
|
|
486
|
+
// • baseline 4xx + injected 4xx → INCONCLUSIVE-baseline (fixture bug).
|
|
487
|
+
// • baseline 2xx + injected 4xx → OK (real extras rejection).
|
|
488
|
+
// • baseline 4xx + injected 2xx → HIGH (extras opened a code path the
|
|
489
|
+
// baseline never reached — privilege/auth bypass).
|
|
490
|
+
// • baseline 2xx + injected 2xx → existing applied/ignored flow.
|
|
491
|
+
let baselineResp;
|
|
492
|
+
try {
|
|
493
|
+
baselineResp = await executeRequest(
|
|
494
|
+
{ method: m, url, headers, body: serializeProbeBody(ep, baseline).content },
|
|
495
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
496
|
+
);
|
|
497
|
+
} catch (err) {
|
|
498
|
+
verdict.severity = "high";
|
|
499
|
+
verdict.summary = `baseline network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
500
|
+
return verdict;
|
|
501
|
+
}
|
|
502
|
+
const baselineBody = baselineResp.body_parsed ?? baselineResp.body;
|
|
503
|
+
verdict.baseline = { status: baselineResp.status, body: baselineBody };
|
|
504
|
+
const baselineOk = baselineResp.status >= 200 && baselineResp.status < 300;
|
|
505
|
+
// If baseline created a resource, DELETE it before issuing the injected
|
|
506
|
+
// probe so the second POST doesn't trip a unique-constraint and so we
|
|
507
|
+
// don't leak resources.
|
|
508
|
+
if (baselineOk && !opts.noCleanup) {
|
|
509
|
+
await tryCleanupBaseline(ep, allEndpoints, schemes, vars, baselineBody, opts);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── Injected probe ──────────────────────────────────────────────────────
|
|
513
|
+
let resp;
|
|
514
|
+
try {
|
|
515
|
+
resp = await executeRequest(
|
|
516
|
+
{ method: m, url, headers, body: serializeProbeBody(ep, body).content },
|
|
517
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
518
|
+
);
|
|
519
|
+
} catch (err) {
|
|
520
|
+
verdict.severity = "high";
|
|
521
|
+
verdict.summary = `network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
522
|
+
return verdict;
|
|
523
|
+
}
|
|
524
|
+
verdict.response = { status: resp.status, body: resp.body_parsed ?? resp.body };
|
|
525
|
+
|
|
526
|
+
if (resp.status >= 500) {
|
|
527
|
+
// TASK-276: if the baseline (no extras) also crashed with ≥500, the
|
|
528
|
+
// endpoint is just crashing — mass-assignment semantics aren't
|
|
529
|
+
// observable, and validation-probe will already have flagged the same
|
|
530
|
+
// endpoint. Don't surface as HIGH privilege-escalation; that buries
|
|
531
|
+
// real findings under noise.
|
|
532
|
+
if (baselineResp.status >= 500) {
|
|
533
|
+
verdict.severity = "inconclusive-5xx";
|
|
534
|
+
verdict.summary = `baseline ${baselineResp.status} → injected ${resp.status} — endpoint crashes regardless of extras (likely duplicate of validation-probe)`;
|
|
535
|
+
for (const f of verdict.fields) f.outcome = "unknown";
|
|
536
|
+
return verdict;
|
|
537
|
+
}
|
|
538
|
+
verdict.severity = "high";
|
|
539
|
+
verdict.summary = `5xx unhandled (${resp.status}) — see negative-probe`;
|
|
540
|
+
return verdict;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const injectedOk = resp.status >= 200 && resp.status < 300;
|
|
544
|
+
|
|
545
|
+
// Matrix dispatch on baseline×injected (TASK-91):
|
|
546
|
+
if (resp.status >= 400 && !injectedOk) {
|
|
547
|
+
if (!baselineOk) {
|
|
548
|
+
// Baseline body itself invalid — extras never reached validation.
|
|
549
|
+
verdict.severity = "inconclusive-baseline";
|
|
550
|
+
verdict.summary = inconclusiveBaselineSummary(
|
|
551
|
+
baselineResp.status,
|
|
552
|
+
baselineBody,
|
|
553
|
+
opts.bodyFkMisses,
|
|
554
|
+
);
|
|
555
|
+
for (const f of verdict.fields) f.outcome = "unknown";
|
|
556
|
+
return verdict;
|
|
557
|
+
}
|
|
558
|
+
// Baseline succeeded, injected rejected → real extras rejection.
|
|
559
|
+
verdict.severity = "ok";
|
|
560
|
+
verdict.summary = strict
|
|
561
|
+
? `rejected ${resp.status} — strict contract honoured`
|
|
562
|
+
: `rejected ${resp.status} — extras refused (baseline ${baselineResp.status})`;
|
|
563
|
+
for (const f of verdict.fields) f.outcome = "absent";
|
|
564
|
+
return verdict;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (injectedOk && !baselineOk) {
|
|
568
|
+
// Extras-as-bypass: baseline didn't make it through, but adding extras did.
|
|
569
|
+
// The extra fields opened a code path that baseline didn't reach (auth
|
|
570
|
+
// scope, FK shadowing, etc.). Treat as HIGH — likely a real bug —
|
|
571
|
+
// and continue to body-classification so per-field outcomes are still
|
|
572
|
+
// recorded for the digest.
|
|
573
|
+
verdict.severity = "high";
|
|
574
|
+
const bypassReason =
|
|
575
|
+
baselineResp.status >= 500
|
|
576
|
+
? "server crash on baseline — extras-bypass turned a 5xx into a successful write"
|
|
577
|
+
: "extras opened a code path baseline didn't reach";
|
|
578
|
+
verdict.summary = `extras-bypass: baseline ${baselineResp.status} → injected ${resp.status} (${bypassReason})`;
|
|
579
|
+
// Fall through to the 2xx classification below; finaliseSeverity won't
|
|
580
|
+
// overwrite "high" once it's set — but we also want to still mark
|
|
581
|
+
// applied/ignored fields. We skip finaliseSeverity at the end for this
|
|
582
|
+
// case to preserve the bypass summary.
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// 2xx — analyse the response body for echoed values, then maybe GET.
|
|
586
|
+
const respBody =
|
|
587
|
+
typeof resp.body_parsed === "object" && resp.body_parsed !== null
|
|
588
|
+
? (resp.body_parsed as Record<string, unknown>)
|
|
589
|
+
: undefined;
|
|
590
|
+
|
|
591
|
+
classifyFromBody(verdict, respBody);
|
|
592
|
+
|
|
593
|
+
// Follow-up GET if any field is still "absent" or "unknown" — to distinguish
|
|
594
|
+
// ignored from silently-persisted-but-not-echoed.
|
|
595
|
+
if (respBody && needsFollowUp(verdict)) {
|
|
596
|
+
const idField = captureFieldFor(ep);
|
|
597
|
+
const id = respBody[idField];
|
|
598
|
+
const getEp = findGetByIdCounterpart(ep, allEndpoints);
|
|
599
|
+
if (id !== undefined && getEp) {
|
|
600
|
+
const getVars = { ...vars, [findIdParam(getEp)]: String(id), id: String(id) };
|
|
601
|
+
const getUrl = buildProbeUrl(getEp, getVars);
|
|
602
|
+
if (getUrl.unresolved.length === 0) {
|
|
603
|
+
try {
|
|
604
|
+
const getResp = await executeRequest(
|
|
605
|
+
{
|
|
606
|
+
method: "GET",
|
|
607
|
+
url: getUrl.url,
|
|
608
|
+
headers: {
|
|
609
|
+
accept: "application/json",
|
|
610
|
+
...liveAuthHeaders(getEp, schemes, vars),
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
614
|
+
);
|
|
615
|
+
const getBody =
|
|
616
|
+
typeof getResp.body_parsed === "object" && getResp.body_parsed !== null
|
|
617
|
+
? (getResp.body_parsed as Record<string, unknown>)
|
|
618
|
+
: undefined;
|
|
619
|
+
verdict.followUpGet = {
|
|
620
|
+
url: getUrl.url,
|
|
621
|
+
status: getResp.status,
|
|
622
|
+
body: getResp.body_parsed ?? getResp.body,
|
|
623
|
+
};
|
|
624
|
+
if (getBody) classifyFromBody(verdict, getBody, true);
|
|
625
|
+
} catch (err) {
|
|
626
|
+
verdict.notes = [
|
|
627
|
+
...(verdict.notes ?? []),
|
|
628
|
+
`follow-up GET failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
629
|
+
];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Cleanup
|
|
635
|
+
if (!opts.noCleanup && id !== undefined) {
|
|
636
|
+
const delEp = findDeleteCounterpart(ep, allEndpoints);
|
|
637
|
+
if (delEp) {
|
|
638
|
+
const delVars = { ...vars, [findIdParam(delEp)]: String(id), id: String(id) };
|
|
639
|
+
const delUrl = buildProbeUrl(delEp, delVars);
|
|
640
|
+
if (delUrl.unresolved.length === 0) {
|
|
641
|
+
try {
|
|
642
|
+
const delResp = await executeRequest(
|
|
643
|
+
{
|
|
644
|
+
method: "DELETE",
|
|
645
|
+
url: delUrl.url,
|
|
646
|
+
headers: {
|
|
647
|
+
accept: "application/json",
|
|
648
|
+
...liveAuthHeaders(delEp, schemes, vars),
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
652
|
+
);
|
|
653
|
+
verdict.cleanup = { attempted: true, status: delResp.status };
|
|
654
|
+
} catch (err) {
|
|
655
|
+
verdict.cleanup = {
|
|
656
|
+
attempted: true,
|
|
657
|
+
error: err instanceof Error ? err.message : String(err),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
} else {
|
|
661
|
+
verdict.cleanup = { attempted: false, error: "unresolved DELETE path placeholders" };
|
|
662
|
+
}
|
|
663
|
+
} else {
|
|
664
|
+
// ARV-153: action POSTs (`/capture`, `/verify`, `/cancel`, …) never
|
|
665
|
+
// allocate a new resource — surface that instead of the alarming
|
|
666
|
+
// "no DELETE counterpart" line that triggered F7's leak-risk noise.
|
|
667
|
+
const reason =
|
|
668
|
+
classifyPostSemantics(ep) === "action"
|
|
669
|
+
? "no cleanup needed (action endpoint — no resource created)"
|
|
670
|
+
: "no DELETE counterpart in spec";
|
|
671
|
+
verdict.cleanup = { attempted: false, error: reason };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Preserve "high" already set by the extras-bypass branch; otherwise
|
|
677
|
+
// derive severity from per-field outcomes.
|
|
678
|
+
if (verdict.severity !== "high") finaliseSeverity(verdict, strict);
|
|
679
|
+
stampRecommendedAction(verdict);
|
|
680
|
+
return verdict;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** ARV-56: route through the single classifier instead of carrying the
|
|
684
|
+
* severity→action switch inline. */
|
|
685
|
+
function stampRecommendedAction(verdict: EndpointVerdict): void {
|
|
686
|
+
const action = classify({
|
|
687
|
+
finding_class: "probe:mass_assignment",
|
|
688
|
+
severity: verdict.severity as Parameters<typeof classify>[0]["severity"],
|
|
689
|
+
});
|
|
690
|
+
if (action) verdict.recommended_action = action;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function tryCleanupBaseline(
|
|
694
|
+
ep: EndpointInfo,
|
|
695
|
+
allEndpoints: EndpointInfo[],
|
|
696
|
+
schemes: SecuritySchemeInfo[],
|
|
697
|
+
vars: Record<string, string>,
|
|
698
|
+
baselineBody: unknown,
|
|
699
|
+
opts: { timeoutMs?: number },
|
|
700
|
+
): Promise<void> {
|
|
701
|
+
const body =
|
|
702
|
+
typeof baselineBody === "object" && baselineBody !== null
|
|
703
|
+
? (baselineBody as Record<string, unknown>)
|
|
704
|
+
: undefined;
|
|
705
|
+
if (!body) return;
|
|
706
|
+
const idField = captureFieldFor(ep);
|
|
707
|
+
const id = body[idField];
|
|
708
|
+
if (id === undefined) return;
|
|
709
|
+
const delEp = findDeleteCounterpart(ep, allEndpoints);
|
|
710
|
+
if (!delEp) return;
|
|
711
|
+
const delVars = { ...vars, [findIdParam(delEp)]: String(id), id: String(id) };
|
|
712
|
+
const delUrl = buildProbeUrl(delEp, delVars);
|
|
713
|
+
if (delUrl.unresolved.length > 0) return;
|
|
714
|
+
try {
|
|
715
|
+
await executeRequest(
|
|
716
|
+
{
|
|
717
|
+
method: "DELETE",
|
|
718
|
+
url: delUrl.url,
|
|
719
|
+
headers: {
|
|
720
|
+
accept: "application/json",
|
|
721
|
+
...liveAuthHeaders(delEp, schemes, vars),
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
725
|
+
);
|
|
726
|
+
} catch {
|
|
727
|
+
// best-effort — if cleanup fails we'll leak a baseline resource, but
|
|
728
|
+
// that's a deployment problem, not a probe bug.
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Build a one-line summary for INCONCLUSIVE-baseline verdicts. We surface
|
|
734
|
+
* the server's error code/name when present so the user can immediately
|
|
735
|
+
* see *which* FK / scope / fixture failed and fix it before re-probing.
|
|
736
|
+
*/
|
|
737
|
+
function inconclusiveBaselineSummary(
|
|
738
|
+
status: number,
|
|
739
|
+
body: unknown,
|
|
740
|
+
bodyFkMisses?: Array<{ field: string; reason: string }>,
|
|
741
|
+
): string {
|
|
742
|
+
const hint = extractBaselineHint(body);
|
|
743
|
+
const base = `baseline body invalid — server returned ${status}`;
|
|
744
|
+
// ARV-104 (F9) → ARV-125: when status is 403 and the response body
|
|
745
|
+
// names a subscription/scope gate (paid plan, feature flag, role/scope
|
|
746
|
+
// insufficient), the right answer isn't "fix fixture" — there's
|
|
747
|
+
// nothing to fix. The pattern set + suppression text now live in the
|
|
748
|
+
// anti-FP registry as `subscription-gated/paid-plan-403`; we route through
|
|
749
|
+
// `applyAntiFp` so the rule body, references, and identifier stay in
|
|
750
|
+
// one place.
|
|
751
|
+
const suppression = hint !== undefined
|
|
752
|
+
? applyAntiFp({ status, message: hint }, "probe:mass-assignment")
|
|
753
|
+
: null;
|
|
754
|
+
const tail = suppression
|
|
755
|
+
? ` — ${suppression.reason}`
|
|
756
|
+
: " — fix fixture / FK value / path-params and re-probe";
|
|
757
|
+
// TASK-137: if body-FK auto-discovery couldn't fill required FK fields, name
|
|
758
|
+
// them in the summary so the user knows exactly what to add to env (or
|
|
759
|
+
// why discover-fk missed — e.g. nested list endpoint, 403 from scope).
|
|
760
|
+
let fkClause = "";
|
|
761
|
+
if (bodyFkMisses && bodyFkMisses.length > 0) {
|
|
762
|
+
const names = bodyFkMisses.map(m => m.field).join(", ");
|
|
763
|
+
fkClause = ` — unresolved body FKs: ${names}`;
|
|
764
|
+
}
|
|
765
|
+
return hint
|
|
766
|
+
? `${base} (${hint})${fkClause}${tail}`
|
|
767
|
+
: `${base}${fkClause}${tail}`;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** ARV-104 (F9) → ARV-125: pattern set + suppression text moved to the
|
|
771
|
+
* anti-FP registry (`subscription-gated/paid-plan-403`). This re-export keeps
|
|
772
|
+
* pre-migration callers (existing unit test in
|
|
773
|
+
* mass-assignment-probe.test.ts) working through a thin shim. New
|
|
774
|
+
* callers should depend on the rule module directly or route
|
|
775
|
+
* through `applyAntiFp(ctx, "probe:mass-assignment")`. */
|
|
776
|
+
export const isSubscriptionGated = matchesPaidPlan403;
|
|
777
|
+
|
|
778
|
+
function extractBaselineHint(body: unknown): string | undefined {
|
|
779
|
+
if (typeof body === "string") {
|
|
780
|
+
const trimmed = body.trim();
|
|
781
|
+
if (trimmed.length === 0) return undefined;
|
|
782
|
+
return trimmed.length > 120 ? `${trimmed.slice(0, 120)}…` : trimmed;
|
|
783
|
+
}
|
|
784
|
+
if (typeof body !== "object" || body === null) return undefined;
|
|
785
|
+
const obj = body as Record<string, unknown>;
|
|
786
|
+
// Common error-envelope fields across SaaS APIs.
|
|
787
|
+
const candidates = [
|
|
788
|
+
obj.message,
|
|
789
|
+
obj.error,
|
|
790
|
+
(obj.error as Record<string, unknown> | undefined)?.message,
|
|
791
|
+
obj.detail,
|
|
792
|
+
obj.title,
|
|
793
|
+
obj.name,
|
|
794
|
+
obj.code,
|
|
795
|
+
];
|
|
796
|
+
for (const c of candidates) {
|
|
797
|
+
if (typeof c === "string" && c.length > 0) {
|
|
798
|
+
return c.length > 120 ? `${c.slice(0, 120)}…` : c;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return undefined;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function needsFollowUp(verdict: EndpointVerdict): boolean {
|
|
805
|
+
return verdict.fields.some(f => f.outcome === "absent" || f.outcome === "unknown");
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function classifyFromBody(
|
|
809
|
+
verdict: EndpointVerdict,
|
|
810
|
+
body: Record<string, unknown> | undefined,
|
|
811
|
+
fromGet = false,
|
|
812
|
+
) {
|
|
813
|
+
if (!body) return;
|
|
814
|
+
for (const field of verdict.fields) {
|
|
815
|
+
// Once a field is decisively classified (applied/echoed-overwritten),
|
|
816
|
+
// don't downgrade. But "absent" on POST may still flip to applied/ignored
|
|
817
|
+
// after GET — so only re-check those.
|
|
818
|
+
if (field.outcome === "applied" || field.outcome === "echoed-overwritten") continue;
|
|
819
|
+
if (!(field.field in body)) {
|
|
820
|
+
// GET also missing → ignored. POST missing → keep "absent" so we GET later.
|
|
821
|
+
field.outcome = fromGet ? "ignored" : "absent";
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
const observed = body[field.field];
|
|
825
|
+
field.observed = observed;
|
|
826
|
+
if (deepEqual(observed, field.injected)) {
|
|
827
|
+
field.outcome = "applied";
|
|
828
|
+
} else if (fromGet) {
|
|
829
|
+
field.outcome = "ignored";
|
|
830
|
+
} else {
|
|
831
|
+
field.outcome = "echoed-overwritten";
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
837
|
+
if (a === b) return true;
|
|
838
|
+
if (typeof a !== typeof b) return false;
|
|
839
|
+
if (a === null || b === null) return false;
|
|
840
|
+
if (typeof a !== "object") return false;
|
|
841
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function findIdParam(ep: EndpointInfo): string {
|
|
845
|
+
const m = ep.path.match(/\{([^}]+)\}/);
|
|
846
|
+
return m ? m[1]! : "id";
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function finaliseSeverity(v: EndpointVerdict, strict: boolean) {
|
|
850
|
+
const applied = v.fields.filter(f => f.outcome === "applied");
|
|
851
|
+
const absent = v.fields.filter(f => f.outcome === "absent");
|
|
852
|
+
|
|
853
|
+
if (applied.length > 0) {
|
|
854
|
+
v.severity = "high";
|
|
855
|
+
v.summary = `accepted-and-applied: ${applied.map(f => f.field).join(", ")}`;
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (absent.length > 0) {
|
|
859
|
+
// ARV-252: absent-but-unverifiable carries single_signal proof.
|
|
860
|
+
// Surfaced as INFO and only shown under --verbose so the report
|
|
861
|
+
// stays clean; the verdict still travels through the JSON envelope
|
|
862
|
+
// for agents that want to triage it explicitly.
|
|
863
|
+
v.severity = "info";
|
|
864
|
+
v.summary = `inconclusive — could not verify via follow-up GET (${absent.map(f => f.field).join(", ")})`;
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
// ARV-252: silently-ignored = correct framework behaviour (Rails
|
|
868
|
+
// strong params / FastAPI extra=ignore). Severity stays INFO so it
|
|
869
|
+
// never gates CI, AND the CLI display layer suppresses it entirely
|
|
870
|
+
// (even under --verbose). Reports must not be noise-floored by
|
|
871
|
+
// correct behaviour. Verdicts still travel through the JSON envelope
|
|
872
|
+
// for agents that explicitly want to inspect them.
|
|
873
|
+
v.severity = "info";
|
|
874
|
+
const status = v.response?.status ?? 0;
|
|
875
|
+
v.summary = `accepted ${status} but extras silently ignored${strict ? " (despite additionalProperties:false — server should reject)" : ""}`;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ──────────────────────────────────────────────
|
|
879
|
+
// Markdown digest
|
|
880
|
+
// ──────────────────────────────────────────────
|
|
881
|
+
|
|
882
|
+
const SEVERITY_ORDER: Severity[] = [
|
|
883
|
+
"high",
|
|
884
|
+
"inconclusive-baseline",
|
|
885
|
+
"inconclusive-5xx",
|
|
886
|
+
"medium",
|
|
887
|
+
"low",
|
|
888
|
+
"info",
|
|
889
|
+
"ok",
|
|
890
|
+
"skipped",
|
|
891
|
+
];
|
|
892
|
+
|
|
893
|
+
const SEVERITY_HEADER: Record<Severity, string> = {
|
|
894
|
+
high: "🚨 HIGH — privilege escalation candidates",
|
|
895
|
+
"inconclusive-baseline": "⚠️ INCONCLUSIVE — baseline body invalid (fix fixture / FK / scope and re-probe)",
|
|
896
|
+
"inconclusive-5xx": "⚠️ INCONCLUSIVE — baseline 5xx (endpoint crashes — likely duplicate of validation-probe)",
|
|
897
|
+
medium: "⚠️ MEDIUM — inconclusive (no follow-up GET available)",
|
|
898
|
+
low: "ℹ️ LOW — inconclusive (single-signal, follow-up GET unavailable)",
|
|
899
|
+
info: "· INFO — accepted-and-ignored (correct framework behaviour, often ineligible to report)",
|
|
900
|
+
ok: "✅ OK — rejected 4xx (best behaviour)",
|
|
901
|
+
skipped: "⏭️ SKIPPED",
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
export function formatDigestMarkdown(
|
|
905
|
+
result: MassAssignmentResult,
|
|
906
|
+
specPath: string,
|
|
907
|
+
): string {
|
|
908
|
+
const lines: string[] = [];
|
|
909
|
+
lines.push(`# Mass-assignment probe digest`);
|
|
910
|
+
lines.push("");
|
|
911
|
+
lines.push(`**Spec:** \`${specPath}\``);
|
|
912
|
+
lines.push(`**Endpoints probed:** ${result.specProbed} of ${result.totalEndpoints} mutating endpoints`);
|
|
913
|
+
lines.push("");
|
|
914
|
+
lines.push(`**Suspected fields tested:** ${Object.keys(SUSPECTED_FIELDS).join(", ")}`);
|
|
915
|
+
lines.push("");
|
|
916
|
+
|
|
917
|
+
const buckets = groupBySeverity(result.verdicts);
|
|
918
|
+
for (const sev of SEVERITY_ORDER) {
|
|
919
|
+
const items = buckets[sev];
|
|
920
|
+
if (!items || items.length === 0) continue;
|
|
921
|
+
lines.push(`## ${SEVERITY_HEADER[sev]} (${items.length})`);
|
|
922
|
+
lines.push("");
|
|
923
|
+
for (const v of items) {
|
|
924
|
+
lines.push(`### ${v.method} ${v.path}`);
|
|
925
|
+
lines.push("");
|
|
926
|
+
if (v.severity === "skipped") {
|
|
927
|
+
lines.push(`- Skipped: ${v.skipReason ?? v.summary}`);
|
|
928
|
+
lines.push("");
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
lines.push(`- ${v.summary}`);
|
|
932
|
+
lines.push(`- Injected: ${v.request.injectedFields.map(n => `\`${n}\``).join(", ")}`);
|
|
933
|
+
if (v.baseline) {
|
|
934
|
+
lines.push(`- Baseline (no extras): ${v.baseline.status}`);
|
|
935
|
+
}
|
|
936
|
+
if (v.response) {
|
|
937
|
+
lines.push(`- With extras: ${v.response.status}`);
|
|
938
|
+
}
|
|
939
|
+
if (v.followUpGet) {
|
|
940
|
+
lines.push(`- Follow-up GET → ${v.followUpGet.status}`);
|
|
941
|
+
}
|
|
942
|
+
const interesting = v.fields.filter(f => f.outcome !== "ignored" && f.outcome !== "absent");
|
|
943
|
+
if (interesting.length > 0) {
|
|
944
|
+
lines.push(`- Per-field outcomes:`);
|
|
945
|
+
for (const f of interesting) {
|
|
946
|
+
const obs = f.observed === undefined ? "n/a" : JSON.stringify(f.observed);
|
|
947
|
+
lines.push(` - \`${f.field}\` → **${f.outcome}** (injected ${JSON.stringify(f.injected)}, observed ${obs})`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (v.cleanup) {
|
|
951
|
+
if (v.cleanup.attempted) {
|
|
952
|
+
lines.push(`- Cleanup DELETE: ${v.cleanup.status ?? "errored"}${v.cleanup.error ? ` — ${v.cleanup.error}` : ""}`);
|
|
953
|
+
} else {
|
|
954
|
+
lines.push(`- Cleanup skipped: ${v.cleanup.error ?? "unknown"}`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (v.notes && v.notes.length > 0) {
|
|
958
|
+
for (const n of v.notes) lines.push(`- Note: ${n}`);
|
|
959
|
+
}
|
|
960
|
+
if (v.severity === "high") {
|
|
961
|
+
lines.push(`- **Action:** treat as P0 — server should reject or strip these fields.`);
|
|
962
|
+
}
|
|
963
|
+
if (v.severity === "inconclusive-baseline") {
|
|
964
|
+
lines.push(
|
|
965
|
+
`- **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.`,
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
if (v.severity === "inconclusive-5xx") {
|
|
969
|
+
lines.push(
|
|
970
|
+
`- **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.`,
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
lines.push("");
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (result.warnings.length > 0) {
|
|
978
|
+
lines.push(`## Warnings`);
|
|
979
|
+
lines.push("");
|
|
980
|
+
for (const w of result.warnings) lines.push(`- ${w}`);
|
|
981
|
+
lines.push("");
|
|
982
|
+
}
|
|
983
|
+
return lines.join("\n");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function groupBySeverity(verdicts: EndpointVerdict[]): Record<Severity, EndpointVerdict[]> {
|
|
987
|
+
const out: Record<Severity, EndpointVerdict[]> = {
|
|
988
|
+
high: [], "inconclusive-baseline": [], "inconclusive-5xx": [], medium: [], low: [], info: [], ok: [], skipped: [],
|
|
989
|
+
};
|
|
990
|
+
for (const v of verdicts) out[v.severity].push(v);
|
|
991
|
+
return out;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ──────────────────────────────────────────────
|
|
995
|
+
// Regression-suite emitter (--emit-tests)
|
|
996
|
+
// ──────────────────────────────────────────────
|
|
997
|
+
|
|
998
|
+
const ACCEPTABLE_4XX = [400, 401, 403, 409, 415, 422];
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Emit YAML suites that lock in the safe behaviour observed during the live
|
|
1002
|
+
* run:
|
|
1003
|
+
* • rejected (4xx) → assert status ∈ ACCEPTABLE_4XX (no regression to 2xx).
|
|
1004
|
+
* • accepted-and-ignored → assert 2xx and that injected fields don't echo
|
|
1005
|
+
* back. Follow-up GET — when available — additionally asserts the field
|
|
1006
|
+
* is not persisted.
|
|
1007
|
+
*
|
|
1008
|
+
* "applied" / "inconclusive" are deliberately NOT emitted: those are bugs to
|
|
1009
|
+
* fix, not baselines to lock.
|
|
1010
|
+
*/
|
|
1011
|
+
export function emitRegressionSuites(
|
|
1012
|
+
result: MassAssignmentResult,
|
|
1013
|
+
endpoints: EndpointInfo[],
|
|
1014
|
+
schemes: SecuritySchemeInfo[],
|
|
1015
|
+
): RawSuite[] {
|
|
1016
|
+
const suites: RawSuite[] = [];
|
|
1017
|
+
for (const v of result.verdicts) {
|
|
1018
|
+
// ARV-250: "info" carries the post-pivot semantics of the old "low"
|
|
1019
|
+
// (extras silently ignored — useful regression even when severity is
|
|
1020
|
+
// demoted). Both "low" (inconclusive) and "info" (ignored) qualify
|
|
1021
|
+
// for the ignored-baseline suite.
|
|
1022
|
+
const isIgnoredCase = v.severity === "low" || v.severity === "info";
|
|
1023
|
+
if (v.severity !== "ok" && !isIgnoredCase) continue;
|
|
1024
|
+
const ep = endpoints.find(e => e.path === v.path && e.method.toUpperCase() === v.method);
|
|
1025
|
+
if (!ep) continue;
|
|
1026
|
+
const suiteHeaders = getAuthHeaders(ep, schemes);
|
|
1027
|
+
const probeExpectedStatus = v.severity === "ok" ? ACCEPTABLE_4XX : [200, 201, 202, 204];
|
|
1028
|
+
// ARV-150: emit `form:` instead of `json:` when the endpoint uses
|
|
1029
|
+
// form-urlencoded bodies — otherwise the regression suite would send
|
|
1030
|
+
// JSON and re-hit the original "wrong content-type" 400.
|
|
1031
|
+
const bodyField =
|
|
1032
|
+
ep.requestBodyContentType === "application/x-www-form-urlencoded"
|
|
1033
|
+
? { form: flattenToFormFields(v.request.body) }
|
|
1034
|
+
: { json: v.request.body };
|
|
1035
|
+
const probeStep: RawStep = {
|
|
1036
|
+
name: `mass-assignment: extras must ${v.severity === "ok" ? "be rejected" : "not apply"}`,
|
|
1037
|
+
source: {
|
|
1038
|
+
generator: "mass-assignment-probe",
|
|
1039
|
+
endpoint: `${v.method} ${v.path}`,
|
|
1040
|
+
response_branch: probeExpectedStatus.map(String).join("|"),
|
|
1041
|
+
},
|
|
1042
|
+
[v.method]: convertPath(ep.path),
|
|
1043
|
+
...bodyField,
|
|
1044
|
+
expect: {
|
|
1045
|
+
status: probeExpectedStatus,
|
|
1046
|
+
},
|
|
1047
|
+
};
|
|
1048
|
+
const tests: RawStep[] = [probeStep];
|
|
1049
|
+
// For ignored case + we have a follow-up GET → emit a verifying GET
|
|
1050
|
+
// that asserts injected fields are absent / overridden.
|
|
1051
|
+
if (isIgnoredCase && v.followUpGet) {
|
|
1052
|
+
const idField = captureFieldFor(ep);
|
|
1053
|
+
probeStep.expect.body = {
|
|
1054
|
+
...(probeStep.expect.body ?? {}),
|
|
1055
|
+
[idField]: { capture: "created_id" },
|
|
1056
|
+
};
|
|
1057
|
+
const getEp = findGetByIdCounterpart(ep, endpoints);
|
|
1058
|
+
if (getEp) {
|
|
1059
|
+
const idParam = findIdParam(getEp);
|
|
1060
|
+
const getStep: RawStep = {
|
|
1061
|
+
name: `verify extras did not persist`,
|
|
1062
|
+
source: {
|
|
1063
|
+
generator: "mass-assignment-probe",
|
|
1064
|
+
endpoint: `GET ${getEp.path}`,
|
|
1065
|
+
response_branch: "200",
|
|
1066
|
+
},
|
|
1067
|
+
GET: convertPath(getEp.path).replace(`{{${idParam}}}`, "{{created_id}}"),
|
|
1068
|
+
expect: {
|
|
1069
|
+
status: 200,
|
|
1070
|
+
body: extrasNotEqualAssertions(v),
|
|
1071
|
+
},
|
|
1072
|
+
};
|
|
1073
|
+
tests.push(getStep);
|
|
1074
|
+
}
|
|
1075
|
+
// cleanup
|
|
1076
|
+
const delEp = findDeleteCounterpart(ep, endpoints);
|
|
1077
|
+
if (delEp) {
|
|
1078
|
+
const idParam = findIdParam(delEp);
|
|
1079
|
+
const delStep: RawStep = {
|
|
1080
|
+
name: "cleanup",
|
|
1081
|
+
source: {
|
|
1082
|
+
generator: "mass-assignment-probe-cleanup",
|
|
1083
|
+
endpoint: `DELETE ${delEp.path}`,
|
|
1084
|
+
},
|
|
1085
|
+
always: true,
|
|
1086
|
+
DELETE: convertPath(delEp.path).replace(`{{${idParam}}}`, "{{created_id}}"),
|
|
1087
|
+
expect: { status: [200, 202, 204, 404] },
|
|
1088
|
+
} as RawStep & { always: boolean };
|
|
1089
|
+
tests.push(delStep);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
suites.push({
|
|
1093
|
+
name: `mass-assignment ${v.method} ${v.path}`,
|
|
1094
|
+
tags: ["probe-mass-assignment", v.severity === "ok" ? "rejected-baseline" : "ignored-baseline"],
|
|
1095
|
+
source: {
|
|
1096
|
+
type: "probe-suite",
|
|
1097
|
+
generator: "mass-assignment-probe",
|
|
1098
|
+
endpoint: `${v.method} ${v.path}`,
|
|
1099
|
+
},
|
|
1100
|
+
fileStem: `mass-assignment-${endpointStem(ep)}`,
|
|
1101
|
+
base_url: "{{base_url}}",
|
|
1102
|
+
...(suiteHeaders ? { headers: suiteHeaders } : {}),
|
|
1103
|
+
tests,
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
return suites;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function extrasNotEqualAssertions(v: EndpointVerdict): Record<string, Record<string, string>> {
|
|
1110
|
+
const out: Record<string, Record<string, string>> = {};
|
|
1111
|
+
for (const f of v.fields) {
|
|
1112
|
+
if (f.outcome === "ignored" || f.outcome === "echoed-overwritten" || f.outcome === "absent") {
|
|
1113
|
+
// Assert the suspicious value did NOT take effect. We check that the
|
|
1114
|
+
// observed value (from the live GET) still holds — the API is allowed
|
|
1115
|
+
// to echo a server default; what's forbidden is echoing OUR sentinel.
|
|
1116
|
+
const expectedNotEqual = JSON.stringify(f.injected);
|
|
1117
|
+
out[f.field] = { not_equals: expectedNotEqual };
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return out;
|
|
1121
|
+
}
|
|
1122
|
+
|