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
4 changes: 3 additions & 1 deletion central/policy/matcher/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ func (m *clusterMatcher) anyExclusionMatches(exclusions []*storage.Exclusion) bo
}

func (m *clusterMatcher) exclusionMatches(exclusion *storage.Exclusion) bool {
if !appliesToDeployments(exclusion) {
// A kind exclusion does not take the policy off the cluster. Image-only exclusions have no
// deployment scope, and a nil scope would otherwise match every cluster.
if exclusion.GetDeployment() == nil {
return false
}

Expand Down
5 changes: 5 additions & 0 deletions central/policy/matcher/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ func (m *deploymentMatcher) exclusionMatches(ctx context.Context, exclusion *sto
if !appliesToDeployments(exclusion) {
return false
}
// Kind exclusions have no deployment scope. Falling through would treat the nil scope as
// matching every deployment.
if exclusion.GetExcludeByKind() != nil {
return kindExclusionMatches(exclusion, m.deployment.GetType())
}

// If excluded scope does not match the deployment then no need to check for deployment name
if !m.scopeMatches(ctx, exclusion.GetDeployment().GetScope()) {
Expand Down
32 changes: 28 additions & 4 deletions central/policy/matcher/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"

"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/features"
"github.com/stackrox/rox/pkg/kubernetes"
)

// Matcher provides functionality to evaluate whether or not policies are applicable to an entity.
Expand All @@ -12,9 +14,31 @@ type Matcher interface {
IsPolicyApplicable(ctx context.Context, policy *storage.Policy) bool
}

// appliesToDeployments reports whether the exclusion has a deployment part. Image-only exclusions
// are applied to images, so they must not exclude deployments, namespaces, or clusters. Without this
// check, the nil deployment scope matches everything and the policy looks inapplicable everywhere.
// appliesToDeployments reports whether the exclusion can take a deployment out of policy scope.
// A deployment name/scope exclusion and a workload-kind exclusion both can. An image-only exclusion
// cannot: its nil deployment scope matches every deployment and would make the policy look
// inapplicable everywhere.
func appliesToDeployments(exclusion *storage.Exclusion) bool {
return exclusion.GetDeployment() != nil
return exclusion.GetDeployment() != nil || exclusion.GetExcludeByKind() != nil
}

// kindExclusionMatches reports whether a kind exclusion excludes this deployment.
// The feature flag gates it. A disabled flag leaves the policy applicable, matching detection.
func kindExclusionMatches(exclusion *storage.Exclusion, deploymentType string) bool {
Comment thread
c-du marked this conversation as resolved.
if !features.PolicyWorkloadKindExclusion.Enabled() {
return false
}
for _, kind := range exclusion.GetExcludeByKind().GetKinds() {
switch kind {
case storage.Exclusion_CRON_JOB:
if deploymentType == kubernetes.CronJob {
return true
}
case storage.Exclusion_JOB:
if deploymentType == kubernetes.Job {
return true
}
}
}
return false
}
41 changes: 41 additions & 0 deletions central/policy/matcher/matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"testing"

"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/features"
"github.com/stackrox/rox/pkg/kubernetes"
"github.com/stackrox/rox/pkg/testutils"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -36,3 +39,41 @@ func TestImageOnlyExclusionDoesNotExcludeEntities(t *testing.T) {
})
}
}

func TestKindExclusionExcludesMatchingDeploymentsOnly(t *testing.T) {
testutils.MustUpdateFeature(t, features.PolicyWorkloadKindExclusion, true)
ctx := context.Background()
namespace := &storage.NamespaceMetadata{Name: "shop", ClusterId: "cluster1"}
cluster := &storage.Cluster{Id: "cluster1"}
policy := &storage.Policy{Exclusions: []*storage.Exclusion{{
Matcher: &storage.Exclusion_ExcludeByKind_{
ExcludeByKind: &storage.Exclusion_ExcludeByKind{
Kinds: []storage.Exclusion_WorkloadKind{storage.Exclusion_JOB, storage.Exclusion_CRON_JOB},
},
},
}}}

job := &storage.Deployment{Name: "batch", Type: kubernetes.Job, ClusterId: "cluster1", Namespace: "shop"}
cronJob := &storage.Deployment{Name: "nightly", Type: kubernetes.CronJob, ClusterId: "cluster1", Namespace: "shop"}
deploy := &storage.Deployment{Name: "web", Type: kubernetes.Deployment, ClusterId: "cluster1", Namespace: "shop"}

assert.False(t, NewDeploymentMatcher(job, nil, nil).IsPolicyApplicable(ctx, policy))
assert.False(t, NewDeploymentMatcher(cronJob, nil, nil).IsPolicyApplicable(ctx, policy))
assert.True(t, NewDeploymentMatcher(deploy, nil, nil).IsPolicyApplicable(ctx, policy))
assert.True(t, NewNamespaceMatcher(namespace).IsPolicyApplicable(ctx, policy))
assert.True(t, NewClusterMatcher(cluster, []*storage.NamespaceMetadata{namespace}).IsPolicyApplicable(ctx, policy))
}

