Build and supply chain

Containing a root Kaniko build, the secure way

You moved off the Docker socket, celebrated, and then read the fine print: kaniko still runs as root. It is a smaller root, in a box you choose. The job now is to make that box as small as it can be.

The short answer

Run kaniko as uid 0 but with every capability dropped except CHOWN, DAC_OVERRIDE, FOWNER, SETUID and SETGID, no privilege escalation, the default seccomp profile, no service account token, a read-only context, a push-only credential for one repository, egress to the registry only, and a dedicated node pool.

Updated Houssam Hammoudi, CTOTested with kaniko executor v1.24.0, Docker 28, kubeconform v0.8.0 (Kubernetes 1.34 schemas)

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

Kaniko builds an image by unpacking the base image over its own root filesystem and running each RUN line there. That is why it needs uid 0 inside its container. It does not need the Docker daemon, and it does not need privileged: true.

The trap is to stop there. A kaniko pod with the default container settings still has fourteen Linux capabilities, a mounted service account token, full network egress and, often, a registry credential that can push to every repository. Every RUN line in the Dockerfile runs as root with all of that. The Dockerfile comes from a pull request, so anyone who can open one can run code there.

What that code can do: read the push credential and overwrite a production image tag, call the Kubernetes API with the pod token, scan your internal network, or mine coins on a node that also runs production pods. None of these needs a container escape.

What the docs say

kaniko by itself does not make it safe to run untrusted builds inside your cluster, or anywhere else.

Source: kaniko README, Security

kaniko relies on the security features of your container runtime to provide build security.

Source: kaniko README, Security

In particular, it cannot use chroot or bind-mount because its container must not require privilege, so it unpacks directly into its own container root and may overwrite anything already there.

Source: kaniko README, Known Issues

The README tells you the runtime must do the work, but it never lists which capabilities kaniko needs. So most setups keep the runtime defaults, which are far wider than a build requires. The Google repository is archived; the same text is in the maintained fork at chainguard-forks/kaniko.

The secure configuration

A Job in its own namespace. Each line that matters has a comment.

yaml
apiVersion: v1
kind: Namespace
metadata:
  name: build
  labels:
    # kaniko must run as uid 0, so "restricted" cannot pass.
    # "baseline" still forbids privileged, host namespaces and hostPath.
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/warn: restricted
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: kaniko
  namespace: build
automountServiceAccountToken: false      # a build step never needs the Kubernetes API
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: kaniko-egress
  namespace: build
spec:
  podSelector:
    matchLabels:
      app: kaniko
  policyTypes: [Ingress, Egress]
  ingress: []                             # nothing may connect to a build pod
  egress:
    - to:                                 # DNS only to the cluster resolver
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }
    - to:                                 # the one registry (mirror) the build may use
        - ipBlock:
            cidr: 192.0.2.10/32
      ports:
        - { protocol: TCP, port: 443 }
---
apiVersion: batch/v1
kind: Job
metadata:
  name: build-app
  namespace: build
spec:
  backoffLimit: 0
  activeDeadlineSeconds: 1800             # a hung or abused build dies after 30 minutes
  ttlSecondsAfterFinished: 600
  template:
    metadata:
      labels:
        app: kaniko
    spec:
      serviceAccountName: kaniko
      automountServiceAccountToken: false
      restartPolicy: Never
      hostUsers: false                    # user namespace: uid 0 in the pod is not uid 0 on the node
      runtimeClassName: gvisor            # optional second wall; remove if the pool has no gVisor
      enableServiceLinks: false
      nodeSelector:
        pool: build                       # builds never share a node with production pods
      tolerations:
        - { key: dedicated, operator: Equal, value: build, effect: NoSchedule }
      securityContext:
        seccompProfile:
          type: RuntimeDefault
      initContainers:
        - name: fetch-context
          image: cgr.dev/chainguard/git@sha256:0000000000000000000000000000000000000000000000000000000000000000
          args: ["clone", "--depth=1", "--branch=main", "https://git.example.com/team-a/app.git", "/workspace"]
          securityContext:
            runAsUser: 65532
            runAsNonRoot: true
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: [ALL] }
          volumeMounts:
            - { name: workspace, mountPath: /workspace }
      containers:
        - name: kaniko
          # Build your own executor image from the maintained fork and pin it by digest.
          image: registry.example.com/tools/kaniko-executor@sha256:0000000000000000000000000000000000000000000000000000000000000000
          args:
            - --context=dir:///workspace
            - --dockerfile=/workspace/Dockerfile
            - --destination=registry.example.com/team-a/app:1.0.0
            - --digest-file=/dev/termination-log
            - --reproducible
            - --cleanup
          securityContext:
            runAsUser: 0                  # kaniko unpacks the base image over its own root
            runAsNonRoot: false
            privileged: false
            allowPrivilegeEscalation: false
            capabilities:
              drop: [ALL]
              add: [CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID]
          resources:
            requests: { cpu: "500m", memory: 1Gi, ephemeral-storage: 4Gi }
            limits:   { cpu: "2",    memory: 2Gi, ephemeral-storage: 8Gi }
          volumeMounts:
            - { name: workspace, mountPath: /workspace, readOnly: true }
            - { name: push-credentials, mountPath: /kaniko/.docker, readOnly: true }
      volumes:
        - name: workspace
          emptyDir: { sizeLimit: 1Gi }
        - name: push-credentials
          secret:
            secretName: team-a-app-push   # push-only robot for ONE repository
            items:
              - { key: .dockerconfigjson, path: config.json }

