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.