Repository: aquasecurity/starboard
Pull request #332 (2021-01-12 15:34:33)

docs(crds): Overview custom resource definitions used by Starboard
To start with it's an overview. Later on we can add examples and explain the contents of each report.

Follow up on https://github.com/aquasecurity/starboard/discussions/324

Signed-off-by: Daniel Pacak <pacak.daniel@gmail.com>


Patch:
diff --git a/docs/crds.md b/docs/crds.md
index e989171..1d7a04d 100644
--- a/docs/crds.md
+++ b/docs/crds.md
@@ -1,14 +1,62 @@
-# Custom Resource Definitions
+## Overview
 
 This project houses CustomResourceDefinitions (CRDs) related to security and compliance checks along with the code
 generated by Kubernetes [code generators][k8s-code-generator] to write such custom resources in a natural way.
 
 | NAME                                             | SHORTNAMES   | APIGROUP               | NAMESPACED |  KIND               |
 | ------------------------------------------------ | ------------ | ---------------------- | ---------- | ------------------- |
-| [vulnerabilityreports][vulnerabilityreports-crd] | vulns,vuln   | aquasecurity.github.io | true       | VulnerabilityReport |
-| [configauditreports][configauditreports-crd]     | configaudit  | aquasecurity.github.io | true       | ConfigAuditReport   |
-| [ciskubebenchreports][ciskubebenchreports-crd]   | kubebench    | aquasecurity.github.io | false      | CISKubeBenchReport  |
-| [kubehunterreports][kubehunterreports-crd]       | kubehunter   | aquasecurity.github.io | false      | KubeHunterReport    |
+| [vulnerabilityreports][vulnerabilityreports-crd] | vulns,vuln   | aquasecurity.github.io | true       | [VulnerabilityReport](#vulnerabilityreport) |
+| [configauditreports][configauditreports-crd]     | configaudit  | aquasecurity.github.io | true       | [ConfigAuditReport](#configauditreport)     |
+| [ciskubebenchreports][ciskubebenchreports-crd]   | kubebench    | aquasecurity.github.io | false      | [CISKubeBenchReport](#ciskubebenchreport)   |
+| [kubehunterreports][kubehunterreports-crd]       | kubehunter   | aquasecurity.github.io | false      | [KubeHunterReport](#kubehunterreport)       |
+
+> **NOTE** We are open to suggestions for adding new or changes to the existing CRDs in the case that would enable
+> additional third-party integrations.
+
+## VulnerabilityReport
+
+An instance of the VulnerabilityReport represents the latest vulnerabilities found in a container image of a given
+Kubernetes workload. It consists of a list of OS package and application vulnerabilities with a summary of
+vulnerabilities grouped by severity. For multi-container workloads Starboard creates multiple instances
+of VulnerabilityReports, which are stored in the same namespace and are owned by this workload. Each report follows the
+naming convention `<workload kind>-<workload name>-<container-name>`.
+
+> **NOTE** For various reasons we'll probably change the naming convention to name VulnerabilityReports by image digest (see [#288][issue-288]).
+
+Any static vulnerability scanner that is compliant with the VulnerabilityReport schema can be integrated with Starboard.
+You can find the list of available integrations [here](./integrations/vulnerability-scanners/index.md).
+
+## ConfigAuditReport
+
+An instance of the ConfigAuditReport represents checks performed by configuration auditing tools, such as [Polaris][polaris],
+against a Kubernetes workload's configuration. For example, check that a given container image runs as non root user
+or that a container has resource requests and limits set. Currently checks only relate to Kubernetes workloads, but most
+likely we'll extend this model to cater for other Kubernetes objects such as Services, ConfigMaps, etc (see [#300][discussion-300]).
+
+Each report owned by the underlying Kubernetes workload and is stored in the same namespace, following the
+`<workload-kind>-<workload-name>` naming convention.
+
+Third party Kubernetes configuration checkers, linters, and sanitizers that are compliant with the ConfigAuditReport
+schema can be integrated with Starboard.
+
+> **NOTE** The challenge with onboarding third party configuration checkers is that they tend to have different interfaces
+> to perform scans and vary in output formats for a relatively common goal, which is inspecting deployment descriptors
+> for common configuration pitfalls.
+
+## CISKubeBenchReport
+
+The CISKubeBenchReport is a cluster scoped resource owned by a Kubernetes node, which represents the latest result
+of running CIS Kubernetes Benchmark tests on that node. It's named after a corresponding node.
+
+We do not anticipate many (at all) kube-bench alike tools, hence the schema of this report is currently the same as
+the output of [kube-bench].
+
+## KubeHunterReport
+
+The KubeHunterReport is a cluster scoped resource which represents the outcome of running pen tests against your cluster.
+Currently the data model is the same as [kube-hunter]'s output, but we can make it more generic to onboard third
+party pen testing tools. There's zero to one instances of KubeHunterReports with hardcoded name `cluster` without any
+owner reference being set as there's no built-in Kubernetes resource that represents a cluster.
 
 [k8s-code-generator]: https://github.com/kubernetes/code-generator
 
@@ -16,3 +64,9 @@ generated by Kubernetes [code generators][k8s-code-generator] to write such cust
 [ciskubebenchreports-crd]: https://raw.githubusercontent.com/aquasecurity/starboard/main/deploy/crd/ciskubebenchreports.crd.yaml
 [kubehunterreports-crd]: https://raw.githubusercontent.com/aquasecurity/starboard/main/deploy/crd/kubehunterreports.crd.yaml
 [configauditreports-crd]: https://raw.githubusercontent.com/aquasecurity/starboard/main/deploy/crd/configauditreports.crd.yaml
+
+[issue-288]: https://github.com/aquasecurity/starboard/issues/288
+[discussion-300]: https://github.com/aquasecurity/starboard/discussions/300
+[polaris]: https://github.com/FairwindsOps/polaris
+[kube-bench]: https://github.com/aquasecurity/kube-bench
+[kube-hunter]: https://github.com/aquasecurity/kube-hunter
diff --git a/go.mod b/go.mod
index e964e5d..04da113 100644
--- a/go.mod
+++ b/go.mod
@@ -9,10 +9,10 @@ require (
 	github.com/google/uuid v1.1.1
 	github.com/onsi/ginkgo v1.14.0
 	github.com/onsi/gomega v1.10.1
-	github.com/spf13/cobra v1.0.0
+	github.com/spf13/cobra v1.1.1
 	github.com/spf13/pflag v1.0.5
-	github.com/stretchr/testify v1.5.1
-	github.com/valyala/quicktemplate v1.6.2
+	github.com/stretchr/testify v1.7.0
+	github.com/valyala/quicktemplate v1.6.3
 	k8s.io/api v0.18.6
 	k8s.io/apiextensions-apiserver v0.18.6
 	k8s.io/apimachinery v0.18.6
diff --git a/go.sum b/go.sum
index 2f2ac0d..7de8410 100644
--- a/go.sum
+++ b/go.sum
@@ -480,7 +480,7 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
 github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
 github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
 github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.10.10/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/klauspost/compress v1.11.0/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
 github.com/klauspost/cpuid v0.0.0-20180405133222-e7e905edc00e/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
 github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -667,8 +667,9 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU
 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
-github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
 github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
+github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4=
+github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
 github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
@@ -687,8 +688,9 @@ github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRci
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/tdakkota/asciicheck v0.0.0-20200416190851-d7f85be797a2/go.mod h1:yHp0ai0Z9gUljN3o0xMhYJnH/IcvkdTBOX2fmJ93JEM=
 github.com/tdakkota/asciicheck v0.0.0-20200416200610-e657995f937b/go.mod h1:yHp0ai0Z9gUljN3o0xMhYJnH/IcvkdTBOX2fmJ93JEM=
@@ -716,10 +718,10 @@ github.com/uudashr/gocognit v1.0.1/go.mod h1:j44Ayx2KW4+oB6SWMv8KsmHzZrOInQav7D3
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.2.0/go.mod h1:4vX61m6KN+xDduDNwXrhIAVZaZaZiQ1luJk8LWSxF3s=
-github.com/valyala/fasthttp v1.15.1/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
+github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
 github.com/valyala/quicktemplate v1.2.0/go.mod h1:EH+4AkTd43SvgIbQHYu59/cJyxDoOVRUAfrukLPuGJ4=
-github.com/valyala/quicktemplate v1.6.2 h1:k0vgK7zlmFzqAoIBIOrhrfmZ6JoTGJlLRPLbkPGr2/M=
-github.com/valyala/quicktemplate v1.6.2/go.mod h1:mtEJpQtUiBV0SHhMX6RtiJtqxncgrfmjcUy5T68X8TM=
+github.com/valyala/quicktemplate v1.6.3 h1:O7EuMwuH7Q94U2CXD6sOX8AYHqQqWtmIk690IhmpkKA=
+github.com/valyala/quicktemplate v1.6.3/go.mod h1:fwPzK2fHuYEODzJ9pkw0ipCPNHZ2tD5KW4lOuSdPKzY=
 github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
 github.com/vdemeester/k8s-pkg-credentialprovider v1.17.4/go.mod h1:inCTmtUdr5KJbreVojo06krnTgaeAz/Z7lynpPk/Q2c=
 github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=
@@ -1103,6 +1105,8 @@ gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/mkdocs.yml b/mkdocs.yml
index f9aae77..dc66f59 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -40,5 +40,8 @@ nav:
 
 theme:
   name: material
-  highlightjs: true
   language: 'en'
+
+markdown_extensions:
+  - pymdownx.highlight
+  - pymdownx.superfences
diff --git a/pkg/cmd/scan_configaudit.go b/pkg/cmd/scan_configaudit.go
index 41db49b..86c8fa7 100644
--- a/pkg/cmd/scan_configaudit.go
+++ b/pkg/cmd/scan_configaudit.go
@@ -4,7 +4,8 @@ import (
 	"context"
 
 	"github.com/aquasecurity/starboard/pkg/configauditreport"
-	starboardapi "github.com/aquasecurity/starboard/pkg/generated/clientset/versioned"
+	"github.com/aquasecurity/starboard/pkg/ext"
+	"github.com/aquasecurity/starboard/pkg/generated/clientset/versioned"
 	"github.com/aquasecurity/starboard/pkg/polaris"
 	"github.com/aquasecurity/starboard/pkg/starboard"
 	"github.com/spf13/cobra"
@@ -60,14 +61,17 @@ func ScanConfigAuditReports(cf *genericclioptions.ConfigFlags) func(cmd *cobra.C
 		if err != nil {
 			return err
 		}
-		report, err := polaris.NewScanner(starboard.NewScheme(), kubeClientset, config, opts).Scan(ctx, workload, gvk)
+		plugin := polaris.NewPlugin(ext.NewSystemClock(), config)
+		scanner := configauditreport.NewScanner(starboard.NewScheme(), kubeClientset, opts, plugin)
+		report, err := scanner.Scan(ctx, workload, gvk)
 		if err != nil {
 			return err
 		}
-		starboardClientset, err := starboardapi.NewForConfig(kubeConfig)
+		starboardClientset, err := versioned.NewForConfig(kubeConfig)
 		if err != nil {
 			return nil
 		}
-		return configauditreport.NewReadWriter(starboardClientset).Write(ctx, report)
+		writer := configauditreport.NewReadWriter(starboardClientset)
+		return writer.Write(ctx, report)
 	}
 }
diff --git a/pkg/configauditreport/doc.go b/pkg/configauditreport/doc.go
new file mode 100644
index 0000000..fd03de4
--- /dev/null
+++ b/pkg/configauditreport/doc.go
@@ -0,0 +1,2 @@
+// This package provides primitives for working with Kubernetes workload configuration checkers.
+package configauditreport
diff --git a/pkg/configauditreport/plugin.go b/pkg/configauditreport/plugin.go
new file mode 100644
index 0000000..1b98500
--- /dev/null
+++ b/pkg/configauditreport/plugin.go
@@ -0,0 +1,22 @@
+package configauditreport
+
+import (
+	"io"
+
+	"github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
+	"github.com/aquasecurity/starboard/pkg/kube"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// Plugin defines the interface between Starboard and Kubernetes workload
+// configuration checkers / linters / sanitizers. Not a final version, rather
+// first step to separate generic workloads discovery code and Polaris
+// implementation details.
+type Plugin interface {
+	GetScanJobSpec(workload kube.Object, gvk schema.GroupVersionKind) (corev1.PodSpec, error)
+
+	GetContainerName() string
+
+	ParseConfigAuditResult(logsReader io.ReadCloser) (v1alpha1.ConfigAuditResult, error)
+}
diff --git a/pkg/configauditreport/scanner.go b/pkg/configauditreport/scanner.go
new file mode 100644
index 0000000..74c9826
--- /dev/null
+++ b/pkg/configauditreport/scanner.go
@@ -0,0 +1,132 @@
+package configauditreport
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
+	"github.com/aquasecurity/starboard/pkg/ext"
+	"github.com/aquasecurity/starboard/pkg/kube"
+	"github.com/aquasecurity/starboard/pkg/kube/pod"
+	"github.com/aquasecurity/starboard/pkg/runner"
+	"github.com/aquasecurity/starboard/pkg/scanners"
+	"github.com/aquasecurity/starboard/pkg/starboard"
+	batchv1 "k8s.io/api/batch/v1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/klog"
+	"k8s.io/utils/pointer"
+)
+
+type Scanner struct {
+	scheme    *runtime.Scheme
+	clientset kubernetes.Interface
+	opts      kube.ScannerOpts
+	pods      *pod.Manager
+	plugin    Plugin
+	ext.IDGenerator
+}
+
+func NewScanner(
+	scheme *runtime.Scheme,
+	clientset kubernetes.Interface,
+	opts kube.ScannerOpts,
+	plugin Plugin,
+) *Scanner {
+	return &Scanner{
+		scheme:      scheme,
+		clientset:   clientset,
+		opts:        opts,
+		plugin:      plugin,
+		pods:        pod.NewPodManager(clientset),
+		IDGenerator: ext.NewGoogleUUIDGenerator(),
+	}
+}
+
+func (s *Scanner) Scan(ctx context.Context, workload kube.Object, gvk schema.GroupVersionKind) (v1alpha1.ConfigAuditReport, error) {
+	klog.V(3).Infof("Getting Pod template for workload: %v", workload)
+
+	_, owner, err := s.pods.GetPodSpecByWorkload(ctx, workload)
+	if err != nil {
+		return v1alpha1.ConfigAuditReport{}, err
+	}
+
+	klog.V(3).Infof("Scanning with options: %+v", s.opts)
+	job, err := s.getScanJob(workload, gvk)
+	if err != nil {
+		return v1alpha1.ConfigAuditReport{}, err
+	}
+
+	err = runner.New().Run(ctx, kube.NewRunnableJob(s.scheme, s.clientset, job))
+	if err != nil {
+		s.pods.LogRunnerErrors(ctx, job)
+		return v1alpha1.ConfigAuditReport{}, fmt.Errorf("running scan job: %w", err)
+	}
+
+	defer func() {
+		if !s.opts.DeleteScanJob {
+			klog.V(3).Infof("Skipping scan job deletion: %s/%s", job.Namespace, job.Name)
+			return
+		}
+		klog.V(3).Infof("Deleting scan job: %s/%s", job.Namespace, job.Name)
+		background := metav1.DeletePropagationBackground
+		_ = s.clientset.BatchV1().Jobs(job.Namespace).Delete(ctx, job.Name, metav1.DeleteOptions{
+			PropagationPolicy: &background,
+		})
+	}()
+
+	containerName := s.plugin.GetContainerName()
+
+	klog.V(3).Infof("Getting logs for %s container in job: %s/%s", containerName,
+		job.Namespace, job.Name)
+	logsReader, err := s.pods.GetContainerLogsByJob(ctx, job, containerName)
+	if err != nil {
+		return v1alpha1.ConfigAuditReport{}, fmt.Errorf("getting logs: %w", err)
+	}
+
+	result, err := s.plugin.ParseConfigAuditResult(logsReader)
+	defer func() {
+		_ = logsReader.Close()
+	}()
+
+	return NewBuilder(s.scheme).
+		Owner(owner).
+		Result(result).
+		Get()
+}
+
+func (s *Scanner) getScanJob(workload kube.Object, gvk schema.GroupVersionKind) (*batchv1.Job, error) {
+	jobSpec, err := s.plugin.GetScanJobSpec(workload, gvk)
+	if err != nil {
+		return nil, err
+	}
+	return &batchv1.Job{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      s.GenerateID(),
+			Namespace: starboard.NamespaceName,
+			Labels: map[string]string{
+				kube.LabelResourceKind:      string(workload.Kind),
+				kube.LabelResourceName:      workload.Name,
+				kube.LabelResourceNamespace: workload.Namespace,
+			},
+		},
+		Spec: batchv1.JobSpec{
+			BackoffLimit:          pointer.Int32Ptr(0),
+			Completions:           pointer.Int32Ptr(1),
+			ActiveDeadlineSeconds: scanners.GetActiveDeadlineSeconds(s.opts.ScanJobTimeout),
+			Template: corev1.PodTemplateSpec{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: map[string]string{
+						kube.LabelResourceKind:      string(workload.Kind),
+						kube.LabelResourceName:      workload.Name,
+						kube.LabelResourceNamespace: workload.Namespace,
+					},
+				},
+				Spec: jobSpec,
+			},
+		},
+	}, nil
+}
diff --git a/pkg/configauditreport/writer.go b/pkg/configauditreport/writer.go
index 27ad529..45d5381 100644
--- a/pkg/configauditreport/writer.go
+++ b/pkg/configauditreport/writer.go
@@ -3,28 +3,27 @@ package configauditreport
 import (
 	"context"
 
-	clientset "github.com/aquasecurity/starboard/pkg/generated/clientset/versioned"
+	"github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
+	"github.com/aquasecurity/starboard/pkg/generated/clientset/versioned"
+	"github.com/aquasecurity/starboard/pkg/kube"
 	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/klog"
-
-	starboard "github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
-	"github.com/aquasecurity/starboard/pkg/kube"
 )
 
 // Write is the interface that wraps basic methods for persisting ConfigAudit reports.
 //
 // Write persists the given ConfigAuditReport report.
 type Writer interface {
-	Write(ctx context.Context, report starboard.ConfigAuditReport) error
+	Write(ctx context.Context, report v1alpha1.ConfigAuditReport) error
 }
 
 // Reader is the interface that wraps basic methods for reading ConfigAudit reports.
 //
 // Read will return a single ConfigAuditReport that match a specific workload
 type Reader interface {
-	FindByOwner(ctx context.Context, owner kube.Object) (*starboard.ConfigAuditReport, error)
+	FindByOwner(ctx context.Context, owner kube.Object) (*v1alpha1.ConfigAuditReport, error)
 }
 
 type ReadWriter interface {
@@ -33,17 +32,17 @@ type ReadWriter interface {
 }
 
 type readWriter struct {
-	client clientset.Interface
+	clientset versioned.Interface
 }
 
-func NewReadWriter(client clientset.Interface) ReadWriter {
+func NewReadWriter(clientset versioned.Interface) ReadWriter {
 	return &readWriter{
-		client: client,
+		clientset: clientset,
 	}
 }
 
-func (w *readWriter) Write(ctx context.Context, report starboard.ConfigAuditReport) error {
-	existing, err := w.client.AquasecurityV1alpha1().ConfigAuditReports(report.Namespace).
+func (w *readWriter) Write(ctx context.Context, report v1alpha1.ConfigAuditReport) error {
+	existing, err := w.clientset.AquasecurityV1alpha1().ConfigAuditReports(report.Namespace).
 		Get(ctx, report.Name, metav1.GetOptions{})
 
 	if err == nil {
@@ -52,14 +51,14 @@ func (w *readWriter) Write(ctx context.Context, report starboard.ConfigAuditRepo
 		deepCopy.Labels = report.Labels
 		deepCopy.Report = report.Report
 
-		_, err = w.client.AquasecurityV1alpha1().ConfigAuditReports(report.Namespace).
+		_, err = w.clientset.AquasecurityV1alpha1().ConfigAuditReports(report.Namespace).
 			Update(ctx, deepCopy, metav1.UpdateOptions{})
 		return err
 	}
 
 	if errors.IsNotFound(err) {
 		klog.V(3).Infof("Creating ConfigAuditReport %q", report.Namespace+"/"+report.Name)
-		_, err = w.client.AquasecurityV1alpha1().ConfigAuditReports(report.Namespace).
+		_, err = w.clientset.AquasecurityV1alpha1().ConfigAuditReports(report.Namespace).
 			Create(ctx, &report, metav1.CreateOptions{})
 		return err
 	}
@@ -67,8 +66,8 @@ func (w *readWriter) Write(ctx context.Context, report starboard.ConfigAuditRepo
 	return err
 }
 
-func (w *readWriter) FindByOwner(ctx context.Context, workload kube.Object) (*starboard.ConfigAuditReport, error) {
-	list, err := w.client.AquasecurityV1alpha1().ConfigAuditReports(workload.Namespace).List(ctx, metav1.ListOptions{
+func (w *readWriter) FindByOwner(ctx context.Context, workload kube.Object) (*v1alpha1.ConfigAuditReport, error) {
+	list, err := w.clientset.AquasecurityV1alpha1().ConfigAuditReports(workload.Namespace).List(ctx, metav1.ListOptions{
 		LabelSelector: labels.Set{
 			kube.LabelResourceKind:      string(workload.Kind),
 			kube.LabelResourceName:      workload.Name,
diff --git a/pkg/kube/pod/manager.go b/pkg/kube/pod/manager.go
index bc82e2d..6af8308 100644
--- a/pkg/kube/pod/manager.go
+++ b/pkg/kube/pod/manager.go
@@ -5,16 +5,14 @@ import (
 	"fmt"
 	"io"
 
-	"k8s.io/klog"
-
 	"github.com/aquasecurity/starboard/pkg/kube"
-
 	apps "k8s.io/api/apps/v1"
 	batch "k8s.io/api/batch/v1"
 	batchv1beta1 "k8s.io/api/batch/v1beta1"
 	core "k8s.io/api/core/v1"
 	meta "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes"
+	"k8s.io/klog"
 )
 
 type Manager struct {
@@ -123,6 +121,9 @@ func (pw *Manager) GetTerminatedContainersStatusesByJob(ctx context.Context, job
 
 func GetTerminatedContainersStatusesByPod(pod *core.Pod) map[string]*core.ContainerStateTerminated {
 	states := make(map[string]*core.ContainerStateTerminated)
+	if pod == nil {
+		return states
+	}
 	for _, status := range pod.Status.InitContainerStatuses {
 		if status.State.Terminated == nil {
 			continue
@@ -159,7 +160,10 @@ func (pw *Manager) GetPodByJob(ctx context.Context, job *batch.Job) (*core.Pod,
 	if err != nil {
 		return nil, err
 	}
-	return &podList.Items[0], nil
+	if podList != nil && len(podList.Items) > 0 {
+		return &podList.Items[0], nil
+	}
+	return nil, nil
 }
 
 func (pw *Manager) GetPodLogs(ctx context.Context, pod *core.Pod, container string) (io.ReadCloser, error) {
@@ -186,16 +190,3 @@ func (pw *Manager) logTerminatedContainersErrors(statuses map[string]*core.Conta
 		klog.Errorf("Container %s terminated with %s: %s", container, status.Reason, status.Message)
 	}
 }
-
-// GetImages gets a slice of images for the specified PodSpec.
-func GetImages(spec core.PodSpec) (images []string) {
-	for _, c := range spec.InitContainers {
-		images = append(images, c.Image)
-	}
-
-	for _, c := range spec.Containers {
-		images = append(images, c.Image)
-	}
-
-	return
-}
diff --git a/pkg/kube/runnable_job.go b/pkg/kube/runnable_job.go
index d518c4f..8a38f20 100644
--- a/pkg/kube/runnable_job.go
+++ b/pkg/kube/runnable_job.go
@@ -57,7 +57,8 @@ func (r *runnableJob) Run(ctx context.Context) error {
 		defaultResyncDuration,
 		informers.WithNamespace(r.job.Namespace),
 	)
-	jobInformer := informerFactory.Batch().V1().Jobs()
+	jobsInformer := informerFactory.Batch().V1().Jobs()
+	eventsInformer := informerFactory.Core().V1().Events()
 
 	var err error
 
@@ -90,8 +91,8 @@ func (r *runnableJob) Run(ctx context.Context) error {
 
 	complete := make(chan error)
 
-	jobInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
-		UpdateFunc: func(oldObj, newObj interface{}) {
+	jobsInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+		UpdateFunc: func(_, newObj interface{}) {
 			newJob, ok := newObj.(*batchv1.Job)
 			if !ok {
 				return
@@ -113,6 +114,28 @@ func (r *runnableJob) Run(ctx context.Context) error {
 		},
 	})
 
+	eventsInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+		AddFunc: func(obj interface{}) {
+			event := obj.(*corev1.Event)
+
+			// TODO We might want to look into events associate with the pod controlled by the current scan job.
+			// For example, when a pod cannot be scheduled due to insufficient resource requests,
+			// the event will be attached to the pod, not the job.
+			if event.InvolvedObject.UID != r.job.UID {
+				return
+			}
+
+			if event.Type == corev1.EventTypeNormal {
+				klog.V(3).Infof("Event: %s (%s)", event.Message, event.Reason)
+			}
+
+			if event.Type == corev1.EventTypeWarning {
+				complete <- fmt.Errorf("warning event received: %s (%s)", event.Message, event.Reason)
+				return
+			}
+		},
+	})
+
 	informerFactory.Start(wait.NeverStop)
 	informerFactory.WaitForCacheSync(wait.NeverStop)
 
diff --git a/pkg/polaris/converter.go b/pkg/polaris/converter.go
deleted file mode 100644
index b2c42da..0000000
--- a/pkg/polaris/converter.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package polaris
-
-import (
-	"encoding/json"
-	"io"
-	"time"
-
-	"github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
-	"github.com/aquasecurity/starboard/pkg/starboard"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-)
-
-type Converter interface {
-	Convert(reader io.Reader) (v1alpha1.ConfigAuditResult, error)
-}
-
-type converter struct {
-	config Config
-}
-
-func NewConverter(config Config) Converter {
-	return &converter{
-		config: config,
-	}
-}
-
-func (c *converter) Convert(reader io.Reader) (v1alpha1.ConfigAuditResult, error) {
-	var report Report
-	err := json.NewDecoder(reader).Decode(&report)
-	if err != nil {
-		return v1alpha1.ConfigAuditResult{}, err
-	}
-	return c.toConfigAudit(report.Results[0])
-}
-
-// TODO Add success checks to the summary
-func (c *converter) toSummary(podChecks []v1alpha1.Check, containerChecks map[string][]v1alpha1.Check) v1alpha1.ConfigAuditSummary {
-	var summary v1alpha1.ConfigAuditSummary
-	for _, c := range podChecks {
-		if c.Success {
-			continue
-		}
-		switch c.Severity {
-		case v1alpha1.ConfigAuditDangerSeverity:
-			summary.DangerCount++
-		case v1alpha1.ConfigAuditWarningSeverity:
-			summary.WarningCount++
-		}
-	}
-	for _, checks := range containerChecks {
-		for _, c := range checks {
-			if c.Success {
-				continue
-			}
-			switch c.Severity {
-			case v1alpha1.ConfigAuditDangerSeverity:
-				summary.DangerCount++
-			case v1alpha1.ConfigAuditWarningSeverity:
-				summary.WarningCount++
-			}
-		}
-	}
-	return summary
-}
-
-func (c *converter) toConfigAudit(result Result) (v1alpha1.ConfigAuditResult, error) {
-	var podChecks []v1alpha1.Check
-	containerChecks := make(map[string][]v1alpha1.Check)
-
-	for _, pr := range result.PodResult.Results {
-		podChecks = append(podChecks, v1alpha1.Check{
-			ID:       pr.ID,
-			Message:  pr.Message,
-			Success:  pr.Success,
-			Severity: pr.Severity,
-			Category: pr.Category,
-		})
-	}
-
-	for _, cr := range result.PodResult.ContainerResults {
-		var checks []v1alpha1.Check
-		for _, crr := range cr.Results {
-			checks = append(checks, v1alpha1.Check{
-				ID:       crr.ID,
-				Message:  crr.Message,
-				Success:  crr.Success,
-				Severity: crr.Severity,
-				Category: crr.Category,
-			})
-
-		}
-		containerChecks[cr.Name] = checks
-	}
-
-	imageRef, err := c.config.GetPolarisImageRef()
-	if err != nil {
-		return v1alpha1.ConfigAuditResult{}, err
-	}
-
-	version, err := starboard.GetVersionFromImageRef(imageRef)
-	if err != nil {
-		return v1alpha1.ConfigAuditResult{}, err
-	}
-
-	return v1alpha1.ConfigAuditResult{
-		Scanner: v1alpha1.Scanner{
-			Name:    "Polaris",
-			Vendor:  "Fairwinds Ops",
-			Version: version,
-		},
-		Summary:         c.toSummary(podChecks, containerChecks),
-		UpdateTimestamp: metav1.NewTime(time.Now()),
-		PodChecks:       podChecks,
-		ContainerChecks: containerChecks,
-	}, nil
-}
diff --git a/pkg/polaris/doc.go b/pkg/polaris/doc.go
new file mode 100644
index 0000000..19e62a2
--- /dev/null
+++ b/pkg/polaris/doc.go
@@ -0,0 +1,2 @@
+// This package provides primitives for working with Polaris.
+package polaris
diff --git a/pkg/polaris/plugin.go b/pkg/polaris/plugin.go
new file mode 100644
index 0000000..2606494
--- /dev/null
+++ b/pkg/polaris/plugin.go
@@ -0,0 +1,208 @@
+package polaris
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+
+	"github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
+	"github.com/aquasecurity/starboard/pkg/configauditreport"
+	"github.com/aquasecurity/starboard/pkg/ext"
+	"github.com/aquasecurity/starboard/pkg/kube"
+	"github.com/aquasecurity/starboard/pkg/starboard"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/utils/pointer"
+)
+
+const (
+	polarisContainerName = "polaris"
+	configVolume         = "config"
+)
+
+type Config interface {
+	GetPolarisImageRef() (string, error)
+}
+
+type plugin struct {
+	clock  ext.Clock
+	config Config
+}
+
+// NewPlugin constructs a new configauditreport.Plugin, which is using an
+// official Polaris container image to audit Kubernetes workloads.
+func NewPlugin(clock ext.Clock, config Config) configauditreport.Plugin {
+	return &plugin{
+		clock:  clock,
+		config: config,
+	}
+}
+
+func (p *plugin) GetScanJobSpec(workload kube.Object, gvk schema.GroupVersionKind) (corev1.PodSpec, error) {
+	imageRef, err := p.config.GetPolarisImageRef()
+	if err != nil {
+		return corev1.PodSpec{}, err
+	}
+	sourceName := p.sourceNameFrom(workload, gvk)
+
+	return corev1.PodSpec{
+		ServiceAccountName:           starboard.ServiceAccountName,
+		AutomountServiceAccountToken: pointer.BoolPtr(true),
+		RestartPolicy:                corev1.RestartPolicyNever,
+		Affinity:                     starboard.LinuxNodeAffinity(),
+		Volumes: []corev1.Volume{
+			{
+				Name: configVolume,
+				VolumeSource: corev1.VolumeSource{
+					ConfigMap: &corev1.ConfigMapVolumeSource{
+						LocalObjectReference: corev1.LocalObjectReference{
+							Name: starboard.ConfigMapName,
+						},
+					},
+				},
+			},
+		},
+		Containers: []corev1.Container{
+			{
+				Name:                     polarisContainerName,
+				Image:                    imageRef,
+				ImagePullPolicy:          corev1.PullIfNotPresent,
+				TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError,
+				Resources: corev1.ResourceRequirements{
+					Limits: corev1.ResourceList{
+						corev1.ResourceCPU:    resource.MustParse("300m"),
+						corev1.ResourceMemory: resource.MustParse("300M"),
+					},
+					Requests: corev1.ResourceList{
+						corev1.ResourceCPU:    resource.MustParse("50m"),
+						corev1.ResourceMemory: resource.MustParse("50M"),
+					},
+				},
+				VolumeMounts: []corev1.VolumeMount{
+					{
+						Name:      configVolume,
+						MountPath: "/etc/starboard",
+					},
+				},
+				Command: []string{"polaris"},
+				Args: []string{
+					"audit",
+					"--log-level", "error",
+					"--config", "/etc/starboard/polaris.config.yaml",
+					"--resource", sourceName,
+				},
+			},
+		},
+	}, nil
+}
+
+func (p *plugin) GetContainerName() string {
+	return polarisContainerName
+}
+
+func (p *plugin) ParseConfigAuditResult(logsReader io.ReadCloser) (v1alpha1.ConfigAuditResult, error) {
+	var report Report
+	err := json.NewDecoder(logsReader).Decode(&report)
+	if err != nil {
+		return v1alpha1.ConfigAuditResult{}, err
+	}
+	return p.configAuditResultFrom(report.Results[0])
+}
+
+func (p *plugin) sourceNameFrom(workload kube.Object, gvk schema.GroupVersionKind) string {
+	group := gvk.Group
+	if len(group) > 0 {
+		group = "." + group
+	}
+	return fmt.Sprintf("%s/%s%s/%s/%s",
+		workload.Namespace,
+		gvk.Kind,
+		group,
+		gvk.Version,
+		workload.Name,
+	)
+}
+
+func (p *plugin) configAuditResultFrom(result Result) (v1alpha1.ConfigAuditResult, error) {
+	var podChecks []v1alpha1.Check
+	containerChecks := make(map[string][]v1alpha1.Check)
+
+	for _, pr := range result.PodResult.Results {
+		podChecks = append(podChecks, v1alpha1.Check{
+			ID:       pr.ID,
+			Message:  pr.Message,
+			Success:  pr.Success,
+			Severity: pr.Severity,
+			Category: pr.Category,
+		})
+	}
+
+	for _, cr := range result.PodResult.ContainerResults {
+		var checks []v1alpha1.Check
+		for _, crr := range cr.Results {
+			checks = append(checks, v1alpha1.Check{
+				ID:       crr.ID,
+				Message:  crr.Message,
+				Success:  crr.Success,
+				Severity: crr.Severity,
+				Category: crr.Category,
+			})
+
+		}
+		containerChecks[cr.Name] = checks
+	}
+
+	imageRef, err := p.config.GetPolarisImageRef()
+	if err != nil {
+		return v1alpha1.ConfigAuditResult{}, err
+	}
+
+	version, err := starboard.GetVersionFromImageRef(imageRef)
+	if err != nil {
+		return v1alpha1.ConfigAuditResult{}, err
+	}
+
+	return v1alpha1.ConfigAuditResult{
+		Scanner: v1alpha1.Scanner{
+			Name:    "Polaris",
+			Vendor:  "Fairwinds Ops",
+			Version: version,
+		},
+		Summary:         p.configAuditSummaryFrom(podChecks, containerChecks),
+		UpdateTimestamp: metav1.NewTime(p.clock.Now()),
+		PodChecks:       podChecks,
+		ContainerChecks: containerChecks,
+	}, nil
+}
+
+// TODO Add success checks count to the ConfigAuditSummary
+func (p *plugin) configAuditSummaryFrom(podChecks []v1alpha1.Check, containerChecks map[string][]v1alpha1.Check) v1alpha1.ConfigAuditSummary {
+	var summary v1alpha1.ConfigAuditSummary
+	for _, c := range podChecks {
+		if c.Success {
+			continue
+		}
+		switch c.Severity {
+		case v1alpha1.ConfigAuditDangerSeverity:
+			summary.DangerCount++
+		case v1alpha1.ConfigAuditWarningSeverity:
+			summary.WarningCount++
+		}
+	}
+	for _, checks := range containerChecks {
+		for _, c := range checks {
+			if c.Success {
+				continue
+			}
+			switch c.Severity {
+			case v1alpha1.ConfigAuditDangerSeverity:
+				summary.DangerCount++
+			case v1alpha1.ConfigAuditWarningSeverity:
+				summary.WarningCount++
+			}
+		}
+	}
+	return summary
+}
diff --git a/pkg/polaris/scanner.go b/pkg/polaris/scanner.go
deleted file mode 100644
index 7563340..0000000
--- a/pkg/polaris/scanner.go
+++ /dev/null
@@ -1,202 +0,0 @@
-package polaris
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
-	"github.com/aquasecurity/starboard/pkg/configauditreport"
-	"github.com/aquasecurity/starboard/pkg/kube"
-	"github.com/aquasecurity/starboard/pkg/kube/pod"
-	"github.com/aquasecurity/starboard/pkg/runner"
-	"github.com/aquasecurity/starboard/pkg/scanners"
-	"github.com/aquasecurity/starboard/pkg/starboard"
-	"github.com/google/uuid"
-	batchv1 "k8s.io/api/batch/v1"
-	corev1 "k8s.io/api/core/v1"
-	"k8s.io/apimachinery/pkg/api/resource"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/runtime"
-	"k8s.io/apimachinery/pkg/runtime/schema"
-	"k8s.io/client-go/kubernetes"
-	"k8s.io/klog"
-	"k8s.io/utils/pointer"
-)
-
-const (
-	polarisContainerName = "polaris"
-	configVolume         = "config"
-)
-
-type Config interface {
-	GetPolarisImageRef() (string, error)
-}
-
-type Scanner struct {
-	scheme    *runtime.Scheme
-	clientset kubernetes.Interface
-	config    Config
-	opts      kube.ScannerOpts
-	pods      *pod.Manager
-	converter Converter
-}
-
-func NewScanner(
-	scheme *runtime.Scheme,
-	clientset kubernetes.Interface,
-	config Config,
-	opts kube.ScannerOpts,
-) *Scanner {
-	return &Scanner{
-		scheme:    scheme,
-		config:    config,
-		clientset: clientset,
-		opts:      opts,
-		pods:      pod.NewPodManager(clientset),
-		converter: NewConverter(config),
-	}
-}
-
-func (s *Scanner) Scan(ctx context.Context, workload kube.Object, gvk schema.GroupVersionKind) (v1alpha1.ConfigAuditReport, error) {
-	klog.V(3).Infof("Getting Pod template for workload: %v", workload)
-
-	_, owner, err := s.pods.GetPodSpecByWorkload(ctx, workload)
-	if err != nil {
-		return v1alpha1.ConfigAuditReport{}, err
-	}
-
-	klog.V(3).Infof("Scanning with options: %+v", s.opts)
-	job, err := s.preparePolarisJob(workload, gvk)
-	if err != nil {
-		return v1alpha1.ConfigAuditReport{}, err
-	}
-
-	err = runner.New().Run(ctx, kube.NewRunnableJob(s.scheme, s.clientset, job))
-	if err != nil {
-		s.pods.LogRunnerErrors(ctx, job)
-		return v1alpha1.ConfigAuditReport{}, fmt.Errorf("running polaris job: %w", err)
-	}
-
-	defer func() {
-		if !s.opts.DeleteScanJob {
-			klog.V(3).Infof("Skipping scan job deletion: %s/%s", job.Namespace, job.Name)
-			return
-		}
-		klog.V(3).Infof("Deleting scan job: %s/%s", job.Namespace, job.Name)
-		background := metav1.DeletePropagationBackground
-		_ = s.clientset.BatchV1().Jobs(job.Namespace).Delete(ctx, job.Name, metav1.DeleteOptions{
-			PropagationPolicy: &background,
-		})
-	}()
-
-	klog.V(3).Infof("Getting logs for %s container in job: %s/%s", polarisContainerName,
-		job.Namespace, job.Name)
-	logsReader, err := s.pods.GetContainerLogsByJob(ctx, job, polarisContainerName)
-	if err != nil {
-		return v1alpha1.ConfigAuditReport{}, fmt.Errorf("getting logs: %w", err)
-	}
-
-	result, err := s.converter.Convert(logsReader)
-	defer func() {
-		_ = logsReader.Close()
-	}()
-
-	return configauditreport.NewBuilder(s.scheme).
-		Owner(owner).
-		Result(result).
-		Get()
-}
-
-func (s *Scanner) sourceNameFrom(workload kube.Object, gvk schema.GroupVersionKind) string {
-	group := gvk.Group
-	if len(group) > 0 {
-		group = "." + group
-	}
-	return fmt.Sprintf("%s/%s%s/%s/%s",
-		workload.Namespace,
-		gvk.Kind,
-		group,
-		gvk.Version,
-		workload.Name,
-	)
-}
-
-func (s *Scanner) preparePolarisJob(workload kube.Object, gvk schema.GroupVersionKind) (*batchv1.Job, error) {
-	imageRef, err := s.config.GetPolarisImageRef()
-	if err != nil {
-		return nil, err
-	}
-	sourceName := s.sourceNameFrom(workload, gvk)
-	return &batchv1.Job{
-		ObjectMeta: metav1.ObjectMeta{
-			Name:      uuid.New().String(),
-			Namespace: starboard.NamespaceName,
-			Labels: map[string]string{
-				kube.LabelResourceKind:      string(workload.Kind),
-				kube.LabelResourceName:      workload.Name,
-				kube.LabelResourceNamespace: workload.Namespace,
-			},
-		},
-		Spec: batchv1.JobSpec{
-			BackoffLimit:          pointer.Int32Ptr(0),
-			Completions:           pointer.Int32Ptr(1),
-			ActiveDeadlineSeconds: scanners.GetActiveDeadlineSeconds(s.opts.ScanJobTimeout),
-			Template: corev1.PodTemplateSpec{
-				ObjectMeta: metav1.ObjectMeta{
-					Labels: map[string]string{
-						kube.LabelResourceKind:      string(workload.Kind),
-						kube.LabelResourceName:      workload.Name,
-						kube.LabelResourceNamespace: workload.Namespace,
-					},
-				},
-				Spec: corev1.PodSpec{
-					ServiceAccountName:           starboard.ServiceAccountName,
-					AutomountServiceAccountToken: pointer.BoolPtr(true),
-					RestartPolicy:                corev1.RestartPolicyNever,
-					Affinity:                     starboard.LinuxNodeAffinity(),
-					Volumes: []corev1.Volume{
-						{
-							Name: configVolume,
-							VolumeSource: corev1.VolumeSource{
-								ConfigMap: &corev1.ConfigMapVolumeSource{
-									LocalObjectReference: corev1.LocalObjectReference{
-										Name: starboard.ConfigMapName,
-									},
-								},
-							},
-						},
-					},
-					Containers: []corev1.Container{
-						{
-							Name:                     polarisContainerName,
-							Image:                    imageRef,
-							ImagePullPolicy:          corev1.PullIfNotPresent,
-							TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError,
-							Resources: corev1.ResourceRequirements{
-								Limits: corev1.ResourceList{
-									corev1.ResourceCPU:    resource.MustParse("300m"),
-									corev1.ResourceMemory: resource.MustParse("300M"),
-								},
-								Requests: corev1.ResourceList{
-									corev1.ResourceCPU:    resource.MustParse("50m"),
-									corev1.ResourceMemory: resource.MustParse("50M"),
-								},
-							},
-							VolumeMounts: []corev1.VolumeMount{
-								{
-									Name:      configVolume,
-									MountPath: "/etc/starboard",
-								},
-							},
-							Command: []string{"polaris"},
-							Args: []string{"audit",
-								"--log-level", "error",
-								"--config", "/etc/starboard/polaris.config.yaml",
-								"--resource", sourceName},
-						},
-					},
-				},
-			},
-		},
-	}, nil
-}
diff --git a/pkg/vulnerabilityreport/scanner.go b/pkg/vulnerabilityreport/scanner.go
index 93811d0..fe1a6d2 100644
--- a/pkg/vulnerabilityreport/scanner.go
+++ b/pkg/vulnerabilityreport/scanner.go
@@ -29,9 +29,9 @@ type Scanner struct {
 	scheme    *runtime.Scheme
 	clientset kubernetes.Interface
 	opts      kube.ScannerOpts
+	plugin    Plugin
 	pods      *pod.Manager
 	ext.IDGenerator
-	Plugin
 	kube.SecretsReader
 }
 
@@ -49,7 +49,7 @@ func NewScanner(
 		scheme:        scheme,
 		clientset:     clientset,
 		opts:          opts,
-		Plugin:        plugin,
+		plugin:        plugin,
 		pods:          pod.NewPodManager(clientset),
 		IDGenerator:   ext.NewGoogleUUIDGenerator(),
 		SecretsReader: kube.NewSecretsReader(clientset),
@@ -101,11 +101,6 @@ func (s *Scanner) Scan(ctx context.Context, workload kube.Object) ([]v1alpha1.Vu
 
 	klog.V(3).Infof("Scan job completed: %s/%s", job.Namespace, job.Name)
 
-	job, err = s.clientset.BatchV1().Jobs(job.Namespace).Get(ctx, job.Name, metav1.GetOptions{})
-	if err != nil {
-		return nil, fmt.Errorf("getting scan job: %w", err)
-	}
-
 	return s.getVulnerabilityReportsByScanJob(ctx, job, owner)
 }
 
@@ -118,7 +113,7 @@ func (s *Scanner) getCredentials(ctx context.Context, spec corev1.PodSpec, ns st
 }
 
 func (s *Scanner) prepareScanJob(workload kube.Object, spec corev1.PodSpec, credentials map[string]docker.Auth) (*batchv1.Job, []*corev1.Secret, error) {
-	templateSpec, secrets, err := s.GetScanJobSpec(spec, credentials)
+	templateSpec, secrets, err := s.plugin.GetScanJobSpec(spec, credentials)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -177,7 +172,7 @@ func (s *Scanner) getVulnerabilityReportsByScanJob(ctx context.Context, job *bat
 		if err != nil {
 			return nil, err
 		}
-		result, err := s.ParseVulnerabilityScanResult(containerImages[c.Name], logReader)
+		result, err := s.plugin.ParseVulnerabilityScanResult(containerImages[c.Name], logReader)
 
 		report, err := NewBuilder(s.scheme).
 			Owner(owner).


Test patch:
diff --git a/pkg/configauditreport/scanner_test.go b/pkg/configauditreport/scanner_test.go
new file mode 100644
index 0000000..0b4fdf8
--- /dev/null
+++ b/pkg/configauditreport/scanner_test.go
@@ -0,0 +1,1 @@
+package configauditreport_test
diff --git a/pkg/configauditreport/writer_test.go b/pkg/configauditreport/writer_test.go
index 87742fb..b05471a 100644
--- a/pkg/configauditreport/writer_test.go
+++ b/pkg/configauditreport/writer_test.go
@@ -4,12 +4,11 @@ import (
 	"context"
 	"testing"
 
-	"github.com/stretchr/testify/assert"
-
 	"github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
 	"github.com/aquasecurity/starboard/pkg/configauditreport"
 	"github.com/aquasecurity/starboard/pkg/generated/clientset/versioned/fake"
 	"github.com/aquasecurity/starboard/pkg/kube"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
diff --git a/pkg/polaris/converter_test.go b/pkg/polaris/converter_test.go
deleted file mode 100644
index d5fccc0..0000000
--- a/pkg/polaris/converter_test.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package polaris_test
-
-import (
-	"os"
-	"testing"
-
-	"github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
-	"github.com/aquasecurity/starboard/pkg/polaris"
-	"github.com/aquasecurity/starboard/pkg/starboard"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-func TestConverter_Convert(t *testing.T) {
-	// FIXME Deterministic assert!
-	t.Skip("Fix me - the assert is not deterministic")
-	file, err := os.Open("testdata/polaris-report.json")
-	require.NoError(t, err)
-	defer func() {
-		_ = file.Close()
-	}()
-
-	reports, err := polaris.NewConverter(starboard.ConfigData{}).Convert(file)
-	require.NoError(t, err)
-	assert.Equal(t, []v1alpha1.ConfigAuditResult{
-		{
-			Scanner: v1alpha1.Scanner{
-				Name:    "Polaris",
-				Vendor:  "Fairwinds",
-				Version: "latest",
-			},
-			PodChecks: []v1alpha1.Check{
-				{
-					ID:       "hostIPCSet",
-					Message:  "Host IPC is not configured",
-					Success:  true,
-					Severity: "error",
-					Category: "Security",
-				},
-				{
-					ID:       "hostNetworkSet",
-					Message:  "Host network is not configured",
-					Success:  true,
-					Severity: "warning",
-					Category: "Networking",
-				},
-			},
-			ContainerChecks: map[string][]v1alpha1.Check{
-				"db": {
-					{
-						ID:       "cpuLimitsMissing",
-						Message:  "CPU limits are set",
-						Success:  true,
-						Severity: "warning",
-						Category: "Resources",
-					},
-					{
-						ID:       "cpuRequestsMissing",
-						Message:  "CPU requests are set",
-						Success:  true,
-						Severity: "warning",
-						Category: "Resources",
-					},
-				},
-			},
-		},
-	}, reports)
-}
diff --git a/pkg/polaris/plugin_test.go b/pkg/polaris/plugin_test.go
new file mode 100644
index 0000000..77b92a8
--- /dev/null
+++ b/pkg/polaris/plugin_test.go
@@ -0,0 +1,177 @@
+package polaris_test
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	"github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
+	"github.com/aquasecurity/starboard/pkg/ext"
+	"github.com/aquasecurity/starboard/pkg/kube"
+	"github.com/aquasecurity/starboard/pkg/polaris"
+	"github.com/aquasecurity/starboard/pkg/starboard"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/utils/pointer"
+)
+
+var (
+	fixedTime  = time.Now()
+	fixedClock = ext.NewFixedClock(fixedTime)
+)
+
+func TestPlugin_GetScanJobSpec(t *testing.T) {
+	testCases := []struct {
+		name string
+
+		config   starboard.ConfigData
+		workload kube.Object
+		gvk      schema.GroupVersionKind
+
+		expectedJobSpec corev1.PodSpec
+	}{
+		{
+			name: "Should return job spec for Deployment",
+			config: starboard.ConfigData{
+				"polaris.imageRef": "quay.io/fairwinds/polaris:3.0",
+			},
+			workload: kube.Object{
+				Name:      "nginx",
+				Namespace: corev1.NamespaceDefault,
+				Kind:      kube.KindDeployment,
+			},
+			gvk: schema.GroupVersionKind{
+				Group:   "apps",
+				Version: "v1",
+				Kind:    "Deployment",
+			},
+			expectedJobSpec: corev1.PodSpec{
+				ServiceAccountName:           starboard.ServiceAccountName,
+				AutomountServiceAccountToken: pointer.BoolPtr(true),
+				RestartPolicy:                corev1.RestartPolicyNever,
+				Affinity:                     starboard.LinuxNodeAffinity(),
+				Volumes: []corev1.Volume{
+					{
+						Name: "config",
+						VolumeSource: corev1.VolumeSource{
+							ConfigMap: &corev1.ConfigMapVolumeSource{
+								LocalObjectReference: corev1.LocalObjectReference{
+									Name: starboard.ConfigMapName,
+								},
+							},
+						},
+					},
+				},
+				Containers: []corev1.Container{
+					{
+						Name:                     "polaris",
+						Image:                    "quay.io/fairwinds/polaris:3.0",
+						ImagePullPolicy:          corev1.PullIfNotPresent,
+						TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError,
+						Resources: corev1.ResourceRequirements{
+							Limits: corev1.ResourceList{
+								corev1.ResourceCPU:    resource.MustParse("300m"),
+								corev1.ResourceMemory: resource.MustParse("300M"),
+							},
+							Requests: corev1.ResourceList{
+								corev1.ResourceCPU:    resource.MustParse("50m"),
+								corev1.ResourceMemory: resource.MustParse("50M"),
+							},
+						},
+						VolumeMounts: []corev1.VolumeMount{
+							{
+								Name:      "config",
+								MountPath: "/etc/starboard",
+							},
+						},
+						Command: []string{"polaris"},
+						Args: []string{
+							"audit",
+							"--log-level", "error",
+							"--config", "/etc/starboard/polaris.config.yaml",
+							"--resource", "default/Deployment.apps/v1/nginx",
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			plugin := polaris.NewPlugin(fixedClock, tc.config)
+			jobSpec, err := plugin.GetScanJobSpec(tc.workload, tc.gvk)
+			require.NoError(t, err, tc.name)
+			assert.Equal(t, tc.expectedJobSpec, jobSpec, tc.name)
+		})
+	}
+
+}
+
+func TestPlugin_GetContainerName(t *testing.T) {
+	plugin := polaris.NewPlugin(fixedClock, starboard.ConfigData{})
+	assert.Equal(t, "polaris", plugin.GetContainerName())
+}
+
+func TestPlugin_ParseConfigAuditResult(t *testing.T) {
+	testReport, err := os.Open("testdata/polaris-report.json")
+	require.NoError(t, err)
+	defer func() {
+		_ = testReport.Close()
+	}()
+
+	config := starboard.ConfigData{
+		"polaris.imageRef": "quay.io/fairwinds/polaris:3.0",
+	}
+	plugin := polaris.NewPlugin(fixedClock, config)
+	result, err := plugin.ParseConfigAuditResult(testReport)
+	require.NoError(t, err)
+	assert.Equal(t, metav1.NewTime(fixedTime), result.UpdateTimestamp)
+	assert.Equal(t, v1alpha1.Scanner{
+		Name:    "Polaris",
+		Vendor:  "Fairwinds Ops",
+		Version: "3.0",
+	}, result.Scanner)
+	assert.Equal(t, v1alpha1.ConfigAuditSummary{
+		DangerCount:  1,
+		WarningCount: 1,
+	}, result.Summary)
+	assert.ElementsMatch(t, []v1alpha1.Check{
+		{
+			ID:       "hostIPCSet",
+			Message:  "Host IPC is not configured",
+			Success:  false,
+			Severity: "danger",
+			Category: "Security",
+		},
+		{
+			ID:       "hostNetworkSet",
+			Message:  "Host network is not configured",
+			Success:  true,
+			Severity: "warning",
+			Category: "Networking",
+		},
+	}, result.PodChecks)
+	assert.Equal(t, map[string][]v1alpha1.Check{
+		"db": {
+			{
+				ID:       "cpuLimitsMissing",
+				Message:  "CPU limits are set",
+				Success:  false,
+				Severity: "warning",
+				Category: "Resources",
+			},
+			{
+				ID:       "cpuRequestsMissing",
+				Message:  "CPU requests are set",
+				Success:  true,
+				Severity: "warning",
+				Category: "Resources",
+			},
+		},
+	}, result.ContainerChecks)
+}
diff --git a/pkg/polaris/testdata/polaris-report.json b/pkg/polaris/testdata/polaris-report.json
index 8d88a1b..63806d8 100644
--- a/pkg/polaris/testdata/polaris-report.json
+++ b/pkg/polaris/testdata/polaris-report.json
@@ -23,8 +23,8 @@
           "hostIPCSet": {
             "ID": "hostIPCSet",
             "Message": "Host IPC is not configured",
-            "Success": true,
-            "Severity": "error",
+            "Success": false,
+            "Severity": "danger",
             "Category": "Security"
           },
           "hostNetworkSet": {
@@ -42,7 +42,7 @@
               "cpuLimitsMissing": {
                 "ID": "cpuLimitsMissing",
                 "Message": "CPU limits are set",
-                "Success": true,
+                "Success": false,
                 "Severity": "warning",
                 "Category": "Resources"
               },