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
61 changes: 61 additions & 0 deletions central/complianceoperator/v2/compliancemanager/manager_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/stackrox/rox/central/sensor/service/connection"
"github.com/stackrox/rox/generated/internalapi/central"
"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/env"
"github.com/stackrox/rox/pkg/features"
"github.com/stackrox/rox/pkg/logging"
"github.com/stackrox/rox/pkg/protocompat"
Expand Down Expand Up @@ -299,6 +300,15 @@ func (m *managerImpl) processRequestToSensor(ctx context.Context, scanRequest *s
}
}

// For scan configs that include tailored profiles, verify that each tailored profile has
// equivalent content (same equivalence_hash) on every selected cluster.
// The bypass env var disables this check entirely (name-only, same as non-tailored profile behavior).
if !env.SkipTailoredProfileEquivalenceHash.BooleanSetting() {
if err := m.validateTailoredProfilesEquivalence(ctx, returnedProfiles, clusters, scanRequest.GetScanConfigName()); err != nil {
return nil, err
}
}

// Build profile refs (name + kind) - needed to support tailored profiles, a different resource kind in CO.
// Persist profile_refs so the startup sync path can forward correct kinds to Sensor on reconnect.
scanRequest.ProfileRefs = internaltov2storage.ProfileV2ToScanConfigRefs(returnedProfiles)
Expand Down Expand Up @@ -569,3 +579,54 @@ func convertSchedule(scanRequest *storage.ComplianceOperatorScanConfigurationV2)

return cron, nil
}

// validateTailoredProfilesEquivalence verifies that each tailored profile in the scan
// request is equivalent on every selected cluster, by comparing their equivalence hashes.
// Non-tailored profiles are not checked. Called only when hash checking is not bypassed.
//
// Hash equivalence: all instances share the same hash value (COUNT(DISTINCT hash) = 1).
// All-empty is allowed — treated as equivalent, matching the profile picker semantics.
func (m *managerImpl) validateTailoredProfilesEquivalence(
ctx context.Context,
cluster0Profiles []*storage.ComplianceOperatorProfileV2,
clusters []string,
scanConfigName string,
) error {
var tailoredProfileNames []string
for _, p := range cluster0Profiles {
if p.GetOperatorKind() == storage.ComplianceOperatorProfileV2_TAILORED_PROFILE {
tailoredProfileNames = append(tailoredProfileNames, p.GetName())
}
}
if len(tailoredProfileNames) == 0 {
return nil
}

allInstances, err := m.profileDS.SearchProfiles(ctx, search.NewQueryBuilder().
AddExactMatches(search.ClusterID, clusters...).
AddExactMatches(search.ComplianceOperatorProfileName, tailoredProfileNames...).ProtoQuery())
if err != nil {
return errors.Wrapf(err, "scan configuration %q: failed to retrieve tailored profiles across clusters", scanConfigName)
}

byName := make(map[string][]*storage.ComplianceOperatorProfileV2, len(tailoredProfileNames))
for _, p := range allInstances {
byName[p.GetName()] = append(byName[p.GetName()], p)
}

for _, name := range tailoredProfileNames {
instances := byName[name]
if len(instances) != len(clusters) {
log.Warnf("Scan configuration %q rejected: tailored profile %q is present on %d of %d selected clusters",
scanConfigName, name, len(instances), len(clusters))
return errors.Errorf("scan configuration %q: tailored profile %q is not present on all selected clusters", scanConfigName, name)
}
if !profileDatastore.TailoredProfilesEquivalent(instances) {
log.Warnf("Scan configuration %q rejected: tailored profile %q has different content across clusters (equivalence hash mismatch). "+
"Deploy an identical tailored profile on all clusters before creating a multi-cluster scan.", scanConfigName, name)
return errors.Errorf("scan configuration %q: tailored profile %q has different content across clusters; "+
"ensure the same tailored profile is deployed on every cluster before creating a multi-cluster scan", scanConfigName, name)
}
}
return nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
sensorMocks "github.com/stackrox/rox/central/sensor/service/connection/mocks"
"github.com/stackrox/rox/generated/internalapi/central"
"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/env"
"github.com/stackrox/rox/pkg/features"
"github.com/stackrox/rox/pkg/fixtures/fixtureconsts"
"github.com/stackrox/rox/pkg/sac"
Expand Down Expand Up @@ -518,6 +519,10 @@ func (suite *complianceManagerTestSuite) TestProcessScanRequestSendsProfileRefsW
getTestProfileWithKind("ocp4-cis", "1.0.0", "platform", "ocp4", testconsts.Cluster1, 1, storage.ComplianceOperatorProfileV2_PROFILE),
getTestProfileWithKind("rhcos4-cis", "1.0.0", "node", "rhcos4", testconsts.Cluster1, 1, storage.ComplianceOperatorProfileV2_TAILORED_PROFILE),
}, nil).Times(1)
// Second SearchProfiles: hash validation for the tailored profile across the selected cluster.
suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{
getTestProfileWithKind("rhcos4-cis", "1.0.0", "node", "rhcos4", testconsts.Cluster1, 1, storage.ComplianceOperatorProfileV2_TAILORED_PROFILE),
}, nil).Times(1)
suite.scanConfigDS.EXPECT().UpsertScanConfiguration(ctx, gomock.Any()).Return(nil).Times(1)
suite.connectionMgr.EXPECT().SendMessage(testconsts.Cluster1, gomock.Any()).DoAndReturn(
func(_ string, msg *central.MsgToSensor) error {
Expand Down Expand Up @@ -923,3 +928,71 @@ func (suite *complianceManagerTestSuite) TestRemoveObsoleteResultsByProfiles() {
})
}
}

