diff --git a/pkg/registry/file/configurationscansummarystorage.go b/pkg/registry/file/configurationscansummarystorage.go index a9da87c05..1b0443bd8 100644 --- a/pkg/registry/file/configurationscansummarystorage.go +++ b/pkg/registry/file/configurationscansummarystorage.go @@ -97,9 +97,25 @@ func (s *ConfigurationScanSummaryStorage) GetList(ctx context.Context, key strin workloadScanSummaryListObjPtr := &softwarecomposition.WorkloadConfigurationScanSummaryList{} - // ask for all workloadconfigurationscansummaries in the cluster - if err := s.realStore.GetList(ctx, "/spdx.softwarecomposition.kubescape.io/"+workloadConfigurationScanSummariesResource, opts, workloadScanSummaryListObjPtr); err != nil { - return err + // ask for all workloadconfigurationscansummaries in the cluster. + // The underlying store caps each page at a default limit and returns a + // continuation token, so we must page through every token to make sure the + // aggregation covers all objects and not just the first page (issue #337). + listOpts := opts + listOpts.Predicate.Continue = "" + for { + page := &softwarecomposition.WorkloadConfigurationScanSummaryList{} + if err := s.realStore.GetList(ctx, "/spdx.softwarecomposition.kubescape.io/"+workloadConfigurationScanSummariesResource, listOpts, page); err != nil { + return err + } + workloadScanSummaryListObjPtr.Items = append(workloadScanSummaryListObjPtr.Items, page.Items...) + + // the store only sets a continuation token when the page was truncated; + // an empty token means we have read the last page. + listOpts.Predicate.Continue = page.Continue + if listOpts.Predicate.Continue == "" { + break + } } // generate a single configurationScanSummary for the cluster, with a configuration scan summary for each namespace diff --git a/pkg/registry/file/configurationscansummarystorage_test.go b/pkg/registry/file/configurationscansummarystorage_test.go index ab55cb316..9577c9fda 100644 --- a/pkg/registry/file/configurationscansummarystorage_test.go +++ b/pkg/registry/file/configurationscansummarystorage_test.go @@ -2,6 +2,7 @@ package file import ( "context" + "fmt" "testing" "time" @@ -201,6 +202,76 @@ func TestConfigurationScanSummaryStorage_GetList(t *testing.T) { } } +func TestConfigurationScanSummaryStorage_GetList_Pagination(t *testing.T) { + // Regression test for issue #337: the underlying store caps each page at a + // default limit of 500 objects and returns a continuation token. GetList must + // consume that token so the cluster-wide aggregation covers every object, + // otherwise namespaces beyond the first page silently disappear and per-namespace + // severity counters are wrong. + const ( + alphaCount = 500 // fills the entire first page on its own + betaCount = 200 // lives entirely beyond the first page + ) + + pool := NewTestPool(t.TempDir()) + require.NotNil(t, pool) + defer func(pool *sqlitemigration.Pool) { + _ = pool.Close() + }(pool) + sch := scheme.Scheme + require.NoError(t, softwarecomposition.AddToScheme(sch)) + realStorage := NewStorageImpl(afero.NewMemMapFs(), "/", pool, nil, sch) + + ctx, cancel := context.WithTimeout(context.TODO(), 60*time.Second) + defer cancel() + + // objects are listed ordered by creation (rowid), so creating all of "alpha" + // before "beta" guarantees "beta" falls entirely on the second page. + createWorkloadSummaries := func(namespace string, count int) { + for i := range count { + name := fmt.Sprintf("workload-%d", i) + wlObj := &softwarecomposition.WorkloadConfigurationScanSummary{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: softwarecomposition.WorkloadConfigurationScanSummarySpec{ + Severities: softwarecomposition.WorkloadConfigurationScanSeveritiesSummary{ + High: 1, + }, + }, + } + key := fmt.Sprintf("/spdx.softwarecomposition.kubescape.io/workloadconfigurationscansummaries/%s/%s", namespace, name) + require.NoError(t, realStorage.Create(ctx, key, wlObj, nil, 0)) + } + } + createWorkloadSummaries("alpha", alphaCount) + createWorkloadSummaries("beta", betaCount) + + configScanSummaryStorage := NewConfigurationScanSummaryStorage(realStorage) + + listObj := &v1beta1.ConfigurationScanSummaryList{} + // the aggregation reads severities from the spec, so request the full spec + err := configScanSummaryStorage.GetList(ctx, "/spdx.softwarecomposition.kubescape.io/configurationscansummaries", storage.ListOptions{ + ResourceVersion: softwarecomposition.ResourceVersionFullSpec, + Predicate: storage.SelectionPredicate{}, + }, listObj) + require.NoError(t, err) + + // Both namespaces must be present even though "beta" lives entirely on the + // second page. Before the fix, only the first page (500 objects) was read, so + // "beta" disappeared completely and the list contained a single namespace. + require.Len(t, listObj.Items, 2, "both namespaces must be present, including the one beyond the first page") + + // The aggregated severities must cover every object, not just the first page. + // Before the fix this totalled only 500 (the truncated first page). + var totalHigh int64 + for _, item := range listObj.Items { + totalHigh += item.Spec.Severities.High + } + assert.Equal(t, int64(alphaCount+betaCount), totalHigh, "severities must aggregate all objects across every page") +} + func TestGenerateConfigurationScanSummary(t *testing.T) { tests := []struct { name string diff --git a/pkg/registry/file/vulnerabilitysummarystorage.go b/pkg/registry/file/vulnerabilitysummarystorage.go index b9332f515..fb3fd503d 100644 --- a/pkg/registry/file/vulnerabilitysummarystorage.go +++ b/pkg/registry/file/vulnerabilitysummarystorage.go @@ -155,9 +155,25 @@ func (s *VulnerabilitySummaryStorage) GetList(ctx context.Context, key string, o vulnerabilityManifestSummaryListObjPtr := &softwarecomposition.VulnerabilityManifestSummaryList{} - // ask for all vulnerabilitySummaries in the cluster - if err := s.realStore.GetList(ctx, "/spdx.softwarecomposition.kubescape.io/"+vulnerabilitySummariesResource, opts, vulnerabilityManifestSummaryListObjPtr); err != nil { - return err + // ask for all vulnerabilitySummaries in the cluster. + // The underlying store caps each page at a default limit and returns a + // continuation token, so we must page through every token to make sure the + // aggregation covers all objects and not just the first page (issue #337). + listOpts := opts + listOpts.Predicate.Continue = "" + for { + page := &softwarecomposition.VulnerabilityManifestSummaryList{} + if err := s.realStore.GetList(ctx, "/spdx.softwarecomposition.kubescape.io/"+vulnerabilitySummariesResource, listOpts, page); err != nil { + return err + } + vulnerabilityManifestSummaryListObjPtr.Items = append(vulnerabilityManifestSummaryListObjPtr.Items, page.Items...) + + // the store only sets a continuation token when the page was truncated; + // an empty token means we have read the last page. + listOpts.Predicate.Continue = page.Continue + if listOpts.Predicate.Continue == "" { + break + } } // generate a single vulnerabilitySummary for the cluster, with a vulnerability summary for each namespace diff --git a/pkg/registry/file/vulnerabilitysummarystorage_test.go b/pkg/registry/file/vulnerabilitysummarystorage_test.go index 2da244474..79307dc37 100644 --- a/pkg/registry/file/vulnerabilitysummarystorage_test.go +++ b/pkg/registry/file/vulnerabilitysummarystorage_test.go @@ -2,6 +2,7 @@ package file import ( "context" + "fmt" "testing" "time" @@ -268,6 +269,66 @@ func TestVulnSummaryStorageImpl_GetList(t *testing.T) { } } +func TestVulnSummaryStorageImpl_GetList_Pagination(t *testing.T) { + // Regression test for issue #337: the underlying store caps each page at a + // default limit of 500 objects and returns a continuation token. GetList must + // consume that token so the cluster-wide aggregation covers every object, + // otherwise namespaces beyond the first page silently disappear. + const ( + alphaCount = 500 // fills the entire first page on its own + betaCount = 200 // lives entirely beyond the first page + ) + + pool := NewTestPool(t.TempDir()) + require.NotNil(t, pool) + defer func(pool *sqlitemigration.Pool) { + _ = pool.Close() + }(pool) + sch := scheme.Scheme + require.NoError(t, softwarecomposition.AddToScheme(sch)) + realStorage := NewStorageImpl(afero.NewMemMapFs(), "/", pool, nil, sch) + + ctx, cancel := context.WithTimeout(context.TODO(), 60*time.Second) + defer cancel() + + // objects are listed ordered by creation (rowid), so creating all of "alpha" + // before "beta" guarantees "beta" falls entirely on the second page. + createManifestSummaries := func(namespace string, count int) { + for i := range count { + name := fmt.Sprintf("workload-%d", i) + obj := &softwarecomposition.VulnerabilityManifestSummary{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + key := fmt.Sprintf("/spdx.softwarecomposition.kubescape.io/vulnerabilitymanifestsummaries/%s/%s", namespace, name) + require.NoError(t, realStorage.Create(ctx, key, obj, nil, 0)) + } + } + createManifestSummaries("alpha", alphaCount) + createManifestSummaries("beta", betaCount) + + s := NewVulnerabilitySummaryStorage(realStorage) + o := &softwarecomposition.VulnerabilitySummaryList{} + require.NoError(t, s.GetList(ctx, "/spdx.softwarecomposition.kubescape.io/vulnerabilitysummaries", storage.ListOptions{}, o)) + + // Both namespaces must be present even though "beta" lives entirely on the + // second page. Before the fix, only the first page (500 objects) was read, so + // "beta" disappeared and the list contained a single namespace. + require.Len(t, o.Items, 2, "both namespaces must be present, including the one beyond the first page") + + // Each manifest summary contributes one scope to its namespace's summary, so + // the total scope count must equal every created object. Before the fix this + // totalled only 500 (the truncated first page). + scopesByNamespace := map[string]int{} + for _, item := range o.Items { + scopesByNamespace[item.Name] = len(item.Spec.WorkloadVulnerabilitiesObj) + } + assert.Equal(t, alphaCount, scopesByNamespace["alpha"], "alpha must aggregate all objects") + assert.Equal(t, betaCount, scopesByNamespace["beta"], "beta must not be truncated/dropped by the page limit") +} + func TestVulnSummaryStorageImpl_GuaranteedUpdate(t *testing.T) { type args struct { key string