diff --git a/adapters/mockplatform.go b/adapters/mockplatform.go index 3c86fc22..561921dd 100644 --- a/adapters/mockplatform.go +++ b/adapters/mockplatform.go @@ -3,32 +3,59 @@ package adapters import ( "context" + "github.com/armosec/armoapi-go/scanfailure" + wlidpkg "github.com/armosec/utils-k8s-go/wlid" "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + v1 "github.com/kubescape/kubevuln/adapters/v1" "github.com/kubescape/kubevuln/core/domain" "github.com/kubescape/kubevuln/core/ports" - "github.com/armosec/armoapi-go/scanfailure" "go.opentelemetry.io/otel" ) // MockPlatform implements a mocked Platform to be used for tests type MockPlatform struct { - wantEmptyReport bool + wantEmptyReport bool + securityExceptionRepo ports.SecurityExceptionRepository } var _ ports.Platform = (*MockPlatform)(nil) // NewMockPlatform initializes the MockPlatform struct -func NewMockPlatform(wantEmptyReport bool) *MockPlatform { +func NewMockPlatform(wantEmptyReport bool, seRepo ports.SecurityExceptionRepository) *MockPlatform { logger.L().Info("keepLocal config is true, statuses and scan reports won't be sent to Armo cloud") return &MockPlatform{ - wantEmptyReport: wantEmptyReport, + wantEmptyReport: wantEmptyReport, + securityExceptionRepo: seRepo, } } -// GetCVEExceptions returns an empty CVEExceptions +// GetCVEExceptions returns CRD-based SecurityException policies func (m MockPlatform) GetCVEExceptions(ctx context.Context) (domain.CVEExceptions, error) { _, span := otel.Tracer("").Start(ctx, "MockPlatform.GetCVEExceptions") defer span.End() + + if m.securityExceptionRepo == nil { + return domain.CVEExceptions{}, nil + } + + workload, ok := ctx.Value(domain.WorkloadKey{}).(domain.ScanCommand) + if !ok { + return domain.CVEExceptions{}, nil + } + + namespace := wlidpkg.GetNamespaceFromWlid(workload.Wlid) + seList, cseList, err := m.securityExceptionRepo.GetSecurityExceptions(ctx, namespace) + if err != nil { + logger.L().Ctx(ctx).Warning("failed to get CRD security exceptions", helpers.Error(err)) + return domain.CVEExceptions{}, nil + } + + if len(seList) > 0 || len(cseList) > 0 { + policies := v1.ConvertToVulnerabilityExceptionPolicies(seList, cseList) + return policies, nil + } + return domain.CVEExceptions{}, nil } @@ -57,3 +84,4 @@ func (m MockPlatform) SubmitCVE(ctx context.Context, _ domain.CVEManifest, _ dom defer span.End() return nil } + diff --git a/adapters/mockplatform_test.go b/adapters/mockplatform_test.go index a20a49ef..f639e87d 100644 --- a/adapters/mockplatform_test.go +++ b/adapters/mockplatform_test.go @@ -9,13 +9,13 @@ import ( ) func TestMockPlatform_GetCVEExceptions(t *testing.T) { - m := NewMockPlatform(true) + m := NewMockPlatform(true, nil) _, err := m.GetCVEExceptions(context.Background()) assert.NoError(t, err) } func TestMockPlatform_SendStatus(t *testing.T) { - m := NewMockPlatform(true) + m := NewMockPlatform(true, nil) ctx := context.TODO() ctx = context.WithValue(ctx, domain.WorkloadKey{}, domain.ScanCommand{}) err := m.SendStatus(ctx, domain.Done) @@ -23,7 +23,7 @@ func TestMockPlatform_SendStatus(t *testing.T) { } func TestMockPlatform_SubmitCVE(t *testing.T) { - m := NewMockPlatform(true) + m := NewMockPlatform(true, nil) ctx := context.TODO() err := m.SubmitCVE(ctx, domain.CVEManifest{}, domain.CVEManifest{}) assert.NoError(t, err) diff --git a/adapters/v1/backend.go b/adapters/v1/backend.go index 1a4888a8..aada5f69 100644 --- a/adapters/v1/backend.go +++ b/adapters/v1/backend.go @@ -111,7 +111,7 @@ func (a *BackendAdapter) GetCVEExceptions(ctx context.Context) (domain.CVEExcept if err != nil { logger.L().Ctx(ctx).Warning("failed to get CRD security exceptions", helpers.Error(err)) } else if len(seList) > 0 || len(cseList) > 0 { - crdPolicies := convertToVulnerabilityExceptionPolicies(seList, cseList) + crdPolicies := ConvertToVulnerabilityExceptionPolicies(seList, cseList) vulnExceptionList = append(vulnExceptionList, crdPolicies...) } diff --git a/adapters/v1/securityexception.go b/adapters/v1/securityexception.go index d0903583..a55fdb8a 100644 --- a/adapters/v1/securityexception.go +++ b/adapters/v1/securityexception.go @@ -5,14 +5,16 @@ import ( "github.com/armosec/armoapi-go/armotypes" "github.com/armosec/armoapi-go/identifiers" + "github.com/kubescape/kubevuln/core/domain" sev1beta1 "github.com/kubescape/kubevuln/pkg/securityexception/v1beta1" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// convertToVulnerabilityExceptionPolicies converts SecurityException and +// ConvertToVulnerabilityExceptionPolicies converts SecurityException and // ClusterSecurityException CRDs into armotypes.VulnerabilityExceptionPolicy // slices compatible with the existing exception pipeline. -func convertToVulnerabilityExceptionPolicies(exceptions []sev1beta1.SecurityException, clusterExceptions []sev1beta1.ClusterSecurityException) []armotypes.VulnerabilityExceptionPolicy { +func ConvertToVulnerabilityExceptionPolicies(exceptions []sev1beta1.SecurityException, clusterExceptions []sev1beta1.ClusterSecurityException) []armotypes.VulnerabilityExceptionPolicy { var policies []armotypes.VulnerabilityExceptionPolicy now := time.Now() @@ -72,6 +74,43 @@ func buildPolicy(spec sev1beta1.SecurityExceptionSpec, vuln sev1beta1.Vulnerabil return p } +// hasIgnoreAction returns true if any of the matched policies contain the Ignore action. +func hasIgnoreAction(policies []armotypes.VulnerabilityExceptionPolicy) bool { + for _, p := range policies { + for _, a := range p.Actions { + if a == armotypes.Ignore { + return true + } + } + } + return false +} + +// ApplySecurityExceptions moves CVEs covered by exception policies from +// doc.Matches to doc.IgnoredMatches with applied ignore rules. +func ApplySecurityExceptions(doc *v1beta1.GrypeDocument, exceptions domain.CVEExceptions) { + if doc == nil || len(exceptions) == 0 { + return + } + + var remaining []v1beta1.Match + for _, m := range doc.Matches { + isFixed := m.Vulnerability.Fix.State == "fixed" + matched := getCVEExceptionMatchCVENameFromList(exceptions, m.Vulnerability.ID, isFixed) + if len(matched) > 0 && hasIgnoreAction(matched) { + doc.IgnoredMatches = append(doc.IgnoredMatches, v1beta1.IgnoredMatch{ + Match: m, + AppliedIgnoreRules: []v1beta1.IgnoreRule{ + {Vulnerability: m.Vulnerability.ID}, + }, + }) + } else { + remaining = append(remaining, m) + } + } + doc.Matches = remaining +} + func buildDesignators(resources []sev1beta1.ResourceMatch, namespace string) []identifiers.PortalDesignator { if len(resources) == 0 { // No specific resource match — create a namespace-only designator diff --git a/adapters/v1/securityexception_test.go b/adapters/v1/securityexception_test.go index 755f4722..01a57649 100644 --- a/adapters/v1/securityexception_test.go +++ b/adapters/v1/securityexception_test.go @@ -4,7 +4,10 @@ import ( "testing" "time" + "github.com/armosec/armoapi-go/armotypes" + "github.com/kubescape/kubevuln/core/domain" sev1beta1 "github.com/kubescape/kubevuln/pkg/securityexception/v1beta1" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -36,7 +39,7 @@ func TestConvertVulnerabilityExceptions(t *testing.T) { }, } - policies := convertToVulnerabilityExceptionPolicies(exceptions, clusterExceptions) + policies := ConvertToVulnerabilityExceptionPolicies(exceptions, clusterExceptions) assert.Len(t, policies, 2) @@ -93,7 +96,7 @@ func TestConvertExpiredOnFix(t *testing.T) { }, } - policies := convertToVulnerabilityExceptionPolicies(exceptions, nil) + policies := ConvertToVulnerabilityExceptionPolicies(exceptions, nil) assert.Len(t, policies, 1) if tt.wantNil { @@ -142,7 +145,7 @@ func TestConvertSkipsExpired(t *testing.T) { }, } - policies := convertToVulnerabilityExceptionPolicies(exceptions, clusterExceptions) + policies := ConvertToVulnerabilityExceptionPolicies(exceptions, clusterExceptions) assert.Len(t, policies, 1) assert.Equal(t, "CVE-VALID", policies[0].VulnerabilityPolicies[0].Name) @@ -166,7 +169,7 @@ func TestConvertMatchResources(t *testing.T) { }, } - policies := convertToVulnerabilityExceptionPolicies(exceptions, nil) + policies := ConvertToVulnerabilityExceptionPolicies(exceptions, nil) assert.Len(t, policies, 1) assert.Len(t, policies[0].Designatores, 2) @@ -181,3 +184,73 @@ func TestConvertMatchResources(t *testing.T) { assert.Equal(t, "StatefulSet", d1.Attributes["kind"]) assert.Equal(t, "my-db", d1.Attributes["name"]) } + +func TestApplySecurityExceptions_MovesToIgnored(t *testing.T) { + doc := &v1beta1.GrypeDocument{ + Matches: []v1beta1.Match{ + {Vulnerability: v1beta1.Vulnerability{VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ID: "CVE-2021-44228"}}}, + {Vulnerability: v1beta1.Vulnerability{VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ID: "CVE-2023-9999"}}}, + }, + } + + exceptions := domain.CVEExceptions{ + { + PolicyType: "vulnerabilityExceptionPolicy", + Actions: []armotypes.VulnerabilityExceptionPolicyActions{armotypes.Ignore}, + VulnerabilityPolicies: []armotypes.VulnerabilityPolicy{{Name: "CVE-2021-44228"}}, + }, + } + + ApplySecurityExceptions(doc, exceptions) + + assert.Len(t, doc.Matches, 1, "one match should remain") + assert.Equal(t, "CVE-2023-9999", doc.Matches[0].Vulnerability.ID) + + assert.Len(t, doc.IgnoredMatches, 1, "one match should be ignored") + assert.Equal(t, "CVE-2021-44228", doc.IgnoredMatches[0].Vulnerability.ID) + assert.Len(t, doc.IgnoredMatches[0].AppliedIgnoreRules, 1) + assert.Equal(t, "CVE-2021-44228", doc.IgnoredMatches[0].AppliedIgnoreRules[0].Vulnerability) +} + +func TestApplySecurityExceptions_ExpiredOnFix(t *testing.T) { + expiredOnFix := true + doc := &v1beta1.GrypeDocument{ + Matches: []v1beta1.Match{ + {Vulnerability: v1beta1.Vulnerability{ + VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ID: "CVE-2021-44228"}, + Fix: v1beta1.Fix{State: "fixed", Versions: []string{"2.17.0"}}, + }}, + }, + } + + exceptions := domain.CVEExceptions{ + { + PolicyType: "vulnerabilityExceptionPolicy", + Actions: []armotypes.VulnerabilityExceptionPolicyActions{armotypes.Ignore}, + VulnerabilityPolicies: []armotypes.VulnerabilityPolicy{{Name: "CVE-2021-44228"}}, + ExpiredOnFix: &expiredOnFix, + }, + } + + ApplySecurityExceptions(doc, exceptions) + + // Fix available + expiredOnFix = exception skipped, CVE stays in Matches + assert.Len(t, doc.Matches, 1, "CVE with fix should remain in Matches when expiredOnFix is set") + assert.Len(t, doc.IgnoredMatches, 0, "nothing should be ignored when fix is available and expiredOnFix is set") +} + +func TestApplySecurityExceptions_NilDoc(t *testing.T) { + ApplySecurityExceptions(nil, domain.CVEExceptions{}) +} + +func TestApplySecurityExceptions_NoExceptions(t *testing.T) { + doc := &v1beta1.GrypeDocument{ + Matches: []v1beta1.Match{ + {Vulnerability: v1beta1.Vulnerability{VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ID: "CVE-2021-44228"}}}, + }, + } + + ApplySecurityExceptions(doc, nil) + + assert.Len(t, doc.Matches, 1, "no filtering when no exceptions") +} diff --git a/cmd/http/main.go b/cmd/http/main.go index a2ef72ff..bad4f548 100644 --- a/cmd/http/main.go +++ b/cmd/http/main.go @@ -85,22 +85,25 @@ func main() { sbomAdapter = v1.NewSyftAdapter(c.ScanTimeout, c.MaxImageSize, c.MaxSBOMSize, c.ScanEmbeddedSboms) } cveAdapter := v1.NewGrypeAdapter(c.ListingURL, c.UseDefaultMatchers) + + // SecurityException CRD integration (works in both local and cloud modes) + var seRepo ports.SecurityExceptionRepository + if storage != nil { + seRepo = storage + logger.L().Info("SecurityException CRD integration enabled") + } else { + seRepo = &repositories.NoOpSecurityExceptionRepository{} + } + var platform ports.Platform if c.KeepLocal { - platform = adapters.NewMockPlatform(true) + platform = adapters.NewMockPlatform(true, seRepo) } else { backendServices, err := config.LoadBackendServicesConfig("/etc/config") if err != nil { logger.L().Ctx(ctx).Fatal("load services error", helpers.Error(err)) } logger.L().Info("loaded backend services", helpers.String("ApiServerUrl", backendServices.GetApiServerUrl()), helpers.String("ReportReceiverHttpUrl", backendServices.GetReportReceiverHttpUrl())) - var seRepo ports.SecurityExceptionRepository - if storage != nil { - seRepo = storage - logger.L().Info("SecurityException CRD integration enabled") - } else { - seRepo = &repositories.NoOpSecurityExceptionRepository{} - } backendAdapter := v1.NewBackendAdapter(credentials.Account, backendServices.GetApiServerUrl(), backendServices.GetReportReceiverHttpUrl(), credentials.AccessKey, seRepo) platform = backendAdapter } diff --git a/cmd/http/main_test.go b/cmd/http/main_test.go index 0478c9ed..8ff62731 100644 --- a/cmd/http/main_test.go +++ b/cmd/http/main_test.go @@ -87,7 +87,7 @@ func TestScan(t *testing.T) { repository := repositories.NewFakeAPIServerStorage("kubescape") sbomAdapter := adapters.NewMockSBOMAdapter(false, false, false) cveAdapter := adapters.NewMockCVEAdapter() - platform := adapters.NewMockPlatform(true) + platform := adapters.NewMockPlatform(true, nil) relevancyProvider := adapters.NewMockRelevancyAdapter() service := services.NewScanService(sbomAdapter, repository, cveAdapter, repository, platform, relevancyProvider, test.storage, false, true, false, false) controller := controllers.NewHTTPController(service, 2) diff --git a/core/services/scan.go b/core/services/scan.go index d4184022..4f3ed3d1 100644 --- a/core/services/scan.go +++ b/core/services/scan.go @@ -267,15 +267,18 @@ func (s *ScanService) ScanCP(mainCtx context.Context) error { continue // we need the CVE } - // store CVE + // apply security exceptions for storage (copy — original stays intact for SubmitCVE) + filteredCve := s.applyExceptionsToManifest(ctx, cve) + + // store filtered CVE if s.storage { - err = s.cveRepository.StoreCVE(ctx, cve, false) + err = s.cveRepository.StoreCVE(ctx, filteredCve, false) if err != nil { logger.L().Ctx(ctx).Warning("storing CVE", helpers.Error(err), helpers.String("imageSlug", slug)) // no continue, storing the CVE is not critical } - err = s.cveRepository.StoreCVESummary(ctx, cve, domain.CVEManifest{}, false) + err = s.cveRepository.StoreCVESummary(ctx, filteredCve, domain.CVEManifest{}, false) if err != nil { logger.L().Ctx(ctx).Warning("storing CVE summary", helpers.Error(err), helpers.String("imageSlug", slug)) @@ -283,15 +286,18 @@ func (s *ScanService) ScanCP(mainCtx context.Context) error { } } } else { - if s.storage { - // store summary CVE if it does not exist - if cveSumm, err := s.cveRepository.GetCVESummary(ctx); err != nil || cveSumm == nil { - err = s.cveRepository.StoreCVESummary(ctx, cve, domain.CVEManifest{}, false) - if err != nil { - logger.L().Ctx(ctx).Warning("storing CVE summary", helpers.Error(err), - helpers.String("imageSlug", slug)) - // no continue, storing the CVE summary is not critical - } + filteredCve := s.applyExceptionsToManifest(ctx, cve) + + if s.storage && len(filteredCve.Content.IgnoredMatches) > len(cve.Content.IgnoredMatches) { + err = s.cveRepository.StoreCVE(ctx, filteredCve, false) + if err != nil { + logger.L().Ctx(ctx).Warning("storing CVE with exceptions", helpers.Error(err), + helpers.String("imageSlug", slug)) + } + err = s.cveRepository.StoreCVESummary(ctx, filteredCve, domain.CVEManifest{}, false) + if err != nil { + logger.L().Ctx(ctx).Warning("storing CVE summary with exceptions", helpers.Error(err), + helpers.String("imageSlug", slug)) } } } @@ -325,16 +331,22 @@ func (s *ScanService) ScanCP(mainCtx context.Context) error { helpers.String("instanceID", scan.InstanceID.GetStringFormatted())) continue // we need the CVE' } - // store CVE' + + // apply security exceptions for storage (copy — original stays intact for SubmitCVE) + filteredCvep := s.applyExceptionsToManifest(ctx, cvep) + + // store filtered CVE' if s.storage { - cvep.Wlid = scan.Wlid - err = s.cveRepository.StoreCVE(ctx, cvep, true) + filteredCvep.Wlid = scan.Wlid + err = s.cveRepository.StoreCVE(ctx, filteredCvep, true) if err != nil { logger.L().Ctx(ctx).Warning("storing CVEp", helpers.Error(err), helpers.String("instanceID", scan.InstanceID.GetStringFormatted())) // no continue, storing the CVE' is not critical } - err = s.cveRepository.StoreCVESummary(ctx, cve, cvep, true) + // Summary uses original cve (all matches for total counts) and + // filteredCvep (exceptions applied to relevant matches only). + err = s.cveRepository.StoreCVESummary(ctx, cve, filteredCvep, true) if err != nil { logger.L().Ctx(ctx).Warning("storing CVE summary", helpers.Error(err), helpers.String("imageSlug", slug)) @@ -451,28 +463,35 @@ func (s *ScanService) ScanCVE(ctx context.Context) error { return fmt.Errorf("scanning SBOM: %w", err) } - // store CVE + // apply security exceptions for storage (copy — original stays intact for SubmitCVE) + filteredCve := s.applyExceptionsToManifest(ctx, cve) + + // store filtered CVE if s.storage { - err = s.cveRepository.StoreCVE(ctx, cve, false) + err = s.cveRepository.StoreCVE(ctx, filteredCve, false) if err != nil { logger.L().Ctx(ctx).Warning("storing CVE", helpers.Error(err), helpers.String("imageSlug", workload.ImageSlug)) } - err = s.cveRepository.StoreCVESummary(ctx, cve, domain.CVEManifest{}, false) + err = s.cveRepository.StoreCVESummary(ctx, filteredCve, domain.CVEManifest{}, false) if err != nil { logger.L().Ctx(ctx).Warning("storing CVE summary", helpers.Error(err), helpers.String("imageSlug", workload.ImageSlug)) } } } else { - if s.storage { - // store summary CVE if does not exist - if cveSumm, err := s.cveRepository.GetCVESummary(ctx); err != nil || cveSumm == nil { - err = s.cveRepository.StoreCVESummary(ctx, cve, domain.CVEManifest{}, false) - if err != nil { - logger.L().Ctx(ctx).Warning("storing CVE summary", helpers.Error(err), - helpers.String("imageSlug", workload.ImageSlug)) - } + filteredCve := s.applyExceptionsToManifest(ctx, cve) + + if s.storage && len(filteredCve.Content.IgnoredMatches) > len(cve.Content.IgnoredMatches) { + err = s.cveRepository.StoreCVE(ctx, filteredCve, false) + if err != nil { + logger.L().Ctx(ctx).Warning("storing CVE with exceptions", helpers.Error(err), + helpers.String("imageSlug", workload.ImageSlug)) + } + err = s.cveRepository.StoreCVESummary(ctx, filteredCve, domain.CVEManifest{}, false) + if err != nil { + logger.L().Ctx(ctx).Warning("storing CVE summary with exceptions", helpers.Error(err), + helpers.String("imageSlug", workload.ImageSlug)) } } } @@ -576,6 +595,28 @@ func (s *ScanService) ScanRegistry(ctx context.Context) error { return nil } +// applyExceptionsToManifest returns a filtered copy of the CVE manifest with +// SecurityException policies applied. The original manifest is not mutated, +// so callers can still use it for SubmitCVE (cloud reporting). +func (s *ScanService) applyExceptionsToManifest(ctx context.Context, cve domain.CVEManifest) domain.CVEManifest { + if cve.Content == nil { + return cve + } + exceptions, err := s.platform.GetCVEExceptions(ctx) + if err != nil { + logger.L().Ctx(ctx).Warning("failed to get CVE exceptions for filtering", helpers.Error(err)) + return cve + } + if len(exceptions) == 0 { + return cve + } + filtered := cve + docCopy := cve.Content.DeepCopy() + v1.ApplySecurityExceptions(docCopy, exceptions) + filtered.Content = docCopy + return filtered +} + func addTimestamp(ctx context.Context) context.Context { return context.WithValue(ctx, domain.TimestampKey{}, time.Now().Unix()) } diff --git a/core/services/scan_test.go b/core/services/scan_test.go index 548f3dae..27a386e4 100644 --- a/core/services/scan_test.go +++ b/core/services/scan_test.go @@ -89,7 +89,7 @@ func TestScanService_GenerateSBOM(t *testing.T) { t.Run(tt.name, func(t *testing.T) { sbomAdapter := adapters.NewMockSBOMAdapter(tt.createSBOMError, tt.timeout, tt.toomanyrequests) storage := repositories.NewMemoryStorage(tt.getError, tt.storeError) - s := NewScanService(sbomAdapter, storage, adapters.NewMockCVEAdapter(), storage, adapters.NewMockPlatform(false), adapters.NewMockRelevancyAdapter(), tt.storage, false, true, false, false) + s := NewScanService(sbomAdapter, storage, adapters.NewMockCVEAdapter(), storage, adapters.NewMockPlatform(false, nil), adapters.NewMockRelevancyAdapter(), tt.storage, false, true, false, false) ctx := context.TODO() workload := domain.ScanCommand{ @@ -228,7 +228,7 @@ func TestScanService_ScanCP(t *testing.T) { storageCP := repositories.NewMemoryStorage(false, false) storageSBOM := repositories.NewMemoryStorage(tt.getErrorSBOM, tt.storeErrorSBOM) storageCVE := repositories.NewMemoryStorage(tt.getErrorCVE, tt.storeErrorCVE) - s := NewScanService(sbomAdapter, storageSBOM, cveAdapter, storageCVE, adapters.NewMockPlatform(tt.wantEmptyReport), v1.NewContainerProfileAdapter(storageCP), tt.storage, false, true, false, false) + s := NewScanService(sbomAdapter, storageSBOM, cveAdapter, storageCVE, adapters.NewMockPlatform(tt.wantEmptyReport, nil), v1.NewContainerProfileAdapter(storageCP), tt.storage, false, true, false, false) ctx := context.TODO() s.Ready(ctx) @@ -404,7 +404,7 @@ func TestScanService_ScanCVE(t *testing.T) { storageCP := repositories.NewMemoryStorage(false, false) storageSBOM := repositories.NewMemoryStorage(tt.getErrorSBOM, tt.storeErrorSBOM) storageCVE := repositories.NewMemoryStorage(tt.getErrorCVE, tt.storeErrorCVE) - s := NewScanService(sbomAdapter, storageSBOM, cveAdapter, storageCVE, adapters.NewMockPlatform(tt.wantEmptyReport), v1.NewContainerProfileAdapter(storageCP), tt.storage, false, true, false, false) + s := NewScanService(sbomAdapter, storageSBOM, cveAdapter, storageCVE, adapters.NewMockPlatform(tt.wantEmptyReport, nil), v1.NewContainerProfileAdapter(storageCP), tt.storage, false, true, false, false) ctx := context.TODO() s.Ready(ctx) @@ -468,7 +468,7 @@ func TestScanService_NginxTest(t *testing.T) { storageCP := repositories.NewMemoryStorage(false, false) storageSBOM := repositories.NewMemoryStorage(false, false) storageCVE := repositories.NewMemoryStorage(false, false) - platform := adapters.NewMockPlatform(false) + platform := adapters.NewMockPlatform(false, nil) relevancyProvider := v1.NewContainerProfileAdapter(storageCP) s := NewScanService(sbomAdapter, storageSBOM, cveAdapter, storageCVE, platform, relevancyProvider, true, false, true, false, false) s.Ready(ctx) @@ -530,7 +530,7 @@ func TestScanService_ValidateGenerateSBOM(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := NewScanService(adapters.NewMockSBOMAdapter(false, false, false), repositories.NewMemoryStorage(false, false), adapters.NewMockCVEAdapter(), repositories.NewMemoryStorage(false, false), adapters.NewMockPlatform(false), adapters.NewMockRelevancyAdapter(), false, false, true, false, false) + s := NewScanService(adapters.NewMockSBOMAdapter(false, false, false), repositories.NewMemoryStorage(false, false), adapters.NewMockCVEAdapter(), repositories.NewMemoryStorage(false, false), adapters.NewMockPlatform(false, nil), adapters.NewMockRelevancyAdapter(), false, false, true, false, false) _, err := s.ValidateGenerateSBOM(context.TODO(), tt.workload) if (err != nil) != tt.wantErr { t.Errorf("ValidateGenerateSBOM() error = %v, wantErr %v", err, tt.wantErr) @@ -570,7 +570,7 @@ func TestScanService_ValidateScanCVE(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := NewScanService(adapters.NewMockSBOMAdapter(false, false, false), repositories.NewMemoryStorage(false, false), adapters.NewMockCVEAdapter(), repositories.NewMemoryStorage(false, false), adapters.NewMockPlatform(false), adapters.NewMockRelevancyAdapter(), false, false, true, false, false) + s := NewScanService(adapters.NewMockSBOMAdapter(false, false, false), repositories.NewMemoryStorage(false, false), adapters.NewMockCVEAdapter(), repositories.NewMemoryStorage(false, false), adapters.NewMockPlatform(false, nil), adapters.NewMockRelevancyAdapter(), false, false, true, false, false) _, err := s.ValidateScanCVE(context.TODO(), tt.workload) if (err != nil) != tt.wantErr { t.Errorf("ValidateScanCVE() error = %v, wantErr %v", err, tt.wantErr) @@ -622,7 +622,7 @@ func TestScanService_ScanRegistry(t *testing.T) { t.Run(tt.name, func(t *testing.T) { sbomAdapter := adapters.NewMockSBOMAdapter(tt.createSBOMError, tt.timeout, tt.toomanyrequests) storage := repositories.NewMemoryStorage(false, false) - s := NewScanService(sbomAdapter, storage, adapters.NewMockCVEAdapter(), storage, adapters.NewMockPlatform(false), adapters.NewMockRelevancyAdapter(), false, false, true, false, false) + s := NewScanService(sbomAdapter, storage, adapters.NewMockCVEAdapter(), storage, adapters.NewMockPlatform(false, nil), adapters.NewMockRelevancyAdapter(), false, false, true, false, false) ctx := context.TODO() workload := domain.ScanCommand{ ImageSlug: "imageSlug", @@ -678,7 +678,7 @@ func TestScanService_ValidateScanRegistry(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := NewScanService(adapters.NewMockSBOMAdapter(false, false, false), repositories.NewMemoryStorage(false, false), adapters.NewMockCVEAdapter(), repositories.NewMemoryStorage(false, false), adapters.NewMockPlatform(false), adapters.NewMockRelevancyAdapter(), false, false, true, false, false) + s := NewScanService(adapters.NewMockSBOMAdapter(false, false, false), repositories.NewMemoryStorage(false, false), adapters.NewMockCVEAdapter(), repositories.NewMemoryStorage(false, false), adapters.NewMockPlatform(false, nil), adapters.NewMockRelevancyAdapter(), false, false, true, false, false) _, err := s.ValidateScanRegistry(context.TODO(), tt.workload) if (err != nil) != tt.wantErr { t.Errorf("ValidateScanRegistry() error = %v, wantErr %v", err, tt.wantErr) diff --git a/repositories/apiserver.go b/repositories/apiserver.go index a2ff54df..534063ea 100644 --- a/repositories/apiserver.go +++ b/repositories/apiserver.go @@ -82,8 +82,11 @@ func NewAPIServerStorage(namespace string) (*APIServerStore, error) { if err != nil { return nil, err } - // Dynamic client for CRDs (uses JSON) - dynClient, err := dynamic.NewForConfig(config) + // Dynamic client for CRDs (uses JSON, separate rate limiter) + dynConfig := *config + dynConfig.QPS = 50 + dynConfig.Burst = 100 + dynClient, err := dynamic.NewForConfig(&dynConfig) if err != nil { return nil, err } @@ -103,11 +106,16 @@ func NewFakeAPIServerStorage(namespace string) *APIServerStore { } func (a *APIServerStore) GetSecurityExceptions(ctx context.Context, namespace string) ([]sev1beta1.SecurityException, []sev1beta1.ClusterSecurityException, error) { + // Use a detached context with timeout — the scan context may be canceled + // before the CRD listing completes due to rate limiting. + listCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + var exceptions []sev1beta1.SecurityException // Only list namespaced exceptions when namespace is provided if namespace != "" { - seList, err := a.DynamicClient.Resource(securityExceptionGVR).Namespace(namespace).List(ctx, metav1.ListOptions{}) + seList, err := a.DynamicClient.Resource(securityExceptionGVR).Namespace(namespace).List(listCtx, metav1.ListOptions{}) if err != nil { logger.L().Ctx(ctx).Warning("failed to list SecurityExceptions", helpers.Error(err), helpers.String("namespace", namespace)) } else { @@ -124,7 +132,7 @@ func (a *APIServerStore) GetSecurityExceptions(ctx context.Context, namespace st // List cluster-scoped exceptions var clusterExceptions []sev1beta1.ClusterSecurityException - cseList, err := a.DynamicClient.Resource(clusterSecurityExceptionGVR).List(ctx, metav1.ListOptions{}) + cseList, err := a.DynamicClient.Resource(clusterSecurityExceptionGVR).List(listCtx, metav1.ListOptions{}) if err != nil { logger.L().Ctx(ctx).Warning("failed to list ClusterSecurityExceptions", helpers.Error(err)) } else {