From 4a436eb2a8d23789c620a18c16814bd8edd08133 Mon Sep 17 00:00:00 2001 From: Frank Jogeleit Date: Fri, 16 Apr 2021 11:06:06 +0200 Subject: [PATCH] Policy report crd update (#24) * Add Support for Properties and Timestamp * Prevent resending violations after Kyverno cleanup --- CHANGELOG.md | 6 + charts/policy-reporter/Chart.yaml | 4 +- charts/policy-reporter/values.yaml | 2 +- go.mod | 1 + go.sum | 2 + .../cluster_policy_report_client.go | 33 +++- pkg/kubernetes/mapper.go | 28 +++- pkg/kubernetes/mapper_test.go | 24 ++- pkg/kubernetes/policy_report_client.go | 58 ++++++- pkg/kubernetes/report_client_test.go | 146 ++++++++++++++++++ pkg/metrics/cluster_policy_report.go | 72 ++++----- pkg/metrics/policy_report.go | 80 ++++------ pkg/report/model.go | 59 +++++-- pkg/report/model_test.go | 44 ++++++ pkg/target/discord/discord.go | 5 + pkg/target/discord/discord_test.go | 19 ++- .../elasticsearch/elasticsearch_test.go | 18 ++- pkg/target/loki/loki.go | 11 +- pkg/target/loki/loki_test.go | 22 ++- pkg/target/slack/slack.go | 19 +++ pkg/target/slack/slack_test.go | 19 ++- pkg/target/teams/teams.go | 12 +- pkg/target/teams/teams_test.go | 19 ++- 23 files changed, 525 insertions(+), 178 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8018a1f..48069d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.2.0 + +* Support for (Cluster)PolicyReport CRD Properties in Target Output +* Support for (Cluster)PolicyReport CRD Timestamp in Target Output +* Fix resend violations after Kyverno Cleanup with ResultHashes + ## 1.1.0 * Added PolicyReport Category to Metrics diff --git a/charts/policy-reporter/Chart.yaml b/charts/policy-reporter/Chart.yaml index 32d5cb5c..a0241305 100644 --- a/charts/policy-reporter/Chart.yaml +++ b/charts/policy-reporter/Chart.yaml @@ -5,8 +5,8 @@ description: | It creates Prometheus Metrics and can send rule validation events to different targets like Loki, Elasticsearch, Slack or Discord type: application -version: 1.1.0 -appVersion: 1.1.0 +version: 1.2.0 +appVersion: 1.2.0 dependencies: - name: monitoring diff --git a/charts/policy-reporter/values.yaml b/charts/policy-reporter/values.yaml index b083a51d..a9b7040b 100644 --- a/charts/policy-reporter/values.yaml +++ b/charts/policy-reporter/values.yaml @@ -1,7 +1,7 @@ image: repository: fjogeleit/policy-reporter pullPolicy: IfNotPresent - tag: 1.1.0 + tag: 1.2.0 imagePullSecrets: [] diff --git a/go.mod b/go.mod index 510f9cd6..64488f7e 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/magiconair/properties v1.8.4 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.1 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/pelletier/go-toml v1.8.1 // indirect github.com/prometheus/client_golang v1.9.0 diff --git a/go.sum b/go.sum index 6371a3f4..048824ce 100644 --- a/go.sum +++ b/go.sum @@ -292,6 +292,8 @@ github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/hashstructure/v2 v2.0.1 h1:L60q1+q7cXE4JeEJJKMnh2brFIe3rZxCihYAB61ypAY= +github.com/mitchellh/hashstructure/v2 v2.0.1/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= diff --git a/pkg/kubernetes/cluster_policy_report_client.go b/pkg/kubernetes/cluster_policy_report_client.go index d2d78952..8cb1decd 100644 --- a/pkg/kubernetes/cluster_policy_report_client.go +++ b/pkg/kubernetes/cluster_policy_report_client.go @@ -20,6 +20,7 @@ type clusterPolicyReportClient struct { startUp time.Time skipExisting bool started bool + modifyHash map[string]uint64 } func (c *clusterPolicyReportClient) RegisterCallback(cb report.ClusterPolicyReportCallback) { @@ -125,6 +126,12 @@ func (c *clusterPolicyReportClient) RegisterPolicyResultWatcher(skipExisting boo c.RegisterCallback(func(s watch.EventType, cpr report.ClusterPolicyReport, opr report.ClusterPolicyReport) { switch s { case watch.Added: + if len(cpr.Results) == 0 { + break + } + + c.modifyHash[cpr.GetIdentifier()] = cpr.ResultHash() + preExisted := cpr.CreationTimestamp.Before(c.startUp) if c.skipExisting && preExisted { @@ -145,6 +152,19 @@ func (c *clusterPolicyReportClient) RegisterPolicyResultWatcher(skipExisting boo wg.Wait() case watch.Modified: + if len(cpr.Results) == 0 { + break + } + + newHash := cpr.ResultHash() + if hash, ok := c.modifyHash[cpr.GetIdentifier()]; ok { + if newHash == hash { + break + } + } + + c.modifyHash[cpr.GetIdentifier()] = newHash + diff := cpr.GetNewResults(opr) wg := sync.WaitGroup{} @@ -160,6 +180,10 @@ func (c *clusterPolicyReportClient) RegisterPolicyResultWatcher(skipExisting boo } wg.Wait() + case watch.Deleted: + if _, ok := c.modifyHash[cpr.GetIdentifier()]; ok { + delete(c.modifyHash, cpr.GetIdentifier()) + } } }) } @@ -167,9 +191,10 @@ func (c *clusterPolicyReportClient) RegisterPolicyResultWatcher(skipExisting boo // NewPolicyReportClient creates a new PolicyReportClient based on the kubernetes go-client func NewClusterPolicyReportClient(client PolicyReportAdapter, store *report.ClusterPolicyReportStore, mapper Mapper, startUp time.Time) report.ClusterPolicyClient { return &clusterPolicyReportClient{ - policyAPI: client, - store: store, - mapper: mapper, - startUp: startUp, + policyAPI: client, + store: store, + mapper: mapper, + startUp: startUp, + modifyHash: make(map[string]uint64), } } diff --git a/pkg/kubernetes/mapper.go b/pkg/kubernetes/mapper.go index d40a448b..b86db918 100644 --- a/pkg/kubernetes/mapper.go +++ b/pkg/kubernetes/mapper.go @@ -140,12 +140,13 @@ func (m *mapper) mapResult(result map[string]interface{}) report.Result { status := result["status"].(report.Status) r := report.Result{ - Message: result["message"].(string), - Policy: result["policy"].(string), - Status: status, - Scored: result["scored"].(bool), - Priority: report.PriorityFromStatus(status), - Resources: resources, + Message: result["message"].(string), + Policy: result["policy"].(string), + Status: status, + Scored: result["scored"].(bool), + Priority: report.PriorityFromStatus(status), + Resources: resources, + Properties: make(map[string]string, 0), } if severity, ok := result["severity"]; ok { @@ -164,6 +165,21 @@ func (m *mapper) mapResult(result map[string]interface{}) report.Result { r.Category = category.(string) } + if created, ok := result["timestamp"]; ok { + time, err := time.Parse("2006-01-02T15:04:05Z", created.(string)) + if err == nil { + r.Timestamp = time + } + } + + if props, ok := result["properties"]; ok { + if properties, ok := props.(map[string]interface{}); ok { + for property, value := range properties { + r.Properties[property] = value.(string) + } + } + } + return r } diff --git a/pkg/kubernetes/mapper_test.go b/pkg/kubernetes/mapper_test.go index e4f987d2..af6e4649 100644 --- a/pkg/kubernetes/mapper_test.go +++ b/pkg/kubernetes/mapper_test.go @@ -28,13 +28,14 @@ var policyMap = map[string]interface{}{ }, "results": []interface{}{ map[string]interface{}{ - "message": "message", - "status": "fail", - "scored": true, - "policy": "required-label", - "rule": "app-label-required", - "category": "test", - "severity": "high", + "message": "message", + "status": "fail", + "scored": true, + "policy": "required-label", + "rule": "app-label-required", + "timestamp": "2021-02-23T15:10:00Z", + "category": "test", + "severity": "high", "resources": []interface{}{ map[string]interface{}{ "apiVersion": "v1", @@ -44,6 +45,9 @@ var policyMap = map[string]interface{}{ "uid": "dfd57c50-f30c-4729-b63f-b1954d8988d1", }, }, + "properties": map[string]interface{}{ + "version": "1.2.0", + }, }, map[string]interface{}{ "message": "message 2", @@ -163,6 +167,12 @@ func Test_MapPolicyReport(t *testing.T) { if result1.Severity != report.High { t.Errorf("Expected Severity '%s' (acutal %s)", report.High, result1.Severity) } + if result1.Timestamp.Format("2006-01-02T15:04:05Z") != "2021-02-23T15:10:00Z" { + t.Errorf("Expected Timestamp '2021-02-23T15:10:00Z' (acutal %s)", result1.Timestamp.Format("2006-01-02T15:04:05Z")) + } + if result1.Properties["version"] != "1.2.0" { + t.Errorf("Expected Property '1.2.0' (acutal %s)", result1.Properties["version"]) + } resource := result1.Resources[0] if resource.APIVersion != "v1" { diff --git a/pkg/kubernetes/policy_report_client.go b/pkg/kubernetes/policy_report_client.go index 33832a11..bc2a7e6c 100644 --- a/pkg/kubernetes/policy_report_client.go +++ b/pkg/kubernetes/policy_report_client.go @@ -20,6 +20,7 @@ type policyReportClient struct { startUp time.Time skipExisting bool started bool + modifyHash map[string]uint64 } func (c *policyReportClient) RegisterCallback(cb report.PolicyReportCallback) { @@ -126,24 +127,64 @@ func (c *policyReportClient) RegisterPolicyResultWatcher(skipExisting bool) { func(e watch.EventType, pr report.PolicyReport, or report.PolicyReport) { switch e { case watch.Added: + if len(pr.Results) == 0 { + break + } + + c.modifyHash[pr.GetIdentifier()] = pr.ResultHash() + preExisted := pr.CreationTimestamp.Before(c.startUp) if c.skipExisting && preExisted { break } - for _, result := range pr.Results { + wg := sync.WaitGroup{} + wg.Add(len(pr.Results) * len(c.resultCallbacks)) + + for _, r := range pr.Results { for _, cb := range c.resultCallbacks { - cb(result, preExisted) + go func(callback report.PolicyResultCallback, result report.Result) { + callback(result, preExisted) + wg.Done() + }(cb, r) } } + + wg.Wait() case watch.Modified: + if len(pr.Results) == 0 { + break + } + + newHash := pr.ResultHash() + if hash, ok := c.modifyHash[pr.GetIdentifier()]; ok { + if newHash == hash { + break + } + } + + c.modifyHash[pr.GetIdentifier()] = newHash + diff := pr.GetNewResults(or) - for _, result := range diff { + + wg := sync.WaitGroup{} + wg.Add(len(diff) * len(c.resultCallbacks)) + + for _, r := range diff { for _, cb := range c.resultCallbacks { - cb(result, false) + go func(callback report.PolicyResultCallback, result report.Result) { + callback(result, false) + wg.Done() + }(cb, r) } } + + wg.Wait() + case watch.Deleted: + if _, ok := c.modifyHash[pr.GetIdentifier()]; ok { + delete(c.modifyHash, pr.GetIdentifier()) + } } }) } @@ -151,9 +192,10 @@ func (c *policyReportClient) RegisterPolicyResultWatcher(skipExisting bool) { // NewPolicyReportClient creates a new PolicyReportClient based on the kubernetes go-client func NewPolicyReportClient(client PolicyReportAdapter, store *report.PolicyReportStore, mapper Mapper, startUp time.Time) report.PolicyClient { return &policyReportClient{ - policyAPI: client, - store: store, - mapper: mapper, - startUp: startUp, + policyAPI: client, + store: store, + mapper: mapper, + startUp: startUp, + modifyHash: make(map[string]uint64), } } diff --git a/pkg/kubernetes/report_client_test.go b/pkg/kubernetes/report_client_test.go index 20278158..0809592d 100644 --- a/pkg/kubernetes/report_client_test.go +++ b/pkg/kubernetes/report_client_test.go @@ -158,3 +158,149 @@ func Test_ResultClient_RegisterPolicyResultWatcher(t *testing.T) { t.Error("Should receive 3 Result from all PolicyReports") } } + +func Test_ResultClient_SkipReportsWithoutResults(t *testing.T) { + _, k8sCMClient := newFakeAPI() + k8sCMClient.Create(context.Background(), configMap, metav1.CreateOptions{}) + fakeAdapter := NewPolicyReportAdapter() + + mapper := NewMapper(k8sCMClient) + + pClient := kubernetes.NewPolicyReportClient(fakeAdapter, report.NewPolicyReportStore(), mapper, time.Now()) + cpClient := kubernetes.NewClusterPolicyReportClient(fakeAdapter, report.NewClusterPolicyReportStore(), mapper, time.Now()) + + client := kubernetes.NewPolicyResultClient(pClient, cpClient) + + client.RegisterPolicyResultWatcher(false) + + wg := sync.WaitGroup{} + wg.Add(3) + + results := make([]report.Result, 0, 3) + + client.RegisterPolicyResultCallback(func(r report.Result, b bool) { + results = append(results, r) + wg.Done() + }) + + go pClient.StartWatching() + go cpClient.StartWatching() + + var policyMap2 = map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "policy-report", + "namespace": "test", + "creationTimestamp": "2021-02-23T15:00:00Z", + }, + "summary": map[string]interface{}{ + "pass": int64(1), + "skip": int64(2), + "warn": int64(3), + "fail": int64(4), + "error": int64(5), + }, + "results": []interface{}{}, + } + + var clusterPolicyMap2 = map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "clusterpolicy-report", + "creationTimestamp": "2021-02-23T15:00:00Z", + }, + "summary": map[string]interface{}{ + "pass": int64(0), + "skip": int64(0), + "warn": int64(0), + "fail": int64(0), + "error": int64(0), + }, + "results": []interface{}{}, + } + + fakeAdapter.clusterPolicyWatcher.Add(&unstructured.Unstructured{Object: clusterPolicyMap2}) + fakeAdapter.clusterPolicyWatcher.Modify(&unstructured.Unstructured{Object: clusterPolicyMap2}) + fakeAdapter.clusterPolicyWatcher.Modify(&unstructured.Unstructured{Object: clusterPolicyMap}) + + fakeAdapter.policyWatcher.Add(&unstructured.Unstructured{Object: policyMap2}) + fakeAdapter.policyWatcher.Modify(&unstructured.Unstructured{Object: policyMap2}) + fakeAdapter.policyWatcher.Modify(&unstructured.Unstructured{Object: policyMap}) + + wg.Wait() + + if len(results) != 3 { + t.Error("Should receive 3 Result from none empty PolicyReport and ClusterPolicyReport Modify") + } +} + +func Test_ResultClient_SkipReportsCleanUpEvents(t *testing.T) { + _, k8sCMClient := newFakeAPI() + k8sCMClient.Create(context.Background(), configMap, metav1.CreateOptions{}) + fakeAdapter := NewPolicyReportAdapter() + + mapper := NewMapper(k8sCMClient) + + pClient := kubernetes.NewPolicyReportClient(fakeAdapter, report.NewPolicyReportStore(), mapper, time.Now()) + cpClient := kubernetes.NewClusterPolicyReportClient(fakeAdapter, report.NewClusterPolicyReportStore(), mapper, time.Now()) + + client := kubernetes.NewPolicyResultClient(pClient, cpClient) + + client.RegisterPolicyResultWatcher(false) + + wg := sync.WaitGroup{} + wg.Add(3) + + results := make([]report.Result, 0, 3) + + client.RegisterPolicyResultCallback(func(r report.Result, b bool) { + results = append(results, r) + wg.Done() + }) + + go pClient.StartWatching() + go cpClient.StartWatching() + + var policyMap2 = map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "policy-report", + "namespace": "test", + "creationTimestamp": "2021-02-23T15:00:00Z", + }, + "summary": map[string]interface{}{ + "pass": int64(0), + "skip": int64(0), + "warn": int64(0), + "fail": int64(0), + "error": int64(0), + }, + "results": []interface{}{}, + } + + var clusterPolicyMap2 = map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "clusterpolicy-report", + "creationTimestamp": "2021-02-23T15:00:00Z", + }, + "summary": map[string]interface{}{ + "pass": int64(0), + "skip": int64(0), + "warn": int64(0), + "fail": int64(0), + "error": int64(0), + }, + "results": []interface{}{}, + } + + fakeAdapter.clusterPolicyWatcher.Add(&unstructured.Unstructured{Object: clusterPolicyMap}) + fakeAdapter.clusterPolicyWatcher.Modify(&unstructured.Unstructured{Object: clusterPolicyMap2}) + fakeAdapter.clusterPolicyWatcher.Modify(&unstructured.Unstructured{Object: clusterPolicyMap}) + + fakeAdapter.policyWatcher.Add(&unstructured.Unstructured{Object: policyMap}) + fakeAdapter.policyWatcher.Modify(&unstructured.Unstructured{Object: policyMap2}) + fakeAdapter.policyWatcher.Modify(&unstructured.Unstructured{Object: policyMap}) + + wg.Wait() + + if len(results) != 3 { + t.Error("Should receive 3 Results from the initial add events, not from the cleanup modify events") + } +} diff --git a/pkg/metrics/cluster_policy_report.go b/pkg/metrics/cluster_policy_report.go index cb5c6c13..1a6daab0 100644 --- a/pkg/metrics/cluster_policy_report.go +++ b/pkg/metrics/cluster_policy_report.go @@ -28,49 +28,17 @@ func CreateClusterPolicyReportMetricsCallback() report.ClusterPolicyReportCallba updateClusterPolicyGauge(policyGauge, report) for _, rule := range report.Results { - res := rule.Resources[0] - ruleGauge.WithLabelValues( - rule.Rule, - rule.Policy, - report.Name, - res.Kind, - res.Name, - rule.Status, - rule.Severity, - rule.Category, - ).Set(1) + ruleGauge.With(generateClusterResultLabels(report, rule)).Set(1) } case watch.Modified: updateClusterPolicyGauge(policyGauge, report) for _, rule := range oldReport.Results { - res := rule.Resources[0] - ruleGauge.DeleteLabelValues( - rule.Rule, - rule.Policy, - report.Name, - res.Kind, - res.Name, - rule.Status, - rule.Severity, - rule.Category, - ) + ruleGauge.Delete(generateClusterResultLabels(oldReport, rule)) } for _, rule := range report.Results { - res := rule.Resources[0] - ruleGauge. - WithLabelValues( - rule.Rule, - rule.Policy, - report.Name, - res.Kind, - res.Name, - rule.Status, - rule.Severity, - rule.Category, - ). - Set(1) + ruleGauge.With(generateClusterResultLabels(report, rule)).Set(1) } case watch.Deleted: policyGauge.DeleteLabelValues(report.Name, "Pass") @@ -80,22 +48,34 @@ func CreateClusterPolicyReportMetricsCallback() report.ClusterPolicyReportCallba policyGauge.DeleteLabelValues(report.Name, "Skip") for _, rule := range report.Results { - res := rule.Resources[0] - ruleGauge.DeleteLabelValues( - rule.Rule, - rule.Policy, - report.Name, - res.Kind, - res.Name, - rule.Status, - rule.Severity, - rule.Category, - ) + ruleGauge.Delete(generateClusterResultLabels(report, rule)) } } } } +func generateClusterResultLabels(report report.ClusterPolicyReport, result report.Result) prometheus.Labels { + labels := prometheus.Labels{ + "rule": result.Rule, + "policy": result.Policy, + "report": report.Name, + "kind": "", + "name": "", + "status": result.Status, + "severity": result.Severity, + "category": result.Category, + } + + if len(result.Resources) > 0 { + res := result.Resources[0] + + labels["kind"] = res.Kind + labels["name"] = res.Name + } + + return labels +} + func updateClusterPolicyGauge(policyGauge *prometheus.GaugeVec, report report.ClusterPolicyReport) { policyGauge. WithLabelValues(report.Name, "Pass"). diff --git a/pkg/metrics/policy_report.go b/pkg/metrics/policy_report.go index 71a51d6d..6f33c3d1 100644 --- a/pkg/metrics/policy_report.go +++ b/pkg/metrics/policy_report.go @@ -28,54 +28,17 @@ func CreatePolicyReportMetricsCallback() report.PolicyReportCallback { updatePolicyGauge(policyGauge, report) for _, rule := range report.Results { - res := rule.Resources[0] - ruleGauge. - WithLabelValues( - report.Namespace, - rule.Rule, - rule.Policy, - report.Name, - res.Kind, - res.Name, - rule.Status, - rule.Severity, - rule.Category, - ). - Set(1) + ruleGauge.With(generateResultLabels(report, rule)).Set(1) } case watch.Modified: updatePolicyGauge(policyGauge, report) for _, rule := range oldReport.Results { - res := rule.Resources[0] - ruleGauge.DeleteLabelValues( - report.Namespace, - rule.Rule, - rule.Policy, - report.Name, - res.Kind, - res.Name, - rule.Status, - rule.Severity, - rule.Category, - ) + ruleGauge.Delete(generateResultLabels(oldReport, rule)) } for _, rule := range report.Results { - res := rule.Resources[0] - ruleGauge. - WithLabelValues( - report.Namespace, - rule.Rule, - rule.Policy, - report.Name, - res.Kind, - res.Name, - rule.Status, - rule.Severity, - rule.Category, - ). - Set(1) + ruleGauge.With(generateResultLabels(report, rule)).Set(1) } case watch.Deleted: policyGauge.DeleteLabelValues(report.Namespace, report.Name, "Pass") @@ -85,24 +48,35 @@ func CreatePolicyReportMetricsCallback() report.PolicyReportCallback { policyGauge.DeleteLabelValues(report.Namespace, report.Name, "Skip") for _, rule := range report.Results { - res := rule.Resources[0] - - ruleGauge.DeleteLabelValues( - report.Namespace, - rule.Rule, - rule.Policy, - report.Name, - res.Kind, - res.Name, - rule.Status, - rule.Severity, - rule.Category, - ) + ruleGauge.Delete(generateResultLabels(report, rule)) } } } } +func generateResultLabels(report report.PolicyReport, result report.Result) prometheus.Labels { + labels := prometheus.Labels{ + "namespace": report.Namespace, + "rule": result.Rule, + "policy": result.Policy, + "report": report.Name, + "kind": "", + "name": "", + "status": result.Status, + "severity": result.Severity, + "category": result.Category, + } + + if len(result.Resources) > 0 { + res := result.Resources[0] + + labels["kind"] = res.Kind + labels["name"] = res.Name + } + + return labels +} + func updatePolicyGauge(policyGauge *prometheus.GaugeVec, report report.PolicyReport) { policyGauge. WithLabelValues(report.Namespace, report.Name, "Pass"). diff --git a/pkg/report/model.go b/pkg/report/model.go index d1aa5a8b..6d414239 100644 --- a/pkg/report/model.go +++ b/pkg/report/model.go @@ -3,7 +3,10 @@ package report import ( "bytes" "fmt" + "sort" "time" + + "github.com/mitchellh/hashstructure/v2" ) // Status Enum defined for PolicyReport @@ -129,15 +132,17 @@ type Resource struct { // Result from the PolicyReport spec wgpolicyk8s.io/v1alpha1.PolicyReportResult type Result struct { - Message string - Policy string - Rule string - Priority Priority - Status Status - Severity Severity `json:",omitempty"` - Category string `json:",omitempty"` - Scored bool - Resources []Resource + Message string + Policy string + Rule string + Priority Priority + Status Status + Severity Severity `json:",omitempty"` + Category string `json:",omitempty"` + Scored bool + Timestamp time.Time + Resources []Resource + Properties map[string]string } // GetIdentifier returns a global unique Result identifier @@ -173,6 +178,24 @@ func (pr PolicyReport) GetIdentifier() string { return fmt.Sprintf("%s__%s", pr.Namespace, pr.Name) } +// ResultHash generates a has of the current result set +func (pr PolicyReport) ResultHash() uint64 { + list := make([]string, 0, len(pr.Results)) + + for id := range pr.Results { + list = append(list, id) + } + + sort.Strings(list) + + hash, err := hashstructure.Hash(list, hashstructure.FormatV2, nil) + if err != nil { + return 0 + } + + return hash +} + // GetNewResults filters already existing Results from the old PolicyReport and returns only the diff with new Results func (pr PolicyReport) GetNewResults(or PolicyReport) []Result { diff := make([]Result, 0) @@ -215,3 +238,21 @@ func (cr ClusterPolicyReport) GetNewResults(cor ClusterPolicyReport) []Result { return diff } + +// ResultHash generates a has of the current result set +func (cr ClusterPolicyReport) ResultHash() uint64 { + list := make([]string, 0, len(cr.Results)) + + for id := range cr.Results { + list = append(list, id) + } + + sort.Strings(list) + + hash, err := hashstructure.Hash(list, hashstructure.FormatV2, nil) + if err != nil { + return 0 + } + + return hash +} diff --git a/pkg/report/model_test.go b/pkg/report/model_test.go index 10af584d..929185fc 100644 --- a/pkg/report/model_test.go +++ b/pkg/report/model_test.go @@ -83,6 +83,28 @@ func Test_PolicyReport(t *testing.T) { t.Error("Expected 1 new result in diff") } }) + + t.Run("Check PolicyReport.ResultHash", func(t *testing.T) { + preport := preport + preport.Results = map[string]report.Result{result1.GetIdentifier(): result1, result2.GetIdentifier(): result2} + + hash := preport.ResultHash() + if hash != 5971778764232883205 { + t.Error("Expected '5971778764232883205' new result in diff") + } + }) + + t.Run("Check PolicyReport.ResultHash same with different order", func(t *testing.T) { + preport1 := preport + preport2 := preport + + preport1.Results = map[string]report.Result{result2.GetIdentifier(): result2, result1.GetIdentifier(): result1} + preport2.Results = map[string]report.Result{result1.GetIdentifier(): result1, result2.GetIdentifier(): result2} + + if preport2.ResultHash() != preport1.ResultHash() { + t.Error("Expected same hash with different order") + } + }) } func Test_ClusterPolicyReport(t *testing.T) { @@ -104,6 +126,28 @@ func Test_ClusterPolicyReport(t *testing.T) { t.Error("Expected 1 new result in diff") } }) + + t.Run("Check PolicyReport.ResultHash", func(t *testing.T) { + report1 := creport + report1.Results = map[string]report.Result{result1.GetIdentifier(): result1, result2.GetIdentifier(): result2} + + hash := report1.ResultHash() + if hash != 5971778764232883205 { + t.Error("Expected '5971778764232883205' new result in diff") + } + }) + + t.Run("Check PolicyReport.ResultHash same with different order", func(t *testing.T) { + report1 := creport + report2 := creport + + report1.Results = map[string]report.Result{result2.GetIdentifier(): result2, result1.GetIdentifier(): result1} + report2.Results = map[string]report.Result{result1.GetIdentifier(): result1, result2.GetIdentifier(): result2} + + if report2.ResultHash() != report1.ResultHash() { + t.Error("Expected same hash with different order") + } + }) } func Test_Result(t *testing.T) { diff --git a/pkg/target/discord/discord.go b/pkg/target/discord/discord.go index 0d6fe4a0..5b2be831 100644 --- a/pkg/target/discord/discord.go +++ b/pkg/target/discord/discord.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log" "net/http" + "strings" "github.com/fjogeleit/policy-reporter/pkg/report" "github.com/fjogeleit/policy-reporter/pkg/target" @@ -74,6 +75,10 @@ func newPayload(result report.Result) payload { embedFields = append(embedFields, embedField{"API Version", res.APIVersion, true}) } + for property, value := range result.Properties { + embedFields = append(embedFields, embedField{strings.Title(property), value, true}) + } + embeds := make([]embed, 0, 1) embeds = append(embeds, embed{ Title: "New Policy Report Result", diff --git a/pkg/target/discord/discord_test.go b/pkg/target/discord/discord_test.go index 67f74e7e..d4dcb835 100644 --- a/pkg/target/discord/discord_test.go +++ b/pkg/target/discord/discord_test.go @@ -3,20 +3,22 @@ package discord_test import ( "net/http" "testing" + "time" "github.com/fjogeleit/policy-reporter/pkg/report" "github.com/fjogeleit/policy-reporter/pkg/target/discord" ) var completeResult = report.Result{ - Message: "validation error: requests and limits required. Rule autogen-check-for-requests-and-limits failed at path /spec/template/spec/containers/0/resources/requests/", - Policy: "require-requests-and-limits-required", - Rule: "autogen-check-for-requests-and-limits", - Priority: report.WarningPriority, - Status: report.Fail, - Severity: report.High, - Category: "resources", - Scored: true, + Message: "validation error: requests and limits required. Rule autogen-check-for-requests-and-limits failed at path /spec/template/spec/containers/0/resources/requests/", + Policy: "require-requests-and-limits-required", + Rule: "autogen-check-for-requests-and-limits", + Timestamp: time.Date(2021, time.February, 23, 15, 10, 0, 0, time.UTC), + Priority: report.WarningPriority, + Status: report.Fail, + Severity: report.High, + Category: "resources", + Scored: true, Resources: []report.Resource{ { APIVersion: "v1", @@ -26,6 +28,7 @@ var completeResult = report.Result{ UID: "536ab69f-1b3c-4bd9-9ba4-274a56188409", }, }, + Properties: map[string]string{"version": "1.2.0"}, } var minimalResult = report.Result{ diff --git a/pkg/target/elasticsearch/elasticsearch_test.go b/pkg/target/elasticsearch/elasticsearch_test.go index c2afe1dc..b674f20e 100644 --- a/pkg/target/elasticsearch/elasticsearch_test.go +++ b/pkg/target/elasticsearch/elasticsearch_test.go @@ -10,14 +10,15 @@ import ( ) var completeResult = report.Result{ - Message: "validation error: requests and limits required. Rule autogen-check-for-requests-and-limits failed at path /spec/template/spec/containers/0/resources/requests/", - Policy: "require-requests-and-limits-required", - Rule: "autogen-check-for-requests-and-limits", - Priority: report.WarningPriority, - Status: report.Fail, - Severity: report.High, - Category: "resources", - Scored: true, + Message: "validation error: requests and limits required. Rule autogen-check-for-requests-and-limits failed at path /spec/template/spec/containers/0/resources/requests/", + Policy: "require-requests-and-limits-required", + Rule: "autogen-check-for-requests-and-limits", + Timestamp: time.Date(2021, time.February, 23, 15, 10, 0, 0, time.UTC), + Priority: report.WarningPriority, + Status: report.Fail, + Severity: report.High, + Category: "resources", + Scored: true, Resources: []report.Resource{ { APIVersion: "v1", @@ -27,6 +28,7 @@ var completeResult = report.Result{ UID: "536ab69f-1b3c-4bd9-9ba4-274a56188409", }, }, + Properties: map[string]string{"version": "1.2.0"}, } type testClient struct { diff --git a/pkg/target/loki/loki.go b/pkg/target/loki/loki.go index 019f67d8..d8251978 100644 --- a/pkg/target/loki/loki.go +++ b/pkg/target/loki/loki.go @@ -32,7 +32,12 @@ type entry struct { } func newLokiPayload(result report.Result) payload { - le := entry{Ts: time.Now().Format(time.RFC3339), Line: "[" + strings.ToUpper(result.Priority.String()) + "] " + result.Message} + timestamp := time.Now() + if !result.Timestamp.IsZero() { + timestamp = result.Timestamp + } + + le := entry{Ts: timestamp.Format(time.RFC3339), Line: "[" + strings.ToUpper(result.Priority.String()) + "] " + result.Message} ls := stream{Entries: []entry{le}} res := report.Resource{} @@ -65,6 +70,10 @@ func newLokiPayload(result report.Result) payload { labels = append(labels, "namespace=\""+res.Namespace+"\"") } + for property, value := range result.Properties { + labels = append(labels, property+"=\""+strings.ReplaceAll(value, "\"", "")+"\"") + } + ls.Labels = "{" + strings.Join(labels, ",") + "}" return payload{Streams: []stream{ls}} diff --git a/pkg/target/loki/loki_test.go b/pkg/target/loki/loki_test.go index 290f513b..72f458dc 100644 --- a/pkg/target/loki/loki_test.go +++ b/pkg/target/loki/loki_test.go @@ -6,20 +6,22 @@ import ( "net/http" "strings" "testing" + "time" "github.com/fjogeleit/policy-reporter/pkg/report" "github.com/fjogeleit/policy-reporter/pkg/target/loki" ) var completeResult = report.Result{ - Message: "validation error: requests and limits required. Rule autogen-check-for-requests-and-limits failed at path /spec/template/spec/containers/0/resources/requests/", - Policy: "require-requests-and-limits-required", - Rule: "autogen-check-for-requests-and-limits", - Priority: report.WarningPriority, - Status: report.Fail, - Severity: report.High, - Category: "resources", - Scored: true, + Message: "validation error: requests and limits required. Rule autogen-check-for-requests-and-limits failed at path /spec/template/spec/containers/0/resources/requests/", + Policy: "require-requests-and-limits-required", + Rule: "autogen-check-for-requests-and-limits", + Timestamp: time.Date(2021, time.February, 23, 15, 10, 0, 0, time.UTC), + Priority: report.WarningPriority, + Status: report.Fail, + Severity: report.High, + Category: "resources", + Scored: true, Resources: []report.Resource{ { APIVersion: "v1", @@ -29,6 +31,7 @@ var completeResult = report.Result{ UID: "536ab69f-1b3c-4bd9-9ba4-274a56188409", }, }, + Properties: map[string]string{"version": "1.2.0"}, } var minimalResult = report.Result{ @@ -107,6 +110,9 @@ func Test_LokiTarget(t *testing.T) { if !strings.Contains(labels, "namespace=\""+res.Namespace+"\"") { t.Error("Missing Content for Label 'namespace'") } + if !strings.Contains(labels, "version=\""+completeResult.Properties["version"]+"\"") { + t.Error("Missing Content for Label 'version'") + } } loki := loki.NewClient("http://localhost:3100", "", false, testClient{callback, 200}) diff --git a/pkg/target/slack/slack.go b/pkg/target/slack/slack.go index 4601be0f..a5ffd0a4 100644 --- a/pkg/target/slack/slack.go +++ b/pkg/target/slack/slack.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log" "net/http" + "strings" "github.com/fjogeleit/policy-reporter/pkg/report" "github.com/fjogeleit/policy-reporter/pkg/target" @@ -148,6 +149,24 @@ func (s *client) newPayload(result report.Result) payload { att.Blocks = append(att.Blocks, block{Type: "section", Fields: []field{{Type: "mrkdwn", Text: "*Namespace*\n" + res.Namespace}}}) } + if len(result.Properties) > 0 { + att.Blocks = append( + att.Blocks, + block{Type: "section", Text: &text{Type: "mrkdwn", Text: "*Properties*"}}, + ) + + propBlock := block{ + Type: "section", + Fields: []field{}, + } + + for property, value := range result.Properties { + propBlock.Fields = append(propBlock.Fields, field{Type: "mrkdwn", Text: "*" + strings.Title(property) + "*\n" + value}) + } + + att.Blocks = append(att.Blocks, propBlock) + } + p.Attachments = append(p.Attachments, att) return p diff --git a/pkg/target/slack/slack_test.go b/pkg/target/slack/slack_test.go index fec948f3..bb2e8a26 100644 --- a/pkg/target/slack/slack_test.go +++ b/pkg/target/slack/slack_test.go @@ -3,20 +3,22 @@ package slack_test import ( "net/http" "testing" + "time" "github.com/fjogeleit/policy-reporter/pkg/report" "github.com/fjogeleit/policy-reporter/pkg/target/slack" ) var completeResult = report.Result{ - Message: "validation error: requests and limits required. Rule autogen-check-for-requests-and-limits failed at path /spec/template/spec/containers/0/resources/requests/", - Policy: "require-requests-and-limits-required", - Rule: "autogen-check-for-requests-and-limits", - Priority: report.WarningPriority, - Status: report.Fail, - Severity: report.High, - Category: "resources", - Scored: true, + Message: "validation error: requests and limits required. Rule autogen-check-for-requests-and-limits failed at path /spec/template/spec/containers/0/resources/requests/", + Policy: "require-requests-and-limits-required", + Rule: "autogen-check-for-requests-and-limits", + Timestamp: time.Date(2021, time.February, 23, 15, 10, 0, 0, time.UTC), + Priority: report.WarningPriority, + Status: report.Fail, + Severity: report.High, + Category: "resources", + Scored: true, Resources: []report.Resource{ { APIVersion: "v1", @@ -26,6 +28,7 @@ var completeResult = report.Result{ UID: "536ab69f-1b3c-4bd9-9ba4-274a56188409", }, }, + Properties: map[string]string{"version": "1.2.0"}, } var minimalResult = report.Result{ diff --git a/pkg/target/teams/teams.go b/pkg/target/teams/teams.go index 476beea2..002a3893 100644 --- a/pkg/target/teams/teams.go +++ b/pkg/target/teams/teams.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log" "net/http" + "strings" "time" "github.com/fjogeleit/policy-reporter/pkg/report" @@ -85,10 +86,19 @@ func newPayload(result report.Result) payload { facts = append(facts, fact{"API Version", res.APIVersion}) } + for property, value := range result.Properties { + facts = append(facts, fact{strings.Title(property), value}) + } + + timestamp := time.Now() + if !result.Timestamp.IsZero() { + timestamp = result.Timestamp + } + sections := make([]section, 0, 1) sections = append(sections, section{ Title: "New Policy Report Result", - SubTitle: time.Now().Format(time.RFC3339), + SubTitle: timestamp.Format(time.RFC3339), Text: result.Message, Facts: facts, }) diff --git a/pkg/target/teams/teams_test.go b/pkg/target/teams/teams_test.go index f9b6137c..d2cc2a95 100644 --- a/pkg/target/teams/teams_test.go +++ b/pkg/target/teams/teams_test.go @@ -4,20 +4,22 @@ import ( "encoding/json" "net/http" "testing" + "time" "github.com/fjogeleit/policy-reporter/pkg/report" "github.com/fjogeleit/policy-reporter/pkg/target/teams" ) var completeResult = report.Result{ - Message: "validation error: requests and limits required. Rule autogen-check-for-requests-and-limits failed at path /spec/template/spec/containers/0/resources/requests/", - Policy: "require-requests-and-limits-required", - Rule: "autogen-check-for-requests-and-limits", - Priority: report.WarningPriority, - Status: report.Fail, - Severity: report.High, - Category: "resources", - Scored: true, + Message: "validation error: requests and limits required. Rule autogen-check-for-requests-and-limits failed at path /spec/template/spec/containers/0/resources/requests/", + Policy: "require-requests-and-limits-required", + Rule: "autogen-check-for-requests-and-limits", + Priority: report.WarningPriority, + Status: report.Fail, + Severity: report.High, + Timestamp: time.Date(2021, time.February, 23, 15, 10, 0, 0, time.UTC), + Category: "resources", + Scored: true, Resources: []report.Resource{ { APIVersion: "v1", @@ -27,6 +29,7 @@ var completeResult = report.Result{ UID: "536ab69f-1b3c-4bd9-9ba4-274a56188409", }, }, + Properties: map[string]string{"version": "1.2.0"}, } var minimalErrorResult = report.Result{