func TestKindExclusionFlagOffLeavesPolicyApplicable(t *testing.T) {
testutils.MustUpdateFeature(t, features.PolicyWorkloadKindExclusion, false)
job := &storage.Deployment{Name: "batch", Type: kubernetes.Job, ClusterId: "cluster1", Namespace: "shop"}
policy := &storage.Policy{Exclusions: []*storage.Exclusion{{
Matcher: &storage.Exclusion_ExcludeByKind_{
ExcludeByKind: &storage.Exclusion_ExcludeByKind{
Kinds: []storage.Exclusion_WorkloadKind{storage.Exclusion_JOB},
},
},
}}}

assert.True(t, NewDeploymentMatcher(job, nil, nil).IsPolicyApplicable(context.Background(), policy))
}
8 changes: 7 additions & 1 deletion central/policy/matcher/namespace.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ func (m *namespaceMatcher) anyExclusionMatches(exclusions []*storage.Exclusion)
}

func (m *namespaceMatcher) exclusionMatches(exclusion *storage.Exclusion) bool {
return appliesToDeployments(exclusion) && m.scopeMatches(exclusion.GetDeployment().GetScope())
// A kind exclusion does not take the policy off the namespace: other workload kinds there
// are still in scope. Image-only exclusions have no deployment scope, and a nil scope would
// otherwise match every namespace.
if exclusion.GetDeployment() == nil {
return false
}
return m.scopeMatches(exclusion.GetDeployment().GetScope())
}