Replace the zero digests with real ones. The two optional walls, hostUsers: false and runtimeClassName: gvisor, depend on your cluster: user namespaces need Kubernetes 1.33 or later with a runtime and kernel that support them, and gVisor needs the RuntimeClass installed. They were not run for this page; test a real build on your own pool before you rely on them.

The same limits on a plain Docker host, for a runner that is not on Kubernetes:

bash
docker run --rm \
  --cap-drop ALL \
  --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \
  --cap-add SETUID --cap-add SETGID \
  --security-opt no-new-privileges \
  --memory 2g --pids-limit 256 \
  -v "$PWD/ctx:/workspace:ro" \
  -v "$PWD/out:/out" \
  "$KANIKO_IMAGE" \
  --context dir:///workspace \
  --destination example.com/team-a/app:1.0 \
  --no-push --tar-path /out/app.tar --digest-file /out/app.digest

Add --cap-add FSETID only if a build must keep setuid or setgid bits on files, and ask why it needs them first.

Prove it

The test Dockerfile prints what a RUN step can see, then installs a package and adds a user, which is what most real builds do.

dockerfile
FROM cgr.dev/chainguard/wolfi-base@sha256:<digest>
RUN id && grep -E '^(CapEff|NoNewPrivs|Seccomp):' /proc/self/status && ls -l /var/run/docker.sock 2>&1 || true
RUN apk add --no-cache curl >/dev/null && adduser -D -u 10001 app
USER 10001

First, too tight. With no capabilities at all, kaniko cannot even stage the Dockerfile:

bash
build --cap-drop ALL
text
Error: error resolving dockerfile path: copying dockerfile: chown /kaniko/Dockerfile: operation not permitted

Then the five capabilities on this page (kaniko log lines trimmed):

bash
build --cap-drop ALL --cap-add CHOWN,DAC_OVERRIDE,FOWNER,SETUID,SETGID
text
level=info msg="RUN id && grep -E '^(CapEff|NoNewPrivs|Seccomp):' /proc/self/status && ls -l /var/run/docker.sock 2>&1 || true"
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
CapEff:	00000000000000cb
NoNewPrivs:	1
Seccomp:	2
ls: /var/run/docker.sock: No such file or directory
level=info msg="RUN apk add --no-cache curl >/dev/null && adduser -D -u 10001 app"
level=info msg="USER 10001"
level=info msg="Skipping push to container registry due to --no-push flag"
sha256:aaeeee1092b13ea4b41aae3bccdc73025717e5d04b48566eccb662166d37e142

The build step is root, but a small root: five capabilities, no new privileges, seccomp in filter mode (2), and no Docker socket. For comparison, a container with default settings:

bash
docker run --rm alpine:3.22 grep CapEff /proc/self/status
capsh --decode=00000000000000cb ; capsh --decode=00000000a80425fb
text
CapEff:	00000000a80425fb
0x00000000000000cb=cap_chown,cap_dac_override,cap_fowner,cap_setgid,cap_setuid
0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap

Nine capabilities gone, including NET_RAW (raw packets on the node network) and MKNOD. The Kubernetes manifests pass schema validation:

bash
kubeconform -strict -summary -kubernetes-version 1.34.0 kaniko-job.yaml
text
Summary: 4 resources found in 1 file - Valid: 4, Invalid: 0, Errors: 0, Skipped: 0

Mistakes people make

Adding privileged: true "because root is needed anyway"

Root in a container and a privileged container are different things. privileged: true gives all capabilities, all devices and no seccomp. Kaniko needs none of that. If a build only works privileged, the build is doing something a build should not do.

A registry credential that can push anywhere

The kaniko pod holds whatever credential you mount. A RUN step can read /kaniko/.docker/config.json. Give each pipeline a robot account that can push to one repository and nothing else, and never mount a credential into a build that runs pull requests from forks.

Builds on the production node pool

Capabilities limit what root can do, not what a kernel bug can do. Put builds on their own nodes with a taint, so a container escape lands on a machine with nothing else on it.

Open egress "so apk and pip work"

A build that can reach the internet can send your source code and credentials anywhere. Point package managers and base images at a mirror and allow only the mirror. The offline-builds-no-internet page shows how.

Reusing the pod for the next build

Kaniko writes over its own root filesystem. The README says it "may overwrite anything already there". One build per pod, restartPolicy: Never, and the pod is gone after ttlSecondsAfterFinished.

Checklist

  • Kaniko runs as uid 0 with privileged: false and allowPrivilegeEscalation: false.
  • Capabilities: drop ALL, add only CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID.
  • seccompProfile: RuntimeDefault is set on the pod.
  • automountServiceAccountToken: false on the ServiceAccount and the pod.
  • The build namespace enforces Pod Security baseline.
  • A NetworkPolicy allows egress to DNS and the registry mirror only, and no ingress.
  • The push credential can write to one repository only.
  • Builds run on a tainted, dedicated node pool.
  • Each build is a new pod with CPU, memory, storage and time limits.
  • The executor image is built from the maintained fork and pinned by digest.
  • Untrusted pull requests build without any push credential.

Kaniko gave you a smaller root. Five capabilities, one credential and one open port make it small enough to hand to a pull request.

H2-CSDE

Learn it on a live range

Building without Docker, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.

Start free

H2 Scanner

Want this caught before it merges?

The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.

Talk to us