Files
satellite-provider/_docs/05_security/static_analysis_cycle5.md
T
Oleksandr Bezdieniezhnykh 61612044fb [AZ-503] [AZ-504] Cycle 5 Steps 11-15 sync
Wrap up cycle 5 verification + documentation:
- Steps 10/11 wrap-up reports (implementation_completeness +
  implementation_report) for the AZ-503-foundation + AZ-504 batch.
- Step 12 test-spec sync: AZ-503-foundation/AZ-504 ACs appended;
  AZ-505 deferred ACs recorded.
- Step 13 update-docs: architecture, data-model, glossary, module-
  layout, uav-tile-upload contract (v1.1.0), DataAccess + Services
  + Tests module docs synced; new common_uuidv5.md module doc.
- Step 14 security audit: PASS_WITH_WARNINGS; 0 new Critical/High;
  2 new Low informational (F1 flightId provenance, F2 pgcrypto
  deploy gap).
- Step 15 performance test: PASS_WITH_INFRA_WARNINGS; PT-08
  passed twice (AZ-504 fix verified); PT-01/02 failed due to
  recurring local Docker/colima DNS cold-start (not an app
  regression). Cycle-3 perf-harness leftover stays OPEN with
  replay #5 documented.
- Autodev state moved to Step 16 (Deploy).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 18:01:27 +03:00

11 KiB

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

  • All AZ-503 production source files scanned
  • AZ-504 shell-script delta scanned
  • No false positives raised from test code
  • 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.