diff --git a/cmd/cmd_scan_test.go b/cmd/cmd_scan_test.go index aaef347..fa68c65 100644 --- a/cmd/cmd_scan_test.go +++ b/cmd/cmd_scan_test.go @@ -220,6 +220,7 @@ func TestScanFlagsDefined(t *testing.T) { for testNum, test := range tests { t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { t.Parallel() + defer lockRootCmd(t)() f := test.Cmd.Flags().Lookup(test.WantFlag) if f == nil { t.Fatalf("%s: flag --%s not defined", test.Cmd.Name(), test.WantFlag) diff --git a/docs/architecture.md b/docs/architecture.md index 385bcf0..3d6871e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,6 +94,7 @@ table in step with the tree, so a new package needs a row here. | `internal/kube` | client-go connection helpers and concurrent fan-out across contexts. | | `internal/leader` | Lease-based leader election for the controller. | | `internal/logutil` | Threads a zap logger through a context. | +| `internal/podsec` | Evaluates pod security fields with Kubernetes precedence for scanners and admission. | | `internal/policyreport` | Converts findings into wgpolicyk8s.io PolicyReport resources. | | `internal/remediate` | Opens a pull request against a GitOps repo from a finding's YAML remediation. | | `internal/report` | Builds the report: comparisons, outliers, findings, cluster health, scores, trends. | diff --git a/docs/operator/admission-webhook.md b/docs/operator/admission-webhook.md index f0d329d..b77c79b 100644 --- a/docs/operator/admission-webhook.md +++ b/docs/operator/admission-webhook.md @@ -11,13 +11,22 @@ does, it warns (advisory mode) or denies (enforce mode). The baseline is a small set of fleet-wide fractions, refreshed once a minute from the latest scan in the Fleetsweeper database: -| Fraction  | What it measures | -|------------------------------------|-------------------------------------------------------------| -| `digest_pin_fraction` | Containers using `image@sha256:...` rather than a tag. | -| `non_root_fraction` | Containers not declared to run as UID 0. | -| `no_privilege_escalation_fraction` | Containers with `allowPrivilegeEscalation=false`. | -| `named_service_account_fraction` | Pods running under a ServiceAccount other than `default`. | -| `read_only_root_fs_fraction` | Containers with `readOnlyRootFilesystem=true`. | +| Fraction  | What it measures | +|------------------------------------|-----------------------------------------------------------| +| `digest_pin_fraction` | Containers using `image@sha256:...` rather than a tag. | +| `non_root_fraction` | Containers explicitly non-root, by the predicate below. | +| `no_privilege_escalation_fraction` | Containers that cannot gain privileges. | +| `named_service_account_fraction` | Pods running under a ServiceAccount other than `default`. | +| `read_only_root_fs_fraction` | Containers with `readOnlyRootFilesystem=true`. | + +A container counts as non-root, in both the fraction and the `non-root` +check, only when its effective `runAsUser` is above 0, or is unset while +its effective `runAsNonRoot` is true, with each container setting winning +over the pod setting, so a container that sets neither is not non-root. A container +cannot gain privileges when `allowPrivilegeEscalation` is false and it is +neither privileged nor adds `SYS_ADMIN`. Every container fraction except +the digest one counts init and regular containers only, the ones admission +sees, and leaves ephemeral debug containers out. Each cluster contributes its own counts, and a scanner that did not complete on a cluster contributes nothing, so a blind cluster cannot diff --git a/internal/admission/baseline.go b/internal/admission/baseline.go index c357340..8328f92 100644 --- a/internal/admission/baseline.go +++ b/internal/admission/baseline.go @@ -138,9 +138,9 @@ func (p *StoreBaselineProvider) recompute(ctx context.Context) (Baseline, error) } // scanBaseline tallies the fractions the built-in checks rely on from one scan. Each cluster -// contributes its own counts: container fractions from workload-security are measured against -// that cluster's image-audit container count, and a scanner result whose State is not OK -// contributes nothing. +// contributes its own counts: the digest fraction comes from image-audit, the other container +// fractions from workload-security over its baseline containers (init and regular, as admission +// sees them), and a scanner result whose State is not OK contributes nothing. func (p *StoreBaselineProvider) scanBaseline(ctx context.Context, scanID string) (Baseline, error) { results, err := p.store.GetScanResults(ctx, scanID) if err != nil { @@ -153,10 +153,8 @@ func (p *StoreBaselineProvider) scanBaseline(ctx context.Context, scanID string) totalPods, defaultSAPods int ) for _, perScanner := range results { - clusterContainers := 0 if raw, ok := perScanner["image-audit"]; ok && raw.State == scanner.StateOK { c, d := tallyImageAudit(raw.Data) - clusterContainers = c totalContainers += c withDigest += d } @@ -167,13 +165,13 @@ func (p *StoreBaselineProvider) scanBaseline(ctx context.Context, scanID string) t := tallyWorkloadSec(raw.Data) totalPods += t.totalPods defaultSAPods += t.defaultSA - if clusterContainers == 0 { + if t.containers <= 0 { continue } - wsContainers += clusterContainers - nonRoot += max(clusterContainers-t.runAsRoot, 0) - noPrivEsc += max(clusterContainers-t.allowPrivEsc, 0) - readRO += max(clusterContainers-t.noReadOnlyRoot, 0) + wsContainers += t.containers + nonRoot += min(max(t.nonRoot, 0), t.containers) + noPrivEsc += max(t.containers-t.allowPrivEsc, 0) + readRO += max(t.containers-t.noReadOnlyRoot, 0) } return Baseline{ @@ -283,13 +281,14 @@ func tallyImageAudit(data any) (total int, digestPinned int) { type workloadSecTally struct { // totalPods is the count of pods scanned. totalPods int - // runAsRoot is the count of containers explicitly running as UID 0. - runAsRoot int - // allowPrivEsc is the count of containers with allowPrivilegeEscalation - // implicitly or explicitly true. + // containers is the count of init and regular containers, the denominator of every + // container fraction except the digest one. + containers int + // nonRoot is the count of containers explicitly non-root. + nonRoot int + // allowPrivEsc is the count of containers that may gain privileges. allowPrivEsc int - // noReadOnlyRoot is the count of containers without - // readOnlyRootFilesystem=true. + // noReadOnlyRoot is the count of containers without a read-only root filesystem. noReadOnlyRoot int // defaultSA is the count of pods running under the default ServiceAccount. defaultSA int @@ -304,7 +303,8 @@ func tallyWorkloadSec(data any) workloadSecTally { } var m struct { TotalPods int `json:"total_pods"` - RunAsRootContainers int `json:"run_as_root_containers"` + BaselineContainers int `json:"baseline_containers"` + RunAsNonRootContainers int `json:"run_as_non_root_containers"` AllowPrivilegeEscalation int `json:"allow_privilege_escalation"` NoReadOnlyRoot int `json:"no_read_only_root"` DefaultServiceAccount int `json:"default_service_account_pods"` @@ -314,7 +314,8 @@ func tallyWorkloadSec(data any) workloadSecTally { } return workloadSecTally{ totalPods: m.TotalPods, - runAsRoot: m.RunAsRootContainers, + containers: m.BaselineContainers, + nonRoot: m.RunAsNonRootContainers, allowPrivEsc: m.AllowPrivilegeEscalation, noReadOnlyRoot: m.NoReadOnlyRoot, defaultSA: m.DefaultServiceAccount, diff --git a/internal/admission/baseline_test.go b/internal/admission/baseline_test.go index b02a379..348c02a 100644 --- a/internal/admission/baseline_test.go +++ b/internal/admission/baseline_test.go @@ -85,10 +85,11 @@ func imageAuditData(totalContainers, noDigest int) scanner.Result { } // workloadSecData builds a workload-security scanner Data payload. -func workloadSecData(totalPods, runAsRoot, allowPrivEsc, noReadOnly, defaultSA int) scanner.Result { +func workloadSecData(totalPods, containers, nonRoot, allowPrivEsc, noReadOnly, defaultSA int) scanner.Result { return scanner.Result{Scanner: "workload-security", Data: map[string]any{ "total_pods": totalPods, - "run_as_root_containers": runAsRoot, + "baseline_containers": containers, + "run_as_non_root_containers": nonRoot, "allow_privilege_escalation": allowPrivEsc, "no_read_only_root": noReadOnly, "default_service_account_pods": defaultSA, @@ -169,13 +170,16 @@ func TestTallyWorkloadSec(t *testing.T) { { // Test 0: Ordinary payload. Data: map[string]any{ "total_pods": 20, + "baseline_containers": 40, "run_as_root_containers": 8, + "run_as_non_root_containers": 30, "allow_privilege_escalation": 10, "no_read_only_root": 12, "default_service_account_pods": 5, }, WantResult: workloadSecTally{ - totalPods: 20, runAsRoot: 8, allowPrivEsc: 10, noReadOnlyRoot: 12, defaultSA: 5, + totalPods: 20, containers: 40, nonRoot: 30, allowPrivEsc: 10, noReadOnlyRoot: 12, + defaultSA: 5, }, }, { // Test 1: Unmarshalable channel value yields a zero tally. @@ -238,7 +242,7 @@ func TestStoreBaselineProviderRecompute(t *testing.T) { results: map[string]map[string]scanner.Result{ "clusterA": { "image-audit": imageAuditData(40, 4), - "workload-security": workloadSecData(20, 8, 10, 12, 5), + "workload-security": workloadSecData(20, 40, 32, 10, 12, 5), }, }, }, @@ -283,8 +287,8 @@ func TestStoreBaselineProviderRecompute(t *testing.T) { WantErr: true, }, { // Test 6: Two clusters aggregate per-cluster counts. A has 40 containers - // (4 unpinned, 8 root, 10 priv-esc, 12 writable root) and 20 pods (5 - // default SA). B has 10 containers (0 unpinned, 10 root, 0 priv-esc, + // (4 unpinned, 32 non-root, 10 priv-esc, 12 writable root) and 20 pods (5 + // default SA). B has 10 containers (0 unpinned, 0 non-root, 0 priv-esc, // 10 writable root) and 10 pods (0 default SA). Digest (36+10)/50=0.92, // non-root (32+0)/50=0.64, no-priv-esc (30+10)/50=0.8, read-only // (28+0)/50=0.56, named SA (15+10)/30. @@ -293,11 +297,11 @@ func TestStoreBaselineProviderRecompute(t *testing.T) { results: map[string]map[string]scanner.Result{ "clusterA": { "image-audit": imageAuditData(40, 4), - "workload-security": workloadSecData(20, 8, 10, 12, 5), + "workload-security": workloadSecData(20, 40, 32, 10, 12, 5), }, "clusterB": { "image-audit": imageAuditData(10, 0), - "workload-security": workloadSecData(10, 10, 0, 10, 0), + "workload-security": workloadSecData(10, 10, 0, 0, 10, 0), }, }, }, @@ -319,11 +323,11 @@ func TestStoreBaselineProviderRecompute(t *testing.T) { results: map[string]map[string]scanner.Result{ "clusterA": { "image-audit": imageAuditData(40, 4), - "workload-security": workloadSecData(20, 8, 10, 12, 5), + "workload-security": workloadSecData(20, 40, 32, 10, 12, 5), }, "clusterB": { "image-audit": imageAuditData(10, 0), - "workload-security": degraded(workloadSecData(10, 10, 0, 10, 0)), + "workload-security": degraded(workloadSecData(10, 10, 0, 0, 10, 0)), }, }, }, @@ -338,18 +342,19 @@ func TestStoreBaselineProviderRecompute(t *testing.T) { SourceScanID: "scan-1", }, }, - { // Test 8: An errored image-audit result on B removes B's containers - // entirely; B's pods still count toward the service account fraction. + { // Test 8: An errored image-audit result on B removes B's containers from the + // digest fraction only. B's workload-security counts still feed the container + // fractions and its pods the service account fraction. Store: &fakeStore{ scans: oneScan, results: map[string]map[string]scanner.Result{ "clusterA": { "image-audit": imageAuditData(40, 4), - "workload-security": workloadSecData(20, 8, 10, 12, 5), + "workload-security": workloadSecData(20, 40, 32, 10, 12, 5), }, "clusterB": { "image-audit": errored(imageAuditData(10, 0)), - "workload-security": workloadSecData(10, 10, 0, 10, 0), + "workload-security": workloadSecData(10, 10, 0, 0, 10, 0), }, }, }, @@ -357,13 +362,57 @@ func TestStoreBaselineProviderRecompute(t *testing.T) { SampleContainers: 40, SamplePods: 30, DigestPinFraction: 0.9, - NonRootFraction: 0.8, - NoPrivilegeEscalationFraction: 0.75, - ReadOnlyRootFSFraction: 0.7, + NonRootFraction: 0.64, + NoPrivilegeEscalationFraction: 0.8, + ReadOnlyRootFSFraction: 0.56, NamedServiceAccountFraction: 25.0 / 30.0, SourceScanID: "scan-1", }, }, + { // Test 9: Unknown containers count as not non-root, so 20 explicit non-root of + // 40 is half even with no container running as root. + Store: &fakeStore{ + scans: oneScan, + results: map[string]map[string]scanner.Result{ + "clusterA": { + "image-audit": imageAuditData(40, 0), + "workload-security": workloadSecData(20, 40, 20, 0, 0, 0), + }, + }, + }, + WantBaseline: Baseline{ + SampleContainers: 40, + SamplePods: 20, + DigestPinFraction: 1, + NonRootFraction: 0.5, + NoPrivilegeEscalationFraction: 1, + ReadOnlyRootFSFraction: 1, + NamedServiceAccountFraction: 1, + SourceScanID: "scan-1", + }, + }, + { // Test 10: Container fractions use baseline containers, not the image-audit + // total, which also counts ephemeral containers. + Store: &fakeStore{ + scans: oneScan, + results: map[string]map[string]scanner.Result{ + "clusterA": { + "image-audit": imageAuditData(50, 0), + "workload-security": workloadSecData(20, 40, 40, 10, 20, 0), + }, + }, + }, + WantBaseline: Baseline{ + SampleContainers: 50, + SamplePods: 20, + DigestPinFraction: 1, + NonRootFraction: 1, + NoPrivilegeEscalationFraction: 0.75, + ReadOnlyRootFSFraction: 0.5, + NamedServiceAccountFraction: 1, + SourceScanID: "scan-1", + }, + }, } for testNum, test := range tests { t.Run("test", func(t *testing.T) { diff --git a/internal/admission/checks.go b/internal/admission/checks.go index 10f684d..53d08f0 100644 --- a/internal/admission/checks.go +++ b/internal/admission/checks.go @@ -5,6 +5,8 @@ import ( "strings" corev1 "k8s.io/api/core/v1" + + "github.com/dcadolph/fleetsweeper/internal/podsec" ) // DefaultChecks returns the built-in fleet-norm checks. They lean on the @@ -50,18 +52,14 @@ type readOnlyRootFSCheck struct{} // Name returns the check identifier. func (readOnlyRootFSCheck) Name() string { return "read-only-root-fs" } -// Evaluate inspects containers' SecurityContext.ReadOnlyRootFilesystem. +// Evaluate flags each admitted container whose root filesystem is not read-only. func (readOnlyRootFSCheck) Evaluate(pod *corev1.Pod, b Baseline) ([]string, string) { if b.ReadOnlyRootFSFraction < Threshold { return nil, "" } var offenders []string for _, c := range admittedContainers(pod) { - ro := false - if c.SecurityContext != nil && c.SecurityContext.ReadOnlyRootFilesystem != nil { - ro = *c.SecurityContext.ReadOnlyRootFilesystem - } - if !ro { + if !podsec.ReadOnlyRootFS(c) { offenders = append(offenders, c.Name) } } @@ -101,31 +99,21 @@ func (digestPinCheck) Evaluate(pod *corev1.Pod, b Baseline) ([]string, string) { return []string{msg}, msg } -// nonRootCheck flags containers without runAsNonRoot when most of the -// fleet runs as non-root. +// nonRootCheck flags containers that are not explicitly non-root when most of the fleet is. type nonRootCheck struct{} // Name returns the check identifier. func (nonRootCheck) Name() string { return "non-root" } -// Evaluate inspects both pod-level and container-level SecurityContext for -// the runAsNonRoot signal. Pod-level wins when set; container-level -// otherwise. +// Evaluate flags each admitted container whose effective user is not explicitly non-root, the +// same predicate the baseline fraction counts. Container settings win over pod settings. func (nonRootCheck) Evaluate(pod *corev1.Pod, b Baseline) ([]string, string) { if b.NonRootFraction < Threshold { return nil, "" } - podLevel := false - if pod.Spec.SecurityContext != nil && pod.Spec.SecurityContext.RunAsNonRoot != nil { - podLevel = *pod.Spec.SecurityContext.RunAsNonRoot - } var offenders []string for _, c := range admittedContainers(pod) { - containerLevel := false - if c.SecurityContext != nil && c.SecurityContext.RunAsNonRoot != nil { - containerLevel = *c.SecurityContext.RunAsNonRoot - } - if !podLevel && !containerLevel { + if podsec.RootStatus(pod, c) != podsec.NonRoot { offenders = append(offenders, c.Name) } } @@ -138,26 +126,21 @@ func (nonRootCheck) Evaluate(pod *corev1.Pod, b Baseline) ([]string, string) { return []string{msg}, msg } -// noPrivEscCheck flags containers without allowPrivilegeEscalation set -// false when most of the fleet has it set. +// noPrivEscCheck flags containers that may gain privileges when most of the fleet cannot. type noPrivEscCheck struct{} // Name returns the check identifier. func (noPrivEscCheck) Name() string { return "no-privilege-escalation" } -// Evaluate inspects container SecurityContext.AllowPrivilegeEscalation. -// nil counts as "not set" which is equivalent to "true" in PSS-restricted. +// Evaluate flags each admitted container that may gain privileges, which an unset +// allowPrivilegeEscalation allows. func (noPrivEscCheck) Evaluate(pod *corev1.Pod, b Baseline) ([]string, string) { if b.NoPrivilegeEscalationFraction < Threshold { return nil, "" } var offenders []string for _, c := range admittedContainers(pod) { - set := false - if c.SecurityContext != nil && c.SecurityContext.AllowPrivilegeEscalation != nil { - set = !*c.SecurityContext.AllowPrivilegeEscalation - } - if !set { + if podsec.AllowsPrivilegeEscalation(c) { offenders = append(offenders, c.Name) } } @@ -174,8 +157,5 @@ func (noPrivEscCheck) Evaluate(pod *corev1.Pod, b Baseline) ([]string, string) { // containers are left out on purpose: they arrive later through the ephemeralcontainers // subresource, never on pod create, so admission never sees them. func admittedContainers(pod *corev1.Pod) []corev1.Container { - out := make([]corev1.Container, 0, len(pod.Spec.Containers)+len(pod.Spec.InitContainers)) - out = append(out, pod.Spec.InitContainers...) - out = append(out, pod.Spec.Containers...) - return out + return podsec.Containers(pod, false) } diff --git a/internal/admission/checks_test.go b/internal/admission/checks_test.go index 6cf8866..52ff133 100644 --- a/internal/admission/checks_test.go +++ b/internal/admission/checks_test.go @@ -1,6 +1,8 @@ package admission import ( + "fmt" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -106,19 +108,76 @@ func TestNoPrivEscCheck(t *testing.T) { } } -// TestNonRootCheckContainerLevel verifies a container-level runAsNonRoot=true -// clears the non-root check even without a pod-level setting. -func TestNonRootCheckContainerLevel(t *testing.T) { +// TestNonRootCheckPrecedence verifies the non-root check flags every admitted container whose +// effective user is not explicitly non-root, with container settings winning over pod settings. +func TestNonRootCheckPrecedence(t *testing.T) { t.Parallel() - yes := true - pod := &corev1.Pod{Spec: corev1.PodSpec{ - Containers: []corev1.Container{{ - Name: "c", - SecurityContext: &corev1.SecurityContext{RunAsNonRoot: &yes}, + yes, no := true, false + root, user := int64(0), int64(1000) + pod := func(psc *corev1.PodSecurityContext, sc *corev1.SecurityContext) *corev1.Pod { + return &corev1.Pod{Spec: corev1.PodSpec{ + SecurityContext: psc, + Containers: []corev1.Container{{Name: "c", SecurityContext: sc}}, + }} + } + tests := []struct { + Pod *corev1.Pod + WantOffenders int + Fraction float64 + }{{ // Test 0: Pod runAsNonRoot true with container false offends (R-4). + Pod: pod(&corev1.PodSecurityContext{RunAsNonRoot: &yes}, + &corev1.SecurityContext{RunAsNonRoot: &no}), + Fraction: 0.9, WantOffenders: 1, + }, { // Test 1: Container runAsNonRoot true passes without a pod setting. + Pod: pod(nil, &corev1.SecurityContext{RunAsNonRoot: &yes}), + Fraction: 0.9, + }, { // Test 2: Pod runAsNonRoot true passes a container that sets nothing. + Pod: pod(&corev1.PodSecurityContext{RunAsNonRoot: &yes}, nil), + Fraction: 0.9, + }, { // Test 3: Nothing set is unknown and offends. + Pod: pod(nil, nil), + Fraction: 0.9, WantOffenders: 1, + }, { // Test 4: A runAsUser above zero is explicitly non-root. + Pod: pod(nil, &corev1.SecurityContext{RunAsUser: &user}), + Fraction: 0.9, + }, { // Test 5: Pod runAsUser 0 under container runAsNonRoot true is root and offends. + Pod: pod(&corev1.PodSecurityContext{RunAsUser: &root}, + &corev1.SecurityContext{RunAsNonRoot: &yes}), + Fraction: 0.9, WantOffenders: 1, + }, { // Test 6: Below threshold stays quiet even with an offender. + Pod: pod(nil, nil), + Fraction: 0.5, + }, { // Test 7: An ephemeral container is never admitted, so it never offends. + Pod: &corev1.Pod{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "c", SecurityContext: &corev1.SecurityContext{RunAsNonRoot: &yes}, + }}, + EphemeralContainers: []corev1.EphemeralContainer{{ + EphemeralContainerCommon: corev1.EphemeralContainerCommon{Name: "debug"}, + }}, + }}, + Fraction: 0.9, + }, { // Test 8: Init and regular offenders are both counted. + Pod: &corev1.Pod{Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{{Name: "init"}}, + Containers: []corev1.Container{{Name: "c"}}, }}, + Fraction: 0.9, WantOffenders: 2, }} - warns, deny := nonRootCheck{}.Evaluate(pod, Baseline{NonRootFraction: 0.9}) - if len(warns) != 0 || deny != "" { - t.Errorf("container-level runAsNonRoot should pass, got warns=%v deny=%q", warns, deny) + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + warns, deny := nonRootCheck{}.Evaluate(test.Pod, Baseline{NonRootFraction: test.Fraction}) + want := "" + if test.WantOffenders > 0 { + want = fmt.Sprintf("%d here do not", test.WantOffenders) + } + if !strings.Contains(deny, want) || (want == "") != (deny == "") { + t.Errorf("deny = %q, want offender phrase %q", deny, want) + } + if diff := cmp.Diff(test.WantOffenders > 0, len(warns) > 0); diff != "" { + t.Errorf("fired mismatch (-want +got):\n%s", diff) + } + }) } } diff --git a/internal/admission/types.go b/internal/admission/types.go index 7a4eb1d..cb80da4 100644 --- a/internal/admission/types.go +++ b/internal/admission/types.go @@ -36,15 +36,16 @@ type Baseline struct { SampleContainers int `json:"sample_containers" yaml:"sample_containers"` // DigestPinFraction is the share of containers using @sha256: digest pins. DigestPinFraction float64 `json:"digest_pin_fraction" yaml:"digest_pin_fraction"` - // NonRootFraction is the share of containers not declared to run as UID 0. + // NonRootFraction is the share of init and regular containers explicitly non-root, the + // predicate the non-root check applies. NonRootFraction float64 `json:"non_root_fraction" yaml:"non_root_fraction"` - // NoPrivilegeEscalationFraction is the share of containers with - // allowPrivilegeEscalation set false (the PSS-restricted check). + // NoPrivilegeEscalationFraction is the share of init and regular containers that cannot + // gain privileges (the PSS-restricted check). NoPrivilegeEscalationFraction float64 `json:"no_privilege_escalation_fraction" yaml:"no_privilege_escalation_fraction"` // NamedServiceAccountFraction is the share of pods using a named // ServiceAccount (not "default"). NamedServiceAccountFraction float64 `json:"named_service_account_fraction" yaml:"named_service_account_fraction"` - // ReadOnlyRootFSFraction is the share of containers with + // ReadOnlyRootFSFraction is the share of init and regular containers with // readOnlyRootFilesystem=true. ReadOnlyRootFSFraction float64 `json:"read_only_root_fs_fraction" yaml:"read_only_root_fs_fraction"` // SourceScanID is the scan the baseline was derived from. diff --git a/internal/podsec/podsec.go b/internal/podsec/podsec.go new file mode 100644 index 0000000..3da1c3b --- /dev/null +++ b/internal/podsec/podsec.go @@ -0,0 +1,160 @@ +// Package podsec evaluates pod and container security settings with Kubernetes precedence, so +// every scanner and admission check that asks the same question gets the same answer. +package podsec + +import ( + "strings" + + corev1 "k8s.io/api/core/v1" +) + +// Status describes what a pod spec settles about the user a container runs as. +type Status string + +// Status values. Unknown counts as not non-root for every consumer. +const ( + // Root means the effective runAsUser is 0. + Root Status = "root" + // NonRoot means the effective runAsUser is above 0, or it is unset and the effective + // runAsNonRoot is true. + NonRoot Status = "non-root" + // Unknown means the spec does not settle the user, so only the image can. + Unknown Status = "unknown" +) + +// podContext returns the pod-level security context, or nil when the pod or its context is +// absent. +func podContext(pod *corev1.Pod) *corev1.PodSecurityContext { + if pod == nil { + return nil + } + return pod.Spec.SecurityContext +} + +// RunAsNonRoot returns the effective runAsNonRoot and whether either level sets it. The container +// value wins when set, else the pod value applies. +func RunAsNonRoot(pod *corev1.Pod, c corev1.Container) (bool, bool) { + if sc := c.SecurityContext; sc != nil && sc.RunAsNonRoot != nil { + return *sc.RunAsNonRoot, true + } + if psc := podContext(pod); psc != nil && psc.RunAsNonRoot != nil { + return *psc.RunAsNonRoot, true + } + return false, false +} + +// RunAsUser returns the effective runAsUser and whether either level sets it. The container value +// wins when set, else the pod value applies. +func RunAsUser(pod *corev1.Pod, c corev1.Container) (int64, bool) { + if sc := c.SecurityContext; sc != nil && sc.RunAsUser != nil { + return *sc.RunAsUser, true + } + if psc := podContext(pod); psc != nil && psc.RunAsUser != nil { + return *psc.RunAsUser, true + } + return 0, false +} + +// RootStatus classifies the container's user from the effective runAsUser and runAsNonRoot. An +// effective runAsUser of 0 is Root even under runAsNonRoot true, since that container cannot run +// as anything else, and runAsNonRoot false alone is Unknown because false only skips the check. +func RootStatus(pod *corev1.Pod, c corev1.Container) Status { + uid, uidSet := RunAsUser(pod, c) + if uidSet && uid == 0 { + return Root + } + if nonRoot, _ := RunAsNonRoot(pod, c); nonRoot { + return NonRoot + } + if uidSet && uid > 0 { + return NonRoot + } + return Unknown +} + +// AllowsPrivilegeEscalation reports whether the container may gain privileges. An unset field is +// true, and privileged or an added CAP_SYS_ADMIN (or ALL) forces true whatever the field says. +func AllowsPrivilegeEscalation(c corev1.Container) bool { + sc := c.SecurityContext + if sc == nil || sc.AllowPrivilegeEscalation == nil { + return true + } + if sc.Privileged != nil && *sc.Privileged { + return true + } + if sc.Capabilities != nil { + for _, add := range sc.Capabilities.Add { + name := strings.TrimPrefix(strings.ToUpper(string(add)), "CAP_") + if name == "SYS_ADMIN" || name == "ALL" { + return true + } + } + } + return *sc.AllowPrivilegeEscalation +} + +// ReadOnlyRootFS reports whether the container's root filesystem is read-only. The field exists +// only on the container and an unset field means writable. +func ReadOnlyRootFS(c corev1.Container) bool { + sc := c.SecurityContext + return sc != nil && sc.ReadOnlyRootFilesystem != nil && *sc.ReadOnlyRootFilesystem +} + +// DropsAllCapabilities reports whether the container drops ALL capabilities, matched without +// regard to case as container runtimes do. The field exists only on the container. +func DropsAllCapabilities(c corev1.Container) bool { + sc := c.SecurityContext + if sc == nil || sc.Capabilities == nil { + return false + } + for _, drop := range sc.Capabilities.Drop { + if strings.EqualFold(string(drop), "ALL") { + return true + } + } + return false +} + +// SeccompConfined reports whether the container runs under a RuntimeDefault or Localhost seccomp +// profile. The container profile wins when set, Unconfined included, else the pod profile +// applies, and no profile at either level is not confined. +func SeccompConfined(pod *corev1.Pod, c corev1.Container) bool { + var profile *corev1.SeccompProfile + if psc := podContext(pod); psc != nil { + profile = psc.SeccompProfile + } + if sc := c.SecurityContext; sc != nil && sc.SeccompProfile != nil { + profile = sc.SeccompProfile + } + if profile == nil { + return false + } + switch profile.Type { + case corev1.SeccompProfileTypeRuntimeDefault, corev1.SeccompProfileTypeLocalhost: + return true + default: + return false + } +} + +// Containers returns the pod's init and regular containers, followed by its ephemeral containers +// converted to Container when includeEphemeral is true. It returns nil for a nil pod. +func Containers(pod *corev1.Pod, includeEphemeral bool) []corev1.Container { + if pod == nil { + return nil + } + spec := &pod.Spec + n := len(spec.InitContainers) + len(spec.Containers) + if includeEphemeral { + n += len(spec.EphemeralContainers) + } + out := make([]corev1.Container, 0, n) + out = append(out, spec.InitContainers...) + out = append(out, spec.Containers...) + if includeEphemeral { + for i := range spec.EphemeralContainers { + out = append(out, corev1.Container(spec.EphemeralContainers[i].EphemeralContainerCommon)) + } + } + return out +} diff --git a/internal/podsec/podsec_test.go b/internal/podsec/podsec_test.go new file mode 100644 index 0000000..d73f040 --- /dev/null +++ b/internal/podsec/podsec_test.go @@ -0,0 +1,625 @@ +package podsec + +import ( + "fmt" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + corev1 "k8s.io/api/core/v1" +) + +// testBool decodes a matrix code into a runAsNonRoot pointer: "-" is unset, "t" true, "f" false. +func testBool(t *testing.T, code string) *bool { + t.Helper() + switch code { + case "-": + return nil + case "t": + v := true + return &v + case "f": + v := false + return &v + } + t.Fatalf("unknown bool code %q", code) + return nil +} + +// testUser decodes a matrix code into a runAsUser pointer: "-" is unset, anything else a UID. +func testUser(t *testing.T, code string) *int64 { + t.Helper() + if code == "-" { + return nil + } + v, err := strconv.ParseInt(code, 10, 64) + if err != nil { + t.Fatalf("bad user code %q: %v", code, err) + } + return &v +} + +// testPod builds a pod whose security context carries the coded runAsNonRoot and runAsUser. +// It leaves the security context nil when both codes are unset. +func testPod(t *testing.T, nonRoot, user string) *corev1.Pod { + t.Helper() + pod := &corev1.Pod{} + if nonRoot == "-" && user == "-" { + return pod + } + pod.Spec.SecurityContext = &corev1.PodSecurityContext{ + RunAsNonRoot: testBool(t, nonRoot), + RunAsUser: testUser(t, user), + } + return pod +} + +// testContainer builds a container whose security context carries the coded runAsNonRoot and +// runAsUser. It leaves the security context nil when both codes are unset. +func testContainer(t *testing.T, nonRoot, user string) corev1.Container { + t.Helper() + c := corev1.Container{Name: "app"} + if nonRoot == "-" && user == "-" { + return c + } + c.SecurityContext = &corev1.SecurityContext{ + RunAsNonRoot: testBool(t, nonRoot), + RunAsUser: testUser(t, user), + } + return c +} + +// testSeccompPod builds a pod with the given pod-level seccomp profile type, or none when empty. +func testSeccompPod(typ corev1.SeccompProfileType) *corev1.Pod { + pod := &corev1.Pod{} + if typ != "" { + pod.Spec.SecurityContext = &corev1.PodSecurityContext{ + SeccompProfile: &corev1.SeccompProfile{Type: typ}, + } + } + return pod +} + +// testSeccompContainer builds a container with the given seccomp profile type, or none when empty. +func testSeccompContainer(typ corev1.SeccompProfileType) corev1.Container { + c := corev1.Container{Name: "app"} + if typ != "" { + c.SecurityContext = &corev1.SecurityContext{ + SeccompProfile: &corev1.SeccompProfile{Type: typ}, + } + } + return c +} + +// testPtr returns a pointer to v. +func testPtr[T any](v T) *T { + return &v +} + +func TestRunAsNonRoot(t *testing.T) { + t.Parallel() + tests := []struct { + PodNonRoot string + NonRoot string + WantValue bool + WantSet bool + }{ + // Test 0: Nothing set. + {PodNonRoot: "-", NonRoot: "-", WantValue: false, WantSet: false}, + // Test 1: Container true only. + {PodNonRoot: "-", NonRoot: "t", WantValue: true, WantSet: true}, + // Test 2: Container false only. + {PodNonRoot: "-", NonRoot: "f", WantValue: false, WantSet: true}, + // Test 3: Pod true only. + {PodNonRoot: "t", NonRoot: "-", WantValue: true, WantSet: true}, + // Test 4: Pod true, container true. + {PodNonRoot: "t", NonRoot: "t", WantValue: true, WantSet: true}, + // Test 5: Pod true, container false, container wins. + {PodNonRoot: "t", NonRoot: "f", WantValue: false, WantSet: true}, + // Test 6: Pod false only. + {PodNonRoot: "f", NonRoot: "-", WantValue: false, WantSet: true}, + // Test 7: Pod false, container true, container wins. + {PodNonRoot: "f", NonRoot: "t", WantValue: true, WantSet: true}, + // Test 8: Pod false, container false. + {PodNonRoot: "f", NonRoot: "f", WantValue: false, WantSet: true}, + } + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + pod := testPod(t, test.PodNonRoot, "-") + gotValue, gotSet := RunAsNonRoot(pod, testContainer(t, test.NonRoot, "-")) + if diff := cmp.Diff(test.WantValue, gotValue); diff != "" { + t.Errorf("value mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(test.WantSet, gotSet); diff != "" { + t.Errorf("set mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestRunAsUser(t *testing.T) { + t.Parallel() + tests := []struct { + PodUser string + User string + WantValue int64 + WantSet bool + }{ + // Test 0: Nothing set. + {PodUser: "-", User: "-", WantValue: 0, WantSet: false}, + // Test 1: Container 0 only. + {PodUser: "-", User: "0", WantValue: 0, WantSet: true}, + // Test 2: Container 1000 only. + {PodUser: "-", User: "1000", WantValue: 1000, WantSet: true}, + // Test 3: Pod 0 only. + {PodUser: "0", User: "-", WantValue: 0, WantSet: true}, + // Test 4: Pod 0, container 0. + {PodUser: "0", User: "0", WantValue: 0, WantSet: true}, + // Test 5: Pod 0, container 1000, container wins. + {PodUser: "0", User: "1000", WantValue: 1000, WantSet: true}, + // Test 6: Pod 1000 only. + {PodUser: "1000", User: "-", WantValue: 1000, WantSet: true}, + // Test 7: Pod 1000, container 0, container wins. + {PodUser: "1000", User: "0", WantValue: 0, WantSet: true}, + // Test 8: Pod 1000, container 1000. + {PodUser: "1000", User: "1000", WantValue: 1000, WantSet: true}, + } + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + pod := testPod(t, "-", test.PodUser) + gotValue, gotSet := RunAsUser(pod, testContainer(t, "-", test.User)) + if diff := cmp.Diff(test.WantValue, gotValue); diff != "" { + t.Errorf("value mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(test.WantSet, gotSet); diff != "" { + t.Errorf("set mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestNilPod(t *testing.T) { + t.Parallel() + c := corev1.Container{SecurityContext: &corev1.SecurityContext{}} + if v, set := RunAsNonRoot(nil, c); v || set { + t.Errorf("RunAsNonRoot(nil) = %v, %v, want false, false", v, set) + } + if v, set := RunAsUser(nil, c); v != 0 || set { + t.Errorf("RunAsUser(nil) = %v, %v, want 0, false", v, set) + } + if got := RootStatus(nil, c); got != Unknown { + t.Errorf("RootStatus(nil) = %q, want %q", got, Unknown) + } + if SeccompConfined(nil, c) { + t.Error("SeccompConfined(nil) = true, want false") + } + if got := Containers(nil, true); got != nil { + t.Errorf("Containers(nil) = %v, want nil", got) + } +} + +func TestRootStatus(t *testing.T) { + t.Parallel() + tests := []struct { + PodNonRoot string + PodUser string + NonRoot string + User string + WantStatus Status + }{ + // Test 0: Pod nonRoot unset user unset, container nonRoot unset user unset. + {PodNonRoot: "-", PodUser: "-", NonRoot: "-", User: "-", WantStatus: Unknown}, + // Test 1: Pod nonRoot unset user unset, container nonRoot unset user 0. + {PodNonRoot: "-", PodUser: "-", NonRoot: "-", User: "0", WantStatus: Root}, + // Test 2: Pod nonRoot unset user unset, container nonRoot unset user 1000. + {PodNonRoot: "-", PodUser: "-", NonRoot: "-", User: "1000", WantStatus: NonRoot}, + // Test 3: Pod nonRoot unset user unset, container nonRoot true user unset. + {PodNonRoot: "-", PodUser: "-", NonRoot: "t", User: "-", WantStatus: NonRoot}, + // Test 4: Pod nonRoot unset user unset, container nonRoot true user 0. + {PodNonRoot: "-", PodUser: "-", NonRoot: "t", User: "0", WantStatus: Root}, + // Test 5: Pod nonRoot unset user unset, container nonRoot true user 1000. + {PodNonRoot: "-", PodUser: "-", NonRoot: "t", User: "1000", WantStatus: NonRoot}, + // Test 6: Pod nonRoot unset user unset, container nonRoot false user unset. + {PodNonRoot: "-", PodUser: "-", NonRoot: "f", User: "-", WantStatus: Unknown}, + // Test 7: Pod nonRoot unset user unset, container nonRoot false user 0. + {PodNonRoot: "-", PodUser: "-", NonRoot: "f", User: "0", WantStatus: Root}, + // Test 8: Pod nonRoot unset user unset, container nonRoot false user 1000. + {PodNonRoot: "-", PodUser: "-", NonRoot: "f", User: "1000", WantStatus: NonRoot}, + // Test 9: Pod nonRoot unset user 0, container nonRoot unset user unset. + {PodNonRoot: "-", PodUser: "0", NonRoot: "-", User: "-", WantStatus: Root}, + // Test 10: Pod nonRoot unset user 0, container nonRoot unset user 0. + {PodNonRoot: "-", PodUser: "0", NonRoot: "-", User: "0", WantStatus: Root}, + // Test 11: Pod nonRoot unset user 0, container nonRoot unset user 1000. + {PodNonRoot: "-", PodUser: "0", NonRoot: "-", User: "1000", WantStatus: NonRoot}, + // Test 12: Pod nonRoot unset user 0, container nonRoot true user unset. + {PodNonRoot: "-", PodUser: "0", NonRoot: "t", User: "-", WantStatus: Root}, + // Test 13: Pod nonRoot unset user 0, container nonRoot true user 0. + {PodNonRoot: "-", PodUser: "0", NonRoot: "t", User: "0", WantStatus: Root}, + // Test 14: Pod nonRoot unset user 0, container nonRoot true user 1000. + {PodNonRoot: "-", PodUser: "0", NonRoot: "t", User: "1000", WantStatus: NonRoot}, + // Test 15: Pod nonRoot unset user 0, container nonRoot false user unset. + {PodNonRoot: "-", PodUser: "0", NonRoot: "f", User: "-", WantStatus: Root}, + // Test 16: Pod nonRoot unset user 0, container nonRoot false user 0. + {PodNonRoot: "-", PodUser: "0", NonRoot: "f", User: "0", WantStatus: Root}, + // Test 17: Pod nonRoot unset user 0, container nonRoot false user 1000. + {PodNonRoot: "-", PodUser: "0", NonRoot: "f", User: "1000", WantStatus: NonRoot}, + // Test 18: Pod nonRoot unset user 1000, container nonRoot unset user unset. + {PodNonRoot: "-", PodUser: "1000", NonRoot: "-", User: "-", WantStatus: NonRoot}, + // Test 19: Pod nonRoot unset user 1000, container nonRoot unset user 0. + {PodNonRoot: "-", PodUser: "1000", NonRoot: "-", User: "0", WantStatus: Root}, + // Test 20: Pod nonRoot unset user 1000, container nonRoot unset user 1000. + {PodNonRoot: "-", PodUser: "1000", NonRoot: "-", User: "1000", WantStatus: NonRoot}, + // Test 21: Pod nonRoot unset user 1000, container nonRoot true user unset. + {PodNonRoot: "-", PodUser: "1000", NonRoot: "t", User: "-", WantStatus: NonRoot}, + // Test 22: Pod nonRoot unset user 1000, container nonRoot true user 0. + {PodNonRoot: "-", PodUser: "1000", NonRoot: "t", User: "0", WantStatus: Root}, + // Test 23: Pod nonRoot unset user 1000, container nonRoot true user 1000. + {PodNonRoot: "-", PodUser: "1000", NonRoot: "t", User: "1000", WantStatus: NonRoot}, + // Test 24: Pod nonRoot unset user 1000, container nonRoot false user unset. + {PodNonRoot: "-", PodUser: "1000", NonRoot: "f", User: "-", WantStatus: NonRoot}, + // Test 25: Pod nonRoot unset user 1000, container nonRoot false user 0. + {PodNonRoot: "-", PodUser: "1000", NonRoot: "f", User: "0", WantStatus: Root}, + // Test 26: Pod nonRoot unset user 1000, container nonRoot false user 1000. + {PodNonRoot: "-", PodUser: "1000", NonRoot: "f", User: "1000", WantStatus: NonRoot}, + // Test 27: Pod nonRoot true user unset, container nonRoot unset user unset. + {PodNonRoot: "t", PodUser: "-", NonRoot: "-", User: "-", WantStatus: NonRoot}, + // Test 28: Pod nonRoot true user unset, container nonRoot unset user 0. + {PodNonRoot: "t", PodUser: "-", NonRoot: "-", User: "0", WantStatus: Root}, + // Test 29: Pod nonRoot true user unset, container nonRoot unset user 1000. + {PodNonRoot: "t", PodUser: "-", NonRoot: "-", User: "1000", WantStatus: NonRoot}, + // Test 30: Pod nonRoot true user unset, container nonRoot true user unset. + {PodNonRoot: "t", PodUser: "-", NonRoot: "t", User: "-", WantStatus: NonRoot}, + // Test 31: Pod nonRoot true user unset, container nonRoot true user 0. + {PodNonRoot: "t", PodUser: "-", NonRoot: "t", User: "0", WantStatus: Root}, + // Test 32: Pod nonRoot true user unset, container nonRoot true user 1000. + {PodNonRoot: "t", PodUser: "-", NonRoot: "t", User: "1000", WantStatus: NonRoot}, + // Test 33: Pod nonRoot true user unset, container nonRoot false user unset. + {PodNonRoot: "t", PodUser: "-", NonRoot: "f", User: "-", WantStatus: Unknown}, + // Test 34: Pod nonRoot true user unset, container nonRoot false user 0. + {PodNonRoot: "t", PodUser: "-", NonRoot: "f", User: "0", WantStatus: Root}, + // Test 35: Pod nonRoot true user unset, container nonRoot false user 1000. + {PodNonRoot: "t", PodUser: "-", NonRoot: "f", User: "1000", WantStatus: NonRoot}, + // Test 36: Pod nonRoot true user 0, container nonRoot unset user unset. + {PodNonRoot: "t", PodUser: "0", NonRoot: "-", User: "-", WantStatus: Root}, + // Test 37: Pod nonRoot true user 0, container nonRoot unset user 0. + {PodNonRoot: "t", PodUser: "0", NonRoot: "-", User: "0", WantStatus: Root}, + // Test 38: Pod nonRoot true user 0, container nonRoot unset user 1000. + {PodNonRoot: "t", PodUser: "0", NonRoot: "-", User: "1000", WantStatus: NonRoot}, + // Test 39: Pod nonRoot true user 0, container nonRoot true user unset. + {PodNonRoot: "t", PodUser: "0", NonRoot: "t", User: "-", WantStatus: Root}, + // Test 40: Pod nonRoot true user 0, container nonRoot true user 0. + {PodNonRoot: "t", PodUser: "0", NonRoot: "t", User: "0", WantStatus: Root}, + // Test 41: Pod nonRoot true user 0, container nonRoot true user 1000. + {PodNonRoot: "t", PodUser: "0", NonRoot: "t", User: "1000", WantStatus: NonRoot}, + // Test 42: Pod nonRoot true user 0, container nonRoot false user unset. + {PodNonRoot: "t", PodUser: "0", NonRoot: "f", User: "-", WantStatus: Root}, + // Test 43: Pod nonRoot true user 0, container nonRoot false user 0. + {PodNonRoot: "t", PodUser: "0", NonRoot: "f", User: "0", WantStatus: Root}, + // Test 44: Pod nonRoot true user 0, container nonRoot false user 1000. + {PodNonRoot: "t", PodUser: "0", NonRoot: "f", User: "1000", WantStatus: NonRoot}, + // Test 45: Pod nonRoot true user 1000, container nonRoot unset user unset. + {PodNonRoot: "t", PodUser: "1000", NonRoot: "-", User: "-", WantStatus: NonRoot}, + // Test 46: Pod nonRoot true user 1000, container nonRoot unset user 0. + {PodNonRoot: "t", PodUser: "1000", NonRoot: "-", User: "0", WantStatus: Root}, + // Test 47: Pod nonRoot true user 1000, container nonRoot unset user 1000. + {PodNonRoot: "t", PodUser: "1000", NonRoot: "-", User: "1000", WantStatus: NonRoot}, + // Test 48: Pod nonRoot true user 1000, container nonRoot true user unset. + {PodNonRoot: "t", PodUser: "1000", NonRoot: "t", User: "-", WantStatus: NonRoot}, + // Test 49: Pod nonRoot true user 1000, container nonRoot true user 0. + {PodNonRoot: "t", PodUser: "1000", NonRoot: "t", User: "0", WantStatus: Root}, + // Test 50: Pod nonRoot true user 1000, container nonRoot true user 1000. + {PodNonRoot: "t", PodUser: "1000", NonRoot: "t", User: "1000", WantStatus: NonRoot}, + // Test 51: Pod nonRoot true user 1000, container nonRoot false user unset. + {PodNonRoot: "t", PodUser: "1000", NonRoot: "f", User: "-", WantStatus: NonRoot}, + // Test 52: Pod nonRoot true user 1000, container nonRoot false user 0. + {PodNonRoot: "t", PodUser: "1000", NonRoot: "f", User: "0", WantStatus: Root}, + // Test 53: Pod nonRoot true user 1000, container nonRoot false user 1000. + {PodNonRoot: "t", PodUser: "1000", NonRoot: "f", User: "1000", WantStatus: NonRoot}, + // Test 54: Pod nonRoot false user unset, container nonRoot unset user unset. + {PodNonRoot: "f", PodUser: "-", NonRoot: "-", User: "-", WantStatus: Unknown}, + // Test 55: Pod nonRoot false user unset, container nonRoot unset user 0. + {PodNonRoot: "f", PodUser: "-", NonRoot: "-", User: "0", WantStatus: Root}, + // Test 56: Pod nonRoot false user unset, container nonRoot unset user 1000. + {PodNonRoot: "f", PodUser: "-", NonRoot: "-", User: "1000", WantStatus: NonRoot}, + // Test 57: Pod nonRoot false user unset, container nonRoot true user unset. + {PodNonRoot: "f", PodUser: "-", NonRoot: "t", User: "-", WantStatus: NonRoot}, + // Test 58: Pod nonRoot false user unset, container nonRoot true user 0. + {PodNonRoot: "f", PodUser: "-", NonRoot: "t", User: "0", WantStatus: Root}, + // Test 59: Pod nonRoot false user unset, container nonRoot true user 1000. + {PodNonRoot: "f", PodUser: "-", NonRoot: "t", User: "1000", WantStatus: NonRoot}, + // Test 60: Pod nonRoot false user unset, container nonRoot false user unset. + {PodNonRoot: "f", PodUser: "-", NonRoot: "f", User: "-", WantStatus: Unknown}, + // Test 61: Pod nonRoot false user unset, container nonRoot false user 0. + {PodNonRoot: "f", PodUser: "-", NonRoot: "f", User: "0", WantStatus: Root}, + // Test 62: Pod nonRoot false user unset, container nonRoot false user 1000. + {PodNonRoot: "f", PodUser: "-", NonRoot: "f", User: "1000", WantStatus: NonRoot}, + // Test 63: Pod nonRoot false user 0, container nonRoot unset user unset. + {PodNonRoot: "f", PodUser: "0", NonRoot: "-", User: "-", WantStatus: Root}, + // Test 64: Pod nonRoot false user 0, container nonRoot unset user 0. + {PodNonRoot: "f", PodUser: "0", NonRoot: "-", User: "0", WantStatus: Root}, + // Test 65: Pod nonRoot false user 0, container nonRoot unset user 1000. + {PodNonRoot: "f", PodUser: "0", NonRoot: "-", User: "1000", WantStatus: NonRoot}, + // Test 66: Pod nonRoot false user 0, container nonRoot true user unset. + {PodNonRoot: "f", PodUser: "0", NonRoot: "t", User: "-", WantStatus: Root}, + // Test 67: Pod nonRoot false user 0, container nonRoot true user 0. + {PodNonRoot: "f", PodUser: "0", NonRoot: "t", User: "0", WantStatus: Root}, + // Test 68: Pod nonRoot false user 0, container nonRoot true user 1000. + {PodNonRoot: "f", PodUser: "0", NonRoot: "t", User: "1000", WantStatus: NonRoot}, + // Test 69: Pod nonRoot false user 0, container nonRoot false user unset. + {PodNonRoot: "f", PodUser: "0", NonRoot: "f", User: "-", WantStatus: Root}, + // Test 70: Pod nonRoot false user 0, container nonRoot false user 0. + {PodNonRoot: "f", PodUser: "0", NonRoot: "f", User: "0", WantStatus: Root}, + // Test 71: Pod nonRoot false user 0, container nonRoot false user 1000. + {PodNonRoot: "f", PodUser: "0", NonRoot: "f", User: "1000", WantStatus: NonRoot}, + // Test 72: Pod nonRoot false user 1000, container nonRoot unset user unset. + {PodNonRoot: "f", PodUser: "1000", NonRoot: "-", User: "-", WantStatus: NonRoot}, + // Test 73: Pod nonRoot false user 1000, container nonRoot unset user 0. + {PodNonRoot: "f", PodUser: "1000", NonRoot: "-", User: "0", WantStatus: Root}, + // Test 74: Pod nonRoot false user 1000, container nonRoot unset user 1000. + {PodNonRoot: "f", PodUser: "1000", NonRoot: "-", User: "1000", WantStatus: NonRoot}, + // Test 75: Pod nonRoot false user 1000, container nonRoot true user unset. + {PodNonRoot: "f", PodUser: "1000", NonRoot: "t", User: "-", WantStatus: NonRoot}, + // Test 76: Pod nonRoot false user 1000, container nonRoot true user 0. + {PodNonRoot: "f", PodUser: "1000", NonRoot: "t", User: "0", WantStatus: Root}, + // Test 77: Pod nonRoot false user 1000, container nonRoot true user 1000. + {PodNonRoot: "f", PodUser: "1000", NonRoot: "t", User: "1000", WantStatus: NonRoot}, + // Test 78: Pod nonRoot false user 1000, container nonRoot false user unset. + {PodNonRoot: "f", PodUser: "1000", NonRoot: "f", User: "-", WantStatus: NonRoot}, + // Test 79: Pod nonRoot false user 1000, container nonRoot false user 0. + {PodNonRoot: "f", PodUser: "1000", NonRoot: "f", User: "0", WantStatus: Root}, + // Test 80: Pod nonRoot false user 1000, container nonRoot false user 1000. + {PodNonRoot: "f", PodUser: "1000", NonRoot: "f", User: "1000", WantStatus: NonRoot}, + } + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + pod := testPod(t, test.PodNonRoot, test.PodUser) + got := RootStatus(pod, testContainer(t, test.NonRoot, test.User)) + if diff := cmp.Diff(test.WantStatus, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestSeccompConfined(t *testing.T) { + t.Parallel() + const ( + unset = corev1.SeccompProfileType("") + runtime = corev1.SeccompProfileTypeRuntimeDefault + unconf = corev1.SeccompProfileTypeUnconfined + local = corev1.SeccompProfileTypeLocalhost + ) + tests := []struct { + Pod corev1.SeccompProfileType + Container corev1.SeccompProfileType + WantResult bool + }{ + // Test 0: Nothing set. + {Pod: unset, Container: unset, WantResult: false}, + // Test 1: Container RuntimeDefault only. + {Pod: unset, Container: runtime, WantResult: true}, + // Test 2: Container Unconfined only. + {Pod: unset, Container: unconf, WantResult: false}, + // Test 3: Pod RuntimeDefault only. + {Pod: runtime, Container: unset, WantResult: true}, + // Test 4: Pod RuntimeDefault, container RuntimeDefault. + {Pod: runtime, Container: runtime, WantResult: true}, + // Test 5: Pod RuntimeDefault, container Unconfined, container wins. + {Pod: runtime, Container: unconf, WantResult: false}, + // Test 6: Pod Unconfined only. + {Pod: unconf, Container: unset, WantResult: false}, + // Test 7: Pod Unconfined, container RuntimeDefault, container wins. + {Pod: unconf, Container: runtime, WantResult: true}, + // Test 8: Pod Unconfined, container Unconfined. + {Pod: unconf, Container: unconf, WantResult: false}, + // Test 9: Container Localhost only. + {Pod: unset, Container: local, WantResult: true}, + // Test 10: Pod Localhost, container Unconfined, container wins. + {Pod: local, Container: unconf, WantResult: false}, + } + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + got := SeccompConfined(testSeccompPod(test.Pod), testSeccompContainer(test.Container)) + if diff := cmp.Diff(test.WantResult, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestSeccompConfinedEmptyType(t *testing.T) { + t.Parallel() + pod := testSeccompPod(corev1.SeccompProfileTypeRuntimeDefault) + c := corev1.Container{SecurityContext: &corev1.SecurityContext{ + SeccompProfile: &corev1.SeccompProfile{}, + }} + if SeccompConfined(pod, c) { + t.Error("SeccompConfined with an empty container profile type = true, want false") + } +} + +func TestAllowsPrivilegeEscalation(t *testing.T) { + t.Parallel() + tests := []struct { + SecurityContext *corev1.SecurityContext + WantResult bool + }{ + // Test 0: No security context defaults to true. + {SecurityContext: nil, WantResult: true}, + // Test 1: Field unset defaults to true. + {SecurityContext: &corev1.SecurityContext{}, WantResult: true}, + // Test 2: Explicit false. + {SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: testPtr(false), + }, WantResult: false}, + // Test 3: Explicit true. + {SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: testPtr(true), + }, WantResult: true}, + // Test 4: Privileged forces true over false. + {SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: testPtr(false), + Privileged: testPtr(true), + }, WantResult: true}, + // Test 5: Privileged false keeps false. + {SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: testPtr(false), + Privileged: testPtr(false), + }, WantResult: false}, + // Test 6: CAP_SYS_ADMIN in adds forces true over false. + {SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: testPtr(false), + Capabilities: &corev1.Capabilities{Add: []corev1.Capability{"CAP_SYS_ADMIN"}}, + }, WantResult: true}, + // Test 7: SYS_ADMIN without the prefix forces true over false. + {SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: testPtr(false), + Capabilities: &corev1.Capabilities{Add: []corev1.Capability{"sys_admin"}}, + }, WantResult: true}, + // Test 8: ALL in adds grants CAP_SYS_ADMIN and forces true over false. + {SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: testPtr(false), + Capabilities: &corev1.Capabilities{Add: []corev1.Capability{"ALL"}}, + }, WantResult: true}, + // Test 9: Other adds keep false. + {SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: testPtr(false), + Capabilities: &corev1.Capabilities{Add: []corev1.Capability{"NET_BIND_SERVICE"}}, + }, WantResult: false}, + // Test 10: CAP_SYS_ADMIN in drops keeps false. + {SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: testPtr(false), + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"CAP_SYS_ADMIN"}}, + }, WantResult: false}, + } + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + got := AllowsPrivilegeEscalation(corev1.Container{SecurityContext: test.SecurityContext}) + if diff := cmp.Diff(test.WantResult, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestReadOnlyRootFS(t *testing.T) { + t.Parallel() + tests := []struct { + SecurityContext *corev1.SecurityContext + WantResult bool + }{ + // Test 0: No security context defaults to writable. + {SecurityContext: nil, WantResult: false}, + // Test 1: Field unset defaults to writable. + {SecurityContext: &corev1.SecurityContext{}, WantResult: false}, + // Test 2: Explicit true. + {SecurityContext: &corev1.SecurityContext{ + ReadOnlyRootFilesystem: testPtr(true), + }, WantResult: true}, + // Test 3: Explicit false. + {SecurityContext: &corev1.SecurityContext{ + ReadOnlyRootFilesystem: testPtr(false), + }, WantResult: false}, + } + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + got := ReadOnlyRootFS(corev1.Container{SecurityContext: test.SecurityContext}) + if diff := cmp.Diff(test.WantResult, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDropsAllCapabilities(t *testing.T) { + t.Parallel() + tests := []struct { + SecurityContext *corev1.SecurityContext + WantResult bool + }{ + // Test 0: No security context. + {SecurityContext: nil, WantResult: false}, + // Test 1: No capabilities. + {SecurityContext: &corev1.SecurityContext{}, WantResult: false}, + // Test 2: Drops ALL. + {SecurityContext: &corev1.SecurityContext{ + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"NET_RAW", "ALL"}}, + }, WantResult: true}, + // Test 3: Drops all in lower case. + {SecurityContext: &corev1.SecurityContext{ + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"all"}}, + }, WantResult: true}, + // Test 4: Drops only some. + {SecurityContext: &corev1.SecurityContext{ + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"NET_RAW"}}, + }, WantResult: false}, + // Test 5: ALL in adds is not a drop. + {SecurityContext: &corev1.SecurityContext{ + Capabilities: &corev1.Capabilities{Add: []corev1.Capability{"ALL"}}, + }, WantResult: false}, + } + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + got := DropsAllCapabilities(corev1.Container{SecurityContext: test.SecurityContext}) + if diff := cmp.Diff(test.WantResult, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestContainers(t *testing.T) { + t.Parallel() + sc := &corev1.SecurityContext{RunAsNonRoot: testPtr(true)} + pod := &corev1.Pod{Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{{Name: "init", Image: "busybox"}}, + Containers: []corev1.Container{{Name: "app", Image: "nginx"}}, + EphemeralContainers: []corev1.EphemeralContainer{{ + EphemeralContainerCommon: corev1.EphemeralContainerCommon{ + Name: "debug", + Image: "alpine", + Command: []string{"sh"}, + SecurityContext: sc, + }, + TargetContainerName: "app", + }}, + }} + tests := []struct { + Pod *corev1.Pod + IncludeEphemeral bool + WantResult []corev1.Container + }{ + // Test 0: Init and regular containers without ephemeral. + {Pod: pod, IncludeEphemeral: false, WantResult: []corev1.Container{ + {Name: "init", Image: "busybox"}, + {Name: "app", Image: "nginx"}, + }}, + // Test 1: Ephemeral containers converted and appended when asked. + {Pod: pod, IncludeEphemeral: true, WantResult: []corev1.Container{ + {Name: "init", Image: "busybox"}, + {Name: "app", Image: "nginx"}, + {Name: "debug", Image: "alpine", Command: []string{"sh"}, SecurityContext: sc}, + }}, + // Test 2: Empty pod yields no containers. + {Pod: &corev1.Pod{}, IncludeEphemeral: true, WantResult: nil}, + // Test 3: Nil pod yields nil. + {Pod: nil, IncludeEphemeral: false, WantResult: nil}, + } + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + got := Containers(test.Pod, test.IncludeEphemeral) + if diff := cmp.Diff(test.WantResult, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/report/fields.go b/internal/report/fields.go index 24dd23a..6c70565 100644 --- a/internal/report/fields.go +++ b/internal/report/fields.go @@ -196,6 +196,9 @@ var fieldSpecs = []fieldSpec{ Polarity: UpIsBad, CountedBy: "workload-security/run-as-root", }, + {FieldRef: ref("workload-security", "run_as_non_root_containers"), Polarity: DownIsBad}, + {FieldRef: ref("workload-security", "run_as_unknown_containers")}, + {FieldRef: ref("workload-security", "baseline_containers")}, { FieldRef: ref("workload-security", "capability_additions"), Severity: SeverityWarning, diff --git a/internal/report/findings_security.go b/internal/report/findings_security.go index 9f974c7..549d600 100644 --- a/internal/report/findings_security.go +++ b/internal/report/findings_security.go @@ -5,6 +5,7 @@ import ( "slices" "strings" + "github.com/dcadolph/fleetsweeper/internal/scanner/security" "github.com/dcadolph/fleetsweeper/internal/util" ) @@ -30,22 +31,22 @@ func securityFindings(r *Report) []Finding { sev = SeverityCritical } } - names := unenforcedNamespaces(r, cluster) + unlabeled, privileged := unenforcedNamespaces(r, cluster) cmd := fmt.Sprintf("kubectl --context %s label namespace "+ "pod-security.kubernetes.io/enforce=baseline --overwrite", cluster) - if len(names) > 0 { + if len(unlabeled) > 0 { cmd = fmt.Sprintf("kubectl --context %s label namespace %s "+ "pod-security.kubernetes.io/enforce=baseline --overwrite", - cluster, strings.Join(names[:min(len(names), maxAffected)], " ")) + cluster, strings.Join(unlabeled[:min(len(unlabeled), maxAffected)], " ")) } findings = append(findings, Finding{ Rule: "security/psa-unenforced", Title: fmt.Sprintf("%s has %d namespace(s) without Pod Security enforcement", cluster, unenforced), - Description: share + " have no Pod Security Standards enforce label. Pods in these namespaces can run with any privileges.", + Description: share + " have no Pod Security Standards enforce label or enforce only privileged. Pods in these namespaces can run with any privileges.", Severity: sev, Cluster: cluster, Scanner: "security", - Affected: capList(names), + Affected: capList(append(unlabeled, privileged...)), Remediation: &Remediation{Command: cmd}, }) } @@ -53,11 +54,11 @@ func securityFindings(r *Report) []Finding { return findings } -// unenforcedNamespaces returns the names of namespaces with no enforce level in a cluster's -// security payload, or nil when the payload has no namespaces list. -func unenforcedNamespaces(r *Report, cluster string) []string { +// unenforcedNamespaces returns the namespaces in a cluster's security payload that enforce +// nothing, split into those with no enforce label and those that enforce privileged. The +// remediation labels only the first, since a namespace set to privileged was set on purpose. +func unenforcedNamespaces(r *Report, cluster string) (unlabeled, privileged []string) { raw, _ := clusterData(r, "security", cluster)["namespaces"].([]any) - var out []string for _, item := range raw { entry, ok := item.(map[string]any) if !ok { @@ -65,11 +66,15 @@ func unenforcedNamespaces(r *Report, cluster string) []string { } name, _ := entry["namespace"].(string) enforce, _ := entry["enforce"].(string) - if name != "" && enforce == "" { - out = append(out, name) + switch { + case name == "" || security.Enforced(enforce): + case enforce == "": + unlabeled = append(unlabeled, name) + default: + privileged = append(privileged, name) } } - return out + return unlabeled, privileged } // networkFindings reports namespaces with no NetworkPolicy coverage and @@ -168,7 +173,7 @@ func workloadSecFindings(r *Report) []Finding { findings = append(findings, Finding{ Rule: "workload-security/run-as-root", Title: fmt.Sprintf("%s has %d container(s) explicitly running as root (%.0f%%)", cluster, rootCount, pct), - Description: "These containers have runAsUser=0 or runAsNonRoot=false set explicitly. Container escape vulnerabilities have higher impact when the workload is root.", + Description: "These containers have an effective runAsUser of 0, set on the container or inherited from the pod. Container escape vulnerabilities have higher impact when the workload is root.", Severity: sev, Cluster: cluster, Scanner: "workload-security", diff --git a/internal/report/findings_test.go b/internal/report/findings_test.go index 3a68164..bafb281 100644 --- a/internal/report/findings_test.go +++ b/internal/report/findings_test.go @@ -96,13 +96,15 @@ func TestFindingGenerators(t *testing.T) { //nolint:funlen // Test function. "unenforced_count": 6, "namespace_count": 10, "namespaces": []any{ map[string]any{"namespace": "dev"}, map[string]any{"namespace": "qa"}, + map[string]any{"namespace": "kube-system", "enforce": "privileged"}, map[string]any{"namespace": "prod", "enforce": "restricted"}, }, }}}), WantResult: []Finding{{ Rule: "security/psa-unenforced", Title: "a has 6 namespace(s) without Pod Security enforcement", - Description: "6 of 10 namespaces (60%) have no Pod Security Standards enforce label. Pods in these namespaces can run with any privileges.", - Severity: SeverityCritical, Cluster: "a", Scanner: "security", Affected: []string{"dev", "qa"}, + Description: "6 of 10 namespaces (60%) have no Pod Security Standards enforce label or enforce only privileged. Pods in these namespaces can run with any privileges.", + Severity: SeverityCritical, Cluster: "a", Scanner: "security", + Affected: []string{"dev", "qa", "kube-system"}, Remediation: &Remediation{Command: "kubectl --context a label namespace dev qa pod-security.kubernetes.io/enforce=baseline --overwrite"}, }}, }, { // Test 3: Exactly half unenforced stays a warning. @@ -117,7 +119,7 @@ func TestFindingGenerators(t *testing.T) { //nolint:funlen // Test function. }}}), WantResult: []Finding{{ Rule: "security/psa-unenforced", Title: "a has 5 namespace(s) without Pod Security enforcement", - Description: "5 of 10 namespaces (50%) have no Pod Security Standards enforce label. Pods in these namespaces can run with any privileges.", + Description: "5 of 10 namespaces (50%) have no Pod Security Standards enforce label or enforce only privileged. Pods in these namespaces can run with any privileges.", Severity: SeverityWarning, Cluster: "a", Scanner: "security", Affected: []string{"dev", "qa"}, Remediation: &Remediation{Command: "kubectl --context a label namespace dev qa pod-security.kubernetes.io/enforce=baseline --overwrite"}, }}, @@ -129,7 +131,7 @@ func TestFindingGenerators(t *testing.T) { //nolint:funlen // Test function. }}}), WantResult: []Finding{{ Rule: "security/psa-unenforced", Title: "a has 4 namespace(s) without Pod Security enforcement", - Description: "4 namespaces have no Pod Security Standards enforce label. Pods in these namespaces can run with any privileges.", + Description: "4 namespaces have no Pod Security Standards enforce label or enforce only privileged. Pods in these namespaces can run with any privileges.", Severity: SeverityWarning, Cluster: "a", Scanner: "security", Remediation: &Remediation{Command: "kubectl --context a label namespace pod-security.kubernetes.io/enforce=baseline --overwrite"}, }}, @@ -222,7 +224,7 @@ func TestFindingGenerators(t *testing.T) { //nolint:funlen // Test function. Severity: SeverityWarning, Cluster: "a", Scanner: "workload-security", Affected: []string{"ns/p/c"}, }, { Rule: "workload-security/run-as-root", Title: "a has 6 container(s) explicitly running as root (60%)", - Description: "These containers have runAsUser=0 or runAsNonRoot=false set explicitly. Container escape vulnerabilities have higher impact when the workload is root.", + Description: "These containers have an effective runAsUser of 0, set on the container or inherited from the pod. Container escape vulnerabilities have higher impact when the workload is root.", Severity: SeverityWarning, Cluster: "a", Scanner: "workload-security", }, { Rule: "workload-security/capability-add", Title: "a has 1 container(s) with added Linux capabilities", @@ -521,7 +523,8 @@ func TestFindingGenerators(t *testing.T) { //nolint:funlen // Test function. }}}), WantResult: []Finding{{ Rule: "security/psa-unenforced", Title: "a has 12 namespace(s) without Pod Security enforcement", - Description: "12 of 12 namespaces (100%) have no Pod Security Standards enforce label. " + + Description: "12 of 12 namespaces (100%) have no Pod Security Standards enforce label " + + "or enforce only privileged. " + "Pods in these namespaces can run with any privileges.", Severity: SeverityCritical, Cluster: "a", Scanner: "security", Affected: []string{ diff --git a/internal/scanner/security/security.go b/internal/scanner/security/security.go index cb26cd2..cb4ffe2 100644 --- a/internal/scanner/security/security.go +++ b/internal/scanner/security/security.go @@ -2,6 +2,7 @@ package security import ( + "cmp" "context" "sort" @@ -24,6 +25,9 @@ const ( labelWarnVer = "pod-security.kubernetes.io/warn-version" ) +// levelPrivileged is the PSS level that enforces nothing. +const levelPrivileged = "privileged" + // NamespaceSecurity describes the Pod Security Standards labels on a namespace. type NamespaceSecurity struct { // Namespace is the namespace name. @@ -46,11 +50,12 @@ type NamespaceSecurity struct { type Data struct { // NamespaceCount is the total number of namespaces. NamespaceCount int `json:"namespace_count"` - // EnforcedCount is the number of namespaces with a PSS enforce label. + // EnforcedCount is the number of namespaces with a PSS enforce label above privileged. EnforcedCount int `json:"enforced_count"` - // UnenorcedCount is the number of namespaces without a PSS enforce label. + // UnenforcedCount is the number of namespaces with no PSS enforce label or with enforce + // privileged, which enforces nothing. UnenforcedCount int `json:"unenforced_count"` - // LevelDistribution maps PSS levels to namespace counts. + // LevelDistribution maps PSS enforce levels to namespace counts, with "none" for unlabeled. LevelDistribution map[string]int `json:"level_distribution"` // Namespaces lists per-namespace security details. Namespaces []NamespaceSecurity `json:"namespaces"` @@ -81,13 +86,12 @@ func NewScanner() scanner.Scanner { Warn: ns.Labels[labelWarn], WarnVersion: ns.Labels[labelWarnVer], } - if sec.Enforce != "" { + if Enforced(sec.Enforce) { data.EnforcedCount++ - data.LevelDistribution[sec.Enforce]++ } else { data.UnenforcedCount++ - data.LevelDistribution["none"]++ } + data.LevelDistribution[cmp.Or(sec.Enforce, "none")]++ data.Namespaces = append(data.Namespaces, sec) } @@ -98,3 +102,9 @@ func NewScanner() scanner.Scanner { return cov.Finish(Name, data) }) } + +// Enforced reports whether a PSS enforce level restricts pods. An empty level and privileged +// both enforce nothing. +func Enforced(level string) bool { + return level != "" && level != levelPrivileged +} diff --git a/internal/scanner/security/security_test.go b/internal/scanner/security/security_test.go index 7c9c2c6..e6b69a7 100644 --- a/internal/scanner/security/security_test.go +++ b/internal/scanner/security/security_test.go @@ -19,45 +19,63 @@ import ( "github.com/dcadolph/fleetsweeper/internal/scanner" ) +// testNamespace returns a namespace named name whose PSS enforce label is enforce, or unlabeled +// when enforce is empty. +func testNamespace(name, enforce string) *corev1.Namespace { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}} + if enforce != "" { + ns.Labels = map[string]string{"pod-security.kubernetes.io/enforce": enforce} + } + return ns +} + +// TestNewScanner checks the enforced and unenforced counts and the level distribution. func TestNewScanner(t *testing.T) { t.Parallel() - - enforced := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "prod", - Labels: map[string]string{ - "pod-security.kubernetes.io/enforce": "restricted", - }, + tests := []struct { + WantLevels map[string]int + Namespaces []runtime.Object + WantCount int + WantEnforced int + WantUnenforced int + }{{ // Test 0: One restricted and one unlabeled namespace. + Namespaces: []runtime.Object{testNamespace("prod", "restricted"), testNamespace("dev", "")}, + WantCount: 2, + WantEnforced: 1, + WantUnenforced: 1, + WantLevels: map[string]int{"restricted": 1, "none": 1}, + }, { // Test 1: Enforce privileged enforces nothing, so it counts as unenforced. + Namespaces: []runtime.Object{ + testNamespace("prod", "baseline"), + testNamespace("kube-system", "privileged"), }, - } - unenforced := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{Name: "dev"}, - } - - cs := fakeclientset.NewSimpleClientset(enforced, unenforced) - client := kube.NewTestClientWithClientset("test", cs) - - result, err := NewScanner().Scan(context.Background(), client) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - data, ok := result.Data.(Data) - if !ok { - t.Fatalf("expected Data type, got %T", result.Data) - } - - if diff := cmp.Diff(2, data.NamespaceCount); diff != "" { - t.Errorf("namespace count mismatch (-want +got):\n%s", diff) - } - if diff := cmp.Diff(1, data.EnforcedCount); diff != "" { - t.Errorf("enforced count mismatch (-want +got):\n%s", diff) - } - if diff := cmp.Diff(1, data.UnenforcedCount); diff != "" { - t.Errorf("unenforced count mismatch (-want +got):\n%s", diff) - } - if diff := cmp.Diff(1, data.LevelDistribution["restricted"]); diff != "" { - t.Errorf("restricted count mismatch (-want +got):\n%s", diff) + WantCount: 2, + WantEnforced: 1, + WantUnenforced: 1, + WantLevels: map[string]int{"baseline": 1, "privileged": 1}, + }} + for testNum, test := range tests { + t.Run(fmt.Sprintf("test %d", testNum), func(t *testing.T) { + t.Parallel() + cs := fakeclientset.NewSimpleClientset(test.Namespaces...) + client := kube.NewTestClientWithClientset("test", cs) + result, err := NewScanner().Scan(context.Background(), client) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, ok := result.Data.(Data) + if !ok { + t.Fatalf("expected Data type, got %T", result.Data) + } + got := []int{data.NamespaceCount, data.EnforcedCount, data.UnenforcedCount} + want := []int{test.WantCount, test.WantEnforced, test.WantUnenforced} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("namespace, enforced, unenforced counts mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(test.WantLevels, data.LevelDistribution); diff != "" { + t.Errorf("level distribution mismatch (-want +got):\n%s", diff) + } + }) } } diff --git a/internal/scanner/workloadsec/workloadsec.go b/internal/scanner/workloadsec/workloadsec.go index f93771e..100e1fa 100644 --- a/internal/scanner/workloadsec/workloadsec.go +++ b/internal/scanner/workloadsec/workloadsec.go @@ -10,24 +10,13 @@ import ( corev1 "k8s.io/api/core/v1" "github.com/dcadolph/fleetsweeper/internal/kube" + "github.com/dcadolph/fleetsweeper/internal/podsec" "github.com/dcadolph/fleetsweeper/internal/scanner" ) // Name is the registry key for this scanner. const Name = "workload-security" -// Root status values describe what we know about a container's identity. -const ( - // RootStatusRoot means the container is explicitly configured to run as UID 0. - RootStatusRoot = "root" - // RootStatusNonRoot means the container is configured to run as a non-root user. - RootStatusNonRoot = "non-root" - // RootStatusUnknown means neither the pod nor the container specifies a user. - // We cannot tell without inspecting the image, so we report this honestly - // rather than defaulting to "root" (which produced enormous false positives). - RootStatusUnknown = "unknown" -) - // PodRisk describes a pod with a security concern. type PodRisk struct { // Namespace is the pod's namespace. @@ -44,7 +33,7 @@ type PodRisk struct { type Data struct { // TotalPods is the number of pods scanned. TotalPods int `json:"total_pods"` - // PrivilegedContainers is containers running with privileged=true. + // PrivilegedContainers is containers, ephemeral included, running with privileged=true. PrivilegedContainers int `json:"privileged_containers"` // HostNetworkPods is pods using the host network namespace. HostNetworkPods int `json:"host_network_pods"` @@ -54,28 +43,36 @@ type Data struct { HostIPCPods int `json:"host_ipc_pods"` // HostPathVolumes is pods that mount a hostPath volume. HostPathVolumes int `json:"host_path_volumes"` - // RunAsRootContainers is containers explicitly configured to run as UID 0. + // BaselineContainers is the init and regular containers, the denominator of every count + // that feeds the admission baseline. Ephemeral containers are left out, since admission + // never sees them. + BaselineContainers int `json:"baseline_containers"` + // RunAsRootContainers is baseline containers whose effective runAsUser is 0. RunAsRootContainers int `json:"run_as_root_containers"` - // UnknownRootContainers is containers without explicit user configuration. - // These may or may not run as root depending on the image; flag them as - // "unknown" so operators can audit selectively rather than treating every - // undeclared workload as a violation. - UnknownRootContainers int `json:"unknown_root_containers"` - // CapabilityAdditions is containers that add Linux capabilities beyond the default set. + // RunAsNonRootContainers is baseline containers explicitly non-root, by an effective + // runAsUser above 0, or an unset one under an effective runAsNonRoot of true. + RunAsNonRootContainers int `json:"run_as_non_root_containers"` + // RunAsUnknownContainers is baseline containers whose spec does not settle the user, so + // only the image can. + RunAsUnknownContainers int `json:"run_as_unknown_containers"` + // CapabilityAdditions is containers, ephemeral included, that add Linux capabilities beyond + // the default set. CapabilityAdditions int `json:"capability_additions"` - // MissingCapabilityDropAll is containers that do not drop ALL capabilities. + // MissingCapabilityDropAll is containers, ephemeral included, that do not drop ALL + // capabilities. MissingCapabilityDropAll int `json:"missing_capability_drop_all"` - // AllowPrivilegeEscalation is containers that do not explicitly set - // allowPrivilegeEscalation=false (the field defaults to true, which is - // the PSS-restricted defining check). + // AllowPrivilegeEscalation is baseline containers that may gain privileges, by an unset or + // true allowPrivilegeEscalation, privileged, or an added CAP_SYS_ADMIN. AllowPrivilegeEscalation int `json:"allow_privilege_escalation"` - // MissingSeccompProfile is containers/pods without an explicit seccomp profile. + // MissingSeccompProfile is containers, ephemeral included, not confined by a RuntimeDefault + // or Localhost seccomp profile. MissingSeccompProfile int `json:"missing_seccomp_profile"` - // NoReadOnlyRoot is containers without a read-only root filesystem. + // NoReadOnlyRoot is baseline containers without a read-only root filesystem. NoReadOnlyRoot int `json:"no_read_only_root"` // DefaultServiceAccountPods is pods using the namespace's default ServiceAccount. DefaultServiceAccountPods int `json:"default_service_account_pods"` - // HighRiskPods lists pods with the most concerning security postures. + // HighRiskPods lists the containers, ephemeral included, with the most concerning security + // postures. HighRiskPods []PodRisk `json:"high_risk_pods"` } @@ -112,42 +109,54 @@ func NewScanner() scanner.Scanner { data.DefaultServiceAccountPods++ } - containers := allContainers(pod) - for _, c := range containers { + baseline := len(pod.Spec.InitContainers) + len(pod.Spec.Containers) + data.BaselineContainers += baseline + for j, c := range podsec.Containers(pod, true) { var risks []string sc := c.SecurityContext + inBaseline := j < baseline if sc != nil && sc.Privileged != nil && *sc.Privileged { data.PrivilegedContainers++ risks = append(risks, "privileged") } - switch rootStatus(pod, sc) { - case RootStatusRoot: - data.RunAsRootContainers++ + status := podsec.RootStatus(pod, c) + if status == podsec.Root { risks = append(risks, "runs-as-root") - case RootStatusUnknown: - data.UnknownRootContainers++ + } + if inBaseline { + switch status { + case podsec.Root: + data.RunAsRootContainers++ + case podsec.NonRoot: + data.RunAsNonRootContainers++ + default: + data.RunAsUnknownContainers++ + } } if sc != nil && sc.Capabilities != nil && len(sc.Capabilities.Add) > 0 { data.CapabilityAdditions++ risks = append(risks, fmt.Sprintf("capabilities-add:%v", sc.Capabilities.Add)) } - if !dropsAllCapabilities(sc) { + if !podsec.DropsAllCapabilities(c) { data.MissingCapabilityDropAll++ } - if allowsPrivilegeEscalation(sc) { - data.AllowPrivilegeEscalation++ + escalates := podsec.AllowsPrivilegeEscalation(c) + if escalates { + if inBaseline { + data.AllowPrivilegeEscalation++ + } risks = append(risks, "allow-privilege-escalation") } - if !hasSeccompProfile(pod, sc) { + if !podsec.SeccompConfined(pod, c) { data.MissingSeccompProfile++ } - - readOnly := sc != nil && sc.ReadOnlyRootFilesystem != nil && *sc.ReadOnlyRootFilesystem - if !readOnly { - data.NoReadOnlyRoot++ + if !podsec.ReadOnlyRootFS(c) { + if inBaseline { + data.NoReadOnlyRoot++ + } risks = append(risks, "writable-root-fs") } @@ -164,7 +173,7 @@ func NewScanner() scanner.Scanner { isHighRisk := (sc != nil && sc.Privileged != nil && *sc.Privileged) || hostNet || hostPID || hostIPC || (sc != nil && sc.Capabilities != nil && len(sc.Capabilities.Add) > 0) || - allowsPrivilegeEscalation(sc) + escalates if isHighRisk { data.HighRiskPods = append(data.HighRiskPods, PodRisk{ Namespace: pod.Namespace, @@ -182,103 +191,6 @@ func NewScanner() scanner.Scanner { }) } -// allContainers returns every container that can run in the pod: init, -// regular, and ephemeral. Ephemeral containers are included because a -// running pod can carry kubectl-debug containers that admission never saw. -func allContainers(pod *corev1.Pod) []corev1.Container { - out := make([]corev1.Container, 0, len(pod.Spec.InitContainers)+len(pod.Spec.Containers)+len(pod.Spec.EphemeralContainers)) - out = append(out, pod.Spec.InitContainers...) - out = append(out, pod.Spec.Containers...) - for _, ec := range pod.Spec.EphemeralContainers { - out = append(out, corev1.Container{ - Name: ec.Name, - Image: ec.Image, - Command: ec.Command, - Args: ec.Args, - SecurityContext: ec.SecurityContext, - }) - } - return out -} - -// rootStatus classifies a container's effective user. We prefer explicit -// pod-level then container-level signals; only when neither is present do we -// return Unknown rather than guess. -func rootStatus(pod *corev1.Pod, sc *corev1.SecurityContext) string { - if sc != nil && sc.RunAsUser != nil { - if *sc.RunAsUser == 0 { - return RootStatusRoot - } - return RootStatusNonRoot - } - if sc != nil && sc.RunAsNonRoot != nil { - if *sc.RunAsNonRoot { - return RootStatusNonRoot - } - return RootStatusRoot - } - if pod.Spec.SecurityContext != nil { - if pod.Spec.SecurityContext.RunAsUser != nil { - if *pod.Spec.SecurityContext.RunAsUser == 0 { - return RootStatusRoot - } - return RootStatusNonRoot - } - if pod.Spec.SecurityContext.RunAsNonRoot != nil { - if *pod.Spec.SecurityContext.RunAsNonRoot { - return RootStatusNonRoot - } - return RootStatusRoot - } - } - return RootStatusUnknown -} - -// allowsPrivilegeEscalation reports whether the container allows escalation. -// The field defaults to true when unset, which is the PSS-restricted -// defining check. Setting privileged=true forces the field to true as well. -func allowsPrivilegeEscalation(sc *corev1.SecurityContext) bool { - if sc == nil { - return true - } - if sc.Privileged != nil && *sc.Privileged { - return true - } - if sc.AllowPrivilegeEscalation == nil { - return true - } - return *sc.AllowPrivilegeEscalation -} - -// dropsAllCapabilities reports whether the container drops ALL capabilities. -// The PSS-baseline standard requires this explicit drop. -func dropsAllCapabilities(sc *corev1.SecurityContext) bool { - if sc == nil || sc.Capabilities == nil { - return false - } - for _, drop := range sc.Capabilities.Drop { - if drop == "ALL" || drop == "all" { - return true - } - } - return false -} - -// hasSeccompProfile reports whether either the pod or container has an -// explicit seccomp profile set. RuntimeDefault and Localhost both satisfy -// PSS-baseline. -func hasSeccompProfile(pod *corev1.Pod, sc *corev1.SecurityContext) bool { - if sc != nil && sc.SeccompProfile != nil && sc.SeccompProfile.Type != "" && sc.SeccompProfile.Type != corev1.SeccompProfileTypeUnconfined { - return true - } - if pod.Spec.SecurityContext != nil && pod.Spec.SecurityContext.SeccompProfile != nil && - pod.Spec.SecurityContext.SeccompProfile.Type != "" && - pod.Spec.SecurityContext.SeccompProfile.Type != corev1.SeccompProfileTypeUnconfined { - return true - } - return false -} - // hasHostPath reports whether the pod mounts a hostPath volume. func hasHostPath(pod *corev1.Pod) bool { for _, v := range pod.Spec.Volumes { diff --git a/internal/scanner/workloadsec/workloadsec_test.go b/internal/scanner/workloadsec/workloadsec_test.go index 4029283..d9f4b75 100644 --- a/internal/scanner/workloadsec/workloadsec_test.go +++ b/internal/scanner/workloadsec/workloadsec_test.go @@ -28,11 +28,11 @@ func boolPtr(b bool) *bool { return &b } // int64Ptr returns a pointer to n. func int64Ptr(n int64) *int64 { return &n } -// lockedPod builds a pod whose single container trips no security checks, so +// lockedPod builds a pod in namespace "a" whose single container trips no security checks, so // the supplied pod-level security context alone drives the root-status result. -func lockedPod(namespace, name string, podSC *corev1.PodSecurityContext) *corev1.Pod { +func lockedPod(name string, podSC *corev1.PodSecurityContext) *corev1.Pod { return &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, + ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: name}, Spec: corev1.PodSpec{ ServiceAccountName: "svc", SecurityContext: podSC, @@ -51,6 +51,20 @@ func lockedPod(namespace, name string, podSC *corev1.PodSecurityContext) *corev1 } } +// lockedContainerPod builds a pod whose single container trips no capability, escalation, or +// filesystem check, so the given pod and container security contexts alone drive the root and +// seccomp results. +func lockedContainerPod(name string, podSC *corev1.PodSecurityContext, sc *corev1.SecurityContext) *corev1.Pod { + pod := lockedPod(name, podSC) + locked := pod.Spec.Containers[0].SecurityContext + locked.RunAsUser, locked.RunAsNonRoot = sc.RunAsUser, sc.RunAsNonRoot + if sc.SeccompProfile != nil { + locked.SeccompProfile = sc.SeccompProfile + } + return pod +} + +// TestNewScanner checks the counts and the risky pod list the scanner builds from pods. func TestNewScanner(t *testing.T) { t.Parallel() @@ -80,7 +94,7 @@ func TestNewScanner(t *testing.T) { }}, }, }}, - WantData: Data{TotalPods: 1}, + WantData: Data{TotalPods: 1, BaselineContainers: 1, RunAsNonRootContainers: 1}, }, { // Test 2: A privileged host pod trips every check and is high risk. Pods: []*corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{Namespace: "prod", Name: "privileged"}, @@ -104,6 +118,7 @@ func TestNewScanner(t *testing.T) { }}, WantData: Data{ TotalPods: 1, + BaselineContainers: 1, PrivilegedContainers: 1, HostNetworkPods: 1, HostPIDPods: 1, @@ -142,7 +157,8 @@ func TestNewScanner(t *testing.T) { }}, WantData: Data{ TotalPods: 1, - UnknownRootContainers: 1, + BaselineContainers: 1, + RunAsUnknownContainers: 1, MissingCapabilityDropAll: 1, AllowPrivilegeEscalation: 1, MissingSeccompProfile: 1, @@ -155,7 +171,7 @@ func TestNewScanner(t *testing.T) { Risks: []string{"allow-privilege-escalation", "writable-root-fs"}, }}, }, - }, { // Test 4: Container run-as-user and an ephemeral container, pod-level seccomp. + }, { // Test 4: An ephemeral container is in the risk list but not the baseline counts. Pods: []*corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "nonroot"}, Spec: corev1.PodSpec{ @@ -179,15 +195,16 @@ func TestNewScanner(t *testing.T) { }}, WantData: Data{ TotalPods: 1, - RunAsRootContainers: 1, + BaselineContainers: 1, + RunAsNonRootContainers: 1, MissingCapabilityDropAll: 2, - AllowPrivilegeEscalation: 2, - NoReadOnlyRoot: 2, + AllowPrivilegeEscalation: 1, + NoReadOnlyRoot: 1, HighRiskPods: []PodRisk{{ Namespace: "app", Pod: "nonroot", Container: "debug", - Risks: []string{"runs-as-root", "allow-privilege-escalation", "writable-root-fs"}, + Risks: []string{"allow-privilege-escalation", "writable-root-fs"}, }, { Namespace: "app", Pod: "nonroot", @@ -216,17 +233,45 @@ func TestNewScanner(t *testing.T) { }}, WantData: Data{ TotalPods: 1, + BaselineContainers: 1, RunAsRootContainers: 1, }, - }, { // Test 6: Pod-level user settings drive root status for clean containers. + }, { // Test 6: Pod-level user settings drive root status, and runAsNonRoot false is unknown. Pods: []*corev1.Pod{ - lockedPod("a", "pod-nonroot-user", &corev1.PodSecurityContext{RunAsUser: int64Ptr(2000)}), - lockedPod("a", "pod-nonroot-flag", &corev1.PodSecurityContext{RunAsNonRoot: boolPtr(true)}), - lockedPod("a", "pod-root-flag", &corev1.PodSecurityContext{RunAsNonRoot: boolPtr(false)}), + lockedPod("pod-nonroot-user", &corev1.PodSecurityContext{RunAsUser: int64Ptr(2000)}), + lockedPod("pod-nonroot-flag", &corev1.PodSecurityContext{RunAsNonRoot: boolPtr(true)}), + lockedPod("pod-root-flag", &corev1.PodSecurityContext{RunAsNonRoot: boolPtr(false)}), }, WantData: Data{ - TotalPods: 3, - RunAsRootContainers: 1, + TotalPods: 3, + BaselineContainers: 3, + RunAsNonRootContainers: 2, + RunAsUnknownContainers: 1, + }, + }, { // Test 7: Container settings win over pod settings for root status and seccomp. + Pods: []*corev1.Pod{ + lockedContainerPod("nonroot-under-pod-root", + &corev1.PodSecurityContext{RunAsUser: int64Ptr(0)}, + &corev1.SecurityContext{RunAsNonRoot: boolPtr(true)}), + lockedContainerPod("false-under-pod-true", + &corev1.PodSecurityContext{RunAsNonRoot: boolPtr(true)}, + &corev1.SecurityContext{RunAsNonRoot: boolPtr(false)}), + lockedContainerPod("unconfined-under-pod-default", + &corev1.PodSecurityContext{ + RunAsUser: int64Ptr(1000), + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}, + }, + &corev1.SecurityContext{ + SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeUnconfined}, + }), + }, + WantData: Data{ + TotalPods: 3, + BaselineContainers: 3, + RunAsRootContainers: 1, + RunAsNonRootContainers: 1, + RunAsUnknownContainers: 1, + MissingSeccompProfile: 1, }, }} diff --git a/internal/server/static/js/glossary.js b/internal/server/static/js/glossary.js index 27b7a46..2849ec6 100644 --- a/internal/server/static/js/glossary.js +++ b/internal/server/static/js/glossary.js @@ -28,7 +28,10 @@ export const TIPS = { 'privileged_containers':'Containers running with privileged=true. Full host kernel access.', 'host_network_pods':'Pods sharing the host network namespace. Can see all node traffic.', 'host_pid_pods':'Pods sharing the host PID namespace. Can see and signal all node processes.', - 'run_as_root_containers':'Containers that may run as UID 0. Missing runAsNonRoot constraint.', + 'run_as_root_containers':'Init and regular containers whose effective runAsUser is 0, set on the container or inherited from the pod.', + 'run_as_non_root_containers':'Init and regular containers explicitly non-root: an effective runAsUser above 0, or an unset one under an effective runAsNonRoot of true.', + 'run_as_unknown_containers':'Init and regular containers whose spec does not settle the user, so only the image decides. Counted as not non-root.', + 'baseline_containers':'Init and regular containers scanned. Ephemeral debug containers are left out, since admission never sees them.', 'capability_additions':'Containers requesting Linux capabilities beyond the default set (NET_ADMIN, SYS_PTRACE, etc.).', 'cluster_admin_bindings':'ClusterRoleBindings granting cluster-admin to non-system accounts. Unrestricted cluster access.', 'wildcard_rules':'RBAC rules using * for verbs or resources. Broader than most workloads need.', @@ -48,8 +51,8 @@ export const TIPS = { 'disk_pressure_nodes':'Nodes where kubelet reports DiskPressure=True. Disk space is critically low.', 'not_ready_nodes':'Nodes not reporting Ready=True. Workloads cannot be scheduled here.', 'unschedulable_nodes':'Nodes that have been cordoned (spec.unschedulable=true). No new pods will be scheduled.', - 'enforced_count':'Namespaces with a pod-security.kubernetes.io/enforce label set (restricted, baseline, or privileged).', - 'unenforced_count':'Namespaces missing the PSS enforce label. Pods can run with any security context.', + 'enforced_count':'Namespaces whose pod-security.kubernetes.io/enforce label is restricted or baseline.', + 'unenforced_count':'Namespaces missing the PSS enforce label or set to privileged, which enforces nothing. Pods can run with any security context.', 'namespaces_with_policies':'Namespaces that have at least one NetworkPolicy resource.', 'namespaces_without_policies':'Namespaces with zero NetworkPolicy resources. All pod-to-pod traffic is unrestricted.', 'warning_events':'Number of Kubernetes events with type=Warning in this cluster.',