Container hardening: non-root user, read-only rootfs, digest-pinned base images #60
Labels
No labels
area/ai
area/backend
area/frontend
area/infra
area/scheduler
area/wled
good-first-issue
priority/high
priority/low
priority/medium
type/bug
type/chore
type/ci-cd
type/docs
type/feature
type/qa
v1.0.0
v1.1.0
v1.2.0
v2.0.0
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/Iris-WLED#60
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
From the 2026-07-15 review follow-ups. Cheap hardening for an internet-adjacent self-hosted app:
/app/data(and the backup dir) are writable by itread_only: truerootfs in compose with explicit writable mounts (/app/data, tmpfs for/tmpif needed) — document any paths the app writes at runtime (Calendarific disk cache location, APScheduler jobstore, backups)pinDigests: true)Done — #117 merged, all seven CI jobs green (six existing plus the new Dockerfile lint).
⚠️ One manual step before the next deploy
The container now runs as uid 1000 and
./datais a host bind mount the old root container created:Startup checks writability before running migrations and exits naming the uid it needs, so a missed chown is a clean stop rather than a half-applied migration or an opaque "attempt to write a readonly database".
IRIS_UID/IRIS_GIDin.envis the alternative if chowning is not possible.Checkboxes
USERis numeric so a runtime enforcingrunAsNonRootcan verify it.read_only: truewith explicit writable mounts — 64MB tmpfs for/tmp, pluscap_drop: ALLandno-new-privileges. Runtime write paths documented indocs/setup.mdand confirmed by exercising each one: the SQLite database and WAL, the APScheduler jobstore (the same database),/app/data/backups, and the Calendarific cache.pinDigests: trueand auto-merged digest bumps.audit.yml.Notes worth keeping
Digest pinning is only safe paired with the automation. An unmanaged pin is worse than the floating tag it replaces:
python:3.14-slimis republished under the same tag whenever Debian patches something, so a stale pin silently stops receiving exactly the security rebuilds that justify pinning. HencepinDigestsand the auto-merge rule landing in the same change rather than as a follow-up.CI boots the hardened container rather than inspecting the compose file. The E2E suite runs against
--read-only --cap-drop ALLwith an assertion that the uid is not 0. A runtime write path that only appears under load is exactly what a config-only assertion misses.Two bugs found by building rather than reading
docker compose up --buildwas already broken for anyone who had followed CONTRIBUTING.md..dockerignorepatterns match from the context root, so the barenode_modules/never matchedfrontend/node_modules; the context carriedbackend/.venv(153MB) andfrontend/node_modules(217MB), and the symlink tree innode_modules/.binfailed the context load outright withinvalid file request frontend/node_modules/.bin/acorn. Fixed with**/prefixes — 342MB → 1.3MB.Both HIGH Trivy findings were pip's, and there were two pips. The vendored msgpack and setuptools come from
pip/_vendor, declared in pip's ownbom.cdx.json— not from anything Iris installs, and unreachable since pip never executes in the container. Removing the venv's pip left the scan completely unchanged, which is how the base image's second copy in/usr/localsurfaced. Both gone; the image scans clean.One knock-on checked rather than assumed: upgrading the venv's seeded setuptools takes it past 81, which dropped
pkg_resources. Nothing on the runtime path imports it —sentry_sdk's only reference is a< py3.8fallback already insideexcept ImportError: return.Incidental
Added a
.gitattributespinning*.shto LF. The repo had none, so a Windows clone (git's defaultautocrlf=truethere) would turn the entrypoint's shebang into#!/bin/sh\rand the image built from that tree fails at start with a "no such file or directory" naming an interpreter that plainly exists.