Skip to content
Draft
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ Put an entry in this file if your change is user-visible and you consider it _pa

Changes should still be described appropriately in JIRA/doc input pages, for inclusion in downstream release notes.

## [NEXT RELEASE]

### Technical Changes

- ROX-37315: Image exclusions no longer turn off deploy-time checks. Previously, an image exclusion on a policy with Build and Deploy stages stopped that policy from raising deploy-time violations or blocking any deployment. Image exclusions now apply only at Build, so after upgrading, affected policies raise deploy-time violations again and block deployments if enforcement is on. To skip apps at deploy time, use deployment exclusions.

## [4.11.5]

**Full Changelog**: [4.11.4...4.11.5](https://github.com/stackrox/stackrox/compare/4.11.4...4.11.5)
Expand Down
4 changes: 4 additions & 0 deletions central/policy/matcher/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ func (m *clusterMatcher) anyExclusionMatches(exclusions []*storage.Exclusion) bo
}

func (m *clusterMatcher) exclusionMatches(exclusion *storage.Exclusion) bool {
if !appliesToDeployments(exclusion) {
return false
}

cs, err := scopecomp.CompileScope(exclusion.GetDeployment().GetScope(), nil, nil)
if err != nil {
utils.Should(errors.Wrap(err, "could not compile excluded scopes"))
Expand Down
4 changes: 4 additions & 0 deletions central/policy/matcher/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ func (m *deploymentMatcher) anyExclusionMatches(ctx context.Context, exclusions
}

func (m *deploymentMatcher) exclusionMatches(ctx context.Context, exclusion *storage.Exclusion) bool {
if !appliesToDeployments(exclusion) {
return false
}

// If excluded scope does not match the deployment then no need to check for deployment name
if !m.scopeMatches(ctx, exclusion.GetDeployment().GetScope()) {
return false
Expand Down
7 changes: 7 additions & 0 deletions central/policy/matcher/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@ type Matcher interface {
FilterApplicablePolicies(ctx context.Context, policies []*storage.Policy) (applicable []*storage.Policy, notApplicable []*storage.Policy)
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.
func appliesToDeployments(exclusion *storage.Exclusion) bool {
return exclusion.GetDeployment() != nil
}
38 changes: 38 additions & 0 deletions central/policy/matcher/matcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package matcher

import (
"context"
"testing"

"github.com/stackrox/rox/generated/storage"
"github.com/stretchr/testify/assert"
)

// TestImageOnlyExclusionDoesNotExcludeEntities checks that an image-only exclusion leaves the policy
// applicable to every deployment, namespace, and cluster, while a deployment exclusion still excludes.
func TestImageOnlyExclusionDoesNotExcludeEntities(t *testing.T) {
ctx := context.Background()
deployment := &storage.Deployment{Name: "payments", ClusterId: "cluster1", Namespace: "shop"}
namespace := &storage.NamespaceMetadata{Name: "shop", ClusterId: "cluster1"}
cluster := &storage.Cluster{Id: "cluster1"}

matchers := map[string]Matcher{
"deployment": NewDeploymentMatcher(deployment, nil, nil),
"namespace": NewNamespaceMatcher(namespace),
"cluster": NewClusterMatcher(cluster, []*storage.NamespaceMetadata{namespace}),
}

imageOnly := &storage.Policy{Exclusions: []*storage.Exclusion{
{Image: &storage.Exclusion_Image{Name: "docker.io/library/nginx"}},
}}
clusterExclusion := &storage.Policy{Exclusions: []*storage.Exclusion{
{Deployment: &storage.Exclusion_Deployment{Scope: &storage.Scope{Cluster: "cluster1"}}},
}}

for name, m := range matchers {
t.Run(name, func(t *testing.T) {
assert.True(t, m.IsPolicyApplicable(ctx, imageOnly), "image-only exclusion should not exclude")
assert.False(t, m.IsPolicyApplicable(ctx, clusterExclusion), "cluster-scoped exclusion should exclude")
})
}
}
2 changes: 1 addition & 1 deletion central/policy/matcher/namespace.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func (m *namespaceMatcher) anyExclusionMatches(exclusions []*storage.Exclusion)
}

func (m *namespaceMatcher) exclusionMatches(exclusion *storage.Exclusion) bool {
return m.scopeMatches(exclusion.GetDeployment().GetScope())
return appliesToDeployments(exclusion) && m.scopeMatches(exclusion.GetDeployment().GetScope())
}

func (m *namespaceMatcher) anyScopeMatches(scopes []*storage.Scope) bool {
Expand Down
11 changes: 9 additions & 2 deletions pkg/detection/compiled_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,8 +459,15 @@ 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.
func (cw *compiledExclusion) appliesToDeployments() bool {
return cw.exclusion.GetDeployment() != nil
}

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

Expand All @@ -472,7 +479,7 @@ func (cw *compiledExclusion) MatchesDeployment(ctx context.Context, deployment *
}

func (cw *compiledExclusion) MatchesAuditEvent(ctx context.Context, auditEvent *storage.KubernetesEvent) bool {
if exclusionIsExpired(cw.exclusion) {
if !cw.appliesToDeployments() || exclusionIsExpired(cw.exclusion) {
return false
}
if !cw.cs.MatchesAuditEvent(ctx, auditEvent) {
Expand Down
11 changes: 11 additions & 0 deletions pkg/detection/compiled_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ func TestCompiledPolicyScopesAndExclusions(t *testing.T) {
exclusions: []*storage.Exclusion{{Deployment: &storage.Exclusion_Deployment{Scope: appStackRoxScope}}},
shouldApplyTo: []*storage.Deployment{stackRoxNSDep},
},
{
desc: "image-only exclusion does not exclude any deployment",
exclusions: []*storage.Exclusion{{Image: &storage.Exclusion_Image{Name: "docker.io/library/unrelated"}}},
shouldApplyTo: []*storage.Deployment{stackRoxNSDep, defaultNSDep, appStackRoxDep},
},
{
desc: "only stackrox ns, with an image-only exclusion",
scopes: []*storage.Scope{stackRoxNSScope},
exclusions: []*storage.Exclusion{{Image: &storage.Exclusion_Image{Name: "docker.io/library/unrelated"}}},
shouldApplyTo: []*storage.Deployment{stackRoxNSDep, appStackRoxDep},
},
{
desc: "only default ns",
scopes: []*storage.Scope{defaultNSScope},
Expand Down
138 changes: 138 additions & 0 deletions pkg/detection/deploytime/detector_impl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package deploytime

import (
"context"
"testing"
"time"

"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/booleanpolicy"
"github.com/stackrox/rox/pkg/booleanpolicy/fieldnames"
"github.com/stackrox/rox/pkg/booleanpolicy/policyversion"
"github.com/stackrox/rox/pkg/detection"
"github.com/stackrox/rox/pkg/protocompat"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const (
excludedImage = "registry.example.com/team/legacy-app:1.0"
vulnerableImage = "registry.example.com/team/payments:2.3"
)

// oldCriticalCVEPolicy mirrors a "Block Critical CVE older than 180 days" policy that runs at
// both the build and deploy stages and excludes a single image.
func oldCriticalCVEPolicy(exclusions ...*storage.Exclusion) *storage.Policy {
return &storage.Policy{
Id: "old-critical-cve",
Name: "Block Critical CVE older than 180 days",
PolicyVersion: policyversion.CurrentVersion().String(),
Severity: storage.Severity_CRITICAL_SEVERITY,
LifecycleStages: []storage.LifecycleStage{storage.LifecycleStage_BUILD, storage.LifecycleStage_DEPLOY},
Exclusions: exclusions,
PolicySections: []*storage.PolicySection{{
PolicyGroups: []*storage.PolicyGroup{
{FieldName: fieldnames.Severity, Values: []*storage.PolicyValue{{Value: "CRITICAL"}}},
{FieldName: fieldnames.DaysSinceImageFirstDiscovered, Values: []*storage.PolicyValue{{Value: "180"}}},
},
}},
}
}

func imageExclusion(fullName string) *storage.Exclusion {
return &storage.Exclusion{Image: &storage.Exclusion_Image{Name: fullName}}
}

func imageWithOldCriticalCVE(t *testing.T, fullName string) *storage.Image {
firstSeen, err := protocompat.ConvertTimeToTimestampOrError(time.Now().AddDate(0, 0, -365))
require.NoError(t, err)
return &storage.Image{
Id: fullName + "-sha",
Name: &storage.ImageName{FullName: fullName},
Scan: &storage.ImageScan{
Components: []*storage.EmbeddedImageScanComponent{{
Name: "openssl",
Version: "1.0.1",
Vulns: []*storage.EmbeddedVulnerability{{
Cve: "CVE-2014-0160",
Severity: storage.VulnerabilitySeverity_CRITICAL_VULNERABILITY_SEVERITY,
FirstImageOccurrence: firstSeen,
}},
}},
},
}
}

func deploymentRunning(name string, image *storage.Image) booleanpolicy.EnhancedDeployment {
return booleanpolicy.EnhancedDeployment{
Deployment: &storage.Deployment{
Id: name + "-id",
Name: name,
Namespace: "shop",
ClusterId: "cluster-1",
Containers: []*storage.Container{{
Name: name,
Image: &storage.ContainerImage{Id: image.GetId(), Name: image.GetName()},
}},
},
Images: []*storage.Image{image},
}
}

func detectorFor(t *testing.T, policy *storage.Policy) Detector {
policySet := detection.NewPolicySet(nil, nil)
require.NoError(t, policySet.UpsertPolicy(policy))
return NewDetector(policySet)
}

// TestImageExclusionDoesNotDisableDeployTimeDetection reproduces the customer report: adding an
// image exclusion to a build and deploy policy stopped the policy from alerting on every deployment,
// including deployments that do not run the excluded image.
func TestImageExclusionDoesNotDisableDeployTimeDetection(t *testing.T) {
ctx := context.Background()
payments := deploymentRunning("payments", imageWithOldCriticalCVE(t, vulnerableImage))

t.Run("without an exclusion, the vulnerable deployment alerts", func(t *testing.T) {
alerts, err := detectorFor(t, oldCriticalCVEPolicy()).Detect(ctx, payments)
require.NoError(t, err)
assert.Len(t, alerts, 1)
})

t.Run("with an image exclusion, a deployment that does not run the excluded image still alerts", func(t *testing.T) {
policy := oldCriticalCVEPolicy(imageExclusion(excludedImage))
alerts, err := detectorFor(t, policy).Detect(ctx, payments)
require.NoError(t, err)
require.Len(t, alerts, 1)
assert.Equal(t, "payments", alerts[0].GetDeployment().GetName())
})

t.Run("with an image exclusion, a deployment running the excluded image still alerts", func(t *testing.T) {
// Image exclusions only apply at build time, so a deployment running the excluded image is
// still evaluated at deploy time.
legacy := deploymentRunning("legacy-app", imageWithOldCriticalCVE(t, excludedImage))
policy := oldCriticalCVEPolicy(imageExclusion(excludedImage))
alerts, err := detectorFor(t, policy).Detect(ctx, legacy)
require.NoError(t, err)
require.Len(t, alerts, 1)
assert.Equal(t, "legacy-app", alerts[0].GetDeployment().GetName())
})

t.Run("a deployment exclusion still skips the named deployment", func(t *testing.T) {
policy := oldCriticalCVEPolicy(&storage.Exclusion{Deployment: &storage.Exclusion_Deployment{Name: "payments"}})
alerts, err := detectorFor(t, policy).Detect(ctx, payments)
require.NoError(t, err)
assert.Empty(t, alerts)
})
}

// TestImageExclusionStillAppliesAtBuildTime checks that the fix does not change build-time
// behavior: the excluded image is skipped, other images are still checked.
func TestImageExclusionStillAppliesAtBuildTime(t *testing.T) {
ctx := context.Background()
policy := oldCriticalCVEPolicy(imageExclusion(excludedImage))
compiled, err := detection.CompilePolicy(policy, nil, nil)
require.NoError(t, err)

assert.False(t, compiled.AppliesTo(ctx, imageWithOldCriticalCVE(t, excludedImage)))
assert.True(t, compiled.AppliesTo(ctx, imageWithOldCriticalCVE(t, vulnerableImage)))
}
22 changes: 22 additions & 0 deletions pkg/detection/exclusion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ func TestMatchesDeploymentExclusion(t *testing.T) {
},
shouldMatch: false,
},
{
name: "Image-only exclusion does not match deployments",
deployment: fixtures.GetDeployment(),
policy: &storage.Policy{
Exclusions: []*storage.Exclusion{
{
Image: &storage.Exclusion_Image{Name: "docker.io/library/nginx"},
},
},
},
shouldMatch: false,
},
{
name: "Scoped excluded scope, but different name",
deployment: fixtures.GetDeployment(),
Expand Down Expand Up @@ -248,3 +260,13 @@ func TestMatchesImageExclusion(t *testing.T) {
})
}
}

func TestImageOnlyExclusionDoesNotMatchAuditEvent(t *testing.T) {
cx, err := newCompiledExclusion(&storage.Exclusion{Image: &storage.Exclusion_Image{Name: "docker.io/library/nginx"}})
require.NoError(t, err)

auditEvent := &storage.KubernetesEvent{
Object: &storage.KubernetesEvent_Object{Name: "my-secret", Namespace: "default", ClusterId: "cluster-1"},
}
assert.False(t, auditEventMatchesExclusions(context.Background(), auditEvent, []*compiledExclusion{cx}))
}
Loading