From b5eba4cbf10bfdfe1ea0e464c106a6a73eb63db0 Mon Sep 17 00:00:00 2001 From: jklein Date: Fri, 21 Aug 2026 21:49:05 +0200 Subject: [PATCH] Harden pod and container security contexts across all workloads Add securityContext hardening (runAsNonRoot, readOnlyRootFilesystem, capabilities drop ALL, allowPrivilegeEscalation false) to the 10 workloads that lacked it: apache-tika, cyberchef, gotenberg, homeassistant, matrix-delegation-nginx, paperless-server, paperless-consume cronjob (plus automountServiceAccountToken false), patchmon-frontend, urlaubsverwaltung and zipline. All contexts are configurable via securityContext.pod / securityContext.container in values.yaml; writable paths are provided as emptyDirs. Image-forced deviations (Home Assistant root start for s6-overlay + NET_RAW for discovery, paperless root init container for /run ownership) are documented in the charts' values.yaml. Major version bump for every changed published chart. --- CLAUDE.md | 2 +- README.md | 2 +- apache-tika/Chart.yaml | 2 +- apache-tika/templates/deployment.yaml | 12 +++++ apache-tika/values.yaml | 14 ++++++ cyberchef/Chart.yaml | 2 +- cyberchef/templates/cyberchef.deployment.yaml | 16 ++++++ cyberchef/values.yaml | 19 +++++++ gotenberg/Chart.yaml | 2 +- gotenberg/templates/deployment.yaml | 17 +++++++ gotenberg/values.yaml | 15 ++++++ homeassistant/Chart.yaml | 2 +- .../templates/homeassistant.sfs.yaml | 14 ++++++ homeassistant/values.yaml | 20 ++++++++ matrix-synapse/Chart.yaml | 2 +- .../matrix-delegation-nginx.configmap.yaml | 18 +++++-- .../matrix-delegation-nginx.deployment.yaml | 13 ++++- matrix-synapse/values.yaml | 15 ++++++ paperless-ngx/Chart.yaml | 2 +- .../templates/paperless-consume.cronjob.yaml | 5 ++ .../templates/paperless-server.sfs.yaml | 49 +++++++++++++++++++ paperless-ngx/values.yaml | 38 ++++++++++++++ patchmon/Chart.yaml | 2 +- .../patchmon-frontend.deployment.yaml | 15 ++++++ patchmon/values.yaml | 18 +++++++ urlaubsverwaltung/Chart.yaml | 2 +- .../urlaubsverwaltung.deployment.yaml | 12 +++++ urlaubsverwaltung/values.yaml | 15 ++++++ zipline/Chart.yaml | 2 +- zipline/templates/zipline.sfs.yaml | 10 ++++ zipline/values.yaml | 16 ++++++ 31 files changed, 357 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 67c30f3..c9de0a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,6 @@ Copy `template-chart/` and replace every `xxx` placeholder — in file names (`t - Secrets are 1Password `OnePasswordItem` CRs: values expose `secrets.SecretRef` holding an `op://` item path; the operator materializes the Kubernetes Secret. - Ingress assumes `ingressClassName: nginx` and cert-manager: `ingress.clusterIssuer` and `ingress.domain` are `required` values, TLS secret named `tls---ingress`. - `env` entries in values support Helm templating — the deployment renders them through `tpl`, and supports `value`, `secretKeyRef`, and `configMapKeyRef` forms. -- Hardened defaults: `automountServiceAccountToken: false`, non-root user, `readOnlyRootFilesystem: true`, all capabilities dropped; probes and resource requests/limits always set. +- Hardened defaults: `automountServiceAccountToken: false`, non-root user, `readOnlyRootFilesystem: true`, all capabilities dropped; probes and resource requests/limits always set. Security contexts are values-configurable (`securityContext.pod` / `securityContext.container`); image-forced deviations are documented with a comment in the chart's `values.yaml`. - Stateful apps use a StatefulSet in a `.sfs.yaml` template instead of a deployment. - No chart has dependencies; `charts/` directories are empty. diff --git a/README.md b/README.md index 7cac6b5..fb9f5d4 100644 --- a/README.md +++ b/README.md @@ -62,5 +62,5 @@ For runtime verification (do the workloads actually start, do probes/security se - Secrets are **1Password `OnePasswordItem` CRs**: values expose `secrets.SecretRef` holding an `op://` item path; the 1Password operator materializes the Kubernetes Secret in-cluster. - Ingress assumes ingress-nginx (`ingressClassName: nginx`) and cert-manager: `ingress.clusterIssuer` and `ingress.domain` are required values; the TLS secret is named `tls---ingress`. - `env` entries in `values.yaml` are rendered through `tpl`, so values may contain Helm templating; `value`, `secretKeyRef` and `configMapKeyRef` forms are supported. -- Hardened defaults: `automountServiceAccountToken: false`, non-root user, `readOnlyRootFilesystem: true`, all capabilities dropped, probes and resource requests/limits always set. +- Hardened defaults: `automountServiceAccountToken: false`, non-root user, `readOnlyRootFilesystem: true`, all capabilities dropped, probes and resource requests/limits always set. Pod and container contexts are configurable via `securityContext.pod` / `securityContext.container` in each chart's values; writable paths (e.g. `/tmp`, nginx cache dirs, s6-overlay's `/run`) are emptyDirs. Where an image cannot satisfy the full standard (e.g. Home Assistant must start as root for s6-overlay, paperless needs a root init container to hand over `/run`), the deviation is minimal and justified with a comment in that chart's `values.yaml`. - Charts have no dependencies (`charts/` directories are empty). diff --git a/apache-tika/Chart.yaml b/apache-tika/Chart.yaml index 7582189..5562956 100644 --- a/apache-tika/Chart.yaml +++ b/apache-tika/Chart.yaml @@ -4,5 +4,5 @@ description: A Helm chart to deploy Apache Tika on Kubernetes type: application -version: 5.0.0+up3.2.3.0.full +version: 6.0.0+up3.2.3.0.full appVersion: "3.2.3.0-full" diff --git a/apache-tika/templates/deployment.yaml b/apache-tika/templates/deployment.yaml index 7bb5edc..3e4d664 100644 --- a/apache-tika/templates/deployment.yaml +++ b/apache-tika/templates/deployment.yaml @@ -16,10 +16,19 @@ spec: app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment spec: automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.tika.securityContext.pod | nindent 8 }} containers: - name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment image: "apache/tika:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.tika.securityContext.container | nindent 12 }} + volumeMounts: + # Tika writes temporary files during parsing; with a read-only + # root filesystem /tmp must be a writable emptyDir. + - name: tmp + mountPath: /tmp ports: - containerPort: 9998 protocol: TCP @@ -39,4 +48,7 @@ spec: memory: "{{ .Values.tika.resources.limits.memory }}" cpu: "{{ .Values.tika.resources.limits.cpu }}" ephemeral-storage: "{{ .Values.tika.resources.limits.ephemeralStorage }}" + volumes: + - name: tmp + emptyDir: {} restartPolicy: Always diff --git a/apache-tika/values.yaml b/apache-tika/values.yaml index f9dfbe4..f86e60f 100644 --- a/apache-tika/values.yaml +++ b/apache-tika/values.yaml @@ -1,4 +1,18 @@ tika: + # The official image runs as the dedicated user 35002:35002. /tmp is a + # writable emptyDir because Tika writes temporary files while parsing. + securityContext: + pod: + runAsNonRoot: true + runAsUser: 35002 + runAsGroup: 35002 + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL # Tika answers "GET /" on its server port with HTTP 200. livenessProbe: httpGet: diff --git a/cyberchef/Chart.yaml b/cyberchef/Chart.yaml index 28b8e67..bc4a3b7 100644 --- a/cyberchef/Chart.yaml +++ b/cyberchef/Chart.yaml @@ -4,7 +4,7 @@ description: A Helm chart to deploy Cyberchef on Kubernetes type: application -version: 2.0.0+up10.22.1 +version: 3.0.0+up10.22.1 appVersion: "10.22.1" maintainers: diff --git a/cyberchef/templates/cyberchef.deployment.yaml b/cyberchef/templates/cyberchef.deployment.yaml index 324e8d3..34640c3 100644 --- a/cyberchef/templates/cyberchef.deployment.yaml +++ b/cyberchef/templates/cyberchef.deployment.yaml @@ -16,10 +16,21 @@ spec: app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment spec: automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.cyberchef.securityContext.pod | nindent 8 }} containers: - name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment image: "ghcr.io/gchq/cyberchef:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.cyberchef.securityContext.container | nindent 12 }} + volumeMounts: + # nginx needs writable cache/temp dirs and a writable pid file + # location when the root filesystem is read-only. + - name: nginx-cache + mountPath: /var/cache/nginx + - name: nginx-run + mountPath: /run ports: - containerPort: 80 protocol: TCP @@ -39,4 +50,9 @@ spec: memory: "{{ .Values.cyberchef.resources.limits.memory }}" cpu: "{{ .Values.cyberchef.resources.limits.cpu }}" ephemeral-storage: "{{ .Values.cyberchef.resources.limits.ephemeralStorage }}" + volumes: + - name: nginx-cache + emptyDir: {} + - name: nginx-run + emptyDir: {} restartPolicy: Always diff --git a/cyberchef/values.yaml b/cyberchef/values.yaml index 35f183c..6938c52 100644 --- a/cyberchef/values.yaml +++ b/cyberchef/values.yaml @@ -1,4 +1,23 @@ cyberchef: + # The upstream image is stock nginx (root, listening on port 80). To still + # run fully non-root, the pod runs as the image's nginx user (uid 101) and + # the safe sysctl net.ipv4.ip_unprivileged_port_start=80 lets the unprivileged + # nginx bind port 80 without NET_BIND_SERVICE. + securityContext: + pod: + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + sysctls: + - name: net.ipv4.ip_unprivileged_port_start + value: "80" + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL livenessProbe: httpGet: path: / diff --git a/gotenberg/Chart.yaml b/gotenberg/Chart.yaml index 504809a..096ac8b 100644 --- a/gotenberg/Chart.yaml +++ b/gotenberg/Chart.yaml @@ -4,7 +4,7 @@ description: A Helm chart to deploy Gotenberg on Kubernetes type: application -version: 2.0.0+up8.26.0 +version: 3.0.0+up8.26.0 appVersion: "8.26.0" maintainers: diff --git a/gotenberg/templates/deployment.yaml b/gotenberg/templates/deployment.yaml index b4a6c1f..b048c8d 100644 --- a/gotenberg/templates/deployment.yaml +++ b/gotenberg/templates/deployment.yaml @@ -16,10 +16,22 @@ spec: app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment spec: automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.gotenberg.securityContext.pod | nindent 8 }} containers: - name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment image: "gotenberg/gotenberg:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.gotenberg.securityContext.container | nindent 12 }} + volumeMounts: + # Gotenberg (and the Chromium/LibreOffice it drives) needs writable + # /tmp and home directories; with a read-only root filesystem these + # are provided as emptyDirs. + - name: tmp + mountPath: /tmp + - name: gotenberg-home + mountPath: /home/gotenberg ports: - containerPort: 3000 protocol: TCP @@ -39,4 +51,9 @@ spec: memory: "{{ .Values.gotenberg.resources.limits.memory }}" cpu: "{{ .Values.gotenberg.resources.limits.cpu }}" ephemeral-storage: "{{ .Values.gotenberg.resources.limits.ephemeralStorage }}" + volumes: + - name: tmp + emptyDir: {} + - name: gotenberg-home + emptyDir: {} restartPolicy: Always diff --git a/gotenberg/values.yaml b/gotenberg/values.yaml index 8b41ad5..ed54738 100644 --- a/gotenberg/values.yaml +++ b/gotenberg/values.yaml @@ -1,4 +1,19 @@ gotenberg: + # The official image runs as the dedicated gotenberg user (uid 1001). /tmp + # and /home/gotenberg are writable emptyDirs so Chromium/LibreOffice keep + # working with a read-only root filesystem. + securityContext: + pod: + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL # Gotenberg exposes a documented health endpoint at /health. livenessProbe: httpGet: diff --git a/homeassistant/Chart.yaml b/homeassistant/Chart.yaml index a9abdb0..df4bc41 100644 --- a/homeassistant/Chart.yaml +++ b/homeassistant/Chart.yaml @@ -3,7 +3,7 @@ name: homeassistant description: A Helm chart to deploy Home Assistant on Kubernetes type: application -version: 2.0.0+up2026.2.3 +version: 3.0.0+up2026.2.3 appVersion: "2026.2.3" diff --git a/homeassistant/templates/homeassistant.sfs.yaml b/homeassistant/templates/homeassistant.sfs.yaml index d7bce3f..3fb7481 100644 --- a/homeassistant/templates/homeassistant.sfs.yaml +++ b/homeassistant/templates/homeassistant.sfs.yaml @@ -17,10 +17,14 @@ spec: name: "{{ .Release.Name }}-{{ .Chart.Name }}-sfs" spec: automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.securityContext.pod | nindent 8 }} containers: - name: "{{ .Release.Name }}-{{ .Chart.Name }}" image: "ghcr.io/home-assistant/home-assistant:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.securityContext.container | nindent 12 }} env: - name: TZ value: "{{ .Values.core.timezone }}" @@ -56,7 +60,17 @@ spec: - mountPath: /config name: hass-volume subPath: hass-config + # Writable emptyDirs so the root filesystem can stay read-only: + # s6-overlay needs /run, Home Assistant writes temp files to /tmp. + - mountPath: /tmp + name: tmp + - mountPath: /run + name: run volumes: + - name: tmp + emptyDir: {} + - name: run + emptyDir: {} - name: core-config secret: secretName: {{ .Release.Name }}-{{ .Chart.Name }}-config-secret diff --git a/homeassistant/values.yaml b/homeassistant/values.yaml index 7a151af..e3ed2d5 100644 --- a/homeassistant/values.yaml +++ b/homeassistant/values.yaml @@ -22,6 +22,26 @@ ingress: # hosts: # - chart-example.local +# Documented deviation from the repo-wide hardening standard: the official +# Home Assistant container must start as root — it boots via the s6-overlay +# init system, which performs its setup as uid 0 (the image supports no +# non-root user), so runAsNonRoot/runAsUser are deliberately not set. +# Everything else is hardened: no privilege escalation, all capabilities +# dropped and a read-only root filesystem with writable emptyDirs for +# /tmp and /run (s6-overlay state). NET_RAW is added back because Home +# Assistant's device discovery (DHCP watcher, ping/arp integrations) uses +# raw sockets and logs "Operation not permitted" without it. +securityContext: + pod: {} + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + add: + - NET_RAW + resources: limits: cpu: 1 diff --git a/matrix-synapse/Chart.yaml b/matrix-synapse/Chart.yaml index 576c6d4..1bcac09 100644 --- a/matrix-synapse/Chart.yaml +++ b/matrix-synapse/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: matrix-synapse description: A Helm chart to deploy a Matrix Synapse server on Kubernetes type: application -version: 2.0.0+upv1.149.1 +version: 3.0.0+upv1.149.1 appVersion: "v1.149.1" maintainers: - name: jklein \ No newline at end of file diff --git a/matrix-synapse/templates/matrix-delegation-nginx.configmap.yaml b/matrix-synapse/templates/matrix-delegation-nginx.configmap.yaml index 09f8bba..ddafd98 100644 --- a/matrix-synapse/templates/matrix-delegation-nginx.configmap.yaml +++ b/matrix-synapse/templates/matrix-delegation-nginx.configmap.yaml @@ -6,15 +6,25 @@ metadata: data: nginx.conf: | worker_processes 1; - + # The container runs as the unprivileged nginx user with a read-only root + # filesystem: pid file and temp paths live in the /tmp emptyDir, and the + # listen port is unprivileged (8080). + pid /tmp/nginx.pid; + events { worker_connections 1024; } - + http { + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + server { - listen 80; - + listen 8080; + location /.well-known/matrix/server { return 200 '{"m.server": "{{ .Values.ingress.domain }}:443"}'; add_header Content-Type application/json; diff --git a/matrix-synapse/templates/matrix-delegation-nginx.deployment.yaml b/matrix-synapse/templates/matrix-delegation-nginx.deployment.yaml index 02f3a31..7d90d07 100644 --- a/matrix-synapse/templates/matrix-delegation-nginx.deployment.yaml +++ b/matrix-synapse/templates/matrix-delegation-nginx.deployment.yaml @@ -16,16 +16,25 @@ spec: app: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-nginx spec: automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.matrix.delegation.securityContext.pod | nindent 8 }} containers: - name: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-nginx image: "nginx:1.28.3" + securityContext: + {{- toYaml .Values.matrix.delegation.securityContext.container | nindent 12 }} ports: - - containerPort: 80 + - containerPort: 8080 name: http volumeMounts: - name: nginx-config mountPath: /etc/nginx/nginx.conf subPath: nginx.conf + # The mounted nginx.conf points the pid file and all temp paths to + # /tmp, so a single writable emptyDir suffices with a read-only + # root filesystem. + - name: tmp + mountPath: /tmp resources: limits: memory: "{{ .Values.matrix.delegation.resources.limits.memory }}" @@ -61,6 +70,8 @@ spec: timeoutSeconds: 3 failureThreshold: 30 volumes: + - name: tmp + emptyDir: {} - name: nginx-config configMap: name: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-nginx-config diff --git a/matrix-synapse/values.yaml b/matrix-synapse/values.yaml index 66f2a82..da802f5 100644 --- a/matrix-synapse/values.yaml +++ b/matrix-synapse/values.yaml @@ -5,6 +5,21 @@ matrix: delegation: enabled: false serverUrl: null + # The delegation nginx runs as the image's unprivileged nginx user + # (uid 101) and listens on the unprivileged port 8080; pid file and temp + # paths are redirected to the /tmp emptyDir by the mounted nginx.conf. + securityContext: + pod: + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL resources: limits: memory: "128Mi" diff --git a/paperless-ngx/Chart.yaml b/paperless-ngx/Chart.yaml index ba4b268..af2ff57 100644 --- a/paperless-ngx/Chart.yaml +++ b/paperless-ngx/Chart.yaml @@ -4,7 +4,7 @@ description: A Helm chart to deploy Paperless-NGX on Kubernetes type: application -version: 5.0.0+up2.20.13 +version: 6.0.0+up2.20.13 appVersion: 2.20.13 diff --git a/paperless-ngx/templates/paperless-consume.cronjob.yaml b/paperless-ngx/templates/paperless-consume.cronjob.yaml index a49a141..44a0ee6 100644 --- a/paperless-ngx/templates/paperless-consume.cronjob.yaml +++ b/paperless-ngx/templates/paperless-consume.cronjob.yaml @@ -9,10 +9,15 @@ spec: spec: template: spec: + automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.smbConnection.cron.securityContext.pod | nindent 12 }} containers: - name: paperless-consume-cronjob image: busybox:1.28 imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.smbConnection.cron.securityContext.container | nindent 16 }} command: - /bin/sh - -c diff --git a/paperless-ngx/templates/paperless-server.sfs.yaml b/paperless-ngx/templates/paperless-server.sfs.yaml index b957e76..6228d2c 100644 --- a/paperless-ngx/templates/paperless-server.sfs.yaml +++ b/paperless-ngx/templates/paperless-server.sfs.yaml @@ -17,10 +17,49 @@ spec: app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs spec: automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.paperless.securityContext.pod | nindent 8 }} + initContainers: + # s6-overlay refuses to start unless /run is owned by the runtime uid, + # but emptyDirs are always created root-owned. This minimal init + # container hands /run over to the paperless uid (see + # paperless.securityContext in values.yaml) so the main container can + # run fully hardened (non-root, no privilege escalation). + - name: init-run-ownership + image: busybox:1.28 + imagePullPolicy: IfNotPresent + command: + - sh + - -c + - chown {{ .Values.paperless.securityContext.pod.runAsUser }}:{{ .Values.paperless.securityContext.pod.runAsGroup }} /run + securityContext: + runAsNonRoot: false + runAsUser: 0 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + add: + - CHOWN + volumeMounts: + - mountPath: /run + name: run + resources: + requests: + cpu: 10m + memory: 16Mi + ephemeral-storage: 1Mi + limits: + cpu: 100m + memory: 32Mi + ephemeral-storage: 10Mi containers: - name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs image: "ghcr.io/paperless-ngx/paperless-ngx:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.paperless.securityContext.container | nindent 12 }} ports: - containerPort: 8000 protocol: TCP @@ -153,6 +192,12 @@ spec: subPath: export/ - mountPath: "/usr/src/paperless/consume" name: "paperless-consume-volume" + # Writable emptyDirs so the root filesystem can stay read-only: + # s6-overlay needs /run, OCR/consumption uses /tmp for scratch files. + - mountPath: /tmp + name: tmp + - mountPath: /run + name: run resources: requests: memory: "{{ .Values.paperless.resources.requests.memory }}" @@ -189,6 +234,10 @@ spec: timeoutSeconds: 5 restartPolicy: Always volumes: + - name: tmp + emptyDir: {} + - name: run + emptyDir: {} - name: paperless-volume persistentVolumeClaim: claimName: {{ .Release.Name }}-{{ .Chart.Name }}-volume-claim diff --git a/paperless-ngx/values.yaml b/paperless-ngx/values.yaml index 86bc451..c007219 100644 --- a/paperless-ngx/values.yaml +++ b/paperless-ngx/values.yaml @@ -42,11 +42,49 @@ paperless: cpu: 1 memory: 2048Mi ephemeralStorage: 400Mi + # Paperless-ngx supports rootless operation: the pod runs as uid/gid 1000 + # (the image's default paperless user), fsGroup makes the PVCs writable and + # /tmp + /run are emptyDirs so the root filesystem stays read-only. + # Documented deviation: s6-overlay requires /run to be OWNED by the runtime + # uid (group-write is not enough), so a minimal root init container chowns + # the /run emptyDir to runAsUser/runAsGroup before the hardened main + # container starts. + securityContext: + pod: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: Always + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL smbConnection: enabled: false server: null cronJob: "0 0,12 * * *" cron: + # The consume mover only shuffles files between the SMB share (mounted + # with 0777 modes) and the consume PVC, so it runs as the same non-root + # uid/gid as paperless itself with a fully hardened context. + securityContext: + pod: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: Always + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL resources: requests: cpu: 10m diff --git a/patchmon/Chart.yaml b/patchmon/Chart.yaml index b7e01fc..1b4e2c5 100644 --- a/patchmon/Chart.yaml +++ b/patchmon/Chart.yaml @@ -4,7 +4,7 @@ description: A Helm chart to deploy Patchmon on Kubernetes type: application -version: 2.0.0+up1.4.2 +version: 3.0.0+up1.4.2 appVersion: "1.4.2" maintainers: diff --git a/patchmon/templates/patchmon-frontend.deployment.yaml b/patchmon/templates/patchmon-frontend.deployment.yaml index d8938b4..4115b85 100644 --- a/patchmon/templates/patchmon-frontend.deployment.yaml +++ b/patchmon/templates/patchmon-frontend.deployment.yaml @@ -16,10 +16,14 @@ spec: app: {{ .Release.Name }}-{{ .Chart.Name }}-frontend spec: automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.patchmon.frontend.securityContext.pod | nindent 8 }} containers: - name: {{ .Release.Name }}-{{ .Chart.Name }}-frontend image: "ghcr.io/patchmon/patchmon-frontend:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.patchmon.frontend.securityContext.container | nindent 12 }} ports: - containerPort: 3000 protocol: TCP @@ -59,8 +63,19 @@ spec: - name: patchmon-volume mountPath: /usr/share/nginx/html/assets subPath: patchmon-assets + # nginx-unprivileged keeps its pid file and temp dirs in /tmp, and + # its entrypoint renders config templates into /etc/nginx/conf.d; + # both must be writable with a read-only root filesystem. + - name: tmp + mountPath: /tmp + - name: nginx-conf-d + mountPath: /etc/nginx/conf.d restartPolicy: Always volumes: + - name: tmp + emptyDir: {} + - name: nginx-conf-d + emptyDir: {} - name: patchmon-volume persistentVolumeClaim: claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc \ No newline at end of file diff --git a/patchmon/values.yaml b/patchmon/values.yaml index 5fc1acb..03c5705 100644 --- a/patchmon/values.yaml +++ b/patchmon/values.yaml @@ -3,6 +3,24 @@ patchmon: size: 10Gi storageClass: null frontend: + # The frontend image is based on nginx-unprivileged; the pod runs as its + # nginx user (uid 101). The image's root-only init script (it would chown + # the shared assets volume) is replaced by fsGroup 1000: the same group + # the backend runs with, so both can read/write the shared assets. + securityContext: + pod: + runAsNonRoot: true + runAsUser: 101 + runAsGroup: 101 + fsGroup: 1000 + fsGroupChangePolicy: Always + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL resources: requests: cpu: 0.1 diff --git a/urlaubsverwaltung/Chart.yaml b/urlaubsverwaltung/Chart.yaml index 65a0c9d..1c1d0af 100644 --- a/urlaubsverwaltung/Chart.yaml +++ b/urlaubsverwaltung/Chart.yaml @@ -3,5 +3,5 @@ name: urlaubsverwaltung description: A Helm chart to deploy urlaubsverwaltung on Kubernetes type: application -version: 2.0.0+up6.5.0 +version: 3.0.0+up6.5.0 appVersion: 6.5.0 diff --git a/urlaubsverwaltung/templates/urlaubsverwaltung.deployment.yaml b/urlaubsverwaltung/templates/urlaubsverwaltung.deployment.yaml index 5faa6f2..ab9012f 100644 --- a/urlaubsverwaltung/templates/urlaubsverwaltung.deployment.yaml +++ b/urlaubsverwaltung/templates/urlaubsverwaltung.deployment.yaml @@ -16,10 +16,19 @@ spec: app: {{ .Chart.Name }}-{{ .Release.Name }}-uv-deployment spec: automountServiceAccountToken: false + securityContext: + {{- toYaml .Values.urlaubsverwaltung.securityContext.pod | nindent 8 }} containers: - name: {{ .Chart.Name }}-{{ .Release.Name }}-uv-container image: "urlaubsverwaltung/urlaubsverwaltung:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent + securityContext: + {{- toYaml .Values.urlaubsverwaltung.securityContext.container | nindent 12 }} + volumeMounts: + # The JVM (Tomcat work dir, temp files) needs a writable /tmp with + # a read-only root filesystem. + - name: tmp + mountPath: /tmp ports: - containerPort: 8080 name: http @@ -134,4 +143,7 @@ spec: periodSeconds: 10 failureThreshold: 30 timeoutSeconds: 5 + volumes: + - name: tmp + emptyDir: {} restartPolicy: Always diff --git a/urlaubsverwaltung/values.yaml b/urlaubsverwaltung/values.yaml index bdf9d87..8032e92 100644 --- a/urlaubsverwaltung/values.yaml +++ b/urlaubsverwaltung/values.yaml @@ -7,6 +7,21 @@ ingress: domain: null urlaubsverwaltung: + # The official image is built with Cloud Native Buildpacks and runs as the + # non-root cnb user (uid 1002, gid 1001). /tmp is a writable emptyDir for + # the JVM's temp/work files. + securityContext: + pod: + runAsNonRoot: true + runAsUser: 1002 + runAsGroup: 1001 + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL resources: requests: cpu: 0.1 diff --git a/zipline/Chart.yaml b/zipline/Chart.yaml index e4c62d5..9786702 100644 --- a/zipline/Chart.yaml +++ b/zipline/Chart.yaml @@ -15,7 +15,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 2.0.0+up4.6.4 +version: 3.0.0+up4.6.4 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to diff --git a/zipline/templates/zipline.sfs.yaml b/zipline/templates/zipline.sfs.yaml index 8ac3e9a..c3215f6 100644 --- a/zipline/templates/zipline.sfs.yaml +++ b/zipline/templates/zipline.sfs.yaml @@ -13,9 +13,13 @@ spec: labels: app: "{{ .Release.Name }}-{{ .Chart.Name }}-sfs" spec: + securityContext: + {{- toYaml .Values.zipline.securityContext.pod | nindent 8 }} containers: - name: "{{ .Release.Name }}-{{ .Chart.Name }}-sfs" image: "ghcr.io/diced/zipline:{{ .Chart.AppVersion }}" + securityContext: + {{- toYaml .Values.zipline.securityContext.container | nindent 12 }} env: - name: DATABASE_URL valueFrom: @@ -60,8 +64,14 @@ spec: - name: zipline-volume mountPath: /zipline/themes subPath: themes/ + # Zipline writes temporary chunks/files below /tmp; with a + # read-only root filesystem this must be a writable emptyDir. + - name: tmp + mountPath: /tmp automountServiceAccountToken: false volumes: + - name: tmp + emptyDir: {} - name: zipline-volume persistentVolumeClaim: claimName: {{ .Release.Name }}-{{ .Chart.Name }}-vc diff --git a/zipline/values.yaml b/zipline/values.yaml index e986f35..7d4a4d0 100644 --- a/zipline/values.yaml +++ b/zipline/values.yaml @@ -7,6 +7,22 @@ config: domain: null maxUploadFileSize: 30m zipline: + # The upstream image ships no dedicated user, but the Node.js server runs + # fine as an arbitrary non-root uid; fsGroup makes the uploads PVC writable. + securityContext: + pod: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: Always + container: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL # Zipline v4 exposes /api/healthcheck (checks server and database health); # the endpoint is enabled by default (config.features.healthcheck). Since it # includes the database, it is used for readiness/startup only; liveness