Harden the container: non-root, read-only rootfs, pinned digests (#60) #117
No reviewers
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 milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/Iris-WLED!117
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/60-container-hardening"
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?
Closes #60.
What's in it
Runs as uid 1000 with an immutable root filesystem, all capabilities dropped, and
no-new-privileges. Base images pinned by digest, with Renovate keeping the pins current.Everything Iris writes was already confined to
/app/data— the SQLite database and its WAL, the APScheduler jobstore (the same database), backups, and the Calendarific cache — soread_only: truecost nothing but a 64MB tmpfs for/tmp.Digest pins are paired with
pinDigestsand auto-merged digest bumps in the same change, deliberately. 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 quietly stops receiving exactly the security rebuilds that motivate pinning.CI runs the whole Playwright E2E suite against a
--read-only, capability-dropped container and asserts the uid is not 0. Asserting that the compose file saysread_onlywould prove nothing — a runtime write path that only appears under load is precisely what a config-only check misses.Two things found by verifying rather than assuming
docker compose up --buildwas broken for anyone following CONTRIBUTING.md..dockerignorepatterns match from the context root, so the barenode_modules/never matchedfrontend/node_modules. The context was shippingbackend/.venv(153MB) andfrontend/node_modules(217MB), and the symlink tree innode_modules/.binfailed the context load outright:I hit this trying to build for the first check. Dev-tree patterns now carry
**/; measured context went 342MB → 1.3MB.Both HIGH Trivy findings were pip's, and there were two pips. The findings — a vendored msgpack 1.1.2 and setuptools 70.3.0 — come from
pip/_vendor, declared in pip's ownbom.cdx.json, not from anything Iris installs. Neither was reachable, since pip never executes in the running container. Removing the venv's pip left the scan completely unchanged, which is how I found the base image's second copy in/usr/local. With both gone the image scans clean on Python packages.Worth flagging one knock-on I checked rather than assumed: upgrading the venv's seeded setuptools (70.3.0, which
requirements.txtdoesn't list so Renovate can't see it) takes it past 81, which droppedpkg_resources. Nothing on the runtime path imports it —sentry_sdk's only reference is a< py3.8fallback already wrapped inexcept ImportError: return, and the others are pytest and setuptools' own install-time script generation. Verified against the built image.Scanners
hadolintgates CI — it reads one file in the repo and its ruleset only moves when the pinned image does, so it can't spontaneously redden an unrelated PR. It's clean; the one suppression (DL3008, apt version pinning) sits next to the instruction with its reason. It caught two things worth fixing properly: the healthcheck is now exec form, andUSERis numeric so a runtime enforcingrunAsNonRootcan actually verify it.trivyreports fromaudit.ymlalongside the dependency audits, per the convention #81 established — its findings come from a CVE feed and can turn red on a morning when nothing changed. It scans the published:mainimage, since the OS layers are most of what a container scan is for and those come from the pinned base digest.Also
Added a
.gitattributespinning*.shto LF.startup.shis the entrypoint; on a Windows clone with git's defaultautocrlf=trueits shebang becomes#!/bin/sh\rand the image fails at start with a "no such file or directory" naming an interpreter that plainly exists. The repo currently has no.gitattributes, so that was live — and I'm on Windows, so it was going to be my problem first.Verification
Every claim above was checked against a built image, not the config:
--read-only --cap-drop ALL --security-opt no-new-privilegesas uid 1000bulk-approvewrites a backup to/app/data/backups, and the Calendarific cache dir is writable/app/datatrivy --pkg-types libraryreports 0 findingshadolintclean;docker compose configrenders as intendeduvx pre-commit run --all-filespassesBackend and frontend suites are untouched by this change and unaffected.
🤖 Generated with Claude Code