// TestTailoredProfileHashValidation verifies that multi-cluster scan configs with tailored
// profiles are rejected when the profile content (equivalence_hash) differs across clusters.
func (suite *complianceManagerTestSuite) TestTailoredProfileHashValidation() {
ctx := suite.testContexts[testutils.UnrestrictedReadWriteCtx]
req := getTestRecWithClustersAndProfiles("", []string{testconsts.Cluster1, testconsts.Cluster2}, []string{"my-tp"})

tpCluster1 := getTestProfileWithKind("my-tp", "1.0.0", "platform", "ocp4", testconsts.Cluster1, 1, storage.ComplianceOperatorProfileV2_TAILORED_PROFILE)
tpCluster2SameHash := getTestProfileWithKind("my-tp", "1.0.0", "platform", "ocp4", testconsts.Cluster2, 1, storage.ComplianceOperatorProfileV2_TAILORED_PROFILE)
tpCluster2DiffHash := getTestProfileWithKind("my-tp", "1.0.0", "platform", "ocp4", testconsts.Cluster2, 1, storage.ComplianceOperatorProfileV2_TAILORED_PROFILE)
tpCluster1.EquivalenceHash = "hash-abc"
tpCluster2SameHash.EquivalenceHash = "hash-abc"
tpCluster2DiffHash.EquivalenceHash = "hash-xyz"

suite.T().Run("TPs with same hash can be scheduled", func(t *testing.T) {
suite.scanConfigDS.EXPECT().GetScanConfigurationByName(ctx, mockScanName).Return(nil, nil).Times(1)
suite.scanConfigDS.EXPECT().ScanConfigurationProfileExists(ctx, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(1)
// First SearchProfiles: cluster 0 only
suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{tpCluster1}, nil).Times(1)
// Second SearchProfiles: all clusters for hash validation
suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{tpCluster1, tpCluster2SameHash}, nil).Times(1)
suite.scanConfigDS.EXPECT().UpsertScanConfiguration(ctx, gomock.Any()).Return(nil).Times(1)
suite.connectionMgr.EXPECT().SendMessage(gomock.Any(), gomock.Any()).Return(nil).Times(2)
suite.clusterDatastore.EXPECT().GetClusterName(gomock.Any(), gomock.Any()).Return("test_cluster", true, nil).Times(2)
suite.scanConfigDS.EXPECT().UpdateClusterStatus(ctx, gomock.Any(), gomock.Any(), "", "test_cluster").Times(2)

config, err := suite.manager.ProcessScanRequest(ctx, req, []string{testconsts.Cluster1, testconsts.Cluster2})
suite.Require().NoError(err)
suite.Require().NotNil(config)
// Restore scan request ID so it can be reused.
req.Id = ""
})

