Loading internal/build/jobspec.go +19 −5 Changes for internal/build/jobspec.go: 19 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -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 <base image>; 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 Loading Loading @@ -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) Loading internal/build/jobspec_test.go +52 −0 Changes for internal/build/jobspec_test.go: 52 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 <base image> 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. Loading Loading
internal/build/jobspec.go +19 −5 Changes for internal/build/jobspec.go: 19 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -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 <base image>; 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 Loading Loading @@ -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) Loading
internal/build/jobspec_test.go +52 −0 Changes for internal/build/jobspec_test.go: 52 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 <base image> 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. Loading