From 6e47730501502fabcb3ac9f2a40caaa142b1c8b2 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 24 Sep 2026 03:05:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(build):=20allow=20kaniko=20to=20unpack=20ba?= =?UTF-8?q?se-image=20layers=20=E2=80=94=20drop-ALL=20removed=20the=20caps?= =?UTF-8?q?=20the=20tar=20apply=20needs=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/build/jobspec.go | 24 ++++++++++++---- internal/build/jobspec_test.go | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/internal/build/jobspec.go b/internal/build/jobspec.go index 22c16fd..aff5515 100644 --- a/internal/build/jobspec.go +++ b/internal/build/jobspec.go @@ -128,15 +128,29 @@ func BuildJob(p JobParams) (*batchv1.Job, error) { deadline = int64(defaultDeadline / time.Second) } - // Hardened container security context shared by both build containers: no - // privilege, no privilege escalation, drop all capabilities. Kaniko needs a - // writable root filesystem to unpack layers, so we do not force read-only - // root here, but it gains no privilege. + // Hardened container security context baseline: no privilege, no privilege + // escalation, drop all capabilities. Kaniko needs a writable root filesystem + // to unpack layers, so we do not force read-only root here; it also needs a + // minimal capability subset added back (kanikoSec below), while fetch and + // trivy run with exactly this baseline. sec := &corev1.SecurityContext{ Privileged: boolPtr(false), AllowPrivilegeEscalation: boolPtr(false), Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, } + // Kaniko unpacks base-image layers as root, and the tar apply must chown/chmod + // files to the owners the layer recorded — impossible under drop-ALL (live: + // "failed to get filesystem from image: chown /etc/gshadow: operation not + // permitted" for any FROM ; scratch builds masked this because + // COPY only ever creates files kaniko itself owns). Add back exactly the caps + // the unpack needs and nothing else: CHOWN/FOWNER for the ownership and mode + // restore, DAC_OVERRIDE to write entries whose bits would otherwise exclude + // even root once the capability-based exemption is gone. + kanikoSec := sec.DeepCopy() + kanikoSec.Capabilities = &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + Add: []corev1.Capability{"CHOWN", "DAC_OVERRIDE", "FOWNER"}, + } // The context Kaniko reads. An http(s) ref (the submit lane's derived ref: the // API streams the blob on its internal face, because the build Pod can neither @@ -224,7 +238,7 @@ func BuildJob(p JobParams) (*batchv1.Job, error) { }, VolumeMounts: kanikoMounts, Resources: corev1.ResourceRequirements{Limits: limits, Requests: buildRequests(limits)}, - SecurityContext: sec, + SecurityContext: kanikoSec, } initContainers = append(initContainers, kaniko) diff --git a/internal/build/jobspec_test.go b/internal/build/jobspec_test.go index f0bb97a..9424ee8 100644 --- a/internal/build/jobspec_test.go +++ b/internal/build/jobspec_test.go @@ -210,6 +210,58 @@ func TestBuildJobKanikoPushesAndTrivyGates(t *testing.T) { } } +// Kaniko (and only kaniko) must carry back exactly the unpacking capability +// subset: drop-ALL broke every FROM build live ("failed to get +// filesystem from image: chown /etc/gshadow: operation not permitted"), while a +// scratch COPY masked it. The pod stays unprivileged and every other container +// keeps the pure baseline. +func TestBuildJobKanikoGetsOnlyUnpackCapabilities(t *testing.T) { + job, err := BuildJob(sampleJobParams()) + if err != nil { + t.Fatalf("BuildJob: %v", err) + } + var kaniko *corev1.Container + for i := range job.Spec.Template.Spec.InitContainers { + if job.Spec.Template.Spec.InitContainers[i].Name == "kaniko" { + kaniko = &job.Spec.Template.Spec.InitContainers[i] + } + } + if kaniko == nil { + t.Fatal("no kaniko initContainer") + } + sc := kaniko.SecurityContext + if sc == nil || sc.Capabilities == nil { + t.Fatal("kaniko lost its security context") + } + if sc.Privileged == nil || *sc.Privileged || + sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation { + t.Errorf("kaniko must stay unprivileged with no escalation, got %+v", sc) + } + if len(sc.Capabilities.Drop) != 1 || string(sc.Capabilities.Drop[0]) != "ALL" { + t.Errorf("kaniko must still drop ALL, got %v", sc.Capabilities.Drop) + } + want := map[corev1.Capability]bool{"CHOWN": true, "DAC_OVERRIDE": true, "FOWNER": true} + if len(sc.Capabilities.Add) != len(want) { + t.Fatalf("kaniko capabilities.add = %v, want exactly the unpack trio", sc.Capabilities.Add) + } + for _, c := range sc.Capabilities.Add { + if !want[c] { + t.Errorf("kaniko must not gain %q — only the unpack trio is justified", c) + } + } + // Every other container keeps the pure baseline: nothing re-added. + all := append([]corev1.Container{}, job.Spec.Template.Spec.InitContainers...) + all = append(all, job.Spec.Template.Spec.Containers...) + for _, c := range all { + if c.Name == "kaniko" { + continue + } + if c.SecurityContext != nil && c.SecurityContext.Capabilities != nil && len(c.SecurityContext.Capabilities.Add) > 0 { + t.Errorf("container %q must not add capabilities, got %v", c.Name, c.SecurityContext.Capabilities.Add) + } + } +} + // A configured DB repository (the internal mirror) must reach Trivy as // --db-repository: without it the scan tries the internet, which the build egress // lock denies, and every build fails closed at the scan gate.