diff --git a/Makefile b/Makefile index 93e2e2b5..8ae31e05 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ debug: doc .PHONY: test test: - $(shell mkdir -p test/data; cd test/data; ../scripts/gen_certs.sh; cd ${TOP_LEVEL}; sudo skopeo --insecure-policy copy -q docker://centos:latest oci:${TOP_LEVEL}/test/data/zot-test:0.0.1) + $(shell mkdir -p test/data; cd test/data; ../scripts/gen_certs.sh; cd ${TOP_LEVEL}; sudo skopeo --insecure-policy copy -q docker://centos:latest oci:${TOP_LEVEL}/test/data/zot-test:0.0.1;sudo skopeo --insecure-policy copy -q docker://centos:8 oci:${TOP_LEVEL}/test/data/zot-cve-test:0.0.1) go test -v -race -cover -coverpkg ./... -coverprofile=coverage.txt -covermode=atomic ./... .PHONY: covhtml diff --git a/README.md b/README.md index a90afd70..a545ea73 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ * Uses [OCI storage layout](https://github.com/opencontainers/image-spec/blob/master/image-layout.md) for storage layout * Supports [helm charts](https://helm.sh/docs/topics/registries/) * Currently suitable for on-prem deployments (e.g. colocated with Kubernetes) +* [Vulnerability scanning of images](#Scanning-images-for-known-vulnerabilities) * [Command-line client support](#cli) * TLS support * Authentication via: @@ -117,8 +118,8 @@ remote-zot https://server-example:8080 local http://localhost:8080 ``` -## Fetching images -You can fetch all images from a server by using its alias specified [in this step](#adding-a-zot-server-url): +## Listing images +You can list all images from a server by using its alias specified [in this step](#adding-a-zot-server-url): ```console $ zot images remote-zot @@ -135,6 +136,76 @@ $ zot images remote-zot -n busybox IMAGE NAME TAG DIGEST SIZE busybox latest 414aeb86 707.8KB ``` +## Scanning images for known vulnerabilities + +You can fetch CVE (Common Vulnerabilities and Exposures) info for images hosted on zot + +- Get all images affected by a CVE + +```console +$ zot cve remote-zot -i CVE-2017-9935 +IMAGE NAME TAG DIGEST SIZE +c3/openjdk-dev commit-5be4d92 ac3762e2 335MB +``` + +- Get all CVEs for an image + +```console +$ zot cve remote-zot -I c3/openjdk-dev:0.3.19 +ID SEVERITY TITLE +CVE-2015-8540 LOW libpng: underflow read in png_check_keyword() +CVE-2017-16826 LOW binutils: Invalid memory access in the coff_s... +``` + +- Get detailed json output + +```console +$ zot cve remote-zot -I c3/openjdk-dev:0.3.19 -o json +{ + "Tag": "0.3.19", + "CVEList": [ + { + "Id": "CVE-2019-17006", + "Severity": "MEDIUM", + "Title": "nss: Check length of inputs for cryptographic primitives", + "Description": "A vulnerability was discovered in nss where input text length was not checked when using certain cryptographic primitives. This could lead to a heap-buffer overflow resulting in a crash and data leak. The highest threat is to confidentiality and integrity of data as well as system availability.", + "PackageList": [ + { + "Name": "nss", + "InstalledVersion": "3.44.0-7.el7_7", + "FixedVersion": "Not Specified" + }, + { + "Name": "nss-sysinit", + "InstalledVersion": "3.44.0-7.el7_7", + "FixedVersion": "Not Specified" + }, + { + "Name": "nss-tools", + "InstalledVersion": "3.44.0-7.el7_7", + "FixedVersion": "Not Specified" + } + ] + }, +``` + +- Get all images in a specific repo affected by a CVE + +```console +$ zot cve remote-zot -I c3/openjdk-dev -i CVE-2017-9935 +IMAGE NAME TAG DIGEST SIZE +c3/openjdk-dev commit-2674e8a 71046748 338MB +c3/openjdk-dev commit-bd5cc94 0ab7fc76 +``` + +- Get all images of a specific repo where a CVE is fixed + +```console +$ zot cve remote-zot -I c3/openjdk-dev -i CVE-2017-9935 --fixed +IMAGE NAME TAG DIGEST SIZE +c3/openjdk-dev commit-2674e8a-squashfs b545b8ba 321MB +c3/openjdk-dev commit-d5024ec-squashfs cd45f8cf 321MB +``` # Ecosystem diff --git a/errors/errors.go b/errors/errors.go index dc72ea40..95b804a9 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -27,7 +27,7 @@ var ( ErrRequireCred = errors.New("ldap: bind credentials required") ErrInvalidCred = errors.New("ldap: invalid credentials") ErrInvalidArgs = errors.New("cli: Invalid Arguments") - ErrInvalidFlagsCombination = errors.New("cli: Invalid combination of flags. Add --help flag") + ErrInvalidFlagsCombination = errors.New("cli: Invalid combination of flags") ErrInvalidURL = errors.New("cli: invalid URL format") ErrUnauthorizedAccess = errors.New("cli: unauthorized access. check credentials") ErrCannotResetConfigKey = errors.New("cli: cannot reset given config key") @@ -36,4 +36,5 @@ var ( ErrIllegalConfigKey = errors.New("cli: given config key is not allowed") ErrScanNotSupported = errors.New("search: scanning of image media type not supported") ErrFixedTagNotFound = errors.New("search: no fixed tag found") + ErrCLITimeout = errors.New("cli: Query timed out while waiting for results") ) diff --git a/pkg/cli/BUILD.bazel b/pkg/cli/BUILD.bazel index 330f9afc..1afa8052 100644 --- a/pkg/cli/BUILD.bazel +++ b/pkg/cli/BUILD.bazel @@ -5,6 +5,7 @@ go_library( srcs = [ "client.go", "config_cmd.go", + "cve_cmd.go", "image_cmd.go", "root.go", "searcher.go", @@ -31,11 +32,15 @@ go_library( go_test( name = "go_default_test", - timeout = "short", + timeout = "moderate", srcs = [ "config_cmd_test.go", + "cve_cmd_test.go", "image_cmd_test.go", "root_test.go", + ], + data = [ + "//:exported_testdata", ], embed = [":go_default_library"], race = "on", diff --git a/pkg/cli/client.go b/pkg/cli/client.go index 06394574..b8ad4752 100644 --- a/pkg/cli/client.go +++ b/pkg/cli/client.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "context" "crypto/tls" "encoding/json" @@ -17,7 +18,7 @@ import ( var httpClient *http.Client //nolint: gochecknoglobals -const httpTimeout = 5 * time.Second +const httpTimeout = 5 * time.Minute func createHTTPClient(verifyTLS bool) *http.Client { var tr = http.DefaultTransport.(*http.Transport).Clone() @@ -40,6 +41,33 @@ func makeGETRequest(url, username, password string, verifyTLS bool, resultsPtr i req.SetBasicAuth(username, password) + return doHTTPRequest(req, verifyTLS, resultsPtr) +} + +func makeGraphQLRequest(url, query, username, + password string, verifyTLS bool, resultsPtr interface{}) error { + req, err := http.NewRequest("GET", url, bytes.NewBufferString(query)) + if err != nil { + return err + } + + q := req.URL.Query() + q.Add("query", query) + + req.URL.RawQuery = q.Encode() + + req.SetBasicAuth(username, password) + req.Header.Add("Content-Type", "application/json") + + _, err = doHTTPRequest(req, verifyTLS, resultsPtr) + if err != nil { + return err + } + + return nil +} + +func doHTTPRequest(req *http.Request, verifyTLS bool, resultsPtr interface{}) (http.Header, error) { if httpClient == nil { httpClient = createHTTPClient(verifyTLS) } @@ -77,7 +105,7 @@ type requestsPool struct { jobs chan *manifestJob done chan struct{} waitGroup *sync.WaitGroup - outputCh chan imageListResult + outputCh chan stringResult context context.Context } @@ -93,7 +121,7 @@ type manifestJob struct { const rateLimiterBuffer = 5000 -func newSmoothRateLimiter(ctx context.Context, wg *sync.WaitGroup, op chan imageListResult) *requestsPool { +func newSmoothRateLimiter(ctx context.Context, wg *sync.WaitGroup, op chan stringResult) *requestsPool { ch := make(chan *manifestJob, rateLimiterBuffer) return &requestsPool{ @@ -132,7 +160,7 @@ func (p *requestsPool) doJob(job *manifestJob) { if isContextDone(p.context) { return } - p.outputCh <- imageListResult{"", err} + p.outputCh <- stringResult{"", err} } digest := header.Get("docker-content-digest") @@ -159,7 +187,7 @@ func (p *requestsPool) doJob(job *manifestJob) { if isContextDone(p.context) { return } - p.outputCh <- imageListResult{"", err} + p.outputCh <- stringResult{"", err} return } @@ -168,7 +196,7 @@ func (p *requestsPool) doJob(job *manifestJob) { return } - p.outputCh <- imageListResult{str, nil} + p.outputCh <- stringResult{str, nil} } func (p *requestsPool) submitJob(job *manifestJob) { diff --git a/pkg/cli/cve_cmd.go b/pkg/cli/cve_cmd.go new file mode 100644 index 00000000..4b869db1 --- /dev/null +++ b/pkg/cli/cve_cmd.go @@ -0,0 +1,135 @@ +package cli + +import ( + "fmt" + "os" + "path" + + zotErrors "github.com/anuvu/zot/errors" + + "github.com/briandowns/spinner" + "github.com/spf13/cobra" +) + +func NewCveCommand(searchService SearchService) *cobra.Command { + searchCveParams := make(map[string]*string) + + var servURL, user, outputFormat string + + var isSpinner, verifyTLS, fixedFlag bool + + var cveCmd = &cobra.Command{ + Use: "cve [config-name]", + Short: "Lookup CVEs in images hosted on zot", + Long: `List CVEs (Common Vulnerabilities and Exposures) of images hosted on a zot instance`, + RunE: func(cmd *cobra.Command, args []string) error { + home, err := os.UserHomeDir() + if err != nil { + panic(err) + } + + configPath := path.Join(home + "/.zot") + if servURL == "" { + if len(args) > 0 { + urlFromConfig, err := getConfigValue(configPath, args[0], "url") + if err != nil { + cmd.SilenceUsage = true + return err + } + if urlFromConfig == "" { + return zotErrors.ErrNoURLProvided + } + servURL = urlFromConfig + } else { + return zotErrors.ErrNoURLProvided + } + } + + if len(args) > 0 { + var err error + isSpinner, err = parseBooleanConfig(configPath, args[0], showspinnerConfig) + if err != nil { + cmd.SilenceUsage = true + return err + } + verifyTLS, err = parseBooleanConfig(configPath, args[0], verifyTLSConfig) + if err != nil { + cmd.SilenceUsage = true + return err + } + } + + spin := spinner.New(spinner.CharSets[39], spinnerDuration, spinner.WithWriter(cmd.ErrOrStderr())) + spin.Prefix = fmt.Sprintf("Fetching from %s.. ", servURL) + + searchConfig := searchConfig{ + params: searchCveParams, + searchService: searchService, + servURL: &servURL, + user: &user, + outputFormat: &outputFormat, + fixedFlag: &fixedFlag, + verifyTLS: &verifyTLS, + resultWriter: cmd.OutOrStdout(), + spinner: spinnerState{spin, isSpinner}, + } + + err = searchCve(searchConfig) + + if err != nil { + cmd.SilenceUsage = true + return err + } + + return nil + }, + } + + vars := cveFlagVariables{ + searchCveParams: searchCveParams, + servURL: &servURL, + user: &user, + outputFormat: &outputFormat, + fixedFlag: &fixedFlag, + } + + setupCveFlags(cveCmd, vars) + + return cveCmd +} + +func setupCveFlags(cveCmd *cobra.Command, variables cveFlagVariables) { + variables.searchCveParams["imageName"] = cveCmd.Flags().StringP("image", "I", "", "List CVEs by IMAGENAME[:TAG]") + variables.searchCveParams["cveID"] = cveCmd.Flags().StringP("cve-id", "i", "", "List images affected by a CVE") + + cveCmd.Flags().StringVar(variables.servURL, "url", "", "Specify zot server URL if config-name is not mentioned") + cveCmd.Flags().StringVarP(variables.user, "user", "u", "", `User Credentials of `+ + `zot server in USERNAME:PASSWORD format`) + cveCmd.Flags().StringVarP(variables.outputFormat, "output", "o", "", "Specify output format [text/json/yaml]."+ + " JSON and YAML format return all info for CVEs") + + cveCmd.Flags().BoolVar(variables.fixedFlag, "fixed", false, "List tags which have fixed a CVE") +} + +type cveFlagVariables struct { + searchCveParams map[string]*string + servURL *string + user *string + outputFormat *string + fixedFlag *bool +} + +func searchCve(searchConfig searchConfig) error { + for _, searcher := range getCveSearchers() { + found, err := searcher.search(searchConfig) + if found { + if err != nil { + return err + } + + return nil + } + } + + return zotErrors.ErrInvalidFlagsCombination +} diff --git a/pkg/cli/cve_cmd_test.go b/pkg/cli/cve_cmd_test.go new file mode 100644 index 00000000..ec3a6da8 --- /dev/null +++ b/pkg/cli/cve_cmd_test.go @@ -0,0 +1,507 @@ +package cli //nolint:testpackage + +import ( + "bytes" + "context" + "fmt" + "io" + "io/ioutil" + "os" + "path" + "regexp" + "strings" + "testing" + "time" + + zotErrors "github.com/anuvu/zot/errors" + "github.com/anuvu/zot/pkg/api" + "gopkg.in/resty.v1" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestSearchCVECmd(t *testing.T) { + Convey("Test CVE help", t, func() { + args := []string{"--help"} + configPath := makeConfigFile("") + defer os.Remove(configPath) + cmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(ioutil.Discard) + cmd.SetArgs(args) + err := cmd.Execute() + So(buff.String(), ShouldContainSubstring, "Usage") + So(err, ShouldBeNil) + Convey("with the shorthand", func() { + args[0] = "-h" + configPath := makeConfigFile("") + defer os.Remove(configPath) + cmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(ioutil.Discard) + cmd.SetArgs(args) + err := cmd.Execute() + So(buff.String(), ShouldContainSubstring, "Usage") + So(err, ShouldBeNil) + }) + }) + Convey("Test CVE no url", t, func() { + args := []string{"cvetest", "-i", "cveIdRandom"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(ioutil.Discard) + cmd.SetArgs(args) + err := cmd.Execute() + So(err, ShouldNotBeNil) + So(err, ShouldEqual, zotErrors.ErrNoURLProvided) + }) + + Convey("Test CVE no params", t, func() { + args := []string{"cvetest", "--url", "someUrl"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(ioutil.Discard) + cmd.SetArgs(args) + err := cmd.Execute() + So(err, ShouldEqual, zotErrors.ErrInvalidFlagsCombination) + }) + + Convey("Test CVE invalid url", t, func() { + args := []string{"cvetest", "--image", "dummyImageName:tag", "--url", "invalidUrl"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err := cmd.Execute() + So(err, ShouldNotBeNil) + So(err, ShouldEqual, zotErrors.ErrInvalidURL) + So(buff.String(), ShouldContainSubstring, "invalid URL format") + }) + + Convey("Test CVE invalid url port", t, func() { + args := []string{"cvetest", "--image", "dummyImageName:tag", "--url", "http://localhost:99999"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err := cmd.Execute() + So(err, ShouldNotBeNil) + So(buff.String(), ShouldContainSubstring, "invalid port") + + Convey("without flags", func() { + args := []string{"cvetest", "--image", "dummyImageName:tag", "--url", "http://localhost:99999"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err = cmd.Execute() + So(err, ShouldNotBeNil) + So(buff.String(), ShouldContainSubstring, "invalid port") + }) + }) + + Convey("Test CVE unreachable", t, func() { + args := []string{"cvetest", "--image", "dummyImageName:tag", "--url", "http://localhost:9999"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err := cmd.Execute() + So(err, ShouldNotBeNil) + }) + + Convey("Test CVE url from config", t, func() { + args := []string{"cvetest", "--image", "dummyImageName:tag"} + + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","url":"https://test-url.com","showspinner":false}]}`) + defer os.Remove(configPath) + + cmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(ioutil.Discard) + cmd.SetArgs(args) + err := cmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + So(strings.TrimSpace(str), ShouldEqual, "ID SEVERITY TITLE dummyCVEID HIGH Title of that CVE") + So(err, ShouldBeNil) + }) + + Convey("Test CVE by name and CVE ID", t, func() { + args := []string{"cvetest", "--image", "dummyImageName", "--cve-id", "aCVEID", "--url", "someURL"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + So(strings.TrimSpace(str), ShouldEqual, "IMAGE NAME TAG DIGEST SIZE dummyImageName tag DigestsA 123kB") + So(err, ShouldBeNil) + Convey("using shorthand", func() { + args := []string{"cvetest", "-I", "dummyImageName", "--cve-id", "aCVEID", "--url", "someURL"} + buff := bytes.NewBufferString("") + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(mockService)) + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + So(strings.TrimSpace(str), ShouldEqual, "IMAGE NAME TAG DIGEST SIZE dummyImageName tag DigestsA 123kB") + So(err, ShouldBeNil) + }) + }) + + Convey("Test CVE by image name", t, func() { + args := []string{"cvetest", "--image", "dummyImageName:tag", "--url", "someURL"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + So(strings.TrimSpace(str), ShouldEqual, "ID SEVERITY TITLE dummyCVEID HIGH Title of that CVE") + So(err, ShouldBeNil) + + Convey("in json format", func() { + args := []string{"cvetest", "--image", "dummyImageName:tag", "--url", "someURL", "-o", "json"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + So(strings.TrimSpace(str), ShouldEqual, `{ "Tag": "dummyImageName:tag", "CVEList": `+ + `[ { "Id": "dummyCVEID", "Severity": "HIGH", "Title": "Title of that CVE", `+ + `"Description": "Description of the CVE", "PackageList": [ { "Name": "packagename",`+ + ` "InstalledVersion": "installedver", "FixedVersion": "fixedver" } ] } ] }`) + So(err, ShouldBeNil) + }) + + Convey("in yaml format", func() { + args := []string{"cvetest", "--image", "dummyImageName:tag", "--url", "someURL", "-o", "yaml"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + So(strings.TrimSpace(str), ShouldEqual, `tag: dummyImageName:tag cvelist: - id: dummyCVEID`+ + ` severity: HIGH title: Title of that CVE description: Description of the CVE packagelist: `+ + `- name: packagename installedversion: installedver fixedversion: fixedver`) + So(err, ShouldBeNil) + }) + Convey("invalid format", func() { + args := []string{"cvetest", "--image", "dummyImageName:tag", "--url", "someURL", "-o", "random"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + So(err, ShouldNotBeNil) + So(strings.TrimSpace(str), ShouldEqual, "Error: invalid output format") + }) + }) + + Convey("Test images by CVE ID", t, func() { + args := []string{"cvetest", "--cve-id", "aCVEID", "--url", "someURL"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + So(strings.TrimSpace(str), ShouldEqual, "IMAGE NAME TAG DIGEST SIZE anImage tag DigestsA 123kB") + So(err, ShouldBeNil) + }) + + Convey("Test fixed tags by and image name CVE ID", t, func() { + args := []string{"cvetest", "--cve-id", "aCVEID", "--image", "fixedImage", "--url", "someURL", "--fixed"} + configPath := makeConfigFile(`{"configs":[{"_name":"cvetest","showspinner":false}]}`) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(mockService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + So(err, ShouldBeNil) + So(strings.TrimSpace(str), ShouldEqual, "IMAGE NAME TAG DIGEST SIZE fixedImage tag DigestsA 123kB") + }) +} + +func TestServerCVEResponse(t *testing.T) { + port := "8080" + url := "http://127.0.0.1:8080" + config := api.NewConfig() + config.HTTP.Port = port + c := api.NewController(config) + + dir, err := ioutil.TempDir("", "oci-repo-test") + if err != nil { + panic(err) + } + + err = copyFiles("../../test/data/zot-cve-test", path.Join(dir, "zot-cve-test")) + if err != nil { + panic(err) + } + + defer os.RemoveAll(dir) + + c.Config.Storage.RootDirectory = dir + cveConfig := &api.CVEConfig{ + UpdateInterval: 2, + } + searchConfig := &api.SearchConfig{ + CVE: cveConfig, + } + c.Config.Extensions = &api.ExtensionConfig{ + Search: searchConfig, + } + + go func(controller *api.Controller) { + // this blocks + if err := controller.Run(); err != nil { + return + } + }(c) + // wait till ready + for { + res, err := resty.R().Get(url + "/query") + if err == nil && res.StatusCode() == 200 { + break + } + + time.Sleep(100 * time.Millisecond) + } + time.Sleep(25 * time.Second) + + defer func(controller *api.Controller) { + ctx := context.Background() + _ = controller.Server.Shutdown(ctx) + }(c) + + Convey("Test CVE by image name", t, func() { + args := []string{"cvetest", "--image", "zot-cve-test:0.0.1"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"cvetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err = cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + str = strings.TrimSpace(str) + So(err, ShouldBeNil) + So(str, ShouldContainSubstring, "ID SEVERITY TITLE") + So(str, ShouldContainSubstring, "CVE") + Convey("invalid image", func() { + args := []string{"cvetest", "--image", "invalid:0.0.1"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"cvetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err = cveCmd.Execute() + So(err, ShouldNotBeNil) + }) + }) + + Convey("Test images by CVE ID", t, func() { + args := []string{"cvetest", "--cve-id", "CVE-2019-20807"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"cvetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + str = strings.TrimSpace(str) + So(err, ShouldBeNil) + So(str, ShouldEqual, "IMAGE NAME TAG DIGEST SIZE zot-cve-test 0.0.1 da0186c7 75MB") + Convey("invalid CVE ID", func() { + args := []string{"cvetest", "--cve-id", "invalid"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"cvetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + str = strings.TrimSpace(str) + So(err, ShouldBeNil) + So(str, ShouldNotContainSubstring, "IMAGE NAME TAG DIGEST SIZE") + }) + }) + + Convey("Test fixed tags by and image name CVE ID", t, func() { + args := []string{"cvetest", "--cve-id", "CVE-2019-20807", "--image", "zot-cve-test", "--fixed"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"cvetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + str = strings.TrimSpace(str) + So(err, ShouldBeNil) + So(str, ShouldEqual, "") + Convey("random cve", func() { + args := []string{"cvetest", "--cve-id", "random", "--image", "zot-cve-test", "--fixed"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"cvetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + str = strings.TrimSpace(str) + So(err, ShouldBeNil) + So(str, ShouldEqual, "") + }) + }) + + Convey("Test CVE by name and CVE ID", t, func() { + args := []string{"cvetest", "--image", "zot-cve-test", "--cve-id", "CVE-2019-20807"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"cvetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + So(err, ShouldBeNil) + So(strings.TrimSpace(str), ShouldEqual, "IMAGE NAME TAG DIGEST SIZE zot-cve-test 0.0.1 da0186c7 75MB") + Convey("invalidname and CVE ID", func() { + args := []string{"cvetest", "--image", "test", "--cve-id", "CVE-20807"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"cvetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cveCmd := NewCveCommand(new(searchService)) + buff := bytes.NewBufferString("") + cveCmd.SetOut(buff) + cveCmd.SetErr(ioutil.Discard) + cveCmd.SetArgs(args) + err := cveCmd.Execute() + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + So(err, ShouldBeNil) + So(strings.TrimSpace(str), ShouldNotContainSubstring, "IMAGE NAME TAG DIGEST SIZE") + }) + }) +} + +func copyFiles(sourceDir string, destDir string) error { + sourceMeta, err := os.Stat(sourceDir) + if err != nil { + return err + } + + if err := os.MkdirAll(destDir, sourceMeta.Mode()); err != nil { + return err + } + + files, err := ioutil.ReadDir(sourceDir) + if err != nil { + return err + } + + for _, file := range files { + sourceFilePath := path.Join(sourceDir, file.Name()) + destFilePath := path.Join(destDir, file.Name()) + + if file.IsDir() { + if err = copyFiles(sourceFilePath, destFilePath); err != nil { + return err + } + } else { + sourceFile, err := os.Open(sourceFilePath) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.Create(destFilePath) + if err != nil { + return err + } + defer destFile.Close() + + if _, err = io.Copy(destFile, sourceFile); err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/cli/image_cmd.go b/pkg/cli/image_cmd.go index 9e527747..447097c8 100644 --- a/pkg/cli/image_cmd.go +++ b/pkg/cli/image_cmd.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" ) -func NewImageCommand(searchService ImageSearchService) *cobra.Command { +func NewImageCommand(searchService SearchService) *cobra.Command { searchImageParams := make(map[string]*string) var servURL, user, outputFormat string @@ -84,7 +84,7 @@ func NewImageCommand(searchService ImageSearchService) *cobra.Command { }, } - setupCmdFlags(imageCmd, searchImageParams, &servURL, &user, &outputFormat) + setupImageFlags(imageCmd, searchImageParams, &servURL, &user, &outputFormat) imageCmd.SetUsageTemplate(imageCmd.UsageTemplate() + usageFooter) return imageCmd @@ -104,7 +104,8 @@ func parseBooleanConfig(configPath, configName, configParam string) (bool, error return val, nil } -func setupCmdFlags(imageCmd *cobra.Command, searchImageParams map[string]*string, servURL, user, outputFormat *string) { +func setupImageFlags(imageCmd *cobra.Command, searchImageParams map[string]*string, + servURL, user, outputFormat *string) { searchImageParams["imageName"] = imageCmd.Flags().StringP("name", "n", "", "List image details by name") imageCmd.Flags().StringVar(servURL, "url", "", "Specify zot server URL if config-name is not mentioned") @@ -113,7 +114,7 @@ func setupCmdFlags(imageCmd *cobra.Command, searchImageParams map[string]*string } func searchImage(searchConfig searchConfig) error { - for _, searcher := range getSearchers() { + for _, searcher := range getImageSearchers() { found, err := searcher.search(searchConfig) if found { if err != nil { diff --git a/pkg/cli/image_cmd_test.go b/pkg/cli/image_cmd_test.go index 6c92df8b..14b09921 100644 --- a/pkg/cli/image_cmd_test.go +++ b/pkg/cli/image_cmd_test.go @@ -63,6 +63,7 @@ func TestSearchImageCmd(t *testing.T) { cmd.SetArgs(args) err := cmd.Execute() So(err, ShouldNotBeNil) + So(err, ShouldEqual, zotErrors.ErrNoURLProvided) }) Convey("Test image no params", t, func() { @@ -440,8 +441,9 @@ func uploadManifest(url string) { type mockService struct{} func (service mockService) getAllImages(ctx context.Context, config searchConfig, username, password string, - channel chan imageListResult, wg *sync.WaitGroup) { + channel chan stringResult, wg *sync.WaitGroup) { defer wg.Done() + defer close(channel) image := &imageStruct{} image.Name = "randomimageName" @@ -455,15 +457,16 @@ func (service mockService) getAllImages(ctx context.Context, config searchConfig str, err := image.string(*config.outputFormat) if err != nil { - channel <- imageListResult{"", err} + channel <- stringResult{"", err} return } - channel <- imageListResult{str, nil} + channel <- stringResult{str, nil} } func (service mockService) getImageByName(ctx context.Context, config searchConfig, - username, password, imageName string, channel chan imageListResult, wg *sync.WaitGroup) { + username, password, imageName string, channel chan stringResult, wg *sync.WaitGroup) { defer wg.Done() + defer close(channel) image := &imageStruct{} image.Name = imageName @@ -477,10 +480,60 @@ func (service mockService) getImageByName(ctx context.Context, config searchConf str, err := image.string(*config.outputFormat) if err != nil { - channel <- imageListResult{"", err} + channel <- stringResult{"", err} return } - channel <- imageListResult{str, nil} + channel <- stringResult{str, nil} +} + +func (service mockService) getCveByImage(ctx context.Context, config searchConfig, username, password, + imageName string, c chan stringResult, wg *sync.WaitGroup) { + defer wg.Done() + defer close(c) + + cveRes := &cveResult{} + cveRes.Data = cveData{ + CVEListForImage: cveListForImage{ + Tag: imageName, + CVEList: []cve{ + { + ID: "dummyCVEID", + Description: "Description of the CVE", + Title: "Title of that CVE", + Severity: "HIGH", + PackageList: []packageList{ + { + Name: "packagename", + FixedVersion: "fixedver", + InstalledVersion: "installedver", + }, + }, + }, + }, + }, + } + + str, err := cveRes.string(*config.outputFormat) + if err != nil { + c <- stringResult{"", err} + return + } + c <- stringResult{str, nil} +} + +func (service mockService) getImagesByCveID(ctx context.Context, config searchConfig, username, password, cveID string, + c chan stringResult, wg *sync.WaitGroup) { + service.getImageByName(ctx, config, username, password, "anImage", c, wg) +} + +func (service mockService) getImageByNameAndCVEID(ctx context.Context, config searchConfig, username, + password, imageName, cveID string, c chan stringResult, wg *sync.WaitGroup) { + service.getImageByName(ctx, config, username, password, imageName, c, wg) +} + +func (service mockService) getFixedTagsForCVE(ctx context.Context, config searchConfig, + username, password, imageName, cveID string, c chan stringResult, wg *sync.WaitGroup) { + service.getImageByName(ctx, config, username, password, imageName, c, wg) } func makeConfigFile(content string) string { diff --git a/pkg/cli/root.go b/pkg/cli/root.go index b81dd91f..721a505e 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -98,7 +98,8 @@ func NewRootCmd() *cobra.Command { rootCmd.AddCommand(gcCmd) rootCmd.AddCommand(NewConfigCommand()) - rootCmd.AddCommand(NewImageCommand(NewImageSearchService())) + rootCmd.AddCommand(NewImageCommand(NewSearchService())) + rootCmd.AddCommand(NewCveCommand(NewSearchService())) rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "show the version and exit") diff --git a/pkg/cli/searcher.go b/pkg/cli/searcher.go index ae01d988..9b0375a8 100644 --- a/pkg/cli/searcher.go +++ b/pkg/cli/searcher.go @@ -9,10 +9,11 @@ import ( "sync" "time" + zotErrors "github.com/anuvu/zot/errors" "github.com/briandowns/spinner" ) -func getSearchers() []searcher { +func getImageSearchers() []searcher { searchers := []searcher{ new(allImagesSearcher), new(imageByNameSearcher), @@ -21,6 +22,17 @@ func getSearchers() []searcher { return searchers } +func getCveSearchers() []searcher { + searchers := []searcher{ + new(cveByImageSearcher), + new(imagesByCVEIDSearcher), + new(tagsByImageNameAndCVEIDSearcher), + new(fixedTagsSearcher), + } + + return searchers +} + type searcher interface { search(searchConfig searchConfig) (bool, error) } @@ -39,11 +51,12 @@ func canSearch(params map[string]*string, requiredParams *set) bool { type searchConfig struct { params map[string]*string - searchService ImageSearchService + searchService SearchService servURL *string user *string outputFormat *string verifyTLS *bool + fixedFlag *bool resultWriter io.Writer spinner spinnerState } @@ -56,7 +69,7 @@ func (search allImagesSearcher) search(config searchConfig) (bool, error) { } username, password := getUsernameAndPassword(*config.user) - imageErr := make(chan imageListResult) + imageErr := make(chan stringResult) ctx, cancel := context.WithCancel(context.Background()) var wg sync.WaitGroup @@ -68,7 +81,7 @@ func (search allImagesSearcher) search(config searchConfig) (bool, error) { var errCh chan error = make(chan error, 1) - go collectImages(config, &wg, imageErr, cancel, errCh) + go collectResults(config, &wg, imageErr, cancel, printImageTableHeader, errCh) wg.Wait() select { case err := <-errCh: @@ -86,18 +99,19 @@ func (search imageByNameSearcher) search(config searchConfig) (bool, error) { } username, password := getUsernameAndPassword(*config.user) - imageErr := make(chan imageListResult) + imageErr := make(chan stringResult) ctx, cancel := context.WithCancel(context.Background()) var wg sync.WaitGroup wg.Add(1) - go config.searchService.getImageByName(ctx, config, username, password, *config.params["imageName"], imageErr, &wg) + go config.searchService.getImageByName(ctx, config, username, password, + *config.params["imageName"], imageErr, &wg) wg.Add(1) var errCh chan error = make(chan error, 1) - go collectImages(config, &wg, imageErr, cancel, errCh) + go collectResults(config, &wg, imageErr, cancel, printImageTableHeader, errCh) wg.Wait() @@ -109,8 +123,146 @@ func (search imageByNameSearcher) search(config searchConfig) (bool, error) { } } -func collectImages(config searchConfig, wg *sync.WaitGroup, imageErr chan imageListResult, - cancel context.CancelFunc, errCh chan error) { +type cveByImageSearcher struct{} + +func (search cveByImageSearcher) search(config searchConfig) (bool, error) { + if !canSearch(config.params, newSet("imageName")) || *config.fixedFlag { + return false, nil + } + + if !validateImageNameTag(*config.params["imageName"]) { + return true, errInvalidImageNameAndTag + } + + username, password := getUsernameAndPassword(*config.user) + strErr := make(chan stringResult) + ctx, cancel := context.WithCancel(context.Background()) + + var wg sync.WaitGroup + + wg.Add(1) + + go config.searchService.getCveByImage(ctx, config, username, password, *config.params["imageName"], strErr, &wg) + wg.Add(1) + + var errCh chan error = make(chan error, 1) + go collectResults(config, &wg, strErr, cancel, printCVETableHeader, errCh) + + wg.Wait() + + select { + case err := <-errCh: + return true, err + default: + return true, nil + } +} + +type imagesByCVEIDSearcher struct{} + +func (search imagesByCVEIDSearcher) search(config searchConfig) (bool, error) { + if !canSearch(config.params, newSet("cveID")) || *config.fixedFlag { + return false, nil + } + + username, password := getUsernameAndPassword(*config.user) + strErr := make(chan stringResult) + ctx, cancel := context.WithCancel(context.Background()) + + var wg sync.WaitGroup + + wg.Add(1) + + go config.searchService.getImagesByCveID(ctx, config, username, password, *config.params["cveID"], strErr, &wg) + wg.Add(1) + + var errCh chan error = make(chan error, 1) + go collectResults(config, &wg, strErr, cancel, printImageTableHeader, errCh) + + wg.Wait() + + select { + case err := <-errCh: + return true, err + default: + return true, nil + } +} + +type tagsByImageNameAndCVEIDSearcher struct{} + +func (search tagsByImageNameAndCVEIDSearcher) search(config searchConfig) (bool, error) { + if !canSearch(config.params, newSet("cveID", "imageName")) || *config.fixedFlag { + return false, nil + } + + if strings.Contains(*config.params["imageName"], ":") { + return true, errInvalidImageName + } + + username, password := getUsernameAndPassword(*config.user) + strErr := make(chan stringResult) + ctx, cancel := context.WithCancel(context.Background()) + + var wg sync.WaitGroup + + wg.Add(1) + + go config.searchService.getImageByNameAndCVEID(ctx, config, username, password, *config.params["imageName"], + *config.params["cveID"], strErr, &wg) + wg.Add(1) + + var errCh chan error = make(chan error, 1) + go collectResults(config, &wg, strErr, cancel, printImageTableHeader, errCh) + + wg.Wait() + + select { + case err := <-errCh: + return true, err + default: + return true, nil + } +} + +type fixedTagsSearcher struct{} + +func (search fixedTagsSearcher) search(config searchConfig) (bool, error) { + if !canSearch(config.params, newSet("cveID", "imageName")) || !*config.fixedFlag { + return false, nil + } + + if strings.Contains(*config.params["imageName"], ":") { + return true, errInvalidImageName + } + + username, password := getUsernameAndPassword(*config.user) + strErr := make(chan stringResult) + ctx, cancel := context.WithCancel(context.Background()) + + var wg sync.WaitGroup + + wg.Add(1) + + go config.searchService.getFixedTagsForCVE(ctx, config, username, password, *config.params["imageName"], + *config.params["cveID"], strErr, &wg) + wg.Add(1) + + var errCh chan error = make(chan error, 1) + go collectResults(config, &wg, strErr, cancel, printImageTableHeader, errCh) + + wg.Wait() + + select { + case err := <-errCh: + return true, err + default: + return true, nil + } +} + +func collectResults(config searchConfig, wg *sync.WaitGroup, imageErr chan stringResult, + cancel context.CancelFunc, printHeader printHeader, errCh chan error) { var foundResult bool defer wg.Done() @@ -133,10 +285,10 @@ func collectImages(config searchConfig, wg *sync.WaitGroup, imageErr chan imageL return } - if !foundResult && (*config.outputFormat == "text" || *config.outputFormat == "") { + if !foundResult && (*config.outputFormat == defaultOutoutFormat || *config.outputFormat == "") { var builder strings.Builder - printImageTableHeader(&builder) + printHeader(&builder) fmt.Fprint(config.resultWriter, builder.String()) } @@ -144,8 +296,10 @@ func collectImages(config searchConfig, wg *sync.WaitGroup, imageErr chan imageL fmt.Fprint(config.resultWriter, result.StrValue) case <-time.After(waitTimeout): - cancel() config.spinner.stopSpinner() + cancel() + + errCh <- zotErrors.ErrCLITimeout return } @@ -161,6 +315,22 @@ func getUsernameAndPassword(user string) (string, string) { return "", "" } +func validateImageNameTag(input string) bool { + if !strings.Contains(input, ":") { + return false + } + + split := strings.Split(input, ":") + name := strings.TrimSpace(split[0]) + tag := strings.TrimSpace(split[1]) + + if name == "" || tag == "" { + return false + } + + return true +} + type set struct { m map[string]struct{} } @@ -190,7 +360,7 @@ var ( ErrInvalidOutputFormat = errors.New("invalid output format") ) -type imageListResult struct { +type stringResult struct { StrValue string Err error } @@ -212,17 +382,37 @@ func (spinner *spinnerState) stopSpinner() { } } +type printHeader func(writer io.Writer) + func printImageTableHeader(writer io.Writer) { - table := getNoBorderTableWriter(writer) - row := []string{"IMAGE NAME", - "TAG", - "DIGEST", - "SIZE", - } + table := getImageTableWriter(writer) + row := make([]string, 4) + + row[colImageNameIndex] = "IMAGE NAME" + row[colTagIndex] = "TAG" + row[colDigestIndex] = "DIGEST" + row[colSizeIndex] = "SIZE" + + table.Append(row) + table.Render() +} + +func printCVETableHeader(writer io.Writer) { + table := getCVETableWriter(writer) + row := make([]string, 3) + row[colCVEIDIndex] = "ID" + row[colCVESeverityIndex] = "SEVERITY" + row[colCVETitleIndex] = "TITLE" + table.Append(row) table.Render() } const ( - waitTimeout = 6 * time.Second + waitTimeout = httpTimeout + 5*time.Second +) + +var ( + errInvalidImageNameAndTag = errors.New("cli: Invalid input format. Expected IMAGENAME:TAG") + errInvalidImageName = errors.New("cli: Invalid input format. Expected IMAGENAME without :TAG") ) diff --git a/pkg/cli/service.go b/pkg/cli/service.go index 6dc841ab..938b560c 100644 --- a/pkg/cli/service.go +++ b/pkg/cli/service.go @@ -2,11 +2,13 @@ package cli import ( "context" + "errors" "fmt" "io" "net/url" "strings" "sync" + "time" "github.com/dustin/go-humanize" jsoniter "github.com/json-iterator/go" @@ -16,20 +18,29 @@ import ( zotErrors "github.com/anuvu/zot/errors" ) -type ImageSearchService interface { +type SearchService interface { getAllImages(ctx context.Context, config searchConfig, username, password string, - channel chan imageListResult, wg *sync.WaitGroup) + channel chan stringResult, wg *sync.WaitGroup) getImageByName(ctx context.Context, config searchConfig, username, password, imageName string, - channel chan imageListResult, wg *sync.WaitGroup) + channel chan stringResult, wg *sync.WaitGroup) + getCveByImage(ctx context.Context, config searchConfig, username, password, imageName string, + channel chan stringResult, wg *sync.WaitGroup) + getImagesByCveID(ctx context.Context, config searchConfig, username, password, cveID string, + channel chan stringResult, wg *sync.WaitGroup) + getImageByNameAndCVEID(ctx context.Context, config searchConfig, username, password, imageName, cveID string, + channel chan stringResult, wg *sync.WaitGroup) + getFixedTagsForCVE(ctx context.Context, config searchConfig, username, password, imageName, cveID string, + channel chan stringResult, wg *sync.WaitGroup) } + type searchService struct{} -func NewImageSearchService() ImageSearchService { +func NewSearchService() SearchService { return searchService{} } func (service searchService) getImageByName(ctx context.Context, config searchConfig, - username, password, imageName string, c chan imageListResult, wg *sync.WaitGroup) { + username, password, imageName string, c chan stringResult, wg *sync.WaitGroup) { defer wg.Done() defer close(c) @@ -47,7 +58,7 @@ func (service searchService) getImageByName(ctx context.Context, config searchCo } func (service searchService) getAllImages(ctx context.Context, config searchConfig, username, password string, - c chan imageListResult, wg *sync.WaitGroup) { + c chan stringResult, wg *sync.WaitGroup) { defer wg.Done() defer close(c) @@ -58,7 +69,7 @@ func (service searchService) getAllImages(ctx context.Context, config searchConf if isContextDone(ctx) { return } - c <- imageListResult{"", err} + c <- stringResult{"", err} return } @@ -68,7 +79,7 @@ func (service searchService) getAllImages(ctx context.Context, config searchConf if isContextDone(ctx) { return } - c <- imageListResult{"", err} + c <- stringResult{"", err} return } @@ -91,7 +102,7 @@ func (service searchService) getAllImages(ctx context.Context, config searchConf } func getImage(ctx context.Context, config searchConfig, username, password, imageName string, - c chan imageListResult, wg *sync.WaitGroup, pool *requestsPool) { + c chan stringResult, wg *sync.WaitGroup, pool *requestsPool) { defer wg.Done() tagListEndpoint, err := combineServerAndEndpointURL(*config.servURL, fmt.Sprintf("/v2/%s/tags/list", imageName)) @@ -99,7 +110,7 @@ func getImage(ctx context.Context, config searchConfig, username, password, imag if isContextDone(ctx) { return } - c <- imageListResult{"", err} + c <- stringResult{"", err} return } @@ -111,7 +122,7 @@ func getImage(ctx context.Context, config searchConfig, username, password, imag if isContextDone(ctx) { return } - c <- imageListResult{"", err} + c <- stringResult{"", err} return } @@ -123,6 +134,199 @@ func getImage(ctx context.Context, config searchConfig, username, password, imag } } +func (service searchService) getImagesByCveID(ctx context.Context, config searchConfig, username, + password, cveID string, c chan stringResult, wg *sync.WaitGroup) { + defer wg.Done() + defer close(c) + + query := fmt.Sprintf(`{ImageListForCVE(id: "%s") {`+` + Name Tags } + }`, + cveID) + result := &imagesForCve{} + + endPoint, err := combineServerAndEndpointURL(*config.servURL, "/query") + if err != nil { + if isContextDone(ctx) { + return + } + c <- stringResult{"", err} + + return + } + + err = makeGraphQLRequest(endPoint, query, username, password, *config.verifyTLS, result) + if err != nil { + if isContextDone(ctx) { + return + } + c <- stringResult{"", err} + + return + } + + if result.Errors != nil { + var errBuilder strings.Builder + + for _, err := range result.Errors { + fmt.Fprintln(&errBuilder, err.Message) + } + + if isContextDone(ctx) { + return + } + c <- stringResult{"", errors.New(errBuilder.String())} //nolint: goerr113 + + return + } + + var localWg sync.WaitGroup + + p := newSmoothRateLimiter(ctx, &localWg, c) + localWg.Add(1) + + go p.startRateLimiter() + + for _, image := range result.Data.ImageListForCVE { + for _, tag := range image.Tags { + localWg.Add(1) + + go addManifestCallToPool(ctx, config, p, username, password, image.Name, tag, c, &localWg) + } + } + + localWg.Wait() +} + +func (service searchService) getImageByNameAndCVEID(ctx context.Context, config searchConfig, username, + password, imageName, cveID string, c chan stringResult, wg *sync.WaitGroup) { + defer wg.Done() + defer close(c) + + query := fmt.Sprintf(`{ImageListForCVE(id: "%s") {`+` + Name Tags } + }`, + cveID) + result := &imagesForCve{} + + endPoint, err := combineServerAndEndpointURL(*config.servURL, "/query") + if err != nil { + if isContextDone(ctx) { + return + } + c <- stringResult{"", err} + + return + } + + err = makeGraphQLRequest(endPoint, query, username, password, *config.verifyTLS, result) + if err != nil { + if isContextDone(ctx) { + return + } + c <- stringResult{"", err} + + return + } + + if result.Errors != nil { + var errBuilder strings.Builder + + for _, err := range result.Errors { + fmt.Fprintln(&errBuilder, err.Message) + } + + if isContextDone(ctx) { + return + } + c <- stringResult{"", errors.New(errBuilder.String())} //nolint: goerr113 + + return + } + + var localWg sync.WaitGroup + + p := newSmoothRateLimiter(ctx, &localWg, c) + localWg.Add(1) + + go p.startRateLimiter() + + for _, image := range result.Data.ImageListForCVE { + if !strings.EqualFold(imageName, image.Name) { + continue + } + + for _, tag := range image.Tags { + localWg.Add(1) + + go addManifestCallToPool(ctx, config, p, username, password, image.Name, tag, c, &localWg) + } + } + + localWg.Wait() +} + +func (service searchService) getCveByImage(ctx context.Context, config searchConfig, username, password, + imageName string, c chan stringResult, wg *sync.WaitGroup) { + defer wg.Done() + defer close(c) + + query := fmt.Sprintf(`{ CVEListForImage (image:"%s")`+ + ` { Tag CVEList { Id Title Severity Description `+ + `PackageList {Name InstalledVersion FixedVersion}} } }`, imageName) + result := &cveResult{} + + endPoint, err := combineServerAndEndpointURL(*config.servURL, "/query") + if err != nil { + if isContextDone(ctx) { + return + } + c <- stringResult{"", err} + + return + } + + err = makeGraphQLRequest(endPoint, query, username, password, *config.verifyTLS, result) + if err != nil { + if isContextDone(ctx) { + return + } + c <- stringResult{"", err} + + return + } + + if result.Errors != nil { + var errBuilder strings.Builder + + for _, err := range result.Errors { + fmt.Fprintln(&errBuilder, err.Message) + } + + if isContextDone(ctx) { + return + } + c <- stringResult{"", errors.New(errBuilder.String())} //nolint: goerr113 + + return + } + + str, err := result.string(*config.outputFormat) + if err != nil { + if isContextDone(ctx) { + return + } + c <- stringResult{"", err} + + return + } + + if isContextDone(ctx) { + return + } + c <- stringResult{str, nil} +} + func isContextDone(ctx context.Context) bool { select { case <-ctx.Done(): @@ -132,8 +336,77 @@ func isContextDone(ctx context.Context) bool { } } +func (service searchService) getFixedTagsForCVE(ctx context.Context, config searchConfig, + username, password, imageName, cveID string, c chan stringResult, wg *sync.WaitGroup) { + defer wg.Done() + defer close(c) + + query := fmt.Sprintf(`{ImageListWithCVEFixed (id: "%s", image: "%s") {`+` + Tags {Name Timestamp} } + }`, + cveID, imageName) + result := &fixedTags{} + + endPoint, err := combineServerAndEndpointURL(*config.servURL, "/query") + if err != nil { + if isContextDone(ctx) { + return + } + c <- stringResult{"", err} + + return + } + + err = makeGraphQLRequest(endPoint, query, username, password, *config.verifyTLS, result) + if err != nil { + if isContextDone(ctx) { + return + } + c <- stringResult{"", err} + + return + } + + if result.Errors != nil { + var errBuilder strings.Builder + + for _, err := range result.Errors { + if err.Message == zotErrors.ErrFixedTagNotFound.Error() { + // this if block and goto should be removed when the server API is fixed. + // currently, the API returns an error if the data is empty and we are ignoring that error here + goto Outside + } + + fmt.Fprintln(&errBuilder, err.Message) + } + + if isContextDone(ctx) { + return + } + c <- stringResult{"", errors.New(errBuilder.String())} //nolint: goerr113 + + return + } + +Outside: + var localWg sync.WaitGroup + + p := newSmoothRateLimiter(ctx, &localWg, c) + localWg.Add(1) + + go p.startRateLimiter() + + for _, imgTag := range result.Data.ImageListWithCVEFixed.Tags { + localWg.Add(1) + + go addManifestCallToPool(ctx, config, p, username, password, imageName, imgTag.Name, c, &localWg) + } + + localWg.Wait() +} + func addManifestCallToPool(ctx context.Context, config searchConfig, p *requestsPool, username, password, imageName, - tagName string, c chan imageListResult, wg *sync.WaitGroup) { + tagName string, c chan stringResult, wg *sync.WaitGroup) { defer wg.Done() resultManifest := manifestResponse{} @@ -144,7 +417,7 @@ func addManifestCallToPool(ctx context.Context, config searchConfig, p *requests if isContextDone(ctx) { return } - c <- imageListResult{"", err} + c <- stringResult{"", err} } job := manifestJob{ @@ -161,6 +434,109 @@ func addManifestCallToPool(ctx context.Context, config searchConfig, p *requests p.submitJob(&job) } +type cveResult struct { + Errors []errorGraphQL `json:"errors"` + Data cveData `json:"data"` +} +type errorGraphQL struct { + Message string `json:"message"` + Path []string `json:"path"` +} +type packageList struct { + Name string `json:"Name"` + InstalledVersion string `json:"InstalledVersion"` + FixedVersion string `json:"FixedVersion"` +} +type cve struct { + ID string `json:"Id"` + Severity string `json:"Severity"` + Title string `json:"Title"` + Description string `json:"Description"` + PackageList []packageList `json:"PackageList"` +} +type cveListForImage struct { + Tag string `json:"Tag"` + CVEList []cve `json:"CVEList"` +} +type cveData struct { + CVEListForImage cveListForImage `json:"CVEListForImage"` +} + +func (cve cveResult) string(format string) (string, error) { + switch strings.ToLower(format) { + case "", defaultOutoutFormat: + return cve.stringPlainText() + case "json": + return cve.stringJSON() + case "yml", "yaml": + return cve.stringYAML() + default: + return "", ErrInvalidOutputFormat + } +} + +func (cve cveResult) stringPlainText() (string, error) { + var builder strings.Builder + + table := getCVETableWriter(&builder) + + for _, c := range cve.Data.CVEListForImage.CVEList { + id := ellipsize(c.ID, cveIDWidth, ellipsis) + title := ellipsize(c.Title, cveTitleWidth, ellipsis) + severity := ellipsize(c.Severity, cveSeverityWidth, ellipsis) + row := make([]string, 3) + row[colCVEIDIndex] = id + row[colCVESeverityIndex] = severity + row[colCVETitleIndex] = title + + table.Append(row) + } + + table.Render() + + return builder.String(), nil +} + +func (cve cveResult) stringJSON() (string, error) { + var json = jsoniter.ConfigCompatibleWithStandardLibrary + body, err := json.MarshalIndent(cve.Data.CVEListForImage, "", " ") + + if err != nil { + return "", err + } + + return string(body), nil +} + +func (cve cveResult) stringYAML() (string, error) { + body, err := yaml.Marshal(&cve.Data.CVEListForImage) + + if err != nil { + return "", err + } + + return string(body), nil +} + +type fixedTags struct { + Errors []errorGraphQL `json:"errors"` + Data struct { + ImageListWithCVEFixed struct { + Tags []struct { + Name string `json:"Name"` + Timestamp time.Time `json:"Timestamp"` + } `json:"Tags"` + } `json:"ImageListWithCVEFixed"` + } `json:"data"` +} + +type imagesForCve struct { + Errors []errorGraphQL `json:"errors"` + Data struct { + ImageListForCVE []tagListResp `json:"ImageListForCVE"` + } `json:"data"` +} + type tagListResp struct { Name string `json:"name"` Tags []string `json:"tags"` @@ -178,7 +554,7 @@ type tags struct { func (img imageStruct) string(format string) (string, error) { switch strings.ToLower(format) { - case "", "text": + case "", defaultOutoutFormat: return img.stringPlainText() case "json": return img.stringJSON() @@ -192,18 +568,19 @@ func (img imageStruct) string(format string) (string, error) { func (img imageStruct) stringPlainText() (string, error) { var builder strings.Builder - table := getNoBorderTableWriter(&builder) + table := getImageTableWriter(&builder) for _, tag := range img.Tags { imageName := ellipsize(img.Name, imageNameWidth, ellipsis) tagName := ellipsize(tag.Name, tagWidth, ellipsis) digest := ellipsize(tag.Digest, digestWidth, "") size := ellipsize(strings.ReplaceAll(humanize.Bytes(tag.Size), " ", ""), sizeWidth, ellipsis) - row := []string{imageName, - tagName, - digest, - size, - } + row := make([]string, 4) + + row[colImageNameIndex] = imageName + row[colTagIndex] = tagName + row[colDigestIndex] = digest + row[colSizeIndex] = size table.Append(row) } @@ -273,6 +650,7 @@ func combineServerAndEndpointURL(serverURL, endPoint string) (string, error) { } func ellipsize(text string, max int, trailing string) string { + text = strings.TrimSpace(text) if len(text) <= max { return text } @@ -282,7 +660,7 @@ func ellipsize(text string, max int, trailing string) string { return text[:max-chopLength] + trailing } -func getNoBorderTableWriter(writer io.Writer) *tablewriter.Table { +func getImageTableWriter(writer io.Writer) *tablewriter.Table { table := tablewriter.NewWriter(writer) table.SetAutoWrapText(false) @@ -304,6 +682,27 @@ func getNoBorderTableWriter(writer io.Writer) *tablewriter.Table { return table } +func getCVETableWriter(writer io.Writer) *tablewriter.Table { + table := tablewriter.NewWriter(writer) + + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetHeaderLine(false) + table.SetBorder(false) + table.SetTablePadding(" ") + table.SetNoWhiteSpace(true) + table.SetColMinWidth(colCVEIDIndex, cveIDWidth) + table.SetColMinWidth(colCVESeverityIndex, cveSeverityWidth) + table.SetColMinWidth(colCVETitleIndex, cveTitleWidth) + + return table +} + const ( imageNameWidth = 32 tagWidth = 24 @@ -315,4 +714,14 @@ const ( colTagIndex = 1 colDigestIndex = 2 colSizeIndex = 3 + + cveIDWidth = 16 + cveSeverityWidth = 8 + cveTitleWidth = 48 + + colCVEIDIndex = 0 + colCVESeverityIndex = 1 + colCVETitleIndex = 2 + + defaultOutoutFormat = "text" )