# Phase 2 — Static Analysis (SAST)

**Date**: 2026-05-11
**Scope**: All `*.cs` files in production projects (Api, Common, DataAccess, Services.*) plus Tests for false-positive triage. Configuration files (`appsettings*.json`, `docker-compose*.yml`, `Dockerfile`, `.env`).
**Method**: Pattern-based grep + targeted file review.

## Patterns checked

| Category | Pattern(s) | Verdict |
|----------|-----------|---------|
| SQL injection | `$"SELECT…"`, `+ "WHERE"`, raw `CommandText`, manual SQL string assembly | **Clean** |
| Command/process injection | `Process.Start`, `ProcessStartInfo`, `cmd.exe`, `/bin/sh`, `UseShellExecute`, `eval`-equivalent | **Clean** |
| XSS | unsanitized user input flowed to HTML or `Response.Write` | **N/A** — JSON-only API, no HTML rendering |
| Template injection | Razor / scriban / handlebars on user input | **N/A** — none used |
| Hardcoded credentials | `password = "…"`, `secret = "…"`, `token = "…"`, `apikey = "…"` in source | See findings S1, S2 |
| Weak crypto | MD5/SHA1 for passwords, `RNGCryptoServiceProvider` (deprecated), hardcoded keys | **N/A** — no password storage, no crypto code in app |
| Insecure deserialization | `BinaryFormatter`, `pickle`, untrusted JSON with type-name handling | **Clean** — `System.Text.Json` with default settings; `Newtonsoft.Json` 13.0.4 used only for outbound serialization to Google session-creation endpoint (line `GoogleMapsDownloaderV2.cs`), no deserialization of untrusted inbound JSON |
| Path traversal | user input flowed into `File.Open`, `Path.Combine` | **Clean** — file paths are computed server-side from validated tile coordinates; no user-supplied path component reaches the filesystem |
| Sensitive data in logs | passwords, API keys, tokens, PII in log statements | **Clean** — `GlobalExceptionHandler.cs` logs only `Method`, `Path`, `correlationId`; client gets a generic 500 + correlationId. `CorsConfigurationValidator` warning (`PermissiveDefaultWarning`) does not include secrets. There is a deliberate test fixture `GlobalExceptionHandlerTests.cs:23` that uses `"Connection string Host=secret-db;Password=hunter2 failed at line 42"` to verify the handler does NOT echo exception messages back — this is a positive control, not a finding |
| Verbose error responses | stack traces or internal details returned to clients | **Clean** — `GlobalExceptionHandler` returns RFC 7807 ProblemDetails with `Detail = "An unexpected error occurred. Use the correlationId to look up the server log entry."` |
| Input validation | numeric ranges, geo coordinates, enum-like strings | See finding S3 |
| Hardcoded credentials (cycle 2 delta) | `Jwt:Secret` value in `appsettings*.json` | `appsettings.Development.json` ships a clearly-tagged DEV-ONLY placeholder; `appsettings.json` ships `""`. `JWT_SECRET` env-var overrides both. See cycle-2 finding F-AUTH-1. |
| Authentication / authorization (cycle 2 delta) | endpoint-level Authorize, custom requirement handlers, claim parsing | `Program.cs` applies `.RequireAuthorization()` on every existing endpoint and the GPS-permission policy on the new `/api/satellite/upload`. `PermissionsAuthorizationHandler` uses `string.Equals(..., Ordinal)` — no substring / case-confusion bypass. See cycle-2 findings F-AUTH-2 .. F-AUTH-4. |
| Multipart binary input (cycle 2 delta) | uploaded bytes flowing into image decode / file write | `UavTileQualityGate` runs magic-byte check before ImageSharp, wraps decode in scoped `try/catch` for `UnknownImageFormatException` / `InvalidImageContentException`. File path is built from integer coords only via `UavTileUploadHandler.BuildUavTileFilePath`. See cycle-2 finding F-UAV-1. |
| Untrusted JSON via claims (cycle 2 delta) | `JsonDocument.Parse(claim.Value)` in `PermissionsAuthorizationHandler` | Tokens are signature-validated *before* the handler runs, so the JSON parsed here is already framework-validated bytes from a verified token. Token size is bounded by Kestrel header limits. See cycle-2 finding F-UAV-2. |

## Findings

### S1 — Default DB password committed in `appsettings.json` (Medium)

