Harden the container: non-root, read-only rootfs, pinned digests (#60) #117

Merged
claude-bot merged 1 commit from feat/60-container-hardening into main 2026-09-04 19:04:25 +00:00
Contributor

Closes #60.

Warning

This needs one manual step on the dev server before the next deploy. The container now runs as uid 1000, and ./data is a host bind mount created by the old root container. The first start after this merges will stop with a message naming the uid it needs:

cd ~/projects/Iris-WLED && sudo chown -R 1000:1000 ./data

Nothing in the database changes — only the file owner moves. The check runs before migrations, so a missed chown is a clean stop, not a half-applied one. IRIS_UID/IRIS_GID in .env is the alternative if you'd rather not chown.

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 — so read_only: true cost nothing but a 64MB tmpfs for /tmp.

Digest pins are paired with pinDigests and auto-merged digest bumps in the same change, deliberately. An unmanaged pin is worse than the floating tag it replaces: python:3.14-slim is 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 says read_only would 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 --build was broken for anyone following CONTRIBUTING.md. .dockerignore patterns match from the context root, so the bare node_modules/ never matched frontend/node_modules. The context was shipping backend/.venv (153MB) and frontend/node_modules (217MB), and the symlink tree in node_modules/.bin failed the context load outright:

ERROR: failed to solve: invalid file request frontend/node_modules/.bin/acorn

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 own bom.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.txt doesn't list so Renovate can't see it) takes it past 81, which dropped pkg_resources. Nothing on the runtime path imports it — sentry_sdk's only reference is a < py3.8 fallback already wrapped in except ImportError: return, and the others are pytest and setuptools' own install-time script generation. Verified against the built image.

Scanners

hadolint gates 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, and USER is numeric so a runtime enforcing runAsNonRoot can actually verify it.

trivy reports from audit.yml alongside 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 :main image, since the OS layers are most of what a container scan is for and those come from the pinned base digest.

Also

Added a .gitattributes pinning *.sh to LF. startup.sh is the entrypoint; on a Windows clone with git's default autocrlf=true its shebang becomes #!/bin/sh\r and 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:

  • Boots healthy under --read-only --cap-drop ALL --security-opt no-new-privileges as uid 1000
  • All three write paths exercised: migrations create the DB, bulk-approve writes a backup to /app/data/backups, and the Calendarific cache dir is writable
  • Zero errors/tracebacks/permission denials in the container log
  • The failure path produces the intended message, tested with a read-only /app/data
  • trivy --pkg-types library reports 0 findings
  • hadolint clean; docker compose config renders as intended
  • uvx pre-commit run --all-files passes

Backend and frontend suites are untouched by this change and unaffected.

🤖 Generated with Claude Code

