feat(cve): cli cve diff (#2242)

* feat(gql): add new query for diff of cves for 2 images

Signed-off-by: Laurentiu Niculae <niculae.laurentiu1@gmail.com>

* feat(cli): add cli for cve diff

Signed-off-by: Laurentiu Niculae <niculae.laurentiu1@gmail.com>

---------

Signed-off-by: Laurentiu Niculae <niculae.laurentiu1@gmail.com>
This commit is contained in:
LaurentiuNiculae
2024-03-06 00:40:29 -08:00
committed by GitHub
parent 752b9e87c1
commit 5039128723
20 changed files with 2555 additions and 11 deletions
+270
View File
@@ -198,6 +198,276 @@ func TestNegativeServerResponse(t *testing.T) {
})
}
func TestCVEDiffList(t *testing.T) {
port := test.GetFreePort()
url := test.GetBaseURL(port)
conf := config.New()
conf.HTTP.Port = port
dir := t.TempDir()
conf.Storage.RootDirectory = dir
trivyConfig := &extconf.TrivyConfig{
DBRepository: "ghcr.io/project-zot/trivy-db",
}
cveConfig := &extconf.CVEConfig{
UpdateInterval: 2,
Trivy: trivyConfig,
}
defaultVal := true
searchConfig := &extconf.SearchConfig{
BaseConfig: extconf.BaseConfig{Enable: &defaultVal},
CVE: cveConfig,
}
conf.Extensions = &extconf.ExtensionConfig{
Search: searchConfig,
}
logFile, err := os.CreateTemp(t.TempDir(), "zot-log*.txt")
if err != nil {
panic(err)
}
logPath := logFile.Name()
defer os.Remove(logPath)
writers := io.MultiWriter(os.Stdout, logFile)
ctlr := api.NewController(conf)
ctlr.Log.Logger = ctlr.Log.Output(writers)
if err := ctlr.Init(); err != nil {
panic(err)
}
layer1 := []byte{10, 20, 30}
layer2 := []byte{11, 21, 31}
layer3 := []byte{12, 22, 23}
otherImage := CreateImageWith().LayerBlobs([][]byte{
layer1,
}).DefaultConfig().Build()
baseImage := CreateImageWith().LayerBlobs([][]byte{
layer1,
layer2,
}).PlatformConfig("testArch", "testOs").Build()
image := CreateImageWith().LayerBlobs([][]byte{
layer1,
layer2,
layer3,
}).PlatformConfig("testArch", "testOs").Build()
multiArchBase := CreateMultiarchWith().Images([]Image{baseImage, CreateRandomImage(), CreateRandomImage()}).
Build()
multiArchImage := CreateMultiarchWith().Images([]Image{image, CreateRandomImage(), CreateRandomImage()}).
Build()
getCveResults := func(digestStr string) map[string]cvemodel.CVE {
switch digestStr {
case image.DigestStr():
return map[string]cvemodel.CVE{
"CVE1": {
ID: "CVE1",
Severity: "HIGH",
Title: "Title CVE1",
Description: "Description CVE1",
PackageList: []cvemodel.Package{{}},
},
"CVE2": {
ID: "CVE2",
Severity: "MEDIUM",
Title: "Title CVE2",
Description: "Description CVE2",
PackageList: []cvemodel.Package{{}},
},
"CVE3": {
ID: "CVE3",
Severity: "LOW",
Title: "Title CVE3",
Description: "Description CVE3",
PackageList: []cvemodel.Package{{}},
},
}
case baseImage.DigestStr():
return map[string]cvemodel.CVE{
"CVE1": {
ID: "CVE1",
Severity: "HIGH",
Title: "Title CVE1",
Description: "Description CVE1",
PackageList: []cvemodel.Package{{}},
},
"CVE2": {
ID: "CVE2",
Severity: "MEDIUM",
Title: "Title CVE2",
Description: "Description CVE2",
PackageList: []cvemodel.Package{{}},
},
}
case otherImage.DigestStr():
return map[string]cvemodel.CVE{
"CVE1": {
ID: "CVE1",
Severity: "HIGH",
Title: "Title CVE1",
Description: "Description CVE1",
PackageList: []cvemodel.Package{{}},
},
}
}
// By default the image has no vulnerabilities
return map[string]cvemodel.CVE{}
}
// MetaDB loaded with initial data, now mock the scanner
// Setup test CVE data in mock scanner
scanner := mocks.CveScannerMock{
ScanImageFn: func(ctx context.Context, image string) (map[string]cvemodel.CVE, error) {
repo, ref, _, _ := zcommon.GetRepoReference(image)
if zcommon.IsDigest(ref) {
return getCveResults(ref), nil
}
repoMeta, _ := ctlr.MetaDB.GetRepoMeta(ctx, repo)
if _, ok := repoMeta.Tags[ref]; !ok {
panic("unexpected tag '" + ref + "', test might be wrong")
}
return getCveResults(repoMeta.Tags[ref].Digest), nil
},
GetCachedResultFn: func(digestStr string) map[string]cvemodel.CVE {
return getCveResults(digestStr)
},
IsResultCachedFn: func(digestStr string) bool {
return true
},
}
ctlr.CveScanner = scanner
go func() {
if err := ctlr.Run(); !errors.Is(err, http.ErrServerClosed) {
panic(err)
}
}()
defer ctlr.Shutdown()
test.WaitTillServerReady(url)
ctx := context.Background()
_, err = ociutils.InitializeTestMetaDB(ctx, ctlr.MetaDB,
ociutils.Repo{
Name: "repo",
Images: []ociutils.RepoImage{
{Image: otherImage, Reference: "other-image"},
{Image: baseImage, Reference: "base-image"},
{Image: image, Reference: "image"},
},
},
ociutils.Repo{
Name: "repo-multi",
MultiArchImages: []ociutils.RepoMultiArchImage{
{MultiarchImage: CreateRandomMultiarch(), Reference: "multi-rand"},
{MultiarchImage: multiArchBase, Reference: "multi-base"},
{MultiarchImage: multiArchImage, Reference: "multi-img"},
},
},
)
Convey("Test CVE by image name - GQL - positive", t, func() {
args := []string{"diff", "repo:image", "repo:base-image", "--config", "cvetest"}
configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"cvetest","url":"%s","showspinner":false}]}`, url))
defer os.Remove(configPath)
cveCmd := client.NewCVECommand(client.NewSearchService())
buff := bytes.NewBufferString("")
cveCmd.SetOut(buff)
cveCmd.SetErr(buff)
cveCmd.SetArgs(args)
err = cveCmd.Execute()
fmt.Println(buff.String())
space := regexp.MustCompile(`\s+`)
str := space.ReplaceAllString(buff.String(), " ")
str = strings.TrimSpace(str)
So(str, ShouldContainSubstring, "CVE3")
So(str, ShouldNotContainSubstring, "CVE1")
So(str, ShouldNotContainSubstring, "CVE2")
})
Convey("Errors", t, func() {
// args := []string{"diff", "repo:image", "repo:base-image", "--config", "cvetest"}
configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"cvetest","url":"%s","showspinner":false}]}`, url))
defer os.Remove(configPath)
cveCmd := client.NewCVECommand(client.NewSearchService())
Convey("Set wrong number of params", func() {
args := []string{"diff", "repo:image", "--config", "cvetest"}
cveCmd.SetArgs(args)
So(cveCmd.Execute(), ShouldNotBeNil)
})
Convey("First input is not a repo:tag", func() {
args := []string{"diff", "bad-input", "repo:base-image", "--config", "cvetest"}
cveCmd.SetArgs(args)
So(cveCmd.Execute(), ShouldNotBeNil)
})
Convey("Second input is arch but not enough args", func() {
args := []string{"diff", "repo:base-image", "linux/amd64", "--config", "cvetest"}
cveCmd.SetArgs(args)
So(cveCmd.Execute(), ShouldNotBeNil)
})
Convey("Second input is arch 3rd is repo:tag", func() {
args := []string{"diff", "repo:base-image", "linux/amd64", "repo:base-image", "--config", "cvetest"}
cveCmd.SetArgs(args)
So(cveCmd.Execute(), ShouldBeNil)
})
Convey("Second input is repo:tag 3rd is repo:tag", func() {
args := []string{"diff", "repo:base-image", "repo:base-image", "repo:base-image", "--config", "cvetest"}
cveCmd.SetArgs(args)
So(cveCmd.Execute(), ShouldNotBeNil)
})
Convey("Second input is arch 3rd is arch as well", func() {
args := []string{"diff", "repo:base-image", "linux/amd64", "linux/amd64", "--config", "cvetest"}
cveCmd.SetArgs(args)
So(cveCmd.Execute(), ShouldNotBeNil)
})
Convey("Second input is repo:tag 3rd is arch", func() {
args := []string{"diff", "repo:base-image", "repo:base-image", "linux/amd64", "--config", "cvetest"}
cveCmd.SetArgs(args)
So(cveCmd.Execute(), ShouldBeNil)
})
Convey("Second input is repo:tag 3rd is arch, 4th is repo:tag", func() {
args := []string{
"diff", "repo:base-image", "repo:base-image", "linux/amd64", "repo:base-image",
"--config", "cvetest",
}
cveCmd.SetArgs(args)
So(cveCmd.Execute(), ShouldNotBeNil)
})
Convey("Second input is arch 3rd is repo:tag, 4th is arch", func() {
args := []string{"diff", "repo:base-image", "linux/amd64", "repo:base-image", "linux/amd64", "--config", "cvetest"}
cveCmd.SetArgs(args)
So(cveCmd.Execute(), ShouldBeNil)
})
Convey("input is with digest ref", func() {
args := []string{"diff", "repo@sha256:123123", "--config", "cvetest"}
cveCmd.SetArgs(args)
So(cveCmd.Execute(), ShouldNotBeNil)
})
Convey("input is with just repo no ref", func() {
args := []string{"diff", "repo", "--config", "cvetest"}
cveCmd.SetArgs(args)
So(cveCmd.Execute(), ShouldNotBeNil)
})
})
}
//nolint:dupl
func TestServerCVEResponse(t *testing.T) {
port := test.GetFreePort()
+1
View File
@@ -30,6 +30,7 @@ func NewCVECommand(searchService SearchService) *cobra.Command {
cvesCmd.AddCommand(NewCveForImageCommand(searchService))
cvesCmd.AddCommand(NewImagesByCVEIDCommand(searchService))
cvesCmd.AddCommand(NewFixedTagsCommand(searchService))
cvesCmd.AddCommand(NewCVEDiffCommand(searchService))
return cvesCmd
}
+136
View File
@@ -140,3 +140,139 @@ func NewFixedTagsCommand(searchService SearchService) *cobra.Command {
return fixedTagsCmd
}
func NewCVEDiffCommand(searchService SearchService) *cobra.Command {
var (
minuendStr, minuendArch string
subtrahendStr, subtrahendArch string
)
imagesByCVEIDCmd := &cobra.Command{
Use: "diff [minuend] ([minuend-platform]) [subtrahend] ([subtrahend-platform])",
Short: "List the CVE's present in minuend that are not present in subtrahend",
Long: `List the CVE's present in minuend that are not present in subtrahend`,
Args: func(cmd *cobra.Command, args []string) error {
const (
twoArgs = 2
threeArgs = 3
fourArgs = 4
)
if err := cobra.RangeArgs(twoArgs, fourArgs)(cmd, args); err != nil {
return err
}
if !isRepoTag(args[0]) {
return fmt.Errorf("%w: first parameter should be a repo:tag", zerr.ErrInvalidArgs)
}
minuendStr = args[0]
if isRepoTag(args[1]) {
subtrahendStr = args[1]
} else {
minuendArch = args[1]
if len(args) == twoArgs {
return fmt.Errorf("%w: not enough arguments, specified only 1 image with arch", zerr.ErrInvalidArgs)
}
}
if len(args) == twoArgs {
return nil
}
if isRepoTag(args[2]) {
if subtrahendStr == "" {
subtrahendStr = args[2]
} else {
return fmt.Errorf("%w: too many repo:tag inputs", zerr.ErrInvalidArgs)
}
} else {
if subtrahendStr == "" {
return fmt.Errorf("%w: 3rd argument should be a repo:tag", zerr.ErrInvalidArgs)
} else {
subtrahendArch = args[2]
}
}
if len(args) == threeArgs {
return nil
}
if isRepoTag(args[3]) {
return fmt.Errorf("%w: 4th argument should not be a repo:tag but an arch", zerr.ErrInvalidArgs)
} else {
subtrahendArch = args[3]
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
searchConfig, err := GetSearchConfigFromFlags(cmd, searchService)
if err != nil {
return err
}
err = CheckExtEndPointQuery(searchConfig, CVEDiffListForImagesQuery())
if err != nil {
return fmt.Errorf("%w: '%s'", err, CVEDiffListForImagesQuery().Name)
}
// parse the args and determine the input
minuend := getImageIdentifier(minuendStr, minuendArch)
subtrahend := getImageIdentifier(subtrahendStr, subtrahendArch)
return SearchCVEDiffList(searchConfig, minuend, subtrahend)
},
}
return imagesByCVEIDCmd
}
func isRepoTag(arg string) bool {
_, _, _, err := zcommon.GetRepoReference(arg) //nolint:dogsled
return err == nil
}
type osArch struct {
Os string
Arch string
}
type ImageIdentifier struct {
Repo string `json:"repo"`
Tag string `json:"tag"`
Digest string `json:"digest"`
Platform *osArch `json:"platform"`
}
func getImageIdentifier(repoTagStr, platformStr string) ImageIdentifier {
var tag, digest string
repo, ref, isTag, err := zcommon.GetRepoReference(repoTagStr)
if err != nil {
return ImageIdentifier{}
}
if isTag {
tag = ref
} else {
digest = ref
}
// check if the following input is a repo:tag or repo@digest, if not then it's a platform
var platform *osArch
if platformStr != "" {
os, arch, _ := strings.Cut(platformStr, "/")
platform = &osArch{Os: os, Arch: arch}
}
return ImageIdentifier{
Repo: repo,
Tag: tag,
Digest: digest,
Platform: platform,
}
}
+1
View File
@@ -22,6 +22,7 @@ const (
DebugFlag = "debug"
SearchedCVEID = "cve-id"
SortByFlag = "sort-by"
PlatformFlag = "platform"
)
const (
+14
View File
@@ -25,6 +25,12 @@ func CVEResultForImage() GQLType {
}
}
func CVEDiffResult() GQLType {
return GQLType{
Name: "CVEDiffResult",
}
}
func PaginatedImagesResult() GQLType {
return GQLType{
Name: "PaginatedImagesResult",
@@ -51,6 +57,14 @@ func ImageListQuery() GQLQuery {
}
}
func CVEDiffListForImagesQuery() GQLQuery {
return GQLQuery{
Name: "CVEDiffListForImages",
Args: []string{"minuend", "subtrahend", "requestedPage", "searchedCVE", "excludedCVE"},
ReturnType: CVEDiffResult(),
}
}
func ImageListForDigestQuery() GQLQuery {
return GQLQuery{
Name: "ImageListForDigest",
+14
View File
@@ -1077,6 +1077,20 @@ type mockService struct {
getFixedTagsForCVEGQLFn func(ctx context.Context, config SearchConfig, username, password,
imageName, cveID string,
) (*common.ImageListWithCVEFixedResponse, error)
getCVEDiffListGQLFn func(ctx context.Context, config SearchConfig, username, password string,
minuend, subtrahend ImageIdentifier,
) (*cveDiffListResp, error)
}
func (service mockService) getCVEDiffListGQL(ctx context.Context, config SearchConfig, username, password string,
minuend, subtrahend ImageIdentifier,
) (*cveDiffListResp, error) {
if service.getCVEDiffListGQLFn != nil {
return service.getCVEDiffListGQLFn(ctx, config, username, password, minuend, subtrahend)
}
return &cveDiffListResp{}, nil
}
func (service mockService) getRepos(ctx context.Context, config SearchConfig, username,
+46
View File
@@ -267,6 +267,52 @@ func SearchCVEForImageGQL(config SearchConfig, image, searchedCveID string) erro
return nil
}
func SearchCVEDiffList(config SearchConfig, minuend, subtrahend ImageIdentifier) error {
username, password := getUsernameAndPassword(config.User)
response, err := config.SearchService.getCVEDiffListGQL(context.Background(), config, username, password,
minuend, subtrahend)
if err != nil {
return err
}
cveDiffResult := response.Data.CveDiffResult
result := cveResult{
Data: cveData{
CVEListForImage: cveListForImage{
Tag: cveDiffResult.Minuend.Tag,
CVEList: cveDiffResult.CVEList,
Summary: cveDiffResult.Summary,
},
},
}
var builder strings.Builder
if config.OutputFormat == defaultOutputFormat || config.OutputFormat == "" {
imageCVESummary := result.Data.CVEListForImage.Summary
statsStr := fmt.Sprintf("CRITICAL %d, HIGH %d, MEDIUM %d, LOW %d, UNKNOWN %d, TOTAL %d\n\n",
imageCVESummary.CriticalCount, imageCVESummary.HighCount, imageCVESummary.MediumCount,
imageCVESummary.LowCount, imageCVESummary.UnknownCount, imageCVESummary.Count)
fmt.Fprint(config.ResultWriter, statsStr)
printCVETableHeader(&builder)
fmt.Fprint(config.ResultWriter, builder.String())
}
out, err := result.string(config.OutputFormat)
if err != nil {
return err
}
fmt.Fprint(config.ResultWriter, out)
return nil
}
func SearchImagesByCVEIDGQL(config SearchConfig, repo, cveid string) error {
username, password := getUsernameAndPassword(config.User)
ctx, cancel := context.WithCancel(context.Background())
+60 -1
View File
@@ -48,6 +48,9 @@ type SearchService interface { //nolint:interfacebloat
baseImage string) (*common.BaseImageListResponse, error)
getReferrersGQL(ctx context.Context, config SearchConfig, username, password string,
repo, digest string) (*common.ReferrersResp, error)
getCVEDiffListGQL(ctx context.Context, config SearchConfig, username, password string,
minuend, subtrahend ImageIdentifier,
) (*cveDiffListResp, error)
globalSearchGQL(ctx context.Context, config SearchConfig, username, password string,
query string) (*common.GlobalSearch, error)
@@ -146,6 +149,46 @@ func (service searchService) getReferrersGQL(ctx context.Context, config SearchC
return result, nil
}
func (service searchService) getCVEDiffListGQL(ctx context.Context, config SearchConfig, username, password string,
minuend, subtrahend ImageIdentifier,
) (*cveDiffListResp, error) {
minuendInput := getImageInput(minuend)
subtrahendInput := getImageInput(subtrahend)
query := fmt.Sprintf(`
{
CVEDiffListForImages( minuend: %s, subtrahend: %s ) {
Minuend {Repo Tag}
Subtrahend {Repo Tag}
CVEList {
Id Title Description Severity Reference
PackageList {Name InstalledVersion FixedVersion}
}
Summary {
Count UnknownCount LowCount MediumCount HighCount CriticalCount
}
Page {TotalCount ItemCount}
}
}`, minuendInput, subtrahendInput)
result := &cveDiffListResp{}
err := service.makeGraphQLQuery(ctx, config, username, password, query, result)
if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil {
return nil, errResult
}
return result, nil
}
func getImageInput(img ImageIdentifier) string {
platform := ""
if img.Platform != nil {
platform = fmt.Sprintf(`, Platform: {Os: "%s", Arch: "%s"}`, img.Platform.Os, img.Platform.Arch)
}
return fmt.Sprintf(`{Repo: "%s", Tag: "%s", Digest: "%s"%s}`, img.Repo, img.Tag, img.Digest, platform)
}
func (service searchService) globalSearchGQL(ctx context.Context, config SearchConfig, username, password string,
query string,
) (*common.GlobalSearch, error) {
@@ -746,6 +789,22 @@ type cve struct {
PackageList []packageList `json:"PackageList"`
}
type cveDiffListResp struct {
Data cveDiffResultsForImages `json:"data"`
Errors []common.ErrorGQL `json:"errors"`
}
type cveDiffResultsForImages struct {
CveDiffResult cveDiffResult `json:"cveDiffListForImages"`
}
type cveDiffResult struct {
Minuend ImageIdentifier `json:"minuend"`
Subtrahend ImageIdentifier `json:"subtrahend"`
CVEList []cve `json:"cveList"`
Summary common.ImageVulnerabilitySummary `json:"summary"`
}
//nolint:tagliatelle // graphQL schema
type cveListForImage struct {
Tag string `json:"Tag"`
@@ -755,7 +814,7 @@ type cveListForImage struct {
//nolint:tagliatelle // graphQL schema
type cveData struct {
CVEListForImage cveListForImage `json:"CVEListForImage"`
CVEListForImage cveListForImage `json:"cveListForImage"`
}
func (cve cveResult) string(format string) (string, error) {