Files
Oleksandr Bezdieniezhnykh 314d1dec39
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful
[AZ-491] [AZ-492] [AZ-493] [AZ-494] [AZ-496] Cycle 3 Step 14: security audit refresh
All 5 phases refreshed against cycle-3 delta:

Phase 1 (Dependency Scan):
  - D1 RESOLVED (AZ-496): Microsoft.AspNetCore.OpenApi 8.0.21 → 8.0.25
  - D3 RESOLVED (AZ-496): JwtBearer 8.0.21 → 8.0.25
  - D4 NEW (Low, test-only): System.IdentityModel.Tokens.Jwt 7.0.3 +
    Microsoft.IdentityModel.Tokens 7.0.3 pinned in TestSupport carry
    CVE-2024-21319 (JWE DoS). Bump to ≥ 7.1.2 tracked as future PBI.

Phase 2 (Static Analysis):
  - F-AUTH-3 (Info): test runner Program.cs logs iss/aud at startup;
    production API does NOT (verified by grep).
  - F-AUTH-4 (Info): DEV-ONLY iss/aud placeholders in
    appsettings.Development.json + .env.example — by design per
    Option B for AZ-494.
  - F-DBR-1: TRUNCATE string interpolation in
    IntegrationTestDatabaseReset.cs — false positive (hard-coded
    table list).
  - F-DBR-2 (Low): TRUNCATE guard is operator-bypassable. Two-guard
    model is conservative-by-default and unit-tested.
  - F-PERF-1 (Low): perf-bootstrap --mint-only writes a 4-hour
    GPS-permission token to stdout. Operator-trusted machine assumed.

Phase 3 (OWASP Top 10):
  - A03 carries D1/D3 RESOLVED + D4 NEW.
  - A07 flips F-AUTH-2 to RESOLVED (AZ-494); residual revocation-list
    Low recorded.
  - A05 status unchanged (F-DBR-1 false positive).
  - A08 picks up F-DBR-2.

Phase 4 (Infrastructure):
  - JWT_ISSUER / JWT_AUDIENCE flow .env → compose → Kestrel config,
    same pattern as JWT_SECRET.
  - INTEGRATION_TEST_DB_RESET + ASPNETCORE_ENVIRONMENT=Testing wired
    for AZ-493 reset gate.
  - SatelliteProvider.TestSupport is IsPackable=false — never ships
    in a production container image.
  - New operational gate added to deploy runbook: grep for DEV-ONLY-
    in the rendered deploy environment must return zero hits.

Phase 5 (Security Report):
  - Verdict: PASS_WITH_WARNINGS (cycle 3 does not escalate).
  - 0 Critical, 0 High, 0 new Medium.
  - Cycle-2 F-AUTH-2 (Medium) RESOLVED; cycle-1 D1 + cycle-2 D3
    RESOLVED.

Autodev state advanced to Step 14 completed. Next: Step 15
(Performance Test, optional gate).

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

14 KiB

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:
    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:
    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

  • All Dockerfiles reviewed (Api + IntegrationTests)
  • All CI/CD configs reviewed (.woodpecker/01-test.yml, 02-build-push.yml)
  • 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 IConfigurationAddSatelliteJwt. 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 IConfigurationAddSatelliteJwt. 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.