Repository: aquasecurity/starboard
Pull request #417 (2021-03-04 08:51:13)

poc: Integrate Conftest as ConfigAuditReports scanner
Signed-off-by: Daniel Pacak <pacak.daniel@gmail.com>

codecov[bot]: # [Codecov](https://codecov.io/gh/aquasecurity/starboard/pull/417?src=pr&el=h1) Report
> Merging [#417](https://codecov.io/gh/aquasecurity/starboard/pull/417?src=pr&el=desc) (7ba3b95) into [main](https://codecov.io/gh/aquasecurity/starboard/commit/8cf7552d2fcf150cf0aa1930923891044eafd907?el=desc) (8cf7552) will **decrease** coverage by `38.54%`.
> The diff coverage is `1.29%`.

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

```diff
@@             Coverage Diff             @@
##             main     #417       +/-   ##
===========================================
- Coverage   67.17%   28.62%   -38.55%     
===========================================
  Files          59       59               
  Lines        3369     3416       +47     
===========================================
- Hits         2263      978     -1285     
- Misses        824     2377     +1553     
+ Partials      282       61      -221     
```


| [Impacted Files](https://codecov.io/gh/aquasecurity/starboard/pull/417?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pkg/cmd/polaris.go](https://codecov.io/gh/aquasecurity/starboard/pull/417/diff?src=pr&el=tree#diff-cGtnL2NtZC9wb2xhcmlzLmdv) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [pkg/cmd/root.go](https://codecov.io/gh/aquasecurity/starboard/pull/417/diff?src=pr&el=tree#diff-cGtnL2NtZC9yb290Lmdv) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [pkg/cmd/scan.go](https://codecov.io/gh/aquasecurity/starboard/pull/417/diff?src=pr&el=tree#diff-cGtnL2NtZC9zY2FuLmdv) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [pkg/cmd/scan\_configaudit.go](https://codecov.io/gh/aquasecurity/starboard/pull/417/diff?src=pr&el=tree#diff-cGtnL2NtZC9zY2FuX2NvbmZpZ2F1ZGl0Lmdv) | `0.00% <0.00%> (-57.90%)` | :arrow_down: |
| [pkg/config/config.go](https://codecov.io/gh/aquasecurity/starboard/pull/417/diff?src=pr&el=tree#diff-cGtnL2NvbmZpZy9jb25maWcuZ28=) | `0.00% <0.00%> (-50.00%)` | :arrow_down: |
| [pkg/conftest/plugin.go](https://codecov.io/gh/aquasecurity/starboard/pull/417/diff?src=pr&el=tree#diff-cGtnL2NvbmZ0ZXN0L3BsdWdpbi5nbw==) | `0.00% <0.00%> (ø)` | |
| [pkg/starboard/config.go](https://codecov.io/gh/aquasecurity/starboard/pull/417/diff?src=pr&el=tree#diff-cGtnL3N0YXJib2FyZC9jb25maWcuZ28=) | `65.18% <14.28%> (-5.89%)` | :arrow_down: |
| [pkg/cmd/get.go](https://codecov.io/gh/aquasecurity/starboard/pull/417/diff?src=pr&el=tree#diff-cGtnL2NtZC9nZXQuZ28=) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [pkg/cmd/find.go](https://codecov.io/gh/aquasecurity/starboard/pull/417/diff?src=pr&el=tree#diff-cGtnL2NtZC9maW5kLmdv) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [pkg/cmd/version.go](https://codecov.io/gh/aquasecurity/starboard/pull/417/diff?src=pr&el=tree#diff-cGtnL2NtZC92ZXJzaW9uLmdv) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| ... and [42 more](https://codecov.io/gh/aquasecurity/starboard/pull/417/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/aquasecurity/starboard/pull/417?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/417?src=pr&el=footer). Last update [8cf7552...dd43284](https://codecov.io/gh/aquasecurity/starboard/pull/417?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index de9c686..2690390 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -227,6 +227,8 @@ started with a basic development workflow. For other install modes see [Operator
      OPERATOR_CIS_KUBERNETES_BENCHMARK_ENABLED=true \
      OPERATOR_VULNERABILITY_SCANNER_ENABLED=true \
      OPERATOR_CONFIG_AUDIT_SCANNER_ENABLED=true \
+     OPERATOR_BATCH_DELETE_LIMIT=3 \
+     OPERATOR_BATCH_DELETE_DELAY="30s" \
      go run cmd/starboard-operator/main.go
    ```
 
diff --git a/Makefile b/Makefile
index 96e3ae4..031d017 100644
--- a/Makefile
+++ b/Makefile
@@ -72,6 +72,7 @@ itests-starboard: check-env get-ginkgo
 	github.com/aquasecurity/starboard/pkg/kubehunter,\
 	github.com/aquasecurity/starboard/pkg/plugin/trivy,\
 	github.com/aquasecurity/starboard/pkg/plugin/polaris,\
+	github.com/aquasecurity/starboard/pkg/plugin/conftest,\
 	github.com/aquasecurity/starboard/pkg/configauditreport,\
 	github.com/aquasecurity/starboard/pkg/vulnerabilityreport \
 	./itest/starboard
@@ -89,6 +90,7 @@ itests-starboard-operator: check-env get-ginkgo
 	github.com/aquasecurity/starboard/pkg/plugin,\
 	github.com/aquasecurity/starboard/pkg/plugin/trivy,\
 	github.com/aquasecurity/starboard/pkg/plugin/polaris,\
+	github.com/aquasecurity/starboard/pkg/plugin/conftest,\
 	github.com/aquasecurity/starboard/pkg/configauditreport,\
 	github.com/aquasecurity/starboard/pkg/vulnerabilityreport,\
 	github.com/aquasecurity/starboard/pkg/kubebench \
diff --git a/deploy/static/03-starboard-operator.clusterrole.yaml b/deploy/static/03-starboard-operator.clusterrole.yaml
index b8b7c70..a1d16d8 100644
--- a/deploy/static/03-starboard-operator.clusterrole.yaml
+++ b/deploy/static/03-starboard-operator.clusterrole.yaml
@@ -81,6 +81,7 @@ rules:
       - watch
       - create
       - update
+      - delete
   - apiGroups:
       - coordination.k8s.io
     resources:
diff --git a/deploy/static/05-starboard-operator.config.yaml b/deploy/static/05-starboard-operator.config.yaml
index aeca9ef..c1e956f 100644
--- a/deploy/static/05-starboard-operator.config.yaml
+++ b/deploy/static/05-starboard-operator.config.yaml
@@ -6,6 +6,7 @@ metadata:
   namespace: starboard-operator
 data:
   vulnerabilityReports.scanner: Trivy
+  configAuditReports.scanner: Polaris
   trivy.severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL
   trivy.imageRef: docker.io/aquasec/trivy:0.16.0
   trivy.mode: Standalone
@@ -195,6 +196,7 @@ data:
         - kube-hunter
         rules:
         - runAsRootAllowed
+  conftest.imageRef: openpolicyagent/conftest:v0.23.0
   kube-bench.imageRef: docker.io/aquasec/kube-bench:0.5.0
 ---
 apiVersion: v1
diff --git a/pkg/cmd/scan_vulnerabilities.go b/pkg/cmd/scan_vulnerabilities.go
index 263741e..448aa2f 100644
--- a/pkg/cmd/scan_vulnerabilities.go
+++ b/pkg/cmd/scan_vulnerabilities.go
@@ -98,11 +98,17 @@ func ScanVulnerabilityReports(buildInfo starboard.BuildInfo, cf *genericclioptio
 		if err != nil {
 			return err
 		}
-		instance, err := plugin.GetVulnerabilityReportPlugin(buildInfo, starboardConfig)
+		plugin, err := plugin.NewResolver().
+			WithBuildInfo(buildInfo).
+			WithNamespace(starboard.NamespaceName).
+			WithServiceAccountName(starboard.ServiceAccountName).
+			WithConfig(starboardConfig).
+			WithClient(kubeClient).
+			GetVulnerabilityPlugin()
 		if err != nil {
 			return err
 		}
-		scanner := vulnerabilityreport.NewScanner(kubeClientset, kubeClient, opts, instance)
+		scanner := vulnerabilityreport.NewScanner(kubeClientset, kubeClient, opts, plugin)
 		reports, err := scanner.Scan(ctx, workload)
 		if err != nil {
 			return err
diff --git a/pkg/configauditreport/builder.go b/pkg/configauditreport/builder.go
index 95e9ed7..63d5cd0 100644
--- a/pkg/configauditreport/builder.go
+++ b/pkg/configauditreport/builder.go
@@ -14,6 +14,7 @@ import (
 type Builder interface {
 	Controller(controller metav1.Object) Builder
 	PodSpecHash(hash string) Builder
+	PluginConfigHash(hash string) Builder
 	Result(result v1alpha1.ConfigAuditResult) Builder
 	Get() (v1alpha1.ConfigAuditReport, error)
 }
@@ -28,6 +29,7 @@ type builder struct {
 	scheme     *runtime.Scheme
 	controller metav1.Object
 	hash       string
+	configHash string
 	result     v1alpha1.ConfigAuditResult
 }
 
@@ -41,6 +43,11 @@ func (b *builder) PodSpecHash(hash string) Builder {
 	return b
 }
 
+func (b *builder) PluginConfigHash(hash string) Builder {
+	b.configHash = hash
+	return b
+}
+
 func (b *builder) Result(result v1alpha1.ConfigAuditResult) Builder {
 	b.result = result
 	return b
@@ -71,6 +78,10 @@ func (b *builder) Get() (v1alpha1.ConfigAuditReport, error) {
 		labels[kube.LabelPodSpecHash] = b.hash
 	}
 
+	if b.configHash != "" {
+		labels[kube.LabelPluginConfigHash] = b.configHash
+	}
+
 	reportName, err := b.reportName()
 	if err != nil {
 		return v1alpha1.ConfigAuditReport{}, err
diff --git a/pkg/configauditreport/doc.go b/pkg/configauditreport/doc.go
index fd03de4..c591f01 100644
--- a/pkg/configauditreport/doc.go
+++ b/pkg/configauditreport/doc.go
@@ -1,2 +1,2 @@
-// This package provides primitives for working with Kubernetes workload configuration checkers.
+// The configauditreport package provides primitives for working with Kubernetes workload configuration checkers.
 package configauditreport
diff --git a/pkg/configauditreport/plugin.go b/pkg/configauditreport/plugin.go
index 708f4ca..f866cd4 100644
--- a/pkg/configauditreport/plugin.go
+++ b/pkg/configauditreport/plugin.go
@@ -28,4 +28,8 @@ type Plugin interface {
 	// GetContainerName returns the name of the container in a pod created by a scan job
 	// to read logs from.
 	GetContainerName() string
+
+	// GetConfigHash returns hash of the plugin's configuration settings. The computed hash
+	// is used to invalidate v1alpha1.ConfigAuditReport object whenever configuration changes.
+	GetConfigHash(ctx starboard.PluginContext) (string, error)
 }
diff --git a/pkg/kube/starboard.go b/pkg/kube/starboard.go
index f44fe6d..57912fd 100644
--- a/pkg/kube/starboard.go
+++ b/pkg/kube/starboard.go
@@ -12,8 +12,9 @@ const (
 	LabelResourceName      = "starboard.resource.name"
 	LabelResourceNamespace = "starboard.resource.namespace"
 
-	LabelContainerName = "starboard.container.name"
-	LabelPodSpecHash   = "pod-spec-hash"
+	LabelContainerName    = "starboard.container.name"
+	LabelPodSpecHash      = "pod-spec-hash"
+	LabelPluginConfigHash = "plugin-config-hash"
 
 	LabelConfigAuditReportScan   = "configAuditReport.scanner"
 	LabelVulnerabilityReportScan = "vulnerabilityReport.scanner"
diff --git a/pkg/operator/controller/configauditreport.go b/pkg/operator/controller/configauditreport.go
index 4eb7931..3332a85 100644
--- a/pkg/operator/controller/configauditreport.go
+++ b/pkg/operator/controller/configauditreport.go
@@ -120,11 +120,15 @@ func (r *ConfigAuditReportReconciler) reconcileWorkload(workloadKind kube.Kind)
 			return ctrl.Result{}, err
 		}
 		podSpecHash := kube.ComputeHash(podSpec)
+		pluginConfigHash, err := r.Plugin.GetConfigHash(r.PluginContext)
+		if err != nil {
+			return ctrl.Result{}, err
+		}
 
-		log = log.WithValues("podSpecHash", podSpecHash)
+		log = log.WithValues("podSpecHash", podSpecHash, "pluginConfigHash", pluginConfigHash)
 
 		log.V(1).Info("Checking whether configuration audit report exists")
-		hasReport, err := r.hasReport(ctx, workloadPartial, podSpecHash)
+		hasReport, err := r.hasReport(ctx, workloadPartial, podSpecHash, pluginConfigHash)
 		if err != nil {
 			return ctrl.Result{}, err
 		}
@@ -194,15 +198,14 @@ func (r *ConfigAuditReportReconciler) reconcileWorkload(workloadKind kube.Kind)
 	}
 }
 
-func (r *ConfigAuditReportReconciler) hasReport(ctx context.Context, owner kube.Object, hash string) (bool, error) {
+func (r *ConfigAuditReportReconciler) hasReport(ctx context.Context, owner kube.Object, podSpecHash string, pluginConfigHash string) (bool, error) {
 	report, err := r.ReadWriter.FindByOwner(ctx, owner)
 	if err != nil {
 		return false, err
 	}
 	if report != nil {
-		if report.Labels[kube.LabelPodSpecHash] == hash {
-			return true, nil
-		}
+		return report.Labels[kube.LabelPodSpecHash] == podSpecHash &&
+			report.Labels[kube.LabelPluginConfigHash] == pluginConfigHash, nil
 	}
 	return false, nil
 }
@@ -227,13 +230,18 @@ func (r *ConfigAuditReportReconciler) getScanJobName(workload kube.Object) strin
 	return fmt.Sprintf("scan-configauditreport-%s", kube.ComputeHash(workload))
 }
 
-func (r *ConfigAuditReportReconciler) getScanJob(workload kube.Object, obj client.Object, hash string) (*batchv1.Job, []*corev1.Secret, error) {
+func (r *ConfigAuditReportReconciler) getScanJob(workload kube.Object, obj client.Object, podSpecHash string) (*batchv1.Job, []*corev1.Secret, error) {
 	jobSpec, secrets, err := r.Plugin.GetScanJobSpec(r.PluginContext, obj)
 
 	if err != nil {
 		return nil, nil, err
 	}
 
+	configHash, err := r.Plugin.GetConfigHash(r.PluginContext)
+	if err != nil {
+		return nil, nil, err
+	}
+
 	return &batchv1.Job{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      r.getScanJobName(workload),
@@ -243,7 +251,8 @@ func (r *ConfigAuditReportReconciler) getScanJob(workload kube.Object, obj clien
 				kube.LabelResourceName:          workload.Name,
 				kube.LabelResourceNamespace:     workload.Namespace,
 				kube.LabelK8SAppManagedBy:       kube.AppStarboardOperator,
-				kube.LabelPodSpecHash:           hash,
+				kube.LabelPodSpecHash:           podSpecHash,
+				kube.LabelPluginConfigHash:      configHash,
 				kube.LabelConfigAuditReportScan: "true",
 			},
 		},
@@ -258,7 +267,8 @@ func (r *ConfigAuditReportReconciler) getScanJob(workload kube.Object, obj clien
 						kube.LabelResourceName:          workload.Name,
 						kube.LabelResourceNamespace:     workload.Namespace,
 						kube.LabelK8SAppManagedBy:       kube.AppStarboardOperator,
-						kube.LabelPodSpecHash:           hash,
+						kube.LabelPodSpecHash:           podSpecHash,
+						kube.LabelPluginConfigHash:      configHash,
 						kube.LabelConfigAuditReportScan: "true",
 					},
 				},
@@ -321,8 +331,12 @@ func (r *ConfigAuditReportReconciler) processCompleteScanJob(ctx context.Context
 	if !ok {
 		return fmt.Errorf("expected label %s not set", kube.LabelPodSpecHash)
 	}
+	pluginConfigHash, ok := job.Labels[kube.LabelPluginConfigHash]
+	if !ok {
+		return fmt.Errorf("expected label %s not set", kube.LabelPluginConfigHash)
+	}
 
-	hasConfigAuditReport, err := r.hasReport(ctx, owner, podSpecHash)
+	hasConfigAuditReport, err := r.hasReport(ctx, owner, podSpecHash, pluginConfigHash)
 	if err != nil {
 		return err
 	}
@@ -346,6 +360,7 @@ func (r *ConfigAuditReportReconciler) processCompleteScanJob(ctx context.Context
 	report, err := configauditreport.NewBuilder(r.Client.Scheme()).
 		Controller(ownerObj).
 		PodSpecHash(podSpecHash).
+		PluginConfigHash(pluginConfigHash).
 		Result(result).
 		Get()
 	if err != nil {
diff --git a/pkg/operator/controller/plugins_config.go b/pkg/operator/controller/plugins_config.go
new file mode 100644
index 0000000..40857ff
--- /dev/null
+++ b/pkg/operator/controller/plugins_config.go
@@ -0,0 +1,95 @@
+package controller
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"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/operator/etc"
+	"github.com/aquasecurity/starboard/pkg/operator/predicate"
+	"github.com/aquasecurity/starboard/pkg/starboard"
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/labels"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/builder"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+type PluginsConfigReconciler struct {
+	logr.Logger
+	etc.Config
+	client.Client
+	starboard.PluginContext
+	configauditreport.Plugin
+}
+
+func (r *PluginsConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&corev1.ConfigMap{}, builder.WithPredicates(
+			predicate.Not(predicate.IsBeingTerminated),
+			predicate.InNamespace(r.Config.Namespace))).
+		Complete(r)
+}
+
+func (r *PluginsConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	log := r.Logger.WithValues("configMap", req.NamespacedName)
+
+	// TODO Use Predicate instead
+	if req.Name != strings.ToLower("starboard-"+r.PluginContext.GetName()+"config") {
+		return ctrl.Result{}, nil
+	}
+
+	cm := &corev1.ConfigMap{}
+
+	err := r.Client.Get(ctx, req.NamespacedName, cm)
+	if err != nil {
+		if errors.IsNotFound(err) {
+			log.V(1).Info("Ignoring cached ConfigMap that must have been deleted")
+			return ctrl.Result{}, nil
+		}
+		return ctrl.Result{}, fmt.Errorf("getting ConfigMap from cache: %w", err)
+	}
+
+	configHash, err := r.Plugin.GetConfigHash(r.PluginContext)
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+
+	labelSelector, err := labels.Parse(fmt.Sprintf("%s != %s", kube.LabelPluginConfigHash, configHash))
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+
+	var reportList v1alpha1.ConfigAuditReportList
+	err = r.Client.List(ctx, &reportList,
+		client.Limit(r.Config.BatchDeleteLimit+1), // TODO The limit is not respected https://github.com/kubernetes-sigs/controller-runtime/issues/1422
+		client.MatchingLabelsSelector{Selector: labelSelector})
+	if err != nil {
+		return ctrl.Result{}, fmt.Errorf("listing reports: %w", err)
+	}
+
+	log.V(1).Info("Listing ConfigAuditReports",
+		"reportsCount", len(reportList.Items),
+		"batchDeleteLimit", r.Config.BatchDeleteLimit)
+
+	for i := 0; i < ext.MinInt(r.Config.BatchDeleteLimit, len(reportList.Items)); i++ {
+		report := reportList.Items[i]
+		log.V(1).Info("Deleting ConfigAuditReport", "report", report.Namespace+"/"+report.Name)
+		err := r.Client.Delete(ctx, &report)
+		if err != nil {
+			return ctrl.Result{}, err
+		}
+	}
+	if len(reportList.Items)-r.Config.BatchDeleteLimit > 0 {
+		// TODO Calculate RequeueAfter based on average scan duration?
+		return ctrl.Result{RequeueAfter: r.Config.BatchDeleteDelay}, nil
+	}
+
+	return ctrl.Result{}, nil
+}
diff --git a/pkg/operator/etc/config.go b/pkg/operator/etc/config.go
index 3dd0de0..f6885e7 100644
--- a/pkg/operator/etc/config.go
+++ b/pkg/operator/etc/config.go
@@ -23,6 +23,8 @@ type Config struct {
 	ConfigAuditScannerEnabled     bool          `env:"OPERATOR_CONFIG_AUDIT_SCANNER_ENABLED" envDefault:"true"`
 	LeaderElectionEnabled         bool          `env:"OPERATOR_LEADER_ELECTION_ENABLED" envDefault:"false"`
 	LeaderElectionID              string        `env:"OPERATOR_LEADER_ELECTION_ID" envDefault:"starboard-operator"`
+	BatchDeleteLimit              int           `env:"OPERATOR_BATCH_DELETE_LIMIT" envDefault:"10"`
+	BatchDeleteDelay              time.Duration `env:"OPERATOR_BATCH_DELETE_DELAY" envDefault:"10s"`
 }
 
 func GetOperatorConfig() (Config, error) {
diff --git a/pkg/operator/operator.go b/pkg/operator/operator.go
index 6335e1c..9848067 100644
--- a/pkg/operator/operator.go
+++ b/pkg/operator/operator.go
@@ -180,6 +180,16 @@ func Run(buildInfo starboard.BuildInfo, operatorConfig etc.Config) error {
 		}).SetupWithManager(mgr); err != nil {
 			return fmt.Errorf("unable to setup configauditreport reconciler: %w", err)
 		}
+
+		if err = (&controller.PluginsConfigReconciler{
+			Logger:        ctrl.Log.WithName("reconciler").WithName("pluginsconfig"),
+			Config:        operatorConfig,
+			Client:        mgr.GetClient(),
+			Plugin:        plugin,
+			PluginContext: pluginContext,
+		}).SetupWithManager(mgr); err != nil {
+			return fmt.Errorf("unable to setup %T: %w", controller.PluginsConfigReconciler{}, err)
+		}
 	}
 
 	if operatorConfig.CISKubernetesBenchmarkEnabled {
diff --git a/pkg/plugin/factory.go b/pkg/plugin/factory.go
index 3d40abe..3bfe7ba 100644
--- a/pkg/plugin/factory.go
+++ b/pkg/plugin/factory.go
@@ -6,6 +6,7 @@ import (
 	"github.com/aquasecurity/starboard/pkg/configauditreport"
 	"github.com/aquasecurity/starboard/pkg/ext"
 	"github.com/aquasecurity/starboard/pkg/plugin/aqua"
+	"github.com/aquasecurity/starboard/pkg/plugin/conftest"
 	"github.com/aquasecurity/starboard/pkg/plugin/polaris"
 	"github.com/aquasecurity/starboard/pkg/plugin/trivy"
 	"github.com/aquasecurity/starboard/pkg/starboard"
@@ -50,21 +51,7 @@ func (r *Resolver) WithClient(client client.Client) *Resolver {
 	return r
 }
 
-func (r *Resolver) GetVulnerabilityPlugin() (vulnerabilityreport.Plugin, error) {
-	return GetVulnerabilityReportPlugin(r.buildInfo, r.config)
-}
-
-func (r *Resolver) GetConfigAuditPlugin() (configauditreport.Plugin, starboard.PluginContext, error) {
-	return polaris.NewPlugin(ext.NewSystemClock(), r.config), starboard.NewPluginContext().
-			WithName("Polaris").
-			WithNamespace(r.namespace).
-			WithServiceAccountName(r.serviceAccountName).
-			WithClient(r.client).
-			Build(),
-		nil
-}
-
-// GetVulnerabilityReportPlugin is a factory method that instantiates the
+// GetVulnerabilityPlugin is a factory method that instantiates the
 // vulnerabilityreport.Plugin for the specified starboard.ConfigData.
 //
 // Starboard currently supports Trivy scanner in Standalone and ClientServer
@@ -72,29 +59,45 @@ func (r *Resolver) GetConfigAuditPlugin() (configauditreport.Plugin, starboard.P
 //
 // You could add your own scanner by implementing the
 // vulnerabilityreport.Plugin interface.
-// Deprecated
-func GetVulnerabilityReportPlugin(buildInfo starboard.BuildInfo, config starboard.ConfigData) (vulnerabilityreport.Plugin, error) {
-	scanner, err := config.GetVulnerabilityReportsScanner()
+func (r *Resolver) GetVulnerabilityPlugin() (vulnerabilityreport.Plugin, error) {
+	scanner, err := r.config.GetVulnerabilityReportsScanner()
 	if err != nil {
 		return nil, err
 	}
 	switch scanner {
 	case starboard.Trivy:
-		return trivy.NewPlugin(ext.NewGoogleUUIDGenerator(), config), nil
+		return trivy.NewPlugin(ext.NewGoogleUUIDGenerator(), r.config), nil
 	case starboard.Aqua:
-		return aqua.NewPlugin(ext.NewGoogleUUIDGenerator(), buildInfo, config), nil
+		return aqua.NewPlugin(ext.NewGoogleUUIDGenerator(), r.buildInfo, r.config), nil
 	}
 	return nil, fmt.Errorf("unsupported vulnerability scanner plugin: %s", scanner)
 }
 
-// GetConfigAuditReportPlugin is a factory method that instantiates the
+// GetConfigAuditPlugin is a factory method that instantiates the
 // configauditreport.Plugin for the specified starboard.ConfigData.
 //
 // Starboard supports Polaris as the only configuration auditing tool.
 //
 // You could add your own scanner by implementing the configauditreport.Plugin
 // interface.
-// Deprecated
-func GetConfigAuditReportPlugin(_ starboard.BuildInfo, config starboard.ConfigData) (configauditreport.Plugin, error) {
-	return polaris.NewPlugin(ext.NewSystemClock(), config), nil
+func (r *Resolver) GetConfigAuditPlugin() (configauditreport.Plugin, starboard.PluginContext, error) {
+	scanner, err := r.config.GetConfigAuditReportsScanner()
+	if err != nil {
+		return nil, nil, err
+	}
+
+	pluginContext := starboard.NewPluginContext().
+		WithName(string(scanner)).
+		WithNamespace(r.namespace).
+		WithServiceAccountName(r.serviceAccountName).
+		WithClient(r.client).
+		Build()
+
+	switch scanner {
+	case starboard.Polaris:
+		return polaris.NewPlugin(ext.NewSystemClock(), r.config), pluginContext, nil
+	case starboard.Conftest:
+		return conftest.NewPlugin(ext.NewGoogleUUIDGenerator(), ext.NewSystemClock(), r.config), pluginContext, nil
+	}
+	return nil, nil, fmt.Errorf("unsupported configuration audit scanner plugin: %s", scanner)
 }
diff --git a/pkg/plugin/polaris/plugin.go b/pkg/plugin/polaris/plugin.go
index 1dd1c4c..993fc5a 100644
--- a/pkg/plugin/polaris/plugin.go
+++ b/pkg/plugin/polaris/plugin.go
@@ -8,6 +8,7 @@ import (
 	"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"
@@ -39,6 +40,11 @@ func NewPlugin(clock ext.Clock, config Config) configauditreport.Plugin {
 	}
 }
 
+func (p *plugin) GetConfigHash(_ starboard.PluginContext) (string, error) {
+	// TODO Compute config hash based on Polaris config
+	return kube.ComputeHash("TODO"), nil
+}
+
 func (p *plugin) GetScanJobSpec(ctx starboard.PluginContext, obj client.Object) (corev1.PodSpec, []*corev1.Secret, error) {
 	imageRef, err := p.config.GetPolarisImageRef()
 	if err != nil {
diff --git a/pkg/starboard/config.go b/pkg/starboard/config.go
index cd5f2ee..6630510 100644
--- a/pkg/starboard/config.go
+++ b/pkg/starboard/config.go
@@ -229,8 +229,10 @@ type BuildInfo struct {
 type Scanner string
 
 const (
-	Trivy Scanner = "Trivy"
-	Aqua  Scanner = "Aqua"
+	Trivy    Scanner = "Trivy"
+	Aqua     Scanner = "Aqua"
+	Polaris  Scanner = "Polaris"
+	Conftest Scanner = "Conftest"
 )
 
 // TrivyMode describes mode in which Trivy client operates.
@@ -243,6 +245,7 @@ const (
 
 const (
 	keyVulnerabilityReportsScanner = "vulnerabilityReports.scanner"
+	keyConfigAuditReportsScanner   = "configAuditReports.scanner"
 
 	keyTrivyMode      = "trivy.mode"
 	keyTrivyServerURL = "trivy.serverURL"
@@ -263,6 +266,7 @@ type ConfigManager interface {
 func GetDefaultConfig() ConfigData {
 	return map[string]string{
 		keyVulnerabilityReportsScanner: string(Trivy),
+		keyConfigAuditReportsScanner:   string(Polaris),
 
 		"trivy.severity": "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL",
 		"trivy.imageRef": "docker.io/aquasec/trivy:0.16.0",
@@ -275,6 +279,8 @@ func GetDefaultConfig() ConfigData {
 
 		"polaris.imageRef":    "quay.io/fairwinds/polaris:3.2",
 		"polaris.config.yaml": polarisConfigYAML,
+
+		"conftest.imageRef": "openpolicyagent/conftest:v0.23.0",
 	}
 }
 
@@ -296,6 +302,23 @@ func (c ConfigData) GetVulnerabilityReportsScanner() (Scanner, error) {
 		value, keyVulnerabilityReportsScanner, Trivy, Aqua)
 }
 
+func (c ConfigData) GetConfigAuditReportsScanner() (Scanner, error) {
+	var ok bool
+	var value string
+	if value, ok = c[keyConfigAuditReportsScanner]; !ok {
+		return "", fmt.Errorf("property %s not set", keyConfigAuditReportsScanner)
+	}
+
+	switch Scanner(value) {
+	case Polaris:
+		return Polaris, nil
+	case Conftest:
+		return Conftest, nil
+	}
+	return "", fmt.Errorf("invalid value (%s) of %s; allowed values (%s, %s)",
+		value, keyConfigAuditReportsScanner, Polaris, Conftest)
+}
+
 func (c ConfigData) GetTrivyImageRef() (string, error) {
 	return c.getRequiredProperty("trivy.imageRef")
 }
@@ -349,6 +372,10 @@ func (c ConfigData) GetPolarisImageRef() (string, error) {
 	return c.getRequiredProperty("polaris.imageRef")
 }
 
+func (c ConfigData) GetConftestImageRef() (string, error) {
+	return c.getRequiredProperty("conftest.imageRef")
+}
+
 func (c ConfigData) getRequiredProperty(key string) (string, error) {
 	var ok bool
 	var value string
diff --git a/pkg/vulnerabilityreport/scanner.go b/pkg/vulnerabilityreport/scanner.go
index d4f2752..922d80a 100644
--- a/pkg/vulnerabilityreport/scanner.go
+++ b/pkg/vulnerabilityreport/scanner.go
@@ -164,7 +164,7 @@ func (s *Scanner) prepareScanJob(workload kube.Object, spec corev1.PodSpec, cred
 // passing owner directly. The goal is for CLI and operator to create jobs
 // with the same struct and set of labels to reuse code responsible for parsing
 // v1alpha1.VulnerabilityReport instances.
-func (s *Scanner) getVulnerabilityReportsByScanJob(ctx context.Context, job *batchv1.Job, owner metav1.Object) ([]v1alpha1.VulnerabilityReport, error) {
+func (s *Scanner) getVulnerabilityReportsByScanJob(ctx context.Context, job *batchv1.Job, owner client.Object) ([]v1alpha1.VulnerabilityReport, error) {
 	var reports []v1alpha1.VulnerabilityReport
 
 	containerImages, err := kube.GetContainerImagesFromJob(job)


Test patch:
diff --git a/pkg/configauditreport/builder_test.go b/pkg/configauditreport/builder_test.go
index d192e46..a48ca35 100644
--- a/pkg/configauditreport/builder_test.go
+++ b/pkg/configauditreport/builder_test.go
@@ -5,8 +5,7 @@ import (
 
 	"github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1"
 	"github.com/aquasecurity/starboard/pkg/configauditreport"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
+	"github.com/onsi/gomega"
 	appsv1 "k8s.io/api/apps/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes/scheme"
@@ -14,6 +13,8 @@ import (
 )
 
 func TestBuilder(t *testing.T) {
+	g := gomega.NewGomegaWithT(t)
+
 	report, err := configauditreport.NewBuilder(scheme.Scheme).
 		Controller(&appsv1.ReplicaSet{
 			ObjectMeta: metav1.ObjectMeta{
@@ -22,10 +23,11 @@ func TestBuilder(t *testing.T) {
 			},
 		}).
 		PodSpecHash("xyz").
+		PluginConfigHash("nop").
 		Result(v1alpha1.ConfigAuditResult{}).Get()
 
-	require.NoError(t, err)
-	assert.Equal(t, v1alpha1.ConfigAuditReport{
+	g.Expect(err).ToNot(gomega.HaveOccurred())
+	g.Expect(report).To(gomega.Equal(v1alpha1.ConfigAuditReport{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      "replicaset-some-owner",
 			Namespace: "qa",
@@ -43,8 +45,9 @@ func TestBuilder(t *testing.T) {
 				"starboard.resource.name":      "some-owner",
 				"starboard.resource.namespace": "qa",
 				"pod-spec-hash":                "xyz",
+				"plugin-config-hash":           "nop",
 			},
 		},
 		Report: v1alpha1.ConfigAuditResult{},
-	}, report)
+	}))
 }
diff --git a/pkg/plugin/conftest/doc.go b/pkg/plugin/conftest/doc.go
new file mode 100644
index 0000000..428ad00
--- /dev/null
+++ b/pkg/plugin/conftest/doc.go
@@ -0,0 +1,2 @@
+// The conftest package provides primitives for working with Conftest.
+package conftest
diff --git a/pkg/plugin/conftest/plugin.go b/pkg/plugin/conftest/plugin.go
new file mode 100644
index 0000000..af95c9a
--- /dev/null
+++ b/pkg/plugin/conftest/plugin.go
@@ -0,0 +1,270 @@
+package conftest
+
+import (
+	"encoding/json"
+	"io"
+	"strings"
+
+	"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/utils/pointer"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/yaml"
+)
+
+const (
+	conftestContainerName = "conftest"
+)
+
+type Config interface {
+	GetConftestImageRef() (string, error)
+}
+
+type plugin struct {
+	idGenerator ext.IDGenerator
+	clock       ext.Clock
+	config      Config
+}
+
+// NewPlugin constructs a new configauditreport.Plugin, which is using an
+// official Conftest container image to audit Kubernetes workloads.
+func NewPlugin(idGenerator ext.IDGenerator, clock ext.Clock, config Config) configauditreport.Plugin {
+	return &plugin{
+		idGenerator: idGenerator,
+		clock:       clock,
+		config:      config,
+	}
+}
+
+func (p *plugin) GetConfigHash(ctx starboard.PluginContext) (string, error) {
+	cm, err := ctx.GetConfig()
+	if err != nil {
+		return "", err
+	}
+	return kube.ComputeHash(cm.Data), nil
+}
+
+func (p *plugin) GetScanJobSpec(ctx starboard.PluginContext, obj client.Object) (corev1.PodSpec, []*corev1.Secret, error) {
+	imageRef, err := p.config.GetConftestImageRef()
+	if err != nil {
+		return corev1.PodSpec{}, nil, err
+	}
+
+	var secrets []*corev1.Secret
+
+	workloadAsYAML, err := yaml.Marshal(obj)
+	if err != nil {
+		return corev1.PodSpec{}, nil, err
+	}
+
+	secret := &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: p.idGenerator.GenerateID(),
+		},
+		StringData: map[string]string{
+			"workload.yaml": string(workloadAsYAML),
+		},
+	}
+
+	secrets = append(secrets, secret)
+
+	policies, err := p.getPolicies(ctx)
+	if err != nil {
+		return corev1.PodSpec{}, nil, err
+	}
+
+	var volumeMounts []corev1.VolumeMount
+	var volumeItems []corev1.KeyToPath
+
+	for _, control := range policies {
+
+		volumeItems = append(volumeItems, corev1.KeyToPath{
+			Key:  "conftest.policy." + control,
+			Path: control + ".rego",
+		})
+
+		volumeMounts = append(volumeMounts, corev1.VolumeMount{
+			Name:      "policies",
+			MountPath: "/project/policy/" + control + ".rego",
+			SubPath:   control + ".rego",
+		})
+
+	}
+
+	volumeMounts = append(volumeMounts, corev1.VolumeMount{
+		Name:      secret.Name,
+		MountPath: "/project/workload.yaml",
+		SubPath:   "workload.yaml",
+	})
+
+	return corev1.PodSpec{
+		ServiceAccountName:           ctx.GetServiceAccountName(),
+		AutomountServiceAccountToken: pointer.BoolPtr(false),
+		RestartPolicy:                corev1.RestartPolicyNever,
+		Affinity:                     starboard.LinuxNodeAffinity(),
+		Volumes: []corev1.Volume{
+			{
+				Name: "policies",
+				VolumeSource: corev1.VolumeSource{
+					ConfigMap: &corev1.ConfigMapVolumeSource{
+						LocalObjectReference: corev1.LocalObjectReference{
+							Name: "conftestconfig",
+						},
+						Items: volumeItems,
+					},
+				},
+			},
+			{
+				Name: secret.Name,
+				VolumeSource: corev1.VolumeSource{
+					Secret: &corev1.SecretVolumeSource{
+						SecretName: secret.Name,
+					},
+				},
+			},
+		},
+		Containers: []corev1.Container{
+			{
+				Name:                     conftestContainerName,
+				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: volumeMounts,
+				Command: []string{
+					"sh",
+				},
+				// TODO Follow up with Conftest maintainers to allow returning 0 exit code in case of failures
+				Args: []string{
+					"-c",
+					"conftest test --output json --all-namespaces --policy /project/policy /project/workload.yaml || true",
+				},
+				SecurityContext: &corev1.SecurityContext{
+					Privileged:               pointer.BoolPtr(false),
+					AllowPrivilegeEscalation: pointer.BoolPtr(false),
+					Capabilities: &corev1.Capabilities{
+						Drop: []corev1.Capability{"all"},
+					},
+					ReadOnlyRootFilesystem: pointer.BoolPtr(true),
+				},
+			},
+		},
+		SecurityContext: &corev1.PodSecurityContext{
+			RunAsUser:  pointer.Int64Ptr(1000),
+			RunAsGroup: pointer.Int64Ptr(1000),
+			SeccompProfile: &corev1.SeccompProfile{
+				Type: corev1.SeccompProfileTypeRuntimeDefault,
+			},
+		},
+	}, secrets, nil
+
+}
+
+func (p *plugin) getPolicies(ctx starboard.PluginContext) ([]string, error) {
+	cm, err := ctx.GetConfig()
+	if err != nil {
+		return nil, err
+	}
+
+	var policies []string
+
+	for key := range cm.Data {
+		if !strings.HasPrefix(key, "conftest.policy.") {
+			continue
+		}
+		policyName := strings.TrimPrefix(key, "conftest.policy.")
+		policies = append(policies, policyName)
+	}
+
+	return policies, nil
+}
+
+func (p *plugin) GetContainerName() string {
+	return conftestContainerName
+}
+
+const (
+	defaultCategory = "Security"
+)
+
+func (p *plugin) ParseConfigAuditReportData(logsReader io.ReadCloser) (v1alpha1.ConfigAuditResult, error) {
+	var checkResults []CheckResult
+	err := json.NewDecoder(logsReader).Decode(&checkResults)
+
+	checks := make([]v1alpha1.Check, 0)
+	var successesCount, warningCount, dangerCount int
+
+	for _, cr := range checkResults {
+		successesCount += cr.Successes
+
+		for _, warning := range cr.Warnings {
+			checks = append(checks, v1alpha1.Check{
+				ID:       p.getPolicyTitleFromResult(warning),
+				Severity: "WARNING",
+				Message:  warning.Message,
+				Category: defaultCategory,
+				Success:  false,
+			})
+			warningCount++
+		}
+
+		for _, failure := range cr.Failures {
+			checks = append(checks, v1alpha1.Check{
+				ID:       p.getPolicyTitleFromResult(failure),
+				Severity: "DANGER",
+				Message:  failure.Message,
+				Category: defaultCategory,
+			})
+			dangerCount++
+		}
+	}
+
+	imageRef, err := p.config.GetConftestImageRef()
+	if err != nil {
+		return v1alpha1.ConfigAuditResult{}, err
+	}
+
+	version, err := starboard.GetVersionFromImageRef(imageRef)
+	if err != nil {
+		return v1alpha1.ConfigAuditResult{}, err
+	}
+
+	return v1alpha1.ConfigAuditResult{
+		UpdateTimestamp: metav1.NewTime(p.clock.Now()),
+		Scanner: v1alpha1.Scanner{
+			Name:    "Conftest",
+			Vendor:  "Open Policy Agent",
+			Version: version,
+		},
+		Summary: v1alpha1.ConfigAuditSummary{
+			PassCount:    successesCount, // TODO This should be a pointer to tell 0 from nil
+			WarningCount: warningCount,
+			DangerCount:  dangerCount,
+		},
+		PodChecks:       checks,
+		ContainerChecks: map[string][]v1alpha1.Check{},
+	}, nil
+}
+
+func (p *plugin) getPolicyTitleFromResult(result Result) string {
+	if title, ok := result.Metadata["title"]; ok {
+		return title.(string)
+	}
+	// Fallback to a unique identifier
+	return p.idGenerator.GenerateID()
+}
diff --git a/pkg/plugin/conftest/plugin_test.go b/pkg/plugin/conftest/plugin_test.go
new file mode 100644
index 0000000..0c01dd4
--- /dev/null
+++ b/pkg/plugin/conftest/plugin_test.go
@@ -0,0 +1,137 @@
+package conftest_test
+
+import (
+	. "github.com/onsi/gomega"
+	. "github.com/onsi/gomega/gstruct"
+
+	"testing"
+	"time"
+
+	"github.com/aquasecurity/starboard/pkg/ext"
+	"github.com/aquasecurity/starboard/pkg/plugin/conftest"
+	"github.com/aquasecurity/starboard/pkg/starboard"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+var (
+	fixedTime  = time.Now()
+	fixedClock = ext.NewFixedClock(fixedTime)
+)
+
+func TestPlugin_GetScanJobSpec(t *testing.T) {
+	g := NewGomegaWithT(t)
+	sequence := ext.NewSimpleIDGenerator()
+	config := starboard.ConfigData{
+		"conftest.imageRef": "openpolicyagent/conftest:v0.23.0",
+	}
+	pluginContext := starboard.NewPluginContext().
+		WithName(string(starboard.Conftest)).
+		WithNamespace(starboard.NamespaceName).
+		WithServiceAccountName(starboard.ServiceAccountName).
+		WithClient(fake.NewClientBuilder().WithObjects(&corev1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "starboard-conftest-config",
+				Namespace: starboard.NamespaceName,
+			},
+			Data: map[string]string{
+				"conftest.policy.kubernetes.rego":   "<REGO>",
+				"conftest.policy.first_check.rego":  "<REGO>",
+				"conftest.policy.second_check.rego": "<REGO>",
+			},
+		}).Build()).Build()
+
+	plugin := conftest.NewPlugin(sequence, fixedClock, config)
+	jobSpec, secrets, err := plugin.GetScanJobSpec(pluginContext, &corev1.Pod{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "nginx",
+			Namespace: "default",
+		},
+		Spec: corev1.PodSpec{
+			Containers: []corev1.Container{
+				{
+					Name:  "nginx",
+					Image: "nginx:1.16",
+				},
+			},
+		},
+	})
+	g.Expect(err).ToNot(HaveOccurred())
+	g.Expect(secrets).To(HaveLen(1))
+	g.Expect(jobSpec).To(MatchFields(IgnoreExtras, Fields{
+		"ServiceAccountName":           Equal("starboard"),
+		"AutomountServiceAccountToken": PointTo(BeFalse()),
+		"RestartPolicy":                Equal(corev1.RestartPolicyNever),
+		"Affinity":                     Equal(starboard.LinuxNodeAffinity()),
+		// TODO Assert other properties
+	}))
+}
+
+func TestPlugin_GetConfigHash(t *testing.T) {
+
+	newPluginContextWithConfigData := func(data map[string]string) starboard.PluginContext {
+		return starboard.NewPluginContext().
+			WithName("Conftest").
+			WithNamespace("starboard-ns").
+			WithClient(fake.NewClientBuilder().
+				WithObjects(&corev1.ConfigMap{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "starboard-conftest-config",
+						Namespace: "starboard-ns",
+					},
+					Data: data,
+				}).
+				Build()).
+			Build()
+	}
+
+	t.Run("Should return different hash for different config data", func(t *testing.T) {
+		g := NewGomegaWithT(t)
+
+		pluginContext1 := newPluginContextWithConfigData(map[string]string{
+			"foo":   "bar",
+			"brown": "fox",
+		})
+		pluginContext2 := newPluginContextWithConfigData(map[string]string{
+			"brown": "fox",
+			"foo":   "baz",
+		})
+
+		plugin := conftest.NewPlugin(ext.NewSimpleIDGenerator(), fixedClock, starboard.ConfigData{})
+		hash1, err := plugin.GetConfigHash(pluginContext1)
+		g.Expect(err).ToNot(HaveOccurred())
+
+		hash2, err := plugin.GetConfigHash(pluginContext2)
+		g.Expect(err).ToNot(HaveOccurred())
+		g.Expect(hash1).ToNot(Equal(hash2))
+	})
+
+	t.Run("Should return the same hash for the same config data", func(t *testing.T) {
+		g := NewGomegaWithT(t)
+
+		pluginContext1 := newPluginContextWithConfigData(map[string]string{
+			"foo":   "bar",
+			"brown": "fox",
+		})
+		pluginContext2 := newPluginContextWithConfigData(map[string]string{
+			"brown": "fox",
+			"foo":   "bar",
+		})
+
+		plugin := conftest.NewPlugin(ext.NewSimpleIDGenerator(), fixedClock, starboard.ConfigData{})
+		hash1, err := plugin.GetConfigHash(pluginContext1)
+		g.Expect(err).ToNot(HaveOccurred())
+
+		hash2, err := plugin.GetConfigHash(pluginContext2)
+		g.Expect(err).ToNot(HaveOccurred())
+		g.Expect(hash1).To(Equal(hash2))
+	})
+}
+
+func TestPlugin_GetContainerName(t *testing.T) {
+	g := NewGomegaWithT(t)
+
+	plugin := conftest.NewPlugin(ext.NewSimpleIDGenerator(), fixedClock, starboard.ConfigData{})
+	g.Expect(plugin.GetContainerName()).To(Equal("conftest"))
+}
diff --git a/pkg/plugin/conftest/types.go b/pkg/plugin/conftest/types.go
new file mode 100644
index 0000000..9a9772d
--- /dev/null
+++ b/pkg/plugin/conftest/types.go
@@ -0,0 +1,37 @@
+package conftest
+
+// CheckResult describes the result of a conftest policy evaluation.
+// Errors produced by rego should be considered separate
+// from other classes of exceptions.
+type CheckResult struct {
+	FileName   string        `json:"filename"`
+	Namespace  string        `json:"namespace"`
+	Successes  int           `json:"successes"`
+	Warnings   []Result      `json:"warnings,omitempty"`
+	Failures   []Result      `json:"failures,omitempty"`
+	Exceptions []Result      `json:"exceptions,omitempty"`
+	Queries    []QueryResult `json:"queries,omitempty"`
+}
+
+// Result describes the result of a single rule evaluation.
+type Result struct {
+	Message  string                 `json:"msg"`
+	Metadata map[string]interface{} `json:"metadata,omitempty"`
+}
+
+// QueryResult describes the result of evaluting a query.
+type QueryResult struct {
+
+	// Query is the fully qualified query that was used
+	// to determine the result. Ex: (data.main.deny)
+	Query string `json:"query"`
+
+	// Results are the individual results of the query.
+	// When querying data.main.deny, multiple deny rules can
+	// exist, producing multiple results.
+	Results []Result `json:"results"`
+
+	// Traces represents a single trace of how the query was
+	// evaluated. Each trace value is a trace line.
+	Traces []string `json:"traces"`
+}
diff --git a/pkg/plugin/conftest/types_test.go b/pkg/plugin/conftest/types_test.go
new file mode 100644
index 0000000..3d438c7
--- /dev/null
+++ b/pkg/plugin/conftest/types_test.go
@@ -0,0 +1,1 @@
+package conftest_test