- **Location**: `SatelliteProvider.Api/appsettings.json:24`
- **Vulnerable code**:
  ```json
  "DefaultConnection": "Host=localhost;Database=satelliteprovider;Username=postgres;Password=postgres"
  ```
- **Description**: The default (non-Development) appsettings file ships with a weak, well-known password (`postgres/postgres`). In production this string is overridden by `ConnectionStrings__DefaultConnection` in `docker-compose.yml`/env, but the file itself becomes the fallback if env-var injection ever fails or is misconfigured (silent connect-as-default behaviour).
- **Impact**: If a deployment misconfiguration drops the env override, the app silently falls back to attempting `postgres:postgres@localhost`. On a developer workstation this connects to the local Postgres container with full superuser; in production it would fail loudly only if the prod DB has different creds. Combined with finding S2 below (matching weak creds in compose file), this normalises a credential pattern that real production deployments may inherit.
- **Remediation**:
  - Replace the default value with a deliberately-invalid placeholder such as `Host=__set-via-env__;Database=__;Username=__;Password=__` so a misconfiguration fails fast at startup instead of silently falling through.
  - OR remove the `ConnectionStrings:DefaultConnection` key from `appsettings.json` entirely and require the env var; `Program.cs` line 23–24 already throws when missing — keep that behaviour.

### S2 — Weak Postgres credentials in `docker-compose.yml` (Medium, dev-only as written)

- **Location**: `docker-compose.yml:6-7, 30`
- **Vulnerable code**:
  ```yaml
  POSTGRES_USER: postgres
  POSTGRES_PASSWORD: postgres
  …
  - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres
  ```
- **Description**: Same `postgres/postgres` credentials as S1. The compose file is labelled `Development` (`ASPNETCORE_ENVIRONMENT=Development`), so this is contained — but the file is the only compose artifact in the repo, which means anyone running `docker-compose up` on a network-reachable host immediately exposes a Postgres-with-default-creds.
- **Impact**: Postgres on `0.0.0.0:5432` (port `"5432:5432"` mapping) with `postgres/postgres` is one of the most-scanned credential pairs on the public internet. If a developer runs this on a non-laptop host (cloud VM, shared lab, etc.) the DB is trivially compromised within minutes.
- **Remediation**:
  - Bind `5432` to `127.0.0.1:5432` rather than `0.0.0.0:5432` so the host firewall isn't the only protection. (Replace `"5432:5432"` with `"127.0.0.1:5432:5432"`.)
  - Source `POSTGRES_USER` / `POSTGRES_PASSWORD` from the same `.env` file that already supplies `GOOGLE_MAPS_API_KEY` (line 31 already shows the pattern). Provide an `.env.example` with placeholder values and document the required vars in the README.
  - The deploy/observability docs at `_docs/02_document/deployment/` already describe a secret-manager strategy for staging/prod — fold the same pattern into the dev compose.

### S3 — Latitude / longitude inputs not range-validated at the API boundary (Low)

- **Locations**:
  - `SatelliteProvider.Api/Program.cs:169` — `GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, …)`
  - `SatelliteProvider.Api/Program.cs:207` — `RequestRegion` validates `SizeMeters` only; `request.Latitude` / `request.Longitude` are unchecked
  - `SatelliteProvider.Api/Program.cs:237` — `CreateRoute` delegates to `RouteService` which validates names but does not range-check waypoint coordinates
- **Description**: `Latitude`, `Longitude`, and (for region requests) the implicit `MaxRoutePointSpacingMeters` boundary are accepted without enforcing valid geographic ranges (`-90 ≤ lat ≤ 90`, `-180 ≤ lon ≤ 180`). `ZoomLevel` IS validated downstream by `GoogleMapsDownloaderV2` against `MapConfig.AllowedZoomLevels` — so it is fine.
- **Impact**:
  - Garbage inputs (e.g. `lat=999`) propagate through `GeoUtils.WorldToTilePos` and the slippy-map math, eventually producing nonsensical tile coordinates that are persisted to `tiles` and `regions`. This is a **data-quality** issue, not a code-execution issue.
  - No DoS amplification: every tile-download endpoint already enforces zoom against `AllowedZoomLevels`, so an attacker cannot use lat/lon abuse to multiply outbound Google Maps traffic beyond what zoom already bounds.
