# 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) — **RESOLVED in cycle 8 (Step-14 follow-up)**

- **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**: **resolved in cycle 8 (Step-14 follow-up)**. Cap added: `MaxPolygons = 50` constant + `.Must(polygons => polygons is null || polygons.Count <= MaxPolygons).WithMessage("…must contain at most 50 polygons.")` on the `geofences.polygons` rule. Cap chosen at 50 because geofences are AOI-restriction rectangles — realistic use is 1-10 polygons per route, 50 gives 5x headroom while bounding the validator's worst-case allocation to ~150 `ValidationFailure` objects. Tests added: `CreateRouteRequestValidatorTests.Validate_GeofencePolygonsTooMany_FailsCountRule` (unit) + `CreateRouteValidationTests.GeofencePolygonsTooMany_Returns400` (integration). Contract bumped to `route-creation.md` v1.0.1 (patch — tightens an existing range; new Inv-10 + test case). See `security_report_cycle8.md` § F-AZ809-1 § Resolution for the full disposition.

### 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** (post-follow-up) — 0 Medium open (F-AZ809-1 resolved in cycle 8) + 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 — but cycle 8 resolved its only Medium in-cycle via the Step-14 follow-up commit before any production exposure. 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.
