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>
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 nullableGuid? FlightIdproperty)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(BuildTileEntitycomputes deterministic Id / LocationHash / ContentSha256)SatelliteProvider.Services.TileDownloader/UavTileUploadHandler.cs(PersistAsyncreadsmetadata.FlightId;BuildUavTileFilePathacceptsGuid? flightId)
AZ-504:
scripts/run-performance-tests.sh:416-417(twogrep -ocounters 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.mdv1.1.0 +UavTileUploadHandler.PersistAsync - Description: The
metadata.flightIdfield 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:- A compromised UAV credential could falsely attribute its uploads to a different flight_id (impersonation on the evidence chain).
- 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_idas 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.flightIdserver-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.subortoken.flight_id).
- (a) Issue per-flight JWTs (subject = flight_id; reject mismatched
- 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:
- The UUIDv5 result is not used as a content integrity check —
content_sha256(SHA-256) is the content-integrity primitive. - 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. Thenamespaceis a pinned constant (5b8d0c2e-...); the only attacker-influenced input is thename, 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 isflight_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. - The .NET implementation's
SHA1.HashDatacalls 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, yare derived from(latitude, longitude, zoom)the client itself supplied — already known to that client.sourceis"uav"for these responses — already known.flight_idis supplied by the client (or00000000-...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.