Summary
Read-only audit of every Dockerfile, docker-compose file, locally-built image, and CI workflow across smartem-decisions, smartem-devtools, and smartem-frontend, looking for credentials baked into image layers. No rebuilds, no pushes, no rotations. Current Dockerfiles produce clean images; one leak exists in an already-published GHCR image from June 2025, and a handful of hardening warnings apply across the three repos.
This issue is filed in smartem-devtools because that's where workspace-wide concerns live. The actual leak is in ghcr.io/diamondlightsource/smartem-decisions:latest, so remediation work will land in smartem-decisions.
Full report (local, in workspace scratch, not in any repo): tmp/docker-audit/docs/security/docker-audit-2026-05-14.md. Audit scratch (extracted rootfs, raw .env from the leaked image) is at tmp/docker-audit/rootfs-*/ and tmp/docker-audit/leaked.env* — should be deleted once this issue is triaged.
Scope
- Dockerfiles reviewed:
smartem-decisions/Dockerfile, smartem-decisions/Dockerfile.dev, smartem-frontend/Dockerfile
- docker-compose reviewed:
smartem-devtools/keycloak-mock/docker-compose.yml
- Images scanned:
ghcr.io/diamondlightsource/smartem-decisions:latest, smartem-decisions:latest (local, 6 mo), smartem-decisions:keycloak-local (local, current)
- CI workflows reviewed: 9 across the three repos. Only
smartem-decisions/.github/workflows/release-smartem-decisions.yml builds and publishes a container image.
- smartem-devtools has no Dockerfile; smartem-frontend has a Dockerfile but no CI builds it.
Methodology
- Static review of each Dockerfile against the checklist in the original audit brief (COPY hygiene, ARG-as-secret, USER directive, multi-stage hygiene, .dockerignore coverage).
docker history --no-trunc and docker inspect for each local image; checked Env, Labels, Cmd, Entrypoint, User, and every layer's CreatedBy.
- Filesystem layer scan via
docker create + docker export (per-image rootfs) + gitleaks detect --no-git. trufflehog 3.94.3 was unusable for this — its docker --image subcommand insists on registry-pulling rather than using the local daemon, and the GHCR repo is private.
- CI grep for
build-arg, BUILD_ARG, NPM_TOKEN, GH_TOKEN, GITHUB_TOKEN, AWS_*, DOCKER_PASSWORD, REGISTRY_PASSWORD.
- For the one leak found: registry-visibility check (
curl -sI ghcr.io/v2/.../manifests/latest), provenance check via git show <rev>:.env.example, hash-preimage check.
Findings
Critical (contingent on one DLS judgement call)
.env and .env.example baked into ghcr.io/diamondlightsource/smartem-decisions:latest
- Image labels:
revision=26a8be4274c505ac01a2d0c7943af398ba44d0bd, version=test-v0.0.2, created=2025-06-02T11:09:29Z.
- Layer 5 of the image (
COPY /app/, ~6.26 MB) contains /app/.env and /app/.env.example — byte-identical, 1466 bytes each.
Config.Cmd = ["cd /app && source .env && python -m smartem_decisions.model.database && uvicorn ..."] — the container actively sources .env at boot.
- Audience for the leak: GHCR repo is private. Anonymous manifest fetch returns
HTTP 401; the ghcr.io/token endpoint refuses to issue an anonymous bearer token for the pull scope. Only DLS-authorised GHCR pullers can extract the file. This significantly narrows urgency.
- What is in the leaked
.env (fingerprints, not raw values):
POSTGRES_USER = literal username — placeholder, not sensitive
POSTGRES_PASSWORD = literal password — placeholder, not sensitive
RABBITMQ_USER = literal username — placeholder
RABBITMQ_PASSWORD = literal password — placeholder
GRAYLOG_ROOT_PASSWORD_SHA2 = 64-char SHA-256, fp "e3c…951". Verified placeholder — equal to echo -n "yourpassword" | sha256sum byte-for-byte. No rotation needed.
GRAYLOG_PASSWORD_SECRET = 96-char pwgen-shaped string, fp "bQai…Prx". Unresolved. Looks real, but I can't tell from the file alone whether it was ever deployed as a Graylog cluster secret. This is the single judgement call below.
- Provenance: the same values exist in the smartem-decisions git history at revision
26a8be4 (git show 26a8be4:.env.example reproduces them verbatim), so anyone with repo read access already sees them via git log. The image is not the exclusive leak channel.
- Current state: today's Dockerfile (production) installs from PyPI and only copies
alembic.ini + entrypoint.sh. Today's Dockerfile.dev does COPY . . but .dockerignore excludes .env*. The fresh build smartem-decisions:keycloak-local has 0 leaks per gitleaks. Today's entrypoint.sh sources .env only when KUBERNETES_SERVICE_HOST is unset (defensive).
Warnings
-
smartem-decisions:latest local (6 months old) carries /app/.secrets.baseline — detect-secrets metadata listing where secret-shaped strings live in the codebase with file/line/hash. Not raw credentials but a recipe of where to look. gitleaks flagged 2 generic-api-key hits in this file. Newer builds don't ship it.
-
smartem-frontend/.dockerignore is dangerously thin — only 4 entries (.react-router, build, node_modules, README.md). The Dockerfile does COPY . /app in two stages. The repo currently has apps/smartem/.env.local. The final image only ships /app/build, but: (a) intermediate stages carry raw .env.local and would leak if registry-backed build cache is enabled; (b) Vite inlines VITE_* into the bundle. Today's VITE_* values are public-by-design OIDC public-client fields (VITE_KEYCLOAK_URL, VITE_KEYCLOAK_REALM, VITE_KEYCLOAK_CLIENT_ID, VITE_AUTH_ENABLED), so no current leak — but no guardrail either. The Dockerfile is not built by any CI workflow, so there is nothing currently published to remediate.
-
smartem-frontend Dockerfile has no CMD/ENTRYPOINT and no USER — final stage copies artefacts but never declares how to serve them; runs as root by default. Not a secrets issue, flagged for hygiene.
-
smartem-decisions/Dockerfile.dev final stage copies /app/ wholesale — line 81, COPY --from=build /app/ /app/. Current .dockerignore is comprehensive, so this is image bloat rather than a secrets leak today, but every new file at the repo root is one .dockerignore-rule-away from leak.
-
Neither smartem-decisions/Dockerfile nor Dockerfile.dev sets USER in the final stage. The conditional useradd only fires when groupid != 0, and USER is never declared. Documented as intentional ("suitable for CI/CD pipelines and local development"); flagged for awareness if the K8s runAsUser override is ever removed.
-
smartem-devtools/.env.k8s.development sits unencrypted at the repo root with real DOCKER_USERNAME, POSTGRES_PASSWORD, RABBITMQ_PASSWORD. Properly gitignored, and the repo has no Dockerfile so nothing can bake it in. Flagged for awareness — if a Dockerfile is ever added here, this file plus a thin .dockerignore becomes a future leak.
CI / build-arg review
- The only
build-arg in any workflow is SMARTEM_VERSION=... in smartem-decisions/.github/workflows/release-smartem-decisions.yml:471 — a non-secret version string.
- All
GITHUB_TOKEN / GH_TOKEN references are runtime-only (passed via env: to docker/login-action or softprops/action-gh-release); none are passed as --build-arg.
- No
NPM_TOKEN, AWS_*, DOCKER_PASSWORD, REGISTRY_PASSWORD patterns.
Tooling notes
- trufflehog v3.94.3 unusable here —
docker --image insists on registry-pulling, ignoring the local daemon. Worked around with docker create + docker export + gitleaks 8.30.0.
- trivy and dockle not installed; skipped per audit brief.
Recommended next actions
Ordered. Nothing applied — left as a checklist.
Conversation-log hygiene heads-up
During provenance verification I ran git show 26a8be4:.env.example, which printed the raw GRAYLOG_PASSWORD_SECRET value (96 chars between quotes) into the audit session's tool output. The report and this issue redact to fingerprints, but the raw value lives in the Claude Code transcript for that session. If any part of that transcript gets pasted into another ticket, Slack message, PR description, or another LLM, scrub that one tool-output block first.
Summary
Read-only audit of every Dockerfile, docker-compose file, locally-built image, and CI workflow across smartem-decisions, smartem-devtools, and smartem-frontend, looking for credentials baked into image layers. No rebuilds, no pushes, no rotations. Current Dockerfiles produce clean images; one leak exists in an already-published GHCR image from June 2025, and a handful of hardening warnings apply across the three repos.
This issue is filed in smartem-devtools because that's where workspace-wide concerns live. The actual leak is in
ghcr.io/diamondlightsource/smartem-decisions:latest, so remediation work will land in smartem-decisions.Full report (local, in workspace scratch, not in any repo):
tmp/docker-audit/docs/security/docker-audit-2026-05-14.md. Audit scratch (extracted rootfs, raw.envfrom the leaked image) is attmp/docker-audit/rootfs-*/andtmp/docker-audit/leaked.env*— should be deleted once this issue is triaged.Scope
smartem-decisions/Dockerfile,smartem-decisions/Dockerfile.dev,smartem-frontend/Dockerfilesmartem-devtools/keycloak-mock/docker-compose.ymlghcr.io/diamondlightsource/smartem-decisions:latest,smartem-decisions:latest(local, 6 mo),smartem-decisions:keycloak-local(local, current)smartem-decisions/.github/workflows/release-smartem-decisions.ymlbuilds and publishes a container image.Methodology
docker history --no-truncanddocker inspectfor each local image; checked Env, Labels, Cmd, Entrypoint, User, and every layer's CreatedBy.docker create+docker export(per-image rootfs) +gitleaks detect --no-git. trufflehog 3.94.3 was unusable for this — itsdocker --imagesubcommand insists on registry-pulling rather than using the local daemon, and the GHCR repo is private.build-arg,BUILD_ARG,NPM_TOKEN,GH_TOKEN,GITHUB_TOKEN,AWS_*,DOCKER_PASSWORD,REGISTRY_PASSWORD.curl -sI ghcr.io/v2/.../manifests/latest), provenance check viagit show <rev>:.env.example, hash-preimage check.Findings
Critical (contingent on one DLS judgement call)
.envand.env.examplebaked intoghcr.io/diamondlightsource/smartem-decisions:latestrevision=26a8be4274c505ac01a2d0c7943af398ba44d0bd,version=test-v0.0.2,created=2025-06-02T11:09:29Z.COPY /app/, ~6.26 MB) contains/app/.envand/app/.env.example— byte-identical, 1466 bytes each.Config.Cmd=["cd /app && source .env && python -m smartem_decisions.model.database && uvicorn ..."]— the container actively sources.envat boot.HTTP 401; theghcr.io/tokenendpoint refuses to issue an anonymous bearer token for the pull scope. Only DLS-authorised GHCR pullers can extract the file. This significantly narrows urgency..env(fingerprints, not raw values):POSTGRES_USER= literalusername— placeholder, not sensitivePOSTGRES_PASSWORD= literalpassword— placeholder, not sensitiveRABBITMQ_USER= literalusername— placeholderRABBITMQ_PASSWORD= literalpassword— placeholderGRAYLOG_ROOT_PASSWORD_SHA2= 64-char SHA-256, fp"e3c…951". Verified placeholder — equal toecho -n "yourpassword" | sha256sumbyte-for-byte. No rotation needed.GRAYLOG_PASSWORD_SECRET= 96-charpwgen-shaped string, fp"bQai…Prx". Unresolved. Looks real, but I can't tell from the file alone whether it was ever deployed as a Graylog cluster secret. This is the single judgement call below.26a8be4(git show 26a8be4:.env.examplereproduces them verbatim), so anyone with repo read access already sees them viagit log. The image is not the exclusive leak channel.alembic.ini+entrypoint.sh. Today'sDockerfile.devdoesCOPY . .but.dockerignoreexcludes.env*. The fresh buildsmartem-decisions:keycloak-localhas 0 leaks per gitleaks. Today'sentrypoint.shsources.envonly whenKUBERNETES_SERVICE_HOSTis unset (defensive).Warnings
smartem-decisions:latestlocal (6 months old) carries/app/.secrets.baseline— detect-secrets metadata listing where secret-shaped strings live in the codebase with file/line/hash. Not raw credentials but a recipe of where to look. gitleaks flagged 2 generic-api-key hits in this file. Newer builds don't ship it.smartem-frontend/.dockerignoreis dangerously thin — only 4 entries (.react-router,build,node_modules,README.md). The Dockerfile doesCOPY . /appin two stages. The repo currently hasapps/smartem/.env.local. The final image only ships/app/build, but: (a) intermediate stages carry raw.env.localand would leak if registry-backed build cache is enabled; (b) Vite inlinesVITE_*into the bundle. Today'sVITE_*values are public-by-design OIDC public-client fields (VITE_KEYCLOAK_URL,VITE_KEYCLOAK_REALM,VITE_KEYCLOAK_CLIENT_ID,VITE_AUTH_ENABLED), so no current leak — but no guardrail either. The Dockerfile is not built by any CI workflow, so there is nothing currently published to remediate.smartem-frontendDockerfile has no CMD/ENTRYPOINT and no USER — final stage copies artefacts but never declares how to serve them; runs as root by default. Not a secrets issue, flagged for hygiene.smartem-decisions/Dockerfile.devfinal stage copies/app/wholesale — line 81,COPY --from=build /app/ /app/. Current.dockerignoreis comprehensive, so this is image bloat rather than a secrets leak today, but every new file at the repo root is one.dockerignore-rule-away from leak.Neither
smartem-decisions/DockerfilenorDockerfile.devsetsUSERin the final stage. The conditionaluseraddonly fires whengroupid != 0, andUSERis never declared. Documented as intentional ("suitable for CI/CD pipelines and local development"); flagged for awareness if the K8srunAsUseroverride is ever removed.smartem-devtools/.env.k8s.developmentsits unencrypted at the repo root with realDOCKER_USERNAME,POSTGRES_PASSWORD,RABBITMQ_PASSWORD. Properly gitignored, and the repo has no Dockerfile so nothing can bake it in. Flagged for awareness — if a Dockerfile is ever added here, this file plus a thin.dockerignorebecomes a future leak.CI / build-arg review
build-argin any workflow isSMARTEM_VERSION=...insmartem-decisions/.github/workflows/release-smartem-decisions.yml:471— a non-secret version string.GITHUB_TOKEN/GH_TOKENreferences are runtime-only (passed viaenv:todocker/login-actionorsoftprops/action-gh-release); none are passed as--build-arg.NPM_TOKEN,AWS_*,DOCKER_PASSWORD,REGISTRY_PASSWORDpatterns.Tooling notes
docker --imageinsists on registry-pulling, ignoring the local daemon. Worked around withdocker create+docker export+ gitleaks 8.30.0.Recommended next actions
Ordered. Nothing applied — left as a checklist.
GRAYLOG_PASSWORD_SECRETfp"bQai…Prx"was ever used as a real Graylog clusterpassword_secret. If yes, rotate (accepting that all encrypted Graylog rows and active sessions invalidate, per the in-file comment); if no, the leak is fully placeholder.ghcr.io/diamondlightsource/smartem-decisions:latestand thetest-v0.0.2digest, then republishlatestfrom the current Dockerfile.smartem-decisions— only needed if the Graylog secret turns out to have been real. Either accept the historical leak (after rotation) or scrub withgit filter-repo/ BFG.smartem-frontend/.dockerignorebefore that image is ever built and published. Deny-list the standard sensitive-file set or flip to allow-list.smartem-decisions/Dockerfile.devfinal stage to copy only/venv/,entrypoint.sh, andalembic.inirather than the whole/app/. Defence in depth.rm -rf /home/vredchenko/dev/ERIC/tmp/docker-audit/rootfs-* /home/vredchenko/dev/ERIC/tmp/docker-audit/leaked.env /home/vredchenko/dev/ERIC/tmp/docker-audit/leaked.env.example. The report itself (docker-audit-2026-05-14.md) can stay or be moved into a realdocs/security/location.Conversation-log hygiene heads-up
During provenance verification I ran
git show 26a8be4:.env.example, which printed the rawGRAYLOG_PASSWORD_SECRETvalue (96 chars between quotes) into the audit session's tool output. The report and this issue redact to fingerprints, but the raw value lives in the Claude Code transcript for that session. If any part of that transcript gets pasted into another ticket, Slack message, PR description, or another LLM, scrub that one tool-output block first.