From 1cac4ce35fe9c614608f6dc44c43b056b1d2b834 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 18 Jul 2026 12:42:40 +0200 Subject: [PATCH 1/2] feat(policy): reject explicit hostUsers opt-out in userns namespaces The user-namespace rollout is mutate-only: add-security-context.yaml injects hostUsers: false through a +(hostUsers) conditional anchor, which only fills in an absent value. A workload that explicitly sets hostUsers: true keeps it, silently, in a namespace that still carries the opt-in label. Add a disallow-host-users validate rule mirroring the mutate rule's scope (same namespaceSelector, same cnpg.io/cluster exclude) and the file's existing =(hostNetwork): false idiom, so an absent value passes and an explicit true is rejected at admission. Part of #1807 --- .../validate-host-restrictions.yaml | 49 +++++++++++- .../kyverno-test.yaml | 34 ++++++++ .../validate-host-restrictions/resources.yaml | 77 +++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 tests/validate-host-restrictions/kyverno-test.yaml create mode 100644 tests/validate-host-restrictions/resources.yaml diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/validate-host-restrictions.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/validate-host-restrictions.yaml index 7e5747c15..3852d4ba2 100644 --- a/k8s/bases/infrastructure/cluster-policies/best-practices/validate-host-restrictions.yaml +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/validate-host-restrictions.yaml @@ -1,5 +1,6 @@ # Validates that pods in non-infrastructure namespaces do not use -# host-level access (hostPath, hostNetwork, hostPID, hostIPC). +# host-level access (hostPath, hostNetwork, hostPID, hostIPC), and that pods +# in user-namespace-enabled namespaces do not opt back out (hostUsers). # # Addresses kubescape controls: C-0045, C-0048, C-0041, C-0038 # @@ -93,3 +94,49 @@ spec: =(hostNetwork): false =(hostPID): false =(hostIPC): false + # The user-namespace rollout (#1807) is delivered by the add-user-namespaces + # MUTATE rule in add-security-context.yaml, which injects hostUsers: false + # through a +(hostUsers) conditional anchor. A conditional anchor only fills + # in an ABSENT value, so a workload that explicitly sets hostUsers: true + # keeps it — silently, with no admission error and no policy report, in a + # namespace that still carries the opt-in label and still looks hardened. + # This rule closes that gap: it is the enforce ratchet for the same control. + # + # Scope mirrors the mutate rule exactly, on purpose: + # - the same namespaceSelector, so only namespaces that have graduated + # through a userns pilot are held to it; + # - the same cnpg.io/cluster exclude, so the validate rule can never block a + # workload the mutate rule deliberately skipped. CNPG pods stay on host + # user namespaces pending their own per-app pilot, and leave hostUsers + # absent; excluding them keeps that true even if CNPG later sets the field + # explicitly. + # + # =(hostUsers) is validate-if-present, matching the hostNetwork/hostPID/ + # hostIPC idiom above: an absent value passes (and the mutate rule supplies + # the default), while an explicit true is rejected. + - name: disallow-host-users + match: + any: + - resources: + kinds: + - Pod + namespaceSelector: + matchLabels: + pod-security.devantler.tech/user-namespaces: enabled + exclude: + any: + - resources: + kinds: + - Pod + selector: + matchExpressions: + - key: cnpg.io/cluster + operator: Exists + validate: + message: >- + hostUsers: true is not allowed in a namespace opted into user + namespaces. Remove the field to inherit hostUsers: false, or drop the + pod-security.devantler.tech/user-namespaces label from the namespace. + pattern: + spec: + =(hostUsers): false diff --git a/tests/validate-host-restrictions/kyverno-test.yaml b/tests/validate-host-restrictions/kyverno-test.yaml new file mode 100644 index 000000000..0dafb4299 --- /dev/null +++ b/tests/validate-host-restrictions/kyverno-test.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: cli.kyverno.io/v1alpha1 +kind: Test +metadata: + name: validate-host-restrictions-host-users +policies: + - >- + ../../k8s/bases/infrastructure/cluster-policies/best-practices/validate-host-restrictions.yaml +resources: + - resources.yaml +results: + # The regression the mutate rule's conditional anchor cannot catch. + - policy: validate-host-restrictions + rule: disallow-host-users + resources: + - userns-enabled/explicit-host-users + kind: Pod + result: fail + # Absent inherits the mutated default; explicit false is already correct. + - policy: validate-host-restrictions + rule: disallow-host-users + resources: + - userns-enabled/explicit-unshared + - userns-enabled/host-users-absent + kind: Pod + result: pass + # CNPG pods are excluded, and namespaces without the opt-in label are out of scope. + - policy: validate-host-restrictions + rule: disallow-host-users + resources: + - userns-enabled/cnpg-instance + - userns-absent/unscoped-host-users + kind: Pod + result: skip diff --git a/tests/validate-host-restrictions/resources.yaml b/tests/validate-host-restrictions/resources.yaml new file mode 100644 index 000000000..8283650aa --- /dev/null +++ b/tests/validate-host-restrictions/resources.yaml @@ -0,0 +1,77 @@ +--- +# Namespace opted into user namespaces — the disallow-host-users rule applies here. +apiVersion: v1 +kind: Namespace +metadata: + name: userns-enabled + labels: + pod-security.devantler.tech/user-namespaces: enabled +--- +# Namespace that has NOT graduated through a userns pilot — the rule must not apply. +apiVersion: v1 +kind: Namespace +metadata: + name: userns-absent +--- +# Explicit opt-out inside an opted-in namespace. This is the regression the +# +(hostUsers) conditional anchor in add-security-context.yaml cannot catch. +apiVersion: v1 +kind: Pod +metadata: + name: explicit-host-users + namespace: userns-enabled +spec: + hostUsers: true + containers: + - name: app + image: registry.k8s.io/pause:3.10 +--- +# Explicit hostUsers: false — the value the mutate rule injects. +apiVersion: v1 +kind: Pod +metadata: + name: explicit-unshared + namespace: userns-enabled +spec: + hostUsers: false + containers: + - name: app + image: registry.k8s.io/pause:3.10 +--- +# hostUsers absent — passes validate-if-present; the mutate rule supplies false. +apiVersion: v1 +kind: Pod +metadata: + name: host-users-absent + namespace: userns-enabled +spec: + containers: + - name: app + image: registry.k8s.io/pause:3.10 +--- +# CNPG-managed pod inside an opted-in namespace. Mirrors the mutate rule's +# exclude, so a mixed namespace (stateless app + CNPG database) stays safe. +apiVersion: v1 +kind: Pod +metadata: + name: cnpg-instance + namespace: userns-enabled + labels: + cnpg.io/cluster: demo-db +spec: + hostUsers: true + containers: + - name: postgres + image: registry.k8s.io/pause:3.10 +--- +# Same explicit opt-out, but in a namespace without the opt-in label: out of scope. +apiVersion: v1 +kind: Pod +metadata: + name: unscoped-host-users + namespace: userns-absent +spec: + hostUsers: true + containers: + - name: app + image: registry.k8s.io/pause:3.10 From ba6636aade1c27452044245223debc8b20736a50 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Sat, 18 Jul 2026 12:46:35 +0200 Subject: [PATCH 2/2] ci: run the kyverno policy tests, pinned to the cluster's engine version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing executed the fixtures under tests/, so tests/validate-replica-floor had never actually run in CI and a policy regression could ship green. Add a 'kyverno test ./tests' step to the existing static validate job and broaden the k8s paths filter from the single cilium fixture to tests/**. The CLI is pinned to v1.18.2, the version the kyverno Helm release runs, so a policy that passes here is evaluated by the same engine that admits it in production. Also add values.yaml to the new fixture: the CLI does not read namespace labels from Namespace objects in resources.yaml, so a namespaceSelector rule reports every resource 'Excluded' and the whole fixture — including its fail case — passes vacuously without it. Part of #1807 --- .github/workflows/ci.yaml | 19 ++++++++++++++++++- .../kyverno-test.yaml | 3 +++ tests/validate-host-restrictions/values.yaml | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/validate-host-restrictions/values.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 13599e4b4..1ce068fce 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,7 +57,7 @@ jobs: - 'go.mod' - 'go.sum' - 'scripts/tests/test-cilium-bandwidth-manager-component.sh' - - 'tests/cilium-bandwidth-manager-bbr/**' + - 'tests/**' - 'scripts/refresh-flux-ghcr-auth.sh' - 'scripts/ghcr-auth-lib.sh' - 'scripts/run-ksail-prod-with-pull-auth.sh' @@ -131,6 +131,23 @@ jobs: # scripts/validate-embedded-json.py (#2480). run: python3 scripts/validate-embedded-json.py + - name: ⚖️ Install Kyverno CLI + if: needs.changes.outputs.k8s == 'true' + # Pinned to the version the cluster actually runs (the kyverno Helm + # release is v1.18.2), so a policy that passes here is evaluated by the + # same engine that admits it in production. Bump both together. + uses: kyverno/action-install-cli@fcee92fca5c883169ef9927acf543e0b5fc58289 # v0.2.0 + with: + release: v1.18.2 + + - name: ⚖️ Validate cluster policies + if: needs.changes.outputs.k8s == 'true' + # Runs the kyverno test fixtures under tests/. Until now nothing + # executed them, so tests/validate-replica-floor had never actually run + # in CI — a policy regression could ship green. No cluster or secrets + # required; the CLI evaluates the committed ClusterPolicy files offline. + run: kyverno test ./tests + - name: 🚦 Validate default-off Cilium bandwidth manager if: needs.changes.outputs.k8s == 'true' # Render the committed and temporary opt-in states so the staged diff --git a/tests/validate-host-restrictions/kyverno-test.yaml b/tests/validate-host-restrictions/kyverno-test.yaml index 0dafb4299..a0ddfccc2 100644 --- a/tests/validate-host-restrictions/kyverno-test.yaml +++ b/tests/validate-host-restrictions/kyverno-test.yaml @@ -8,6 +8,9 @@ policies: ../../k8s/bases/infrastructure/cluster-policies/best-practices/validate-host-restrictions.yaml resources: - resources.yaml +# Declares the namespace labels the namespaceSelector matches on. Without it the +# CLI reports every resource "Excluded" and the fixture passes vacuously. +variables: values.yaml results: # The regression the mutate rule's conditional anchor cannot catch. - policy: validate-host-restrictions diff --git a/tests/validate-host-restrictions/values.yaml b/tests/validate-host-restrictions/values.yaml new file mode 100644 index 000000000..066e6a7a8 --- /dev/null +++ b/tests/validate-host-restrictions/values.yaml @@ -0,0 +1,15 @@ +--- +# The Kyverno CLI does not read namespace labels from the Namespace objects in +# resources.yaml — a rule matched by namespaceSelector is reported "Excluded" +# for every resource unless the namespace labels are declared here. Without +# this file the whole fixture passes vacuously, including the fail case. +apiVersion: cli.kyverno.io/v1alpha1 +kind: Values +metadata: + name: values +namespaceSelector: + - name: userns-enabled + labels: + pod-security.devantler.tech/user-namespaces: enabled + - name: userns-absent + labels: {}