func (m *namespaceMatcher) anyScopeMatches(scopes []*storage.Scope) bool {
Expand Down
29 changes: 28 additions & 1 deletion central/policy/service/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ func (s *policyValidator) validateExclusions(policy *storage.Policy) error {
}

func (s *policyValidator) validateExclusion(policy *storage.Policy, exclusion *storage.Exclusion) error {
if exclusion.GetDeployment() == nil && exclusion.GetImage() == nil {
if exclusion.GetDeployment() == nil && exclusion.GetImage() == nil && exclusion.GetExcludeByKind() == nil {
return errors.New("all excluded scopes must have some criteria to match on")
}
if exclusion.GetDeployment() != nil {
Expand All @@ -297,6 +297,17 @@ func (s *policyValidator) validateExclusion(policy *storage.Policy, exclusion *s
return err
}
}
if exclusion.GetExcludeByKind() != nil {
if !features.PolicyWorkloadKindExclusion.Enabled() {
return errors.New("exclude_by_kind requires feature flag ROX_POLICY_WORKLOAD_KIND_EXCLUSION to be enabled")
}
if !policies.AppliesAtDeployTime(policy) && !policies.AppliesAtRunTime(policy) {
return errors.New("excluding by workload kind is only valid during the DEPLOY and RUNTIME lifecycles")
}
if err := validateExcludeByKind(exclusion.GetExcludeByKind()); err != nil {
return err
}
}
if exclusion.GetImage() != nil {
if !policies.AppliesAtBuildTime(policy) {
return errors.New("excluding an image is only valid during the BUILD lifecycle")
Expand All @@ -308,6 +319,22 @@ func (s *policyValidator) validateExclusion(policy *storage.Policy, exclusion *s
return nil
}

func validateExcludeByKind(excludeByKind *storage.Exclusion_ExcludeByKind) error {
kinds := excludeByKind.GetKinds()
if len(kinds) == 0 {
return errors.New("exclude_by_kind must specify at least one workload kind")
}
for _, kind := range kinds {
if kind == storage.Exclusion_WORKLOAD_KIND_UNSPECIFIED {
return errors.New("exclude_by_kind cannot include WORKLOAD_KIND_UNSPECIFIED")
}
if kind != storage.Exclusion_CRON_JOB && kind != storage.Exclusion_JOB {
return errors.Errorf("exclude_by_kind contains unknown workload kind %s", kind)
}
}
return nil
}

func (s *policyValidator) validateDeploymentExclusion(exclusion *storage.Exclusion) error {
deployment := exclusion.GetDeployment()
if deployment.GetScope() == nil && deployment.GetName() == "" {
Expand Down
73 changes: 73 additions & 0 deletions central/policy/service/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,79 @@ func (s *PolicyValidatorTestSuite) TestValidateExclusions() {
s.NoError(s.validator.validateExclusions(policy))
}

func (s *PolicyValidatorTestSuite) TestValidateExcludeByKind() {
kindExclusion := func(kinds ...storage.Exclusion_WorkloadKind) *storage.Exclusion {
return &storage.Exclusion{
Matcher: &storage.Exclusion_ExcludeByKind_{
ExcludeByKind: &storage.Exclusion_ExcludeByKind{Kinds: kinds},
},
}
}
deployPolicy := func(exclusion *storage.Exclusion) *storage.Policy {
return &storage.Policy{
LifecycleStages: []storage.LifecycleStage{storage.LifecycleStage_DEPLOY},
Exclusions: []*storage.Exclusion{exclusion},
}
}

s.ErrorContains(s.validator.validateExclusions(deployPolicy(kindExclusion(storage.Exclusion_JOB))), "ROX_POLICY_WORKLOAD_KIND_EXCLUSION")

testutils.MustUpdateFeature(s.T(), features.PolicyWorkloadKindExclusion, true)

for name, tc := range map[string]struct {
policy *storage.Policy
errContains string
}{
"JOB is valid": {
policy: deployPolicy(kindExclusion(storage.Exclusion_JOB)),
},
"CRON_JOB is valid": {
policy: deployPolicy(kindExclusion(storage.Exclusion_CRON_JOB)),
},
"JOB and CRON_JOB are valid": {
policy: deployPolicy(kindExclusion(storage.Exclusion_CRON_JOB, storage.Exclusion_JOB)),
},
"empty kind list is rejected": {
policy: deployPolicy(kindExclusion()),
errContains: "at least one workload kind",
},
"UNSPECIFIED is rejected": {
policy: deployPolicy(kindExclusion(storage.Exclusion_WORKLOAD_KIND_UNSPECIFIED)),
errContains: "WORKLOAD_KIND_UNSPECIFIED",
},
"unknown kind is rejected": {
policy: deployPolicy(kindExclusion(storage.Exclusion_WorkloadKind(99))),
errContains: "unknown workload kind",
},
"mixed valid and unknown kinds are rejected": {
policy: deployPolicy(kindExclusion(storage.Exclusion_JOB, storage.Exclusion_WorkloadKind(99))),
errContains: "unknown workload kind",
},
"RUNTIME lifecycle is valid": {
policy: &storage.Policy{
LifecycleStages: []storage.LifecycleStage{storage.LifecycleStage_RUNTIME},
Exclusions: []*storage.Exclusion{kindExclusion(storage.Exclusion_JOB)},
},
},
"BUILD lifecycle is rejected": {
policy: &storage.Policy{
LifecycleStages: []storage.LifecycleStage{storage.LifecycleStage_BUILD},
Exclusions: []*storage.Exclusion{kindExclusion(storage.Exclusion_JOB)},
},
errContains: "DEPLOY and RUNTIME",
},
} {
s.T().Run(name, func(t *testing.T) {
err := s.validator.validateExclusions(tc.policy)
if tc.errContains == "" {
assert.NoError(t, err)
} else {
assert.ErrorContains(t, err, tc.errContains)
}
})
}
}

func (s *PolicyValidatorTestSuite) TestValidateExclusionRejectsLabels() {
for name, tc := range map[string]struct {
exclusion *storage.Exclusion
Expand Down
3 changes: 2 additions & 1 deletion config-controller/api/v1alpha1/policy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ type SecurityPolicySpec struct {
}

type Exclusion struct {
Name string `json:"name,omitempty"`
Name string `json:"name,omitempty"`
// +optional
Deployment Deployment `json:"deployment,omitempty"`
Image Image `json:"image,omitempty"`
// +optional
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
59 changes: 53 additions & 6 deletions pkg/detection/compiled_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ import (
"github.com/stackrox/rox/pkg/booleanpolicy/augmentedobjs"
"github.com/stackrox/rox/pkg/booleanpolicy/filter"
"github.com/stackrox/rox/pkg/booleanpolicy/policyfields"
"github.com/stackrox/rox/pkg/features"
"github.com/stackrox/rox/pkg/kubernetes"
"github.com/stackrox/rox/pkg/policies"
"github.com/stackrox/rox/pkg/regexutils"
"github.com/stackrox/rox/pkg/scopecomp"
"github.com/stackrox/rox/pkg/set"
)

// CompiledPolicy is a compiled policy, which means it can match a policy, as well as check whether a policy is applicable.
Expand Down Expand Up @@ -46,11 +49,14 @@ func newCompiledPolicy(policy *storage.Policy, clusterLabelProvider scopecomp.Cl

exclusions := make([]*compiledExclusion, 0, len(policy.GetExclusions()))
for _, w := range policy.GetExclusions() {
w, err := newCompiledExclusion(w)
if w.GetExcludeByKind() != nil && !features.PolicyWorkloadKindExclusion.Enabled() {
continue
}
cx, err := newCompiledExclusion(w)
if err != nil {
return nil, errors.Wrapf(err, "compiling exclusion list %+v for policy %s", w, policy.GetName())
}
exclusions = append(exclusions, w)
exclusions = append(exclusions, cx)
}

scopes := make([]*scopecomp.CompiledScope, 0, len(policy.GetScope()))
Expand Down Expand Up @@ -477,6 +483,7 @@ type compiledExclusion struct {
exclusion *storage.Exclusion
deploymentNameMatcher regexutils.StringMatcher
cs *scopecomp.CompiledScope
excludedKinds set.Set[string]
}

type alwaysFalseMatcher struct{}
Expand All @@ -489,6 +496,18 @@ func newCompiledExclusion(exclusion *storage.Exclusion) (*compiledExclusion, err
cx := &compiledExclusion{
exclusion: exclusion,
}
if exclusion.GetExcludeByKind() != nil {
if features.PolicyWorkloadKindExclusion.Enabled() {
excludedKinds, err := kubernetesKindsForExcludeByKind(exclusion.GetExcludeByKind())
if err != nil {
return nil, err
}
cx.excludedKinds = excludedKinds
} else {
cx.excludedKinds = set.NewSet[string]()
}
return cx, nil
}
if name := exclusion.GetDeployment().GetName(); name != "" {
deploymentNameMatcher, err := regexutils.CompileWholeStringMatcher(name, regexutils.Flags{CaseInsensitive: true})
if err != nil {
Expand All @@ -514,18 +533,23 @@ func newCompiledExclusion(exclusion *storage.Exclusion) (*compiledExclusion, err
return cx, nil
}

// appliesToDeployments reports whether the exclusion has a deployment part. Image-only exclusions are
// applied to images, so they must not match deployments or audit events. Without this check, the nil
// deployment scope matches everything and the exclusion turns the policy off for every deployment.
// appliesToDeployments reports whether the exclusion can match a deployment.
// Image-only exclusions are applied to images, so they must not match deployments or audit events.
// Without this check, the nil deployment scope matches everything and the exclusion turns the policy off for every deployment.
// Kind exclusions have no deployment matcher, but they still apply to deployments of the selected kinds.
func (cw *compiledExclusion) appliesToDeployments() bool {
return cw.exclusion.GetDeployment() != nil
return cw.exclusion.GetDeployment() != nil || cw.excludedKinds != nil
}

func (cw *compiledExclusion) MatchesDeployment(ctx context.Context, deployment *storage.Deployment) bool {
if !cw.appliesToDeployments() || exclusionIsExpired(cw.exclusion) {
return false
}

if cw.excludedKinds != nil {
return cw.excludedKinds.Contains(deployment.GetType())
}

if cw.deploymentNameMatcher != nil && !cw.deploymentNameMatcher.MatchString(deployment.GetName()) {
return false
}
Expand All @@ -537,12 +561,35 @@ func (cw *compiledExclusion) MatchesAuditEvent(ctx context.Context, auditEvent *
if !cw.appliesToDeployments() || exclusionIsExpired(cw.exclusion) {
return false
}
// Kind exclusions apply to workload detections, not audit-log events.
if cw.excludedKinds != nil {
return false
}
if !cw.cs.MatchesAuditEvent(ctx, auditEvent) {
return false
}
return true
}

func kubernetesKindsForExcludeByKind(excludeByKind *storage.Exclusion_ExcludeByKind) (set.Set[string], error) {
kinds := excludeByKind.GetKinds()
if len(kinds) == 0 {
return nil, errors.New("exclude_by_kind must specify at least one workload kind")
}
kubernetesKinds := set.NewSet[string]()
for _, kind := range kinds {
switch kind {
case storage.Exclusion_CRON_JOB:
kubernetesKinds.Add(kubernetes.CronJob)
case storage.Exclusion_JOB:
kubernetesKinds.Add(kubernetes.Job)
default:
return nil, errors.Errorf("exclude_by_kind contains unknown workload kind %s", kind)
}
}
return kubernetesKinds, nil
}

// Predicate for deployments.
type deploymentPredicate struct {
exclusions []*compiledExclusion
Expand Down
Loading
Loading