Skip to content

Commit f24e33c

Browse files
authored
fix(kubescape): require a cluster-wide posture exception to declare itself (#3010)
* fix(kubescape): require a cluster-wide posture exception to declare itself An omitted spec.match makes a ClusterSecurityException suppress its controls for every workload in the cluster. That is the widest scope the repository can express and it is reached by typing nothing, so a forgotten scope and a deliberate one produce byte-identical CRs and review has no signal to tell them apart. Nine of the twenty committed exceptions were in that state. Require an explicit platform.devantler.tech/cluster-wide: declared annotation for it, failing closed on a missing marker, a malformed value, and on a CR that both declares cluster-wide scope and sets spec.match. Each committed cluster-wide exception now carries the marker with its actual justification -- including pod-security-mutations and vpa-managed-resources, whose scopes are recorded as knowingly wider than their own rationale. Generated output is unchanged: the marker is source-side only. * test(kubescape): assert which error each malformed cluster-wide marker returns Every fixture in TestClusterWideAnnotationFailsClosed also omits spec.match, so a regression that ignored the annotation entirely would still be rejected -- by the undeclared-scope guard -- and the bare err != nil check stayed green while the marker did nothing. Proven by ablation: with resolveClusterWideDeclaration stubbed to always return false, the old assertion passes and the new one reports the undeclared-scope error where it wanted the type error.
1 parent 506ba50 commit f24e33c

11 files changed

Lines changed: 250 additions & 2 deletions

k8s/bases/infrastructure/cluster-security-exceptions/admission-controllers.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ apiVersion: kubescape.io/v1beta1
66
kind: ClusterSecurityException
77
metadata:
88
name: admission-controllers
9+
annotations:
10+
# Cluster-wide on purpose: the webhook configurations these controls scan
11+
# are installed by upstream charts across every controller namespace, and
12+
# the platform sets none of them, so there is no narrower scope that is
13+
# also complete.
14+
platform.devantler.tech/cluster-wide: declared
915
spec:
1016
reason: >-
1117
Admission webhook timeouts are configured by upstream Helm charts

k8s/bases/infrastructure/cluster-security-exceptions/cilium-network-policies.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ apiVersion: kubescape.io/v1beta1
77
kind: ClusterSecurityException
88
metadata:
99
name: cilium-network-policies
10+
annotations:
11+
# Cluster-wide on purpose: Cilium is the CNI for the whole cluster, so
12+
# every namespace is segmented by CiliumNetworkPolicy rather than by the
13+
# NetworkPolicy objects these controls look for. Scoping this would only
14+
# make the controls fail somewhere Cilium is still the enforcement point.
15+
platform.devantler.tech/cluster-wide: declared
1016
spec:
1117
reason: >-
1218
Platform uses CiliumNetworkPolicies for all network segmentation.

k8s/bases/infrastructure/cluster-security-exceptions/helm-chart-metadata.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ apiVersion: kubescape.io/v1beta1
77
kind: ClusterSecurityException
88
metadata:
99
name: helm-chart-metadata
10+
annotations:
11+
# Cluster-wide on purpose: the recommended-label conventions these controls
12+
# check are set by whichever upstream chart rendered the object, in every
13+
# namespace that runs one. The platform authors none of them.
14+
platform.devantler.tech/cluster-wide: declared
1015
spec:
1116
reason: >-
1217
Third-party Helm charts use their own label conventions. Adding all

k8s/bases/infrastructure/cluster-security-exceptions/image-verification.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ apiVersion: kubescape.io/v1beta1
1111
kind: ClusterSecurityException
1212
metadata:
1313
name: image-verification
14+
annotations:
15+
# Cluster-wide on purpose: C-0237 scans every workload image reference, and
16+
# unsigned third-party chart images are spread across every namespace. The
17+
# narrower posture is tracked as first-party pull enforcement (#2856), not
18+
# as a scope on this exception.
19+
platform.devantler.tech/cluster-wide: declared
1420
spec:
1521
reason: >-
1622
First-party images (ghcr.io/devantler-tech/*) are cosign-signed and

k8s/bases/infrastructure/cluster-security-exceptions/pod-security-mutations.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ apiVersion: kubescape.io/v1beta1
1515
kind: ClusterSecurityException
1616
metadata:
1717
name: pod-security-mutations
18+
annotations:
19+
# Cluster-wide, and KNOWN to be wider than its own rationale (#2824). The
20+
# compensating mutation named below excludes twelve namespaces —
21+
# kube-system, kube-public, kube-node-lease, flux-system, longhorn-system,
22+
# local-path-storage, kubescape, kubevirt, cdi, observability, velero,
23+
# chaos-mesh — so in exactly those namespaces this exception suppresses
24+
# C-0013/C-0016/C-0055/C-0211 with nothing injecting them. Six of the twelve
25+
# are separately and deliberately excepted for C-0013/C-0016/C-0055 in
26+
# infrastructure-privileged.yaml; C-0211 and the other six are not covered
27+
# anywhere. Narrowing it needs a scope the CSE designators cannot yet spell
28+
# (an In-list would fail every newly-onboarded namespace), which is why this
29+
# is declared rather than fixed here.
30+
platform.devantler.tech/cluster-wide: declared
1831
spec:
1932
reason: >-
2033
Kyverno mutate policy "add-security-context" injects these controls

k8s/bases/infrastructure/cluster-security-exceptions/talos-cis-control-plane-false-positives.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ metadata:
3737
# cluster-wide `kind: ".*"` designator for it, which the plugin would apply
3838
# to every workload in the cluster.
3939
platform.devantler.tech/headlamp-mirror: exclude
40+
# Cluster-wide on purpose: every control here is a host-scanner finding on
41+
# the control-plane node, which has no workload, namespace or kind to scope
42+
# against. See the NO-`match:` note above.
43+
platform.devantler.tech/cluster-wide: declared
4044
spec:
4145
reason: >-
4246
CIS control-plane checks that are false positives on Talos Linux, matching

k8s/bases/infrastructure/cluster-security-exceptions/talos-cis-worker-false-positives.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ metadata:
4040
# emit a cluster-wide `kind: ".*"` designator for it, which the plugin would
4141
# apply to every workload in the cluster.
4242
platform.devantler.tech/headlamp-mirror: exclude
43+
# Cluster-wide on purpose: every control here is a host-scanner finding on
44+
# the worker node, which has no workload, namespace or kind to scope
45+
# against. See the NO-`match:` note above.
46+
platform.devantler.tech/cluster-wide: declared
4347
spec:
4448
reason: >-
4549
CIS worker-node checks that are false positives on Talos Linux, matching Sidero's

k8s/bases/infrastructure/cluster-security-exceptions/upstream-chart-defaults.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ apiVersion: kubescape.io/v1beta1
99
kind: ClusterSecurityException
1010
metadata:
1111
name: upstream-chart-defaults
12+
annotations:
13+
# Cluster-wide on purpose: C-0258 is an etcd-level property of the Talos
14+
# control plane with no workload scope at all, and the remaining three are
15+
# properties of upstream chart images and operator hostPath usage that
16+
# appear wherever those charts are installed.
17+
platform.devantler.tech/cluster-wide: declared
1218
spec:
1319
reason: >-
1420
These controls flag behaviors defined by upstream Helm charts or the

k8s/bases/infrastructure/cluster-security-exceptions/vpa-managed-resources.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ apiVersion: kubescape.io/v1beta1
99
kind: ClusterSecurityException
1010
metadata:
1111
name: vpa-managed-resources
12+
annotations:
13+
# Cluster-wide, and KNOWN to be wider than its own rationale: the reason
14+
# below holds only where a VPA object actually right-sizes the workload,
15+
# which is not every namespace. Narrowing it to the workloads VPA manages
16+
# is tracked in #2824 alongside the same defect in pod-security-mutations.
17+
# Declared here so the scope is a recorded decision rather than an omission.
18+
platform.devantler.tech/cluster-wide: declared
1219
spec:
1320
reason: >-
1421
VPA manages resource requests and limits at pod admission time.

scripts/generate-kubescape-exceptions/main.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ const (
6060
// cluster-wide designator would except that control for every workload.
6161
mirrorExclude = "exclude"
6262

63+
// clusterWideAnnotation declares that an exception is MEANT to suppress its
64+
// controls for every workload in the cluster. An omitted `spec.match` is
65+
// what produces that scope, so without this marker the widest exception the
66+
// repository can express is also the one written by typing the least, and a
67+
// forgotten scope is indistinguishable from a deliberate one. Suppression is
68+
// silent — an excepted workload that genuinely violates a control reads
69+
// exactly like a compliant one — so the scope has to be stated, not inferred
70+
// from an absence.
71+
clusterWideAnnotation = "platform.devantler.tech/cluster-wide"
72+
// clusterWideDeclared is the only accepted clusterWideAnnotation value. As
73+
// with mirrorExclude, any other value fails closed rather than being read as
74+
// a declaration: a typo must not grant cluster-wide suppression.
75+
clusterWideDeclared = "declared"
76+
6377
formatKubescape = "kubescape"
6478
formatConfigMap = "headlamp-configmap"
6579

@@ -362,6 +376,42 @@ func resolveMirrorExclusion(metadata map[string]any, path, name string) (bool, e
362376
return true, nil
363377
}
364378

379+
// resolveClusterWideDeclaration reads the CR's cluster-wide scope marker.
380+
//
381+
// Absent annotation => not declared, which is the safe default: an exception
382+
// that forgot to scope itself then fails closed at conversion instead of
383+
// silently excepting its controls for every workload in the cluster.
384+
func resolveClusterWideDeclaration(metadata map[string]any, path, name string) (bool, error) {
385+
rawAnnotations, present := metadata["annotations"]
386+
if !present || rawAnnotations == nil {
387+
return false, nil
388+
}
389+
390+
// Same fail-closed reasoning as resolveMirrorExclusion: a malformed
391+
// annotations block must not be read as "no marker", because here that
392+
// reading is the permissive one.
393+
annotations, ok := rawAnnotations.(map[string]any)
394+
if !ok {
395+
return false, cseErrorf(path, name, "metadata.annotations must be a mapping, got %v", rawAnnotations)
396+
}
397+
398+
raw, ok := annotations[clusterWideAnnotation]
399+
if !ok {
400+
return false, nil
401+
}
402+
403+
value, ok := raw.(string)
404+
if !ok {
405+
return false, cseErrorf(path, name, "%s must be a string, got %v", clusterWideAnnotation, raw)
406+
}
407+
408+
if value != clusterWideDeclared {
409+
return false, cseErrorf(path, name, "unsupported %s value %q (only %q is recognised)", clusterWideAnnotation, value, clusterWideDeclared)
410+
}
411+
412+
return true, nil
413+
}
414+
365415
// convertDocument converts one ClusterSecurityException document; nil for other kinds.
366416
func convertDocument(doc any, path string) (*policy, error) {
367417
document, ok := doc.(map[string]any)
@@ -438,6 +488,22 @@ func convertDocument(doc any, path string) (*policy, error) {
438488
match = parsed
439489
}
440490

491+
clusterWide, err := resolveClusterWideDeclaration(metadata, path, name)
492+
if err != nil {
493+
return nil, err
494+
}
495+
496+
switch {
497+
case len(match) == 0 && !clusterWide:
498+
return nil, cseErrorf(path, name,
499+
"no spec.match, which excepts these controls for EVERY workload; scope it, or declare the scope with the %s: %s annotation",
500+
clusterWideAnnotation, clusterWideDeclared)
501+
case len(match) > 0 && clusterWide:
502+
return nil, cseErrorf(path, name,
503+
"declares %s: %s but also sets spec.match; the marker would claim a scope the exception does not have",
504+
clusterWideAnnotation, clusterWideDeclared)
505+
}
506+
441507
resources, err := resolveMatch(match, path, name)
442508
if err != nil {
443509
return nil, err

0 commit comments

Comments
 (0)