Description
What
docker-compose.yml is the documented standalone deploy target (docker-compose.yml:2-3 — “This is the deploy target (e.g. jules)”; docs/deployment/index.md:17-18 — “the baseline for a standalone deployment”). Its service block hardens resources and network exposure but not runtime privileges:
docker-compose.yml:20-57setsimage,pull_policy,env_file, three bind mounts (:24-34),command, loopback-onlyports(:40),stop_grace_period: 35s(:45),mem_limit/mem_reservation(:52-53) andrestart: unless-stopped(:57).- It sets no
read_only: true, nocap_drop: [ALL], nosecurity_opt: ["no-new-privileges:true"], and notmpfsfor/tmp. Those four tokens appear nowhere in the file (the only occurrences in the repo are the Helm chart’s, below).
The image compounds it. Dockerfile:43 is COPY --from=builder --chown=sf2loki:sf2loki /app /app and Dockerfile:48 is USER sf2loki (uid/gid 10001, created at Dockerfile:39-40). uid 10001 therefore owns /app, /app/.venv (the interpreter on PATH, Dockerfile:45) and the source tree, so with a writable root filesystem the service can rewrite the code it executes on the next start. Nothing in the image requires that ownership: UV_COMPILE_BYTECODE=1 (Dockerfile:15) pre-compiles installed packages at build time, and the runtime only ever reads /app.
The Helm chart already runs the same image fully hardened, which proves the posture is viable:
deploy/helm/values.yaml:169-174—readOnlyRootFilesystem: true,allowPrivilegeEscalation: false,capabilities.drop: [ALL].deploy/helm/values.yaml:163-168—runAsNonRoot,runAsUser/runAsGroup/fsGroup: 10001,seccompProfile: RuntimeDefault.deploy/helm/templates/deployment.yaml:159-164— only two writable paths: the state dir and a/tmpemptyDir.docs/deployment/kubernetes.md:143-144documents that posture, but only for Kubernetes.
Two constraints any fix must respect:
/tmpmust stay writable.src/sf2loki/salesforce/eventlogfile_client.py:310spools each EventLogFile body into aSpooledTemporaryFile(max_size=_SPOOL_MAX_MEMORY_BYTES);_SPOOL_MAX_MEMORY_BYTES = 8 * 1024 * 1024(:37). Above 8 MiB the spool spills totempfile.gettempdir(), soread_only: truewithout a writable/tmpbreaks ELF ingestion of any non-trivial file. This is why the chart mounts a/tmpemptyDir.- The three existing bind mounts are unaffected by
read_only— Docker exempts volume/bind targets, so/etc/sf2loki/config.yaml(ro),/etc/sf2loki/secrets(ro) and/var/lib/sf2loki(rw,docker-compose.yml:25-34, matchingconfig.docker.yaml:79) keep working.
Nothing enforces or documents this. .github/workflows/docker-security.yml delegates to the shared reusable image-CVE scan; no test references docker-compose.yml; docs/security.md covers secret handling and the unauthenticated health endpoints but not container runtime privileges; docs/deployment/index.md:86-90 “Known gaps” lists only the missing Terraform module.
Why it matters
Given any code-execution primitive inside the container (a dependency vulnerability on the Avro decode, CSV parse, protobuf, or HTTP paths), the writable-and-owned /app tree converts a transient compromise into a persistent one: the attacker overwrites a module under /app/.venv/lib/python3.14/site-packages/ (or the entrypoint package), and because restart: unless-stopped (docker-compose.yml:57) restarts the same container with the same writable layer, the implant is re-executed on every crash-restart until an operator explicitly recreates the container. The process holds live Salesforce credentials and a Grafana Cloud push token, so persistence means indefinite credential access and the ability to tamper with the audit-event stream being forwarded to Loki.
Missing security_opt: ["no-new-privileges:true"] additionally leaves the setuid-root binaries that ship in python:3.14-slim (su, mount, passwd, gpasswd, chsh, …) usable as an escalation path from uid 10001. cap_drop: [ALL] is defence in depth on top: the process already has an empty effective capability set because it runs as a non-root uid with no file or ambient capabilities, but dropping the bounding set removes what a successful escalation could inherit.
An identical compromise on the Helm deployment cannot modify code or persist (deploy/helm/values.yaml:169-174). The compose and ECS paths are the ones left exposed, and they are the paths the docs point standalone operators at.
Proposed approach
-
docker-compose.yml— add to thesf2lokiservice, with a comment explaining the/tmprequirement and pointing ateventlogfile_client.py’s spool:read_only: true tmpfs: # EventLogFile bodies spool to /tmp above 8 MiB # (salesforce/eventlogfile_client.py:37,310); read_only forbids any # other writable path, so /tmp must be an explicit tmpfs. - /tmp cap_drop: - ALL security_opt: - "no-new-privileges:true"Size the tmpfs if the host needs a bound (
- /tmp:size=64m), keeping headroom above the largest expected EventLogFile blob. -
Dockerfile:43— drop--chown=sf2loki:sf2lokiso the runtime tree is root-owned and world-readable, making code immutable to uid 10001 even whereread_onlyis forgotten (a hand-rolleddocker run, the ECS path, an operator-modified compose file). KeepUSER sf2loki(:48). Note: if the root project is installed editable (uv’s default for the workspace root), CPython can no longer write__pycache__under/app/src; that write failure is silently ignored, and the only cost is per-start bytecode recompilation of the project’s own modules. If that cost is measurable, either settool.uv.package/--no-editableso the project lands insite-packages(already byte-compiled byUV_COMPILE_BYTECODE=1,Dockerfile:15) or pre-create the__pycache__dirs in the builder stage — do not restore the chown. -
docker-compose.build.yml— confirm the local-build override still starts under the same flags (it only layersbuild:, so no change is expected). -
Docs — add the hardening flags and the
/tmprequirement todocs/deployment/index.md(Docker section,:15-38) and a “container runtime privileges” subsection todocs/security.mdstating that the shipped compose file and the Helm chart both run non-root, read-only-rootfs, all-capabilities-dropped, and that removing any of them is an operator decision. Extend the ECS guidance (docs/deployment/index.md:40-53) with the equivalent task-definition settings (readonlyRootFilesystem: true, a/tmptmpfs vialinuxParameters.tmpfs,linuxParameters.capabilities.drop: ["ALL"]). -
Regression gate — add
tests/test_deploy_compose_hardening.py(pureyaml.safe_load+ text assertions, no Docker required, same style astests/test_config_artifacts_drift.py) so the flags cannot silently regress, and assert the runtimeCOPYinDockerfilecarries no--chown. -
Live check before closing — run
docker compose up -dagainst a dev config and confirm the service reaches/readyz200 and that an EventLogFile larger than 8 MiB is ingested (the spool-spill path), rather than asserting it from the config alone.
Imported from GitHub issue #152 on 2026-08-14, when this repo migrated from GitHub Issues to Backlog.md. The original issue has been deleted; its verbatim body, labels and comments are preserved in archive/issues-dump.json (jq '.[] | select(.number == 152)' archive/issues-dump.json).
Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).
Acceptance Criteria
- #1
docker-compose.ymlsetsread_only: true,tmpfs: [/tmp],cap_drop: [ALL], andsecurity_opt: ["no-new-privileges:true"]on thesf2lokiservice, with a comment naming the/tmpspool dependency. - #2
Dockerfile:43no longer passes--chown=sf2loki:sf2loki;USER sf2loki(:48) is unchanged and the container still starts. - #3
tests/test_deploy_compose_hardening.pyparsesdocker-compose.ymland asserts all four hardening keys on thesf2lokiservice (read_only is True,ALLincap_drop,no-new-privileges:trueinsecurity_opt, a/tmpentry intmpfs). - #4 The same test asserts the three existing mounts survive:
/etc/sf2loki/config.yamland/etc/sf2loki/secretsread-only,/var/lib/sf2lokiwritable. - #5 The same test asserts the runtime-stage
COPY --from=builder ... /app /appline inDockerfilecontains no--chown. - #6
docs/deployment/index.mddocuments the four flags and why/tmpmust be writable;docs/security.mdgains a container-runtime-privileges subsection covering both compose and Helm. - #7
docs/deployment/index.mdECS section lists the equivalent task-definition settings (readonlyRootFilesystem,linuxParameters.tmpfsfor/tmp,linuxParameters.capabilities.drop). - #8 Live verification recorded on the issue:
docker compose up -dreaches/readyz200 with the flags applied, and an EventLogFile body exceeding 8 MiB is ingested end to end (exercises the/tmpspill). - #9
just gategreen.
Definition of Done
- #1 just gate is green (ruff check + ruff format –check + mypy src + pytest) — run it, don’t assert it
- #2 just gen-config run and its output committed, if config.py changed (CI drift gate fails otherwise)
- #3 committed straight to main with a conventional-commit message, and pushed