Repository: aquasecurity/starboard
Pull request #139 (2020-08-31 07:19:04)

fix: Find vulnerabilities in workloads running images from private DockerHub repositories
Signed-off-by: Daniel Pacak <pacak.daniel@gmail.com>

codecov[bot]: # [Codecov](https://codecov.io/gh/aquasecurity/starboard/pull/139?src=pr&el=h1) Report
> Merging [#139](https://codecov.io/gh/aquasecurity/starboard/pull/139?src=pr&el=desc) into [master](https://codecov.io/gh/aquasecurity/starboard/commit/b2ed495d73705755ae382676011869bb774afe58?el=desc) will **decrease** coverage by `1.89%`.
> The diff coverage is `28.40%`.

[![Impacted file tree graph](https://codecov.io/gh/aquasecurity/starboard/pull/139/graphs/tree.svg?width=650&height=150&src=pr&token=qqTGKAJ0Wb)](https://codecov.io/gh/aquasecurity/starboard/pull/139?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #139      +/-   ##
==========================================
- Coverage   43.75%   41.85%   -1.90%     
==========================================
  Files          31       33       +2     
  Lines        1520     1615      +95     
==========================================
+ Hits          665      676      +11     
- Misses        783      865      +82     
- Partials       72       74       +2     
```


| [Impacted Files](https://codecov.io/gh/aquasecurity/starboard/pull/139?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pkg/kube/secret/manager.go](https://codecov.io/gh/aquasecurity/starboard/pull/139/diff?src=pr&el=tree#diff-cGtnL2t1YmUvc2VjcmV0L21hbmFnZXIuZ28=) | `0.00% <0.00%> (ø)` | |
| [pkg/kube/secrets/secrets.go](https://codecov.io/gh/aquasecurity/starboard/pull/139/diff?src=pr&el=tree#diff-cGtnL2t1YmUvc2VjcmV0cy9zZWNyZXRzLmdv) | `0.00% <0.00%> (ø)` | |
| [pkg/docker/config.go](https://codecov.io/gh/aquasecurity/starboard/pull/139/diff?src=pr&el=tree#diff-cGtnL2RvY2tlci9jb25maWcuZ28=) | `51.78% <50.00%> (-38.22%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/aquasecurity/starboard/pull/139?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/aquasecurity/starboard/pull/139?src=pr&el=footer). Last update [b2ed495...5711a07](https://codecov.io/gh/aquasecurity/starboard/pull/139?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

danielpacak: > Let me make sure what the PR sorted out. It used to expect a registry URL without "http/https" schema, so if `~/.docker/config.json` includes it, it didn't work. This PR adds `GetHostFromServer` and fixes the issue, right? Or, does this PR also fix something else?

Exactly that was the problem. Plus I smuggled some refactoring with better names for structs and variables.

Patch:
diff --git a/pkg/docker/config.go b/pkg/docker/config.go
index 4d63e63..3386603 100644
--- a/pkg/docker/config.go
+++ b/pkg/docker/config.go
@@ -3,47 +3,119 @@ package docker
 import (
 	"encoding/base64"
 	"encoding/json"
+	"fmt"
+	"net/url"
 	"strings"
+
+	"github.com/google/go-containerregistry/pkg/name"
 )
 
-// ServerCredentials represent credentials used to login to a Docker server.
-type ServerCredentials struct {
-	Auth     string `json:"auth"`
-	Username string `json:"username"`
-	Password string `json:"password"`
+type BasicAuth string
+
+func NewBasicAuth(username, password string) BasicAuth {
+	var v = new(BasicAuth)
+	v.Encode(username, password)
+	return *v
 }
 
-// Credentials represents Docker credentials which are typically stored in `~/.docker/config.json`.
-type Credentials struct {
-	Auths map[string]ServerCredentials `json:"auths"`
+func (v *BasicAuth) Encode(username, password string) {
+	*v = BasicAuth(base64.StdEncoding.EncodeToString(
+		[]byte(fmt.Sprintf("%s:%s", username, password))))
 }
 
-func ReadCredentialsFromBytes(contents []byte) (cfg map[string]ServerCredentials, err error) {
-	var credentials Credentials
-	if err = json.Unmarshal(contents, &credentials); err != nil {
-		return nil, err
+func (v *BasicAuth) Decode() (username, password string, err error) {
+	bytes, err := base64.StdEncoding.DecodeString(string(*v))
+	if err != nil {
+		return
 	}
-	return encodeAuth(credentials.Auths)
+	split := strings.Split(string(bytes), ":")
+
+	username = split[0]
+	password = split[1]
+	return
+}
+
+func (v BasicAuth) String() string {
+	return "[REDACTED]"
+}
+
+// Auth represent credentials used to login to a Docker registry.
+type Auth struct {
+	Auth     BasicAuth `json:"auth,omitempty"`
+	Username string    `json:"username,omitempty"`
+	Password string    `json:"password,omitempty"`
+}
+
+func (v Auth) String() string {
+	return "[REDACTED]"
+}
+
+// Config represents Docker configuration which is typically saved as `~/.docker/config.json`.
+type Config struct {
+	Auths map[string]Auth `json:"auths"`
+}
+
+func (c *Config) Read(contents []byte) (err error) {
+	if err := json.Unmarshal(contents, c); err != nil {
+		return err
+	}
+	c.Auths, err = decodeAuths(c.Auths)
+	return
 }
 
-func encodeAuth(config map[string]ServerCredentials) (encodedConfig map[string]ServerCredentials, err error) {
-	encodedConfig = make(map[string]ServerCredentials)
-	for server, entry := range config {
-		if (ServerCredentials{}) == entry {
+func decodeAuths(auths map[string]Auth) (map[string]Auth, error) {
+	decodedAuths := make(map[string]Auth)
+	for server, entry := range auths {
+		if (Auth{}) == entry {
 			continue
 		}
-		var decodedAuth []byte
-		decodedAuth, err = base64.StdEncoding.DecodeString(entry.Auth)
+
+		username, password, err := entry.Auth.Decode()
 		if err != nil {
-			return
+			return nil, err
 		}
-		splitDecodedAuth := strings.Split(string(decodedAuth), ":")
-		encodedConfig[server] = ServerCredentials{
+
+		decodedAuths[server] = Auth{
 			Auth:     entry.Auth,
-			Username: splitDecodedAuth[0],
-			Password: splitDecodedAuth[1],
+			Username: username,
+			Password: password,
 		}
 
 	}
-	return
+	return decodedAuths, nil
+}
+
+func (c Config) Write() ([]byte, error) {
+	bytes, err := json.Marshal(&c)
+	if err != nil {
+		return nil, err
+	}
+	return bytes, nil
+}
+
+// GetServerFromImageRef returns registry server from the specified imageRef.
+func GetServerFromImageRef(imageRef string) (string, error) {
+	ref, err := name.ParseReference(imageRef)
+	if err != nil {
+		return "", err
+	}
+	return ref.Context().RegistryStr(), nil
+}
+
+// GetHostFromServer returns the host for the specified registry server.
+//
+// In ~/.docker/config.json auth keys can be specified as URLs or host names.
+// For the sake of comparison we need to normalize the registry identifier.
+func GetHostFromServer(server string) (string, error) {
+	if strings.HasPrefix(server, "http://") ||
+		strings.HasPrefix(server, "https://") {
+
+		parsed, err := url.Parse(server)
+		if err != nil {
+			return "", err
+		}
+
+		return parsed.Host, nil
+	}
+	return server, nil
 }
diff --git a/pkg/kube/secret/manager.go b/pkg/kube/secret/manager.go
index 379fd29..843525b 100644
--- a/pkg/kube/secret/manager.go
+++ b/pkg/kube/secret/manager.go
@@ -2,14 +2,15 @@ package secret
 
 import (
 	"context"
-	"strings"
+	"fmt"
 
 	"github.com/aquasecurity/starboard/pkg/docker"
-	core "k8s.io/api/core/v1"
-	meta "k8s.io/apimachinery/pkg/apis/meta/v1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes"
 )
 
+// TODO Refactor so we don't use the kubernetes.Interface but rather Secrets and imageRefs values.
 type Manager struct {
 	clientset kubernetes.Interface
 }
@@ -22,23 +23,22 @@ func NewSecretManager(clientset kubernetes.Interface) *Manager {
 }
 
 // GetImagesWithCredentials gets private images for the specified PodSpec and maps them to the Docker ServerCredentials.
-func (s *Manager) GetImagesWithCredentials(ctx context.Context, namespace string, spec core.PodSpec) (credentials map[string]docker.ServerCredentials, err error) {
+func (s *Manager) GetImagesWithCredentials(ctx context.Context, namespace string, spec corev1.PodSpec) (credentials map[string]docker.Auth, err error) {
 	images := s.GetImages(spec)
 
-	serverCredentials, err := s.GetServersWithCredentials(ctx, namespace, spec.ImagePullSecrets)
+	serverCredentials, err := s.GetRegistryHostsWithCredentials(ctx, namespace, spec.ImagePullSecrets)
 	if err != nil {
 		return
 	}
 
-	credentials = make(map[string]docker.ServerCredentials)
+	credentials = make(map[string]docker.Auth)
 	for _, image := range images {
-		server := s.GetServerFromImage(image)
+		server, err := docker.GetServerFromImageRef(image)
+		if err != nil {
+			return nil, fmt.Errorf("getting registry from image reference: %s: %w", image, err)
+		}
 		if ce, ok := serverCredentials[server]; ok {
 			credentials[image] = ce
-		} else if ce, ok := serverCredentials["http://"+server]; ok {
-			credentials[image] = ce
-		} else if ce, ok := serverCredentials["https://"+server]; ok {
-			credentials[image] = ce
 		}
 	}
 
@@ -46,7 +46,7 @@ func (s *Manager) GetImagesWithCredentials(ctx context.Context, namespace string
 }
 
 // GetImages gets a slice of images for the specified PodSpec.
-func (s *Manager) GetImages(spec core.PodSpec) (images []string) {
+func (s *Manager) GetImages(spec corev1.PodSpec) (images []string) {
 	for _, c := range spec.InitContainers {
 		images = append(images, c.Image)
 	}
@@ -58,30 +58,30 @@ func (s *Manager) GetImages(spec core.PodSpec) (images []string) {
 	return
 }
 
-func (s *Manager) GetServersWithCredentials(ctx context.Context, namespace string, imagePullSecrets []core.LocalObjectReference) (credentials map[string]docker.ServerCredentials, err error) {
-	credentials = make(map[string]docker.ServerCredentials)
+func (s *Manager) GetRegistryHostsWithCredentials(ctx context.Context, namespace string, imagePullSecrets []corev1.LocalObjectReference) (credentials map[string]docker.Auth, err error) {
+	credentials = make(map[string]docker.Auth)
 
 	for _, secret := range imagePullSecrets {
 		secret, err := s.clientset.CoreV1().
 			Secrets(namespace).
-			Get(ctx, secret.Name, meta.GetOptions{})
+			Get(ctx, secret.Name, metav1.GetOptions{})
 
 		if err != nil {
 			return nil, err
 		}
-		dockerCfg, err := docker.ReadCredentialsFromBytes(secret.Data[".dockerconfigjson"])
-		for server, configEntry := range dockerCfg {
-			credentials[server] = configEntry
+		dockerConfig := &docker.Config{}
+		err = dockerConfig.Read(secret.Data[corev1.DockerConfigJsonKey])
+		if err != nil {
+			return nil, err
+		}
+		for server, auth := range dockerConfig.Auths {
+			host, err := docker.GetHostFromServer(server)
+			if err != nil {
+				return nil, err
+			}
+			credentials[host] = auth
 		}
 	}
 
 	return
 }
-
-func (s *Manager) GetServerFromImage(image string) string {
-	chunks := strings.Split(image, "/")
-	if len(chunks) > 0 {
-		return chunks[0]
-	}
-	return ""
-}
diff --git a/pkg/kube/secrets/secrets.go b/pkg/kube/secrets/secrets.go
new file mode 100644
index 0000000..4b5e1f0
--- /dev/null
+++ b/pkg/kube/secrets/secrets.go
@@ -0,0 +1,31 @@
+package secrets
+
+import (
+	"github.com/aquasecurity/starboard/pkg/docker"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// NewImagePullSecret constructs a new image pull Secret with the specified
+// registry server and basic authentication credentials.
+func NewImagePullSecret(meta metav1.ObjectMeta, server, username, password string) (*corev1.Secret, error) {
+	dockerConfig, err := docker.Config{
+		Auths: map[string]docker.Auth{
+			server: {
+				Username: username,
+				Password: password,
+				Auth:     docker.NewBasicAuth(username, password),
+			},
+		},
+	}.Write()
+	if err != nil {
+		return nil, err
+	}
+	return &corev1.Secret{
+		ObjectMeta: meta,
+		Type:       corev1.SecretTypeDockerConfigJson,
+		Data: map[string][]byte{
+			corev1.DockerConfigJsonKey: dockerConfig,
+		},
+	}, nil
+}


Test patch:
diff --git a/itest/starboard_cli_test.go b/itest/starboard_cli_test.go
index 47096d1..6fb662c 100644
--- a/itest/starboard_cli_test.go
+++ b/itest/starboard_cli_test.go
@@ -2,8 +2,11 @@ package itest
 
 import (
 	"context"
+	"os"
 	"time"
 
+	"github.com/aquasecurity/starboard/pkg/kube/secrets"
+
 	"github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
 
 	. "github.com/onsi/gomega/gbytes"
@@ -329,6 +332,112 @@ var _ = Describe("Starboard CLI", func() {
 
 		})
 
+		// TODO Run with other integration tests
+		// The only reason this test is marked as pending is that I don't know
+		// how to pass DockerHub private repository credentials to this test case.
+		PContext("when unmanaged Pod with private image is specified as workload", func() {
+			var pod *corev1.Pod
+
+			var podName = "nginx-with-private-image"
+			var secretName = "registry-credentials"
+			var podNamespace = corev1.NamespaceDefault
+
+			BeforeEach(func() {
+				var err error
+				var secret *corev1.Secret
+				secret, err = secrets.NewImagePullSecret(metav1.ObjectMeta{
+					Name:      secretName,
+					Namespace: podNamespace,
+				}, "https://index.docker.io/v1",
+					os.Getenv("STARBOARD_TEST_DOCKERHUB_REGISTRY_USERNAME"),
+					os.Getenv("STARBOARD_TEST_DOCKERHUB_REGISTRY_PASSWORD"))
+				Expect(err).ToNot(HaveOccurred())
+
+				_, err = kubernetesClientset.CoreV1().Secrets(podNamespace).
+					Create(context.TODO(), secret, metav1.CreateOptions{})
+				Expect(err).ToNot(HaveOccurred())
+
+				pod, err = kubernetesClientset.CoreV1().Pods(podNamespace).
+					Create(context.TODO(), &corev1.Pod{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      podName,
+							Namespace: podNamespace,
+						},
+						Spec: corev1.PodSpec{
+							ImagePullSecrets: []corev1.LocalObjectReference{
+								{
+									Name: secretName,
+								},
+							},
+							Containers: []corev1.Container{
+								{
+									Name:            "nginx",
+									Image:           "starboardcicd/private-nginx:1.16",
+									ImagePullPolicy: corev1.PullAlways,
+								},
+							},
+						},
+					}, metav1.CreateOptions{})
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("should create vulnerabilities resources", func() {
+				err := cmd.Run(versionInfo, []string{
+					"starboard",
+					"find", "vulnerabilities", "po/nginx-with-private-image",
+					"-v", starboardCLILogLevel,
+				}, GinkgoWriter, GinkgoWriter)
+				Expect(err).ToNot(HaveOccurred())
+
+				reportList, err := starboardClientset.AquasecurityV1alpha1().Vulnerabilities(podNamespace).
+					List(context.TODO(), metav1.ListOptions{
+						LabelSelector: labels.Set{
+							kube.LabelResourceKind:      "Pod",
+							kube.LabelResourceName:      podName,
+							kube.LabelResourceNamespace: podNamespace,
+						}.String(),
+					})
+				Expect(err).ToNot(HaveOccurred())
+
+				Expect(reportList.Items).To(MatchAllElements(containerNameAsIdFn, Elements{
+					"nginx": MatchFields(IgnoreExtras, Fields{
+						"ObjectMeta": MatchFields(IgnoreExtras, Fields{
+							"Labels": MatchAllKeys(Keys{
+								kube.LabelContainerName:     Equal("nginx"),
+								kube.LabelResourceKind:      Equal("Pod"),
+								kube.LabelResourceName:      Equal(podName),
+								kube.LabelResourceNamespace: Equal(podNamespace),
+							}),
+							"OwnerReferences": ConsistOf(metav1.OwnerReference{
+								APIVersion: "v1",
+								Kind:       "Pod",
+								Name:       podName,
+								UID:        pod.UID,
+							}),
+						}),
+						"Report": MatchFields(IgnoreExtras, Fields{
+							"Scanner": Equal(v1alpha1.Scanner{
+								Name:    "Trivy",
+								Vendor:  "Aqua Security",
+								Version: "0.9.1",
+							}),
+						}),
+					}),
+				}))
+			})
+
+			AfterEach(func() {
+				err := kubernetesClientset.CoreV1().Pods(podNamespace).
+					Delete(context.TODO(), podName, metav1.DeleteOptions{})
+				Expect(err).ToNot(HaveOccurred())
+
+				err = kubernetesClientset.CoreV1().Secrets(podNamespace).
+					Delete(context.TODO(), secretName, metav1.DeleteOptions{})
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+		})
+
 		Context("when ReplicaSet is specified as workload", func() {
 			var rs *appsv1.ReplicaSet
 			var rsName = "nginx"
diff --git a/pkg/docker/config_test.go b/pkg/docker/config_test.go
index 001b2d6..92d7568 100644
--- a/pkg/docker/config_test.go
+++ b/pkg/docker/config_test.go
@@ -1,30 +1,36 @@
-package docker
+package docker_test
 
 import (
 	"errors"
 	"testing"
 
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	"github.com/aquasecurity/starboard/pkg/docker"
+	. "github.com/onsi/ginkgo/extensions/table"
 	"github.com/stretchr/testify/assert"
 )
 
-func TestReadCredentialsFromBytes(t *testing.T) {
+// TODO Refactor to Ginkgo+Gomega
+func TestConfig_Read(t *testing.T) {
 	testCases := []struct {
 		name string
 
 		givenJSON string
 
-		expectedCredentials map[string]ServerCredentials
-		expectedError       error
+		expectedAuth  map[string]docker.Auth
+		expectedError error
 	}{
 		{
-			name:                "Should return empty credentials when content is empty JSON object",
-			givenJSON:           "{}",
-			expectedCredentials: map[string]ServerCredentials{},
+			name:         "Should return empty credentials when content is empty JSON object",
+			givenJSON:    "{}",
+			expectedAuth: map[string]docker.Auth{},
 		},
 		{
-			name:                "Should return empty credentials when content is null JSON",
-			givenJSON:           "null",
-			expectedCredentials: map[string]ServerCredentials{},
+			name:         "Should return empty credentials when content is null JSON",
+			givenJSON:    "null",
+			expectedAuth: map[string]docker.Auth{},
 		},
 		{
 			name:          "Should return error when content is blank",
@@ -43,7 +49,7 @@ func TestReadCredentialsFromBytes(t *testing.T) {
 							}
 						}
 						}`,
-			expectedCredentials: map[string]ServerCredentials{
+			expectedAuth: map[string]docker.Auth{
 				"harbor.domain": {
 					Auth:     "YWRtaW46SGFyYm9yMTIzNDU=",
 					Username: "admin",
@@ -68,7 +74,7 @@ func TestReadCredentialsFromBytes(t *testing.T) {
 						}
 						}
 					}`,
-			expectedCredentials: map[string]ServerCredentials{
+			expectedAuth: map[string]docker.Auth{
 				"harbor.domain": {
 					Auth:     "YWRtaW46SGFyYm9yMTIzNDU=",
 					Username: "admin",
@@ -80,15 +86,57 @@ func TestReadCredentialsFromBytes(t *testing.T) {
 
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			credentials, err := ReadCredentialsFromBytes([]byte(tc.givenJSON))
+			dockerConfig := &docker.Config{}
+			err := dockerConfig.Read([]byte(tc.givenJSON))
 			switch {
 			case tc.expectedError != nil:
 				assert.EqualError(t, err, tc.expectedError.Error())
 			default:
 				assert.NoError(t, err)
-				assert.Equal(t, tc.expectedCredentials, credentials)
+				assert.Equal(t, tc.expectedAuth, dockerConfig.Auths)
 			}
 		})
 
 	}
 }
+
+func TestDocker(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Docker")
+}
+
+var _ = Describe("Docker", func() {
+
+	DescribeTable("GetHostFromServer", func(server, expectedHost string) {
+		host, err := docker.GetHostFromServer(server)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(host).To(Equal(expectedHost))
+	},
+		Entry("34.86.43.130.80",
+			"34.86.43.130.80", "34.86.43.130.80"),
+		Entry("core.harbor.domain:8080",
+			"core.harbor.domain:8080", "core.harbor.domain:8080"),
+		Entry("registry.aquasec.com",
+			"registry.aquasec.com", "registry.aquasec.com"),
+		Entry("https://index.docker.io/v1/",
+			"https://index.docker.io/v1/", "index.docker.io"),
+		Entry("https://registry:3780/",
+			"https://registry:3780/", "registry:3780"),
+	)
+
+	DescribeTable("GetServerFromImageRef", func(imageRef, expectedServer string) {
+		server, err := docker.GetServerFromImageRef(imageRef)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(server).To(Equal(expectedServer))
+	},
+		Entry("aquasec/trivy:0.10.0",
+			"aquasec/trivy:0.10.0", "index.docker.io"),
+		Entry("docker.io/aquasec/harbor-scanner-trivy:0.10.0",
+			"docker.io/aquasec/harbor-scanner-trivy:0.10.0", "index.docker.io"),
+		Entry("index.docker.io/aquasec/harbor-scanner-trivy:0.10.0",
+			"index.docker.io/aquasec/harbor-scanner-trivy:0.10.0", "index.docker.io"),
+		Entry("gcr.io/google-samples/hello-app:1.0",
+			"gcr.io/google-samples/hello-app:1.0", "gcr.io"),
+	)
+
+})
diff --git a/pkg/find/vulnerabilities/crd/writer_test.go b/pkg/find/vulnerabilities/crd/writer_test.go
index aad9d3a..baf5598 100644
--- a/pkg/find/vulnerabilities/crd/writer_test.go
+++ b/pkg/find/vulnerabilities/crd/writer_test.go
@@ -2,9 +2,10 @@ package crd_test
 
 import (
 	"context"
-	"github.com/aquasecurity/starboard/pkg/find/vulnerabilities/crd"
 	"testing"
 
+	"github.com/aquasecurity/starboard/pkg/find/vulnerabilities/crd"
+
 	"github.com/aquasecurity/starboard/pkg/cmd"
 
 	"github.com/aquasecurity/starboard/pkg/find/vulnerabilities"
diff --git a/pkg/find/vulnerabilities/trivy/converter_test.go b/pkg/find/vulnerabilities/trivy/converter_test.go
index 3923b2c..294ed9f 100644
--- a/pkg/find/vulnerabilities/trivy/converter_test.go
+++ b/pkg/find/vulnerabilities/trivy/converter_test.go
@@ -3,10 +3,11 @@ package trivy_test
 import (
 	"errors"
 	"fmt"
-	"github.com/aquasecurity/starboard/pkg/find/vulnerabilities/trivy"
 	"strings"
 	"testing"
 
+	"github.com/aquasecurity/starboard/pkg/find/vulnerabilities/trivy"
+
 	starboard "github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
diff --git a/pkg/kube/secret/manager_test.go b/pkg/kube/secret/manager_test.go
new file mode 100644
index 0000000..0d1b1b7
--- /dev/null
+++ b/pkg/kube/secret/manager_test.go
@@ -0,0 +1,1 @@
+package secret_test
diff --git a/pkg/kube/secrets/secrets_test.go b/pkg/kube/secrets/secrets_test.go
new file mode 100644
index 0000000..9b92014
--- /dev/null
+++ b/pkg/kube/secrets/secrets_test.go
@@ -0,0 +1,53 @@
+package secrets_test
+
+import (
+	"testing"
+
+	"github.com/aquasecurity/starboard/pkg/kube/secrets"
+	. "github.com/onsi/gomega/gstruct"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestSecrets(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Secrets")
+}
+
+var _ = Describe("Secrets", func() {
+
+	Context("NewImagePullSecret", func() {
+
+		It("should construct a new image secret with encoded data", func() {
+			secret, err := secrets.NewImagePullSecret(metav1.ObjectMeta{
+				Name:      "my-secret",
+				Namespace: "my-namespace",
+			}, "http://index.docker.io/v1", "root", "s3cret")
+
+			Expect(err).ToNot(HaveOccurred())
+			Expect(*secret).To(MatchFields(IgnoreExtras, Fields{
+				"ObjectMeta": MatchFields(IgnoreExtras, Fields{
+					"Name":      Equal("my-secret"),
+					"Namespace": Equal("my-namespace"),
+				}),
+				"Type": Equal(corev1.SecretTypeDockerConfigJson),
+				"Data": MatchAllKeys(Keys{
+					".dockerconfigjson": MatchJSON(`{
+  "auths": {
+    "http://index.docker.io/v1": {
+      "auth": "cm9vdDpzM2NyZXQ=",
+      "username": "root",
+      "password": "s3cret"
+    }
+  }
+}`),
+				}),
+			}))
+		})
+
+	})
+
+})