Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/cmd_scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
23 changes: 16 additions & 7 deletions docs/operator/admission-webhook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 19 additions & 18 deletions internal/admission/baseline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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{
Expand Down Expand Up @@ -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
Expand All @@ -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"`
Expand All @@ -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,
Expand Down
83 changes: 66 additions & 17 deletions internal/admission/baseline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
},
},
},
Expand Down Expand Up @@ -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.
Expand All @@ -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),
},
},
},
Expand All @@ -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)),
},
},
},
Expand All @@ -338,32 +342,77 @@ 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),
},
},
},
WantBaseline: Baseline{
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) {
Expand Down
Loading
Loading