suite.T().Run("TPs with different hash are rejected", func(t *testing.T) {
suite.scanConfigDS.EXPECT().GetScanConfigurationByName(ctx, mockScanName).Return(nil, nil).Times(1)
suite.scanConfigDS.EXPECT().ScanConfigurationProfileExists(ctx, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(1)
// First SearchProfiles: cluster 0 only
suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{tpCluster1}, nil).Times(1)
// Second SearchProfiles: returns divergent hashes
suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{tpCluster1, tpCluster2DiffHash}, nil).Times(1)
// No Upsert or SendMessage expected — we fail before that.

req.Id = ""
config, err := suite.manager.ProcessScanRequest(ctx, req, []string{testconsts.Cluster1, testconsts.Cluster2})
suite.Require().Error(err)
suite.Require().Nil(config)
suite.Require().ErrorContains(err, "different content across clusters")
req.Id = ""
})

suite.T().Run("bypass env skips hash validation for multi-cluster tailored profile", func(t *testing.T) {
suite.T().Setenv(env.SkipTailoredProfileEquivalenceHash.EnvVar(), "true")

reqBypass := getTestRecWithClustersAndProfiles("", []string{testconsts.Cluster1, testconsts.Cluster2}, []string{"my-tp"})
suite.scanConfigDS.EXPECT().GetScanConfigurationByName(ctx, mockScanName).Return(nil, nil).Times(1)
suite.scanConfigDS.EXPECT().ScanConfigurationProfileExists(ctx, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(1)
// First SearchProfiles only — second (hash validation) call skipped due to bypass.
suite.profileDS.EXPECT().SearchProfiles(ctx, gomock.Any()).Return([]*storage.ComplianceOperatorProfileV2{tpCluster1}, nil).Times(1)
suite.scanConfigDS.EXPECT().UpsertScanConfiguration(ctx, gomock.Any()).Return(nil).Times(1)
suite.connectionMgr.EXPECT().SendMessage(gomock.Any(), gomock.Any()).Return(nil).Times(2)
suite.clusterDatastore.EXPECT().GetClusterName(gomock.Any(), gomock.Any()).Return("test_cluster", true, nil).Times(2)
suite.scanConfigDS.EXPECT().UpdateClusterStatus(ctx, gomock.Any(), gomock.Any(), "", "test_cluster").Times(2)

config, err := suite.manager.ProcessScanRequest(ctx, reqBypass, []string{testconsts.Cluster1, testconsts.Cluster2})
suite.Require().NoError(err)
suite.Require().NotNil(config)
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package datastore

import (
"testing"

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

func makeProfile(name, clusterID string, kind storage.ComplianceOperatorProfileV2_OperatorKind, hash string) *storage.ComplianceOperatorProfileV2 {
return &storage.ComplianceOperatorProfileV2{
Name: name,
ClusterId: clusterID,
OperatorKind: kind,
EquivalenceHash: hash,
}
}

const (
standard = storage.ComplianceOperatorProfileV2_PROFILE
tailored = storage.ComplianceOperatorProfileV2_TAILORED_PROFILE
)

// buildByName is a test helper that groups a profile list the same way filterNonEquivalentTPs does.
func buildByName(profiles []*storage.ComplianceOperatorProfileV2) map[string][]*storage.ComplianceOperatorProfileV2 {
byName := make(map[string][]*storage.ComplianceOperatorProfileV2)
for _, p := range profiles {
byName[p.GetName()] = append(byName[p.GetName()], p)
}
return byName
}

func TestApplyEquivalenceFilter_OOBPassesThrough(t *testing.T) {
profiles := []*storage.ComplianceOperatorProfileV2{
makeProfile("ocp4-cis", "c1", standard, ""),
makeProfile("ocp4-cis", "c2", standard, ""),
}
names := []string{"ocp4-cis"}
got := applyEquivalenceFilter(names, buildByName(profiles))
assert.Equal(t, []string{"ocp4-cis"}, got)
}

func TestApplyEquivalenceFilter_TPSameHashPassesThrough(t *testing.T) {
profiles := []*storage.ComplianceOperatorProfileV2{
makeProfile("my-tp", "c1", tailored, "hash-abc"),
makeProfile("my-tp", "c2", tailored, "hash-abc"),
}
names := []string{"my-tp"}
got := applyEquivalenceFilter(names, buildByName(profiles))
assert.Equal(t, []string{"my-tp"}, got)
}

func TestApplyEquivalenceFilter_TPDifferentHashExcluded(t *testing.T) {
profiles := []*storage.ComplianceOperatorProfileV2{
makeProfile("my-tp", "c1", tailored, "hash-abc"),
makeProfile("my-tp", "c2", tailored, "hash-xyz"),
}
names := []string{"my-tp"}
got := applyEquivalenceFilter(names, buildByName(profiles))
assert.Empty(t, got)
}

func TestApplyEquivalenceFilter_AllEmptyHashEquivalent(t *testing.T) {
profiles := []*storage.ComplianceOperatorProfileV2{
makeProfile("my-tp", "c1", tailored, ""),
makeProfile("my-tp", "c2", tailored, ""),
}
names := []string{"my-tp"}
got := applyEquivalenceFilter(names, buildByName(profiles))
assert.Equal(t, []string{"my-tp"}, got)
}

func TestApplyEquivalenceFilter_PreservesOrder(t *testing.T) {
profiles := []*storage.ComplianceOperatorProfileV2{
makeProfile("tp-a", "c1", tailored, "h"),
makeProfile("tp-a", "c2", tailored, "h"),
makeProfile("tp-bad", "c1", tailored, "h1"),
makeProfile("tp-bad", "c2", tailored, "h2"),
makeProfile("ocp4-cis", "c1", standard, ""),
makeProfile("ocp4-cis", "c2", standard, ""),
}
names := []string{"tp-a", "tp-bad", "ocp4-cis"}
got := applyEquivalenceFilter(names, buildByName(profiles))
assert.Equal(t, []string{"tp-a", "ocp4-cis"}, got)
}

func TestApplyEquivalenceFilter_EmptyInput(t *testing.T) {
got := applyEquivalenceFilter(nil, nil)
assert.Nil(t, got)
}

func TestTailoredProfilesEquivalent(t *testing.T) {
tests := []struct {
name string
instances []*storage.ComplianceOperatorProfileV2
want bool
}{
{
name: "nil slice",
instances: nil,
want: true,
},
{
name: "empty slice",
instances: []*storage.ComplianceOperatorProfileV2{},
want: true,
},
{
name: "single instance",
instances: []*storage.ComplianceOperatorProfileV2{makeProfile("x", "c1", tailored, "abc")},
want: true,
},
{
name: "same hash across clusters",
instances: []*storage.ComplianceOperatorProfileV2{
makeProfile("x", "c1", tailored, "abc"),
makeProfile("x", "c2", tailored, "abc"),
},
want: true,
},
{
name: "all-empty hash treated as equivalent",
instances: []*storage.ComplianceOperatorProfileV2{
makeProfile("x", "c1", tailored, ""),
makeProfile("x", "c2", tailored, ""),
},
want: true,
},
{
name: "different hashes",
instances: []*storage.ComplianceOperatorProfileV2{
makeProfile("x", "c1", tailored, "abc"),
makeProfile("x", "c2", tailored, "xyz"),
},
want: false,
},
{
name: "one empty one non-empty hash",
instances: []*storage.ComplianceOperatorProfileV2{
makeProfile("x", "c1", tailored, "abc"),
makeProfile("x", "c2", tailored, ""),
},
want: false,
},
{
name: "three clusters, all same hash",
instances: []*storage.ComplianceOperatorProfileV2{
makeProfile("x", "c1", tailored, "h"),
makeProfile("x", "c2", tailored, "h"),
makeProfile("x", "c3", tailored, "h"),
},
want: true,
},
{
name: "three clusters, last differs",
instances: []*storage.ComplianceOperatorProfileV2{
makeProfile("x", "c1", tailored, "h"),
makeProfile("x", "c2", tailored, "h"),
makeProfile("x", "c3", tailored, "z"),
},
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, TailoredProfilesEquivalent(tc.instances))
})
}
}
Loading
Loading