- **Remediation**: Add explicit guard clauses at the API boundary (matches the existing `SizeMeters` 100-10000 pattern):
  ```csharp
  if (Latitude < -90 || Latitude > 90)  return Results.BadRequest(new { error = "Latitude must be between -90 and 90" });
  if (Longitude < -180 || Longitude > 180) return Results.BadRequest(new { error = "Longitude must be between -180 and 180" });
  ```
  Apply uniformly to `GetTileByLatLon`, `RequestRegion`, and to each waypoint inside `CreateRoute`.

### S4 — `.env` file on developer filesystem contains an apparently real Google Maps API key (Medium — exposure depends on key reach)

- **Location**: `.env` (workspace root, **not** tracked — confirmed via `git ls-files` and `.gitignore:10`)
- **Description**: The local `.env` contains a 39-character `AIzaSy…` value matching the Google Maps API key format. The file is correctly excluded from git (line 10 of `.gitignore`) and `git log -- .env` returns no history, so the key was never committed to this repository.
- **Impact**: No repository exposure. **However**:
  - If the same key is shared across developers via Slack / email / other repos, it has likely already leaked elsewhere.
  - There is no `.env.example` template in the repo, which means new contributors typically request the real key via insecure channels rather than generating a fresh one.
  - The key has no per-call attribution; abuse cannot be traced back to a specific developer.
- **Remediation**:
  - **Rotate the key in the Google Cloud console** (out of scope for this audit — the key value is intentionally not echoed into this report).
  - Add `.env.example` to the repo with `GOOGLE_MAPS_API_KEY=replace-with-your-own-key-from-cloud-console` and reference it in the README setup section.
  - Configure Google Cloud key restrictions: HTTP referrer allowlist (for browser keys) or IP allowlist (for server keys), and per-API quotas. Optional: per-developer keys.

---

## Cycle 2 Delta Findings (AZ-487 + AZ-488)

### F-AUTH-1 — Dev JWT secret is committed to `appsettings.Development.json` (Low — accepted by design)

- **Location**: `SatelliteProvider.Api/appsettings.Development.json:14` — `"Secret": "DEV-ONLY-DO-NOT-USE-IN-PROD-replace-with-real-secret-via-JWT_SECRET-env-var"`.
- **Description**: A 73-byte placeholder labelled DEV-ONLY ships in the repo. The value is clearly tagged; `ResolveSecretOrThrow` in `AuthenticationServiceCollectionExtensions.cs:43` reads `JWT_SECRET` from the environment first and only falls back to config when it is unset, so a production deploy with `JWT_SECRET` set overrides it.
- **Impact**: Cosmetic only — the placeholder is not a usable production secret (it is published on every git clone and would be rejected by any token verifier already in the wild). A careless operator who copies the file verbatim into prod and forgets to set `JWT_SECRET` would still pass the ≥32-byte gate, so the secret would *work* locally — that is the dependency to monitor.
- **Disposition**: Accept. Mitigation: the `DEV-ONLY-DO-NOT-USE-IN-PROD` prefix is the operator-readable warning; the deploy skill must verify `JWT_SECRET` is set before promotion.

### F-AUTH-2 — JWT issuer / audience are not validated (Medium — by design, until admin team defines values)

- **Location**: `SatelliteProvider.Api/Authentication/AuthenticationServiceCollectionExtensions.cs:31-32` — `ValidateIssuer = false`, `ValidateAudience = false`.
- **Description**: Per the suite contract `suite/_docs/10_auth.md`, expected `iss` / `aud` values are not yet defined. The validator therefore accepts any HS256 token signed with the correct shared secret — including tokens minted by other services in the suite that share the secret. This is a horizontal-trust risk: any service that holds `JWT_SECRET` can mint tokens accepted by satellite-provider as if they came from the admin API.
- **Impact**: Bounded by the secret-distribution policy. Within the trust boundary documented in cycle 1's A01 caveat ("internal/trusted-network service") this is acceptable.
- **Remediation (follow-up, NOT this cycle)**: When the admin team publishes `iss` / `aud` values, flip `ValidateIssuer = true` + `ValidIssuer = "<admin-iss>"` and the audience equivalent in `AddSatelliteJwt`. AZ-487 § Constraints already flags this as a small follow-up.

### F-AUTH-3 — No rate limiting on 401-producing paths (Low — recurrence of cycle-1 I3)

