13 KiB
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 |
|
|
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
promtailservice 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 thetest_docuvault_app_cannot_create_tabletest silently skips rather than failing when the role is absent — defeating the purpose of the DDL-privilege gate. - Five warnings: the
migrateservice mounts the entire source tree as a writable volume (defeatingread_only), theminio-initservice has no hardening at all, version-pinnedlatesttags on three images, the integration test class relies on execution order without@pytest.mark.order, andcelery-beatis missingCLOUD_CREDS_KEYand JWT key env vars. - Three info items: README/AGENTS.md version skew, the
backendservice unnecessarily receivesDATABASE_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 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:
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