# Static Analysis (Cycle 5)

**Date**: 2026-05-12
**Mode**: Delta scan
**Scope**: Cycle-5 delta over the cycle-3 static analysis (`_docs/05_security/static_analysis.md`). Cycle 4 was source-edit-free for SAST surfaces (AZ-500 was a runtime/package bump only); cycle 5 reintroduces real source edits and is the first SAST delta since cycle 3.

## Files in Scope

AZ-503-foundation (production code only — test code excluded from SAST per the cycle-3 policy):

- `SatelliteProvider.Common/Utils/Uuidv5.cs` (new)
- `SatelliteProvider.Common/DTO/UavTileMetadata.cs` (+1 nullable `Guid? FlightId` property)
- `SatelliteProvider.DataAccess/Migrations/014_AddTileIdentityColumns.sql` (new — SQL migration)
- `SatelliteProvider.DataAccess/Models/TileEntity.cs` (+4 properties)
- `SatelliteProvider.DataAccess/Repositories/TileRepository.cs` (UPSERT key change; one new column list element on UPDATE)
- `SatelliteProvider.Services.TileDownloader/TileService.cs` (`BuildTileEntity` computes deterministic Id / LocationHash / ContentSha256)
- `SatelliteProvider.Services.TileDownloader/UavTileUploadHandler.cs` (`PersistAsync` reads `metadata.FlightId`; `BuildUavTileFilePath` accepts `Guid? flightId`)

AZ-504:

- `scripts/run-performance-tests.sh:416-417` (two `grep -o` counters wrapped in `{ … || true; }`)

## Injection

### SQL injection

`TileRepository.InsertAsync` uses Dapper parameterized SQL throughout — no string interpolation of user-controlled values into the SQL text. `flight_id`, `location_hash`, `content_sha256`, `legacy_id` are bound via `@flightId`, `@locationHash`, `@contentSha256`, `@legacyId`. The new `COALESCE(flight_id, '00000000-...'::uuid)` predicate uses a hardcoded zero-UUID literal — not user-controlled.

Migration 014's PL/pgSQL `pg_temp.uuidv5` function takes `namespace_uuid uuid, name text` parameters; the only `name` value passed is `tile_zoom::text || '/' || tile_x::text || '/' || tile_y::text` over data already in the table. The migration runs under DbUp's bootstrap path (server-trusted, no user input).

**Finding**: none.

### Command injection

No `Process.Start`, `Shell.Execute`, or `subprocess`-equivalent calls were introduced. `Uuidv5.cs` uses pure BCL (`SHA1.HashData`, `BinaryPrimitives`). `UavTileUploadHandler.PersistAsync` writes a file via `File.WriteAllBytesAsync` — no shell.

The AZ-504 shell-script edit wrapped two `grep -o` invocations in `{ … || true; }`. The wrapped commands were already there in cycle 3; AZ-504 only added the `|| true` guard. No new shell-evaluated input.

**Finding**: none.

### Path traversal

