Files
satellite-provider/_docs/05_security/static_analysis_cycle8.md
T
Oleksandr Bezdieniezhnykh ac40a8b352 [AZ-808] [AZ-809] [AZ-810] [AZ-811] [AZ-812] Cycle 8 security audit
PASS_WITH_WARNINGS. Zero Critical / High.

New cycle-8 findings:
- F-AZ809-1 (Medium / A04 Insecure Design): unbounded
  geofences.polygons enables an authenticated DoS on
  POST /api/satellite/route. Cap candidate: 50 or 500.
- F-AZ810-1 (Low / A09): JsonException.Message echoed in
  UavUploadValidationFilter (new instance of cycle-7 F-AZ795-1
  pattern in a second code path).
- F-AZ810-2 (Low / Informational): UavTileMetadata.CapturedAt
  typed DateTime not DateTimeOffset; freshness window drifts in
  non-UTC dev environments. Zero impact in UTC-deployed prod.

Carry-overs (cycle 7): F-AZ795-1, F-AZ795-2, D-AZ795-1 still
open. Cycle 4 D2-cy4 still open (test-runtime Medium).

Cycle-8 architectural wins recorded: per-endpoint validation
reached 100% coverage; three approved validation paths
formalised; OSM wire-format normalisation under strict mode
(AZ-812); UAV-handler defence-in-depth retained.

Highest-priority cycle-9 follow-up: F-AZ809-1 polygon cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 15:17:31 +03:00

