d76c549dfb
I accidentally didn't push this commit to #367. Fixes #368.
426 lines
11 KiB
Go
426 lines
11 KiB
Go
// Copyright 2015 clair authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/coreos/clair/api/v1"
|
|
"github.com/coreos/clair/utils/types"
|
|
"github.com/fatih/color"
|
|
"github.com/kr/text"
|
|
)
|
|
|
|
const (
|
|
postLayerURI = "/v1/layers"
|
|
getLayerFeaturesURI = "/v1/layers/%s?vulnerabilities"
|
|
httpPort = 9279
|
|
)
|
|
|
|
var (
|
|
flagEndpoint = flag.String("endpoint", "http://127.0.0.1:6060", "Address to Clair API")
|
|
flagMyAddress = flag.String("my-address", "127.0.0.1", "Address from the point of view of Clair")
|
|
flagMinimumSeverity = flag.String("minimum-severity", "Negligible", "Minimum severity of vulnerabilities to show (Unknown, Negligible, Low, Medium, High, Critical, Defcon1)")
|
|
flagColorMode = flag.String("color", "auto", "Colorize the output (always, auto, never)")
|
|
)
|
|
|
|
type vulnerabilityInfo struct {
|
|
vulnerability v1.Vulnerability
|
|
feature v1.Feature
|
|
severity types.Priority
|
|
}
|
|
|
|
type By func(v1, v2 vulnerabilityInfo) bool
|
|
|
|
func (by By) Sort(vulnerabilities []vulnerabilityInfo) {
|
|
ps := &sorter{
|
|
vulnerabilities: vulnerabilities,
|
|
by: by,
|
|
}
|
|
sort.Sort(ps)
|
|
}
|
|
|
|
type sorter struct {
|
|
vulnerabilities []vulnerabilityInfo
|
|
by func(v1, v2 vulnerabilityInfo) bool
|
|
}
|
|
|
|
func (s *sorter) Len() int {
|
|
return len(s.vulnerabilities)
|
|
}
|
|
|
|
func (s *sorter) Swap(i, j int) {
|
|
s.vulnerabilities[i], s.vulnerabilities[j] = s.vulnerabilities[j], s.vulnerabilities[i]
|
|
}
|
|
|
|
func (s *sorter) Less(i, j int) bool {
|
|
return s.by(s.vulnerabilities[i], s.vulnerabilities[j])
|
|
}
|
|
|
|
func main() {
|
|
os.Exit(intMain())
|
|
}
|
|
|
|
func intMain() int {
|
|
// Parse command-line arguments.
|
|
flag.Usage = func() {
|
|
fmt.Fprintf(os.Stderr, "Usage: %s [options] image-id\n\nOptions:\n", os.Args[0])
|
|
flag.PrintDefaults()
|
|
}
|
|
flag.Parse()
|
|
|
|
if len(flag.Args()) != 1 {
|
|
flag.Usage()
|
|
return 1
|
|
}
|
|
imageName := flag.Args()[0]
|
|
|
|
minSeverity := types.Priority(*flagMinimumSeverity)
|
|
if !minSeverity.IsValid() {
|
|
flag.Usage()
|
|
return 1
|
|
}
|
|
|
|
if *flagColorMode == "never" {
|
|
color.NoColor = true
|
|
} else if *flagColorMode == "always" {
|
|
color.NoColor = false
|
|
}
|
|
|
|
// Create a temporary folder.
|
|
tmpPath, err := ioutil.TempDir("", "analyze-local-image-")
|
|
if err != nil {
|
|
log.Fatalf("Could not create temporary folder: %s", err)
|
|
}
|
|
defer os.RemoveAll(tmpPath)
|
|
|
|
// Intercept SIGINT / SIGKILl signals.
|
|
interrupt := make(chan os.Signal)
|
|
signal.Notify(interrupt, os.Interrupt, os.Kill)
|
|
|
|
// Analyze the image.
|
|
analyzeCh := make(chan error, 1)
|
|
go func() {
|
|
analyzeCh <- AnalyzeLocalImage(imageName, minSeverity, *flagEndpoint, *flagMyAddress, tmpPath)
|
|
}()
|
|
|
|
select {
|
|
case <-interrupt:
|
|
return 130
|
|
case err := <-analyzeCh:
|
|
if err != nil {
|
|
log.Print(err)
|
|
return 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func AnalyzeLocalImage(imageName string, minSeverity types.Priority, endpoint, myAddress, tmpPath string) error {
|
|
// Save image.
|
|
log.Printf("Saving %s to local disk (this may take some time)", imageName)
|
|
err := save(imageName, tmpPath)
|
|
if err != nil {
|
|
return fmt.Errorf("Could not save image: %s", err)
|
|
}
|
|
|
|
// Retrieve history.
|
|
log.Println("Retrieving image history")
|
|
layerIDs, err := historyFromCommand(imageName)
|
|
if err != nil || len(layerIDs) == 0 {
|
|
return fmt.Errorf("Could not get image's history: %s", err)
|
|
}
|
|
|
|
// Setup a simple HTTP server if Clair is not local.
|
|
if !strings.Contains(endpoint, "127.0.0.1") && !strings.Contains(endpoint, "localhost") {
|
|
allowedHost := strings.TrimPrefix(endpoint, "http://")
|
|
portIndex := strings.Index(allowedHost, ":")
|
|
if portIndex >= 0 {
|
|
allowedHost = allowedHost[:portIndex]
|
|
}
|
|
|
|
log.Printf("Setting up HTTP server (allowing: %s)\n", allowedHost)
|
|
|
|
ch := make(chan error)
|
|
go listenHTTP(tmpPath, allowedHost, ch)
|
|
select {
|
|
case err := <-ch:
|
|
return fmt.Errorf("An error occured when starting HTTP server: %s", err)
|
|
case <-time.After(100 * time.Millisecond):
|
|
break
|
|
}
|
|
|
|
tmpPath = "http://" + myAddress + ":" + strconv.Itoa(httpPort)
|
|
}
|
|
|
|
// Analyze layers.
|
|
log.Printf("Analyzing %d layers... \n", len(layerIDs))
|
|
for i := 0; i < len(layerIDs); i++ {
|
|
log.Printf("Analyzing %s\n", layerIDs[i])
|
|
|
|
if i > 0 {
|
|
err = analyzeLayer(endpoint, tmpPath+"/"+layerIDs[i]+"/layer.tar", layerIDs[i], layerIDs[i-1])
|
|
} else {
|
|
err = analyzeLayer(endpoint, tmpPath+"/"+layerIDs[i]+"/layer.tar", layerIDs[i], "")
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("Could not analyze layer: %s", err)
|
|
}
|
|
}
|
|
|
|
// Get vulnerabilities.
|
|
log.Println("Retrieving image's vulnerabilities")
|
|
layer, err := getLayer(endpoint, layerIDs[len(layerIDs)-1])
|
|
if err != nil {
|
|
return fmt.Errorf("Could not get layer information: %s", err)
|
|
}
|
|
|
|
// Print report.
|
|
fmt.Printf("Clair report for image %s (%s)\n", imageName, time.Now().UTC())
|
|
|
|
if len(layer.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 nil
|
|
}
|
|
|
|
isSafe := true
|
|
hasVisibleVulnerabilities := false
|
|
|
|
var vulnerabilities = make([]vulnerabilityInfo, 0)
|
|
for _, feature := range layer.Features {
|
|
if len(feature.Vulnerabilities) > 0 {
|
|
for _, vulnerability := range feature.Vulnerabilities {
|
|
severity := types.Priority(vulnerability.Severity)
|
|
isSafe = false
|
|
|
|
if minSeverity.Compare(severity) > 0 {
|
|
continue
|
|
}
|
|
|
|
hasVisibleVulnerabilities = true
|
|
vulnerabilities = append(vulnerabilities, vulnerabilityInfo{vulnerability, feature, severity})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort vulnerabilitiy by severity.
|
|
priority := func(v1, v2 vulnerabilityInfo) bool {
|
|
return v1.severity.Compare(v2.severity) >= 0
|
|
}
|
|
|
|
By(priority).Sort(vulnerabilities)
|
|
|
|
for _, vulnerabilityInfo := range vulnerabilities {
|
|
vulnerability := vulnerabilityInfo.vulnerability
|
|
feature := vulnerabilityInfo.feature
|
|
severity := vulnerabilityInfo.severity
|
|
|
|
fmt.Printf("%s (%s)\n", vulnerability.Name, coloredSeverity(severity))
|
|
|
|
if vulnerability.Description != "" {
|
|
fmt.Printf("%s\n\n", text.Indent(text.Wrap(vulnerability.Description, 80), "\t"))
|
|
}
|
|
|
|
fmt.Printf("\tPackage: %s @ %s\n", feature.Name, feature.Version)
|
|
|
|
if vulnerability.FixedBy != "" {
|
|
fmt.Printf("\tFixed version: %s\n", vulnerability.FixedBy)
|
|
}
|
|
|
|
if vulnerability.Link != "" {
|
|
fmt.Printf("\tLink: %s\n", vulnerability.Link)
|
|
}
|
|
|
|
fmt.Printf("\tLayer: %s\n", feature.AddedBy)
|
|
fmt.Println("")
|
|
}
|
|
|
|
if isSafe {
|
|
fmt.Printf("%s No vulnerabilities were detected in your image\n", color.GreenString("Success!"))
|
|
} else if !hasVisibleVulnerabilities {
|
|
fmt.Printf("%s No vulnerabilities matching the minimum severity level were detected in your image\n", color.YellowString("NOTE:"))
|
|
} else {
|
|
return fmt.Errorf("A total of %d vulnerabilities have been detected in your image", len(vulnerabilities))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func save(imageName, path string) error {
|
|
var stderr bytes.Buffer
|
|
save := exec.Command("docker", "save", imageName)
|
|
save.Stderr = &stderr
|
|
extract := exec.Command("tar", "xf", "-", "-C"+path)
|
|
extract.Stderr = &stderr
|
|
pipe, err := extract.StdinPipe()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
save.Stdout = pipe
|
|
|
|
err = extract.Start()
|
|
if err != nil {
|
|
return errors.New(stderr.String())
|
|
}
|
|
err = save.Run()
|
|
if err != nil {
|
|
return errors.New(stderr.String())
|
|
}
|
|
err = pipe.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = extract.Wait()
|
|
if err != nil {
|
|
return errors.New(stderr.String())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func historyFromCommand(imageName string) ([]string, error) {
|
|
var stderr bytes.Buffer
|
|
cmd := exec.Command("docker", "history", "-q", "--no-trunc", imageName)
|
|
cmd.Stderr = &stderr
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return []string{}, err
|
|
}
|
|
|
|
err = cmd.Start()
|
|
if err != nil {
|
|
return []string{}, errors.New(stderr.String())
|
|
}
|
|
|
|
var layers []string
|
|
scanner := bufio.NewScanner(stdout)
|
|
for scanner.Scan() {
|
|
layers = append(layers, scanner.Text())
|
|
}
|
|
|
|
for i := len(layers)/2 - 1; i >= 0; i-- {
|
|
opp := len(layers) - 1 - i
|
|
layers[i], layers[opp] = layers[opp], layers[i]
|
|
}
|
|
|
|
return layers, nil
|
|
}
|
|
|
|
func listenHTTP(path, allowedHost string, ch chan error) {
|
|
restrictedFileServer := func(path, allowedHost string) http.Handler {
|
|
fc := func(w http.ResponseWriter, r *http.Request) {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err == nil && strings.EqualFold(host, allowedHost) {
|
|
http.FileServer(http.Dir(path)).ServeHTTP(w, r)
|
|
return
|
|
}
|
|
w.WriteHeader(403)
|
|
}
|
|
return http.HandlerFunc(fc)
|
|
}
|
|
|
|
ch <- http.ListenAndServe(":"+strconv.Itoa(httpPort), restrictedFileServer(path, allowedHost))
|
|
}
|
|
|
|
func analyzeLayer(endpoint, 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", endpoint+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 getLayer(endpoint, layerID string) (v1.Layer, error) {
|
|
response, err := http.Get(endpoint + 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
|
|
}
|
|
|
|
func coloredSeverity(severity types.Priority) string {
|
|
red := color.New(color.FgRed).SprintFunc()
|
|
yellow := color.New(color.FgYellow).SprintFunc()
|
|
white := color.New(color.FgWhite).SprintFunc()
|
|
|
|
switch severity {
|
|
case types.High, types.Critical:
|
|
return red(severity)
|
|
case types.Medium:
|
|
return yellow(severity)
|
|
default:
|
|
return white(severity)
|
|
}
|
|
}
|