diff --git a/Gopkg.lock b/Gopkg.lock index d94ab7e..a301740 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -82,6 +82,12 @@ packages = ["proto"] revision = "5fc2294e655b78ed8a02082d37808d46c17d7e64" +[[projects]] + name = "github.com/jawher/mow.cli" + packages = ["."] + revision = "a459d5906bb7a9c5eda7c4d02eec7c541120226e" + version = "v1.0.1" + [[projects]] name = "github.com/julienschmidt/httprouter" packages = ["."] @@ -167,6 +173,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "0584d7d1d30097543c26cba4c8e8cf5b98cee52d41054a5712ba8a2b4008bda2" + inputs-digest = "835a2265f4645de70922f3b4a56e3a89c373b25c482652aacc77ed594ad28ab4" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 2b758d1..70d2fd2 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -1,25 +1,4 @@ - -# Gopkg.toml example -# -# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md -# for detailed Gopkg.toml documentation. -# -# required = ["github.com/user/thing/cmd/thing"] -# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] -# -# [[constraint]] -# name = "github.com/user/project" -# version = "1.0.0" -# -# [[constraint]] -# name = "github.com/user/project2" -# branch = "dev" -# source = "github.com/myfork/project2" -# -# [[override]] -# name = "github.com/x/y" -# version = "2.4.0" - +# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md for detailed Gopkg.toml documentation. [[constraint]] name = "github.com/coreos/clair" @@ -31,3 +10,10 @@ [[constraint]] name = "gopkg.in/yaml.v2" + +[[constraint]] + name = "github.com/jawher/mow.cli" + version = "v1.0.1" + +[[constraint]] + name = "github.com/mbndr/logo" diff --git a/README.md b/README.md index bc62903..4366219 100644 --- a/README.md +++ b/README.md @@ -6,39 +6,39 @@ ## Docker containers vulnerability scan -When you work with containers (Docker) you are not only packaging your application but also part of the OS. Therefore it is crucial to know what kind of libraries might be vulnerable in you container. One way to find this information is to use and look at the Docker Hub or Quay.io security scan. The problem whit these scans is that they are only showing you the information but are not part of your CI/CD that actually blocks your container when it contains vulnerabilities. +When you work with containers (Docker) you are not only packaging your application but also part of the OS. It is crucial to know what kind of libraries might be vulnerable in your container. One way to find this information is to look at the Docker registry [Hub or Quay.io] security scan. This means your vulnerable image is already on the Docker registry. -What you want is: +What you want is a scan as a part of CI/CD pipeline that stops the Docker image push on vulnerabilities: 1. Build and test your application 1. Build the container 1. Test the container for vulnerabilities -1. Check the vulnerabilities against allowed ones, if everything is allowed pass, otherwise fail +1. Check the vulnerabilities against allowed ones, if everything is allowed then pass otherwise fail -This straight forward process is not that easy to achieve when using the services like Docker Hub or Quay.io. This is because they work asynchronously which makes it harder to do straight forward CI/CD pipeline. +This straightforward process is not that easy to achieve when using the services like Docker Hub or Quay.io. This is because they work asynchronously which makes it harder to do straightforward CI/CD pipeline. ## Clair to the rescue -CoreOS has created an awesome container scan tool called "clair". Clair is also used by Quay.io. What clair does not have is a simple tool that scans your image and compares the vulnerabilities against a whitelist to see if they are approved or not. +CoreOS has created an awesome container scan tool called Clair. Clair is also used by Quay.io. What clair does not have is a simple tool that scans your image and compares the vulnerabilities against a whitelist to see if they are approved or not. -This is where clair-scanner comes in to place. The clair-scanner does the following: +This is where clair-scanner comes into place. The clair-scanner does the following: * Scans an image against Clair server * Compares the vulnerabilities against a whitelist -* Tells you if there are vurnabilities that are not in the whitelist and fails +* Tells you if there are vulnerabilities that are not in the whitelist and fails * If everything is fine it completes correctly ## Clair server or standalone -For the clair-scanner to work you need a clair server. It is not always convenient to have a dedicated clair server therefore I have created a way to run this standalone. See here +For the clair-scanner to work, you need a clair server. It is not always convenient to have a dedicated clair server, therefore, I have created a way to run this standalone. See here ## Credits -The clair-scanner is a copy of the Clair 'analyze-local-images' with changes/improvments and addition that checks the vulnerabilities against a whitelist. +The clair-scanner is a copy of the Clair 'analyze-local-images' with changes/improvements and addition that checks the vulnerabilities against a whitelist. ## Build -clair-scanner is build with Go 1.9 and uses `dep` as dependencies manager. Use the Makefile to build and install dependencies. +clair-scanner is built with Go 1.9 and uses `dep` as dependencies manager. Use the Makefile to build and install dependencies. ```bash make ensure && make build @@ -59,16 +59,53 @@ docker run -p 5432:5432 -d --name db arminc/clair-db:2017-09-18 docker run -p 6060:6060 --link db:postgres -d --name clair arminc/clair-local-scan:v2.0.1 ``` -Now scan a container, that has a whitelisted CVE: +Now scan a container, that has a whitelisted CVE (this is on OSX with Docker for Mac): ```bash -clair-scanner nginx:1.11.6-alpine example-nginx.yaml http://YOUR_LOCAL_IP:6060 YOUR_LOCAL_IP +clair-scanner -w example-alpine.yaml --ip YOUR_LOCAL_IP alpine:3.5 ``` -Or a container that does not have a whitelisted CVE: +Output: ```bash -clair-scanner nginx:1.11.6-alpine example-whitelist.yaml http://YOUR_LOCAL_IP:6060 YOUR_LOCAL_IP +2017/09/24 11:20:24 [INFO] ▶ Start clair-scanner +2017/09/24 11:20:24 [INFO] ▶ Server listening on port 9279 +2017/09/24 11:20:24 [INFO] ▶ Analyzing 693bdf455e7bf0952f8a4539f9f96aa70c489ca239a7dbed0afb481c87cbe131 +2017/09/24 11:20:24 [INFO] ▶ Image [alpine:3.5] not vulnerable +``` + +Or a container that does not have a whitelisted CVE (this is on OSX with Docker for Mac): + +```bash +clair-scanner --ip YOUR_LOCAL_IP alpine:3.5 +``` + +Output: + +```bash +2017/09/24 11:16:41 [INFO] ▶ Start clair-scanner +2017/09/24 11:16:41 [INFO] ▶ Server listening on port 9279 +2017/09/24 11:16:41 [INFO] ▶ Analyzing 693bdf455e7bf0952f8a4539f9f96aa70c489ca239a7dbed0afb481c87cbe131 +2017/09/24 11:16:41 [CRIT] ▶ Image contains unapproved vulnerabilities: [CVE-2016-9840 CVE-2016-9841 CVE-2016-9842 CVE-2016-9843] +``` + +## Help information + +```bash +$ ./clair-scanner -h + +Usage: clair-scanner [OPTIONS] IMAGE + +Scan local Docker images for vulnerabilities with Clair + +Arguments: + IMAGE="" Name of the Docker image to scan + +Options: + -w, --whitelist="" Path to the whitelist file + -c, --clair="http://127.0.0.1:6060" Clair url + --ip="localhost" IP addres where clair-scanner is running on + -l, --log="" Log to a file ``` ## Example whitelist yaml file @@ -85,4 +122,8 @@ images: CVE-2017-5230: XSX alpine: CVE-2017-3261: SE -``` \ No newline at end of file +``` + +## Release + +To make a release create a tag and push it \ No newline at end of file diff --git a/clair.go b/clair.go new file mode 100644 index 0000000..6941c9d --- /dev/null +++ b/clair.go @@ -0,0 +1,57 @@ +package main + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + + "github.com/coreos/clair/api/v1" +) + +func analyzeLayers(layerIds []string, clairURL string, scannerIP string) { + tmpPath := "http://" + scannerIP + ":" + httpPort + + for i := 0; i < len(layerIds); i++ { + Logger.Infof("Analyzing %s", layerIds[i]) + + if i > 0 { + analyzeLayer(clairURL, tmpPath+"/"+layerIds[i]+"/layer.tar", layerIds[i], layerIds[i-1]) + } else { + analyzeLayer(clairURL, tmpPath+"/"+layerIds[i]+"/layer.tar", layerIds[i], "") + } + } +} + +func analyzeLayer(clairURL, path, layerName, parentLayerName string) { + payload := v1.LayerEnvelope{ + Layer: &v1.Layer{ + Name: layerName, + Path: path, + ParentName: parentLayerName, + Format: "Docker", + }, + } + jsonPayload, err := json.Marshal(payload) + if err != nil { + Logger.Fatalf("Could not analyze layer, payload is not json %s", err) + } + + request, err := http.NewRequest("POST", clairURL+postLayerURI, bytes.NewBuffer(jsonPayload)) + if err != nil { + Logger.Fatalf("Could not analyze layer, could not prepare request for Clair %s", err) + } + + request.Header.Set("Content-Type", "application/json") + client := &http.Client{} + response, err := client.Do(request) + if err != nil { + Logger.Fatalf("Could not analyze layer, POST to Clair failed %s", err) + } + defer response.Body.Close() + + if response.StatusCode != 201 { + body, _ := ioutil.ReadAll(response.Body) + Logger.Fatalf("Could not analyze layer, Clair responded with a failure: Got response %d with message %s", response.StatusCode, string(body)) + } +} diff --git a/docker.go b/docker.go new file mode 100644 index 0000000..10c1ab4 --- /dev/null +++ b/docker.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "os" + "strings" + + "github.com/docker/docker/client" +) + +// TODO Add support for older version of docker + +type manifestJson struct { + Layers []string +} + +// saveDockerImage saves Docker image to temorary folder +func saveDockerImage(imageName string, tmpPath string) { + docker := createDockerClient() + + imageReader, err := docker.ImageSave(context.Background(), []string{imageName}) + if err != nil { + Logger.Fatalf("Could not save Docker image [%v] : %v", imageName, err) + } + + defer imageReader.Close() + + if err = untar(imageReader, tmpPath); err != nil { + Logger.Fatalf("Could not save Docker image, could not untar [%v] : %v", imageName, err) + } +} + +func createDockerClient() client.APIClient { + docker, err := client.NewEnvClient() + if err != nil { + Logger.Fatalf("Could not create a Docker client: %v", err) + } + return docker +} + +// TODO make a test +func getImageLayerIds(path string) []string { + manifest := readManifestFile(path) + + var layers []string + for _, layer := range manifest[0].Layers { + layers = append(layers, strings.TrimSuffix(layer, "/layer.tar")) + } + return layers +} + +func readManifestFile(path string) []manifestJson { + manifestFile := path + "/manifest.json" + mf, err := os.Open(manifestFile) + if err != nil { + Logger.Fatalf("Could not read Docker image layers, could not open [%v]: %v", manifestFile, err) + } + defer mf.Close() + + return parseAndValidateManifestFile(mf) +} + +func parseAndValidateManifestFile(manifestFile io.Reader) []manifestJson { + var manifest []manifestJson + if err := json.NewDecoder(manifestFile).Decode(&manifest); err != nil { + Logger.Fatalf("Could not read Docker image layers, manifest.json is not json: %v", err) + } else if len(manifest) != 1 { + Logger.Fatalf("Could not read Docker image layers, manifest.json is not valid") + } else if len(manifest[0].Layers) == 0 { + Logger.Fatalf("Could not read Docker image layers, no layers can be found") + } + return manifest +} diff --git a/example-alpine.yaml b/example-alpine.yaml new file mode 100644 index 0000000..74f5c2b --- /dev/null +++ b/example-alpine.yaml @@ -0,0 +1,6 @@ +images: + alpine: + CVE-2016-9840: zlib + CVE-2016-9841: zlib + CVE-2016-9842: zlib + CVE-2016-9843: zlib diff --git a/example-nginx.yaml b/example-nginx.yaml deleted file mode 100644 index 3cb3ff5..0000000 --- a/example-nginx.yaml +++ /dev/null @@ -1,3 +0,0 @@ -images: - nginx: - CVE-2016-6301: NTP atack diff --git a/main.go b/main.go index bb28e1d..be90465 100644 --- a/main.go +++ b/main.go @@ -1,361 +1,69 @@ package main import ( - "archive/tar" - "bytes" - "context" - "encoding/json" - "errors" - "flag" "fmt" - "io" - "io/ioutil" "log" - "net/http" "os" - "os/signal" - "path/filepath" - "strconv" - "strings" - "time" - "gopkg.in/yaml.v2" - - "github.com/coreos/clair/api/v1" - "github.com/docker/docker/client" - "github.com/fatih/color" + cli "github.com/jawher/mow.cli" + "github.com/mbndr/logo" ) const ( - scriptTerminatedByControlC = 130 - generalExit = 1 - success = 0 - tmpPrefix = "clair-scanner-" - httpPort = 9279 - postLayerURI = "/v1/layers" - getLayerFeaturesURI = "/v1/layers/%s?vulnerabilities" + tmpPrefix = "clair-scanner-" + postLayerURI = "/v1/layers" + getLayerFeaturesURI = "/v1/layers/%s?vulnerabilities" ) -type vulnerabilityInfo struct { - vulnerability string - namespace string - severity string -} - -type acceptedVulnerability struct { - Cve string - Description string -} - -type vulnerabilitiesWhitelist struct { - GeneralWhitelist map[string]string - Images map[string]map[string]string -} +var ( + whitelist = vulnerabilitiesWhitelist{} + Logger *logo.Logger +) func main() { - flag.Parse() - start(flag.Args()[0], parseWhitelist(flag.Args()[1]), flag.Args()[2], flag.Args()[3]) - os.Exit(success) -} - -func parseWhitelist(whitelistFile string) vulnerabilitiesWhitelist { - whitelist := vulnerabilitiesWhitelist{} - whitelistBytes, err := ioutil.ReadFile(whitelistFile) - if err != nil { - log.Fatal(err) - } - err = yaml.Unmarshal(whitelistBytes, &whitelist) - if err != nil { - log.Fatalf("error: %v", err) - } - return whitelist -} - -func start(imageName string, whitelist vulnerabilitiesWhitelist, clairURL string, scannerIP string) { - tmpPath := createTmpPath() - defer os.RemoveAll(tmpPath) - interrupt := make(chan os.Signal) - signal.Notify(interrupt, os.Interrupt, os.Kill) - - analyzeCh := make(chan error, 1) - go func() { - analyzeCh <- analyzeImage(imageName, tmpPath, clairURL, scannerIP, whitelist) - }() - - select { - case <-interrupt: - os.Exit(scriptTerminatedByControlC) - case err := <-analyzeCh: - if err != nil { - os.Exit(generalExit) + app := cli.App("clair-scanner", "Scan local Docker images for vulnerabilities with Clair") + + var ( + whitelistFile = app.StringOpt("w whitelist", "", "Path to the whitelist file") + clair = app.StringOpt("c clair", "http://127.0.0.1:6060", "Clair url") + ip = app.StringOpt("ip", "localhost", "IP addres where clair-scanner is running on") + logFile = app.StringOpt("l log", "", "Log to a file") + imageName = app.StringArg("IMAGE", "", "Name of the Docker image to scan") + ) + + app.Before = func() { + logger(*logFile) + if *whitelistFile != "" { + whitelist = parseWhitelistFile(*whitelistFile) } } -} - -func createTmpPath() string { - tmpPath, err := ioutil.TempDir("", tmpPrefix) - if err != nil { - log.Fatalf("Could not create temporary folder: %s", err) - } - return tmpPath -} - -func analyzeImage(imageName string, tmpPath string, clairURL string, scannerIP string, whitelist vulnerabilitiesWhitelist) error { - err := saveImage(imageName, tmpPath) - if err != nil { - log.Printf("Could not save the image %s", err) - return err - } - layerIds, err := getImageLayerIds(tmpPath) - if err != nil { - log.Printf("Could not read the image layer ids %s", err) - return err - } - if err = analyzeLayers(layerIds, tmpPath, clairURL, scannerIP); err != nil { - log.Printf("Analyzing faild: %s", err) - return err - } - vulnerabilities, err := getVulnerabilities(clairURL, layerIds) - if err != nil { - log.Printf("Analyzing failed: %s", err) - return err - } - err = vulnerabilitiesApproved(imageName, vulnerabilities, whitelist) - if err != nil { - log.Printf("Image contains unapproved vulnerabilities: %s", err) - return err - } - return nil -} - -func vulnerabilitiesApproved(imageName string, vulnerabilities []vulnerabilityInfo, whitelist vulnerabilitiesWhitelist) error { - var unapproved []string - imageVulnerabilities := getImageVulnerabilities(imageName, whitelist.Images) - for i := 0; i < len(vulnerabilities); i++ { - vulnerability := vulnerabilities[i].vulnerability - vulnerable := true + app.Action = func() { + Logger.Info("Start clair-scanner") - if _, exists := whitelist.GeneralWhitelist[vulnerability]; exists { - vulnerable = false - } - if vulnerable && len(imageVulnerabilities) > 0 { - if _, exists := imageVulnerabilities[vulnerability]; exists { - vulnerable = false - } - } - if vulnerable { - unapproved = append(unapproved, vulnerability) - } - } - if len(unapproved) > 0 { - return fmt.Errorf("%s", unapproved) - } - return nil -} + go listenForSignal(func(s os.Signal) { + log.Fatalf("Application interupted [%v]", s) + }) -func getImageVulnerabilities(imageName string, whitelistImageVulnerabilities map[string]map[string]string) map[string]string { - var imageVulnerabilities map[string]string - imageWithoutVersion := strings.Split(imageName, ":") - if val, exists := whitelistImageVulnerabilities[imageWithoutVersion[0]]; exists { - imageVulnerabilities = val + scan(*imageName, whitelist, *clair, *ip) } - return imageVulnerabilities + app.Run(os.Args) } -func analyzeLayers(layerIds []string, tmpPath string, clairURL string, scannerIP string) error { - ch := make(chan error) - go listenHTTP(tmpPath, ch) - select { - case err := <-ch: - return fmt.Errorf("An error occurred when starting HTTP server: %s", err) - case <-time.After(100 * time.Millisecond): - break - } +func logger(logFile string) { + cliRec := logo.NewReceiver(os.Stderr, "") + cliRec.Color = true - tmpPath = "http://" + scannerIP + ":" + strconv.Itoa(httpPort) - var err error - - for i := 0; i < len(layerIds); i++ { - log.Printf("Analyzing %s\n", layerIds[i]) - - if i > 0 { - err = analyzeLayer(clairURL, tmpPath+"/"+layerIds[i]+"/layer.tar", layerIds[i], layerIds[i-1]) - } else { - err = analyzeLayer(clairURL, tmpPath+"/"+layerIds[i]+"/layer.tar", layerIds[i], "") - } + if logFile != "" { + file, err := logo.Open(logFile) if err != nil { - return fmt.Errorf("Could not analyze layer: %s", err) + fmt.Printf("Could not initialize logging file %v", err) + os.Exit(1) } - } - return nil -} -func saveImage(imageName string, tmpPath string) error { - docker := createDockerClient() - imageID := []string{imageName} - imageReader, err := docker.ImageSave(context.Background(), imageID) - if err != nil { - return err + fileRec := logo.NewReceiver(file, "") + Logger = logo.NewLogger(cliRec, fileRec) + } else { + Logger = logo.NewLogger(cliRec) } - - defer imageReader.Close() - return untar(imageReader, tmpPath) -} - -func createDockerClient() *client.Client { - docker, err := client.NewEnvClient() - if err != nil { - panic(err) - } - return docker -} - -func getImageLayerIds(path string) ([]string, error) { - mf, err := os.Open(path + "/manifest.json") - if err != nil { - return nil, err - } - defer mf.Close() - - // https://github.com/docker/docker/blob/master/image/tarexport/tarexport.go#L17 - type manifestItem struct { - Config string - RepoTags []string - Layers []string - } - - var manifest []manifestItem - if err = json.NewDecoder(mf).Decode(&manifest); err != nil { - return nil, err - } else if len(manifest) != 1 { - return nil, err - } - var layers []string - for _, layer := range manifest[0].Layers { - layers = append(layers, strings.TrimSuffix(layer, "/layer.tar")) - } - return layers, nil -} - -func untar(imageReader io.ReadCloser, target string) error { - tarReader := tar.NewReader(imageReader) - - for { - header, err := tarReader.Next() - if err == io.EOF { - break - } else if err != nil { - return err - } - - path := filepath.Join(target, header.Name) - info := header.FileInfo() - if info.IsDir() { - if err = os.MkdirAll(path, info.Mode()); err != nil { - return err - } - continue - } - - file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) - if err != nil { - return err - } - defer file.Close() - if _, err = io.Copy(file, tarReader); err != nil { - return err - } - } - return nil -} - -func listenHTTP(path string, ch chan error) { - fileServer := func(path string) http.Handler { - fc := func(w http.ResponseWriter, r *http.Request) { - http.FileServer(http.Dir(path)).ServeHTTP(w, r) - return - } - return http.HandlerFunc(fc) - } - - ch <- http.ListenAndServe(":"+strconv.Itoa(httpPort), fileServer(path)) -} - -func analyzeLayer(clairURL, path, layerName, parentLayerName string) error { - payload := v1.LayerEnvelope{ - Layer: &v1.Layer{ - Name: layerName, - Path: path, - ParentName: parentLayerName, - Format: "Docker", - }, - } - jsonPayload, err := json.Marshal(payload) - if err != nil { - return err - } - request, err := http.NewRequest("POST", clairURL+postLayerURI, bytes.NewBuffer(jsonPayload)) - if err != nil { - return err - } - request.Header.Set("Content-Type", "application/json") - client := &http.Client{} - response, err := client.Do(request) - if err != nil { - return err - } - defer response.Body.Close() - - if response.StatusCode != 201 { - body, _ := ioutil.ReadAll(response.Body) - return fmt.Errorf("Got response %d with message %s", response.StatusCode, string(body)) - } - - return nil -} -func getVulnerabilities(clairURL string, layerIds []string) ([]vulnerabilityInfo, error) { - var vulnerabilities = make([]vulnerabilityInfo, 0) - //Last layer gives you all the vulnerabilities of all layers - rawVulnerabilities, err := fetchLayerVulnerabilities(clairURL, layerIds[len(layerIds)-1]) - if err != nil { - return vulnerabilities, err - } - if len(rawVulnerabilities.Features) == 0 { - fmt.Printf("%s No features have been detected in the image. This usually means that the image isn't supported by Clair.\n", color.YellowString("NOTE:")) - return vulnerabilities, nil - } - - for _, feature := range rawVulnerabilities.Features { - if len(feature.Vulnerabilities) > 0 { - for _, vulnerability := range feature.Vulnerabilities { - vulnerability := vulnerabilityInfo{vulnerability.Name, vulnerability.NamespaceName, vulnerability.Severity} - vulnerabilities = append(vulnerabilities, vulnerability) - } - } - } - return vulnerabilities, nil -} - -func fetchLayerVulnerabilities(clairURL string, layerID string) (v1.Layer, error) { - response, err := http.Get(clairURL + fmt.Sprintf(getLayerFeaturesURI, layerID)) - if err != nil { - return v1.Layer{}, err - } - defer response.Body.Close() - - if response.StatusCode != 200 { - body, _ := ioutil.ReadAll(response.Body) - err := fmt.Errorf("Got response %d with message %s", response.StatusCode, string(body)) - return v1.Layer{}, err - } - - var apiResponse v1.LayerEnvelope - if err = json.NewDecoder(response.Body).Decode(&apiResponse); err != nil { - return v1.Layer{}, err - } else if apiResponse.Error != nil { - return v1.Layer{}, errors.New(apiResponse.Error.Message) - } - - return *apiResponse.Layer, nil } diff --git a/scanner.go b/scanner.go new file mode 100644 index 0000000..d7552a7 --- /dev/null +++ b/scanner.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "os" + "strings" + + "github.com/coreos/clair/api/v1" + "github.com/fatih/color" +) + +type vulnerabilityInfo struct { + vulnerability string + namespace string + severity string +} + +type acceptedVulnerability struct { + Cve string + Description string +} + +type vulnerabilitiesWhitelist struct { + GeneralWhitelist map[string]string + Images map[string]map[string]string +} + +func scan(imageName string, whitelist vulnerabilitiesWhitelist, clairURL string, scannerIP string) { + //Create a temporary folder where the docker image layers are going to be stored + tmpPath := createTmpPath(tmpPrefix) + defer os.RemoveAll(tmpPath) + + saveDockerImage(imageName, tmpPath) + layerIds := getImageLayerIds(tmpPath) + + //Start a server that can serve Docker image layers to Clair + server := httpFileServer(tmpPath) + defer server.Shutdown(nil) + + analyzeLayers(layerIds, clairURL, scannerIP) + vulnerabilities, err := getVulnerabilities(clairURL, layerIds) + if err != nil { + Logger.Fatalf("Analyzing failed: %s", err) + } + if err = vulnerabilitiesApproved(imageName, vulnerabilities, whitelist); err != nil { + Logger.Fatalf("Image contains unapproved vulnerabilities: %s", err) + } + Logger.Infof("Image [%s] not vulnerable", imageName) +} + +func vulnerabilitiesApproved(imageName string, vulnerabilities []vulnerabilityInfo, whitelist vulnerabilitiesWhitelist) error { + var unapproved []string + imageVulnerabilities := getImageVulnerabilities(imageName, whitelist.Images) + + for i := 0; i < len(vulnerabilities); i++ { + vulnerability := vulnerabilities[i].vulnerability + vulnerable := true + + if _, exists := whitelist.GeneralWhitelist[vulnerability]; exists { + vulnerable = false + } + if vulnerable && len(imageVulnerabilities) > 0 { + if _, exists := imageVulnerabilities[vulnerability]; exists { + vulnerable = false + } + } + if vulnerable { + unapproved = append(unapproved, vulnerability) + } + } + if len(unapproved) > 0 { + return fmt.Errorf("%s", unapproved) + } + return nil +} + +func getImageVulnerabilities(imageName string, whitelistImageVulnerabilities map[string]map[string]string) map[string]string { + var imageVulnerabilities map[string]string + imageWithoutVersion := strings.Split(imageName, ":") + if val, exists := whitelistImageVulnerabilities[imageWithoutVersion[0]]; exists { + imageVulnerabilities = val + } + return imageVulnerabilities +} + +func getVulnerabilities(clairURL string, layerIds []string) ([]vulnerabilityInfo, error) { + var vulnerabilities = make([]vulnerabilityInfo, 0) + //Last layer gives you all the vulnerabilities of all layers + rawVulnerabilities, err := fetchLayerVulnerabilities(clairURL, layerIds[len(layerIds)-1]) + if err != nil { + return vulnerabilities, err + } + if len(rawVulnerabilities.Features) == 0 { + fmt.Printf("%s No features have been detected in the image. This usually means that the image isn't supported by Clair.\n", color.YellowString("NOTE:")) + return vulnerabilities, nil + } + + for _, feature := range rawVulnerabilities.Features { + if len(feature.Vulnerabilities) > 0 { + for _, vulnerability := range feature.Vulnerabilities { + vulnerability := vulnerabilityInfo{vulnerability.Name, vulnerability.NamespaceName, vulnerability.Severity} + vulnerabilities = append(vulnerabilities, vulnerability) + } + } + } + return vulnerabilities, nil +} + +func fetchLayerVulnerabilities(clairURL string, layerID string) (v1.Layer, error) { + response, err := http.Get(clairURL + fmt.Sprintf(getLayerFeaturesURI, layerID)) + if err != nil { + return v1.Layer{}, err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + body, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf("Got response %d with message %s", response.StatusCode, string(body)) + return v1.Layer{}, err + } + + var apiResponse v1.LayerEnvelope + if err = json.NewDecoder(response.Body).Decode(&apiResponse); err != nil { + return v1.Layer{}, err + } else if apiResponse.Error != nil { + return v1.Layer{}, errors.New(apiResponse.Error.Message) + } + + return *apiResponse.Layer, nil +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..1d6f816 --- /dev/null +++ b/server.go @@ -0,0 +1,22 @@ +package main + +import ( + "net/http" + "time" +) + +const ( + httpPort = "9279" +) + +// TODO make a test +func httpFileServer(path string) *http.Server { + server := &http.Server{Addr: ":" + httpPort} + http.Handle("/", http.FileServer(http.Dir(path))) + go func() { + server.ListenAndServe() + }() + time.Sleep(100 * time.Millisecond) + Logger.Infof("Server listening on port %s", httpPort) + return server +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..5bae76f --- /dev/null +++ b/utils.go @@ -0,0 +1,82 @@ +package main + +import ( + "archive/tar" + "io" + "io/ioutil" + "os" + "os/signal" + "path/filepath" + "syscall" + + yaml "gopkg.in/yaml.v2" +) + +// listenForSignal listens for interaptions and exectus the desired code when it happens +func listenForSignal(fn func(os.Signal)) { + signalChannel := make(chan os.Signal) + + signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGQUIT) + + for { + execute := <-signalChannel + fn(execute) + } +} + +// createTmpPath creates an temporary folder with an prefix +func createTmpPath(tmpPrefix string) string { + tmpPath, err := ioutil.TempDir("", tmpPrefix) + if err != nil { + Logger.Fatalf("Could not create temporary folder: %s", err) + } + return tmpPath +} + +// untar uses a Reader that represents a tar to untar it on the fly to a target folder +// TODO make a test +func untar(imageReader io.ReadCloser, target string) error { + tarReader := tar.NewReader(imageReader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } else if err != nil { + return err + } + + path := filepath.Join(target, header.Name) + info := header.FileInfo() + if info.IsDir() { + if err = os.MkdirAll(path, info.Mode()); err != nil { + return err + } + continue + } + + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) + if err != nil { + return err + } + defer file.Close() + if _, err = io.Copy(file, tarReader); err != nil { + return err + } + } + return nil +} + +// TODO make a test +func parseWhitelistFile(whitelistFile string) vulnerabilitiesWhitelist { + whitelistTmp := vulnerabilitiesWhitelist{} + + whitelistBytes, err := ioutil.ReadFile(whitelistFile) + if err != nil { + Logger.Fatalf("Could not parse whitelist file, could not read file %v", err) + } + if err = yaml.Unmarshal(whitelistBytes, &whitelistTmp); err != nil { + Logger.Fatalf("Could not parse whitelist file, could not unmarshal %v", err) + } + return whitelistTmp +} diff --git a/utils_test.go b/utils_test.go new file mode 100644 index 0000000..46ba879 --- /dev/null +++ b/utils_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "os" + "syscall" + "testing" + "time" +) + +func TestSigint(t *testing.T) { + testListenOnSignal(t, syscall.SIGINT) +} + +func TestSigquit(t *testing.T) { + testListenOnSignal(t, syscall.SIGQUIT) +} + +func testListenOnSignal(t *testing.T, testSignal syscall.Signal) { + done := make(chan bool) + + go listenForSignal(func(signal os.Signal) { + if signal != testSignal { + t.Errorf("Expected signal %s, but got %s", testSignal, signal) + } + done <- true + }) + + time.AfterFunc(10*time.Millisecond, func() { + syscall.Kill(syscall.Getpid(), testSignal) + }) + <-done +}