Skip to content
Open
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
72 changes: 62 additions & 10 deletions central/reports/service/v2/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func (s *serviceImpl) convertV2VulnReportFiltersToProto(filters *apiV2.Vulnerabi
IncludeNvdCvss: filters.GetIncludeNvdCvss(),
IncludeEpssProbability: filters.GetIncludeEpssProbability(),
IncludeAdvisory: filters.GetIncludeAdvisory(),
Query: filters.GetQuery(),
}

for _, severity := range filters.GetSeverities() {
Expand Down Expand Up @@ -122,12 +123,36 @@ func (s *serviceImpl) convertV2ResourceScopeToProto(scope *apiV2.ResourceScope)
}

ret := &storage.ResourceScope{}
if scope.GetCollectionScope() != nil {
ret.ScopeReference = &storage.ResourceScope_CollectionId{CollectionId: scope.GetCollectionScope().GetCollectionId()}
switch ref := scope.GetScopeReference().(type) {
case *apiV2.ResourceScope_CollectionScope:
ret.ScopeReference = &storage.ResourceScope_CollectionId{CollectionId: ref.CollectionScope.GetCollectionId()}
case *apiV2.ResourceScope_EntityScope:
ret.ScopeReference = &storage.ResourceScope_EntityScope{EntityScope: convertV2EntityScopeToStorage(ref.EntityScope)}
}
return ret
}

func convertV2EntityScopeToStorage(es *apiV2.EntityScope) *storage.EntityScope {
if es == nil {
return nil
}
rules := make([]*storage.EntityScopeRule, 0, len(es.GetRules()))
for _, rule := range es.GetRules() {
sr := &storage.EntityScopeRule{
Entity: storage.EntityType(rule.GetEntity()),
Field: storage.EntityField(rule.GetField()),
}
for _, rv := range rule.GetValues() {
sr.Values = append(sr.Values, &storage.RuleValue{
Value: rv.GetValue(),
MatchType: storage.MatchType(rv.GetMatchType()),
})
}
Comment on lines +141 to +150
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Should we not convert these with a switch on the value to future proof this? Today the values line up, but they may not always.

rules = append(rules, sr)
}
return &storage.EntityScope{Rules: rules}
}

