From 00ea301e332eb64d022322016272c929ee1869ee Mon Sep 17 00:00:00 2001 From: jklein Date: Sun, 23 Aug 2026 23:29:51 +0200 Subject: [PATCH] template-chart: seccomp profile, unprivileged port, and two placeholder fixes Five changes to the scaffold every future chart is copied from: 1. seccompProfile.type: RuntimeDefault in the pod security context, in line with the rest of the seccomp series (#84). Pod context, because from there it also covers init containers and any container added later without being repeated. 2. containerPort 80 -> 8080, clearing AVD-KSV-0117. Note that the usual justification for this does not hold: a non-root container CAN bind port 80 on current Kubernetes, because the kubelet sets net.ipv4.ip_unprivileged_port_start=0 in the pod network namespace. Measured, not assumed - see the values.yaml comment. The reason to stay above 1024 is that this is a runtime default rather than a guarantee, and that every real chart here already does. 3. The ConfigMap placeholder key is renamed from the literal "key" to "example-setting", clearing AVD-KSV-01010 (secret-scanner patterns match any key named "key"). 4. The image placeholder is fully qualified as ghcr.io/ORG/IMAGE, so the template stops teaching the unqualified form that produced the repo's unqualified Docker Hub images. 5. env values are rendered through "quote". Without it a value like "8080" renders as a YAML number and the API server rejects the whole manifest ("cannot unmarshal number into Go struct field EnvVar.value of type string"). Every real chart in the repo already quotes here; only the template did not. Found by instantiating the template and installing it, not by rendering it. Verified by instantiating the scaffold into a working chart per the README procedure and installing it in k3d: the workload becomes Ready, the Service routes to the app on 8080 and an ephemeral container added to the running pod inherits Seccomp: 2 from the pod context. Refs #84 --- ci/excluded-charts.txt | 2 +- template-chart/Chart.yaml | 2 +- template-chart/templates/_helpers.tpl | 3 +- template-chart/templates/xxx.configmap.yaml | 6 +++- template-chart/templates/xxx.deployment.yaml | 22 ++++++++----- template-chart/templates/xxx.sfs.yaml | 22 ++++++++----- template-chart/values.yaml | 33 +++++++++++++++++++- 7 files changed, 71 insertions(+), 19 deletions(-) diff --git a/ci/excluded-charts.txt b/ci/excluded-charts.txt index f238f3e..d53b450 100644 --- a/ci/excluded-charts.txt +++ b/ci/excluded-charts.txt @@ -1,4 +1,4 @@ # Charts excluded from the PR install-test, one per line: . # ci/run-install-test.sh skips these with the given reason. satisfactory the game-server image is multiple GB and Steam downloads 10GB+ game files on first start - impractical on a CI runner -template-chart scaffold with placeholder image "registry/image:xxx", never published +template-chart scaffold with placeholder image "ghcr.io/ORG/IMAGE:xxx", never published diff --git a/template-chart/Chart.yaml b/template-chart/Chart.yaml index 478a980..3cf9a13 100644 --- a/template-chart/Chart.yaml +++ b/template-chart/Chart.yaml @@ -5,7 +5,7 @@ icon: https://helm.sh/img/helm.svg type: application -version: 3.0.0+upxxx +version: 4.0.0+upxxx appVersion: "xxx" maintainers: diff --git a/template-chart/templates/_helpers.tpl b/template-chart/templates/_helpers.tpl index 62d13b5..f157873 100644 --- a/template-chart/templates/_helpers.tpl +++ b/template-chart/templates/_helpers.tpl @@ -229,7 +229,8 @@ guidelines"): "runAsUser" 20000 "runAsGroup" 1000 "fsGroup" 1000 - "fsGroupChangePolicy" "Always" -}} + "fsGroupChangePolicy" "Always" + "seccompProfile" (dict "type" "RuntimeDefault") -}} {{- include "xxx.defaultedDict" (dict "defaults" $defaults "given" $given "path" "xxx.securityContext.pod") -}} {{- end -}} diff --git a/template-chart/templates/xxx.configmap.yaml b/template-chart/templates/xxx.configmap.yaml index d0e5765..4982c18 100644 --- a/template-chart/templates/xxx.configmap.yaml +++ b/template-chart/templates/xxx.configmap.yaml @@ -3,4 +3,8 @@ kind: ConfigMap metadata: name: "{{ .Release.Name }}-{{ .Chart.Name }}-configmap" data: - key: "value" + # Placeholder for the chart's real configuration keys. Named for what it is: + # a key literally called "key" matches the patterns secret scanners use to + # find hardcoded credentials, so every copy of this template inherited a + # false positive. + example-setting: "value" diff --git a/template-chart/templates/xxx.deployment.yaml b/template-chart/templates/xxx.deployment.yaml index 7314e1b..c2efdec 100644 --- a/template-chart/templates/xxx.deployment.yaml +++ b/template-chart/templates/xxx.deployment.yaml @@ -27,26 +27,34 @@ spec: {{- include "xxx.podSecurityContext" .Values.xxx.securityContext | nindent 8 }} containers: - name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment - image: "registry/image:{{ .Chart.AppVersion }}" + # Fully qualified on purpose: an unqualified "registry/image" resolves + # to Docker Hub, which is how the repo accumulated its unqualified + # images. Replace host/org/image, keep the registry host. + image: "ghcr.io/ORG/IMAGE:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent env: {{- range $value := .Values.xxx.env }} {{- if $value.value }} - - name: {{ $value.name }} - value: {{ tpl $value.value $ }} + - name: {{ $value.name | quote }} + # quote is required, not cosmetic: a value like "8080" or "true" + # renders unquoted as a YAML number/bool, and the API server then + # rejects the whole manifest with "cannot unmarshal number into + # Go struct field EnvVar.value of type string". Every chart in + # this repo quotes here. + value: {{ tpl $value.value $ | quote }} {{- else if $value.valueFrom }} {{- if $value.valueFrom.secretKeyRef }} - name: {{ $value.name }} valueFrom: secretKeyRef: - name: {{ tpl $value.valueFrom.secretKeyRef.name $ }} - key: {{ tpl $value.valueFrom.secretKeyRef.key $ }} + name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }} + key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }} {{- else if $value.valueFrom.configMapKeyRef }} - name: {{ $value.name }} valueFrom: configMapKeyRef: - name: {{ tpl $value.valueFrom.configMapKeyRef.name $ }} - key: {{ tpl $value.valueFrom.configMapKeyRef.key $ }} + name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }} + key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }} {{- end }} {{- end }} {{- end }} diff --git a/template-chart/templates/xxx.sfs.yaml b/template-chart/templates/xxx.sfs.yaml index 6dbbb33..a3e44c2 100644 --- a/template-chart/templates/xxx.sfs.yaml +++ b/template-chart/templates/xxx.sfs.yaml @@ -28,26 +28,34 @@ spec: {{- include "xxx.podSecurityContext" .Values.xxx.securityContext | nindent 8 }} containers: - name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs - image: "registry/image:{{ .Chart.AppVersion }}" + # Fully qualified on purpose: an unqualified "registry/image" resolves + # to Docker Hub, which is how the repo accumulated its unqualified + # images. Replace host/org/image, keep the registry host. + image: "ghcr.io/ORG/IMAGE:{{ .Chart.AppVersion }}" imagePullPolicy: IfNotPresent env: {{- range $value := .Values.xxx.env }} {{- if $value.value }} - - name: {{ $value.name }} - value: {{ tpl $value.value $ }} + - name: {{ $value.name | quote }} + # quote is required, not cosmetic: a value like "8080" or "true" + # renders unquoted as a YAML number/bool, and the API server then + # rejects the whole manifest with "cannot unmarshal number into + # Go struct field EnvVar.value of type string". Every chart in + # this repo quotes here. + value: {{ tpl $value.value $ | quote }} {{- else if $value.valueFrom }} {{- if $value.valueFrom.secretKeyRef }} - name: {{ $value.name }} valueFrom: secretKeyRef: - name: {{ tpl $value.valueFrom.secretKeyRef.name $ }} - key: {{ tpl $value.valueFrom.secretKeyRef.key $ }} + name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }} + key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }} {{- else if $value.valueFrom.configMapKeyRef }} - name: {{ $value.name }} valueFrom: configMapKeyRef: - name: {{ tpl $value.valueFrom.configMapKeyRef.name $ }} - key: {{ tpl $value.valueFrom.configMapKeyRef.key $ }} + name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }} + key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }} {{- end }} {{- end }} {{- end }} diff --git a/template-chart/values.yaml b/template-chart/values.yaml index d422ffb..908b36a 100644 --- a/template-chart/values.yaml +++ b/template-chart/values.yaml @@ -50,6 +50,15 @@ xxx: runAsGroup: 1000 fsGroup: 1000 fsGroupChangePolicy: Always + # RuntimeDefault applies the container runtime's default seccomp filter, + # which blocks the syscalls a normal workload never makes. It sits in the + # pod context on purpose: from here it covers every container in the pod + # including init containers, while a container-level seccompProfile would + # have to be repeated for each one (and silently missed on the next one + # added). A container that genuinely needs a different filter can still + # override it under securityContext.container, which wins over this value. + seccompProfile: + type: RuntimeDefault container: allowPrivilegeEscalation: false readOnlyRootFilesystem: true @@ -59,7 +68,29 @@ xxx: - ALL add: [] ports: - - containerPort: 80 + # An unprivileged port on purpose (AVD-KSV-0117 flags containerPort < 1024). + # + # Note what the reason is NOT: on current Kubernetes a non-root container + # can bind port 80 just fine. The kubelet sets + # net.ipv4.ip_unprivileged_port_start=0 in the pod network namespace, so + # the classic "ports below 1024 need NET_BIND_SERVICE" rule does not apply + # inside a pod. Measured in k3d (k3s v1.35.5): the sysctl reads 0, and a + # container running as runAsUser 20000 with capabilities.drop: [ALL] binds + # port 80 successfully. Anyone who "fixes" this back to 80 after testing it + # once will find that it works - which is exactly why the reason is written + # down here. + # + # The reason to stay above 1024 is that this is a runtime default, not a + # guarantee: a cluster that restores ip_unprivileged_port_start=1024 (node + # sysctl or a hardened distribution) breaks every workload that copied the + # 80, and the failure then appears in the copied chart, not here. Every + # real chart in this repo listens above 1024 (3000, 8080, 8123, 9998, ...); + # the one exception is samba, which needs 139/445 and adds NET_BIND_SERVICE + # with a documented justification. + # + # containerPort is declarative - it does not force the application onto the + # port. Point the application at the same port when copying this template. + - containerPort: 8080 protocol: TCP name: http env: