diff --git a/services/jenkins/lesavka-userns-probe.yaml b/services/jenkins/lesavka-userns-probe.yaml index 03958421..048e7a4a 100644 --- a/services/jenkins/lesavka-userns-probe.yaml +++ b/services/jenkins/lesavka-userns-probe.yaml @@ -1,9 +1,19 @@ # services/jenkins/lesavka-userns-probe.yaml # Qualification only: never relax the compiler pod to bypass a failed gate. +apiVersion: v1 +kind: ConfigMap +metadata: + name: lesavka-userns-probe-mappings + namespace: jenkins +data: + # The subordinate range must fit inside Kubernetes's 65536-ID pod mapping. + subuid: "jenkins:10000:20000\n" + subgid: "jenkins:10000:20000\n" +--- apiVersion: batch/v1 kind: Job metadata: - name: lesavka-userns-probe-1 + name: lesavka-userns-probe-2 namespace: jenkins spec: backoffLimit: 0 @@ -26,23 +36,33 @@ spec: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 + fsGroup: 1000 + # Nested private mounts need syscalls denied by RuntimeDefault. This + # exception is confined to a hostUsers:false pod with no host mounts. seccompProfile: - type: RuntimeDefault + type: Unconfined containers: - name: probe image: registry.bstein.dev/bstein/lesavka-ci@sha256:7d3a2548ef0df8da053a2e2939675f5a3cf23e26517828e5fa9a3185911a8e75 securityContext: - allowPrivilegeEscalation: false + # Permit only the image's setuid UID/GID mapping helpers. Their root + # is mapped to an unprivileged host ID, checked before the probe. + allowPrivilegeEscalation: true readOnlyRootFilesystem: true capabilities: drop: ["ALL"] + add: ["SETUID", "SETGID"] resources: requests: {cpu: 10m, memory: 32Mi} - limits: {cpu: 100m, memory: 64Mi, ephemeral-storage: 32Mi} + limits: {cpu: 100m, memory: 128Mi, ephemeral-storage: 32Mi} + volumeMounts: + - {name: mappings, mountPath: /etc/subuid, subPath: subuid, readOnly: true} + - {name: mappings, mountPath: /etc/subgid, subPath: subgid, readOnly: true} + - {name: scratch, mountPath: /tmp} command: ["python3", "-I", "-c"] args: - | - import json, os + import json, os, subprocess from pathlib import Path maps = {name: [[int(v) for v in line.split()] for line in Path('/proc/self/' + name).read_text().splitlines()] @@ -52,5 +72,21 @@ spec: assert len(entries) == 1 inside, outside, count = entries[0] assert inside == 0 and outside >= 65536 and count >= 65536 + subprocess.run(['bwrap', '--unshare-user', '--uid', '0', '--gid', '0', + '--ro-bind', '/', '/', '--', '/bin/true'], check=True, timeout=10) + subprocess.run(['unshare', '--user', '--mount', '--map-auto', + '--map-root-user', '--pid', '--fork', '--kill-child=KILL', + '--propagation', 'private', 'python3', '-I', '-c', + 'import os; os.setgroups([]); os.setgid(1001); os.setuid(1001); ' + 'assert os.geteuid() == 1001 and os.getegid() == 1001'], + check=True, timeout=10) print(json.dumps({'pod_user_namespace': 'pass', 'maps': maps, - 'scope': 'outer mapping only; nested test namespaces unqualified'})) + 'nested_mapping_private_mount': 'pass', + 'scope': 'namespace prerequisites only; full suite unqualified'})) + volumes: + - name: mappings + configMap: + name: lesavka-userns-probe-mappings + - name: scratch + emptyDir: + sizeLimit: 16Mi