# Phase 4 — Configuration & Infrastructure Review

**Date**: 2026-05-11
**Scope**: `Dockerfile` (API + integration tests), `docker-compose.yml`, `docker-compose.tests.yml`, `.dockerignore`, `.woodpecker/01-test.yml`, `.woodpecker/02-build-push.yml`, `appsettings*.json`, `.env` handling.

## Findings

### I1 — Dockerfile runs as root (Low)

- **Location**: `SatelliteProvider.Api/Dockerfile` (no `USER` directive)
- **Description**: The final stage of the API image inherits root from `mcr.microsoft.com/dotnet/aspnet:8.0` (current Microsoft images default to root unless overridden). Any process compromise — even a low-impact one — has uid-0 inside the container.
- **Impact**: Container escape primitives (e.g., kernel CVE, sloppy bind-mount of `/var/run/docker.sock`) become host-root rather than host-uid-1000. The `02-build-push.yml` step itself bind-mounts `/var/run/docker.sock` into the build container — that's a separate concern (build host, not runtime), but it underscores why "least privilege at runtime" matters even on a single-tenant box.
- **Remediation**: Add to the `final` stage:
  ```dockerfile
  RUN adduser --disabled-password --gecos "" --uid 10001 satellite && \
      chown -R satellite:satellite /app
  USER satellite
  ```
  Also verify `./tiles`, `./ready`, `./logs` host volumes are writable by uid 10001 in deployment manifests.

### I2 — No security headers middleware (Low)

- **Location**: `SatelliteProvider.Api/Program.cs` (no `app.UseSecurityHeaders()` / `app.Use(headers …)` block)
- **Description**: API responses do not set `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, or HSTS (`Strict-Transport-Security`) — only `app.UseHttpsRedirection()` is wired. For a JSON-only API this is **low** impact (no browser is the primary client), but the missing `Cache-Control` defaults can let proxies cache 5xx responses.
- **Impact**: Limited — JSON-only responses, no cookies, no browser session. The Swagger UI (Development-only) does render HTML; a permissive default there is more of a hygiene issue than a real risk.
- **Remediation**: Add a tiny middleware to set the standard hardening headers, OR install `NWebsec.AspNetCore.Middleware` and wire `app.UseHsts()` + the `nosniff` / `frame-options` defaults. Cheap, no behavioural change.

### I3 — No rate limiting on any HTTP endpoint (Medium)

- **Location**: `SatelliteProvider.Api/Program.cs` (no `app.UseRateLimiter()`, no `AddRateLimiter()`)
- **Description**: There is internal concurrency control on outbound Google Maps calls (`SemaphoreSlim`, `MaxConcurrentDownloads`), but no inbound rate limiting. An attacker can:
  - Submit `N` `POST /api/satellite/request` calls in a tight loop, filling the bounded `IRegionRequestQueue` (capacity 1000) and DoS-ing the background processor.
  - Submit `N` `GET /api/satellite/tiles/latlon` calls with novel lat/lon pairs, forcing the upstream Google Maps quota to drain.
- **Impact**: Service-degradation DoS. Combined with finding A01-caveat (no auth), the only protection is the network boundary.
- **Remediation**: Wire `Microsoft.AspNetCore.RateLimiting` (built into .NET 8 — no new package). Conservative starting point:
  ```csharp
  builder.Services.AddRateLimiter(options =>
  {
      options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
          RateLimitPartition.GetFixedWindowLimiter(
              partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
              factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 60, Window = TimeSpan.FromMinutes(1) }));
  });
  app.UseRateLimiter();
  ```
  Tune per-endpoint after observing baseline production load.

### I4 — No security-event logs / alerting (Low)

- **Location**: Logging strategy across `Program.cs`, `GlobalExceptionHandler`, `CorsConfigurationValidator`
- **Description**: Operational logs are well-structured (Serilog → file rotation; correlationId propagation), but there are no log entries for what would be security-relevant events: validation failures (BadRequest stream), repeated 4xx from a single IP, malformed input bursts, or migration failures. The migration failure path does throw and crash startup (good signal), but this leaves no trail in the file logs.
- **Impact**: No way to detect abuse of the unauthenticated endpoints from logs alone. For an internal-only deployment this is acceptable; if the API ever moves toward a less-trusted network, post-deploy log-mining will not be able to reconstruct attack patterns.
- **Remediation**: Defer until/unless the trust boundary changes. When required: add a structured log line for each 400/404 (with `Method`, `Path`, `RemoteIp`, `correlationId`) and a counter for "validation failures per minute per IP".

### I5 — `.env` is NOT in `.dockerignore` (Medium)

- **Location**: `.dockerignore` (line by line review — no `.env` entry); `SatelliteProvider.Api/Dockerfile:15` (`COPY . .`)
- **Description**: The Dockerfile's `COPY . .` step copies the entire build context into `/src`. The build context starts at the repo root, where `.env` lives. `.env` IS in `.gitignore` so the dev-only Google Maps key never reaches the git repo, but it WILL be baked into the build-stage image layer (and into the final image, since `final` does `COPY --from=publish /app/publish .` — only `/app/publish` survives, but the `build` stage retains `.env` and is reachable if anyone introspects an intermediate layer).
- **Impact**:
  - Anyone with read access to the registry can `docker pull <build-stage-tag>` (if exported) and recover the API key from the layer.
  - Even just the `final` image: BuildKit cache mounts and any future Dockerfile change that does `COPY . /app` instead of `COPY --from=publish` would silently include the file.
- **Remediation**: Add `.env` to `.dockerignore`:
  ```
  .env
  .env.*
  !.env.example
  ```
  This is a one-line fix and complements finding S4.

### I6 — `docker-compose.yml` exposes Postgres on `0.0.0.0:5432` (Medium — duplicate of S2; restated here for infra-domain completeness)

- **Location**: `docker-compose.yml:9-10`
- **Description / Remediation**: See S2.

## Items checked clean

- **Secrets management in CI**: `.woodpecker/02-build-push.yml` uses `from_secret: registry_host / registry_user / registry_token` — no plaintext credentials. The `docker login` step pipes the token via `--password-stdin`, which avoids leaking the token via process list. ✓
- **Image attribution**: build step labels images with `org.opencontainers.image.revision`, `…created`, `…source` — good provenance hygiene. ✓
- **Healthcheck on Postgres**: `pg_isready -U postgres` configured. (Note: relies on the weak default user from S2.)
- **Log volume layout**: `./logs:/app/logs` mounted; not exposed via the API. ✓
- **Test runner isolation**: `docker-compose.tests.yml` extends the API service (good — same image) but uses `restart: "no"` so a flapping integration test doesn't loop and amplify load.

## Self-verification

- [x] All Dockerfiles reviewed (Api + IntegrationTests)
- [x] All CI/CD configs reviewed (`.woodpecker/01-test.yml`, `02-build-push.yml`)
- [x] All env / config files reviewed (`appsettings*.json`, `.env`, `docker-compose*.yml`)

---

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

### Infra changes this cycle

- `docker-compose.yml:32` adds `JWT_SECRET=${JWT_SECRET}` to the `api` service `environment` block (AZ-487). Sourced from the host env (or `.env`).
- `docker-compose.tests.yml:21` adds the same `JWT_SECRET=${JWT_SECRET}` to the integration-test runner so tests can mint matching tokens.
- `.env.example:18` adds an empty `JWT_SECRET=` line as a deploy-time reminder.
- `.env` (workspace root, not tracked) ships a `DEV-ONLY-CHANGE-ME-…` placeholder ≥ 32 bytes for local docker-compose use.
- No new infrastructure for AZ-488 — the upload endpoint reuses the existing `./tiles/` volume; UAV files land under `./tiles/uav/{z}/{x}/{y}.jpg` inside the same mount.

### Cycle-2 verdict — clean

- **Secret distribution**: `JWT_SECRET` flows env-var → docker-compose `environment` → Kestrel `IConfiguration` → `AddSatelliteJwt`. No checked-in production secret. The DEV-ONLY value in `.env` is also explicitly labelled and is bound to the cycle-1 S4 follow-up (rotate-and-document workflow). ✓
- **`.env` in `.dockerignore` (cycle-1 I5)**: still tracked under that remediation; cycle 2 does not add a new `.env` exposure path. The `JWT_SECRET` mirroring lives in compose, not the Dockerfile, so it doesn't bake into image layers. ✓
- **No new exposed ports**: cycle 2 changes are HTTP-layer only — endpoint registration, middleware, and a multipart handler. No new listener.
- **No new external services**: ImageSharp decode is in-process; no new outbound network call introduced by AZ-487 / AZ-488.
- **No CI workflow changes**: existing `.woodpecker/01-test.yml` continues to run unit + integration tests; new cycle-2 unit + integration tests run inside the existing workflow.

### Cycle-2 operational follow-ups (NOT findings — pre-deploy verification)

1. The deploy pipeline must verify `JWT_SECRET` is set to a ≥ 32-byte value distinct from the DEV-ONLY placeholder before promoting `api`. The application throws at startup if the value is missing or short, so a misconfigured deploy fails fast — but a deploy that *promotes* the dev placeholder verbatim would still pass the 32-byte gate. Tracked in `security_report.md` cycle-2 recommendations.
2. Coordinate with admin team on `iss`/`aud` values (F-AUTH-2). When values are defined, both the `AddSatelliteJwt` call site and `.env`/compose docs must be updated together. **RESOLVED in cycle 3 (AZ-494)** — `AddSatelliteJwt` now validates both; values flow `JWT_ISSUER` / `JWT_AUDIENCE` env → compose `environment` → Kestrel config. See cycle-3 delta below.

---

## Cycle 3 Delta (AZ-491 / AZ-492 / AZ-493 / AZ-494 / AZ-495 / AZ-496)

### Infra changes this cycle

- `docker-compose.yml:33-34` adds `JWT_ISSUER=${JWT_ISSUER}` and `JWT_AUDIENCE=${JWT_AUDIENCE}` to the `api` service `environment` block (AZ-494).
- `docker-compose.tests.yml:24-25` mirrors them on `integration-tests` so the runner mints tokens against the same iss/aud the API validates.
- `.env.example` documents both new variables with the fail-fast contract and ships explicit `DEV-ONLY-` placeholder values (AZ-494). Replaces an earlier draft that left them blank with a stale "leave blank to fall back" comment — corrected during Step 16 setup.
- `.env` (gitignored, developer-only) now also carries `JWT_ISSUER=DEV-ONLY-iss-admin-azaion-local` + `JWT_AUDIENCE=DEV-ONLY-aud-satellite-provider` for the local docker-compose flow.
- `docker-compose.tests.yml:21` sets `ASPNETCORE_ENVIRONMENT=Testing` and `:22` sets `DB_CONNECTION_STRING=Host=postgres;...`. Both are consumed by AZ-493's `IntegrationTestResetGuard.EnsureGuardPassesOrThrow(env, host)` — env must be `Testing` AND host must be in the allowlist (`postgres` / `localhost` / `127.0.0.1`), otherwise the reset throws.
- `docker-compose.tests.yml:20` adds `INTEGRATION_KEEP_STATE=${INTEGRATION_KEEP_STATE:-}` so the `--keep-state` flag on `scripts/run-tests.sh` short-circuits the reset (debugging convenience).
- `scripts/run-tests.sh` + `scripts/run-performance-tests.sh` both load + export the new env vars and fail-fast if missing (mirrors the AZ-487 `JWT_SECRET` pattern).
- No new exposed ports. No new external services. No CI workflow changes (`.woodpecker/01-test.yml` and `02-build-push.yml` unchanged).
- New `SatelliteProvider.TestSupport` project — `IsPackable=false`, never builds a container, never ships outside test images. Confirmed via `Dockerfile` review: only `SatelliteProvider.Api/Dockerfile` and `SatelliteProvider.IntegrationTests/Dockerfile` exist; neither produces a published artifact from TestSupport.

### Cycle-3 verdict — clean

- **Secret distribution**: `JWT_ISSUER` / `JWT_AUDIENCE` flow `.env` → docker-compose `environment` → Kestrel `IConfiguration` → `AddSatelliteJwt`. Same path as `JWT_SECRET`, no new mechanism introduced. Production values are NOT in the repo (the .env in git tracks only the example); production deploy supplies them via the deploy pipeline (operator-confirmed admin-team values).
- **Production safety contract**: empty `Jwt.Issuer` / `Jwt.Audience` in `appsettings.json` + the fail-fast `ResolveRequiredOrThrow` extension guarantees that a production deploy without explicit env-var values cannot start. This is the user-selected Option B forcing function.
- **Test-side DB reset**: AZ-493's two-guard logic (`ASPNETCORE_ENVIRONMENT == "Testing"` + Host allowlist) is the test-runner's equivalent of a fail-fast contract. Documented as F-DBR-2 (Low) in static_analysis.md.
- **Perf harness**: `scripts/run-performance-tests.sh` uses `mktemp -d` for fixture + response paths; cleaned up on `trap EXIT`. Token minted via `dotnet <integration-tests.dll> --mint-only` — never written to disk by the harness (only to a shell variable). Documented as F-PERF-1 (Low) in static_analysis.md.

### Cycle-3 operational follow-ups (NOT findings — pre-deploy verification)

1. The deploy pipeline must supply real `JWT_ISSUER` + `JWT_AUDIENCE` values (admin-team-confirmed) when promoting `api`. The application throws at startup if either is empty or whitespace — a misconfigured deploy fails fast.
2. The DEV-ONLY iss/aud placeholders in `appsettings.Development.json` + `.env.example` are deliberately committed (Option B). A grep for `DEV-ONLY-` surfaces every site that must NOT ship to production. Make this grep part of the deploy gate runbook.
3. The cross-repo `suite/_docs/10_auth.md` write (AC-7) is deferred — outside this workspace's boundary. The suite repo's owner must take this on as a follow-up.
