From 58ec62b3e48a0d0a32a880146d8e9fae29691b28 Mon Sep 17 00:00:00 2001 From: Andrei Aaron Date: Wed, 25 Jan 2023 01:03:10 +0200 Subject: [PATCH] feat(cve): graphql: paginate returned CVEs for a given image (#1136) Signed-off-by: Andrei Aaron --- pkg/extensions/search/cve/cve.go | 49 ++- pkg/extensions/search/cve/cve_test.go | 83 ++-- pkg/extensions/search/cve/pagination.go | 144 +++++++ pkg/extensions/search/cve/pagination_test.go | 355 ++++++++++++++++++ .../search/gql_generated/generated.go | 84 ++++- .../search/gql_generated/models_gen.go | 9 +- pkg/extensions/search/resolver.go | 28 +- pkg/extensions/search/resolver_test.go | 15 +- pkg/extensions/search/schema.graphql | 6 +- pkg/extensions/search/schema.resolvers.go | 4 +- pkg/test/mocks/cve_mock.go | 21 +- 11 files changed, 724 insertions(+), 74 deletions(-) create mode 100644 pkg/extensions/search/cve/pagination.go create mode 100644 pkg/extensions/search/cve/pagination_test.go diff --git a/pkg/extensions/search/cve/cve.go b/pkg/extensions/search/cve/cve.go index 30679e67..ef398135 100644 --- a/pkg/extensions/search/cve/cve.go +++ b/pkg/extensions/search/cve/cve.go @@ -18,8 +18,9 @@ import ( type CveInfo interface { GetImageListForCVE(repo, cveID string) ([]common.TagInfo, error) GetImageListWithCVEFixed(repo, cveID string) ([]common.TagInfo, error) - GetCVEListForImage(image string) (map[string]cvemodel.CVE, error) + GetCVEListForImage(image string, pageinput PageInput) ([]cvemodel.CVE, PageInfo, error) GetCVESummaryForImage(image string) (ImageCVESummary, error) + CompareSeverities(severity1, severity2 string) int UpdateDB() error } @@ -102,15 +103,11 @@ func (cveinfo BaseCveInfo) GetImageListForCVE(repo, cveID string) ([]common.TagI continue } - for id := range cveMap { - if id == cveID { - imgList = append(imgList, common.TagInfo{ - Name: tag, - Digest: manifestDigest, - }) - - break - } + if _, hasCVE := cveMap[cveID]; hasCVE { + imgList = append(imgList, common.TagInfo{ + Name: tag, + Digest: manifestDigest, + }) } } @@ -208,15 +205,33 @@ func (cveinfo BaseCveInfo) GetImageListWithCVEFixed(repo, cveID string) ([]commo return fixedTags, nil } -func (cveinfo BaseCveInfo) GetCVEListForImage(image string) (map[string]cvemodel.CVE, error) { - cveMap := make(map[string]cvemodel.CVE) - +func (cveinfo BaseCveInfo) GetCVEListForImage(image string, pageInput PageInput) ( + []cvemodel.CVE, + PageInfo, + error, +) { isValidImage, err := cveinfo.Scanner.IsImageFormatScannable(image) if !isValidImage { - return cveMap, err + return []cvemodel.CVE{}, PageInfo{}, err } - return cveinfo.Scanner.ScanImage(image) + cveMap, err := cveinfo.Scanner.ScanImage(image) + if err != nil { + return []cvemodel.CVE{}, PageInfo{}, err + } + + pageFinder, err := NewCvePageFinder(pageInput.Limit, pageInput.Offset, pageInput.SortBy, cveinfo) + if err != nil { + return []cvemodel.CVE{}, PageInfo{}, err + } + + for _, cve := range cveMap { + pageFinder.Add(cve) + } + + cveList, pageInfo := pageFinder.Page() + + return cveList, pageInfo, nil } func (cveinfo BaseCveInfo) GetCVESummaryForImage(image string) (ImageCVESummary, error) { @@ -260,3 +275,7 @@ func (cveinfo BaseCveInfo) GetCVESummaryForImage(image string) (ImageCVESummary, func (cveinfo BaseCveInfo) UpdateDB() error { return cveinfo.Scanner.UpdateDB() } + +func (cveinfo BaseCveInfo) CompareSeverities(severity1, severity2 string) int { + return cveinfo.Scanner.CompareSeverities(severity1, severity2) +} diff --git a/pkg/extensions/search/cve/cve_test.go b/pkg/extensions/search/cve/cve_test.go index ae581820..13dd4749 100644 --- a/pkg/extensions/search/cve/cve_test.go +++ b/pkg/extensions/search/cve/cve_test.go @@ -1216,58 +1216,75 @@ func TestCVEStruct(t *testing.T) { t.Log("Test GetCVEListForImage") + pageInput := cveinfo.PageInput{ + SortBy: cveinfo.SeverityDsc, + } + // Image is found - cveMap, err := cveInfo.GetCVEListForImage("repo1:0.1.0") + cveList, pageInfo, err := cveInfo.GetCVEListForImage("repo1:0.1.0", pageInput) So(err, ShouldBeNil) - So(len(cveMap), ShouldEqual, 1) - So(cveMap, ShouldContainKey, "CVE1") - So(cveMap, ShouldNotContainKey, "CVE2") - So(cveMap, ShouldNotContainKey, "CVE3") + So(len(cveList), ShouldEqual, 1) + So(cveList[0].ID, ShouldEqual, "CVE1") + So(pageInfo.ItemCount, ShouldEqual, 1) + So(pageInfo.TotalCount, ShouldEqual, 1) - cveMap, err = cveInfo.GetCVEListForImage("repo1:1.0.0") + cveList, pageInfo, err = cveInfo.GetCVEListForImage("repo1:1.0.0", pageInput) So(err, ShouldBeNil) - So(len(cveMap), ShouldEqual, 3) - So(cveMap, ShouldContainKey, "CVE1") - So(cveMap, ShouldContainKey, "CVE2") - So(cveMap, ShouldContainKey, "CVE3") + So(len(cveList), ShouldEqual, 3) + So(cveList[0].ID, ShouldEqual, "CVE2") + So(cveList[1].ID, ShouldEqual, "CVE1") + So(cveList[2].ID, ShouldEqual, "CVE3") + So(pageInfo.ItemCount, ShouldEqual, 3) + So(pageInfo.TotalCount, ShouldEqual, 3) - cveMap, err = cveInfo.GetCVEListForImage("repo1:1.0.1") + cveList, pageInfo, err = cveInfo.GetCVEListForImage("repo1:1.0.1", pageInput) So(err, ShouldBeNil) - So(len(cveMap), ShouldEqual, 2) - So(cveMap, ShouldContainKey, "CVE1") - So(cveMap, ShouldNotContainKey, "CVE2") - So(cveMap, ShouldContainKey, "CVE3") + So(len(cveList), ShouldEqual, 2) + So(cveList[0].ID, ShouldEqual, "CVE1") + So(cveList[1].ID, ShouldEqual, "CVE3") + So(pageInfo.ItemCount, ShouldEqual, 2) + So(pageInfo.TotalCount, ShouldEqual, 2) - cveMap, err = cveInfo.GetCVEListForImage("repo1:1.1.0") + cveList, pageInfo, err = cveInfo.GetCVEListForImage("repo1:1.1.0", pageInput) So(err, ShouldBeNil) - So(len(cveMap), ShouldEqual, 1) - So(cveMap, ShouldNotContainKey, "CVE1") - So(cveMap, ShouldNotContainKey, "CVE2") - So(cveMap, ShouldContainKey, "CVE3") + So(len(cveList), ShouldEqual, 1) + So(cveList[0].ID, ShouldEqual, "CVE3") + So(pageInfo.ItemCount, ShouldEqual, 1) + So(pageInfo.TotalCount, ShouldEqual, 1) - cveMap, err = cveInfo.GetCVEListForImage("repo6:1.0.0") + cveList, pageInfo, err = cveInfo.GetCVEListForImage("repo6:1.0.0", pageInput) So(err, ShouldBeNil) - So(len(cveMap), ShouldEqual, 0) + So(len(cveList), ShouldEqual, 0) + So(pageInfo.ItemCount, ShouldEqual, 0) + So(pageInfo.TotalCount, ShouldEqual, 0) // Image is not scannable - cveMap, err = cveInfo.GetCVEListForImage("repo2:1.0.0") + cveList, pageInfo, err = cveInfo.GetCVEListForImage("repo2:1.0.0", pageInput) So(err, ShouldEqual, zerr.ErrScanNotSupported) - So(len(cveMap), ShouldEqual, 0) + So(len(cveList), ShouldEqual, 0) + So(pageInfo.ItemCount, ShouldEqual, 0) + So(pageInfo.TotalCount, ShouldEqual, 0) // Tag is not found - cveMap, err = cveInfo.GetCVEListForImage("repo3:1.0.0") + cveList, pageInfo, err = cveInfo.GetCVEListForImage("repo3:1.0.0", pageInput) So(err, ShouldEqual, zerr.ErrTagMetaNotFound) - So(len(cveMap), ShouldEqual, 0) + So(len(cveList), ShouldEqual, 0) + So(pageInfo.ItemCount, ShouldEqual, 0) + So(pageInfo.TotalCount, ShouldEqual, 0) // Manifest is not found - cveMap, err = cveInfo.GetCVEListForImage("repo5:nonexitent-manifest") + cveList, pageInfo, err = cveInfo.GetCVEListForImage("repo5:nonexitent-manifest", pageInput) So(err, ShouldEqual, zerr.ErrManifestDataNotFound) - So(len(cveMap), ShouldEqual, 0) + So(len(cveList), ShouldEqual, 0) + So(pageInfo.ItemCount, ShouldEqual, 0) + So(pageInfo.TotalCount, ShouldEqual, 0) // Repo is not found - cveMap, err = cveInfo.GetCVEListForImage("repo100:1.0.0") + cveList, pageInfo, err = cveInfo.GetCVEListForImage("repo100:1.0.0", pageInput) So(err, ShouldEqual, zerr.ErrRepoMetaNotFound) - So(len(cveMap), ShouldEqual, 0) + So(len(cveList), ShouldEqual, 0) + So(pageInfo.ItemCount, ShouldEqual, 0) + So(pageInfo.TotalCount, ShouldEqual, 0) t.Log("Test GetImageListWithCVEFixed") @@ -1383,9 +1400,11 @@ func TestCVEStruct(t *testing.T) { So(cveSummary.Count, ShouldEqual, 0) So(cveSummary.MaxSeverity, ShouldEqual, "") - cveMap, err = cveInfo.GetCVEListForImage("repo1:0.1.0") + cveList, pageInfo, err = cveInfo.GetCVEListForImage("repo1:0.1.0", pageInput) So(err, ShouldNotBeNil) - So(cveMap, ShouldBeNil) + So(cveList, ShouldBeEmpty) + So(pageInfo.ItemCount, ShouldEqual, 0) + So(pageInfo.TotalCount, ShouldEqual, 0) tagList, err = cveInfo.GetImageListWithCVEFixed("repo1", "CVE1") // CVE is not considered fixed as scan is not possible diff --git a/pkg/extensions/search/cve/pagination.go b/pkg/extensions/search/cve/pagination.go new file mode 100644 index 00000000..db2543f3 --- /dev/null +++ b/pkg/extensions/search/cve/pagination.go @@ -0,0 +1,144 @@ +package cveinfo + +import ( + "sort" + + "github.com/pkg/errors" + + zerr "zotregistry.io/zot/errors" + cvemodel "zotregistry.io/zot/pkg/extensions/search/cve/model" +) + +type SortCriteria string + +const ( + AlphabeticAsc = SortCriteria("ALPHABETIC_ASC") + AlphabeticDsc = SortCriteria("ALPHABETIC_DSC") + SeverityDsc = SortCriteria("SEVERITY") +) + +func SortFunctions() map[SortCriteria]func(pageBuffer []cvemodel.CVE, cveInfo CveInfo) func(i, j int) bool { + return map[SortCriteria]func(pageBuffer []cvemodel.CVE, cveInfo CveInfo) func(i, j int) bool{ + AlphabeticAsc: SortByAlphabeticAsc, + AlphabeticDsc: SortByAlphabeticDsc, + SeverityDsc: SortBySeverity, + } +} + +func SortByAlphabeticAsc(pageBuffer []cvemodel.CVE, cveInfo CveInfo) func(i, j int) bool { + return func(i, j int) bool { + return pageBuffer[i].ID < pageBuffer[j].ID + } +} + +func SortByAlphabeticDsc(pageBuffer []cvemodel.CVE, cveInfo CveInfo) func(i, j int) bool { + return func(i, j int) bool { + return pageBuffer[i].ID > pageBuffer[j].ID + } +} + +func SortBySeverity(pageBuffer []cvemodel.CVE, cveInfo CveInfo) func(i, j int) bool { + return func(i, j int) bool { + return cveInfo.CompareSeverities(pageBuffer[i].Severity, pageBuffer[j].Severity) < 0 + } +} + +// PageFinder permits keeping a pool of objects using Add +// and returning a specific page. +type PageFinder interface { + Add(cve cvemodel.CVE) + Page() ([]cvemodel.CVE, PageInfo) + Reset() +} + +// CvePageFinder implements PageFinder. It manages Cve objects and calculates the page +// using the given limit, offset and sortBy option. +type CvePageFinder struct { + limit int + offset int + sortBy SortCriteria + pageBuffer []cvemodel.CVE + cveInfo CveInfo +} + +func NewCvePageFinder(limit, offset int, sortBy SortCriteria, cveInfo CveInfo) (*CvePageFinder, error) { + if sortBy == "" { + sortBy = SeverityDsc + } + + if limit < 0 { + return nil, zerr.ErrLimitIsNegative + } + + if offset < 0 { + return nil, zerr.ErrOffsetIsNegative + } + + if _, found := SortFunctions()[sortBy]; !found { + return nil, errors.Wrapf(zerr.ErrSortCriteriaNotSupported, "sorting CVEs by '%s' is not supported", sortBy) + } + + return &CvePageFinder{ + limit: limit, + offset: offset, + sortBy: sortBy, + pageBuffer: make([]cvemodel.CVE, 0, limit), + cveInfo: cveInfo, + }, nil +} + +func (bpt *CvePageFinder) Reset() { + bpt.pageBuffer = []cvemodel.CVE{} +} + +func (bpt *CvePageFinder) Add(cve cvemodel.CVE) { + bpt.pageBuffer = append(bpt.pageBuffer, cve) +} + +func (bpt *CvePageFinder) Page() ([]cvemodel.CVE, PageInfo) { + if len(bpt.pageBuffer) == 0 { + return []cvemodel.CVE{}, PageInfo{} + } + + pageInfo := &PageInfo{} + + sort.Slice(bpt.pageBuffer, SortFunctions()[bpt.sortBy](bpt.pageBuffer, bpt.cveInfo)) + + // the offset and limit are calculated in terms of CVEs counted + start := bpt.offset + end := bpt.offset + bpt.limit + + // we'll return an empty array when the offset is greater than the number of elements + if start >= len(bpt.pageBuffer) { + start = len(bpt.pageBuffer) + end = start + } + + if end >= len(bpt.pageBuffer) { + end = len(bpt.pageBuffer) + } + + cves := bpt.pageBuffer[start:end] + + pageInfo.ItemCount = len(cves) + + if start == 0 && end == 0 { + cves = bpt.pageBuffer + pageInfo.ItemCount = len(cves) + } + + pageInfo.TotalCount = len(bpt.pageBuffer) + + return cves, *pageInfo +} + +type PageInfo struct { + TotalCount int + ItemCount int +} + +type PageInput struct { + Limit int + Offset int + SortBy SortCriteria +} diff --git a/pkg/extensions/search/cve/pagination_test.go b/pkg/extensions/search/cve/pagination_test.go new file mode 100644 index 00000000..5f7fbebb --- /dev/null +++ b/pkg/extensions/search/cve/pagination_test.go @@ -0,0 +1,355 @@ +package cveinfo_test + +import ( + "encoding/json" + "fmt" + "sort" + "testing" + "time" + + godigest "github.com/opencontainers/go-digest" + ispec "github.com/opencontainers/image-spec/specs-go/v1" + . "github.com/smartystreets/goconvey/convey" + + cveinfo "zotregistry.io/zot/pkg/extensions/search/cve" + cvemodel "zotregistry.io/zot/pkg/extensions/search/cve/model" + "zotregistry.io/zot/pkg/log" + "zotregistry.io/zot/pkg/meta/repodb" + bolt "zotregistry.io/zot/pkg/meta/repodb/boltdb-wrapper" + "zotregistry.io/zot/pkg/test/mocks" +) + +func TestCVEPagination(t *testing.T) { + Convey("CVE Pagination", t, func() { + repoDB, err := bolt.NewBoltDBWrapper(bolt.DBParameters{ + RootDir: t.TempDir(), + }) + So(err, ShouldBeNil) + + // Create repodb data for scannable image with vulnerabilities + timeStamp11 := time.Date(2008, 1, 1, 12, 0, 0, 0, time.UTC) + + configBlob11, err := json.Marshal(ispec.Image{ + Created: &timeStamp11, + }) + So(err, ShouldBeNil) + + manifestBlob11, err := json.Marshal(ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: 0, + Digest: godigest.FromBytes(configBlob11), + }, + Layers: []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageLayerGzip, + Size: 0, + Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), + }, + }, + }) + So(err, ShouldBeNil) + + repoMeta11 := repodb.ManifestMetadata{ + ManifestBlob: manifestBlob11, + ConfigBlob: configBlob11, + } + + digest11 := godigest.FromBytes(manifestBlob11) + err = repoDB.SetManifestMeta("repo1", digest11, repoMeta11) + So(err, ShouldBeNil) + err = repoDB.SetRepoTag("repo1", "0.1.0", digest11, ispec.MediaTypeImageManifest) + So(err, ShouldBeNil) + + timeStamp12 := time.Date(2009, 1, 1, 12, 0, 0, 0, time.UTC) + + configBlob12, err := json.Marshal(ispec.Image{ + Created: &timeStamp12, + }) + So(err, ShouldBeNil) + + manifestBlob12, err := json.Marshal(ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: 0, + Digest: godigest.FromBytes(configBlob12), + }, + Layers: []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageLayerGzip, + Size: 0, + Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), + }, + }, + }) + So(err, ShouldBeNil) + + repoMeta12 := repodb.ManifestMetadata{ + ManifestBlob: manifestBlob12, + ConfigBlob: configBlob12, + } + + digest12 := godigest.FromBytes(manifestBlob12) + err = repoDB.SetManifestMeta("repo1", digest12, repoMeta12) + So(err, ShouldBeNil) + err = repoDB.SetRepoTag("repo1", "1.0.0", digest12, ispec.MediaTypeImageManifest) + So(err, ShouldBeNil) + + // RepoDB loaded with initial data, mock the scanner + severityToInt := map[string]int{ + "UNKNOWN": 0, + "LOW": 1, + "MEDIUM": 2, + "HIGH": 3, + "CRITICAL": 4, + } + + intToSeverity := make(map[int]string, len(severityToInt)) + for k, v := range severityToInt { + intToSeverity[v] = k + } + + // Setup test CVE data in mock scanner + scanner := mocks.CveScannerMock{ + ScanImageFn: func(image string) (map[string]cvemodel.CVE, error) { + cveMap := map[string]cvemodel.CVE{} + + if image == "repo1:0.1.0" { + for i := 0; i < 5; i++ { + cveMap[fmt.Sprintf("CVE%d", i)] = cvemodel.CVE{ + ID: fmt.Sprintf("CVE%d", i), + Severity: intToSeverity[i%5], + Title: fmt.Sprintf("Title for CVE%d", i), + Description: fmt.Sprintf("Description for CVE%d", i), + } + } + } + + if image == "repo1:1.0.0" { + for i := 0; i < 30; i++ { + cveMap[fmt.Sprintf("CVE%d", i)] = cvemodel.CVE{ + ID: fmt.Sprintf("CVE%d", i), + Severity: intToSeverity[i%5], + Title: fmt.Sprintf("Title for CVE%d", i), + Description: fmt.Sprintf("Description for CVE%d", i), + } + } + } + + // By default the image has no vulnerabilities + return cveMap, nil + }, + CompareSeveritiesFn: func(severity1, severity2 string) int { + return severityToInt[severity2] - severityToInt[severity1] + }, + } + + log := log.NewLogger("debug", "") + cveInfo := cveinfo.BaseCveInfo{Log: log, Scanner: scanner, RepoDB: repoDB} + + Convey("create new paginator errors", func() { + paginator, err := cveinfo.NewCvePageFinder(-1, 10, cveinfo.AlphabeticAsc, cveInfo) + So(paginator, ShouldBeNil) + So(err, ShouldNotBeNil) + + paginator, err = cveinfo.NewCvePageFinder(2, -1, cveinfo.AlphabeticAsc, cveInfo) + So(paginator, ShouldBeNil) + So(err, ShouldNotBeNil) + + paginator, err = cveinfo.NewCvePageFinder(2, 1, "wrong sorting criteria", cveInfo) + So(paginator, ShouldBeNil) + So(err, ShouldNotBeNil) + }) + + Convey("Reset", func() { + paginator, err := cveinfo.NewCvePageFinder(1, 0, cveinfo.AlphabeticAsc, cveInfo) + So(err, ShouldBeNil) + So(paginator, ShouldNotBeNil) + + paginator.Add(cvemodel.CVE{}) + paginator.Add(cvemodel.CVE{}) + paginator.Add(cvemodel.CVE{}) + + paginator.Reset() + + result, _ := paginator.Page() + So(result, ShouldBeEmpty) + }) + + Convey("Page", func() { + Convey("defaults", func() { + // By default expect unlimitted results sorted by severity + cves, pageInfo, err := cveInfo.GetCVEListForImage("repo1:0.1.0", cveinfo.PageInput{}) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 5) + So(pageInfo.ItemCount, ShouldEqual, 5) + So(pageInfo.TotalCount, ShouldEqual, 5) + previousSeverity := 4 + for _, cve := range cves { + So(severityToInt[cve.Severity], ShouldBeLessThanOrEqualTo, previousSeverity) + previousSeverity = severityToInt[cve.Severity] + } + + cves, pageInfo, err = cveInfo.GetCVEListForImage("repo1:1.0.0", cveinfo.PageInput{}) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 30) + So(pageInfo.ItemCount, ShouldEqual, 30) + So(pageInfo.TotalCount, ShouldEqual, 30) + previousSeverity = 4 + for _, cve := range cves { + So(severityToInt[cve.Severity], ShouldBeLessThanOrEqualTo, previousSeverity) + previousSeverity = severityToInt[cve.Severity] + } + }) + + Convey("no limit or offset", func() { + cveIds := []string{} + for i := 0; i < 30; i++ { + cveIds = append(cveIds, fmt.Sprintf("CVE%d", i)) + } + + cves, pageInfo, err := cveInfo.GetCVEListForImage("repo1:0.1.0", cveinfo.PageInput{SortBy: cveinfo.AlphabeticAsc}) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 5) + So(pageInfo.ItemCount, ShouldEqual, 5) + So(pageInfo.TotalCount, ShouldEqual, 5) + for i, cve := range cves { + So(cve.ID, ShouldEqual, cveIds[i]) + } + + sort.Strings(cveIds) + cves, pageInfo, err = cveInfo.GetCVEListForImage("repo1:1.0.0", cveinfo.PageInput{SortBy: cveinfo.AlphabeticAsc}) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 30) + So(pageInfo.ItemCount, ShouldEqual, 30) + So(pageInfo.TotalCount, ShouldEqual, 30) + for i, cve := range cves { + So(cve.ID, ShouldEqual, cveIds[i]) + } + + sort.Sort(sort.Reverse(sort.StringSlice(cveIds))) + cves, pageInfo, err = cveInfo.GetCVEListForImage("repo1:1.0.0", cveinfo.PageInput{SortBy: cveinfo.AlphabeticDsc}) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 30) + So(pageInfo.ItemCount, ShouldEqual, 30) + So(pageInfo.TotalCount, ShouldEqual, 30) + for i, cve := range cves { + So(cve.ID, ShouldEqual, cveIds[i]) + } + + cves, pageInfo, err = cveInfo.GetCVEListForImage("repo1:1.0.0", cveinfo.PageInput{SortBy: cveinfo.SeverityDsc}) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 30) + So(pageInfo.ItemCount, ShouldEqual, 30) + So(pageInfo.TotalCount, ShouldEqual, 30) + previousSeverity := 4 + for _, cve := range cves { + So(severityToInt[cve.Severity], ShouldBeLessThanOrEqualTo, previousSeverity) + previousSeverity = severityToInt[cve.Severity] + } + }) + + Convey("limit < len(cves)", func() { + cveIds := []string{} + for i := 0; i < 30; i++ { + cveIds = append(cveIds, fmt.Sprintf("CVE%d", i)) + } + + cves, pageInfo, err := cveInfo.GetCVEListForImage("repo1:0.1.0", cveinfo.PageInput{ + Limit: 3, + Offset: 1, + SortBy: cveinfo.AlphabeticAsc, + }) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 3) + So(pageInfo.ItemCount, ShouldEqual, 3) + So(pageInfo.TotalCount, ShouldEqual, 5) + So(cves[0].ID, ShouldEqual, "CVE1") // CVE0 is first ID and is not part of the page + So(cves[1].ID, ShouldEqual, "CVE2") + So(cves[2].ID, ShouldEqual, "CVE3") + + cves, pageInfo, err = cveInfo.GetCVEListForImage("repo1:0.1.0", cveinfo.PageInput{ + Limit: 2, + Offset: 1, + SortBy: cveinfo.AlphabeticDsc, + }) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 2) + So(pageInfo.ItemCount, ShouldEqual, 2) + So(pageInfo.TotalCount, ShouldEqual, 5) + So(cves[0].ID, ShouldEqual, "CVE3") + So(cves[1].ID, ShouldEqual, "CVE2") + + cves, pageInfo, err = cveInfo.GetCVEListForImage("repo1:0.1.0", cveinfo.PageInput{ + Limit: 3, + Offset: 1, + SortBy: cveinfo.SeverityDsc, + }) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 3) + So(pageInfo.ItemCount, ShouldEqual, 3) + So(pageInfo.TotalCount, ShouldEqual, 5) + previousSeverity := 4 + for _, cve := range cves { + So(severityToInt[cve.Severity], ShouldBeLessThanOrEqualTo, previousSeverity) + previousSeverity = severityToInt[cve.Severity] + } + + sort.Strings(cveIds) + cves, pageInfo, err = cveInfo.GetCVEListForImage("repo1:1.0.0", cveinfo.PageInput{ + Limit: 5, + Offset: 20, + SortBy: cveinfo.AlphabeticAsc, + }) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 5) + So(pageInfo.ItemCount, ShouldEqual, 5) + So(pageInfo.TotalCount, ShouldEqual, 30) + for i, cve := range cves { + So(cve.ID, ShouldEqual, cveIds[i+20]) + } + }) + + Convey("limit > len(cves)", func() { + cves, pageInfo, err := cveInfo.GetCVEListForImage("repo1:0.1.0", cveinfo.PageInput{ + Limit: 6, + Offset: 3, + SortBy: cveinfo.AlphabeticAsc, + }) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 2) + So(pageInfo.ItemCount, ShouldEqual, 2) + So(pageInfo.TotalCount, ShouldEqual, 5) + So(cves[0].ID, ShouldEqual, "CVE3") + So(cves[1].ID, ShouldEqual, "CVE4") + + cves, pageInfo, err = cveInfo.GetCVEListForImage("repo1:0.1.0", cveinfo.PageInput{ + Limit: 6, + Offset: 3, + SortBy: cveinfo.AlphabeticDsc, + }) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 2) + So(pageInfo.ItemCount, ShouldEqual, 2) + So(pageInfo.TotalCount, ShouldEqual, 5) + So(cves[0].ID, ShouldEqual, "CVE1") + So(cves[1].ID, ShouldEqual, "CVE0") + + cves, pageInfo, err = cveInfo.GetCVEListForImage("repo1:0.1.0", cveinfo.PageInput{ + Limit: 6, + Offset: 3, + SortBy: cveinfo.SeverityDsc, + }) + So(err, ShouldBeNil) + So(len(cves), ShouldEqual, 2) + So(pageInfo.ItemCount, ShouldEqual, 2) + So(pageInfo.TotalCount, ShouldEqual, 5) + previousSeverity := 4 + for _, cve := range cves { + So(severityToInt[cve.Severity], ShouldBeLessThanOrEqualTo, previousSeverity) + previousSeverity = severityToInt[cve.Severity] + } + }) + }) + }) +} diff --git a/pkg/extensions/search/gql_generated/generated.go b/pkg/extensions/search/gql_generated/generated.go index 246d5f49..53605281 100644 --- a/pkg/extensions/search/gql_generated/generated.go +++ b/pkg/extensions/search/gql_generated/generated.go @@ -58,6 +58,7 @@ type ComplexityRoot struct { CVEResultForImage struct { CVEList func(childComplexity int) int + Page func(childComplexity int) int Tag func(childComplexity int) int } @@ -144,7 +145,7 @@ type ComplexityRoot struct { Query struct { BaseImageList func(childComplexity int, image string) int - CVEListForImage func(childComplexity int, image string) int + CVEListForImage func(childComplexity int, image string, requestedPage *PageInput) int DerivedImageList func(childComplexity int, image string) int ExpandedRepoInfo func(childComplexity int, repo string) int GlobalSearch func(childComplexity int, query string, filter *Filter, requestedPage *PageInput) int @@ -186,7 +187,7 @@ type ComplexityRoot struct { } type QueryResolver interface { - CVEListForImage(ctx context.Context, image string) (*CVEResultForImage, error) + CVEListForImage(ctx context.Context, image string, requestedPage *PageInput) (*CVEResultForImage, error) ImageListForCve(ctx context.Context, id string, requestedPage *PageInput) ([]*ImageSummary, error) ImageListWithCVEFixed(ctx context.Context, id string, image string, requestedPage *PageInput) ([]*ImageSummary, error) ImageListForDigest(ctx context.Context, id string, requestedPage *PageInput) ([]*ImageSummary, error) @@ -271,6 +272,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.CVEResultForImage.CVEList(childComplexity), true + case "CVEResultForImage.Page": + if e.complexity.CVEResultForImage.Page == nil { + break + } + + return e.complexity.CVEResultForImage.Page(childComplexity), true + case "CVEResultForImage.Tag": if e.complexity.CVEResultForImage.Tag == nil { break @@ -636,7 +644,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Query.CVEListForImage(childComplexity, args["image"].(string)), true + return e.complexity.Query.CVEListForImage(childComplexity, args["image"].(string), args["requestedPage"].(*PageInput)), true case "Query.DerivedImageList": if e.complexity.Query.DerivedImageList == nil { @@ -947,6 +955,7 @@ Contains the tag of the image and a list of CVEs type CVEResultForImage { Tag: String CVEList: [CVE] + Page: PageInfo } """ @@ -1103,6 +1112,7 @@ enum SortCriteria { UPDATE_TIME ALPHABETIC_ASC ALPHABETIC_DSC + SEVERITY STARS DOWNLOADS } @@ -1141,9 +1151,9 @@ input Filter { type Query { """ - Returns a CVE list for the image specified in the arugment + Returns a CVE list for the image specified in the argument. Format image:tag """ - CVEListForImage(image: String!): CVEResultForImage! + CVEListForImage(image: String!, requestedPage: PageInput): CVEResultForImage! """ Returns a list of images vulnerable to the CVE of the specified ID @@ -1236,6 +1246,15 @@ func (ec *executionContext) field_Query_CVEListForImage_args(ctx context.Context } } args["image"] = arg0 + var arg1 *PageInput + if tmp, ok := rawArgs["requestedPage"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("requestedPage")) + arg1, err = ec.unmarshalOPageInput2ᚖzotregistryᚗioᚋzotᚋpkgᚋextensionsᚋsearchᚋgql_generatedᚐPageInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["requestedPage"] = arg1 return args, nil } @@ -1912,6 +1931,53 @@ func (ec *executionContext) fieldContext_CVEResultForImage_CVEList(ctx context.C return fc, nil } +func (ec *executionContext) _CVEResultForImage_Page(ctx context.Context, field graphql.CollectedField, obj *CVEResultForImage) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_CVEResultForImage_Page(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Page, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*PageInfo) + fc.Result = res + return ec.marshalOPageInfo2ᚖzotregistryᚗioᚋzotᚋpkgᚋextensionsᚋsearchᚋgql_generatedᚐPageInfo(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_CVEResultForImage_Page(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CVEResultForImage", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "TotalCount": + return ec.fieldContext_PageInfo_TotalCount(ctx, field) + case "ItemCount": + return ec.fieldContext_PageInfo_ItemCount(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _GlobalSearchResult_Page(ctx context.Context, field graphql.CollectedField, obj *GlobalSearchResult) (ret graphql.Marshaler) { fc, err := ec.fieldContext_GlobalSearchResult_Page(ctx, field) if err != nil { @@ -4114,7 +4180,7 @@ func (ec *executionContext) _Query_CVEListForImage(ctx context.Context, field gr }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().CVEListForImage(rctx, fc.Args["image"].(string)) + return ec.resolvers.Query().CVEListForImage(rctx, fc.Args["image"].(string), fc.Args["requestedPage"].(*PageInput)) }) if err != nil { ec.Error(ctx, err) @@ -4143,6 +4209,8 @@ func (ec *executionContext) fieldContext_Query_CVEListForImage(ctx context.Conte return ec.fieldContext_CVEResultForImage_Tag(ctx, field) case "CVEList": return ec.fieldContext_CVEResultForImage_CVEList(ctx, field) + case "Page": + return ec.fieldContext_CVEResultForImage_Page(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type CVEResultForImage", field.Name) }, @@ -8038,6 +8106,10 @@ func (ec *executionContext) _CVEResultForImage(ctx context.Context, sel ast.Sele out.Values[i] = ec._CVEResultForImage_CVEList(ctx, field, obj) + case "Page": + + out.Values[i] = ec._CVEResultForImage_Page(ctx, field, obj) + default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/pkg/extensions/search/gql_generated/models_gen.go b/pkg/extensions/search/gql_generated/models_gen.go index 176f1457..ec703dca 100644 --- a/pkg/extensions/search/gql_generated/models_gen.go +++ b/pkg/extensions/search/gql_generated/models_gen.go @@ -25,8 +25,9 @@ type Cve struct { // Contains the tag of the image and a list of CVEs type CVEResultForImage struct { - Tag *string `json:"Tag"` - CVEList []*Cve `json:"CVEList"` + Tag *string `json:"Tag"` + CVEList []*Cve `json:"CVEList"` + Page *PageInfo `json:"Page"` } type Filter struct { @@ -167,6 +168,7 @@ const ( SortCriteriaUpdateTime SortCriteria = "UPDATE_TIME" SortCriteriaAlphabeticAsc SortCriteria = "ALPHABETIC_ASC" SortCriteriaAlphabeticDsc SortCriteria = "ALPHABETIC_DSC" + SortCriteriaSeverity SortCriteria = "SEVERITY" SortCriteriaStars SortCriteria = "STARS" SortCriteriaDownloads SortCriteria = "DOWNLOADS" ) @@ -176,13 +178,14 @@ var AllSortCriteria = []SortCriteria{ SortCriteriaUpdateTime, SortCriteriaAlphabeticAsc, SortCriteriaAlphabeticDsc, + SortCriteriaSeverity, SortCriteriaStars, SortCriteriaDownloads, } func (e SortCriteria) IsValid() bool { switch e { - case SortCriteriaRelevance, SortCriteriaUpdateTime, SortCriteriaAlphabeticAsc, SortCriteriaAlphabeticDsc, SortCriteriaStars, SortCriteriaDownloads: + case SortCriteriaRelevance, SortCriteriaUpdateTime, SortCriteriaAlphabeticAsc, SortCriteriaAlphabeticDsc, SortCriteriaSeverity, SortCriteriaStars, SortCriteriaDownloads: return true } return false diff --git a/pkg/extensions/search/resolver.go b/pkg/extensions/search/resolver.go index a08098a1..a16ef7a8 100644 --- a/pkg/extensions/search/resolver.go +++ b/pkg/extensions/search/resolver.go @@ -195,23 +195,36 @@ func getCVEListForImage( ctx context.Context, //nolint:unparam // may be used in the future to filter by permissions image string, cveInfo cveinfo.CveInfo, + requestedPage *gql_generated.PageInput, log log.Logger, //nolint:unparam // may be used by devs for debugging ) (*gql_generated.CVEResultForImage, error) { + if requestedPage == nil { + requestedPage = &gql_generated.PageInput{} + } + + pageInput := cveinfo.PageInput{ + Limit: safeDerefferencing(requestedPage.Limit, 0), + Offset: safeDerefferencing(requestedPage.Offset, 0), + SortBy: cveinfo.SortCriteria( + safeDerefferencing(requestedPage.SortBy, gql_generated.SortCriteriaSeverity), + ), + } + _, copyImgTag := common.GetImageDirAndTag(image) if copyImgTag == "" { return &gql_generated.CVEResultForImage{}, gqlerror.Errorf("no reference provided") } - cveidMap, err := cveInfo.GetCVEListForImage(image) + cveList, pageInfo, err := cveInfo.GetCVEListForImage(image, pageInput) if err != nil { return &gql_generated.CVEResultForImage{}, err } cveids := []*gql_generated.Cve{} - for id, cveDetail := range cveidMap { - vulID := id + for _, cveDetail := range cveList { + vulID := cveDetail.ID desc := cveDetail.Description title := cveDetail.Title severity := cveDetail.Severity @@ -241,7 +254,14 @@ func getCVEListForImage( ) } - return &gql_generated.CVEResultForImage{Tag: ©ImgTag, CVEList: cveids}, nil + return &gql_generated.CVEResultForImage{ + Tag: ©ImgTag, + CVEList: cveids, + Page: &gql_generated.PageInfo{ + TotalCount: pageInfo.TotalCount, + ItemCount: pageInfo.ItemCount, + }, + }, nil } func FilterByTagInfo(tagsInfo []common.TagInfo) repodb.FilterFunc { diff --git a/pkg/extensions/search/resolver_test.go b/pkg/extensions/search/resolver_test.go index c0b298f2..429493cb 100644 --- a/pkg/extensions/search/resolver_test.go +++ b/pkg/extensions/search/resolver_test.go @@ -1020,7 +1020,7 @@ func TestImageList(t *testing.T) { }, Signatures: map[string]repodb.ManifestSignatures{ "digestTag1.0.1": { - "cosgin": []repodb.SignatureInfo{ + "cosign": []repodb.SignatureInfo{ {SignatureManifestDigest: "digestSignature1"}, }, }, @@ -1045,7 +1045,7 @@ func TestImageList(t *testing.T) { ConfigBlob: configBlob, DownloadCount: 0, Signatures: repodb.ManifestSignatures{ - "cosgin": []repodb.SignatureInfo{ + "cosign": []repodb.SignatureInfo{ {SignatureManifestDigest: "digestSignature1"}, }, }, @@ -1733,12 +1733,15 @@ func TestCVEResolvers(t *testing.T) { //nolint:gocyclo Convey("Get CVE list for image ", t, func() { Convey("Unpaginated request to get all CVEs in an image", func() { - // CVE pagination will be implemented later + sortCriteria := gql_generated.SortCriteriaAlphabeticAsc + pageInput := &gql_generated.PageInput{ + SortBy: &sortCriteria, + } responseContext := graphql.WithResponseContext(context.Background(), graphql.DefaultErrorPresenter, graphql.DefaultRecover) - cveResult, err := getCVEListForImage(responseContext, "repo1:1.0.0", cveInfo, log) + cveResult, err := getCVEListForImage(responseContext, "repo1:1.0.0", cveInfo, pageInput, log) So(err, ShouldBeNil) So(*cveResult.Tag, ShouldEqual, "1.0.0") @@ -1749,7 +1752,7 @@ func TestCVEResolvers(t *testing.T) { //nolint:gocyclo So(expectedCves, ShouldContain, *cve.ID) } - cveResult, err = getCVEListForImage(responseContext, "repo1:1.0.1", cveInfo, log) + cveResult, err = getCVEListForImage(responseContext, "repo1:1.0.1", cveInfo, pageInput, log) So(err, ShouldBeNil) So(*cveResult.Tag, ShouldEqual, "1.0.1") @@ -1760,7 +1763,7 @@ func TestCVEResolvers(t *testing.T) { //nolint:gocyclo So(expectedCves, ShouldContain, *cve.ID) } - cveResult, err = getCVEListForImage(responseContext, "repo1:1.1.0", cveInfo, log) + cveResult, err = getCVEListForImage(responseContext, "repo1:1.1.0", cveInfo, pageInput, log) So(err, ShouldBeNil) So(*cveResult.Tag, ShouldEqual, "1.1.0") diff --git a/pkg/extensions/search/schema.graphql b/pkg/extensions/search/schema.graphql index ac8d2e7d..a109a2ba 100644 --- a/pkg/extensions/search/schema.graphql +++ b/pkg/extensions/search/schema.graphql @@ -6,6 +6,7 @@ Contains the tag of the image and a list of CVEs type CVEResultForImage { Tag: String CVEList: [CVE] + Page: PageInfo } """ @@ -162,6 +163,7 @@ enum SortCriteria { UPDATE_TIME ALPHABETIC_ASC ALPHABETIC_DSC + SEVERITY STARS DOWNLOADS } @@ -200,9 +202,9 @@ input Filter { type Query { """ - Returns a CVE list for the image specified in the arugment + Returns a CVE list for the image specified in the argument. Format image:tag """ - CVEListForImage(image: String!): CVEResultForImage! + CVEListForImage(image: String!, requestedPage: PageInput): CVEResultForImage! """ Returns a list of images vulnerable to the CVE of the specified ID diff --git a/pkg/extensions/search/schema.resolvers.go b/pkg/extensions/search/schema.resolvers.go index b0895e4e..39c0c871 100644 --- a/pkg/extensions/search/schema.resolvers.go +++ b/pkg/extensions/search/schema.resolvers.go @@ -14,12 +14,12 @@ import ( ) // CVEListForImage is the resolver for the CVEListForImage field. -func (r *queryResolver) CVEListForImage(ctx context.Context, image string) (*gql_generated.CVEResultForImage, error) { +func (r *queryResolver) CVEListForImage(ctx context.Context, image string, requestedPage *gql_generated.PageInput) (*gql_generated.CVEResultForImage, error) { if r.cveInfo == nil { return &gql_generated.CVEResultForImage{}, zerr.ErrCVESearchDisabled } - return getCVEListForImage(ctx, image, r.cveInfo, r.log) + return getCVEListForImage(ctx, image, r.cveInfo, requestedPage, r.log) } // ImageListForCve is the resolver for the ImageListForCVE field. diff --git a/pkg/test/mocks/cve_mock.go b/pkg/test/mocks/cve_mock.go index b536d0d7..1931017d 100644 --- a/pkg/test/mocks/cve_mock.go +++ b/pkg/test/mocks/cve_mock.go @@ -9,8 +9,9 @@ import ( type CveInfoMock struct { GetImageListForCVEFn func(repo, cveID string) ([]common.TagInfo, error) GetImageListWithCVEFixedFn func(repo, cveID string) ([]common.TagInfo, error) - GetCVEListForImageFn func(image string) (map[string]cvemodel.CVE, error) + GetCVEListForImageFn func(image string, pageInput cveinfo.PageInput) ([]cvemodel.CVE, cveinfo.PageInfo, error) GetCVESummaryForImageFn func(image string) (cveinfo.ImageCVESummary, error) + CompareSeveritiesFn func(severity1, severity2 string) int UpdateDBFn func() error } @@ -30,12 +31,16 @@ func (cveInfo CveInfoMock) GetImageListWithCVEFixed(repo, cveID string) ([]commo return []common.TagInfo{}, nil } -func (cveInfo CveInfoMock) GetCVEListForImage(image string) (map[string]cvemodel.CVE, error) { +func (cveInfo CveInfoMock) GetCVEListForImage(image string, pageInput cveinfo.PageInput) ( + []cvemodel.CVE, + cveinfo.PageInfo, + error, +) { if cveInfo.GetCVEListForImageFn != nil { - return cveInfo.GetCVEListForImageFn(image) + return cveInfo.GetCVEListForImageFn(image, pageInput) } - return map[string]cvemodel.CVE{}, nil + return []cvemodel.CVE{}, cveinfo.PageInfo{}, nil } func (cveInfo CveInfoMock) GetCVESummaryForImage(image string) (cveinfo.ImageCVESummary, error) { @@ -46,6 +51,14 @@ func (cveInfo CveInfoMock) GetCVESummaryForImage(image string) (cveinfo.ImageCVE return cveinfo.ImageCVESummary{}, nil } +func (cveInfo CveInfoMock) CompareSeverities(severity1, severity2 string) int { + if cveInfo.CompareSeveritiesFn != nil { + return cveInfo.CompareSeveritiesFn(severity1, severity2) + } + + return 0 +} + func (cveInfo CveInfoMock) UpdateDB() error { if cveInfo.UpdateDBFn != nil { return cveInfo.UpdateDBFn()