Files
kite/.planning/phases/12-cloud-resource-foundation/12-05-REVIEW.md
T

13 KiB
Raw Blame History

phase, reviewed, depth, files_reviewed, files_reviewed_list, findings, status
phase reviewed depth files_reviewed files_reviewed_list findings status
12-cloud-resource-foundation 2026-06-20T00:00:00Z standard 10
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
critical warning info total
3 5 3 11
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.

volumes:
  - /var/run/docker.sock:/var/run/docker.sock   # no :ro, no hardening

Fix: Mount the socket read-only and add container hardening:

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:

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

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

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:

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:

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:

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:

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:

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:

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 212223) is missing CLOUD_CREDS_KEY, JWT_PRIVATE_KEY, and JWT_PUBLIC_KEY. These are present in celery-worker (lines 171186). 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:

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

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:

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