func (s *serviceImpl) convertV2NotifierConfigToProto(notifier *apiV2.NotifierConfiguration) *storage.NotifierConfiguration {
if notifier == nil {
return nil
Expand Down Expand Up @@ -227,6 +252,7 @@ func (s *serviceImpl) convertProtoVulnReportFiltersToV2(filters *storage.Vulnera
IncludeNvdCvss: filters.GetIncludeNvdCvss(),
IncludeEpssProbability: filters.GetIncludeEpssProbability(),
IncludeAdvisory: filters.GetIncludeAdvisory(),
Query: filters.GetQuery(),
}

for _, severity := range filters.GetSeverities() {
Expand Down Expand Up @@ -263,27 +289,48 @@ func (s *serviceImpl) convertProtoResourceScopeToV2(scope *storage.ResourceScope
}

ret := &apiV2.ResourceScope{}
if scope.GetScopeReference() != nil {
var collectionName string
collection, found, err := s.collectionDatastore.Get(allAccessCtx, scope.GetCollectionId())
switch ref := scope.GetScopeReference().(type) {
case *storage.ResourceScope_CollectionId:
collection, found, err := s.collectionDatastore.Get(allAccessCtx, ref.CollectionId)
if err != nil {
return nil, err
}
if !found {
return nil, errors.Errorf("Collection with ID %s no longer exists", scope.GetCollectionId())
return nil, errors.Errorf("Collection with ID %s no longer exists", ref.CollectionId)
}
collectionName = collection.GetName()

ret.ScopeReference = &apiV2.ResourceScope_CollectionScope{
CollectionScope: &apiV2.CollectionReference{
CollectionId: scope.GetCollectionId(),
CollectionName: collectionName,
CollectionId: ref.CollectionId,
CollectionName: collection.GetName(),
},
}
case *storage.ResourceScope_EntityScope:
ret.ScopeReference = &apiV2.ResourceScope_EntityScope{EntityScope: convertStorageEntityScopeToV2(ref.EntityScope)}
}
return ret, nil
}

func convertStorageEntityScopeToV2(es *storage.EntityScope) *apiV2.EntityScope {
if es == nil {
return nil
}
rules := make([]*apiV2.EntityScopeRule, 0, len(es.GetRules()))
for _, rule := range es.GetRules() {
ar := &apiV2.EntityScopeRule{
Entity: apiV2.ScopeEntity(rule.GetEntity()),
Field: apiV2.ScopeField(rule.GetField()),
}
for _, rv := range rule.GetValues() {
ar.Values = append(ar.Values, &apiV2.RuleValue{
Value: rv.GetValue(),
MatchType: apiV2.MatchType(rv.GetMatchType()),
Comment on lines +320 to +326
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: same comment as above on converting by value to allow for divergence.

})
}
rules = append(rules, ar)
}
return &apiV2.EntityScope{Rules: rules}
}

// convertProtoNotifierConfigToV2 converts storage.NotifierConfiguration to apiV2.NotifierConfiguration
func (s *serviceImpl) convertProtoNotifierConfigToV2(notifierConfig *storage.NotifierConfiguration) (*apiV2.NotifierConfiguration, error) {
if notifierConfig == nil {
Expand Down Expand Up @@ -433,6 +480,10 @@ func (s *serviceImpl) convertProtoReportSnapshotstoV2(snapshots []*storage.Repor
}
v2snaps := make([]*apiV2.ReportSnapshot, 0, len(snapshots))
for _, snapshot := range snapshots {
resourceScope, err := s.convertProtoResourceScopeToV2(snapshot.GetResourceScope())
if err != nil {
return nil, err
}
Comment on lines +483 to +486
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use snapshot metadata instead of live collection lookups here.

Lines 483-486 now route snapshot ResourceScope through convertProtoResourceScopeToV2, which resolves collection-scoped names from the current datastore. That means one deleted collection will make the whole snapshot conversion fail, and a renamed collection will also rewrite the historical name, even though snapshot.GetCollection() already stores the snapshot-time metadata. Please build ResourceScope.CollectionScope from the snapshot copy first and only fall back to a live lookup when that snapshot metadata is absent.

💡 Suggested direction
-        resourceScope, err := s.convertProtoResourceScopeToV2(snapshot.GetResourceScope())
-        if err != nil {
-            return nil, err
-        }
+        resourceScope, err := s.convertProtoSnapshotResourceScopeToV2(
+            snapshot.GetResourceScope(),
+            snapshot.GetCollection(),
+        )
+        if err != nil {
+            return nil, err
+        }

convertProtoSnapshotResourceScopeToV2 should prefer snapshot.GetCollection() for collection-scoped snapshots so historical report reads stay stable after collection rename/deletion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@central/reports/service/v2/convert.go` around lines 483 - 486, The current
conversion calls convertProtoResourceScopeToV2 on snapshot.GetResourceScope(),
which performs live datastore collection lookups and can fail or rewrite
historical names; change the conversion logic to first build
ResourceScope.CollectionScope from snapshot.GetCollection() (use
snapshot.GetCollection() to populate CollectionScope fields) and only call
convertProtoResourceScopeToV2 as a fallback when snapshot.GetCollection() is
empty/missing; implement this in a new/updated helper (e.g.,
convertProtoSnapshotResourceScopeToV2) that accepts the snapshot, prefers
snapshot.GetCollection() for CollectionScope, and delegates to
convertProtoResourceScopeToV2(snapshot.GetResourceScope()) only when snapshot
collection metadata is absent.

snapshotv2 := &apiV2.ReportSnapshot{
ReportStatus: s.convertPrototoV2Reportstatus(snapshot.GetReportStatus()),
ReportConfigId: snapshot.GetReportConfigurationId(),
Expand All @@ -448,6 +499,7 @@ func (s *serviceImpl) convertProtoReportSnapshotstoV2(snapshots []*storage.Repor
Filter: &apiV2.ReportSnapshot_VulnReportFilters{
VulnReportFilters: s.convertProtoVulnReportFiltersToV2(snapshot.GetVulnReportFilters()),
},
ResourceScope: resourceScope,
IsDownloadAvailable: blobNames.Contains(common.GetReportBlobPath(snapshot.GetReportConfigurationId(), snapshot.GetReportId())),
}
for _, notifier := range snapshot.GetNotifiers() {
Expand Down
82 changes: 82 additions & 0 deletions central/reports/service/v2/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,88 @@ func (s *typeConversionTestSuite) TestConvertProtoReportConfigurationToV2() {
}
}

func (s *typeConversionTestSuite) TestConvertEntityScope() {
apiScope := &apiV2.EntityScope{
Rules: []*apiV2.EntityScopeRule{
{
Entity: apiV2.ScopeEntity_SCOPE_ENTITY_DEPLOYMENT,
Field: apiV2.ScopeField_FIELD_NAME,
Values: []*apiV2.RuleValue{
{Value: "frontend", MatchType: apiV2.MatchType_EXACT},
{Value: "back.*", MatchType: apiV2.MatchType_REGEX},
},
},
{
Entity: apiV2.ScopeEntity_SCOPE_ENTITY_NAMESPACE,
Field: apiV2.ScopeField_FIELD_LABEL,
Values: []*apiV2.RuleValue{
{Value: "env=prod", MatchType: apiV2.MatchType_EXACT},
},
},
},
}
storageScope := &storage.EntityScope{
Rules: []*storage.EntityScopeRule{
{
Entity: storage.EntityType_ENTITY_TYPE_DEPLOYMENT,
Field: storage.EntityField_FIELD_NAME,
Values: []*storage.RuleValue{
{Value: "frontend", MatchType: storage.MatchType_EXACT},
{Value: "back.*", MatchType: storage.MatchType_REGEX},
},
},
{
Entity: storage.EntityType_ENTITY_TYPE_NAMESPACE,
Field: storage.EntityField_FIELD_LABEL,
Values: []*storage.RuleValue{
{Value: "env=prod", MatchType: storage.MatchType_EXACT},
},
},
},
}

s.T().Run("API to storage entity scope", func(t *testing.T) {
got := convertV2EntityScopeToStorage(apiScope)
protoassert.Equal(t, storageScope, got)
})

s.T().Run("Storage to API entity scope", func(t *testing.T) {
got := convertStorageEntityScopeToV2(storageScope)
protoassert.Equal(t, apiScope, got)
})

s.T().Run("convertProtoResourceScopeToV2 with entity scope does not call collection datastore", func(t *testing.T) {
// collection datastore must NOT be called — no EXPECT set up
scope := &storage.ResourceScope{
ScopeReference: &storage.ResourceScope_EntityScope{EntityScope: storageScope},
}
got, err := s.service.convertProtoResourceScopeToV2(scope)
assert.NoError(t, err)
protoassert.Equal(t, &apiV2.ResourceScope{
ScopeReference: &apiV2.ResourceScope_EntityScope{EntityScope: apiScope},
}, got)
})

s.T().Run("convertV2ResourceScopeToProto with entity scope", func(t *testing.T) {
scope := &apiV2.ResourceScope{
ScopeReference: &apiV2.ResourceScope_EntityScope{EntityScope: apiScope},
}
got := s.service.convertV2ResourceScopeToProto(scope)
protoassert.Equal(t, &storage.ResourceScope{
ScopeReference: &storage.ResourceScope_EntityScope{EntityScope: storageScope},
}, got)
})

s.T().Run("Query field round-trips through vuln filters", func(t *testing.T) {
apiFilters := &apiV2.VulnerabilityReportFilters{Query: "CVE:CVE-2024-1234"}
storageFilters := s.service.convertV2VulnReportFiltersToProto(apiFilters, nil)
assert.Equal(t, "CVE:CVE-2024-1234", storageFilters.GetQuery())

backToAPI := s.service.convertProtoVulnReportFiltersToV2(storageFilters)
assert.Equal(t, "CVE:CVE-2024-1234", backToAPI.GetQuery())
})
}

func (s *typeConversionTestSuite) TestConvertProtoScheduleToV2() {
var cases = []struct {
testname string
Expand Down
84 changes: 73 additions & 11 deletions central/reports/validation/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
"github.com/stackrox/rox/pkg/errox"
"github.com/stackrox/rox/pkg/grpc/authn"
"github.com/stackrox/rox/pkg/sac"
"github.com/stackrox/rox/pkg/search"
"github.com/stackrox/rox/pkg/set"
"github.com/stackrox/rox/pkg/stringutils"
"github.com/stackrox/rox/pkg/uuid"
)
Expand Down Expand Up @@ -163,15 +165,25 @@ func (v *Validator) validateEmailConfig(emailConfig *apiV2.EmailNotifierConfigur
}

func (v *Validator) validateResourceScope(config *apiV2.ReportConfiguration) error {
if config.GetResourceScope() == nil || config.GetResourceScope().GetCollectionScope() == nil {
scope := config.GetResourceScope()
if scope == nil {
return errors.Wrap(errox.InvalidArgs, "Report configuration must specify a valid resource scope")
}
collectionID := config.GetResourceScope().GetCollectionScope().GetCollectionId()
switch ref := scope.GetScopeReference().(type) {
case *apiV2.ResourceScope_CollectionScope:
return v.validateCollectionScope(ref.CollectionScope)
case *apiV2.ResourceScope_EntityScope:
return validateEntityScope(ref.EntityScope)
default:
return errors.Wrap(errox.InvalidArgs, "Report configuration must specify a valid resource scope")
}
}

if collectionID == "" {
func (v *Validator) validateCollectionScope(collectionRef *apiV2.CollectionReference) error {
if collectionRef == nil || collectionRef.GetCollectionId() == "" {
return errors.Wrap(errox.InvalidArgs, "Report configuration must specify a valid collection ID")
}

collectionID := collectionRef.GetCollectionId()
// Use allAccessCtx since report creator/updater might not have permissions for workflowAdministrationSAC
exists, err := v.collectionDatastore.Exists(allAccessCtx, collectionID)
if err != nil {
Expand All @@ -180,7 +192,46 @@ func (v *Validator) validateResourceScope(config *apiV2.ReportConfiguration) err
if !exists {
return errors.Wrapf(errox.NotFound, "Collection %s not found.", collectionID)
}
return nil
}

func validateEntityScope(es *apiV2.EntityScope) error {
if es == nil {
return errors.Wrap(errox.InvalidArgs, "Report configuration must specify a valid resource scope: either a collection scope with a valid collection ID or a non-nil entity scope")
}
type entityFieldKey struct {
entity apiV2.ScopeEntity
field apiV2.ScopeField
}
seen := set.NewSet[entityFieldKey]()
for _, rule := range es.GetRules() {
if rule.GetEntity() == apiV2.ScopeEntity_SCOPE_ENTITY_UNSET {
return errors.Wrapf(errox.InvalidArgs, "Unexpected entity scope rule: %s", rule.GetEntity())
}
if rule.GetField() == apiV2.ScopeField_FIELD_UNSET {
return errors.Wrap(errox.InvalidArgs, "Unexpected entity scope rule with an unset field")
}
// Cluster annotation is not indexed and therefore unsupported.
if rule.GetEntity() == apiV2.ScopeEntity_SCOPE_ENTITY_CLUSTER && rule.GetField() == apiV2.ScopeField_FIELD_ANNOTATION {
return errors.Wrap(errox.InvalidArgs, "Annotation field is not supported for cluster entity scope rules")
}
key := entityFieldKey{entity: rule.GetEntity(), field: rule.GetField()}
if !seen.Add(key) {
return errors.Wrapf(errox.InvalidArgs,
"Duplicate (entity, field) pair in entity scope rules: entity=%v field=%v", rule.GetEntity(), rule.GetField())
}
if len(rule.GetValues()) == 0 {
return errors.Wrapf(errox.InvalidArgs,
"provide at least one matching value for entity=%v field=%v rule", rule.GetEntity(), rule.GetField())
}
if rule.GetField() == apiV2.ScopeField_FIELD_LABEL {
for _, rv := range rule.GetValues() {
if !strings.Contains(rv.GetValue(), "=") {
return errors.Wrap(errox.InvalidArgs, "Label values must be in 'key=value' format")
}
}
}
}
return nil
}

Expand All @@ -199,6 +250,11 @@ func (v *Validator) validateReportFilters(config *apiV2.ReportConfiguration) err
return errors.Wrap(errox.InvalidArgs, "Vulnerability report filters must specify how far back in time to look for CVEs. "+
"The valid options are 'sinceLastSentScheduledReport', 'allVuln', and 'startDate'")
}
if q := filters.GetQuery(); q != "" {
if _, err := search.ParseQuery(q); err != nil {
return errors.Wrapf(errox.InvalidArgs, "Invalid query in vulnerability report filters: %s", err)
}
}
return nil
}

Expand All @@ -225,12 +281,17 @@ func (v *Validator) ValidateAndGenerateReportRequest(
"Email request sent for a report configuration that does not have any email notifiers configured")
}

collection, found, err := v.collectionDatastore.Get(allAccessCtx, config.GetResourceScope().GetCollectionId())
if err != nil {
return nil, errors.Wrapf(err, "Error finding collection ID '%s'", config.GetResourceScope().GetCollectionId())
}
if !found {
return nil, errors.Wrapf(errox.NotFound, "Collection ID '%s' not found", config.GetResourceScope().GetCollectionId())
var collection *storage.ResourceCollection
if collectionID := config.GetResourceScope().GetCollectionId(); collectionID != "" {
var found bool
var err error
collection, found, err = v.collectionDatastore.Get(allAccessCtx, collectionID)
if err != nil {
return nil, errors.Wrapf(err, "Error finding collection ID '%s'", collectionID)
}
if !found {
return nil, errors.Wrapf(errox.NotFound, "Collection ID '%s' not found", collectionID)
}
}

notifierIDs := make([]string, 0, len(config.GetNotifiers()))
Expand Down Expand Up @@ -291,7 +352,8 @@ func generateReportSnapshot(
Id: config.GetResourceScope().GetCollectionId(),
Name: collection.GetName(),
},
Schedule: config.GetSchedule(),
ResourceScope: config.GetResourceScope(),
Schedule: config.GetSchedule(),
ReportStatus: &storage.ReportStatus{
RunState: storage.ReportStatus_WAITING,
ReportRequestType: requestType,
Expand Down
Loading
Loading