diff --git a/services/jenkins/lesavka-userns-probe.yaml b/services/jenkins/lesavka-userns-probe.yaml index 4239fbc9..acc7a46b 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-5 + name: lesavka-userns-probe-6 namespace: jenkins spec: backoffLimit: 0 @@ -26,23 +36,72 @@ 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 + # The CRI default denies userns_create even inside an isolated pod. + appArmorProfile: + type: Unconfined + initContainers: + - name: mapping-tools + image: registry.bstein.dev/bstein/lesavka-ci@sha256:7d3a2548ef0df8da053a2e2939675f5a3cf23e26517828e5fa9a3185911a8e75 + securityContext: + runAsNonRoot: false + runAsUser: 0 + runAsGroup: 0 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + seccompProfile: + type: RuntimeDefault + appArmorProfile: + type: RuntimeDefault + capabilities: + drop: ["ALL"] + add: ["SETFCAP"] + resources: + requests: {cpu: 10m, memory: 32Mi} + limits: {cpu: 100m, memory: 64Mi, ephemeral-storage: 8Mi} + volumeMounts: + - {name: mapping-tools, mountPath: /mapping-tools} + command: ["sh", "-ec"] + args: + - | + # Fail before preparing helpers unless pod root is an unprivileged host ID. + python3 -I -c 'from pathlib import Path; rows=[list(map(int,Path("/proc/self/"+name).read_text().split())) for name in ("uid_map","gid_map")]; assert all(len(r)==3 and r[0]==0 and r[1]>=65536 and r[2]>=65536 for r in rows)' + cp /usr/bin/newuidmap /usr/bin/newgidmap /mapping-tools/ + chmod 0755 /mapping-tools/newuidmap /mapping-tools/newgidmap + setcap cap_setuid=ep /mapping-tools/newuidmap + setcap cap_setgid=ep /mapping-tools/newgidmap + getcap /mapping-tools/newuidmap /mapping-tools/newgidmap + sha256sum /usr/bin/newuidmap /mapping-tools/newuidmap /usr/bin/newgidmap /mapping-tools/newgidmap containers: - name: probe image: registry.bstein.dev/bstein/lesavka-ci@sha256:7d3a2548ef0df8da053a2e2939675f5a3cf23e26517828e5fa9a3185911a8e75 securityContext: - allowPrivilegeEscalation: false + # File capabilities keep the helper's effective UID equal to the + # target namespace owner. No SYS_ADMIN or host identity is granted. + 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} + env: + - name: PATH + value: /mapping-tools:/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + volumeMounts: + - {name: mapping-tools, mountPath: /mapping-tools, readOnly: true} + - {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, stat + 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,9 +111,24 @@ spec: assert len(entries) == 1 inside, outside, count = entries[0] assert inside == 0 and outside >= 65536 and count >= 65536 - helpers = {name: {'mode': oct(stat.S_IMODE(Path('/usr/bin/' + name).stat().st_mode)), - 'uid': Path('/usr/bin/' + name).stat().st_uid, - 'nosuid_mount': bool(os.statvfs('/usr/bin/' + name).f_flag & os.ST_NOSUID)} - for name in ('newuidmap', 'newgidmap')} - print(json.dumps({'pod_user_namespace': 'pass', 'maps': maps, 'helpers': helpers, - 'scope': 'outer mapping only; nested test namespaces unqualified'})) + 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, + 'nested_mapping_private_mount': 'pass', + 'scope': 'namespace prerequisites only; full suite unqualified'})) + volumes: + - name: mapping-tools + emptyDir: + sizeLimit: 2Mi + - name: mappings + configMap: + name: lesavka-userns-probe-mappings + - name: scratch + emptyDir: + sizeLimit: 16Mi