--- phase: 12-cloud-resource-foundation reviewed: 2026-06-20T00:00:00Z depth: standard files_reviewed: 10 files_reviewed_list: - docker-compose.yml - backend/tests/test_compose_migrations.py - backend/tests/test_migration_0006.py - backend/main.py - frontend/package.json - AGENTS.md - README.md - RUNBOOK.md - SECURITY.md - CLAUDE.md findings: critical: 3 warning: 5 info: 3 total: 11 status: issues_found --- # Phase 12: Code Review Report **Reviewed:** 2026-06-20 **Depth:** standard **Files Reviewed:** 10 **Status:** issues_found ## Summary The Phase 12 gap-closure implementation adds a one-shot `migrate` service to docker-compose.yml, seven static compose-invariant tests, and a 12-test integration suite for the 0005→0006 Alembic migration. The compose hardening approach (read_only, cap_drop ALL, no-new-privileges, restart: no) is correct in concept. However, several concrete defects were found: - Three critical issues: the `promtail` service mounts the Docker socket with no restrictions (full container escape vector), the Grafana admin password has a hardcoded default with no boot-time override enforcement, and the `test_docuvault_app_cannot_create_table` test silently skips rather than failing when the role is absent — defeating the purpose of the DDL-privilege gate. - Five warnings: the `migrate` service mounts the entire source tree as a writable volume (defeating `read_only`), the `minio-init` service has no hardening at all, version-pinned `latest` tags on three images, the integration test class relies on execution order without `@pytest.mark.order`, and `celery-beat` is missing `CLOUD_CREDS_KEY` and JWT key env vars. - Three info items: README/AGENTS.md version skew, the `backend` service unnecessarily receives `DATABASE_MIGRATE_URL`, and the health endpoint leaks Python exception class names to callers. --- ## Critical Issues ### CR-01: promtail mounts Docker socket — full container escape **File:** `docker-compose.yml:259` **Issue:** The `promtail` service bind-mounts `/var/run/docker.sock` into the container with no read-only flag and no capability restriction. Docker socket access is equivalent to unrestricted root on the host: any process inside promtail that is compromised (or any malicious log entry that achieves code execution) can spawn privileged containers, exfiltrate secrets from all other containers, or escape to the host entirely. ```yaml volumes: - /var/run/docker.sock:/var/run/docker.sock # no :ro, no hardening ``` **Fix:** Mount the socket read-only and add container hardening: ```yaml promtail: image: grafana/promtail:2.9.10 # pin version (see WR-02) volumes: - ./docker/loki/promtail-config.yaml:/etc/promtail/config.yaml - /var/lib/docker/containers:/var/lib/docker/containers:ro - /var/run/docker.sock:/var/run/docker.sock:ro cap_drop: - ALL security_opt: - "no-new-privileges:true" read_only: true tmpfs: - "/tmp:mode=1777" ``` Even with `:ro` the socket still grants read access to container metadata (env vars, image names). For production, prefer the promtail `file`-based log scraping mode and remove the socket entirely. --- ### CR-02: Grafana admin password has hardcoded `changeme` default — no enforcement gate **File:** `docker-compose.yml:271` **Issue:** `GF_SECURITY_ADMIN_PASSWORD` falls back to the literal string `changeme`: ```yaml - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-changeme} ``` If an operator deploys without setting `GRAFANA_ADMIN_PASSWORD` in `.env`, Grafana starts with a well-known default credential. Grafana has full access to Loki logs, which contain correlation IDs, path information, and potentially PII in error messages. There is no startup check that rejects the default. The CLAUDE.md Security Protocol requires "no default credentials". **Fix:** Remove the default fallback — use a strict variable reference so Docker Compose fails at startup if the variable is unset: ```yaml - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD must be set in .env} ``` Apply the same pattern to `GF_SECURITY_ADMIN_USER`. Update `.env.example` accordingly. --- ### CR-03: `test_docuvault_app_cannot_create_table` silently skips when the role is absent — gate is toothless **File:** `backend/tests/test_migration_0006.py:269-288` **Issue:** The test that enforces "DML-only app role cannot run DDL" calls `pytest.skip()` when the `docuvault_app` role does not exist in the integration database: ```python if row is None: pytest.skip("docuvault_app role does not exist in the integration database") ``` A `SKIP` exit is indistinguishable from a passing test in the `pytest -q` summary the security gate uses (12 skipped — correct behavior). If an integration environment is misconfigured and the role was never created, the privilege-escalation gate silently passes instead of catching the gap. The skip also means the test cannot be run in CI without the exact role present — which makes the gate depend on an undocumented pre-condition rather than failing loudly. **Fix:** Change `pytest.skip` to `pytest.fail` so a missing role is treated as a configuration error that must be resolved before the gate passes. If the role genuinely cannot exist in all environments, add an explicit environment variable (`ASSERT_APP_ROLE_EXISTS=1`) that controls whether the absent-role branch is a skip or a failure, defaulting to failure: ```python if row is None: msg = ( "docuvault_app role does not exist in the integration database. " "Create the role with USAGE-only privilege or set SKIP_ROLE_CHECK=1 to bypass." ) if os.environ.get("SKIP_ROLE_CHECK") == "1": pytest.skip(msg) pytest.fail(msg) ``` --- ## Warnings ### WR-01: `migrate` source-code volume defeats `read_only: true` **File:** `docker-compose.yml:107-108` **Issue:** The migrate service mounts the entire backend source tree: ```yaml volumes: - ./backend:/app ``` This bind-mount is writable by default. A compromised or buggy migration script running under this service could modify source files on the host (`/app/alembic/`, `main.py`, etc.) even though the container filesystem is otherwise read-only. The `read_only: true` flag only protects the container layer, not the bind-mounted host path. The same issue exists for `backend`, `celery-worker`, and `celery-beat`, but is most severe for `migrate` because Alembic runs as the DDL-capable role. **Fix:** For the `migrate` service, mount the backend directory read-only: ```yaml volumes: - ./backend:/app:ro - /tmp # if alembic needs to write temp files, use a tmpfs ``` This is already present for the postgres initdb volume at line 10 (`:ro`). Apply the same pattern to the migrate service. Production deployments should bake the migration scripts into the image and omit the bind-mount entirely. --- ### WR-02: Three services use `latest` image tags — non-deterministic deployments **File:** `docker-compose.yml:19, 245, 254, 264` **Issue:** `minio/minio:latest`, `grafana/loki:latest`, `grafana/promtail:latest`, and `grafana/grafana:latest` all use floating `latest` tags. CLAUDE.md mandates pinned exact versions for security-critical packages. A `latest` tag can silently introduce breaking changes or vulnerabilities on the next `docker compose pull`. The SECURITY.md security gate checklist requires `docker scout` CVE scanning, which only produces reproducible results against pinned versions. **Fix:** Pin each image to a specific digest or semver tag: ```yaml minio: image: minio/minio:RELEASE.2024-11-07T00-52-20Z loki: image: grafana/loki:3.3.2 promtail: image: grafana/promtail:3.3.2 grafana: image: grafana/grafana:11.4.0 ``` --- ### WR-03: `minio-init` service has no container hardening **File:** `docker-compose.yml:40-87` **Issue:** The `minio-init` service runs an inline shell script that holds both the MinIO root credentials and the application access key/secret. Unlike every other service, it has no `read_only`, `cap_drop`, `security_opt`, or `restart` policy. If this container is compromised or the shell script is manipulated via a volume-mount attack, the attacker has full MinIO admin access (root credentials are present). It also has no `restart: "no"`, meaning Docker's default restart policy could cause it to re-run. **Fix:** ```yaml minio-init: image: minio/mc:latest restart: "no" cap_drop: - ALL security_opt: - "no-new-privileges:true" read_only: true tmpfs: - "/tmp:mode=1777" ``` --- ### WR-04: Integration test class relies on implicit execution order without ordering enforcement **File:** `backend/tests/test_migration_0006.py:137-288` **Issue:** `TestMigration0005To0006` contains 12 test methods that must run in definition order (`test_upgrade_to_0005` before `test_0005_lacks_phase12_tables` before `test_upgrade_to_head` before schema-assertion tests). pytest does not guarantee execution order within a class by default, especially when collected by multiple workers (`pytest-xdist`) or when tests are re-ordered by plugins. If `test_upgrade_to_head` runs before `test_upgrade_to_0005`, the "lacks_phase12_tables" assertions will fail spuriously — or worse, pass because the DB was already at head from a prior test run, hiding a real regression. The module-scoped `pg_conn` fixture also means any test that leaves the DB in a dirty state (partial upgrade) will corrupt all subsequent tests in the module. **Fix:** Add `@pytest.mark.order` (via `pytest-ordering` plugin) or restructure as sequential functions with explicit `depends` markers. Alternatively, use a single test function that sequences the steps explicitly and rolls back via a fixture teardown: ```python def test_full_0005_to_0006_upgrade_cycle(migrate_dsn, pg_conn): _run_alembic("0005", dsn=migrate_dsn) # assert 0005 state _run_alembic("head", dsn=migrate_dsn) # assert 0006 state ``` --- ### WR-05: `celery-beat` missing `CLOUD_CREDS_KEY`, `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` env vars **File:** `docker-compose.yml:212-243` **Issue:** The `celery-beat` service environment block (lines 212–223) is missing `CLOUD_CREDS_KEY`, `JWT_PRIVATE_KEY`, and `JWT_PUBLIC_KEY`. These are present in `celery-worker` (lines 171–186). If a scheduled Celery beat task ever needs to decrypt cloud credentials (e.g., a future refresh task scheduled via beat rather than triggered from the browse endpoint) or verify a JWT, it will raise a `KeyError` or produce an unintelligible `settings` validation error at runtime rather than at startup. `celery-worker` already carries all three, indicating the intent is for all Celery processes to have access to them. **Fix:** Add the missing variables to `celery-beat`: ```yaml celery-beat: environment: - CLOUD_CREDS_KEY=${CLOUD_CREDS_KEY} - JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY} - JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY} ``` --- ## Info ### IN-01: README version (0.2.0) diverges from backend/main.py and AGENTS.md (0.2.1) **File:** `README.md:3` **Issue:** README.md reports `Version 0.2.0 — Alpha` while `backend/main.py:247` and `frontend/package.json` (version `0.2.1`) and `AGENTS.md` all reflect `0.2.1`. CLAUDE.md mandates a version bump in both `backend/main.py` and `frontend/package.json` and notes these are the "sources of truth". README was not updated after the Phase 12 gap-closure patch bump. **Fix:** Update `README.md:3` to `**Version 0.2.1 — Alpha**`. --- ### IN-02: `backend` service unnecessarily receives `DATABASE_MIGRATE_URL` **File:** `docker-compose.yml:129` **Issue:** The `backend` service environment block includes `DATABASE_MIGRATE_URL`. The application service uses `DATABASE_URL` (DML-only role). Providing the DDL-capable migrate URL to the application process means that if any code path in the app accidentally reads `DATABASE_MIGRATE_URL` from the environment and uses it (e.g., a misconfigured Alembic `env.py` fallback), it would operate with DDL privileges. The principle of least privilege requires the application service to hold only the credentials it needs. **Fix:** Remove `DATABASE_MIGRATE_URL` from the `backend` (and `celery-worker`) environment blocks. It is only needed by the `migrate` service. --- ### IN-03: `/health` endpoint leaks Python exception class names in response body **File:** `backend/main.py:299, 308` **Issue:** The health endpoint renders exceptions as `f"error: {type(e).__name__}: {e}"`: ```python checks["postgres"] = f"error: {type(e).__name__}: {e}" checks["minio"] = f"error: {type(e).__name__}: {e}" ``` The inline comment at line 289 acknowledges this ("acceptable for an internal/dev endpoint in Phase 1. Phase 2 will trim…"), but Phase 12 is complete and the endpoint is still exposing exception internals. Exception messages from psycopg or the MinIO SDK may include connection strings, hostnames, or partial credentials. The CLAUDE.md Security Protocol requires "no stack traces in API responses". **Fix:** Trim the error payload to a safe, opaque string for any path that could be publicly reachable: ```python checks["postgres"] = "error" checks["minio"] = "error" ``` Log the full exception to structlog at WARNING level for observability without exposing it in the response body. --- _Reviewed: 2026-06-20_ _Reviewer: Claude (gsd-code-reviewer)_ _Depth: standard_