244 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Static Analysis (Cycle 8)
**Date**: 2026-05-23
**Mode**: Delta scan
**Scope**: Source code introduced or changed by AZ-808 + AZ-809 + AZ-810 + AZ-811 + AZ-812. Cycle-7 baseline (`static_analysis_cycle7.md`) remains authoritative for the AZ-794 / AZ-795 / AZ-796 surface; this scan only audits the cycle-8 delta.
**Files in scope** (40 changed source files; non-test detail):
- **API — 11 files**
- `SatelliteProvider.Api/Program.cs` (DI + endpoint wiring deltas — `WithValidation<RequestRegionRequest>`, `WithValidation<CreateRouteRequest>`, `WithValidation<GetTileByLatLonQuery>`, `AddEndpointFilter<UavUploadValidationFilter>`, `AddTransient<UavUploadValidationFilter>`, `RejectUnknownQueryParamsEndpointFilter` registration)
- `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs` (new — record with nullable bindings)
- `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` (new — AZ-808)
- `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs` (new — AZ-809)
- `SatelliteProvider.Api/Validators/GeofencePolygonValidator.cs` (new — AZ-809)
- `SatelliteProvider.Api/Validators/RoutePointValidator.cs` (new — AZ-809)
- `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs` (new — AZ-810)
- `SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs` (new — AZ-810)
- `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs` (new — AZ-810)
- `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs` (new — AZ-811)
- `SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs` (new — AZ-811)
- `SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs` (AZ-811 — added `lat`/`lon`/`zoom` description entries)
- **Common — 6 DTO files** (AZ-808/809/810/812 — `[JsonRequired]` annotations + AZ-812 rename)
- `SatelliteProvider.Common/DTO/RequestRegionRequest.cs`
- `SatelliteProvider.Common/DTO/CreateRouteRequest.cs`
- `SatelliteProvider.Common/DTO/GeofencePolygon.cs`
- `SatelliteProvider.Common/DTO/GeoPoint.cs`
- `SatelliteProvider.Common/DTO/RoutePoint.cs`
- `SatelliteProvider.Common/DTO/UavTileMetadata.cs`
- **Test code** (reviewed for fixture-only secrets + auth-bypass patterns) — 8 new validator unit tests + 4 new integration test files + several modified integration test helpers.
- **Shell scripts** (reviewed for embedded secrets + unsafe sequences) — 4 new probe scripts (`probe_latlon_validation.sh`, `probe_region_validation.sh`, `probe_route_validation.sh`, `probe_upload_validation.sh`) + 1 modified perf script (`run-performance-tests.sh`, wire-rename diff only).
**Method**: Read each new file end-to-end; targeted `Grep` for injection / hardcoded-credential / unsafe-API patterns (`password|secret|api.?key|bearer|token` over `SatelliteProvider.Api/Validators` returned **0 matches**); diff-review of every DTO change vs. its cycle-7 baseline; trace each `[JsonRequired]` chain through to its FluentValidation rule.
## Findings
### F-AZ809-1 — Unbounded `geofences.polygons` collection enables an authenticated DoS via `CreateRouteRequest` (Medium / A04 — Insecure Design)
- **Location**: `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs:72-82` (`When(req => req.Geofences is not null, () => …)`).
- **Description**: The `CreateRouteRequestValidator` chains a `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator())` block but only enforces `NotEmpty` on the collection. There is **no upper bound on `Geofences.Polygons.Count`**. The parent collection `Points` IS capped (`MaxPoints = 500` at line 27) — the polygons collection is the only nested list-bearing field on this endpoint without a cap.
- **Code**:
```csharp
When(req => req.Geofences is not null, () =>
{
RuleFor(req => req.Geofences!.Polygons)
.NotNull().WithMessage("`geofences.polygons` is required when `geofences` is present.")
.NotEmpty().WithMessage("`geofences.polygons` must contain at least 1 polygon when `geofences` is present.")
.OverridePropertyName("geofences.polygons");
RuleForEach(req => req.Geofences!.Polygons)
.SetValidator(new GeofencePolygonValidator())
.OverridePropertyName("geofences.polygons");
});
```
- **Exploit math** (worst-case envelope under current configuration):
- `KestrelServerOptions.Limits.MaxRequestBodySize = uavBatchBodyLimit = MaxBatchSize × MaxBytes = 100 × 5 MiB = 500 MiB` (set globally in `Program.cs:41-43` for the UAV endpoint; the same limit applies to every endpoint by default because Kestrel exposes a per-server, not per-endpoint, default).
- Minimum JSON polygon footprint: `{"northWest":{"lat":1.0,"lon":2.0},"southEast":{"lat":3.0,"lon":4.0}}` ≈ 90 bytes including the comma separator.
- Theoretical maximum polygon count in a single 500 MiB request: `500 MiB / 90 bytes ≈ 5.8 million polygons`.
- With invalid polygons (e.g. `lat` out of range), `GeofencePolygonValidator` adds 2 corner-range `ValidationFailure` objects + 1 cross-field NW-of-SE failure = ~3 failures per polygon. Worst-case allocation: ~17 million `ValidationFailure` instances + the matching error-map keys before the filter formats the `ValidationProblemDetails` body.
- **Impact**: Medium. A single authenticated authorized request can saturate the LOH (large object heap) and trigger a full GC pass on the API process. The endpoint is `RequireAuthorization()`-gated (line 251 in `Program.cs`) so anonymous callers cannot reach the validator — the attacker must hold a valid JWT. But once authenticated, a single malformed request degrades the service for every other tenant operator until GC reclaims the heap. Repeatable. No data leak, no privilege escalation; pure availability impact.
- **OWASP mapping**: A04 — Insecure Design (missing rate/size limit on a collection-bearing input field). Adjacent to A05 (Security Misconfiguration — the global Kestrel limit was set for the UAV endpoint in cycle 5 but applies to every endpoint).
- **Remediation**: Add `Must(p => p is null || p.Count <= MaxPolygons)` with a defensible upper bound. Reasonable cap candidates:
- `MaxPolygons = 50` (consistent with the historical use case — a route is unlikely to need more than a handful of geofence rectangles for AOI restriction).
- `MaxPolygons = MaxPoints = 500` (consistent with the sibling `Points` cap on the same DTO).
- The matching `cumulative_review_batches_01-04_cycle8_report.md` already enumerates `points.Count <= 500` (route), `items.Count <= 100` (UAV upload), `coords.Count <= 1000` (tile inventory, cycle 7) as bounded — the missing entry for `geofences.polygons` is the one inconsistency.
- **Status**: open — file as a cycle-9 follow-up under AZ-809 (or a sibling child ticket). Not release-blocking for cycle 8 itself: exploitation requires an authenticated caller with a valid GPS-permission-less JWT — the same threat model already had access to the cycle-7-pre-existing `inventory.Tiles.Count` cap, so the marginal new exposure is moderate, not catastrophic. But it MUST be fixed before any untrusted-tenant exposure is added to the route endpoint.
### F-AZ810-1 — `JsonException.Message` propagated to client in `UavUploadValidationFilter` (Low / A09 — Information Disclosure)
- **Location**: `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs:75-84` (the `catch (JsonException ex)` block of the metadata parse).
- **Code**:
```csharp
catch (JsonException ex)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[MetadataField] = new[] { $"`metadata` could not be parsed as JSON: {ex.Message}" },
});
}
```
- **Description**: The cycle-8 `UavUploadValidationFilter` echoes the raw `JsonException.Message` directly to the client as the value of `errors["metadata"]`. This is the **same information-disclosure pattern as cycle-7 F-AZ795-1** (in `GlobalExceptionHandler.cs:108-117`), introduced in a *second* code path that bypasses the global exception handler (the filter intercepts and returns `Results.ValidationProblem(...)` directly). Cycle-7 F-AZ795-1 remains open; cycle 8 adds a second instance of the same pattern that would also need to be sanitised by the same remediation.
- **Impact**: Low. Same severity classification as F-AZ795-1. Auth-gated (`.RequireAuthorization(SatellitePermissions.UavUploadPolicy)` at `Program.cs:238`) — only callers holding a valid JWT *with* the `GPS` permission claim can reach the filter and trigger this path. The leaked content (type names, parse positions, `System.Text.Json` fingerprint) is already inferable from the OpenAPI spec; the new path narrows the attack surface for an authenticated GPS-permissioned operator but does not expose secrets, PII, or pivot vectors.
- **Remediation**: Sanitise the response message to a generic string (e.g. `"`metadata` could not be parsed as JSON. See the server log for details."`) while continuing to log the raw `ex.Message` server-side under the request's `correlationId`. Best done in tandem with F-AZ795-1's remediation since both paths surface the same exception class through the same response shape. Add an integration-test assertion in `UavUploadValidationTests` that no `System.*` substring appears in the response body's `errors[]` value, mirroring the cycle-7 gap noted in `static_analysis_cycle7.md` § F-AZ795-1 *Test coverage gap*.
- **Status**: open — file as a cycle-9 follow-up child of the same F-AZ795-1 ticket so both call sites get the sanitiser at once.
### F-AZ810-2 — `UavTileMetadata.CapturedAt` typed `DateTime` not `DateTimeOffset` (Low / Informational — Time-handling correctness)
- **Location**: `SatelliteProvider.Common/DTO/UavTileMetadata.cs:30` + `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs:52-60`.
- **Code**:
```csharp
// UavTileMetadata.cs:30
[JsonRequired]
public DateTime CapturedAt { get; init; }
// UavTileMetadataValidator.cs:52-60
RuleFor(m => m.CapturedAt)
.Must(capturedAt => capturedAt.ToUniversalTime() <= tp.GetUtcNow().UtcDateTime.AddSeconds(futureSkewSeconds))
.WithMessage($"`capturedAt` must be within {futureSkewSeconds}s of the current time (no future-dated tiles).")
.Must(capturedAt => capturedAt.ToUniversalTime() >= tp.GetUtcNow().UtcDateTime.AddDays(-maxAgeDays))
.WithMessage($"`capturedAt` must be within the last {maxAgeDays} days.");
```
- **Description**: When `System.Text.Json` deserializes an ISO-8601 string into a `DateTime`, the resulting `DateTime.Kind` depends on the string's offset suffix:
- `"2026-05-22T12:00:00Z"` → `Kind = Utc`.
- `"2026-05-22T12:00:00+03:00"` → `Kind = Local` after normalization to local time.
- `"2026-05-22T12:00:00"` (no suffix) → `Kind = Unspecified`.
For `Kind = Unspecified`, `DateTime.ToUniversalTime()` treats the value as **local time**, which means the freshness comparison drifts by the host's timezone offset. In a UTC-deployed prod container (Linux `TZ=UTC`), the local offset is zero and there is no observable impact. In a developer's local environment (e.g. `TZ=Europe/Kyiv` = UTC+02:00 or +03:00), a `capturedAt` value of `"2026-05-22T12:00:00"` would be treated as `2026-05-22T10:00:00Z` (in summer), shifting the freshness window by the offset.
- **Impact**: Low / Informational. Zero observable impact in the supported deployment configuration (Docker containers run in UTC by default; the cycle-2 `docker-compose.yml` does not override `TZ`). The freshness rule could be loosely-bounded by the same offset (in either direction) in a dev environment with a non-UTC host TZ. No security exploit: an attacker cannot force the server's TZ; they can only submit `capturedAt` values, and the server's UTC-deployed configuration treats those deterministically.
- **OWASP mapping**: A09 — Security Logging and Monitoring Failures (adjacent — a stale freshness window could mask an out-of-band attack on the upload endpoint).
- **Remediation** (two options):
1. **Strict**: Change the DTO type to `DateTimeOffset` so the parsed value always carries an explicit offset, and add a JSON converter that rejects offset-less ISO-8601 strings at deserialization (`JsonConverterAttribute` pointing at a custom converter that checks for the `Z` / `+HH:MM` suffix and throws `JsonException` if missing — surfaces as a 400 via `GlobalExceptionHandler`).
2. **Lenient**: Add a FluentValidation rule that rejects `DateTime.Kind == DateTimeKind.Unspecified` so the caller must supply a tz-aware ISO-8601 string. Keeps the DTO shape; doesn't break clients that already send `"Z"` suffix.
Option 2 is the minimum behaviour-preserving fix. Option 1 is correct for a v2.0 of `uav-tile-upload.md`.
- **Status**: open — file as a Low cycle-9 follow-up. Not release-blocking for cycle 8 because every documented client in `uav-tile-upload.md` example payloads and every integration-test fixture sends the `Z` suffix.
## Pattern Sweep — Cycle-8 Delta
### Injection (SQL / Command / XSS / Template)
| Pattern | Result |
|---------|--------|
| `string.Format`, interpolation `$"..."`, or concatenation feeding into a Dapper / Npgsql command in the new files | None. The 9 new files in `SatelliteProvider.Api/Validators/` and `SatelliteProvider.Api/DTOs/` do not touch the data layer. |
| `Process.Start`, `subprocess`, `eval`, `Invoke-Expression`, raw `system()` | None. |
| User-input echoed into HTML (XSS) | None. The API returns JSON only. The cycle-8 `RejectUnknownQueryParamsEndpointFilter` echoes the offending parameter name back as a JSON string value — `System.Text.Json` performs canonical JSON escaping of control characters; no HTML injection vector. |
| Template injection (Razor / Liquid / etc.) | None. No templating in the new files. |
### Authentication & Authorization
| Pattern | Result |
|---------|--------|
| Hardcoded credentials, secrets, API keys | `Grep -i 'password|secret|api.?key|bearer|token'` against `SatelliteProvider.Api/Validators/` returned **0 matches**. The new integration test files (`CreateRouteValidationTests`, `GetTileByLatLonValidationTests`, `RegionRequestValidationTests`, `UavUploadValidationTests`, `RegionFieldRenameTests`) reuse the runner-side `JwtTestHelpers.MintAuthenticated(...)` helper for token attachment; no hardcoded secret material. |
| Missing `.RequireAuthorization()` on a public endpoint | Every cycle-8 endpoint binding in `Program.cs` retains `.RequireAuthorization()`: `RequestRegion` (line 251), `CreateRoute` (267), `GetTileByLatLon` (213), `UploadUavTileBatch` (238 — additionally requires the `GPS` permission claim via `SatellitePermissions.UavUploadPolicy`). |
| Validator running before auth check | No. ASP.NET Core endpoint filters run AFTER the routing layer's authorization middleware (`app.UseAuthorization()` at `Program.cs:206`). All four cycle-8 filters (`ValidationEndpointFilter<RequestRegionRequest>`, `ValidationEndpointFilter<CreateRouteRequest>`, `RejectUnknownQueryParamsEndpointFilter` + `ValidationEndpointFilter<GetTileByLatLonQuery>`, `UavUploadValidationFilter`) cannot run for anonymous callers — the 401 short-circuit fires first. |
| Permission/policy regression | `RequiresGpsPermission` on `/api/satellite/upload` retained. `RejectUnknownQueryParamsEndpointFilter` does NOT call `RequireAuthorization()` itself — it relies on the endpoint chain's existing `.RequireAuthorization()` (line 213). Correct. |
### Cryptographic Failures
| Pattern | Result |
|---------|--------|
| Weak hash (MD5 / SHA1) used for passwords or signatures | None in cycle 8. Pre-existing UUIDv5 SHA-1 surface (`Uuidv5.Create`) is untouched. |
| New crypto material introduced | None. Cycle 8 has no cryptography. |
| Plaintext transmission | API listens on `https://+:8080` with ALPN (cycle-6 baseline, unchanged). |
### Data Exposure
| Pattern | Result |
|---------|--------|
| Sensitive data in logs | The new validators and filters do NOT log the request body. `GlobalExceptionHandler` 5xx branch logs `Method`, `Path`, `correlationId`, and the exception object (pre-existing). Cycle 8 doesn't widen this. |
| Sensitive fields in API responses | F-AZ810-1 (`JsonException.Message` echo) above is the only new echo-back path. No password hashes, no PII (the four endpoints carry only metadata: geospatial coords, integer IDs, timestamps). The `RejectUnknownQueryParamsEndpointFilter` echoes the offending parameter NAME (not value) back — and the list of allowed param names IS already in the OpenAPI spec, so no new fingerprinting surface. |
| Debug endpoints in production | Swagger is gated by `app.Environment.IsDevelopment()` (unchanged). |
| Secrets in version control | `.env*` files are gitignored. The 4 new probe scripts (`probe_*_validation.sh`) all use `${JWT:?…}`-style env-var reads with explicit "set JWT env var" error guards; no embedded credentials. |
### Insecure Deserialization
| Pattern | Result |
|---------|--------|
| `Pickle` / `BinaryFormatter` / unsafe XML / `JsonConvert.DeserializeObject<T>` with `TypeNameHandling.All` | None in cycle 8. `UavUploadValidationFilter.cs:73` uses `JsonSerializer.Deserialize<UavTileBatchMetadataPayload>(...)` with the global `JsonSerializerOptions` from `IOptions<JsonOptions>` — which has `UnmappedMemberHandling.Disallow` set by cycle-7 `ConfigureHttpJsonOptions`. ✓ |
| Unbounded collection sizes | **F-AZ809-1 above** — `geofences.polygons` lacks a cap. Other cycle-8 list fields ARE capped: `Points.Count <= 500` (`CreateRouteRequestValidator:60-61`), `Items.Count <= MaxBatchSize=100` (`UavTileBatchMetadataPayloadValidator:27-28`). Cycle-7 `Tiles.Count <= 5000` / `LocationHashes.Count <= 5000` unchanged. The framework-level `MaxRequestBodySize` bound is in force for all paths — but it's set at 500 MiB to accommodate the UAV upload endpoint, which makes per-endpoint count caps the primary defence. |
### Integer Overflow / Bounded Math
The cycle-8 validators do not perform any integer arithmetic beyond `Math.Max(options.ValueLengthLimit, uavQuality.MaxBatchSize * 512)` (`Program.cs:61` — `100 * 512 = 51_200`, no overflow possible). No left-shifts, no power-of-two computations on caller-controlled values. ✓
### ReDoS / Algorithmic Complexity
Cycle-8 validation rules are all O(1) per entry × N entries with bounded N (with the F-AZ809-1 exception above):
| Validator | Per-entry cost | Total cost (worst case) |
|-----------|----------------|-------------------------|
| `RegionRequestValidator` | O(1) — 5 range checks on scalar fields | O(1) |
| `CreateRouteRequestValidator` (root + cross-field invariant) | O(1) base + O(N points) + O(P polygons unbounded) | **O(N × P)** with unbounded P — see F-AZ809-1 |
| `RoutePointValidator` (via `RuleForEach`) | O(1) — 2 range checks | O(500) bounded by parent `MaxPoints` |
| `GeofencePolygonValidator` (via `RuleForEach`) | O(1) — 2 corner null + 4 range + 2 cross-field | O(P) where P is unbounded — see F-AZ809-1 |
| `UavTileMetadataValidator` (via `RuleForEach`) | O(1) — 4 range checks + 2 freshness | O(100) bounded by `MaxBatchSize` |
| `UavTileBatchMetadataPayloadValidator` (root) | O(1) | O(1) |
| `GetTileByLatLonQueryValidator` | O(1) — 3 cascaded NotNull + range | O(1) |
| `RejectUnknownQueryParamsEndpointFilter` | O(K log K) where K = caller's query-param count | O(K) bounded by Kestrel's per-request header/query limits (default 32 KB of query string) |
No regex, no recursion, no nested loops with caller-controlled bounds (except F-AZ809-1).
## `UavUploadValidationFilter` Defence-in-Depth Verification
The filter intercepts the primary multipart-upload code path. The pre-existing `UavTileUploadHandler.HandleAsync` retains the SAME envelope checks (`SatelliteProvider.Services.TileDownloader/UavTileUploadHandler.cs:64-141`) — confirming the filter is **defence-in-depth on top of** the handler's checks, not a replacement:
| Check | `UavUploadValidationFilter` (filter layer) | `UavTileUploadHandler` (handler layer — defence-in-depth) |
|-------|--------------------------------------------|-----------------------------------------------------------|
| `metadata` form field present + non-empty | Line 62-68 | Line 68-71 (`string.IsNullOrWhiteSpace`) |
| `metadata` parses as JSON | Line 71-84 (catches `JsonException`) | Line 74-81 (catches `JsonException`) — **same pattern, same `ex.Message` echo (pre-existing parallel to F-AZ810-1)** |
| `metadata.items` non-null + non-empty | Line 86-92 (`payload is null`) + `UavTileBatchMetadataPayloadValidator:25-26` (`NotNull`/`NotEmpty`) | Line 83-86 (`Items.Count == 0`) |
| `metadata.items.Count == files.Count` | Line 105-118 | Line 88-91 |
| `metadata.items.Count <= MaxBatchSize` | `UavTileBatchMetadataPayloadValidator:27-28` | Line 93-96 |
The handler's envelope checks are still reachable by any caller invoking `IUavTileUploadHandler.HandleAsync(...)` directly (e.g. unit tests, or a future programmatic flow). Cycle 8 left them intact — correct ✓. The duplicated `JsonException.Message` echo in the handler (line 80) is a pre-existing-class instance of the same Low finding as F-AZ810-1; the remediation MUST sanitise both call sites in lock-step or the defence-in-depth path will continue to leak.
## Pre-existing-Surface Inconsistency Noted (NOT a cycle-8 regression)
`Program.cs:387-399` — the `CreateRoute` handler still wraps its body in `try { ... } catch (ArgumentException ex) { return Results.BadRequest(new { error = ex.Message }); }`. This is a pre-cycle-8 inconsistency: the response shape is the unstructured `{error: string}` object, NOT the `ValidationProblemDetails` shape mandated by `error-shape.md` v1.0.0. Cycle 8 added `WithValidation<CreateRouteRequest>` at line 268 which intercepts most `ArgumentException` cases — so the `catch` block is now largely dead code, but it WILL fire if `IRouteService.CreateRouteAsync` itself throws `ArgumentException` (e.g. a future internal validation rule). When it fires, the response leaks `ex.Message` in a non-conformant shape.
- **Severity**: Low / Informational. Pre-existing inconsistency; cycle 8 reduced its reachable surface but did not eliminate it.
- **Remediation**: Drop the `try/catch` block now that the validator covers the same cases at the filter layer; let unexpected `ArgumentException` propagate to `GlobalExceptionHandler` for uniform 400 + sanitised `ValidationProblemDetails` (or 500 with a `correlationId` if the cause is internal). One-line edit; recommend folding into the same cycle-9 follow-up as F-AZ795-1 / F-AZ810-1 since all three converge on the same error-shape consistency improvement.
## Test Code Review
### `SatelliteProvider.Tests/Validators/*ValidatorTests.cs` (8 new files)
- Pure CPU; no I/O, no network, no file system, no DB.
- All inputs constructed inline. No fixture-file reads, no hardcoded JWTs.
- All test files share the cycle-7 `ValidatorTestModuleInitializer.cs` (`[ModuleInitializer]` → `GlobalValidatorConfig.ApplyOnce()` at test-assembly load) — single source of truth for the camelCase property resolver. No test drift risk.
- ✓ No findings.
### `SatelliteProvider.IntegrationTests/{CreateRouteValidationTests,GetTileByLatLonValidationTests,RegionFieldRenameTests,RegionRequestValidationTests,UavUploadValidationTests}.cs` + modified helpers
- All five new files use the runner-side `JwtTestHelpers.MintAuthenticated(...)` to attach a Bearer token, mirroring cycle 7's pattern. No hardcoded secret material; the token's signing secret comes from the `JWT_SECRET` env var (32+ bytes, dev-only in `docker-compose.tests.yml`).
- Test inputs use raw `HttpRequestMessage` with hand-built JSON strings — exercises the exact wire shape the validator + deserializer see in production. Each file covers the cycle-8 negative cases for its endpoint.
- `ProblemDetailsAssertions.cs` was extended (additive) — no removed assertions. The cycle-7 `error-shape.md` Inv-2 / Inv-4 contract assertions all still apply.
- `UavUploadTests.cs` + `UavUploadValidationTests.cs` both clamp their fixture-generated coordinates into non-overlapping OSM-valid sub-ranges (cycle-8 hotfix commit `b763da3` per `_docs/03_implementation/batch_04_cycle8_report.md` § AC-9) — a test-data correctness fix, not a security finding.
- ✓ No findings.
### `scripts/probe_{latlon,region,route,upload}_validation.sh` (4 new files)
- All four scripts begin with `set -euo pipefail` — fail-fast on undefined vars, broken pipes, command failures.
- All four read `${API_URL:-https://localhost:8080}` (default to localhost) and `${JWT:-}` with an explicit "ERROR: set JWT env var" guard that `exit 2`s if `$JWT` is empty. No embedded credentials.
- `curl -k` is used (justified — the dev cert is self-signed; the scripts target localhost in dev/test only — documented in each script's header).
- ✓ No findings — same posture as cycle-7's `probe_inventory_validation.sh`.
### `scripts/run-performance-tests.sh` (modified, wire-rename only)
- Diff is exclusively the AZ-812 wire-format rename: `?Latitude=…&Longitude=…&ZoomLevel=…` → `?lat=…&lon=…&zoom=…` and `{"latitude":…,"longitude":…}` → `{"lat":…,"lon":…}` across PT-01 through PT-08 invocations. No new code, no new credentials, no shell-injection surface introduced.
- ✓ No findings.
## Cycle-7 Carry-overs (still open at cycle-8 tip)
| Finding | Source cycle | Carry-over status | Cycle-8 interaction |
|---------|--------------|-------------------|---------------------|
| **F-AZ795-1** — `JsonException.Message` propagated in `GlobalExceptionHandler.cs:108-117` | cycle 7 | open | Unchanged. Cycle 8 introduced **F-AZ810-1** which is the SAME pattern in a NEW code path; both need lock-step sanitiser remediation. |
| **F-AZ795-2** — Generic `BadHttpRequestException.Message` propagated in `GlobalExceptionHandler.cs:88-93` | cycle 7 | open | Unchanged. Cycle 8 routes more endpoint failures through `WithValidation<T>()` which builds `ValidationProblemDetails` directly — reducing the practical reachability of this path on the 4 newly-validated endpoints, but not eliminating it (model-binding failures pre-validator can still hit the path). |
## Verdict (Phase 2)
**PASS_WITH_WARNINGS** — 1 Medium (F-AZ809-1) + 2 new Lows (F-AZ810-1, F-AZ810-2) + 2 cycle-7 Low carry-overs (F-AZ795-1, F-AZ795-2). No Critical or High findings.
Per the skill's verdict-logic, Medium severity yields PASS_WITH_WARNINGS — not FAIL (FAIL is reserved for Critical or High). F-AZ809-1 is exploitable only by an authenticated tenant operator with a valid JWT (the route endpoint requires `RequireAuthorization()` without a permission scope, so any tenant with API access reaches it). The Medium is contained within the cycle-8 threat model — every cycle-8 endpoint is auth-gated — but should be the **highest-priority cycle-9 follow-up**: pre-existing-class Mediums tend to be the entry vector for higher-severity issues when adjacent threat-model assumptions shift (e.g. if a future feature exposes the route endpoint to an untrusted-tenant audience).
The 2 new Lows + 2 carry-over Lows are not release-blockers in isolation; they are filed for the next cycle's follow-up batch.