The new `UavTileUploadHandler.BuildUavTileFilePath(StorageConfig, int tileZoom, int tileX, int tileY, Guid? flightId)` constructs an on-disk path. All inputs are integer-typed (`tileZoom`, `tileX`, `tileY`) or `Guid?`. The `flightId` segment is rendered via `flightId.Value.ToString("D", CultureInfo.InvariantCulture)` which **always** emits the 36-character hyphenated form (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`). It is structurally impossible to inject `..`, `/`, `\`, or null bytes into a `Guid.ToString("D")`. Anonymous uploads use the literal compile-time constant `AnonymousFlightSegment = "none"`. Integer-typed coordinates similarly cannot carry traversal characters.

Path joining uses `Path.Combine` which handles platform separator normalization. The deletion case `rm -rf ./tiles/uav/{flight_id}/` is operator-driven, not API-driven — there is no endpoint that takes a flight_id and deletes anything.

**Finding**: none.

### Template / formatting injection

`Uuidv5.Create(Guid namespaceId, string name)` accepts a `string name`. The string is hashed (SHA-1 of namespace bytes + UTF-8 name), not interpolated into any template. The hash output is then post-processed into a `Guid`. No injection surface.

**Finding**: none.

## Authentication & Authorization

AZ-503 did NOT add a new endpoint and did NOT change the existing auth/permission policies on `POST /api/satellite/upload` (still: JWT (HS256) + `GPS` permission claim, owned by AZ-487 + AZ-488). The optional `flightId` per-item metadata field is on the inside of the JWT-protected envelope; an unauthenticated caller cannot reach it.

The new `metadata.flightId` field is **not** used as an authorization key. It is an opaque identifier for evidence-isolation. The handler does not check "does this principal own this flight" — that is intentional for v1.1.0 of the upload contract (documented in `_docs/02_document/contracts/api/uav-tile-upload.md`). A future contract bump may add per-flight ownership; for now, any caller with the `GPS` permission can write under any `flightId`.

**Finding**: F1-cy5 — Low (informational), Insecure Design.
- Location: `_docs/02_document/contracts/api/uav-tile-upload.md` v1.1.0 + `UavTileUploadHandler.PersistAsync`
- Description: The `metadata.flightId` field is accepted from authenticated callers without verifying the caller "owns" or is authorized to write under that flight identifier. By design (v1.1.0). Two adversarial cases:
  1. A compromised UAV credential could falsely attribute its uploads to a different flight_id (impersonation on the evidence chain).
  2. A legitimate UAV credential could falsely attribute its own uploads to a competing operator's flight_id (false-flag).
- Impact: Evidence-chain integrity is **not** cryptographically enforced for the flight_id field; downstream consumers should not treat `tiles.flight_id` as proof of provenance. They MUST cross-reference flight_id against an authoritative flight registry (out of this workspace's scope) before drawing conclusions.
- Remediation: Recorded as a design constraint, not a defect. If a future cycle requires per-flight ownership, three options:
  - (a) Issue per-flight JWTs (subject = flight_id; reject mismatched `metadata.flightId` server-side).
  - (b) Have the Admin API mint a short-lived flight-scoped permission claim, e.g. `permissions: ["GPS:flight=<uuid>"]`.
  - (c) Move flight_id derivation server-side from a trusted claim (`token.sub` or `token.flight_id`).
- Severity rationale: Low because (i) no current consumer treats flight_id as authenticated provenance, (ii) the GPS permission is gated upstream by the Admin API per `suite/_docs/10_auth.md`, (iii) the surface only exists when an attacker already holds a valid GPS-permissioned JWT.

## Cryptographic Failures

### SHA-1 in `Uuidv5.cs`

`Uuidv5.Create` uses `SHA1.HashData(...)` — but **not** as a cryptographic primitive. It is the RFC 9562 §5.5 algorithm requirement for UUIDv5 generation; the result is a stable, deterministic 128-bit handle used as a database key and an on-disk path component. SHA-1's collision vulnerability (SHAttered, 2017) is irrelevant here because:

1. The UUIDv5 result is **not** used as a content integrity check — `content_sha256` (SHA-256) is the content-integrity primitive.
2. Two different `(namespace, name)` pairs producing the same UUIDv5 would require an adversary to craft SHA-1 collisions in advance with full control over both inputs. The `namespace` is a pinned constant (`5b8d0c2e-...`); the only attacker-influenced input is the `name`, which for tile identity is `{z}/{x}/{y}/{source}/{flight_id-or-zero}`. The attacker cannot freely choose `{z}/{x}/{y}` (those are integers derived from public coordinates) or `{source}` (closed enum). The only free variable is `flight_id`. A collision-induced overwrite would require the attacker to (a) hold a GPS-permissioned JWT, (b) compute a SHA-1 collision against a target row's full canonical name, (c) submit a JPEG that matches the victim's `(z, x, y, source)`. The compute cost remains in the hundreds-of-thousands of GPU-hours range and the operational cost (a valid JWT + a forged image that bypasses the 5-rule quality gate) makes this purely theoretical.
3. The .NET implementation's `SHA1.HashData` calls into CNG / OpenSSL FIPS-validated SHA-1; the algorithm is not in-process and not subject to side-channel concerns.

**Finding**: none. SHA-1 use is RFC-mandated and documented in `_docs/02_document/modules/common_uuidv5.md` § Security with the appropriate "not a cryptographic hash for security purposes" disclaimer.

### SHA-256 in `TileService.BuildTileEntity` + `UavTileUploadHandler.PersistAsync`

`content_sha256` is computed via `SHA256.HashData(stream)` over the on-disk JPEG body. Stored in `tiles.content_sha256` (bytea). Used to detect byte-identical re-uploads (AZ-503 AC-7). SHA-256 is appropriate here.

The DB column is `bytea NULL` — legacy pre-AZ-503 rows have NULL because the migration cannot reliably re-read those file bytes (file paths rotate on every Google Maps re-download due to the timestamped legacy layout). Application code enforces `NOT NULL` for new writes. The Low-severity maintainability finding recorded in `batch_02_cycle5_report.md` covers this trade-off.

**Finding**: none (already covered by code-review at Low maintainability level).

### Plaintext storage of `JWT_SECRET`, `GOOGLE_MAPS_API_KEY`

Unchanged from cycle 3 — both come from environment variables / `.env` (gitignored). AZ-503 added no new secret.

## Data Exposure

### `content_sha256` in API responses

The `tileId` returned in `UavTileBatchUploadResponse.items[].tileId` is the deterministic UUIDv5. Is this a privacy leak? The UUID encodes `(z, x, y, source, flight_id-or-zero)` deterministically under a public namespace. An external observer with the `tileId` could verify a `(z, x, y, source, flight_id)` guess but cannot enumerate or reverse without the inputs already in hand. For UAV uploads:
- `z, x, y` are derived from `(latitude, longitude, zoom)` the client itself supplied — already known to that client.
- `source` is `"uav"` for these responses — already known.
- `flight_id` is supplied by the client (or `00000000-...` for anonymous) — already known.

So the deterministic `tileId` returned to the client tells the client only what the client already knew. **No new information leak.**

For Google Maps tiles (returned via `GET /api/satellite/tiles/latlon`), the same logic holds: the client already knows `(z, x, y)` because it constructed the request.

**Finding**: none.

### Migration backfill data exposure

Migration 014 writes `location_hash` and `legacy_id` to every existing row. Neither column is projected by `GET /api/satellite/region/{id}`, `GET /api/satellite/route/{id}`, or any public response. They are internal columns. The migration's logs (DbUp's `LogToConsole()`) emit row counts and timing — not row content.

**Finding**: none.

### `legacy_id` retention

`legacy_id` retains the pre-AZ-503 random `Guid` for forensics. This is internal data — not exposed via API surface. To be dropped in a future cycle (already noted in `data_model.md`).

**Finding**: none.

## Insecure Deserialization

`UavTileMetadata.FlightId` is deserialized from JSON via `System.Text.Json`. The target type is `Guid?` — the deserializer enforces a strict 36-character hyphenated format (or 32-char un-hyphenated) and throws `JsonException` on invalid input, which is caught at the envelope level and surfaces as HTTP 400. No type-confusion or polymorphic deserialization path was introduced.

`Uuidv5.Create` does not deserialize anything — it consumes a `string`/`ReadOnlySpan<byte>` and a `Guid`.

**Finding**: none.

## Self-verification

- [x] All AZ-503 production source files scanned
- [x] AZ-504 shell-script delta scanned
- [x] No false positives raised from test code
- [x] All findings carry file path / location

## Save action

Written to `_docs/05_security/static_analysis_cycle5.md`. Carry the cycle-3 `static_analysis.md` forward — no overlap with cycle-5 surface.
