# Security Audit — Consolidated Report

**Audit window**: 2026-05-19
**Auditor**: autodev Step 14 (greenfield flow) — `.cursor/skills/security/SKILL.md` § Phases 1–5
**Scope**: `gps-denied-onboard` SUT — production code path (`src/gps_denied_onboard/**`), pinned dependencies, container build artefacts, docker-compose topology, committed secret hygiene.
**Out of scope**: parent-suite components (D-PROJ-1 deploy/observability stack, D-PROJ-2 ingest service, satellite-provider, operator GCS UI); third-party SITL images beyond their tagging hygiene.

## Executive Summary

The SUT's security posture is built around a single defining design property: **the airborne process has no inbound or outbound network surface**. That property is enforced at three independent layers (architectural, operational iptables + DNS blackhole, test-harness verification via NFT-SEC-02 / NFT-SEC-05), which keeps the externally-reachable attack surface at zero in flight. The pre-flight (operator-workstation) phases DO touch the network — over TLS, with pinned hosts, parameterized SQL, redacted logs, and Ed25519-signed manifests / uploads.

Result: **0 Critical, 0 High, 5 Medium, 17 Low.** No release-blocking finding. The Medium findings are container-hardening gaps (root user in the production image, dev extras in the production image, moving-tag base images for SITL) that would be raised to High under a multi-tenant or internet-exposed threat model but are bounded to Medium here by the closed-system invariant.

| Severity | Count | Phase 1 (Deps) | Phase 2 (SAST) | Phase 3 (OWASP) | Phase 4 (Infra) |
|---|---|---|---|---|---|
| Critical | 0 | 0 | 0 | 0 | 0 |
| High | 0 | 0 | 0 | 0 | 0 |
| Medium | 5 | 1 | 0 | 0 | 4 |
| Low | 17 | 11 | 1 (informational) | 0 | 5 |
| Total | 22 | 12 | 1 | 0 | 9 |

**Release-blocking findings**: 0.
**Remediation expected before next major dependency refresh**: F14 (non-root container), F15 (production `[dev]` extras), F16 (`mavproxy:latest`).
**Remediation deferred to a future hardening pass**: F13 (plugin-loader assertion), F17 (digest-pin production base images), F18-F22 (housekeeping).
**Cross-component leftover already tracked**: D-CROSS-CVE-1 — `opencv-python` pin coupling with `gtsam` numpy<2 ABI block (`_docs/_process_leftovers/2026-05-11_d_cross_cve_1_opencv_pin_deferred.md`).

## Threat Model Snapshot

| Property | Status | Phase | Evidence |
|---|---|---|---|
| Airborne process has no network listener | enforced | A04 | `compose_root_airborne` wires no HTTP server; NFT-SEC-05 enforces at runtime |
| Airborne process has no network egress | enforced | A04 | iptables OUTPUT REJECT + DNS blackhole (RESTRICT-OPS-1); NFT-SEC-02 verifies in CI |
| Operator-workstation HTTP traffic is TLS-validated | enforced | A02 | 0 `verify=False` matches; pinned base URLs only |
| Tile / Manifest integrity end-to-end | enforced | A08 | SHA-256 sidecar + Ed25519 detached signatures |
| MAVLink ↔ FC channel is signed | enforced (AP) / documented residual (iNav) | A07 | Per-flight key rotation logged to FDR (NFT-SEC-03) |
| In-container runtime executes as non-root | **GAP — F14** | A05, F14 | Production Dockerfiles missing `USER` directive |
| Production runtime image excludes dev tools | **GAP — F15** | A05, F15 | `pip install -e ".[dev]"` ships pytest, ruff, mypy into prod |
| All container images are digest-pinned | **GAP — F16/F17** | A05, F16, F17 | Mixed: `tile-cache-builder` digest-pinned, production images use floating tags |
| Single-tenant binary (no per-user RBAC) | by design | A01 | One-OS-user container, FC arm/disarm IS the privilege gate |
| Secrets never logged | enforced | A09 | `_REDACTED` convention across 13 files |
| Test secrets are synthetic, marked TEST ONLY | enforced | P6 | Both committed passkey files contain `0123…ef`-repeated bytes |

## Per-Phase Outcomes

### Phase 1 — Dependency Scan (`_docs/05_security/dependency_scan.md`)

`pip-audit` against the pinned environment surfaced 12 advisories across 5 packages.

| Package | Advisories | Project-specific finding |
|---|---|---|
| `onnx==1.18.0` | 1 (GHSA-rcgw-4cgp-9q7q) | Reachable only via developer-mode model loading; SUT serves prebuilt engines on the production path. Medium. |
| `cryptography==42.0.8` | 4 | None reachable from SUT usage (Ed25519 sign/verify + httpx default X.509). Low. |
| `numpy==1.26.4` | 4 | None reachable from SUT usage (no untrusted-input numerical ingestion). Low. |
| `protobuf==4.25.3` | 2 | None reachable; protobuf usage is internal serialization to the FDR layer. Low. |
| `setuptools` | 1 | Dev-time only. Low. |

**D-CROSS-CVE-1 outcome**: re-running `pip-audit` against the relaxed pin (`opencv-python>=4.11.0.86,<4.12`) confirmed that CVE-2025-53644 is NOT flagged for `opencv-python==4.11.0.86`. The deferred-bump leftover was updated to record this; the residual risk window is effectively closed at the current pin. Bump to 4.12.x remains coupled to the `gtsam` numpy-2 ABI work.

### Phase 2 — Static Analysis (SAST) (`_docs/05_security/static_analysis.md`)

Pattern-driven ripgrep scan across 259 Python files in `src/`.

