From 27f74e17e5d7f2fe7c34ab1a3964fe50f28c560b Mon Sep 17 00:00:00 2001 From: "Alex Caston (Aex12)" Date: Mon, 23 Mar 2026 14:34:28 +0100 Subject: [PATCH 1/2] feat(kubernetes): support allowPrivilegeEscalation and capabilities backend_options --- .../11-backends/20-kubernetes.md | 20 ++++++ .../backend/kubernetes/backend_options.go | 22 +++--- pipeline/backend/kubernetes/pod.go | 56 ++++++++++----- pipeline/backend/kubernetes/pod_test.go | 71 +++++++++++++++++-- 4 files changed, 138 insertions(+), 31 deletions(-) diff --git a/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md b/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md index 7de7054dab3..a6f43a683af 100644 --- a/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md +++ b/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md @@ -359,6 +359,26 @@ backend_options: The feature requires Kubernetes v1.30 or above. ::: +You can set `allowPrivilegeEscalation` to `false` to prevent a container from gaining more privileges than its parent process. + +```yaml +backend_options: + kubernetes: + securityContext: + allowPrivilegeEscalation: false +``` + +You can also drop [Linux capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) from a container. Adding capabilities is not allowed. + +```yaml +backend_options: + kubernetes: + securityContext: + capabilities: + drop: + - ALL +``` + ### Annotations and labels You can specify arbitrary [annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) and [labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to be set on the Pod definition for a given workflow step using the following configuration: diff --git a/pipeline/backend/kubernetes/backend_options.go b/pipeline/backend/kubernetes/backend_options.go index 75559082b3e..5f37f2690a8 100644 --- a/pipeline/backend/kubernetes/backend_options.go +++ b/pipeline/backend/kubernetes/backend_options.go @@ -66,14 +66,16 @@ const ( ) type SecurityContext struct { - Privileged *bool `mapstructure:"privileged"` - RunAsNonRoot *bool `mapstructure:"runAsNonRoot"` - RunAsUser *int64 `mapstructure:"runAsUser"` - RunAsGroup *int64 `mapstructure:"runAsGroup"` - FSGroup *int64 `mapstructure:"fsGroup"` - FsGroupChangePolicy *kube_core_v1.PodFSGroupChangePolicy `mapstructure:"fsGroupChangePolicy"` - SeccompProfile *SecProfile `mapstructure:"seccompProfile"` - ApparmorProfile *SecProfile `mapstructure:"apparmorProfile"` + Privileged *bool `mapstructure:"privileged"` + RunAsNonRoot *bool `mapstructure:"runAsNonRoot"` + RunAsUser *int64 `mapstructure:"runAsUser"` + RunAsGroup *int64 `mapstructure:"runAsGroup"` + FSGroup *int64 `mapstructure:"fsGroup"` + FsGroupChangePolicy *kube_core_v1.PodFSGroupChangePolicy `mapstructure:"fsGroupChangePolicy"` + SeccompProfile *SecProfile `mapstructure:"seccompProfile"` + ApparmorProfile *SecProfile `mapstructure:"apparmorProfile"` + AllowPrivilegeEscalation *bool `mapstructure:"allowPrivilegeEscalation"` + Capabilities *Capabilities `mapstructure:"capabilities"` } type SecProfile struct { @@ -83,6 +85,10 @@ type SecProfile struct { type SecProfileType string +type Capabilities struct { + Drop []string `mapstructure:"drop"` +} + // SecretRef defines Kubernetes secret reference. type SecretRef struct { Name string `mapstructure:"name"` diff --git a/pipeline/backend/kubernetes/pod.go b/pipeline/backend/kubernetes/pod.go index f59c99e30ea..88960a41e3a 100644 --- a/pipeline/backend/kubernetes/pod.go +++ b/pipeline/backend/kubernetes/pod.go @@ -590,33 +590,57 @@ func apparmorProfile(scp *SecProfile) *kube_core_v1.AppArmorProfile { return apparmorProfile } -func containerSecurityContext(sc *SecurityContext, stepPrivileged bool) *kube_core_v1.SecurityContext { - if !stepPrivileged { +func containerCapabilities(capabilities *Capabilities) *kube_core_v1.Capabilities { + if capabilities == nil || len(capabilities.Drop) == 0 { return nil } - //nolint:staticcheck - privileged := false + drop := make([]kube_core_v1.Capability, len(capabilities.Drop)) - // if security context privileged is set explicitly - if sc != nil && sc.Privileged != nil && *sc.Privileged { - privileged = true + for i, c := range capabilities.Drop { + drop[i] = kube_core_v1.Capability(c) } - // if security context privileged is not set explicitly, but step is privileged - if (sc == nil || sc.Privileged == nil) && stepPrivileged { - privileged = true + return &kube_core_v1.Capabilities{ + Drop: drop, } +} - if privileged { - securityContext := &kube_core_v1.SecurityContext{ - Privileged: newBool(true), +func containerSecurityContext(sc *SecurityContext, stepPrivileged bool) *kube_core_v1.SecurityContext { + var ( + privileged *bool + allowPrivilegeEscalation *bool + capabilities *kube_core_v1.Capabilities + ) + + // A container may only run privileged when the step itself is privileged. + // If the step is privileged, the container is privileged by default unless + // explicitly disabled via securityContext.privileged=false. + if stepPrivileged && (sc == nil || sc.Privileged == nil || *sc.Privileged) { + privileged = newBool(true) + } + + if sc != nil { + // allowPrivilegeEscalation can only be set to false. + if sc.AllowPrivilegeEscalation != nil && !*sc.AllowPrivilegeEscalation { + allowPrivilegeEscalation = sc.AllowPrivilegeEscalation } - log.Trace().Msgf("container security context that will be used: %v", securityContext) - return securityContext + + capabilities = containerCapabilities(sc.Capabilities) } - return nil + if privileged == nil && capabilities == nil && allowPrivilegeEscalation == nil { + return nil + } + + securityContext := &kube_core_v1.SecurityContext{ + Privileged: privileged, + AllowPrivilegeEscalation: allowPrivilegeEscalation, + Capabilities: capabilities, + } + + log.Trace().Msgf("container security context that will be used: %v", securityContext) + return securityContext } func mapToEnvVars(m map[string]string) []kube_core_v1.EnvVar { diff --git a/pipeline/backend/kubernetes/pod_test.go b/pipeline/backend/kubernetes/pod_test.go index 83f4f773c66..5c281abb3dd 100644 --- a/pipeline/backend/kubernetes/pod_test.go +++ b/pipeline/backend/kubernetes/pod_test.go @@ -300,7 +300,11 @@ func TestFullPod(t *testing.T) { ], "imagePullPolicy": "Always", "securityContext": { - "privileged": true + "privileged": true, + "allowPrivilegeEscalation": false, + "capabilities": { + "drop": ["ALL"] + } } } ], @@ -378,12 +382,16 @@ func TestFullPod(t *testing.T) { } fsGroupChangePolicy := kube_core_v1.PodFSGroupChangePolicy("OnRootMismatch") secCtx := SecurityContext{ - Privileged: newBool(true), - RunAsNonRoot: newBool(true), - RunAsUser: newInt64(101), - RunAsGroup: newInt64(101), - FSGroup: newInt64(101), - FsGroupChangePolicy: &fsGroupChangePolicy, + Privileged: newBool(true), + RunAsNonRoot: newBool(true), + RunAsUser: newInt64(101), + RunAsGroup: newInt64(101), + FSGroup: newInt64(101), + FsGroupChangePolicy: &fsGroupChangePolicy, + AllowPrivilegeEscalation: newBool(false), + Capabilities: &Capabilities{ + Drop: []string{"ALL"}, + }, SeccompProfile: &SecProfile{ Type: "Localhost", LocalhostProfile: "profiles/audit.json", @@ -526,6 +534,55 @@ func TestPodPrivilege(t *testing.T) { pod, err = createTestPod(false, true, secCtx) assert.NoError(t, err) assert.True(t, *pod.Spec.SecurityContext.RunAsNonRoot) + + // non-privileged step with allowPrivilegeEscalation=false: applied + secCtx = SecurityContext{ + AllowPrivilegeEscalation: newBool(false), + } + pod, err = createTestPod(false, false, secCtx) + assert.NoError(t, err) + assert.NotNil(t, pod.Spec.Containers[0].SecurityContext) + assert.False(t, *pod.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation) + assert.Nil(t, pod.Spec.Containers[0].SecurityContext.Privileged) + + // non-privileged step with allowPrivilegeEscalation=true: ignored + secCtx = SecurityContext{ + AllowPrivilegeEscalation: newBool(true), + } + pod, err = createTestPod(false, false, secCtx) + assert.NoError(t, err) + assert.Nil(t, pod.Spec.Containers[0].SecurityContext) + + // privileged step with allowPrivilegeEscalation=true: ignored + secCtx = SecurityContext{ + AllowPrivilegeEscalation: newBool(true), + } + pod, err = createTestPod(true, false, secCtx) + assert.NoError(t, err) + assert.Nil(t, pod.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation) + + // non-privileged step with capabilities drop: applied + secCtx = SecurityContext{ + Capabilities: &Capabilities{Drop: []string{"ALL"}}, + } + pod, err = createTestPod(false, false, secCtx) + assert.NoError(t, err) + assert.NotNil(t, pod.Spec.Containers[0].SecurityContext) + assert.Equal(t, []kube_core_v1.Capability{"ALL"}, pod.Spec.Containers[0].SecurityContext.Capabilities.Drop) + assert.Nil(t, pod.Spec.Containers[0].SecurityContext.Capabilities.Add) + assert.Nil(t, pod.Spec.Containers[0].SecurityContext.Privileged) + + // non-privileged step with drop capabilities and allowPrivilegeEscalation=false: both applied + secCtx = SecurityContext{ + AllowPrivilegeEscalation: newBool(false), + Capabilities: &Capabilities{Drop: []string{"ALL"}}, + } + pod, err = createTestPod(false, false, secCtx) + assert.NoError(t, err) + assert.NotNil(t, pod.Spec.Containers[0].SecurityContext) + assert.Nil(t, pod.Spec.Containers[0].SecurityContext.Privileged) + assert.False(t, *pod.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation) + assert.Equal(t, []kube_core_v1.Capability{"ALL"}, pod.Spec.Containers[0].SecurityContext.Capabilities.Drop) } func TestScratchPod(t *testing.T) { From 68354ea3805ef8fb12025040e4fb6272eea2784e Mon Sep 17 00:00:00 2001 From: "Alex Caston (Aex12)" Date: Mon, 23 Mar 2026 20:40:23 +0100 Subject: [PATCH 2/2] kubernetes: default and enforced security context flags --- .../11-backends/20-kubernetes.md | 100 +++++++++++ pipeline/backend/kubernetes/flags.go | 12 ++ pipeline/backend/kubernetes/kubernetes.go | 33 +++- .../backend/kubernetes/kubernetes_test.go | 104 +++++++++-- pipeline/backend/kubernetes/pod.go | 123 ++++++++----- pipeline/backend/kubernetes/pod_test.go | 162 +++++++++++++++++- 6 files changed, 461 insertions(+), 73 deletions(-) diff --git a/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md b/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md index a6f43a683af..42d031fd777 100644 --- a/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md +++ b/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md @@ -448,6 +448,82 @@ steps: If ports are defined on a service, then woodpecker will create a normal service for the pod, which use hosts override using the services cluster IP. +## Running in an unprivileged namespace + +Woodpecker by default requires the namespace where workflow pods run to be privileged. + +However, it's possible to configure the agent in such a way that allows workflow pods to run in an unprivileged namespace. +This comes with some drawbacks and it's the reason why its disabled by default. + +Major drawbacks are: + +- You won't be able to use commands like `apk add` or `apt install` in most images. + The easiest way to workaround this is by building your own image with the tools you require already prebundled in it. + This also have the advantage that workflows will run faster, since it won't need to fetch packages during each run. +- If you need to build Docker/OCI images, you'll need to use a rootless builder like Buildah or BuildKit in rootless mode. +- The default clone step currently doesn't work in unprivileged namespaces, however its possible to define your own clone step that can run unprivileged. More details below. + +Please note, this guide assumes you already have a working woodpecker instance running in your kubernetes cluster in a privileged namespace. + +### Setting security context environment variables + +Depending on how you installed the woodpecker server and agent, this step may be different. +To make this guide as generic as possible, we will only list the environment variables that need to be updated. + +On your woodpecker-agent Deployment/StatefulSet, set this environment variables: + +```sh +WOODPECKER_BACKEND_K8S_DEFAULT_SECCTX='{"runAsUser":1000,"runAsGroup":1000,"fsGroup":1000,"fsGroupChangePolicy": "OnRootMismatch"}' +WOODPECKER_BACKEND_K8S_ENFORCED_SECCTX='{"privileged":false,"runAsNonRoot":true,"allowPrivilegeEscalation":false,"seccompProfile": {"type": "RuntimeDefault"}, "capabilities": {"drop": ["ALL"]}}' +``` + +Wait until the update rolls out. + +### Setting up the namespace + +Make the namespace where woodpecker worker pods run restricted, if you haven't done it yet: + +```sh +kubectl label namespace woodpecker \ + pod-security.kubernetes.io/enforce=restricted \ + pod-security.kubernetes.io/audit=restricted \ + pod-security.kubernetes.io/warn=restricted \ + --overwrite +``` + +Please note here we use the namespace name `woodpecker`, but you should replace it with the actual namespace name you're using for woodpecker worker pods. If you have set `WOODPECKER_BACKEND_K8S_NAMESPACE`, then this is the namespace you should update. If you haven't, worker pods will run by default in the same namespace as the `woodpecker-agent`. + +### Unprivileged clone step + +Currently, the default git clone step depends on the kubernetes container runtime to create its working directory. +Most container runtimes will create it owned by root by default, which will make the plugin fail with `Permission denied` errors if we dont precreate it, since the container will run unprivileged. + +Also, the default git clone plugin will use /app as its home, which is owned by root and writable only by root in the image, so we'll need to change that too. + +This is how our workflow should look like: + +```yaml +# skip the default clone step since we're replacing it with our own. +skip_clone: true + +steps: + # precreate the `plugin-git` working directory, so it won't fail with `Permission denied` errors later. + - name: prepare + image: alpine + commands: + - mkdir -p $CI_WORKSPACE + + - name: clone + image: quay.io/woodpeckerci/plugin-git + settings: + # set home to /tmp, which is writable by everybody in the `plugin-git` image. + home: /tmp +``` + +### Final notes about unprivileged namespaces + +Please note this setup is experimental, and you may encounter permission issues with other plugins. + ## Environment variables These env vars can be set in the `env:` sections of the agent. @@ -571,6 +647,30 @@ Determines if containers must be required to run as non-root users. --- +### BACKEND_K8S_DEFAULT_SECCTX + +- Name: `WOODPECKER_BACKEND_K8S_DEFAULT_SECCTX` +- Default: none + +The default security context that will be applied to all step pods. + +Must be a YAML object, e.g. `{"runAsUser":1000,"runAsGroup":1000,"fsGroup":1000,"fsGroupChangePolicy": "OnRootMismatch"}` + +The security context defined here can be overriden by workflow steps. If you want to define a security context that cannot be overriden, check the next option. + +--- + +### BACKEND_K8S_ENFORCED_SECCTX + +- Name: `WOODPECKER_BACKEND_K8S_ENFORCED_SECCTX` +- Default: none + +The security context that will be applied to all step pods. Cannot be overriden by workflow steps. + +Must be a YAML object, e.g. `{"privileged":false,"runAsNonRoot":true,"allowPrivilegeEscalation":false,"seccompProfile": {"type": "RuntimeDefault"}, "capabilities": {"drop": ["ALL"]}}` + +--- + ### BACKEND_K8S_PULL_SECRET_NAMES - Name: `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES` diff --git a/pipeline/backend/kubernetes/flags.go b/pipeline/backend/kubernetes/flags.go index fdec8e496d4..35cfc0e0f88 100644 --- a/pipeline/backend/kubernetes/flags.go +++ b/pipeline/backend/kubernetes/flags.go @@ -108,6 +108,18 @@ var Flags = []cli.Flag{ Name: "backend-k8s-secctx-nonroot", Usage: "`run as non root` Kubernetes security context option", }, + &cli.StringFlag{ + Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_DEFAULT_SECCTX"), // cspell:words secctx + Name: "backend-k8s-default-secctx", + Usage: "default Kubernetes security context option", + Value: "", + }, + &cli.StringFlag{ + Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_ENFORCED_SECCTX"), // cspell:words secctx + Name: "backend-k8s-enforced-secctx", + Usage: "enforced Kubernetes security context option, cannot be overridden by step options", + Value: "", + }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES"), Name: "backend-k8s-pod-image-pull-secret-names", diff --git a/pipeline/backend/kubernetes/kubernetes.go b/pipeline/backend/kubernetes/kubernetes.go index a28ed0fbed0..3cebe198254 100644 --- a/pipeline/backend/kubernetes/kubernetes.go +++ b/pipeline/backend/kubernetes/kubernetes.go @@ -75,7 +75,8 @@ type config struct { PodAffinity *kube_core_v1.Affinity PodAffinityAllowFromStep bool ImagePullSecretNames []string - SecurityContext SecurityContextConfig + DefaultSecurityContext SecurityContext + EnforcedSecurityContext SecurityContext NativeSecretsAllowFromStep bool PriorityClassName string } @@ -87,11 +88,6 @@ func (c *config) GetNamespace(orgID int64) string { return c.Namespace } -type SecurityContextConfig struct { - RunAsNonRoot bool - FSGroup *int64 -} - func newDefaultDeleteOptions() kube_meta_v1.DeleteOptions { gracePeriodSeconds := int64(0) // immediately propagationPolicy := kube_meta_v1.DeletePropagationBackground @@ -120,10 +116,10 @@ func configFromCliContext(ctx context.Context) (*config, error) { PodNodeSelector: make(map[string]string), // just init empty map to prevent nil panic PodAffinityAllowFromStep: c.Bool("backend-k8s-pod-affinity-allow-from-step"), ImagePullSecretNames: c.StringSlice("backend-k8s-pod-image-pull-secret-names"), - SecurityContext: SecurityContextConfig{ - RunAsNonRoot: c.Bool("backend-k8s-secctx-nonroot"), // cspell:words secctx nonroot - FSGroup: newInt64(defaultFSGroup), + DefaultSecurityContext: SecurityContext{ + FSGroup: newInt64(defaultFSGroup), }, + EnforcedSecurityContext: SecurityContext{}, NativeSecretsAllowFromStep: c.Bool("backend-k8s-allow-native-secrets"), } // Unmarshal label and annotation settings here to ensure they're valid on startup @@ -157,6 +153,25 @@ func configFromCliContext(ctx context.Context) (*config, error) { return nil, err } } + if defaultSecCtx := c.String("backend-k8s-default-secctx"); defaultSecCtx != "" { + var sc SecurityContext + if err := yaml.Unmarshal([]byte(defaultSecCtx), &sc); err != nil { + log.Error().Err(err).Msgf("could not unmarshal default security context '%s'", defaultSecCtx) + return nil, err + } + config.DefaultSecurityContext = sc + } + if enforcedSecCtx := c.String("backend-k8s-enforced-secctx"); enforcedSecCtx != "" { + var sc SecurityContext + if err := yaml.Unmarshal([]byte(enforcedSecCtx), &sc); err != nil { + log.Error().Err(err).Msgf("could not unmarshal enforced security context '%s'", enforcedSecCtx) + return nil, err + } + config.EnforcedSecurityContext = sc + } + if c.Bool("backend-k8s-secctx-nonroot") { + config.EnforcedSecurityContext.RunAsNonRoot = newBool(true) + } return &config, nil } diff --git a/pipeline/backend/kubernetes/kubernetes_test.go b/pipeline/backend/kubernetes/kubernetes_test.go index 120eb072a0d..dcfdc05641b 100644 --- a/pipeline/backend/kubernetes/kubernetes_test.go +++ b/pipeline/backend/kubernetes/kubernetes_test.go @@ -35,14 +35,14 @@ import ( func TestGettingConfig(t *testing.T) { engine := kube{ config: &config{ - Namespace: "default", - StorageClass: "hdd", - VolumeSize: "1G", - StorageRwx: false, - PodLabels: map[string]string{"l1": "v1"}, - PodAnnotations: map[string]string{"a1": "v1"}, - ImagePullSecretNames: []string{"regcred"}, - SecurityContext: SecurityContextConfig{RunAsNonRoot: false}, + Namespace: "default", + StorageClass: "hdd", + VolumeSize: "1G", + StorageRwx: false, + PodLabels: map[string]string{"l1": "v1"}, + PodAnnotations: map[string]string{"a1": "v1"}, + ImagePullSecretNames: []string{"regcred"}, + DefaultSecurityContext: SecurityContext{RunAsNonRoot: newBool(false)}, }, } config := engine.getConfig() @@ -52,7 +52,7 @@ func TestGettingConfig(t *testing.T) { config.PodLabels = nil config.PodAnnotations["a2"] = "v2" config.ImagePullSecretNames = append(config.ImagePullSecretNames, "docker.io") - config.SecurityContext.RunAsNonRoot = true + config.DefaultSecurityContext.RunAsNonRoot = newBool(true) assert.Equal(t, "default", engine.config.Namespace) assert.Equal(t, "hdd", engine.config.StorageClass) @@ -61,7 +61,7 @@ func TestGettingConfig(t *testing.T) { assert.Len(t, engine.config.PodLabels, 1) assert.Len(t, engine.config.PodAnnotations, 1) assert.Len(t, engine.config.ImagePullSecretNames, 1) - assert.False(t, engine.config.SecurityContext.RunAsNonRoot) + assert.False(t, *engine.config.DefaultSecurityContext.RunAsNonRoot) } func TestSetupWorkflow(t *testing.T) { @@ -73,14 +73,14 @@ func TestSetupWorkflow(t *testing.T) { engine := kube{ config: &config{ - Namespace: namespace, - StorageClass: "hdd", - VolumeSize: "1G", - StorageRwx: false, - PodLabels: map[string]string{"l1": "v1"}, - PodAnnotations: map[string]string{"a1": "v1"}, - ImagePullSecretNames: []string{"regcred"}, - SecurityContext: SecurityContextConfig{RunAsNonRoot: false}, + Namespace: namespace, + StorageClass: "hdd", + VolumeSize: "1G", + StorageRwx: false, + PodLabels: map[string]string{"l1": "v1"}, + PodAnnotations: map[string]string{"a1": "v1"}, + ImagePullSecretNames: []string{"regcred"}, + DefaultSecurityContext: SecurityContext{RunAsNonRoot: newBool(false)}, }, client: fake.NewClientset(), } @@ -181,6 +181,74 @@ func TestAffinityFromCliContext(t *testing.T) { require.NoError(t, err) } +func TestSecctxNonrootFromCliContext(t *testing.T) { + t.Setenv("WOODPECKER_BACKEND_K8S_SECCTX_NONROOT", "true") + + cmd := &cli.Command{ + Flags: Flags, + Action: func(ctx context.Context, c *cli.Command) error { + ctx = context.WithValue(ctx, types.CliCommand, c) + config, err := configFromCliContext(ctx) + + require.NoError(t, err) + require.NotNil(t, config) + + // Verify security context was parsed + require.NotNil(t, config.EnforcedSecurityContext) + assert.True(t, *config.EnforcedSecurityContext.RunAsNonRoot) + return nil + }, + } + err := cmd.Run(context.Background(), []string{"test"}) + require.NoError(t, err) +} + +func TestSecurityContextFromCliContext(t *testing.T) { + t.Setenv("WOODPECKER_BACKEND_K8S_DEFAULT_SECCTX", `{ + "runAsUser":1000, + "runAsGroup":1000, + "fsGroup":1000, + "fsGroupChangePolicy": "OnRootMismatch" + }`) + t.Setenv("WOODPECKER_BACKEND_K8S_ENFORCED_SECCTX", `{ + "privileged":false, + "runAsNonRoot":true, + "allowPrivilegeEscalation":false, + "seccompProfile": {"type": "RuntimeDefault"}, + "capabilities": {"drop": ["ALL"]} + }`) + + cmd := &cli.Command{ + Flags: Flags, + Action: func(ctx context.Context, c *cli.Command) error { + ctx = context.WithValue(ctx, types.CliCommand, c) + config, err := configFromCliContext(ctx) + + require.NoError(t, err) + require.NotNil(t, config) + + // Verify security context was parsed + require.NotNil(t, config.DefaultSecurityContext) + require.NotNil(t, config.EnforcedSecurityContext) + + assert.Equal(t, (int64)(1000), *config.DefaultSecurityContext.RunAsUser) + assert.Equal(t, (int64)(1000), *config.DefaultSecurityContext.RunAsGroup) + assert.Equal(t, (int64)(1000), *config.DefaultSecurityContext.FSGroup) + assert.Equal(t, kube_core_v1.PodFSGroupChangePolicy("OnRootMismatch"), *config.DefaultSecurityContext.FsGroupChangePolicy) + + assert.False(t, *config.EnforcedSecurityContext.Privileged) + assert.True(t, *config.EnforcedSecurityContext.RunAsNonRoot) + assert.False(t, *config.EnforcedSecurityContext.AllowPrivilegeEscalation) + assert.Equal(t, SecProfileType("RuntimeDefault"), config.EnforcedSecurityContext.SeccompProfile.Type) + assert.Equal(t, []string{"ALL"}, config.EnforcedSecurityContext.Capabilities.Drop) + + return nil + }, + } + err := cmd.Run(context.Background(), []string{"test"}) + require.NoError(t, err) +} + func makeStep(uuid string) *types.Step { return &types.Step{ UUID: uuid, diff --git a/pipeline/backend/kubernetes/pod.go b/pipeline/backend/kubernetes/pod.go index 88960a41e3a..c4c65ee75a7 100644 --- a/pipeline/backend/kubernetes/pod.go +++ b/pipeline/backend/kubernetes/pod.go @@ -60,7 +60,7 @@ func mkPod(step *types.Step, config *config, podName, goos string, options Backe return nil, err } - container, err := podContainer(step, podName, goos, options, nsp) + container, err := podContainer(step, podName, goos, config, options, nsp) if err != nil { return nil, err } @@ -190,7 +190,7 @@ func podSpec(step *types.Step, config *config, options BackendOptions, nsp nativ NodeSelector: nodeSelector(options.NodeSelector, config.PodNodeSelector, step.Environment["CI_SYSTEM_PLATFORM"]), Tolerations: tolerations(options.Tolerations), Affinity: affinity(options.Affinity, config.PodAffinity, config.PodAffinityAllowFromStep), - SecurityContext: podSecurityContext(options.SecurityContext, config.SecurityContext, step.Privileged), + SecurityContext: podSecurityContext(options.SecurityContext, config, step.Privileged), } // If there are tolerations and they are allowed @@ -231,14 +231,14 @@ func podSpec(step *types.Step, config *config, options BackendOptions, nsp nativ return spec, nil } -func podContainer(step *types.Step, podName, goos string, options BackendOptions, nsp nativeSecretsProcessor) (kube_core_v1.Container, error) { +func podContainer(step *types.Step, podName, goos string, config *config, options BackendOptions, nsp nativeSecretsProcessor) (kube_core_v1.Container, error) { var err error container := kube_core_v1.Container{ Name: podName, Image: step.Image, WorkingDir: step.WorkingDir, Ports: containerPorts(step.Ports), - SecurityContext: containerSecurityContext(options.SecurityContext, step.Privileged), + SecurityContext: containerSecurityContext(options.SecurityContext, config, step.Privileged), } if step.Pull { @@ -492,70 +492,84 @@ func affinity(stepAffinity, agentAffinity *kube_core_v1.Affinity, allowFromStep return nil } -func podSecurityContext(sc *SecurityContext, secCtxConf SecurityContextConfig, stepPrivileged bool) *kube_core_v1.PodSecurityContext { - var ( - nonRoot *bool - user *int64 - group *int64 - fsGroup *int64 - fsGroupChangePolicy *kube_core_v1.PodFSGroupChangePolicy - seccomp *kube_core_v1.SeccompProfile - apparmor *kube_core_v1.AppArmorProfile - ) - - if secCtxConf.RunAsNonRoot { - nonRoot = newBool(true) +func mergePodSecurityContext(target *kube_core_v1.PodSecurityContext, mergeFrom SecurityContext) { + if mergeFrom.RunAsNonRoot != nil { + target.RunAsNonRoot = mergeFrom.RunAsNonRoot + } + if mergeFrom.RunAsUser != nil { + target.RunAsUser = mergeFrom.RunAsUser + } + if mergeFrom.RunAsGroup != nil { + target.RunAsGroup = mergeFrom.RunAsGroup + } + if mergeFrom.FSGroup != nil { + target.FSGroup = mergeFrom.FSGroup + } + if mergeFrom.FsGroupChangePolicy != nil { + target.FSGroupChangePolicy = mergeFrom.FsGroupChangePolicy } - if secCtxConf.FSGroup != nil { - fsGroup = secCtxConf.FSGroup + if mergeFrom.SeccompProfile != nil { + target.SeccompProfile = seccompProfile(mergeFrom.SeccompProfile) } + if mergeFrom.ApparmorProfile != nil { + target.AppArmorProfile = apparmorProfile(mergeFrom.ApparmorProfile) + } +} + +func podSecurityContext(sc *SecurityContext, config *config, stepPrivileged bool) *kube_core_v1.PodSecurityContext { + podsc := &kube_core_v1.PodSecurityContext{} + + mergePodSecurityContext(podsc, config.DefaultSecurityContext) if sc != nil { // only allow to set user if its not root or step is privileged if sc.RunAsUser != nil && (*sc.RunAsUser != 0 || stepPrivileged) { - user = sc.RunAsUser + podsc.RunAsUser = sc.RunAsUser } // only allow to set group if its not root or step is privileged if sc.RunAsGroup != nil && (*sc.RunAsGroup != 0 || stepPrivileged) { - group = sc.RunAsGroup + podsc.RunAsGroup = sc.RunAsGroup } // only allow to set fsGroup if its not root or step is privileged if sc.FSGroup != nil && (*sc.FSGroup != 0 || stepPrivileged) { - fsGroup = sc.FSGroup + podsc.FSGroup = sc.FSGroup } // if unset, set fsGroup to 1000 by default to support non-root images if sc.FSGroup != nil { - fsGroup = sc.FSGroup + podsc.FSGroup = sc.FSGroup } - // only allow to set nonRoot if it's not set globally already - if nonRoot == nil && sc.RunAsNonRoot != nil { - nonRoot = sc.RunAsNonRoot + if sc.RunAsNonRoot != nil { + podsc.RunAsNonRoot = sc.RunAsNonRoot } - seccomp = seccompProfile(sc.SeccompProfile) - apparmor = apparmorProfile(sc.ApparmorProfile) - fsGroupChangePolicy = sc.FsGroupChangePolicy + podsc.SeccompProfile = seccompProfile(sc.SeccompProfile) + podsc.AppArmorProfile = apparmorProfile(sc.ApparmorProfile) + podsc.FSGroupChangePolicy = sc.FsGroupChangePolicy } - if nonRoot == nil && user == nil && group == nil && fsGroup == nil && seccomp == nil && apparmor == nil { - return nil + mergePodSecurityContext(podsc, config.EnforcedSecurityContext) + + // prefer nil over default values in kubernetes + if podsc.RunAsNonRoot != nil && !*podsc.RunAsNonRoot { + podsc.RunAsNonRoot = nil } - securityContext := &kube_core_v1.PodSecurityContext{ - RunAsNonRoot: nonRoot, - RunAsUser: user, - RunAsGroup: group, - FSGroup: fsGroup, - FSGroupChangePolicy: fsGroupChangePolicy, - SeccompProfile: seccomp, - AppArmorProfile: apparmor, + if podsc.RunAsNonRoot == nil && + podsc.RunAsUser == nil && + podsc.RunAsGroup == nil && + podsc.FSGroup == nil && + podsc.FSGroupChangePolicy == nil && + podsc.SeccompProfile == nil && + podsc.AppArmorProfile == nil { + return nil } - log.Trace().Msgf("pod security context that will be used: %v", securityContext) - return securityContext + + log.Trace().Msgf("pod security context that will be used: %v", podsc) + return podsc } func seccompProfile(scp *SecProfile) *kube_core_v1.SeccompProfile { @@ -606,13 +620,20 @@ func containerCapabilities(capabilities *Capabilities) *kube_core_v1.Capabilitie } } -func containerSecurityContext(sc *SecurityContext, stepPrivileged bool) *kube_core_v1.SecurityContext { +func containerSecurityContext(sc *SecurityContext, config *config, stepPrivileged bool) *kube_core_v1.SecurityContext { var ( privileged *bool allowPrivilegeEscalation *bool capabilities *kube_core_v1.Capabilities ) + if config.DefaultSecurityContext.AllowPrivilegeEscalation != nil { + allowPrivilegeEscalation = config.DefaultSecurityContext.AllowPrivilegeEscalation + } + if config.DefaultSecurityContext.Capabilities != nil { + capabilities = containerCapabilities(config.DefaultSecurityContext.Capabilities) + } + // A container may only run privileged when the step itself is privileged. // If the step is privileged, the container is privileged by default unless // explicitly disabled via securityContext.privileged=false. @@ -629,6 +650,24 @@ func containerSecurityContext(sc *SecurityContext, stepPrivileged bool) *kube_co capabilities = containerCapabilities(sc.Capabilities) } + if config.EnforcedSecurityContext.AllowPrivilegeEscalation != nil { + allowPrivilegeEscalation = config.EnforcedSecurityContext.AllowPrivilegeEscalation + } + if config.EnforcedSecurityContext.Privileged != nil { + privileged = config.EnforcedSecurityContext.Privileged + } + if config.EnforcedSecurityContext.Capabilities != nil { + capabilities = containerCapabilities(config.EnforcedSecurityContext.Capabilities) + } + + // prefer nil over default values in kubernetes + if allowPrivilegeEscalation != nil && *allowPrivilegeEscalation { + allowPrivilegeEscalation = nil + } + if privileged != nil && !*privileged { + privileged = nil + } + if privileged == nil && capabilities == nil && allowPrivilegeEscalation == nil { return nil } diff --git a/pipeline/backend/kubernetes/pod_test.go b/pipeline/backend/kubernetes/pod_test.go index 5c281abb3dd..cb563fdefd9 100644 --- a/pipeline/backend/kubernetes/pod_test.go +++ b/pipeline/backend/kubernetes/pod_test.go @@ -427,7 +427,7 @@ func TestFullPod(t *testing.T) { PodAnnotationsAllowFromStep: true, PodTolerationsAllowFromStep: true, PodNodeSelector: map[string]string{"topology.kubernetes.io/region": "eu-central-1"}, - SecurityContext: SecurityContextConfig{RunAsNonRoot: false}, + DefaultSecurityContext: SecurityContext{RunAsNonRoot: newBool(false)}, }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", @@ -454,15 +454,23 @@ func TestFullPod(t *testing.T) { } func TestPodPrivilege(t *testing.T) { - createTestPod := func(stepPrivileged, globalRunAsRoot bool, secCtx SecurityContext) (*kube_core_v1.Pod, error) { + createTestPod := func(stepPrivileged, globalRunAsNonRoot bool, secCtx SecurityContext) (*kube_core_v1.Pod, error) { + defaultSecurityContext := SecurityContext{} + enforcedSecurityContext := SecurityContext{} + if globalRunAsNonRoot { + enforcedSecurityContext.RunAsNonRoot = newBool(globalRunAsNonRoot) + } else { + defaultSecurityContext.RunAsNonRoot = newBool(globalRunAsNonRoot) + } return mkPod(&types.Step{ Name: "go-test", Image: "golang:1.16", UUID: "01he8bebctabr3kgk0qj36d2me-0", Privileged: stepPrivileged, }, &config{ - Namespace: "woodpecker", - SecurityContext: SecurityContextConfig{RunAsNonRoot: globalRunAsRoot}, + Namespace: "woodpecker", + EnforcedSecurityContext: enforcedSecurityContext, + DefaultSecurityContext: defaultSecurityContext, }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ SecurityContext: &secCtx, }, "11301") @@ -585,6 +593,152 @@ func TestPodPrivilege(t *testing.T) { assert.Equal(t, []kube_core_v1.Capability{"ALL"}, pod.Spec.Containers[0].SecurityContext.Capabilities.Drop) } +func TestDefaultSecurityContext(t *testing.T) { + step := &types.Step{ + Name: "test", + Image: "alpine", + UUID: "01he8bebctabr3kgk0qj36d2me-0", + Privileged: true, + } + + // default security context is applied when step has no security context + fsGroupChangePolicy := kube_core_v1.FSGroupChangeOnRootMismatch + pod, err := mkPod(step, &config{ + Namespace: "woodpecker", + DefaultSecurityContext: SecurityContext{ + RunAsNonRoot: newBool(true), + RunAsUser: newInt64(1000), + RunAsGroup: newInt64(2000), + FSGroup: newInt64(2000), + FsGroupChangePolicy: &fsGroupChangePolicy, + SeccompProfile: &SecProfile{Type: "RuntimeDefault"}, + Capabilities: &Capabilities{Drop: []string{"ALL"}}, + AllowPrivilegeEscalation: newBool(false), + }, + }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{}, taskUUID) + assert.NoError(t, err) + assert.True(t, *pod.Spec.SecurityContext.RunAsNonRoot) + assert.Equal(t, int64(1000), *pod.Spec.SecurityContext.RunAsUser) + assert.Equal(t, int64(2000), *pod.Spec.SecurityContext.RunAsGroup) + assert.Equal(t, int64(2000), *pod.Spec.SecurityContext.FSGroup) + assert.Equal(t, kube_core_v1.FSGroupChangeOnRootMismatch, *pod.Spec.SecurityContext.FSGroupChangePolicy) + assert.Equal(t, kube_core_v1.SeccompProfileTypeRuntimeDefault, pod.Spec.SecurityContext.SeccompProfile.Type) + assert.Equal(t, []kube_core_v1.Capability{"ALL"}, pod.Spec.Containers[0].SecurityContext.Capabilities.Drop) + assert.Nil(t, pod.Spec.Containers[0].SecurityContext.Capabilities.Add) + assert.False(t, *pod.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation) + + // step security context overrides individual fields from the default + pod, err = mkPod(step, &config{ + Namespace: "woodpecker", + DefaultSecurityContext: SecurityContext{ + RunAsUser: newInt64(1000), + RunAsGroup: newInt64(2000), + FSGroup: newInt64(2000), + }, + }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ + SecurityContext: &SecurityContext{ + RunAsGroup: newInt64(999), + FSGroup: newInt64(999), + }, + }, taskUUID) + assert.NoError(t, err) + assert.Equal(t, int64(999), *pod.Spec.SecurityContext.RunAsGroup) + assert.Equal(t, int64(999), *pod.Spec.SecurityContext.FSGroup) + // RunAsUser from default is preserved since the step does not override it + assert.Equal(t, int64(1000), *pod.Spec.SecurityContext.RunAsUser) + + // step can override default runAsNonRoot + pod, err = mkPod(step, &config{ + Namespace: "woodpecker", + DefaultSecurityContext: SecurityContext{ + RunAsNonRoot: newBool(true), + }, + }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ + SecurityContext: &SecurityContext{ + RunAsNonRoot: newBool(false), + }, + }, taskUUID) + assert.NoError(t, err) + // false is normalized to nil; with no other fields set, the security context is absent entirely + assert.Nil(t, pod.Spec.SecurityContext) +} + +func TestEnforcedSecurityContext(t *testing.T) { + step := &types.Step{ + Name: "test", + Image: "alpine", + UUID: "01he8bebctabr3kgk0qj36d2me-0", + Privileged: true, + } + + // enforced security context overrides step security context + pod, err := mkPod(step, &config{ + Namespace: "woodpecker", + EnforcedSecurityContext: SecurityContext{ + RunAsNonRoot: newBool(true), + RunAsUser: newInt64(1000), + RunAsGroup: newInt64(2000), + FSGroup: newInt64(2000), + SeccompProfile: &SecProfile{Type: "RuntimeDefault"}, + ApparmorProfile: &SecProfile{Type: "RuntimeDefault"}, + Capabilities: &Capabilities{Drop: []string{"ALL"}}, + AllowPrivilegeEscalation: newBool(false), + Privileged: newBool(false), + }, + }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ + SecurityContext: &SecurityContext{ + RunAsNonRoot: newBool(false), + RunAsUser: newInt64(0), + RunAsGroup: newInt64(0), + FSGroup: newInt64(0), + SeccompProfile: nil, + ApparmorProfile: &SecProfile{Type: "Unconfined"}, + Capabilities: &Capabilities{Drop: []string{""}}, + AllowPrivilegeEscalation: newBool(true), + Privileged: newBool(true), + }, + }, taskUUID) + assert.NoError(t, err) + assert.True(t, *pod.Spec.SecurityContext.RunAsNonRoot) + assert.Equal(t, int64(1000), *pod.Spec.SecurityContext.RunAsUser) + assert.Equal(t, int64(2000), *pod.Spec.SecurityContext.RunAsGroup) + assert.Equal(t, int64(2000), *pod.Spec.SecurityContext.FSGroup) + assert.Equal(t, kube_core_v1.SeccompProfileTypeRuntimeDefault, pod.Spec.SecurityContext.SeccompProfile.Type) + assert.Equal(t, kube_core_v1.AppArmorProfileTypeRuntimeDefault, pod.Spec.SecurityContext.AppArmorProfile.Type) + assert.Equal(t, []kube_core_v1.Capability{"ALL"}, pod.Spec.Containers[0].SecurityContext.Capabilities.Drop) + assert.Nil(t, pod.Spec.Containers[0].SecurityContext.Capabilities.Add) + assert.False(t, *pod.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation) + assert.Nil(t, pod.Spec.Containers[0].SecurityContext.Privileged) + + // enforced security context is applied when no step security context is set + pod, err = mkPod(step, &config{ + Namespace: "woodpecker", + EnforcedSecurityContext: SecurityContext{ + RunAsUser: newInt64(1000), + FSGroup: newInt64(2000), + }, + }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{}, taskUUID) + assert.NoError(t, err) + assert.Equal(t, int64(1000), *pod.Spec.SecurityContext.RunAsUser) + assert.Equal(t, int64(2000), *pod.Spec.SecurityContext.FSGroup) + + // enforced security context overrides default security context + pod, err = mkPod(step, &config{ + Namespace: "woodpecker", + DefaultSecurityContext: SecurityContext{ + RunAsUser: newInt64(1000), + RunAsGroup: newInt64(2000), + }, + EnforcedSecurityContext: SecurityContext{ + RunAsUser: newInt64(1001), + }, + }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{}, taskUUID) + assert.NoError(t, err) + assert.Equal(t, int64(1001), *pod.Spec.SecurityContext.RunAsUser) + // RunAsGroup from default is preserved since the enforced security context does not override it + assert.Equal(t, int64(2000), *pod.Spec.SecurityContext.RunAsGroup) +} + func TestScratchPod(t *testing.T) { const expected = ` {