- **Location**: every `/api/satellite/*` endpoint after the AZ-487 `.RequireAuthorization()` middleware.
- **Description**: An attacker can flood `Authorization: Bearer <random>` requests; each one triggers an HMAC verification (cheap, but non-zero) and an HTTP 401 response. This re-uses the cycle-1 I3 finding ("no inbound rate limiting on any HTTP endpoint") — the JWT layer didn't introduce a new vulnerability, but it did add a new cheap-to-trigger 401 surface that magnifies I3.
- **Disposition**: Track under existing I3 remediation (wire `Microsoft.AspNetCore.RateLimiting`). No separate Jira.

### F-UAV-1 — ImageSharp decode on attacker-controlled bytes (Medium — exposure increase, mitigations sufficient today)

- **Location**: `SatelliteProvider.Services.TileDownloader/UavTileQualityGate.cs:60-95` — `Image.Identify` (Rule 3) and `Image.Load<L8>` + `Mutate(ctx => ctx.Resize)` (Rule 5).
- **Description**: Pre-AZ-488, ImageSharp only decoded responses from the Google Maps tile CDN (trusted origin). AZ-488 added a second call site that decodes arbitrary `POST /api/satellite/upload` payloads. Current ImageSharp 3.1.11 is patched (see cycle-2 dependency-scan finding F-DEPS-UAV); the change here is *exposure*, not a present vulnerability.
- **Mitigations in place**:
  - Rule 1 magic-byte gate runs before any ImageSharp call (`FF D8 FF` prefix required).
  - Rule 2 caps per-item size at 5 MiB; Kestrel + FormOptions cap the envelope at `MaxBatchSize × MaxBytes`.
  - Decode is wrapped in `try { … } catch (UnknownImageFormatException) { … } catch (InvalidImageContentException) { … }` — malformed JPEGs produce a structured `INVALID_FORMAT` reject; no unhandled exception reaches the client.
- **Remediation**: Subscribe to `SixLabors.ImageSharp` GHSA advisories; bump within 7 days of a patch. Sandboxing (separate process / libvips + seccomp) is not warranted at the current trust boundary but should be reconsidered if the endpoint is exposed publicly. Recorded as recurring follow-up.

### F-UAV-2 — `JsonDocument.Parse` invoked on token-supplied claim values (Low — bounded by Kestrel header limits)

