From 3caa0f325379131a0212feb499c6678e32ac913f Mon Sep 17 00:00:00 2001 From: Andrei Aaron Date: Fri, 20 Jan 2023 22:09:40 +0200 Subject: [PATCH] feat(cve): the cve related calls to use repodb and add pagination on image results (#1118) Signed-off-by: Andrei Aaron --- pkg/extensions/search/cve/cve.go | 19 +- pkg/extensions/search/cve/cve_test.go | 107 +-- pkg/extensions/search/cve/trivy/scanner.go | 4 +- .../search/cve/trivy/scanner_internal_test.go | 188 ++++ .../search/gql_generated/generated.go | 38 +- pkg/extensions/search/resolver.go | 193 ++++ pkg/extensions/search/resolver_test.go | 838 ++++++++++++++++-- pkg/extensions/search/schema.graphql | 4 +- pkg/extensions/search/schema.resolvers.go | 136 +-- pkg/test/mocks/cve_mock.go | 6 +- 10 files changed, 1226 insertions(+), 307 deletions(-) diff --git a/pkg/extensions/search/cve/cve.go b/pkg/extensions/search/cve/cve.go index 92088872..30679e67 100644 --- a/pkg/extensions/search/cve/cve.go +++ b/pkg/extensions/search/cve/cve.go @@ -16,7 +16,7 @@ import ( ) type CveInfo interface { - GetImageListForCVE(repo, cveID string) ([]ImageInfoByCVE, error) + GetImageListForCVE(repo, cveID string) ([]common.TagInfo, error) GetImageListWithCVEFixed(repo, cveID string) ([]common.TagInfo, error) GetCVEListForImage(image string) (map[string]cvemodel.CVE, error) GetCVESummaryForImage(image string) (ImageCVESummary, error) @@ -30,12 +30,6 @@ type Scanner interface { UpdateDB() error } -type ImageInfoByCVE struct { - Tag string - Digest godigest.Digest - Manifest ispec.Manifest -} - type ImageCVESummary struct { Count int MaxSeverity string @@ -59,8 +53,8 @@ func NewCVEInfo(storeController storage.StoreController, repoDB repodb.RepoDB, } } -func (cveinfo BaseCveInfo) GetImageListForCVE(repo, cveID string) ([]ImageInfoByCVE, error) { - imgList := make([]ImageInfoByCVE, 0) +func (cveinfo BaseCveInfo) GetImageListForCVE(repo, cveID string) ([]common.TagInfo, error) { + imgList := make([]common.TagInfo, 0) repoMeta, err := cveinfo.RepoDB.GetRepoMeta(repo) if err != nil { @@ -110,10 +104,9 @@ func (cveinfo BaseCveInfo) GetImageListForCVE(repo, cveID string) ([]ImageInfoBy for id := range cveMap { if id == cveID { - imgList = append(imgList, ImageInfoByCVE{ - Tag: tag, - Digest: manifestDigest, - Manifest: manifestContent, + imgList = append(imgList, common.TagInfo{ + Name: tag, + Digest: manifestDigest, }) break diff --git a/pkg/extensions/search/cve/cve_test.go b/pkg/extensions/search/cve/cve_test.go index 78f1f8d6..ae581820 100644 --- a/pkg/extensions/search/cve/cve_test.go +++ b/pkg/extensions/search/cve/cve_test.go @@ -414,10 +414,9 @@ func TestCVESearchDisabled(t *testing.T) { ctrlManager.StartAndWait(port) // Wait for trivy db to download - _, err = ReadLogFileAndSearchString(logPath, "DB update completed, next update scheduled", 90*time.Second) - if err != nil { - panic(err) - } + found, err := ReadLogFileAndSearchString(logPath, "CVE config not provided, skipping CVE update", 90*time.Second) + So(err, ShouldBeNil) + So(found, ShouldBeTrue) defer ctrlManager.StopServer() @@ -798,6 +797,11 @@ func TestCVEStruct(t *testing.T) { 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, @@ -805,9 +809,6 @@ func TestCVEStruct(t *testing.T) { Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), }, }, - Config: ispec.Descriptor{ - Digest: godigest.FromBytes(configBlob11), - }, }) So(err, ShouldBeNil) @@ -832,6 +833,11 @@ func TestCVEStruct(t *testing.T) { 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, @@ -839,9 +845,6 @@ func TestCVEStruct(t *testing.T) { Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), }, }, - Config: ispec.Descriptor{ - Digest: godigest.FromBytes(configBlob12), - }, }) So(err, ShouldBeNil) @@ -866,6 +869,11 @@ func TestCVEStruct(t *testing.T) { So(err, ShouldBeNil) manifestBlob13, err := json.Marshal(ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: 0, + Digest: godigest.FromBytes(configBlob13), + }, Layers: []ispec.Descriptor{ { MediaType: ispec.MediaTypeImageLayerGzip, @@ -873,9 +881,6 @@ func TestCVEStruct(t *testing.T) { Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), }, }, - Config: ispec.Descriptor{ - Digest: godigest.FromBytes(configBlob13), - }, }) So(err, ShouldBeNil) @@ -898,6 +903,11 @@ func TestCVEStruct(t *testing.T) { So(err, ShouldBeNil) manifestBlob14, err := json.Marshal(ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: 0, + Digest: godigest.FromBytes(configBlob14), + }, Layers: []ispec.Descriptor{ { MediaType: ispec.MediaTypeImageLayerGzip, @@ -905,9 +915,6 @@ func TestCVEStruct(t *testing.T) { Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), }, }, - Config: ispec.Descriptor{ - Digest: godigest.FromBytes(configBlob14), - }, }) So(err, ShouldBeNil) @@ -931,6 +938,11 @@ func TestCVEStruct(t *testing.T) { So(err, ShouldBeNil) manifestBlob61, err := json.Marshal(ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: 0, + Digest: godigest.FromBytes(configBlob61), + }, Layers: []ispec.Descriptor{ { MediaType: ispec.MediaTypeImageLayerGzip, @@ -938,9 +950,6 @@ func TestCVEStruct(t *testing.T) { Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), }, }, - Config: ispec.Descriptor{ - Digest: godigest.FromBytes(configBlob61), - }, }) So(err, ShouldBeNil) @@ -964,6 +973,11 @@ func TestCVEStruct(t *testing.T) { So(err, ShouldBeNil) manifestBlob21, err := json.Marshal(ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: 0, + Digest: godigest.FromBytes(configBlob21), + }, Layers: []ispec.Descriptor{ { MediaType: ispec.MediaTypeImageLayerNonDistributableGzip, @@ -971,9 +985,6 @@ func TestCVEStruct(t *testing.T) { Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), }, }, - Config: ispec.Descriptor{ - Digest: godigest.FromBytes(configBlob21), - }, }) So(err, ShouldBeNil) @@ -1312,55 +1323,49 @@ func TestCVEStruct(t *testing.T) { t.Log("Test GetImageListForCVE") // Image is found - imageInfoByCveList, err := cveInfo.GetImageListForCVE("repo1", "CVE1") + tagList, err = cveInfo.GetImageListForCVE("repo1", "CVE1") So(err, ShouldBeNil) - So(len(imageInfoByCveList), ShouldEqual, 3) + So(len(tagList), ShouldEqual, 3) expectedTags = []string{"0.1.0", "1.0.0", "1.0.1"} - So(expectedTags, ShouldContain, imageInfoByCveList[0].Tag) - So(expectedTags, ShouldContain, imageInfoByCveList[1].Tag) - So(expectedTags, ShouldContain, imageInfoByCveList[2].Tag) + So(expectedTags, ShouldContain, tagList[0].Name) + So(expectedTags, ShouldContain, tagList[1].Name) + So(expectedTags, ShouldContain, tagList[2].Name) - imageInfoByCveList, err = cveInfo.GetImageListForCVE("repo1", "CVE2") + tagList, err = cveInfo.GetImageListForCVE("repo1", "CVE2") So(err, ShouldBeNil) - So(len(imageInfoByCveList), ShouldEqual, 1) - So(imageInfoByCveList[0].Tag, ShouldEqual, "1.0.0") + So(len(tagList), ShouldEqual, 1) + So(tagList[0].Name, ShouldEqual, "1.0.0") - imageInfoByCveList, err = cveInfo.GetImageListForCVE("repo1", "CVE3") + tagList, err = cveInfo.GetImageListForCVE("repo1", "CVE3") So(err, ShouldBeNil) - So(len(imageInfoByCveList), ShouldEqual, 3) + So(len(tagList), ShouldEqual, 3) expectedTags = []string{"1.0.0", "1.0.1", "1.1.0"} - So(expectedTags, ShouldContain, imageInfoByCveList[0].Tag) - So(expectedTags, ShouldContain, imageInfoByCveList[1].Tag) - So(expectedTags, ShouldContain, imageInfoByCveList[2].Tag) + So(expectedTags, ShouldContain, tagList[0].Name) + So(expectedTags, ShouldContain, tagList[1].Name) + So(expectedTags, ShouldContain, tagList[2].Name) // Image/repo doesn't have the CVE at all - imageInfoByCveList, err = cveInfo.GetImageListForCVE("repo6", "CVE1") + tagList, err = cveInfo.GetImageListForCVE("repo6", "CVE1") So(err, ShouldBeNil) - So(len(imageInfoByCveList), ShouldEqual, 0) + So(len(tagList), ShouldEqual, 0) // Image is not scannable - imageInfoByCveList, err = cveInfo.GetImageListForCVE("repo2", "CVE100") + tagList, err = cveInfo.GetImageListForCVE("repo2", "CVE100") // Image is not considered affected with CVE as scan is not possible // but do not return an error So(err, ShouldBeNil) - So(len(imageInfoByCveList), ShouldEqual, 0) + So(len(tagList), ShouldEqual, 0) // Tag is not found, but we should not error - imageInfoByCveList, err = cveInfo.GetImageListForCVE("repo3", "CVE101") + tagList, err = cveInfo.GetImageListForCVE("repo3", "CVE101") So(err, ShouldBeNil) - So(len(imageInfoByCveList), ShouldEqual, 0) - - // Manifest is not found, assume it is affetected by the CVE - // But we don't have enough of it's data to actually return it - imageInfoByCveList, err = cveInfo.GetImageListForCVE("repo5", "CVE101") - So(err, ShouldEqual, zerr.ErrManifestMetaNotFound) - So(len(imageInfoByCveList), ShouldEqual, 0) + So(len(tagList), ShouldEqual, 0) // Repo is not found, assume it is affetected by the CVE // But we don't have enough of it's data to actually return it - imageInfoByCveList, err = cveInfo.GetImageListForCVE("repo100", "CVE100") + tagList, err = cveInfo.GetImageListForCVE("repo100", "CVE100") So(err, ShouldEqual, zerr.ErrRepoMetaNotFound) - So(len(imageInfoByCveList), ShouldEqual, 0) + So(len(tagList), ShouldEqual, 0) t.Log("Test errors while scanning") @@ -1388,10 +1393,10 @@ func TestCVEStruct(t *testing.T) { So(err, ShouldBeNil) So(len(tagList), ShouldEqual, 0) - imageInfoByCveList, err = cveInfo.GetImageListForCVE("repo1", "CVE1") + tagList, err = cveInfo.GetImageListForCVE("repo1", "CVE1") // Image is not considered affected with CVE as scan is not possible // but do not return an error So(err, ShouldBeNil) - So(len(imageInfoByCveList), ShouldEqual, 0) + So(len(tagList), ShouldEqual, 0) }) } diff --git a/pkg/extensions/search/cve/trivy/scanner.go b/pkg/extensions/search/cve/trivy/scanner.go index baab325c..2460fc97 100644 --- a/pkg/extensions/search/cve/trivy/scanner.go +++ b/pkg/extensions/search/cve/trivy/scanner.go @@ -212,7 +212,7 @@ func (scanner Scanner) IsImageFormatScannable(image string) (bool, error) { for _, imageLayer := range manifestContent.Layers { switch imageLayer.MediaType { case ispec.MediaTypeImageLayerGzip, ispec.MediaTypeImageLayer, string(regTypes.DockerLayer): - return true, nil + continue default: scanner.log.Debug().Str("image", image). Msgf("image media type %s not supported for scanning", imageLayer.MediaType) @@ -221,7 +221,7 @@ func (scanner Scanner) IsImageFormatScannable(image string) (bool, error) { } } - return false, nil + return true, nil } func (scanner Scanner) ScanImage(image string) (map[string]cvemodel.CVE, error) { diff --git a/pkg/extensions/search/cve/trivy/scanner_internal_test.go b/pkg/extensions/search/cve/trivy/scanner_internal_test.go index 0cb596da..3a1fe1bc 100644 --- a/pkg/extensions/search/cve/trivy/scanner_internal_test.go +++ b/pkg/extensions/search/cve/trivy/scanner_internal_test.go @@ -6,6 +6,7 @@ import ( "os" "path" "testing" + "time" godigest "github.com/opencontainers/go-digest" ispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -209,6 +210,193 @@ func TestTrivyLibraryErrors(t *testing.T) { }) } +func TestImageScannable(t *testing.T) { + rootDir := t.TempDir() + + repoDB, err := bolt.NewBoltDBWrapper(bolt.DBParameters{ + RootDir: rootDir, + }) + if err != nil { + panic(err) + } + + // Create test data for the following cases + // - Error: RepoMeta not found in DB + // - Error: Tag not found in DB + // - Error: Digest in RepoMeta is invalid + // - Error: ManifestData not found in repodb + // - Error: ManifestData cannot be unmarshalled + // - Error: ManifestData contains unscannable layer type + // - Valid Scannable image + + // Create repodb data for scannable image + timeStamp := time.Date(2008, 1, 1, 12, 0, 0, 0, time.UTC) + + validConfigBlob, err := json.Marshal(ispec.Image{ + Created: &timeStamp, + }) + if err != nil { + panic(err) + } + + validManifestBlob, err := json.Marshal(ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: 0, + Digest: godigest.FromBytes(validConfigBlob), + }, + Layers: []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageLayerGzip, + Size: 0, + Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), + }, + }, + }) + if err != nil { + panic(err) + } + + validRepoMeta := repodb.ManifestData{ + ManifestBlob: validManifestBlob, + ConfigBlob: validConfigBlob, + } + + digestValidManifest := godigest.FromBytes(validManifestBlob) + + err = repoDB.SetManifestData(digestValidManifest, validRepoMeta) + if err != nil { + panic(err) + } + + err = repoDB.SetRepoTag("repo1", "valid", digestValidManifest, ispec.MediaTypeImageManifest) + if err != nil { + panic(err) + } + + // Create RepoDB data for manifest with unscannable layers + manifestBlobUnscannableLayer, err := json.Marshal(ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: 0, + Digest: godigest.FromBytes(validConfigBlob), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "unscannable_media_type", + Size: 0, + Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), + }, + }, + }) + if err != nil { + panic(err) + } + + repoMetaUnscannableLayer := repodb.ManifestData{ + ManifestBlob: manifestBlobUnscannableLayer, + ConfigBlob: validConfigBlob, + } + + digestManifestUnscannableLayer := godigest.FromBytes(manifestBlobUnscannableLayer) + + err = repoDB.SetManifestData(digestManifestUnscannableLayer, repoMetaUnscannableLayer) + if err != nil { + panic(err) + } + + err = repoDB.SetRepoTag("repo1", "unscannable-layer", digestManifestUnscannableLayer, ispec.MediaTypeImageManifest) + if err != nil { + panic(err) + } + + // Create RepoDB data for unmarshable manifest + unmarshableManifestBlob := []byte("Some string") + repoMetaUnmarshable := repodb.ManifestData{ + ManifestBlob: unmarshableManifestBlob, + ConfigBlob: validConfigBlob, + } + + digestUnmarshableManifest := godigest.FromBytes(unmarshableManifestBlob) + + err = repoDB.SetManifestData(digestUnmarshableManifest, repoMetaUnmarshable) + if err != nil { + panic(err) + } + + err = repoDB.SetRepoTag("repo1", "unmarshable", digestUnmarshableManifest, ispec.MediaTypeImageManifest) + if err != nil { + panic(err) + } + + // Manifest meta cannot be found + digestMissingManifest := godigest.FromBytes([]byte("Some other string")) + + err = repoDB.SetRepoTag("repo1", "missing", digestMissingManifest, ispec.MediaTypeImageManifest) + if err != nil { + panic(err) + } + + // RepoMeta contains invalid digest + err = repoDB.SetRepoTag("repo1", "invalid-digest", "invalid", ispec.MediaTypeImageManifest) + if err != nil { + panic(err) + } + + // Continue with initializing the objects the scanner depends on + log := log.NewLogger("debug", "") + metrics := monitoring.NewMetricsServer(false, log) + + store := local.NewImageStore(rootDir, false, storage.DefaultGCDelay, false, false, log, metrics, nil, nil) + + storeController := storage.StoreController{} + storeController.DefaultStore = store + + scanner := NewScanner(storeController, repoDB, "ghcr.io/project-zot/trivy-db", log) + + Convey("Valid image should be scannable", t, func() { + result, err := scanner.IsImageFormatScannable("repo1:valid") + So(err, ShouldBeNil) + So(result, ShouldBeTrue) + }) + + Convey("Image with layers of unsupported types should be unscannable", t, func() { + result, err := scanner.IsImageFormatScannable("repo1:unscannable-layer") + So(err, ShouldNotBeNil) + So(result, ShouldBeFalse) + }) + + Convey("Image with unmarshable manifests should be unscannable", t, func() { + result, err := scanner.IsImageFormatScannable("repo1:unmarshable") + So(err, ShouldNotBeNil) + So(result, ShouldBeFalse) + }) + + Convey("Image with missing manifest meta should be unscannable", t, func() { + result, err := scanner.IsImageFormatScannable("repo1:missing") + So(err, ShouldNotBeNil) + So(result, ShouldBeFalse) + }) + + Convey("Image with invalid manifest digest should be unscannable", t, func() { + result, err := scanner.IsImageFormatScannable("repo1:invalid-digest") + So(err, ShouldNotBeNil) + So(result, ShouldBeFalse) + }) + + Convey("Image with unknown tag should be unscannable", t, func() { + result, err := scanner.IsImageFormatScannable("repo1:unknown-tag") + So(err, ShouldNotBeNil) + So(result, ShouldBeFalse) + }) + + Convey("Image with unknown repo should be unscannable", t, func() { + result, err := scanner.IsImageFormatScannable("unknown-repo:sometag") + So(err, ShouldNotBeNil) + So(result, ShouldBeFalse) + }) +} + func TestDefaultTrivyDBUrl(t *testing.T) { Convey("Test trivy DB download from default location", t, func() { // Create temporary directory diff --git a/pkg/extensions/search/gql_generated/generated.go b/pkg/extensions/search/gql_generated/generated.go index 0b394733..0e3122d1 100644 --- a/pkg/extensions/search/gql_generated/generated.go +++ b/pkg/extensions/search/gql_generated/generated.go @@ -150,9 +150,9 @@ type ComplexityRoot struct { GlobalSearch func(childComplexity int, query string, filter *Filter, requestedPage *PageInput) int Image func(childComplexity int, image string) int ImageList func(childComplexity int, repo string) int - ImageListForCve func(childComplexity int, id string) int + ImageListForCve func(childComplexity int, id string, requestedPage *PageInput) int ImageListForDigest func(childComplexity int, id string, requestedPage *PageInput) int - ImageListWithCVEFixed func(childComplexity int, id string, image string) int + ImageListWithCVEFixed func(childComplexity int, id string, image string, requestedPage *PageInput) int Referrers func(childComplexity int, repo string, digest string, typeArg string) int RepoListWithNewestImage func(childComplexity int, requestedPage *PageInput) int } @@ -187,8 +187,8 @@ type ComplexityRoot struct { type QueryResolver interface { CVEListForImage(ctx context.Context, image string) (*CVEResultForImage, error) - ImageListForCve(ctx context.Context, id string) ([]*ImageSummary, error) - ImageListWithCVEFixed(ctx context.Context, id string, image string) ([]*ImageSummary, 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) RepoListWithNewestImage(ctx context.Context, requestedPage *PageInput) (*PaginatedReposResult, error) ImageList(ctx context.Context, repo string) ([]*ImageSummary, error) @@ -708,7 +708,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Query.ImageListForCve(childComplexity, args["id"].(string)), true + return e.complexity.Query.ImageListForCve(childComplexity, args["id"].(string), args["requestedPage"].(*PageInput)), true case "Query.ImageListForDigest": if e.complexity.Query.ImageListForDigest == nil { @@ -732,7 +732,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Query.ImageListWithCVEFixed(childComplexity, args["id"].(string), args["image"].(string)), true + return e.complexity.Query.ImageListWithCVEFixed(childComplexity, args["id"].(string), args["image"].(string), args["requestedPage"].(*PageInput)), true case "Query.Referrers": if e.complexity.Query.Referrers == nil { @@ -1148,12 +1148,12 @@ type Query { """ Returns a list of images vulnerable to the CVE of the specified ID """ - ImageListForCVE(id: String!): [ImageSummary!] + ImageListForCVE(id: String!, requestedPage: PageInput): [ImageSummary!] """ Returns a list of images that are no longer vulnerable to the CVE of the specified ID, from the specified image (repo) """ - ImageListWithCVEFixed(id: String!, image: String!): [ImageSummary!] + ImageListWithCVEFixed(id: String!, image: String!, requestedPage: PageInput): [ImageSummary!] """ Returns a list of images which contain the specified digest @@ -1314,6 +1314,15 @@ func (ec *executionContext) field_Query_ImageListForCVE_args(ctx context.Context } } args["id"] = 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 } @@ -1362,6 +1371,15 @@ func (ec *executionContext) field_Query_ImageListWithCVEFixed_args(ctx context.C } } args["image"] = arg1 + var arg2 *PageInput + if tmp, ok := rawArgs["requestedPage"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("requestedPage")) + arg2, err = ec.unmarshalOPageInput2ᚖzotregistryᚗioᚋzotᚋpkgᚋextensionsᚋsearchᚋgql_generatedᚐPageInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["requestedPage"] = arg2 return args, nil } @@ -4148,7 +4166,7 @@ func (ec *executionContext) _Query_ImageListForCVE(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().ImageListForCve(rctx, fc.Args["id"].(string)) + return ec.resolvers.Query().ImageListForCve(rctx, fc.Args["id"].(string), fc.Args["requestedPage"].(*PageInput)) }) if err != nil { ec.Error(ctx, err) @@ -4244,7 +4262,7 @@ func (ec *executionContext) _Query_ImageListWithCVEFixed(ctx context.Context, fi }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().ImageListWithCVEFixed(rctx, fc.Args["id"].(string), fc.Args["image"].(string)) + return ec.resolvers.Query().ImageListWithCVEFixed(rctx, fc.Args["id"].(string), fc.Args["image"].(string), fc.Args["requestedPage"].(*PageInput)) }) if err != nil { ec.Error(ctx, err) diff --git a/pkg/extensions/search/resolver.go b/pkg/extensions/search/resolver.go index b767963b..0bbd3a90 100644 --- a/pkg/extensions/search/resolver.go +++ b/pkg/extensions/search/resolver.go @@ -191,6 +191,199 @@ func getImageSummary(ctx context.Context, repo, tag string, repoDB repodb.RepoDB return imageSummaries[0], nil } +func getCVEListForImage( + ctx context.Context, //nolint:unparam // may be used in the future to filter by permissions + image string, + cveInfo cveinfo.CveInfo, + log log.Logger, //nolint:unparam // may be used by devs for debugging +) (*gql_generated.CVEResultForImage, error) { + _, copyImgTag := common.GetImageDirAndTag(image) + + if copyImgTag == "" { + return &gql_generated.CVEResultForImage{}, gqlerror.Errorf("no reference provided") + } + + cveidMap, err := cveInfo.GetCVEListForImage(image) + if err != nil { + return &gql_generated.CVEResultForImage{}, err + } + + cveids := []*gql_generated.Cve{} + + for id, cveDetail := range cveidMap { + vulID := id + desc := cveDetail.Description + title := cveDetail.Title + severity := cveDetail.Severity + + pkgList := make([]*gql_generated.PackageInfo, 0) + + for _, pkg := range cveDetail.PackageList { + pkg := pkg + + pkgList = append(pkgList, + &gql_generated.PackageInfo{ + Name: &pkg.Name, + InstalledVersion: &pkg.InstalledVersion, + FixedVersion: &pkg.FixedVersion, + }, + ) + } + + cveids = append(cveids, + &gql_generated.Cve{ + ID: &vulID, + Title: &title, + Description: &desc, + Severity: &severity, + PackageList: pkgList, + }, + ) + } + + return &gql_generated.CVEResultForImage{Tag: ©ImgTag, CVEList: cveids}, nil +} + +func FilterByTagInfo(tagsInfo []common.TagInfo) repodb.FilterFunc { + return func(repoMeta repodb.RepoMetadata, manifestMeta repodb.ManifestMetadata) bool { + manifestDigest := godigest.FromBytes(manifestMeta.ManifestBlob).String() + + for _, tagInfo := range tagsInfo { + if tagInfo.Digest.String() == manifestDigest { + return true + } + } + + return false + } +} + +func getImageListForCVE( + ctx context.Context, + cveID string, + cveInfo cveinfo.CveInfo, + requestedPage *gql_generated.PageInput, + repoDB repodb.RepoDB, + log log.Logger, +) ([]*gql_generated.ImageSummary, error) { + // Obtain all repos and tags + // Infinite page to make sure we scan all repos in advance, before filtering results + // The CVE scan logic is called from here, not in the actual filter, + // this is because we shouldn't keep the DB locked while we wait on scan results + reposMeta, err := repoDB.GetMultipleRepoMeta(ctx, + func(repoMeta repodb.RepoMetadata) bool { return true }, + repodb.PageInput{Limit: 0, Offset: 0, SortBy: repodb.SortCriteria(gql_generated.SortCriteriaUpdateTime)}, + ) + if err != nil { + return []*gql_generated.ImageSummary{}, err + } + + affectedImages := []common.TagInfo{} + + for _, repoMeta := range reposMeta { + repo := repoMeta.Name + + log.Info().Str("repo", repo).Str("CVE", cveID).Msg("extracting list of tags affected by CVE") + + tagsInfo, err := cveInfo.GetImageListForCVE(repo, cveID) + if err != nil { + log.Error().Str("repo", repo).Str("CVE", cveID).Err(err). + Msg("error getting image list for CVE from repo") + + return []*gql_generated.ImageSummary{}, err + } + + affectedImages = append(affectedImages, tagsInfo...) + } + + imageList := make([]*gql_generated.ImageSummary, 0) + + // We're not interested in other vulnerabilities + skip := convert.SkipQGLField{Vulnerabilities: true} + + if requestedPage == nil { + requestedPage = &gql_generated.PageInput{} + } + + // Actual page requested by user + pageInput := repodb.PageInput{ + Limit: safeDerefferencing(requestedPage.Limit, 0), + Offset: safeDerefferencing(requestedPage.Offset, 0), + SortBy: repodb.SortCriteria( + safeDerefferencing(requestedPage.SortBy, gql_generated.SortCriteriaUpdateTime), + ), + } + + // get all repos + reposMeta, manifestMetaMap, err := repoDB.FilterTags(ctx, FilterByTagInfo(affectedImages), pageInput) + if err != nil { + return []*gql_generated.ImageSummary{}, err + } + + for _, repoMeta := range reposMeta { + imageSummaries := convert.RepoMeta2ImageSummaries(ctx, repoMeta, manifestMetaMap, skip, cveInfo) + + imageList = append(imageList, imageSummaries...) + } + + return imageList, nil +} + +func getImageListWithCVEFixed( + ctx context.Context, + cveID string, + repo string, + cveInfo cveinfo.CveInfo, + requestedPage *gql_generated.PageInput, + repoDB repodb.RepoDB, + log log.Logger, +) ([]*gql_generated.ImageSummary, error) { + imageList := make([]*gql_generated.ImageSummary, 0) + + log.Info().Str("repo", repo).Str("CVE", cveID).Msg("extracting list of tags where CVE is fixed") + + tagsInfo, err := cveInfo.GetImageListWithCVEFixed(repo, cveID) + if err != nil { + log.Error().Str("repo", repo).Str("CVE", cveID).Err(err). + Msg("error getting image list with CVE fixed from repo") + + return imageList, err + } + + // We're not interested in other vulnerabilities + skip := convert.SkipQGLField{Vulnerabilities: true} + + if requestedPage == nil { + requestedPage = &gql_generated.PageInput{} + } + + // Actual page requested by user + pageInput := repodb.PageInput{ + Limit: safeDerefferencing(requestedPage.Limit, 0), + Offset: safeDerefferencing(requestedPage.Offset, 0), + SortBy: repodb.SortCriteria( + safeDerefferencing(requestedPage.SortBy, gql_generated.SortCriteriaUpdateTime), + ), + } + + // get all repos + reposMeta, manifestMetaMap, err := repoDB.FilterTags(ctx, FilterByTagInfo(tagsInfo), pageInput) + if err != nil { + return []*gql_generated.ImageSummary{}, err + } + + for _, repoMeta := range reposMeta { + if repoMeta.Name != repo { + continue + } + + imageSummaries := convert.RepoMeta2ImageSummaries(ctx, repoMeta, manifestMetaMap, skip, cveInfo) + imageList = append(imageList, imageSummaries...) + } + + return imageList, nil +} + func repoListWithNewestImage( ctx context.Context, cveInfo cveinfo.CveInfo, diff --git a/pkg/extensions/search/resolver_test.go b/pkg/extensions/search/resolver_test.go index 2e4d0bd5..dff7f5c8 100644 --- a/pkg/extensions/search/resolver_test.go +++ b/pkg/extensions/search/resolver_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "strings" "testing" "time" @@ -15,9 +16,11 @@ import ( "zotregistry.io/zot/pkg/extensions/search/common" cveinfo "zotregistry.io/zot/pkg/extensions/search/cve" + cvemodel "zotregistry.io/zot/pkg/extensions/search/cve/model" "zotregistry.io/zot/pkg/extensions/search/gql_generated" "zotregistry.io/zot/pkg/log" "zotregistry.io/zot/pkg/meta/repodb" + bolt "zotregistry.io/zot/pkg/meta/repodb/boltdb-wrapper" localCtx "zotregistry.io/zot/pkg/requestcontext" "zotregistry.io/zot/pkg/storage" "zotregistry.io/zot/pkg/test/mocks" @@ -1178,17 +1181,19 @@ func TestQueryResolverErrors(t *testing.T) { log := log.NewLogger("debug", "") ctx := context.Background() - Convey("ImageListForCve olu.GetRepositories() errors", func() { + Convey("ImageListForCve error in GetMultipleRepoMeta", func() { resolverConfig := NewResolver( log, storage.StoreController{ - DefaultStore: mocks.MockedImageStore{ - GetRepositoriesFn: func() ([]string, error) { - return nil, ErrTestError - }, + DefaultStore: mocks.MockedImageStore{}, + }, + mocks.RepoDBMock{ + GetMultipleRepoMetaFn: func(ctx context.Context, filter func(repoMeta repodb.RepoMetadata) bool, + requestedPage repodb.PageInput, + ) ([]repodb.RepoMetadata, error) { + return []repodb.RepoMetadata{}, ErrTestError }, }, - mocks.RepoDBMock{}, mocks.CveInfoMock{}, ) @@ -1196,66 +1201,59 @@ func TestQueryResolverErrors(t *testing.T) { resolverConfig, } - _, err := qr.ImageListForCve(ctx, "id") + _, err := qr.ImageListForCve(ctx, "cve1", &gql_generated.PageInput{}) So(err, ShouldNotBeNil) }) - Convey("ImageListForCve cveInfo.GetImageListForCVE() errors", func() { + Convey("ImageListForCve error in FilterTags", func() { resolverConfig := NewResolver( log, storage.StoreController{ - DefaultStore: mocks.MockedImageStore{ - GetRepositoriesFn: func() ([]string, error) { - return []string{"repo"}, nil - }, - }, - }, - mocks.RepoDBMock{}, - mocks.CveInfoMock{ - GetImageListForCVEFn: func(repo, cveID string) ([]cveinfo.ImageInfoByCVE, error) { - return nil, ErrTestError + DefaultStore: mocks.MockedImageStore{}, + }, + mocks.RepoDBMock{ + FilterTagsFn: func(ctx context.Context, filter repodb.FilterFunc, + requestedPage repodb.PageInput, + ) ([]repodb.RepoMetadata, map[string]repodb.ManifestMetadata, error) { + return []repodb.RepoMetadata{}, map[string]repodb.ManifestMetadata{}, ErrTestError }, }, + mocks.CveInfoMock{}, ) qr := queryResolver{ resolverConfig, } - _, err := qr.ImageListForCve(ctx, "a") + _, err := qr.ImageListForCve(ctx, "cve1", &gql_generated.PageInput{}) So(err, ShouldNotBeNil) }) - Convey("ImageListForCve olu.GetImageConfigInfo() errors", func() { + Convey("ImageListWithCVEFixed error in FilterTags", func() { resolverConfig := NewResolver( log, storage.StoreController{ - DefaultStore: mocks.MockedImageStore{ - GetRepositoriesFn: func() ([]string, error) { - return []string{"repo"}, nil - }, - GetBlobContentFn: func(repo string, digest godigest.Digest) ([]byte, error) { - return nil, ErrTestError - }, - }, - }, - mocks.RepoDBMock{}, - mocks.CveInfoMock{ - GetImageListForCVEFn: func(repo, cveID string) ([]cveinfo.ImageInfoByCVE, error) { - return []cveinfo.ImageInfoByCVE{{}}, nil + DefaultStore: mocks.MockedImageStore{}, + }, + mocks.RepoDBMock{ + FilterTagsFn: func(ctx context.Context, filter repodb.FilterFunc, + requestedPage repodb.PageInput, + ) ([]repodb.RepoMetadata, map[string]repodb.ManifestMetadata, error) { + return []repodb.RepoMetadata{}, map[string]repodb.ManifestMetadata{}, ErrTestError }, }, + mocks.CveInfoMock{}, ) qr := queryResolver{ resolverConfig, } - _, err := qr.ImageListForCve(ctx, "a") + _, err := qr.ImageListWithCVEFixed(ctx, "cve1", "image", &gql_generated.PageInput{}) So(err, ShouldNotBeNil) }) - Convey("RepoListWithNewestImage repoListWithNewestImage errors", func() { + Convey("RepoListWithNewestImage repoListWithNewestImage() errors mocked StoreController", func() { resolverConfig := NewResolver( log, storage.StoreController{ @@ -1279,68 +1277,7 @@ func TestQueryResolverErrors(t *testing.T) { So(err, ShouldNotBeNil) }) - Convey("ImageListWithCVEFixed olu.GetImageBlobManifest() errors", func() { - resolverConfig := NewResolver( - log, - storage.StoreController{ - DefaultStore: mocks.MockedImageStore{ - GetBlobContentFn: func(repo string, digest godigest.Digest) ([]byte, error) { - return nil, ErrTestError - }, - }, - }, - mocks.RepoDBMock{}, - mocks.CveInfoMock{ - GetImageListWithCVEFixedFn: func(repo, cveID string) ([]common.TagInfo, error) { - return []common.TagInfo{{}}, nil - }, - }, - ) - - qr := queryResolver{ - resolverConfig, - } - - _, err := qr.ImageListWithCVEFixed(ctx, "a", "d") - So(err, ShouldNotBeNil) - }) - - Convey("ImageListWithCVEFixed olu.GetImageConfigInfo() errors", func() { - getBlobContentCallCounter := 0 - - resolverConfig := NewResolver( - log, - storage.StoreController{ - DefaultStore: mocks.MockedImageStore{ - GetBlobContentFn: func(repo string, digest godigest.Digest) ([]byte, error) { - if getBlobContentCallCounter == 1 { - getBlobContentCallCounter++ - - return nil, ErrTestError - } - getBlobContentCallCounter++ - - return []byte("{}"), nil - }, - }, - }, - mocks.RepoDBMock{}, - mocks.CveInfoMock{ - GetImageListWithCVEFixedFn: func(repo, cveID string) ([]common.TagInfo, error) { - return []common.TagInfo{{}}, nil - }, - }, - ) - - qr := queryResolver{ - resolverConfig, - } - - _, err := qr.ImageListWithCVEFixed(ctx, "a", "d") - So(err, ShouldNotBeNil) - }) - - Convey("RepoListWithNewestImage repoListWithNewestImage() errors", func() { + Convey("RepoListWithNewestImage repoListWithNewestImage() errors valid StoreController", func() { resolverConfig := NewResolver( log, storage.StoreController{}, @@ -1470,5 +1407,712 @@ func TestQueryResolverErrors(t *testing.T) { _, err := qr.BaseImageList(ctx, "repo:tag") So(err, ShouldNotBeNil) }) + + Convey("GetReferrers error", func() { + resolverConfig := NewResolver( + log, + storage.StoreController{ + DefaultStore: mocks.MockedImageStore{ + GetReferrersFn: func(repo string, digest godigest.Digest, artifactType string) (ispec.Index, error) { + return ispec.Index{}, ErrTestError + }, + }, + }, + mocks.RepoDBMock{}, + mocks.CveInfoMock{}, + ) + + qr := queryResolver{ + resolverConfig, + } + + _, err := qr.Referrers(ctx, "repo", "", "") + So(err, ShouldNotBeNil) + }) }) } + +func TestCVEResolvers(t *testing.T) { //nolint:gocyclo + repoDB, err := bolt.NewBoltDBWrapper(bolt.DBParameters{ + RootDir: t.TempDir(), + }) + if err != nil { + panic(err) + } + + // Create repodb data for scannable image with vulnerabilities + // Create manifets metadata first + timeStamp1 := time.Date(2008, 1, 1, 12, 0, 0, 0, time.UTC) + + configBlob1, err := json.Marshal(ispec.Image{ + Created: &timeStamp1, + }) + if err != nil { + panic(err) + } + + manifestBlob1, err := json.Marshal(ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: 0, + Digest: godigest.FromBytes(configBlob1), + }, + Layers: []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageLayerGzip, + Size: 0, + Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), + }, + }, + }) + if err != nil { + panic(err) + } + + repoMeta1 := repodb.ManifestData{ + ManifestBlob: manifestBlob1, + ConfigBlob: configBlob1, + } + + digest1 := godigest.FromBytes(manifestBlob1) + + err = repoDB.SetManifestData(digest1, repoMeta1) + if err != nil { + panic(err) + } + + timeStamp2 := time.Date(2009, 1, 1, 12, 0, 0, 0, time.UTC) + + configBlob2, err := json.Marshal(ispec.Image{ + Created: &timeStamp2, + }) + if err != nil { + panic(err) + } + + manifestBlob2, err := json.Marshal(ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: 0, + Digest: godigest.FromBytes(configBlob2), + }, + Layers: []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageLayerGzip, + Size: 0, + Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), + }, + }, + }) + if err != nil { + panic(err) + } + + repoMeta2 := repodb.ManifestData{ + ManifestBlob: manifestBlob2, + ConfigBlob: configBlob2, + } + + digest2 := godigest.FromBytes(manifestBlob2) + + err = repoDB.SetManifestData(digest2, repoMeta2) + if err != nil { + panic(err) + } + + timeStamp3 := time.Date(2010, 1, 1, 12, 0, 0, 0, time.UTC) + + configBlob3, err := json.Marshal(ispec.Image{ + Created: &timeStamp3, + }) + if err != nil { + panic(err) + } + + manifestBlob3, err := json.Marshal(ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeImageConfig, + Size: 0, + Digest: godigest.FromBytes(configBlob3), + }, + Layers: []ispec.Descriptor{ + { + MediaType: ispec.MediaTypeImageLayerGzip, + Size: 0, + Digest: godigest.NewDigestFromEncoded(godigest.SHA256, "digest"), + }, + }, + }) + if err != nil { + panic(err) + } + + repoMeta3 := repodb.ManifestData{ + ManifestBlob: manifestBlob3, + ConfigBlob: configBlob3, + } + + digest3 := godigest.FromBytes(manifestBlob3) + + err = repoDB.SetManifestData(digest3, repoMeta3) + if err != nil { + panic(err) + } + + // Create the repo metadata using previously defined manifests + tagsMap := map[string]godigest.Digest{} + tagsMap["repo1:1.0.0"] = digest1 + tagsMap["repo1:1.0.1"] = digest2 + tagsMap["repo1:1.1.0"] = digest3 + tagsMap["repo1:latest"] = digest3 + tagsMap["repo2:2.0.0"] = digest1 + tagsMap["repo2:2.0.1"] = digest2 + tagsMap["repo2:2.1.0"] = digest3 + tagsMap["repo2:latest"] = digest3 + tagsMap["repo3:3.0.1"] = digest2 + tagsMap["repo3:3.1.0"] = digest3 + tagsMap["repo3:latest"] = digest3 + + for image, digest := range tagsMap { + repo, tag := common.GetImageDirAndTag(image) + + err := repoDB.SetRepoTag(repo, tag, digest, ispec.MediaTypeImageManifest) + if err != nil { + panic(err) + } + } + + // Create the repo metadata using previously defined manifests + + // RepoDB loaded with initial data, mock the scanner + severities := map[string]int{ + "UNKNOWN": 0, + "LOW": 1, + "MEDIUM": 2, + "HIGH": 3, + "CRITICAL": 4, + } + + // Setup test CVE data in mock scanner + scanner := mocks.CveScannerMock{ + ScanImageFn: func(image string) (map[string]cvemodel.CVE, error) { + digest, ok := tagsMap[image] + if !ok { + return map[string]cvemodel.CVE{}, nil + } + + if digest.String() == digest1.String() { + return map[string]cvemodel.CVE{ + "CVE1": { + ID: "CVE1", + Severity: "HIGH", + Title: "Title CVE1", + Description: "Description CVE1", + }, + "CVE2": { + ID: "CVE2", + Severity: "MEDIM", + Title: "Title CVE2", + Description: "Description CVE2", + }, + "CVE3": { + ID: "CVE3", + Severity: "LOW", + Title: "Title CVE3", + Description: "Description CVE3", + }, + }, nil + } + + if digest.String() == digest2.String() { + return map[string]cvemodel.CVE{ + "CVE2": { + ID: "CVE2", + Severity: "MEDIUM", + Title: "Title CVE2", + Description: "Description CVE2", + }, + "CVE3": { + ID: "CVE3", + Severity: "LOW", + Title: "Title CVE3", + Description: "Description CVE3", + }, + }, nil + } + + if digest.String() == digest3.String() { + return map[string]cvemodel.CVE{ + "CVE3": { + ID: "CVE3", + Severity: "LOW", + Title: "Title CVE3", + Description: "Description CVE3", + }, + }, nil + } + + // By default the image has no vulnerabilities + return map[string]cvemodel.CVE{}, nil + }, + CompareSeveritiesFn: func(severity1, severity2 string) int { + return severities[severity2] - severities[severity1] + }, + } + + log := log.NewLogger("debug", "") + + cveInfo := &cveinfo.BaseCveInfo{ + Log: log, + Scanner: scanner, + RepoDB: repoDB, + } + + 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 + + responseContext := graphql.WithResponseContext(context.Background(), graphql.DefaultErrorPresenter, + graphql.DefaultRecover) + + cveResult, err := getCVEListForImage(responseContext, "repo1:1.0.0", cveInfo, log) + So(err, ShouldBeNil) + So(*cveResult.Tag, ShouldEqual, "1.0.0") + + expectedCves := []string{"CVE1", "CVE2", "CVE3"} + So(len(cveResult.CVEList), ShouldEqual, len(expectedCves)) + + for _, cve := range cveResult.CVEList { + So(expectedCves, ShouldContain, *cve.ID) + } + + cveResult, err = getCVEListForImage(responseContext, "repo1:1.0.1", cveInfo, log) + So(err, ShouldBeNil) + So(*cveResult.Tag, ShouldEqual, "1.0.1") + + expectedCves = []string{"CVE2", "CVE3"} + So(len(cveResult.CVEList), ShouldEqual, len(expectedCves)) + + for _, cve := range cveResult.CVEList { + So(expectedCves, ShouldContain, *cve.ID) + } + + cveResult, err = getCVEListForImage(responseContext, "repo1:1.1.0", cveInfo, log) + So(err, ShouldBeNil) + So(*cveResult.Tag, ShouldEqual, "1.1.0") + + expectedCves = []string{"CVE3"} + So(len(cveResult.CVEList), ShouldEqual, len(expectedCves)) + + for _, cve := range cveResult.CVEList { + So(expectedCves, ShouldContain, *cve.ID) + } + }) + }) + + Convey("Get a list of images affected by a particular CVE ", t, func() { + Convey("Unpaginated request", func() { + responseContext := graphql.WithResponseContext(context.Background(), graphql.DefaultErrorPresenter, + graphql.DefaultRecover) + + images, err := getImageListForCVE(responseContext, "CVE1", cveInfo, nil, repoDB, log) + So(err, ShouldBeNil) + + expectedImages := []string{ + "repo1:1.0.0", + "repo2:2.0.0", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + images, err = getImageListForCVE(responseContext, "CVE2", cveInfo, nil, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.0.0", "repo1:1.0.1", + "repo2:2.0.0", "repo2:2.0.1", + "repo3:3.0.1", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + images, err = getImageListForCVE(responseContext, "CVE3", cveInfo, nil, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.0.0", "repo1:1.0.1", "repo1:1.1.0", "repo1:latest", + "repo2:2.0.0", "repo2:2.0.1", "repo2:2.1.0", "repo2:latest", + "repo3:3.0.1", "repo3:3.1.0", "repo3:latest", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + }) + + Convey("Paginated requests", func() { + responseContext := graphql.WithResponseContext(context.Background(), graphql.DefaultErrorPresenter, + graphql.DefaultRecover, + ) + + pageInput := getPageInput(1, 0) + + images, err := getImageListForCVE(responseContext, "CVE1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages := []string{ + "repo1:1.0.0", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(1, 1) + + images, err = getImageListForCVE(responseContext, "CVE1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo2:2.0.0", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(1, 2) + + images, err = getImageListForCVE(responseContext, "CVE1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + So(len(images), ShouldEqual, 0) + + pageInput = getPageInput(1, 5) + + images, err = getImageListForCVE(responseContext, "CVE1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + So(len(images), ShouldEqual, 0) + + pageInput = getPageInput(2, 0) + + images, err = getImageListForCVE(responseContext, "CVE1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.0.0", + "repo2:2.0.0", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(5, 0) + + images, err = getImageListForCVE(responseContext, "CVE1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.0.0", + "repo2:2.0.0", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(5, 1) + + images, err = getImageListForCVE(responseContext, "CVE1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo2:2.0.0", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(5, 2) + + images, err = getImageListForCVE(responseContext, "CVE1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + So(len(images), ShouldEqual, 0) + + pageInput = getPageInput(5, 5) + + images, err = getImageListForCVE(responseContext, "CVE1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + So(len(images), ShouldEqual, 0) + + pageInput = getPageInput(5, 0) + + images, err = getImageListForCVE(responseContext, "CVE2", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.0.0", "repo1:1.0.1", + "repo2:2.0.0", "repo2:2.0.1", + "repo3:3.0.1", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(5, 3) + + images, err = getImageListForCVE(responseContext, "CVE2", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo2:2.0.1", + "repo3:3.0.1", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(5, 0) + + images, err = getImageListForCVE(responseContext, "CVE3", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.0.0", "repo1:1.0.1", "repo1:1.1.0", "repo1:latest", + "repo2:2.0.0", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(5, 5) + + images, err = getImageListForCVE(responseContext, "CVE3", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo2:2.0.1", "repo2:2.1.0", "repo2:latest", + "repo3:3.0.1", "repo3:3.1.0", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(5, 10) + + images, err = getImageListForCVE(responseContext, "CVE3", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo3:latest", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + }) + }) + + Convey("Get a list of images where a particular CVE is fixed", t, func() { + Convey("Unpaginated request", func() { + responseContext := graphql.WithResponseContext(context.Background(), graphql.DefaultErrorPresenter, + graphql.DefaultRecover) + + images, err := getImageListWithCVEFixed(responseContext, "CVE1", "repo1", cveInfo, nil, repoDB, log) + So(err, ShouldBeNil) + + expectedImages := []string{ + "repo1:1.0.1", "repo1:1.1.0", "repo1:latest", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + images, err = getImageListWithCVEFixed(responseContext, "CVE2", "repo1", cveInfo, nil, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.1.0", "repo1:latest", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + images, err = getImageListWithCVEFixed(responseContext, "CVE3", "repo1", cveInfo, nil, repoDB, log) + So(err, ShouldBeNil) + So(len(images), ShouldEqual, 0) + }) + + Convey("Paginated requests", func() { + responseContext := graphql.WithResponseContext(context.Background(), graphql.DefaultErrorPresenter, + graphql.DefaultRecover, + ) + + pageInput := getPageInput(1, 0) + + images, err := getImageListWithCVEFixed(responseContext, "CVE1", "repo1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages := []string{ + "repo1:1.0.1", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(1, 1) + + images, err = getImageListWithCVEFixed(responseContext, "CVE1", "repo1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.1.0", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(1, 2) + + images, err = getImageListWithCVEFixed(responseContext, "CVE1", "repo1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:latest", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(1, 3) + + images, err = getImageListWithCVEFixed(responseContext, "CVE1", "repo1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + So(len(images), ShouldEqual, 0) + + pageInput = getPageInput(1, 10) + + images, err = getImageListWithCVEFixed(responseContext, "CVE1", "repo1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + So(len(images), ShouldEqual, 0) + + pageInput = getPageInput(2, 0) + + images, err = getImageListWithCVEFixed(responseContext, "CVE1", "repo1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.0.1", "repo1:1.1.0", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(2, 1) + + images, err = getImageListWithCVEFixed(responseContext, "CVE1", "repo1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.1.0", "repo1:latest", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(2, 2) + + images, err = getImageListWithCVEFixed(responseContext, "CVE1", "repo1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:latest", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(5, 0) + + images, err = getImageListWithCVEFixed(responseContext, "CVE1", "repo1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.0.1", "repo1:1.1.0", "repo1:latest", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(5, 0) + + images, err = getImageListWithCVEFixed(responseContext, "CVE2", "repo1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + + expectedImages = []string{ + "repo1:1.1.0", "repo1:latest", + } + So(len(images), ShouldEqual, len(expectedImages)) + + for _, image := range images { + So(fmt.Sprintf("%s:%s", *image.RepoName, *image.Tag), ShouldBeIn, expectedImages) + } + + pageInput = getPageInput(5, 2) + + images, err = getImageListWithCVEFixed(responseContext, "CVE2", "repo1", cveInfo, pageInput, repoDB, log) + So(err, ShouldBeNil) + So(len(images), ShouldEqual, 0) + }) + }) +} + +func getPageInput(limit int, offset int) *gql_generated.PageInput { + sortCriteria := gql_generated.SortCriteriaAlphabeticAsc + + return &gql_generated.PageInput{ + Limit: &limit, + Offset: &offset, + SortBy: &sortCriteria, + } +} diff --git a/pkg/extensions/search/schema.graphql b/pkg/extensions/search/schema.graphql index e8765413..a2b8221c 100644 --- a/pkg/extensions/search/schema.graphql +++ b/pkg/extensions/search/schema.graphql @@ -207,12 +207,12 @@ type Query { """ Returns a list of images vulnerable to the CVE of the specified ID """ - ImageListForCVE(id: String!): [ImageSummary!] + ImageListForCVE(id: String!, requestedPage: PageInput): [ImageSummary!] """ Returns a list of images that are no longer vulnerable to the CVE of the specified ID, from the specified image (repo) """ - ImageListWithCVEFixed(id: String!, image: String!): [ImageSummary!] + ImageListWithCVEFixed(id: String!, image: String!, requestedPage: PageInput): [ImageSummary!] """ Returns a list of images which contain the specified digest diff --git a/pkg/extensions/search/schema.resolvers.go b/pkg/extensions/search/schema.resolvers.go index 6265ff92..b325dbbe 100644 --- a/pkg/extensions/search/schema.resolvers.go +++ b/pkg/extensions/search/schema.resolvers.go @@ -10,7 +10,6 @@ import ( "github.com/vektah/gqlparser/v2/gqlerror" zerr "zotregistry.io/zot/errors" "zotregistry.io/zot/pkg/extensions/search/common" - "zotregistry.io/zot/pkg/extensions/search/convert" "zotregistry.io/zot/pkg/extensions/search/gql_generated" ) @@ -20,146 +19,25 @@ func (r *queryResolver) CVEListForImage(ctx context.Context, image string) (*gql return &gql_generated.CVEResultForImage{}, zerr.ErrCVESearchDisabled } - _, copyImgTag := common.GetImageDirAndTag(image) - - if copyImgTag == "" { - return &gql_generated.CVEResultForImage{}, gqlerror.Errorf("no reference provided") - } - - cveidMap, err := r.cveInfo.GetCVEListForImage(image) - if err != nil { - return &gql_generated.CVEResultForImage{}, err - } - - cveids := []*gql_generated.Cve{} - - for id, cveDetail := range cveidMap { - vulID := id - desc := cveDetail.Description - title := cveDetail.Title - severity := cveDetail.Severity - - pkgList := make([]*gql_generated.PackageInfo, 0) - - for _, pkg := range cveDetail.PackageList { - pkg := pkg - - pkgList = append(pkgList, - &gql_generated.PackageInfo{ - Name: &pkg.Name, - InstalledVersion: &pkg.InstalledVersion, - FixedVersion: &pkg.FixedVersion, - }, - ) - } - - cveids = append(cveids, - &gql_generated.Cve{ - ID: &vulID, - Title: &title, - Description: &desc, - Severity: &severity, - PackageList: pkgList, - }, - ) - } - - return &gql_generated.CVEResultForImage{Tag: ©ImgTag, CVEList: cveids}, nil + return getCVEListForImage(ctx, image, r.cveInfo, r.log) } // ImageListForCve is the resolver for the ImageListForCVE field. -func (r *queryResolver) ImageListForCve(ctx context.Context, id string) ([]*gql_generated.ImageSummary, error) { - olu := common.NewBaseOciLayoutUtils(r.storeController, r.log) - affectedImages := []*gql_generated.ImageSummary{} - +func (r *queryResolver) ImageListForCve(ctx context.Context, id string, requestedPage *gql_generated.PageInput) ([]*gql_generated.ImageSummary, error) { if r.cveInfo == nil { - return affectedImages, zerr.ErrCVESearchDisabled + return []*gql_generated.ImageSummary{}, zerr.ErrCVESearchDisabled } - r.log.Info().Msg("extracting repositories") - repoList, err := olu.GetRepositories() - if err != nil { //nolint: wsl - r.log.Error().Err(err).Msg("unable to search repositories") - - return affectedImages, err - } - - r.log.Info().Msg("scanning each repository") - - for _, repo := range repoList { - r.log.Info().Str("repo", repo).Msg("extracting list of tags available in image repo") - - imageListByCVE, err := r.cveInfo.GetImageListForCVE(repo, id) - if err != nil { - r.log.Error().Str("repo", repo).Str("CVE", id).Err(err). - Msg("error getting image list for CVE from repo") - - return affectedImages, err - } - - for _, imageByCVE := range imageListByCVE { - imageConfig, err := olu.GetImageConfigInfo(repo, imageByCVE.Digest) - if err != nil { - return affectedImages, err - } - - isSigned := olu.CheckManifestSignature(repo, imageByCVE.Digest) - imageInfo := convert.BuildImageInfo( - repo, imageByCVE.Tag, - imageByCVE.Digest, - imageByCVE.Manifest, - imageConfig, - isSigned, - ) - - affectedImages = append( - affectedImages, - imageInfo, - ) - } - } - - return affectedImages, nil + return getImageListForCVE(ctx, id, r.cveInfo, requestedPage, r.repoDB, r.log) } // ImageListWithCVEFixed is the resolver for the ImageListWithCVEFixed field. -func (r *queryResolver) ImageListWithCVEFixed(ctx context.Context, id string, image string) ([]*gql_generated.ImageSummary, error) { - olu := common.NewBaseOciLayoutUtils(r.storeController, r.log) - - unaffectedImages := []*gql_generated.ImageSummary{} - +func (r *queryResolver) ImageListWithCVEFixed(ctx context.Context, id string, image string, requestedPage *gql_generated.PageInput) ([]*gql_generated.ImageSummary, error) { if r.cveInfo == nil { - return unaffectedImages, zerr.ErrCVESearchDisabled + return []*gql_generated.ImageSummary{}, zerr.ErrCVESearchDisabled } - tagsInfo, err := r.cveInfo.GetImageListWithCVEFixed(image, id) - if err != nil { - return unaffectedImages, err - } - - for _, tag := range tagsInfo { - digest := tag.Digest - - manifest, err := olu.GetImageBlobManifest(image, digest) - if err != nil { - r.log.Error().Err(err).Str("repo", image).Str("digest", tag.Digest.String()). - Msg("extension api: error reading manifest") - - return unaffectedImages, err - } - - imageConfig, err := olu.GetImageConfigInfo(image, digest) - if err != nil { - return []*gql_generated.ImageSummary{}, err - } - - isSigned := olu.CheckManifestSignature(image, digest) - imageInfo := convert.BuildImageInfo(image, tag.Name, digest, manifest, imageConfig, isSigned) - - unaffectedImages = append(unaffectedImages, imageInfo) - } - - return unaffectedImages, nil + return getImageListWithCVEFixed(ctx, id, image, r.cveInfo, requestedPage, r.repoDB, r.log) } // ImageListForDigest is the resolver for the ImageListForDigest field. diff --git a/pkg/test/mocks/cve_mock.go b/pkg/test/mocks/cve_mock.go index f6b0dea1..b536d0d7 100644 --- a/pkg/test/mocks/cve_mock.go +++ b/pkg/test/mocks/cve_mock.go @@ -7,19 +7,19 @@ import ( ) type CveInfoMock struct { - GetImageListForCVEFn func(repo, cveID string) ([]cveinfo.ImageInfoByCVE, error) + 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) GetCVESummaryForImageFn func(image string) (cveinfo.ImageCVESummary, error) UpdateDBFn func() error } -func (cveInfo CveInfoMock) GetImageListForCVE(repo, cveID string) ([]cveinfo.ImageInfoByCVE, error) { +func (cveInfo CveInfoMock) GetImageListForCVE(repo, cveID string) ([]common.TagInfo, error) { if cveInfo.GetImageListForCVEFn != nil { return cveInfo.GetImageListForCVEFn(repo, cveID) } - return []cveinfo.ImageInfoByCVE{}, nil + return []common.TagInfo{}, nil } func (cveInfo CveInfoMock) GetImageListWithCVEFixed(repo, cveID string) ([]common.TagInfo, error) {