From 92959cac38f824df4b4786e2d82cee7acf94f759 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Wed, 7 Oct 2026 22:16:06 +0200 Subject: [PATCH] Run the pod as non-root with a read-only filesystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of the same security-hardening pass as the last three commits. Neither the Dockerfile nor the chart's Deployment set any securityContext at all, so the container ran as root by default — scratch has no /etc/passwd for a USER directive to resolve against, so nobody had set one. Dockerfile now ends with USER 65532:65532 (numeric, since scratch has no user database; 65532 is the common "nonroot" convention, distroless's own uid). The chart's Deployment adds a matching pod-level securityContext (runAsNonRoot, runAsUser/runAsGroup: 65532, seccompProfile: RuntimeDefault) plus per-container hardening (allowPrivilegeEscalation: false, capabilities dropped, readOnlyRootFilesystem: true) on both the app container and the wait-for-postgres init container — neither writes anything to disk, so the root filesystem can stay read-only. Verified with helm-lint and a manual `helm template` render of both the terdut-server and terdut-demo charts. Not yet verified: an actual pod starting with these in place — readOnlyRootFilesystem is exactly where a non-obvious write (a temp file, a cache dir) would surface as a crash rather than a lint error, so that needs a real rollout to confirm. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Dockerfile | 6 +++++ .../terdut-server/templates/deployment.yaml | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/Dockerfile b/Dockerfile index 0598a01..68c37f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,4 +24,10 @@ FROM scratch COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /terdut /terdut EXPOSE 8080 +# Numeric, not a name: scratch has no /etc/passwd for one to resolve against, +# and Docker's USER accepts a bare UID:GID without it. 65532 is the common +# "nonroot" convention (distroless's own uid), chosen so the chart's pod +# securityContext (runAsNonRoot, runAsUser: 65532) matches what the image +# already runs as rather than fighting it. +USER 65532:65532 ENTRYPOINT ["/terdut"] diff --git a/charts/terdut-server/templates/deployment.yaml b/charts/terdut-server/templates/deployment.yaml index e193306..15557d6 100644 --- a/charts/terdut-server/templates/deployment.yaml +++ b/charts/terdut-server/templates/deployment.yaml @@ -25,11 +25,30 @@ spec: {{- include "terdut-server.selectorLabels" . | nindent 8 }} spec: enableServiceLinks: false + # Pod-wide default; both containers below run as this UID regardless of + # what their own image would otherwise pick (postgres:17-alpine's + # pg_isready needs no particular user, and 65532 is what the app image + # itself runs as now — see the Dockerfile's USER). seccompProfile here + # rather than per-container: there is no reason it would ever differ + # between them. + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault {{- if .Values.database.waitForPostgres.enabled }} initContainers: - name: wait-for-postgres image: "{{ .Values.database.waitForPostgres.image.repository }}:{{ .Values.database.waitForPostgres.image.tag }}" imagePullPolicy: {{ .Values.database.waitForPostgres.image.pullPolicy }} + # No capability this loop needs, and nothing in it writes to disk: + # sh, pg_isready, echo and sleep all run read-only. + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] env: - name: TERDUT_DB_DSN value: {{ required "database.dsn is required" .Values.database.dsn | quote }} @@ -46,6 +65,13 @@ spec: - name: terdut-server image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} + # scratch, nothing to write: the binary keeps no local state and + # writes nothing to disk, so the root filesystem can stay read-only. + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] ports: - name: http containerPort: {{ .Values.service.port }}