diff --git a/Makefile b/Makefile index 87b18e7..a9d82e8 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ $(BINARY): $(SOURCES) setup: curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s v1.21.0 + .PHONY: setup lint: diff --git a/README.md b/README.md index 265a82e..0969e44 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Configuration of the adapter is done via environment variables at startup. | `SCANNER_API_SERVER_ADDR` | `:8080` | Binding address for the API HTTP server. | | `SCANNER_API_SERVER_TLS_CERTIFICATE` | | The absolute path to the x509 certificate file. | | `SCANNER_API_SERVER_TLS_KEY` | | The absolute path to the x509 private key file. | +| `SCANNER_TLS_INSECURE_SKIP_VERIFY` | `false` | Controls whether an HTTP client verifies the server's certificate chain and host name. | +| `SCANNER_TLS_CLIENTCAS` | | An array of absolute paths to x509 CA files that will be added to host's root CA set. | | `SCANNER_API_SERVER_READ_TIMEOUT` | `15s` | The maximum duration for reading the entire request, including the body. | | `SCANNER_API_SERVER_WRITE_TIMEOUT` | `15s` | The maximum duration before timing out writes of the response. | | `SCANNER_CLAIR_URL` | `http://harbor-harbor-clair:6060` | Clair URL | diff --git a/cmd/harbor-scanner-clair/main.go b/cmd/harbor-scanner-clair/main.go index e265967..8ffe53e 100644 --- a/cmd/harbor-scanner-clair/main.go +++ b/cmd/harbor-scanner-clair/main.go @@ -39,8 +39,13 @@ func main() { log.Fatalf("Error: %v", err) } - registryClientFactory := registry.NewClientFactory() - clairClient := clair.NewClient(clairConfig.URL) + tlsConfig, err := etc.GetTLSConfig() + if err != nil { + log.Fatalf("Error: %v", err) + } + + registryClientFactory := registry.NewClientFactory(tlsConfig) + clairClient := clair.NewClient(tlsConfig, clairConfig) scanner := clair.NewScanner(registryClientFactory, clairClient, model.NewTransformer()) apiConfig, err := etc.GetAPIConfig() diff --git a/kube/harbor-scanner-clair.yaml b/kube/harbor-scanner-clair.yaml index 21244a3..c7f1a1a 100644 --- a/kube/harbor-scanner-clair.yaml +++ b/kube/harbor-scanner-clair.yaml @@ -45,6 +45,10 @@ spec: value: "15s" - name: "SCANNER_CLAIR_URL" value: "http://harbor-harbor-clair:6060" + - name: "SCANNER_TLS_INSECURE_SKIP_VERIFY" + value: "false" + - name: "SCANNER_TLS_CLIENTCAS" + value: "" ports: - name: api-server-port containerPort: 8443 diff --git a/pkg/etc/config.go b/pkg/etc/config.go index 1d2a102..d6fe679 100644 --- a/pkg/etc/config.go +++ b/pkg/etc/config.go @@ -1,10 +1,15 @@ package etc import ( + "crypto/x509" + "fmt" "github.com/caarlos0/env/v6" "github.com/goharbor/harbor-scanner-clair/pkg/model/harbor" "github.com/sirupsen/logrus" + log "github.com/sirupsen/logrus" + "io/ioutil" "os" + "strings" "time" ) @@ -20,6 +25,13 @@ func (c *APIConfig) IsTLSEnabled() bool { return c.TLSCertificate != "" && c.TLSKey != "" } +type TLSConfig struct { + ClientCAs []string `env:"SCANNER_TLS_CLIENTCAS"` + InsecureSkipVerify bool `env:"SCANNER_TLS_INSECURE_SKIP_VERIFY" envDefault:"false"` + + RootCAs *x509.CertPool +} + type ClairConfig struct { URL string `env:"SCANNER_CLAIR_URL" envDefault:"http://harbor-harbor-clair:6060"` } @@ -40,6 +52,36 @@ func GetAPIConfig() (cfg APIConfig, err error) { return } +func GetTLSConfig() (cfg TLSConfig, err error) { + err = env.Parse(&cfg) + if err != nil { + return + } + + cfg.RootCAs, err = x509.SystemCertPool() + if err != nil { + log.WithError(err).Warn("Error while loading system root CAs") + } + if cfg.RootCAs == nil { + log.Debug("Creating empty root CAs pool") + cfg.RootCAs = x509.NewCertPool() + } + + for _, certFile := range cfg.ClientCAs { + certs, err := ioutil.ReadFile(strings.TrimSpace(certFile)) + if err != nil { + return cfg, fmt.Errorf("failed to append %q to root CAs pool: %v", certFile, err) + } + + if ok := cfg.RootCAs.AppendCertsFromPEM(certs); !ok { + return cfg, fmt.Errorf("failed to append %q to root CAs pool: %v", certFile, err) + } + log.WithField("cert", certFile).Debug("Client CA appended to root CAs pool") + } + + return +} + func GetClairConfig() (cfg ClairConfig, err error) { err = env.Parse(&cfg) return diff --git a/pkg/etc/config_test.go b/pkg/etc/config_test.go index d58c3c2..d7ffa62 100644 --- a/pkg/etc/config_test.go +++ b/pkg/etc/config_test.go @@ -133,6 +133,39 @@ func TestAPIConfig_IsTLSEnabled(t *testing.T) { } } +func TestGetTLSConfig(t *testing.T) { + testCases := []struct { + name string + envs Envs + expectedConfig TLSConfig + }{ + { + name: "Should return default config", + expectedConfig: TLSConfig{ + InsecureSkipVerify: false, + }, + }, + { + name: "Should overwrite default config with envs", + envs: Envs{ + "SCANNER_TLS_INSECURE_SKIP_VERIFY": "true", + "SCANNER_TLS_CLIENTCAS": "test/data/ca.crt", + }, + expectedConfig: TLSConfig{ + InsecureSkipVerify: true}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + setenvs(t, tc.envs) + // TODO Assert on the actual cfg and RootCAs + _, err := GetTLSConfig() + require.NoError(t, err) + }) + } +} + func TestGetClairConfig(t *testing.T) { testCases := []struct { name string diff --git a/pkg/etc/test/data/ca.crt b/pkg/etc/test/data/ca.crt new file mode 100644 index 0000000..4cfb448 --- /dev/null +++ b/pkg/etc/test/data/ca.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC9DCCAdygAwIBAgIQIkbGQq4DnlKlorAYV5SjAzANBgkqhkiG9w0BAQsFADAU +MRIwEAYDVQQDEwloYXJib3ItY2EwHhcNMTkwOTMwMTUyMTM1WhcNMjAwOTI5MTUy +MTM1WjAUMRIwEAYDVQQDEwloYXJib3ItY2EwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDIaVSzd7YD7LR0ymnkhlvAOCMfDETc6Kd8NIJcgZVEZoeb7RqT +AEE6aSvw0IgZcU08mrmA2R2YplZj24FOy/Xj1qiMcRKY/YITkY4avNmJy2Wqpst0 +QVu62VSACWpYg/455ZjcqEWs9Xu9c9+2dFW/zStxAK5gnQiUpUbTyjmNHVEYLRwu +U1vRPhbEJiIoorMeoAdplNra7I0tq5HWkyMWx1VDQHLEcVhFpJkTMtFqPzCkuSN4 +ji1QapbO9jBCeD0n5xlhZ9GmZI2epzTXMacxL09VVD+od29SABdpmGIwMo9F559A +hMKOMcu1j7TJxJn2lROxIfm8afwo98YYsZBjAgMBAAGjQjBAMA4GA1UdDwEB/wQE +AwICpDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDwYDVR0TAQH/BAUw +AwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAxp+wqzb0iqaPJ3b0PcRob9SA+SjSrvdo +ZYxNZdKUxOIAGWrK4oidW2Nxy7YsqdH+O4pYGc1Mtmi0v+b8RJWNyiVTnD8ntSEO +AefkHEv9Z3E6iJCSV4/ileZFZKRaz/XgDAOtK3v54m0lJqXVdyrmPazHcRptNgt4 +IDBvb6SiNSTOxBFt25tMMWMmOqbLNXIuJJYdOJsYg4mGsWjbflNMA9axM+IVWP+f +acWbqB4BD7Bj+zJY/gSX0AO36B4fQ713+nWQDy+ZQ67y3RVzcJeuohPo+hxgbHCA +RFTvMRj1bNPoUlCjU8iUGZiBJMuhaV5NSrSKYr1VcUiHas+/LQRznw== +-----END CERTIFICATE----- diff --git a/pkg/mock/registry_client.go b/pkg/mock/registry_client.go index dd74b9f..2a320fe 100644 --- a/pkg/mock/registry_client.go +++ b/pkg/mock/registry_client.go @@ -2,6 +2,7 @@ package mock import ( "github.com/docker/distribution" + "github.com/goharbor/harbor-scanner-clair/pkg/model/harbor" "github.com/goharbor/harbor-scanner-clair/pkg/registry" "github.com/stretchr/testify/mock" ) @@ -22,12 +23,12 @@ func NewRegistryClient() *RegistryClient { return &RegistryClient{} } -func (f *RegistryClientFactory) Get(registryURL, authorization string) (registry.Client, error) { - args := f.Called(registryURL, authorization) - return args.Get(0).(registry.Client), args.Error(1) +func (f *RegistryClientFactory) Get() registry.Client { + args := f.Called() + return args.Get(0).(registry.Client) } -func (c *RegistryClient) Manifest(repository, reference string) (distribution.Manifest, string, error) { - args := c.Called(repository, reference) - return args.Get(0).(distribution.Manifest), args.String(1), args.Error(2) +func (c *RegistryClient) GetManifest(req harbor.ScanRequest) (distribution.Manifest, error) { + args := c.Called(req) + return args.Get(0).(distribution.Manifest), args.Error(1) } diff --git a/pkg/registry/client.go b/pkg/registry/client.go index c7cc839..1e54c22 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -3,81 +3,90 @@ package registry import ( "crypto/tls" "fmt" + "github.com/docker/distribution" + "github.com/docker/distribution/manifest/schema2" + "github.com/goharbor/harbor-scanner-clair/pkg/etc" + "github.com/goharbor/harbor-scanner-clair/pkg/model/harbor" "io/ioutil" "net/http" - "strings" + "sync" +) - "github.com/docker/distribution" - "github.com/docker/distribution/manifest/schema2" - log "github.com/sirupsen/logrus" +var ( + once sync.Once + singleton *client ) type ClientFactory interface { - Get(registryURL, authorization string) (Client, error) + Get() Client } type Client interface { - Manifest(repository, reference string) (distribution.Manifest, string, error) + GetManifest(req harbor.ScanRequest) (distribution.Manifest, error) } type client struct { - registryURL string - client *http.Client - authorization string + client *http.Client } type clientFactory struct { + tlsConfig etc.TLSConfig } -func (cf *clientFactory) Get(registryURL, authorization string) (Client, error) { - return &client{ - registryURL: registryURL, - client: &http.Client{Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - // FIXME Allow configuring custom or self-signed certs rather than skipping verification. - InsecureSkipVerify: true, - }, - }}, - authorization: authorization, - }, nil +func NewClientFactory(TLSConfig etc.TLSConfig) ClientFactory { + return &clientFactory{ + tlsConfig: TLSConfig, + } } -func NewClientFactory() ClientFactory { - return &clientFactory{} +func (cf *clientFactory) Get() Client { + once.Do(func() { + singleton = &client{ + client: &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: cf.tlsConfig.RootCAs, + InsecureSkipVerify: cf.tlsConfig.InsecureSkipVerify, + }, + }}, + } + }) + + return singleton } -func (c *client) Manifest(repository, reference string) (distribution.Manifest, string, error) { - requestURL := fmt.Sprintf("%s/v2/%s/manifests/%s", c.registryURL, repository, reference) - log.Debugf("Fetch manifest URL: %s", requestURL) - req, err := http.NewRequest("GET", requestURL, nil) +func (c *client) GetManifest(sr harbor.ScanRequest) (distribution.Manifest, error) { + req, err := http.NewRequest(http.MethodGet, c.manifestURL(sr), nil) if err != nil { - return nil, "", err + return nil, err } req.Header.Add("Accept", schema2.MediaTypeManifest) - req.Header.Add("Authorization", c.authorization) + req.Header.Add("Authorization", sr.Registry.Authorization) resp, err := c.client.Do(req) if err != nil { - return nil, "", err + return nil, err } - log.Debugf("Response status: %s", resp.Status) - log.Debugf("Response headers: %v", resp.Header) defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { - return nil, "", err + return nil, err } if resp.StatusCode != http.StatusOK { - return nil, "", fmt.Errorf("HTTP not ok: %v", resp.Status) + return nil, fmt.Errorf("fetching manifest with status %q: %s", resp.Status, string(b)) } manifest, _, err := distribution.UnmarshalManifest(schema2.MediaTypeManifest, b) if err != nil { - return nil, "", fmt.Errorf("unmarshaling manifest: %v", err) + return nil, fmt.Errorf("unmarshaling manifest: %v", err) } + return manifest, nil +} - return manifest, strings.TrimPrefix(c.authorization, "Bearer "), nil +func (c *client) manifestURL(sr harbor.ScanRequest) string { + return fmt.Sprintf("%s/v2/%s/manifests/%s", sr.Registry.URL, + sr.Artifact.Repository, + sr.Artifact.Digest) } diff --git a/pkg/scanner/clair/client.go b/pkg/scanner/clair/client.go index fdf5b08..1838ec4 100644 --- a/pkg/scanner/clair/client.go +++ b/pkg/scanner/clair/client.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "github.com/goharbor/harbor-scanner-clair/pkg/etc" "github.com/goharbor/harbor-scanner-clair/pkg/model/clair" "io/ioutil" "net/http" @@ -24,13 +25,13 @@ type client struct { } // NewClient constructs a new client for Clair REST API pointing to the specified endpoint URL. -func NewClient(endpointURL string) Client { +func NewClient(tlsConfig etc.TLSConfig, cfg etc.ClairConfig) Client { return &client{ - endpointURL: strings.TrimSuffix(endpointURL, "/"), + endpointURL: strings.TrimSuffix(cfg.URL, "/"), client: &http.Client{Transport: &http.Transport{ TLSClientConfig: &tls.Config{ - // FIXME Allow configuring custom or self-signed certs rather that skipping verification. - InsecureSkipVerify: true, + InsecureSkipVerify: tlsConfig.InsecureSkipVerify, + RootCAs: tlsConfig.RootCAs, }, }}, } diff --git a/pkg/scanner/clair/scanner.go b/pkg/scanner/clair/scanner.go index 502667d..d7cb099 100644 --- a/pkg/scanner/clair/scanner.go +++ b/pkg/scanner/clair/scanner.go @@ -17,21 +17,21 @@ type Scanner interface { GetReport(scanRequestID string) (harbor.ScanReport, error) } -type imageScanner struct { +type scanner struct { registryClientFactory registry.ClientFactory clairClient Client transformer model.Transformer } func NewScanner(registryClientFactory registry.ClientFactory, clairClient Client, transformer model.Transformer) Scanner { - return &imageScanner{ + return &scanner{ registryClientFactory: registryClientFactory, clairClient: clairClient, transformer: transformer, } } -func (s *imageScanner) Scan(req harbor.ScanRequest) (harbor.ScanResponse, error) { +func (s *scanner) Scan(req harbor.ScanRequest) (harbor.ScanResponse, error) { layers, err := s.prepareLayers(req) if err != nil { return harbor.ScanResponse{}, fmt.Errorf("preparing layers: %v", err) @@ -55,23 +55,14 @@ func (s *imageScanner) Scan(req harbor.ScanRequest) (harbor.ScanResponse, error) return harbor.ScanResponse{ID: layerName}, nil } -func (s *imageScanner) prepareLayers(req harbor.ScanRequest) ([]clair.Layer, error) { - layers := make([]clair.Layer, 0) - - registryClient, err := s.registryClientFactory.Get(req.Registry.URL, req.Registry.Authorization) - if err != nil { - return nil, fmt.Errorf("constructing registry client: %v", err) - } - - manifest, bearerToken, err := registryClient.Manifest(req.Artifact.Repository, req.Artifact.Digest) +func (s *scanner) prepareLayers(req harbor.ScanRequest) ([]clair.Layer, error) { + manifest, err := s.registryClientFactory.Get().GetManifest(req) if err != nil { return nil, err } - tokenHeader := map[string]string{ - "Connection": "close", - "Authorization": fmt.Sprintf("Bearer %s", bearerToken), - } + layers := make([]clair.Layer, 0) + // form the chain by using the digests of all parent layers in the image, such that if another image is built on top of this image the layer name can be re-used. shaChain := "" for _, d := range manifest.References() { @@ -80,10 +71,13 @@ func (s *imageScanner) prepareLayers(req harbor.ScanRequest) ([]clair.Layer, err } shaChain += string(d.Digest) + "-" l := clair.Layer{ - Name: fmt.Sprintf("%x", sha256.Sum256([]byte(shaChain))), - Headers: tokenHeader, - Format: "Docker", - Path: s.buildBlobURL(req.Registry.URL, req.Artifact.Repository, string(d.Digest)), + Name: fmt.Sprintf("%x", sha256.Sum256([]byte(shaChain))), + Headers: map[string]string{ + "Connection": "close", + "Authorization": req.Registry.Authorization, + }, + Format: "Docker", + Path: s.buildBlobURL(req.Registry.URL, req.Artifact.Repository, string(d.Digest)), } if len(layers) > 0 { l.ParentName = layers[len(layers)-1].Name @@ -93,11 +87,11 @@ func (s *imageScanner) prepareLayers(req harbor.ScanRequest) ([]clair.Layer, err return layers, nil } -func (s *imageScanner) buildBlobURL(endpoint, repository, digest string) string { +func (s *scanner) buildBlobURL(endpoint, repository, digest string) string { return fmt.Sprintf("%s/v2/%s/blobs/%s", endpoint, repository, digest) } -func (s *imageScanner) GetReport(layerName string) (harbor.ScanReport, error) { +func (s *scanner) GetReport(layerName string) (harbor.ScanReport, error) { res, err := s.clairClient.GetLayer(layerName) if err != nil { return harbor.ScanReport{}, fmt.Errorf("getting layer %s: %v", layerName, err) diff --git a/pkg/scanner/clair/scanner_test.go b/pkg/scanner/clair/scanner_test.go index 0bc2868..a37dd49 100644 --- a/pkg/scanner/clair/scanner_test.go +++ b/pkg/scanner/clair/scanner_test.go @@ -2,14 +2,92 @@ package clair import ( "errors" + "github.com/docker/distribution" + "github.com/docker/distribution/manifest" + "github.com/docker/distribution/manifest/schema2" "github.com/goharbor/harbor-scanner-clair/pkg/mock" "github.com/goharbor/harbor-scanner-clair/pkg/model/clair" "github.com/goharbor/harbor-scanner-clair/pkg/model/harbor" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "testing" "time" ) +func TestImageScanner_Scan(t *testing.T) { + req := harbor.ScanRequest{ + Registry: harbor.Registry{ + URL: "https://core.harbor.domain", + Authorization: "Bearer ", + }, + Artifact: harbor.Artifact{ + Repository: "library/erlang", + Digest: "sha256:d66da0a3b3b856a737168f28549be04512d9c9af2ff5120686d75d3a55e4af57", + }, + } + + registryClientFactory := mock.NewRegistryClientFactory() + registryClient := mock.NewRegistryClient() + clairClient := mock.NewClairClient() + transformer := mock.NewTransformer() + + registryClientFactory.On("Get").Return(registryClient) + registryClient.On("GetManifest", req).Return(schema2.DeserializedManifest{ + Manifest: schema2.Manifest{ + Versioned: manifest.Versioned{ + SchemaVersion: 2, + MediaType: schema2.MediaTypeManifest, + }, + Config: distribution.Descriptor{ + MediaType: schema2.MediaTypeImageConfig, + Digest: "sha256:e82e9f112bf7d37e4dd037a2707030ba647af94329036f6da101d36c53c974cf", + }, + Layers: []distribution.Descriptor{ + { + MediaType: schema2.MediaTypeLayer, + Digest: "sha256:9a0b0ce99936ce4861d44ce1f193e881e5b40b5bf1847627061205b092fa7f1d", + }, + { + MediaType: schema2.MediaTypeLayer, + Digest: "sha256:db3b6004c61a0e86fbf910b9b4a6611ae79e238a336011a1b5f9b177d85cbf9d", + }, + }, + }, + }, nil) + clairClient.On("ScanLayer", clair.Layer{ + Name: "31d8546ce949163443fad8147ad5831fc5ecc6efc889a06d2a3b93af56dd4bcd", + Path: "https://core.harbor.domain/v2/library/erlang/blobs/sha256:9a0b0ce99936ce4861d44ce1f193e881e5b40b5bf1847627061205b092fa7f1d", + Headers: map[string]string{ + "Authorization": "Bearer ", + "Connection": "close", + }, + Format: "Docker", + }).Return(nil) + clairClient.On("ScanLayer", clair.Layer{ + Name: "d10095311d9a7dde7d350fdab383ef1e525ec793c33ca941ac593675762bc5d8", + ParentName: "31d8546ce949163443fad8147ad5831fc5ecc6efc889a06d2a3b93af56dd4bcd", + Path: "https://core.harbor.domain/v2/library/erlang/blobs/sha256:db3b6004c61a0e86fbf910b9b4a6611ae79e238a336011a1b5f9b177d85cbf9d", + Headers: map[string]string{ + "Authorization": "Bearer ", + "Connection": "close", + }, + Format: "Docker", + }).Return(nil) + + scanner := NewScanner(registryClientFactory, clairClient, transformer) + + resp, err := scanner.Scan(req) + require.NoError(t, err) + assert.Equal(t, harbor.ScanResponse{ + ID: "d10095311d9a7dde7d350fdab383ef1e525ec793c33ca941ac593675762bc5d8", + }, resp) + + registryClientFactory.AssertExpectations(t) + registryClient.AssertExpectations(t) + clairClient.AssertExpectations(t) + transformer.AssertExpectations(t) +} + func TestImageScanner_GetReport(t *testing.T) { layerEnvelope := clair.LayerEnvelope{ Layer: &clair.Layer{