Closes #60. > [!WARNING] > **This needs one manual step on the dev server before the next deploy.** The container now runs as uid 1000, and `./data` is a host bind mount created by the old root container. The first start after this merges will stop with a message naming the uid it needs: > > ```sh > cd ~/projects/Iris-WLED && sudo chown -R 1000:1000 ./data > ``` > > Nothing in the database changes — only the file owner moves. The check runs *before* migrations, so a missed chown is a clean stop, not a half-applied one. `IRIS_UID`/`IRIS_GID` in `.env` is the alternative if you'd rather not chown. ### 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 — so `read_only: true` cost nothing but a 64MB tmpfs for `/tmp`. **Digest pins are paired with `pinDigests` and auto-merged digest bumps in the same change**, deliberately. An unmanaged pin is *worse* than the floating tag it replaces: `python:3.14-slim` is 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 *says* `read_only` would 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 --build` was broken for anyone following CONTRIBUTING.md.** `.dockerignore` patterns match from the context root, so the bare `node_modules/` never matched `frontend/node_modules`. The context was shipping `backend/.venv` (153MB) and `frontend/node_modules` (217MB), and the symlink tree in `node_modules/.bin` failed the context load outright: ``` ERROR: failed to solve: invalid file request frontend/node_modules/.bin/acorn ``` 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 own `bom.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.txt` doesn't list so Renovate can't see it) takes it past 81, which **dropped `pkg_resources`**. Nothing on the runtime path imports it — `sentry_sdk`'s only reference is a `< py3.8` fallback already wrapped in `except ImportError: return`, and the others are pytest and setuptools' own install-time script generation. Verified against the built image. ### Scanners `hadolint` **gates** 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, and `USER` is numeric so a runtime enforcing `runAsNonRoot` can actually verify it. `trivy` **reports** from `audit.yml` alongside 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 `:main` image, since the OS layers are most of what a container scan is for and those come from the pinned base digest. ### Also Added a `.gitattributes` pinning `*.sh` to LF. `startup.sh` is the entrypoint; on a Windows clone with git's default `autocrlf=true` its shebang becomes `#!/bin/sh\r` and 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: - Boots healthy under `--read-only --cap-drop ALL --security-opt no-new-privileges` as uid 1000 - All three write paths exercised: migrations create the DB, `bulk-approve` writes a backup to `/app/data/backups`, and the Calendarific cache dir is writable - Zero errors/tracebacks/permission denials in the container log - The failure path produces the intended message, tested with a read-only `/app/data` - `trivy --pkg-types library` reports 0 findings - `hadolint` clean; `docker compose config` renders as intended - `uvx pre-commit run --all-files` passes Backend and frontend suites are untouched by this change and unaffected. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Harden the container: non-root, read-only rootfs, pinned digests (#60)
All checks were successful
CI / Dockerfile lint (pull_request) Successful in 12s
CI / Alembic migration check (pull_request) Successful in 45s
CI / Pre-commit hooks (pull_request) Successful in 55s
CI / Frontend lint, test & build (pull_request) Successful in 1m16s
CI / Python lint & type-check (pull_request) Successful in 1m30s
CI / Python tests (pull_request) Successful in 2m28s
CI / Docker build, health smoke & E2E (pull_request) Successful in 1m52s
212d5e5ca4
Runs as uid 1000 with an immutable root filesystem, all capabilities
dropped and no-new-privileges set. Base images are 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 -- so read_only cost nothing but a
64MB tmpfs for /tmp.

CI now runs the whole E2E suite against a --read-only, capability-dropped
container and asserts the uid is not 0. Asserting that the compose file
says read_only would prove nothing; a runtime write path that only shows
up under load is precisely what a config-only check misses.

Digest pins are paired with Renovate's pinDigests and auto-merged digest
bumps in the same change. An unmanaged pin is worse than the floating tag
it replaces: python:3.14-slim is republished under the same tag whenever
Debian patches something, so a stale pin quietly stops receiving those.

Two things found while verifying rather than assuming:

- `.dockerignore` patterns match from the context root, so the bare
  `node_modules/` never matched `frontend/node_modules`. The context
  carried backend/.venv (153MB) and frontend/node_modules (217MB), and
  the symlink tree in node_modules/.bin failed the context load outright
  -- `docker compose up --build` did not work at all on a tree set up per
  CONTRIBUTING.md. Now 342MB -> 1.3MB.

- Both HIGH findings Trivy reported came from `pip/_vendor` (a vendored
  msgpack and setuptools, declared in pip's own bom.cdx.json), not from
  anything Iris installs, and neither was reachable since pip never runs
  in the container. There were two pips to remove -- the venv's and the
  base image's in /usr/local -- and removing only the first left the scan
  unchanged, which is how the second was found. The image scans clean.

hadolint gates CI: it reads one file in the repo and cannot spontaneously
redden an unrelated PR. Trivy reports weekly from audit.yml alongside the
dependency audits, for the reason #81 established.

BREAKING for existing deployments: ./data is a host bind mount created by
the old root container, so the first start after upgrading stops with a
message naming the uid it needs. `sudo chown -R 1000:1000 ./data` is the
whole fix, or set IRIS_UID/IRIS_GID to the current owner. The check runs
before migrations, so a missed chown cannot half-apply one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/60-container-hardening 2026-09-04 19:04:26 +00:00
Sign in to join this conversation.
No description provided.