- **Location**: `SatelliteProvider.Api/Authentication/PermissionsRequirement.cs:84-111` — `JsonDocument.Parse(claim.Value)` when the `permissions` claim arrives as a JSON-array string.
- **Description**: `JsonDocument.Parse` has no built-in depth or size limit. A maliciously-shaped permissions claim (e.g. deeply-nested array) would consume CPU/heap during parsing. The token has already passed HS256 signature validation by the time the handler runs, so this is only exploitable by a party that holds `JWT_SECRET` — i.e. another suite service or an admin-team principal — and only inside the issued-token-size window (bounded by Kestrel's `MaxRequestHeadersTotalSize`, default 32 KiB).
- **Disposition**: Accept. The combination of `RequireSignedTokens = true` + header-size cap + ordinal-only string comparison makes a practical exploit prohibitive. Future hardening: pass `JsonDocumentOptions { MaxDepth = 8 }` to `JsonDocument.Parse` and reject claims longer than e.g. 8 KiB before parsing.

### F-UAV-3 — Reject reasons disclose gate structure (Informational — accepted trade-off)

- **Location**: `SatelliteProvider.Services.TileDownloader/UavTileQualityGate.cs` — each rule returns a distinct enum code.
- **Description**: A client (or attacker who can present a `GPS`-permission token) can map the gate by probing inputs (1×1 black image → `WRONG_DIMENSIONS`; 1 KB JPEG → `SIZE_OUT_OF_BAND`; etc.). The thresholds are also documented in the public contract `_docs/02_document/contracts/api/uav-tile-upload.md`.
- **Disposition**: Accept — UX (helping clients self-correct) outweighs the information-hiding benefit, especially since the contract is public anyway. Flagged to keep operators aware: rule thresholds are NOT a security boundary; do not move secrets into reject details.

---

## Cycle 3 Delta (2026-05-12 — AZ-491 / AZ-492 / AZ-493 / AZ-494 / AZ-495 / AZ-496)

### Scope of this delta scan

| File | Cycle-3 task(s) | Domain |
|------|-----------------|--------|
| `SatelliteProvider.Api/Authentication/AuthenticationServiceCollectionExtensions.cs` | AZ-494 | Production auth (high-sensitivity surface) |
| `SatelliteProvider.Api/appsettings.json` + `appsettings.Development.json` | AZ-494 | Configuration / secrets handling |
| `SatelliteProvider.IntegrationTests/JwtTestHelpers.cs` | AZ-491, AZ-494 | Test-side, runner-only |
| `SatelliteProvider.IntegrationTests/IntegrationTestDatabaseReset.cs` | AZ-493 | Test-side; destructive DB op (TRUNCATE) gated by two-guard model |
| `SatelliteProvider.IntegrationTests/PerfBootstrap.cs` | AZ-492, AZ-494 | Test-side CLI subcommand (mint token, write JPEG fixture) |
| `SatelliteProvider.IntegrationTests/Program.cs` | AZ-491..AZ-494 | Test-side bootstrap |
| `SatelliteProvider.TestSupport/JwtTokenFactory.cs` | AZ-491, AZ-494 | Test-side, runner-only |
| `SatelliteProvider.TestSupport/IntegrationTestResetGuard.cs` | AZ-493 | Test-side; safety-guard logic |
| `SatelliteProvider.Tests/Authentication/AuthenticationServiceCollectionExtensionsTests.cs` | AZ-487, AZ-494 | Test-side unit |
| `SatelliteProvider.Tests/TestSupport/IntegrationTestResetGuardTests.cs` | AZ-493 | Test-side unit |
| `scripts/run-tests.sh` / `scripts/run-performance-tests.sh` | AZ-492, AZ-493, AZ-494 | Operator-side shell |
| `docker-compose.yml` / `docker-compose.tests.yml` | AZ-494 (env pass-through) | Infrastructure |
| `.env.example` | AZ-494 | Configuration template |

### Cycle-3 findings

#### F-AUTH-3 — Test runner logs `iss` / `aud` values at startup (Informational — test runner only, never in prod)

- **Location**: `SatelliteProvider.IntegrationTests/Program.cs:67` — `Console.WriteLine($"Auth    : JWT_SECRET resolved ({…} bytes); iss={jwtIssuer}; aud={jwtAudience}");`
- **Description**: The integration-tests bootstrap prints the resolved iss and aud at startup. Values printed in this cycle's runs were the `DEV-ONLY-iss-admin-azaion-local` / `DEV-ONLY-aud-satellite-provider` placeholders, so no prod-value leak occurred. The production API (`SatelliteProvider.Api/Program.cs`) does NOT print iss/aud — verified by repo grep returning no hits.
- **Impact**: Only meaningful if the integration test runner is somehow pointed at production env vars. The fail-fast contract makes that operator decision visible at startup (the values are visible in test logs).
- **Disposition**: Accept — Informational. Operators inspecting test logs already see the secret byte count and the iss/aud, which is appropriate for a runner whose entire job is to validate against those values. No code change needed.

#### F-DBR-1 — `TRUNCATE TABLE` via string interpolation (False Positive — hard-coded table list)

- **Location**: `SatelliteProvider.IntegrationTests/IntegrationTestDatabaseReset.cs:32` — `$"TRUNCATE TABLE {string.Join(", ", TruncateOrder)} RESTART IDENTITY CASCADE"`.
- **Description**: SAST pattern flagged string-interpolated SQL. Source analysis confirms `TruncateOrder` is a `public static readonly IReadOnlyList<string>` initialised with a hard-coded array of five literal table names; no caller-supplied input flows into the SQL string.
- **Impact**: None. SQL injection here would require an attacker to modify the source file, at which point integrity is already broken.
- **Disposition**: False positive — recorded so future scanners don't re-flag.

#### F-DBR-2 — Destructive `TRUNCATE` action protected only by two soft guards (Low — operator-controlled, deliberate trade-off)

- **Location**: `SatelliteProvider.TestSupport/IntegrationTestResetGuard.cs:11-36` + `SatelliteProvider.IntegrationTests/IntegrationTestDatabaseReset.cs:24-37`.
- **Description**: The reset runs only when (a) `ASPNETCORE_ENVIRONMENT == "Testing"` AND (b) the Npgsql Host is one of `postgres` / `localhost` / `127.0.0.1`. An operator who sets `ASPNETCORE_ENVIRONMENT=Testing` and SSH-tunnels a production Postgres to `localhost:5432` could trick the guard.
- **Impact**: Loss of all `tiles`, `regions`, `routes`, `route_points`, `route_regions` rows on the targeted database.
- **Mitigations in place**: the cycle-3 spec deliberately preferred Host allowlist over DB-name pattern (per the AZ-493 review's "Spec-vs-reality" note); both DB-name and Host checks are cheap to add together if the operator surface grows. The guard is unit-tested (`IntegrationTestResetGuardTests`) with representative production hostnames (`prod-db-cluster-1.example.com`, etc.) to confirm they're rejected.
- **Disposition**: Accept — Low. The guard is conservative-by-default; bypassing requires deliberate operator action (env var + tunnel). Future PBI: add a third guard requiring an explicit `INTEGRATION_TEST_DB_RESET_CONFIRM=I-UNDERSTAND-THIS-TRUNCATES` env var when the guard runs against `localhost` from outside Docker.

#### F-PERF-1 — Perf-bootstrap mint subcommand writes a 4-hour `GPS`-permission token to stdout (Low — operator-controlled CLI, no network exposure)

- **Location**: `SatelliteProvider.IntegrationTests/PerfBootstrap.cs:21-48`.
- **Description**: `dotnet <integration-tests.dll> --mint-only` prints a 4-hour HS256 token with `permissions: GPS` claim to stdout. The token grants the same access as a production-issued `GPS` admin token for the lifetime window. The token bytes flow through the operator's shell history, terminal scrollback, and any process accounting logs.
- **Impact**: An attacker with read access to the operator's machine within the 4-hour window could replay the token against the API.
- **Mitigations in place**: lifetime is bounded to 4 hours (vs. e.g. 24 hours that would be tempting for "convenient perf runs"). The token is minted against `JWT_SECRET` from `.env` — same trust boundary as a developer's local dev setup. Operators are expected to run the perf script on a trusted machine.
- **Disposition**: Accept — Low. Future hardening: pipe the token to `xargs` / process substitution so it never lands in the shell history; consider mounting `JWT_SECRET` via a Docker secret rather than an env var when running the perf harness inside CI.

#### F-AUTH-4 — DEV-ONLY iss/aud placeholders committed to `appsettings.Development.json` + `.env.example` (Informational — by design, AZ-494 Option B)

- **Location**: `SatelliteProvider.Api/appsettings.Development.json` (`DEV-ONLY-iss-admin-azaion-local` / `DEV-ONLY-aud-satellite-provider`); `.env.example` (same placeholders).
- **Description**: AZ-494 (Option B per user decision) deliberately ships DEV-ONLY placeholder values in development config so local dev / docker-compose flows work without operator setup. Production config (`appsettings.json`) ships with empty values, triggering the fail-fast contract.
- **Impact**: None in production (the empty values guarantee a startup failure before any token validates). In development, the placeholders are clearly tagged with `DEV-ONLY-` prefix so a grep can surface them at any time.
- **Disposition**: Accept — by design. This is the explicit Option B trade-off the user selected over Option A (postpone) and Option C (hard-code prod values).

### Resolved this cycle

- **F-AUTH-2** (cycle 2): `iss` / `aud` not validated. **RESOLVED in AZ-494** — `ValidateIssuer = true` + `ValidateAudience = true` wired against env-sourced values with fail-fast startup. Verified at the source (`AuthenticationServiceCollectionExtensions.cs:37-40`).

### Patterns NOT triggered by cycle-3 changes

- **Injection**: SQL injection ✗ (only TRUNCATE with hard-coded table names — F-DBR-1 false positive). Command injection ✗ (no `Process.Start` / `exec` / `shell=True`). XSS ✗ (no HTML rendering paths added). Template injection ✗.
- **Cryptographic Failures**: no new hashing or encryption code; HS256 unchanged from AZ-487.
- **Insecure Deserialization**: ImageSharp decode path unchanged from cycle 2; no new `JsonSerializer.Deserialize<>` against attacker input.

## Self-verification

- [x] All production source directories scanned (Api, Common, DataAccess, Services.TileDownloader, Services.RegionProcessing, Services.RouteManagement)
- [x] All cycle-3 test-side surfaces scanned (TestSupport, IntegrationTests, Tests)
- [x] Each finding has file path and line number
- [x] False positives from test files explicitly distinguished (`GlobalExceptionHandlerTests.cs:23` "leakySecret" is a positive control); F-DBR-1 also classified as false-positive with rationale
- [x] No real secret values printed in this report (S4 is described without echoing the key; F-AUTH-4 cites placeholder values that are public-by-design)
- [x] Cycle-3 surfaces (`AddSatelliteJwt` iss/aud extension, `IntegrationTestDatabaseReset`, `PerfBootstrap`, two-guard logic) all reviewed; findings either documented above or explicitly cleared
