Files
satellite-provider/_docs/05_security/static_analysis.md
T
Oleksandr Bezdieniezhnykh 5214a4a647 [AZ-487] [AZ-488] security: cycle 2 delta audit (PASS_WITH_WARNINGS)
Step 14 (Security Audit) for cycle 2 — delta scan against the cycle-1
baseline. Verdict remains PASS_WITH_WARNINGS; no Critical/High.

Scope: JWT auth boundary (AZ-487) and UAV multipart upload + ImageSharp
decode of attacker-controlled bytes (AZ-488). Both new packages
(JwtBearer 8.0.21, ImageSharp 3.1.11 in Services.TileDownloader)
checked.

Cycle-2 delta:
* 0 Critical / 0 High
* 2 Medium: F-AUTH-2 (iss/aud not validated — by design until admin
  team publishes values, AZ-487 § Constraints), F-UAV-1 (ImageSharp
  decode now runs on attacker-controlled bytes — mitigations
  sufficient; pin to GHSA subscribe-and-bump policy).
* 4 Low: F-AUTH-1 (DEV-ONLY secret in appsettings.Development.json —
  accepted), F-AUTH-3 (rate-limit gap extends to 401 floods — folds
  into cycle-1 I3), F-UAV-2 (JsonDocument.Parse on signature-validated
  claims — bounded by Kestrel header cap), D3 (JwtBearer shares D1
  patch line).
* 1 Informational: F-UAV-3 (reject reasons disclose gate structure —
  accepted UX trade-off; documented in contract).

OWASP refresh: A01 / A07 move from N/A (with caveat) to
PASS_WITH_WARNINGS (per-tenant authz absent; iss/aud + revocation
gaps tracked).

Pre-deploy operational gate added: deploy pipeline must verify
JWT_SECRET != DEV-ONLY placeholder before promoting api.

Artifacts: dependency_scan.md, static_analysis.md, owasp_review.md,
infrastructure_review.md, security_report.md — all appended with a
"Cycle 2 Delta" section preserving cycle-1 finding IDs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 00:13:58 +03:00

141 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 2324 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.
## Self-verification
- [x] All production source directories scanned (Api, Common, DataAccess, Services.TileDownloader, Services.RegionProcessing, Services.RouteManagement)
- [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)
- [x] No real secret values printed in this report (S4 is described without echoing the key)