From 5f6ab232f4b08e6fb366357b87beb9837b0dac56 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 25 Sep 2026 12:19:09 +0200 Subject: [PATCH 1/3] feat(kubescape): fail CI when a namespace leaves scan coverage unreviewed Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yaml | 15 +++ .../controllers/kubescape/helm-release.yaml | 3 + scripts/guard-kubescape-scan-scope.sh | 119 +++++++++++++++++ scripts/kubescape-unscanned-namespaces.tsv | 20 +++ .../tests/test-guard-kubescape-scan-scope.sh | 122 ++++++++++++++++++ 5 files changed, 279 insertions(+) create mode 100755 scripts/guard-kubescape-scan-scope.sh create mode 100644 scripts/kubescape-unscanned-namespaces.tsv create mode 100755 scripts/tests/test-guard-kubescape-scan-scope.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c3abb30717..a65996af24 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -690,6 +690,11 @@ jobs: # covered by 'k8s/**'. - 'scripts/guard-baseline-context-optin-scan-scope.sh' - 'scripts/tests/test-guard-baseline-context-optin-scan-scope.sh' + # Both halves and the reviewed list, for the same reasons. The guard also + # reads the kubescape HelmRelease, covered by 'k8s/**'. + - 'scripts/guard-kubescape-scan-scope.sh' + - 'scripts/tests/test-guard-kubescape-scan-scope.sh' + - 'scripts/kubescape-unscanned-namespaces.tsv' # Both halves, for the same reason as the guards above. The guard also # reads the exception manifest itself, covered by 'k8s/**'. - 'scripts/guard-pod-security-exception-documented.sh' @@ -1845,6 +1850,16 @@ jobs: bash scripts/guard-baseline-context-optin-scan-scope.sh bash scripts/tests/test-guard-baseline-context-optin-scan-scope.sh + - name: πŸ—ΊοΈ Validate every unscanned namespace is reviewed + if: needs.changes.outputs.k8s == 'true' + # A namespace in the operator's excludeNamespaces produces no scan result, and the + # posture score cannot tell that apart from a clean namespace. So the exclusions must + # match scripts/kubescape-unscanned-namespaces.tsv exactly, each row with a reason, + # and a namespace can only leave scan coverage through a reviewed change (#3215). + run: | + bash scripts/guard-kubescape-scan-scope.sh + bash scripts/tests/test-guard-kubescape-scan-scope.sh + - name: πŸ”Ž Validate every suppressed control is documented as failing open if: needs.changes.outputs.k8s == 'true' # A control added under spec.posture that the file's fail-open warning does diff --git a/k8s/bases/infrastructure/controllers/kubescape/helm-release.yaml b/k8s/bases/infrastructure/controllers/kubescape/helm-release.yaml index a578dbdc62..c4b8107b69 100644 --- a/k8s/bases/infrastructure/controllers/kubescape/helm-release.yaml +++ b/k8s/bases/infrastructure/controllers/kubescape/helm-release.yaml @@ -57,6 +57,9 @@ spec: # them from the score alone. The same trap is documented for the `kubescape` # namespace in cluster-security-exceptions/pod-security-mutations-unscoped.yaml. # + # Each namespace here has a row saying why in scripts/kubescape-unscanned-namespaces.tsv, + # and CI fails when the two lists differ, so change both together (#3215). + # # It was undeclared until #3923, so the scope could only be learned by reading # a live ConfigMap. Two issues were planned against `kube-system` on the # assumption it was scanned (#3239, #3919); it is not, and the exception #3919 diff --git a/scripts/guard-kubescape-scan-scope.sh b/scripts/guard-kubescape-scan-scope.sh new file mode 100755 index 0000000000..220c3c4ae1 --- /dev/null +++ b/scripts/guard-kubescape-scan-scope.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# +# Fail when the namespaces Kubescape does not scan differ from the reviewed list in +# scripts/kubescape-unscanned-namespaces.tsv (#3215). +# +# A namespace in the operator's `excludeNamespaces` produces no scan result at all, and the +# posture score cannot tell that apart from a namespace with nothing wrong. So adding one +# silently narrows every posture claim the platform makes. This guard makes that change +# visible: the exclusion list and the reviewed list must match exactly, and every reviewed +# row must say why the namespace is not scanned. +# +# Both directions fail. A namespace excluded without a row is the silent narrowing this guard +# exists to catch. A row whose namespace is no longer excluded is a stale claim that a scanned +# namespace is unscanned, which would mislead the next reader in the opposite direction. +# +# FAIL CLOSED. Both inputs must be non-empty before they are compared: a moved yq path or an +# emptied list would otherwise compare two empty sets and pass forever. +# +# Exit codes: +# 0 the exclusion list and the reviewed list match, and every row has a reason +# 1 they differ, or a row has no reason; each problem is named +# 2 cannot check: bad usage, a missing or unparseable file, or an empty input + +set -uo pipefail + +die() { + printf 'guard-kubescape-scan-scope: %s\n' "$*" >&2 + exit 2 +} + +[ "$#" -le 2 ] || die "usage: $0 [ ]" +command -v yq >/dev/null 2>&1 || die "yq is required but not installed" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || + die "could not resolve the repository root" + +helm_release="${1:-$repo_root/k8s/bases/infrastructure/controllers/kubescape/helm-release.yaml}" +reviewed="${2:-$repo_root/scripts/kubescape-unscanned-namespaces.tsv}" + +[ -f "$helm_release" ] || die "kubescape HelmRelease '$helm_release' not found" +[ -f "$reviewed" ] || die "reviewed list '$reviewed' not found" + +# --- input 1: what the operator is told not to scan ------------------------------------- +excluded_raw="$(yq -r '.spec.values.excludeNamespaces' "$helm_release" 2>/dev/null)" || + die "could not read .spec.values.excludeNamespaces from '$helm_release' β€” unparseable YAML" +[ -n "$excluded_raw" ] && [ "$excluded_raw" != "null" ] || + die "excludeNamespaces came back EMPTY in '$helm_release'; refusing to compare against an empty set" + +excluded="$(printf '%s\n' "$excluded_raw" | tr ',' '\n' | + sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | sed '/^$/d' | sort -u)" +[ -n "$excluded" ] || + die "excludeNamespaces in '$helm_release' held no namespace names after splitting on commas" + +# --- input 2: the reviewed rows --------------------------------------------------------- +failures=0 +listed="" +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in '' | '#'*) continue ;; esac + ns="${line%%$'\t'*}" + if [ "$ns" = "$line" ]; then + reason="" + else + reason="${line#*$'\t'}" + fi + reason="$(printf '%s' "$reason" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" + if [ -z "$ns" ]; then + printf 'guard-kubescape-scan-scope: a row in %s has an empty namespace\n' "$reviewed" >&2 + failures=$((failures + 1)) + continue + fi + if [ -z "$reason" ]; then + printf 'guard-kubescape-scan-scope: %s is listed without a reason; say why it is not scanned\n' "$ns" >&2 + failures=$((failures + 1)) + fi + listed="${listed}${ns}"$'\n' +done <"$reviewed" + +listed_sorted="$(printf '%s' "$listed" | sed '/^$/d' | sort)" +[ -n "$listed_sorted" ] || + die "no rows in '$reviewed'; refusing to compare against an empty reviewed list" + +duplicates="$(printf '%s\n' "$listed_sorted" | uniq -d)" +if [ -n "$duplicates" ]; then + while IFS= read -r ns; do + printf 'guard-kubescape-scan-scope: %s is listed more than once in %s\n' "$ns" "$reviewed" >&2 + failures=$((failures + 1)) + done <&2 + failures=$((failures + 1)) + done <&2 + failures=$((failures + 1)) + done < +kubescape The scanner's own namespace. Excluded by the chart default; its four operator Deployments are measured by neither the in-cluster scan nor the CI scan, which never renders the HelmRelease (#3215). +kube-system Control-plane and CNI workloads that Talos and KSail install. Excluded by the chart default, so exceptions and opt-ins here change no posture result (#3919, #3923). +kube-public Holds only the cluster-info ConfigMap and no workloads. Excluded by the chart default. +kube-node-lease Holds only node heartbeat Leases and no workloads. Excluded by the chart default. +kubeconfig Not present on this platform. Kept to match the chart default the cluster already runs. +gmp-system Google Managed Prometheus. Not present on this platform. Kept to match the chart default. +gmp-public Google Managed Prometheus. Not present on this platform. Kept to match the chart default. diff --git a/scripts/tests/test-guard-kubescape-scan-scope.sh b/scripts/tests/test-guard-kubescape-scan-scope.sh new file mode 100755 index 0000000000..63569bfa93 --- /dev/null +++ b/scripts/tests/test-guard-kubescape-scan-scope.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# +# Cover guard-kubescape-scan-scope.sh. +# +# The load-bearing case is a namespace added to excludeNamespaces with no reviewed row: that +# is the silent narrowing of scan coverage the guard exists to catch (#3215). The opposite +# direction, a row for a namespace that is scanned again, is asserted too. +# +# Every bad input is built from the REAL files and changed ONE way, and each change is +# asserted to have landed before the guard runs, so no case can pass because its fixture +# failed to build. + +set -euo pipefail + +cd "$(dirname "$0")/../.." +readonly GUARD='scripts/guard-kubescape-scan-scope.sh' +readonly REAL_HR='k8s/bases/infrastructure/controllers/kubescape/helm-release.yaml' +readonly REAL_TSV='scripts/kubescape-unscanned-namespaces.tsv' + +tmp="$(mktemp -d)" +# A test that exits 0 without having run is worse than one that errors. Bash 3.2 reports +# $? as 0 to an EXIT trap for a `set -u` abort, so completion is recorded explicitly. +finished=0 +cleanup() { + local rc=$? + rm -rf "${tmp}" + if [ "${finished}" != 1 ] && [ "${rc}" -eq 0 ]; then + printf 'test-guard-kubescape-scan-scope: aborted before finishing; reporting failure rather than a clean pass\n' >&2 + rc=1 + fi + exit "${rc}" +} +trap cleanup EXIT + +failures=0 +ok() { printf 'ok: %s\n' "$1"; } +bad() { + printf 'FAIL: %s\n' "$1" >&2 + failures=$((failures + 1)) +} + +for f in "${REAL_HR}" "${REAL_TSV}"; do + [ -f "${f}" ] || { + printf 'FAIL: fixture source %s is missing; every case below would be vacuous\n' "${f}" >&2 + exit 1 + } +done + +# expect +expect() { + local name="$1" want="$2" needle="$3" hr="$4" tsv="$5" rc=0 + bash "${GUARD}" "${hr}" "${tsv}" >"${tmp}/out" 2>"${tmp}/err" || rc=$? + if [ "${rc}" -ne "${want}" ]; then + bad "${name}: exit ${rc}, want ${want} ($(tr '\n' ' ' <"${tmp}/err"))" + return + fi + if [ -n "${needle}" ] && ! grep -qF -- "${needle}" "${tmp}/err"; then + bad "${name}: stderr lacks '${needle}' ($(tr '\n' ' ' <"${tmp}/err"))" + return + fi + ok "${name}" +} + +# landed β€” assert a mutation is present before using the fixture +landed() { + grep -qF -- "$2" "$1" || { + printf 'FAIL: fixture %s lacks %s; the case would be vacuous\n' "$1" "$2" >&2 + exit 1 + } +} + +expect "real tree passes" 0 "" "${REAL_HR}" "${REAL_TSV}" + +# A namespace newly excluded, with no reviewed row. +sed 's/excludeNamespaces: "kubescape,/excludeNamespaces: "kubescape,velero,/' "${REAL_HR}" >"${tmp}/hr-extra.yaml" +landed "${tmp}/hr-extra.yaml" 'excludeNamespaces: "kubescape,velero,' +expect "unreviewed exclusion fails" 1 "velero is excluded from scanning but has no row" \ + "${tmp}/hr-extra.yaml" "${REAL_TSV}" + +# A namespace scanned again, whose row was left behind. +sed 's/excludeNamespaces: "kubescape,/excludeNamespaces: "/' "${REAL_HR}" >"${tmp}/hr-fewer.yaml" +landed "${tmp}/hr-fewer.yaml" 'excludeNamespaces: "kube-system,' +expect "stale row fails" 1 "kubescape is listed as unscanned but is not in excludeNamespaces" \ + "${tmp}/hr-fewer.yaml" "${REAL_TSV}" + +# A row with no reason. +sed 's/^kube-public'$'\t''.*/kube-public/' "${REAL_TSV}" >"${tmp}/no-reason.tsv" +grep -qx 'kube-public' "${tmp}/no-reason.tsv" || { + printf 'FAIL: no-reason fixture did not land\n' >&2 + exit 1 +} +expect "row without a reason fails" 1 "kube-public is listed without a reason" \ + "${REAL_HR}" "${tmp}/no-reason.tsv" + +# A duplicated row. +{ cat "${REAL_TSV}"; grep '^kubeconfig ' "${REAL_TSV}"; } >"${tmp}/dup.tsv" +[ "$(grep -c '^kubeconfig ' "${tmp}/dup.tsv")" -eq 2 ] || { + printf 'FAIL: duplicate fixture did not land\n' >&2 + exit 1 +} +expect "duplicated row fails" 1 "kubeconfig is listed more than once" \ + "${REAL_HR}" "${tmp}/dup.tsv" + +# Fail closed: a moved key reads as null. +sed 's/excludeNamespaces:/excludedNamespaces:/' "${REAL_HR}" >"${tmp}/hr-moved.yaml" +landed "${tmp}/hr-moved.yaml" 'excludedNamespaces:' +expect "moved key is UNKNOWN" 2 "came back EMPTY" "${tmp}/hr-moved.yaml" "${REAL_TSV}" + +# Fail closed: a reviewed list with only comments. +grep '^#' "${REAL_TSV}" >"${tmp}/empty.tsv" +expect "empty reviewed list is UNKNOWN" 2 "no rows in" "${REAL_HR}" "${tmp}/empty.tsv" + +# Fail closed: missing files. +expect "missing reviewed list is UNKNOWN" 2 "not found" "${REAL_HR}" "${tmp}/absent.tsv" +expect "missing HelmRelease is UNKNOWN" 2 "not found" "${tmp}/absent.yaml" "${REAL_TSV}" + +finished=1 +if [ "${failures}" -ne 0 ]; then + printf 'test-guard-kubescape-scan-scope: %d case(s) failed\n' "${failures}" >&2 + exit 1 +fi +printf 'test-guard-kubescape-scan-scope: all cases passed\n' From f49eaa18dc50e722c96e9dd2dda1fa26f158fba7 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 25 Sep 2026 13:08:36 +0200 Subject: [PATCH 2/3] fix(ci): reject scan-scope overrides outside the kubescape base HelmRelease An overlay patch, JSON6902 op, inline kustomization patch or valuesFrom that sets excludeNamespaces would narrow the deployed scan scope while the guard kept reading the unchanged base. Fail on any of them; five RED cases, ablation fired. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yaml | 2 + scripts/guard-kubescape-scan-scope.sh | 56 +++++++++++- .../tests/test-guard-kubescape-scan-scope.sh | 85 ++++++++++++++++++- 3 files changed, 138 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a65996af24..dc2fec8e0b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1856,6 +1856,8 @@ jobs: # posture score cannot tell that apart from a clean namespace. So the exclusions must # match scripts/kubescape-unscanned-namespaces.tsv exactly, each row with a reason, # and a namespace can only leave scan coverage through a reviewed change (#3215). + # The base HelmRelease is the only place the field may be set: an overlay patch or a + # valuesFrom that sets it would narrow the deployed scope while this read the base. run: | bash scripts/guard-kubescape-scan-scope.sh bash scripts/tests/test-guard-kubescape-scan-scope.sh diff --git a/scripts/guard-kubescape-scan-scope.sh b/scripts/guard-kubescape-scan-scope.sh index 220c3c4ae1..aafec11e1f 100755 --- a/scripts/guard-kubescape-scan-scope.sh +++ b/scripts/guard-kubescape-scan-scope.sh @@ -28,7 +28,7 @@ die() { exit 2 } -[ "$#" -le 2 ] || die "usage: $0 [ ]" +[ "$#" -le 3 ] || die "usage: $0 [ []]" command -v yq >/dev/null 2>&1 || die "yq is required but not installed" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || @@ -36,9 +36,11 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || helm_release="${1:-$repo_root/k8s/bases/infrastructure/controllers/kubescape/helm-release.yaml}" reviewed="${2:-$repo_root/scripts/kubescape-unscanned-namespaces.tsv}" +k8s_root="${3:-$repo_root/k8s}" [ -f "$helm_release" ] || die "kubescape HelmRelease '$helm_release' not found" [ -f "$reviewed" ] || die "reviewed list '$reviewed' not found" +[ -d "$k8s_root" ] || die "k8s tree '$k8s_root' not found" # --- input 1: what the operator is told not to scan ------------------------------------- excluded_raw="$(yq -r '.spec.values.excludeNamespaces' "$helm_release" 2>/dev/null)" || @@ -51,8 +53,58 @@ excluded="$(printf '%s\n' "$excluded_raw" | tr ',' '\n' | [ -n "$excluded" ] || die "excludeNamespaces in '$helm_release' held no namespace names after splitting on commas" +# --- nothing else may set the scan scope -------------------------------------------------- +# The comparison above reads the base HelmRelease, so it is only the deployed scope while +# nothing overrides that field. Provider overlays already patch this HelmRelease, and a +# patch or valuesFrom that sets excludeNamespaces would narrow the deployed scope while this +# guard kept reading the unchanged base. So the base is the one place the field may be set: +# any other manifest that sets it, and any valuesFrom on the kubescape HelmRelease, fails. +base_rel="bases/infrastructure/controllers/kubescape/helm-release.yaml" +# Every branch ends in `filename + ...`, never a bare string literal: yq evaluates a literal +# after `select` even when the select matched nothing, so `select(x) | "text"` always prints. +overrides_expr=' + (select(tag == "!!map" and .kind == "HelmRelease" and .metadata.name == "kubescape") + | select(.spec.values.excludeNamespaces != null) + | filename + ": the kubescape HelmRelease sets spec.values.excludeNamespaces"), + (select(tag == "!!map" and .kind == "HelmRelease" and .metadata.name == "kubescape") + | select(.spec.valuesFrom != null) + | filename + ": the kubescape HelmRelease sets spec.valuesFrom"), + (select(tag == "!!seq") | .[] + | select(((.path // "") | tostring) | test("excludeNamespaces|valuesFrom")) + | filename + ": a JSON patch op targets " + .path), + (select(tag == "!!map" and .patches != null) | .patches[] + | select(((.patch // "") | tostring) | test("excludeNamespaces")) + | filename + ": an inline patch sets excludeNamespaces")' + +valuesfrom_base="$(yq -r '.spec.valuesFrom // ""' "$helm_release" 2>/dev/null)" || + die "could not read .spec.valuesFrom from '$helm_release'" +if [ -n "$valuesfrom_base" ]; then + printf 'guard-kubescape-scan-scope: %s uses spec.valuesFrom, which can override excludeNamespaces unseen; set values inline\n' \ + "$helm_release" >&2 + overrides_found=1 +else + overrides_found=0 +fi + +manifests="$(cd "$k8s_root" && find . -type f \( -name '*.yaml' -o -name '*.yml' \) ! -path "./$base_rel" | sort)" || + die "could not list manifests under '$k8s_root'" +[ -n "$manifests" ] || die "no manifests found under '$k8s_root'; refusing to report no overrides" + +overrides="$(cd "$k8s_root" && printf '%s\n' "$manifests" | tr '\n' '\0' | + xargs -0 yq e "$overrides_expr" 2>/dev/null)" || + die "could not parse every manifest under '$k8s_root' while looking for scan-scope overrides" +overrides="$(printf '%s\n' "$overrides" | sed '/^---$/d; /^$/d')" +if [ -n "$overrides" ]; then + while IFS= read -r hit; do + printf 'guard-kubescape-scan-scope: %s; set the scan scope only in %s\n' "${hit#./}" "$base_rel" >&2 + done < +# expect [] expect() { - local name="$1" want="$2" needle="$3" hr="$4" tsv="$5" rc=0 - bash "${GUARD}" "${hr}" "${tsv}" >"${tmp}/out" 2>"${tmp}/err" || rc=$? + local name="$1" want="$2" needle="$3" hr="$4" tsv="$5" root="${6:-k8s}" rc=0 + bash "${GUARD}" "${hr}" "${tsv}" "${root}" >"${tmp}/out" 2>"${tmp}/err" || rc=$? if [ "${rc}" -ne "${want}" ]; then bad "${name}: exit ${rc}, want ${want} ($(tr '\n' ' ' <"${tmp}/err"))" return @@ -114,6 +114,85 @@ expect "empty reviewed list is UNKNOWN" 2 "no rows in" "${REAL_HR}" "${tmp}/empt expect "missing reviewed list is UNKNOWN" 2 "not found" "${REAL_HR}" "${tmp}/absent.tsv" expect "missing HelmRelease is UNKNOWN" 2 "not found" "${tmp}/absent.yaml" "${REAL_TSV}" +# Overrides: the base is the only place the scan scope may be set. Each case copies the real +# k8s tree and adds ONE override, so the tree itself is known to pass (the first case above). +fresh_tree() { + rm -rf "${tmp}/k8s" + cp -R k8s "${tmp}/k8s" +} +readonly OVERLAY_DIR="${tmp}/k8s/providers/hetzner/infrastructure/controllers/kubescape/patches" + +# A strategic-merge patch that narrows the scope in one provider. +fresh_tree +cat >"${OVERLAY_DIR}/narrow-scan-scope.yaml" <<'YAML' +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: kubescape + namespace: kubescape +spec: + values: + excludeNamespaces: "kube-system,velero" +YAML +landed "${OVERLAY_DIR}/narrow-scan-scope.yaml" 'excludeNamespaces: "kube-system,velero"' +expect "overlay patch setting excludeNamespaces fails" 1 "sets spec.values.excludeNamespaces" \ + "${REAL_HR}" "${REAL_TSV}" "${tmp}/k8s" + +# A patch that adds valuesFrom, which can carry excludeNamespaces from a ConfigMap unseen. +fresh_tree +cat >"${OVERLAY_DIR}/values-from.yaml" <<'YAML' +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: kubescape + namespace: kubescape +spec: + valuesFrom: + - kind: ConfigMap + name: kubescape-values +YAML +landed "${OVERLAY_DIR}/values-from.yaml" 'valuesFrom:' +expect "overlay patch adding valuesFrom fails" 1 "sets spec.valuesFrom" \ + "${REAL_HR}" "${REAL_TSV}" "${tmp}/k8s" + +# A JSON6902 patch file. +fresh_tree +cat >"${OVERLAY_DIR}/json-scan-scope.yaml" <<'YAML' +- op: replace + path: /spec/values/excludeNamespaces + value: "kube-system" +YAML +landed "${OVERLAY_DIR}/json-scan-scope.yaml" '/spec/values/excludeNamespaces' +expect "JSON6902 op on excludeNamespaces fails" 1 "a JSON patch op targets /spec/values/excludeNamespaces" \ + "${REAL_HR}" "${REAL_TSV}" "${tmp}/k8s" + +# An inline patch in a kustomization. +fresh_tree +cat >>"${tmp}/k8s/providers/docker/infrastructure/controllers/kustomization.yaml" <<'YAML' + - target: + kind: HelmRelease + name: kubescape + patch: |- + - op: replace + path: /spec/values/excludeNamespaces + value: "kube-system" +YAML +landed "${tmp}/k8s/providers/docker/infrastructure/controllers/kustomization.yaml" 'path: /spec/values/excludeNamespaces' +expect "inline kustomization patch on excludeNamespaces fails" 1 "an inline patch sets excludeNamespaces" \ + "${REAL_HR}" "${REAL_TSV}" "${tmp}/k8s" + +# valuesFrom on the base itself. +printf ' valuesFrom:\n - kind: ConfigMap\n name: kubescape-values\n' | cat "${REAL_HR}" - >"${tmp}/hr-values-from.yaml" +[ "$(yq -r '.spec.valuesFrom[0].name' "${tmp}/hr-values-from.yaml")" = "kubescape-values" ] || { + printf 'FAIL: base valuesFrom fixture did not land under .spec\n' >&2 + exit 1 +} +expect "valuesFrom on the base fails" 1 "uses spec.valuesFrom" \ + "${tmp}/hr-values-from.yaml" "${REAL_TSV}" + +# Fail closed: no k8s tree to search. +expect "missing k8s tree is UNKNOWN" 2 "not found" "${REAL_HR}" "${REAL_TSV}" "${tmp}/absent-k8s" + finished=1 if [ "${failures}" -ne 0 ]; then printf 'test-guard-kubescape-scan-scope: %d case(s) failed\n' "${failures}" >&2 From 2a935cc6104b95f038bebedbf14d5ca96db92e29 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 25 Sep 2026 13:36:08 +0200 Subject: [PATCH 3/3] fix(ci): compare the rendered provider scan scope, not just override spellings Kustomize replacements (and other generators) can set excludeNamespaces without any spelling the text checks name. Render every provider controllers layer and require any kubescape HelmRelease in it to carry exactly the base scope with no valuesFrom; also reject Flux Kustomization spec.patches touching the scope. Fails closed when no provider renders the release. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/guard-kubescape-scan-scope.sh | 41 +++++++++++- .../tests/test-guard-kubescape-scan-scope.sh | 62 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/scripts/guard-kubescape-scan-scope.sh b/scripts/guard-kubescape-scan-scope.sh index aafec11e1f..c68e687bea 100755 --- a/scripts/guard-kubescape-scan-scope.sh +++ b/scripts/guard-kubescape-scan-scope.sh @@ -74,7 +74,10 @@ overrides_expr=' | filename + ": a JSON patch op targets " + .path), (select(tag == "!!map" and .patches != null) | .patches[] | select(((.patch // "") | tostring) | test("excludeNamespaces")) - | filename + ": an inline patch sets excludeNamespaces")' + | filename + ": an inline patch sets excludeNamespaces"), + (select(tag == "!!map" and .kind == "Kustomization" and .spec.patches != null) | .spec.patches[] + | select(((.patch // "") | tostring) | test("excludeNamespaces|valuesFrom")) + | filename + ": a Flux Kustomization patch touches the scan scope")' valuesfrom_base="$(yq -r '.spec.valuesFrom // ""' "$helm_release" 2>/dev/null)" || die "could not read .spec.valuesFrom from '$helm_release'" @@ -103,6 +106,42 @@ EOF overrides_found=1 fi +# --- the rendered provider releases must carry exactly the base scope --------------------- +# The checks above name the override spellings this repository uses, but Kustomize has more +# (replacements, components, generators), so the deployed scope is also checked directly: +# every provider layer is rendered and any kubescape HelmRelease in it must set the same +# excludeNamespaces as the base, with no valuesFrom. A provider that does not deploy +# Kubescape renders no release and is not compared; at least one provider must render one, +# or a moved layout would compare nothing and pass. +command -v kubectl >/dev/null 2>&1 || die "kubectl is required to render the provider layers" +rendered_releases=0 +for layer in "$k8s_root"/providers/*/infrastructure/controllers; do + [ -f "$layer/kustomization.yaml" ] || continue + rendered="$(kubectl kustomize "$layer" 2>/dev/null)" || + die "could not render '$layer'" + release="$(printf '%s\n' "$rendered" | + yq -o=json -I=0 'select(.kind == "HelmRelease" and .metadata.name == "kubescape") | + {"scope": (.spec.values.excludeNamespaces // ""), "valuesFrom": (.spec.valuesFrom != null)}' 2>/dev/null)" || + die "could not read the kubescape HelmRelease rendered from '$layer'" + [ -n "$release" ] || continue + [ "$(printf '%s\n' "$release" | wc -l | tr -d ' ')" -eq 1 ] || + die "'$layer' renders more than one kubescape HelmRelease" + rendered_releases=$((rendered_releases + 1)) + rendered_scope="$(printf '%s' "$release" | yq -r '.scope')" + if [ "$rendered_scope" != "$excluded_raw" ]; then + printf 'guard-kubescape-scan-scope: %s renders excludeNamespaces "%s", not the base "%s"; set the scan scope only in %s\n' \ + "${layer#"$k8s_root"/}" "$rendered_scope" "$excluded_raw" "$base_rel" >&2 + overrides_found=1 + fi + if [ "$(printf '%s' "$release" | yq -r '.valuesFrom')" = "true" ]; then + printf 'guard-kubescape-scan-scope: %s renders a kubescape HelmRelease with valuesFrom; set values inline\n' \ + "${layer#"$k8s_root"/}" >&2 + overrides_found=1 + fi +done +[ "$rendered_releases" -gt 0 ] || + die "no provider layer under '$k8s_root/providers' renders the kubescape HelmRelease; refusing to report no overrides" + # --- input 2: the reviewed rows --------------------------------------------------------- failures=$overrides_found listed="" diff --git a/scripts/tests/test-guard-kubescape-scan-scope.sh b/scripts/tests/test-guard-kubescape-scan-scope.sh index 162cace573..cbf9a3359a 100755 --- a/scripts/tests/test-guard-kubescape-scan-scope.sh +++ b/scripts/tests/test-guard-kubescape-scan-scope.sh @@ -181,6 +181,68 @@ landed "${tmp}/k8s/providers/docker/infrastructure/controllers/kustomization.yam expect "inline kustomization patch on excludeNamespaces fails" 1 "an inline patch sets excludeNamespaces" \ "${REAL_HR}" "${REAL_TSV}" "${tmp}/k8s" +# Kustomize replacements: no override spelling names the field, so only the rendered release +# shows it. The source ConfigMap is added as a resource the replacement reads from. +fresh_tree +readonly HZ_CONTROLLERS="${tmp}/k8s/providers/hetzner/infrastructure/controllers" +cat >"${HZ_CONTROLLERS}/config-map-scan-scope.yaml" <<'YAML' +apiVersion: v1 +kind: ConfigMap +metadata: + name: scan-scope + namespace: kubescape +data: + scope: "kube-system,velero" +YAML +cat >>"${HZ_CONTROLLERS}/kustomization.yaml" <<'YAML' +replacements: + - source: + kind: ConfigMap + name: scan-scope + fieldPath: data.scope + targets: + - select: + kind: HelmRelease + name: kubescape + fieldPaths: + - spec.values.excludeNamespaces +YAML +yq -i '.resources += ["config-map-scan-scope.yaml"]' "${HZ_CONTROLLERS}/kustomization.yaml" +[ "$(kubectl kustomize "${HZ_CONTROLLERS}" | yq 'select(.kind == "HelmRelease" and .metadata.name == "kubescape") | .spec.values.excludeNamespaces')" = "kube-system,velero" ] || { + printf 'FAIL: replacements fixture did not change the rendered scope\n' >&2 + exit 1 +} +expect "kustomize replacement of the scope fails" 1 'renders excludeNamespaces "kube-system,velero"' \ + "${REAL_HR}" "${REAL_TSV}" "${tmp}/k8s" + +# A Flux Kustomization patch applied at reconcile time, which no kustomize build renders. +fresh_tree +cat >"${tmp}/k8s/clusters/prod/flux-kustomization-scan-scope.yaml" <<'YAML' +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: infrastructure-controllers + namespace: flux-system +spec: + patches: + - target: + kind: HelmRelease + name: kubescape + patch: |- + - op: replace + path: /spec/values/excludeNamespaces + value: "kube-system" +YAML +landed "${tmp}/k8s/clusters/prod/flux-kustomization-scan-scope.yaml" 'path: /spec/values/excludeNamespaces' +expect "Flux Kustomization patch on the scope fails" 1 "a Flux Kustomization patch touches the scan scope" \ + "${REAL_HR}" "${REAL_TSV}" "${tmp}/k8s" + +# Fail closed: no provider renders the release, so the rendered comparison would be empty. +fresh_tree +rm -rf "${tmp}/k8s/providers/hetzner" +expect "no provider rendering kubescape is UNKNOWN" 2 "renders the kubescape HelmRelease" \ + "${REAL_HR}" "${REAL_TSV}" "${tmp}/k8s" + # valuesFrom on the base itself. printf ' valuesFrom:\n - kind: ConfigMap\n name: kubescape-values\n' | cat "${REAL_HR}" - >"${tmp}/hr-values-from.yaml" [ "$(yq -r '.spec.valuesFrom[0].name' "${tmp}/hr-values-from.yaml")" = "kubescape-values" ] || {