| Category | Findings |
|---|---|
| SQL injection | 0 — all 19 `cur.execute(...)` sites are parameterized |
| Command injection | 0 — 1 `subprocess.run` (TensorRT `trtexec`), list args, no untrusted input |
| Hardcoded secrets / weak crypto | 0 — Ed25519, SHA-256, `secrets.token_bytes` |
| Insecure deserialization | 0 — no `pickle.loads` / `eval` / `exec` on untrusted data; `json.loads` only on local SUT-managed files |
| TLS / verification bypass | 0 — all `httpx.Client` defaults preserved |
| **F13** (informational) — Dynamic `__import__` via plugin registry | 1 — bounded by closed `KNOWN_*_STRATEGIES` whitelist; hardening recommended |

Defense-in-depth observations: per-flight ephemeral Ed25519 signing in C11; `secrets.token_bytes` for MAVLink passkey generation; `paramiko.RejectPolicy` (NOT `AutoAddPolicy`) for SSH; `_REDACTED` convention enforced across 13 files; secret-buffer zeroising on session end (AZ-318).

### Phase 3 — OWASP Top 10 Review (`_docs/05_security/owasp_top_10_review.md`)

All 10 categories assessed against the SUT's actual surface. A01 (Broken Access Control) is N/A by design (single-tenant binary, no per-user RBAC). A02 through A10 each cleared with 0 findings beyond those already reported in Phases 1, 2, and 4. The closed-system threat model is itself the strongest A04 (Insecure Design) property and is enforced at three layers.

### Phase 4 — Configuration & Infrastructure (`_docs/05_security/config_infra_review.md`)

9 findings (4 Medium, 5 Low) — all container-hardening gaps in the production images. The test stack is well-secured (`internal: true` network, digest-pinned tile-cache-builder, public-boundary discipline on the runner). The gap is consistency: the project already KNOWS how to do this (see `e2e/fixtures/tile-cache-builder/Dockerfile`) — production images need to match that bar.

## Remediation Plan

### Recommended before next dependency-refresh cycle

| ID | Title | Effort | Coupling |
|---|---|---|---|
| F14 | Add non-root `USER` directive to `docker/companion-tier1.Dockerfile` + `docker/operator-orchestrator.Dockerfile` | 2 pts | Couples with F22 (chown WORKDIR) |
| F15 | Replace `pip install -e ".[dev]"` with runtime-only install in both production Dockerfiles | 2 pts | Standalone |
| F16 | Pin `ardupilot/mavproxy:latest` and `ardupilot/ardupilot-sitl:plane-stable` to explicit versions or SHA256 digests | 1 pt | Standalone |
| F1 | Bump `onnx` from 1.18.0 → 1.19+ when next dep cycle lands (GHSA-rcgw-4cgp-9q7q) | 2 pts | Couples with C7 model-loader review |

### Recommended at next hardening pass

| ID | Title | Effort |
|---|---|---|
| F13 | Add `assert strategy in KNOWN_*_STRATEGIES` at each `__import__` call site (defense-in-depth, redundant today) | 2 pts |
| F17 | Pin production base images by SHA256 digest, with explicit refresh cadence | 1 pt |
| F18 | Delete or fix the orphan `docker/mock-suite-sat-service.Dockerfile` | 1 pt |
| F19 | Remove unused `curl` from `docker/operator-orchestrator.Dockerfile` | 1 pt |
| F20 | Add upper bound to runner's `opencv-python` pin | 1 pt |
| F21 | Update `.env.example` to current secret paths + env var names | 1 pt |
| F22 | Coupled with F14 — chown WORKDIR after adding `USER` directive | included in F14 |

### Deferred / external

- **D-CROSS-CVE-1** — `opencv-python` 4.12.x bump deferred until `gtsam` ships a numpy-2 compatible release. Tracked at `_docs/_process_leftovers/2026-05-11_d_cross_cve_1_opencv_pin_deferred.md`. Residual exposure window is currently closed (CVE-2025-53644 no longer flagged at 4.11.0.86).
- **Per-flight rekeying audit cadence** — out of scope for this audit; will be revisited when D-PROJ-2 ingest endpoint lands and the upload contract is finalised.
- **Operator-workstation host hardening** (no-swap RESTRICT-OPS-1, etc.) — parent-suite responsibility; cross-referenced in `_docs/02_document/architecture.md` § Operating envelopes.

## Audit Method Footnote

- Phase 1 used `pip-audit` against a freshly resolved `pip freeze` of the SUT venv (the project's editable install was filtered out per the project-package exclusion rule).
- Phase 2 used `ripgrep` with the patterns enumerated in `.cursor/skills/security/SKILL.md` § Phase 2; every match was cross-validated by reading the call site.
- Phase 3 was a desk review against the OWASP 2021 list, with each category mapped to the SUT's actual production surface (see `static_analysis.md` "Surface Map") rather than a generic web-app threat model.
- Phase 4 was a desk review of all 6 Dockerfiles, 2 docker-compose files, all committed secrets, `.env.example`, and `.gitignore`.

## Self-Verification

- [x] Each phase has its own dedicated artefact and is cross-referenced here
- [x] Severity counts in the Executive Summary sum to the per-phase counts in the Outcomes section (22 = 12 + 1 + 0 + 9)
- [x] No finding is silently downgraded — every project-specific severity calibration is justified at the finding site
- [x] D-CROSS-CVE-1 leftover updated and cross-referenced; no stale or missing tracker entry
- [x] Remediation effort estimates use the user's complexity-points rule (2-3 pts default, 5 pts max for big items, no 13-point monolith)
