diff --git a/CHANGELOG.md b/CHANGELOG.md index 11231288..872bcbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.9.0 + +* Implement Discord as Target for PolicyReportResults + ## 0.8.0 * Implement Slack as Target for PolicyReportResults @@ -8,4 +12,4 @@ * Implement Elasticsearch as Target for PolicyReportResults * Replace CLI flags with a single `config.yaml` to manage target-configurations as separate `ConfigMap` -* Set `loki.skipExistingOnStartup` default value to `true` \ No newline at end of file +* Set `loki.skipExistingOnStartup` default value to `true` diff --git a/README.md b/README.md index 8d06cfc0..921901eb 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,17 @@ helm install policy-reporter policy-reporter/policy-reporter -n policy-reporter ```bash helm install policy-reporter policy-reporter/policy-reporter --set loki.host=http://loki:3100 -n policy-reporter --create-namespace ``` +#### Additional configurations for Loki + +* Configure `loki.minimumPriority` to send only results with the configured minimumPriority or above, empty means all results. (info < warning < error) +* Configure `loki.skipExistingOnStartup` to skip all results who already existed before the PolicyReporter started (default: `true`). + +```yaml +loki: + host: "" + minimumPriority: "" + skipExistingOnStartup: true +``` ### Installation with Elasticsearch @@ -35,26 +46,7 @@ helm install policy-reporter policy-reporter/policy-reporter --set loki.host=htt helm install policy-reporter policy-reporter/policy-reporter --set elasticsearch.host=http://elasticsearch:3100 -n policy-reporter --create-namespace ``` -### Installation with Slack - -```bash -helm install policy-reporter policy-reporter/policy-reporter --set slack.webhook=http://hook.slack -n policy-reporter --create-namespace -``` - -You can also customize the `./charts/policy-reporter/values.yaml` to change the default configurations. - -### Additional configurations for Loki - -* Configure `loki.minimumPriority` to send only results with the configured minimumPriority or above, empty means all results. (info < warning < error) -* Configure `loki.skipExistingOnStartup` to skip all results who already existed before the PolicyReporter started (default: `true`). - -```yaml -loki: - minimumPriority: "" - skipExistingOnStartup: true -``` - -### Additional configurations for Elasticsearch +#### Additional configurations for Elasticsearch * Configure `elasticsearch.index` to customize the elasticsearch index. * Configure `elasticsearch.rotation` is added as suffix to the index. Possible values are `daily`, `monthly`, `annually` and `none`. @@ -63,12 +55,20 @@ loki: ```yaml elasticsearch: + host: "" index: "policy-reporter" rotation: "daily" minimumPriority: "" skipExistingOnStartup: true ``` + +### Installation with Slack + +```bash +helm install policy-reporter policy-reporter/policy-reporter --set slack.webhook=http://hook.slack -n policy-reporter --create-namespace +``` + ### Additional configurations for Slack * Configure `slack.minimumPriority` to send only results with the configured minimumPriority or above, empty means all results. (info < warning < error) @@ -76,10 +76,33 @@ elasticsearch: ```yaml slack: + webhook: "" minimumPriority: "" skipExistingOnStartup: true ``` +### Installation with Discord + +```bash +helm install policy-reporter policy-reporter/policy-reporter --set discord.webhook=http://hook.discord -n policy-reporter --create-namespace +``` + +#### Additional configurations for Discord + +* Configure `discord.minimumPriority` to send only results with the configured minimumPriority or above, empty means all results. (info < warning < error) +* Configure `discord.skipExistingOnStartup` to skip all results who already existed before the PolicyReporter started (default: `true`). + +```yaml +discord: + webhook: "" + minimumPriority: "" + skipExistingOnStartup: true +``` + +### Customization + +You can combine multiple targets by setting the required `host` or `webhook` configuration for your targets of choice. For all possible configurations checkout the `./charts/policy-reporter/values.yaml` to change any configurations available. + ### Configure Policy Priorities By default kyverno PolicyReports has no priority or severity for policies. So every passed rule validation will be processed as notice, a failed validation is processed as error. To customize this you can configure a mapping from policies to fail priorities. So you can send them as warnings instead of errors. To configure the priorities create a ConfigMap in the `policy-reporter` namespace with the name `policy-reporter-priorities`. Configure each priority as value with the Policyname as key and the Priority as value. This Configuration is loaded and synchronized during runtime. Any change to this configmap will automaticly synchronized, no new deployment needed. diff --git a/charts/policy-reporter/Chart.yaml b/charts/policy-reporter/Chart.yaml index 50c2bcae..95b4d217 100644 --- a/charts/policy-reporter/Chart.yaml +++ b/charts/policy-reporter/Chart.yaml @@ -3,5 +3,5 @@ name: policy-reporter description: K8s PolicyReporter watches for wgpolicyk8s.io/v1alpha1.PolicyReport resources. It creates Prometheus Metrics and can send rule validation events to Loki type: application -version: 0.9.0 -appVersion: 0.8.0 +version: 0.10.0 +appVersion: 0.9.0 diff --git a/charts/policy-reporter/templates/targetsconfigmap.yaml b/charts/policy-reporter/templates/targetsconfigmap.yaml index ba1cc009..21a96e2f 100644 --- a/charts/policy-reporter/templates/targetsconfigmap.yaml +++ b/charts/policy-reporter/templates/targetsconfigmap.yaml @@ -20,5 +20,10 @@ data: slack: webhook: {{ .Values.slack.webhook | quote }} - minimumPriority: {{ .Values.loki.minimumPriority | quote }} - skipExistingOnStartup: {{ .Values.loki.skipExistingOnStartup }} + minimumPriority: {{ .Values.slack.minimumPriority | quote }} + skipExistingOnStartup: {{ .Values.slack.skipExistingOnStartup }} + + discord: + webhook: {{ .Values.discord.webhook | quote }} + minimumPriority: {{ .Values.discord.minimumPriority | quote }} + skipExistingOnStartup: {{ .Values.discord.skipExistingOnStartup }} diff --git a/charts/policy-reporter/values.yaml b/charts/policy-reporter/values.yaml index adba368f..be87c142 100644 --- a/charts/policy-reporter/values.yaml +++ b/charts/policy-reporter/values.yaml @@ -27,6 +27,14 @@ slack: # Skip already existing PolicyReportResults on startup skipExistingOnStartup: true +discord: + # discord app webhook address + webhook: "" + # minimum priority "" < info < warning < error + minimumPriority: "" + # Skip already existing PolicyReportResults on startup + skipExistingOnStartup: true + metrics: serviceMonitor: false dashboard: @@ -36,7 +44,7 @@ metrics: image: repository: fjogeleit/policy-reporter pullPolicy: IfNotPresent - tag: 0.8.0 + tag: 0.9.0 imagePullSecrets: [] diff --git a/pkg/config/config.go b/pkg/config/config.go index 4042579c..dcf28f98 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -23,11 +23,19 @@ type Slack struct { MinimumPriority string `mapstructure:"minimumPriority"` } +// Discord configuration +type Discord struct { + Webhook string `mapstructure:"webhook"` + SkipExisting bool `mapstructure:"skipExistingOnStartup"` + MinimumPriority string `mapstructure:"minimumPriority"` +} + // Config of the PolicyReporter type Config struct { Loki Loki `mapstructure:"loki"` Elasticsearch Elasticsearch `mapstructure:"elasticsearch"` Slack Slack `mapstructure:"slack"` + Discord Discord `mapstructure:"discord"` Kubeconfig string `mapstructure:"kubeconfig"` Namespace string `mapstructure:"namespace"` } diff --git a/pkg/config/resolver.go b/pkg/config/resolver.go index 49e259ba..aec40d51 100644 --- a/pkg/config/resolver.go +++ b/pkg/config/resolver.go @@ -2,6 +2,7 @@ package config import ( "context" + "log" "net/http" "time" @@ -9,6 +10,7 @@ import ( "github.com/fjogeleit/policy-reporter/pkg/metrics" "github.com/fjogeleit/policy-reporter/pkg/report" "github.com/fjogeleit/policy-reporter/pkg/target" + "github.com/fjogeleit/policy-reporter/pkg/target/discord" "github.com/fjogeleit/policy-reporter/pkg/target/elasticsearch" "github.com/fjogeleit/policy-reporter/pkg/target/loki" "github.com/fjogeleit/policy-reporter/pkg/target/slack" @@ -21,6 +23,7 @@ type Resolver struct { lokiClient target.Client elasticsearchClient target.Client slackClient target.Client + discordClient target.Client policyReportMetrics metrics.Metrics clusterPolicyReportMetrics metrics.Metrics } @@ -60,6 +63,8 @@ func (r *Resolver) LokiClient() target.Client { &http.Client{}, ) + log.Println("[INFO] Loki configured") + return r.lokiClient } @@ -88,10 +93,12 @@ func (r *Resolver) ElasticsearchClient() target.Client { &http.Client{}, ) + log.Println("[INFO] Elasticsearch configured") + return r.elasticsearchClient } -// ElasticsearchClient resolver method +// SlackClient resolver method func (r *Resolver) SlackClient() target.Client { if r.slackClient != nil { return r.slackClient @@ -108,9 +115,33 @@ func (r *Resolver) SlackClient() target.Client { &http.Client{}, ) + log.Println("[INFO] Slack configured") + return r.slackClient } +// DiscordClient resolver method +func (r *Resolver) DiscordClient() target.Client { + if r.discordClient != nil { + return r.discordClient + } + + if r.config.Discord.Webhook == "" { + return nil + } + + r.discordClient = discord.NewClient( + r.config.Discord.Webhook, + r.config.Discord.MinimumPriority, + r.config.Discord.SkipExisting, + &http.Client{}, + ) + + log.Println("[INFO] Discord configured") + + return r.discordClient +} + // PolicyReportMetrics resolver method func (r *Resolver) PolicyReportMetrics() (metrics.Metrics, error) { if r.policyReportMetrics != nil { @@ -158,6 +189,10 @@ func (r *Resolver) TargetClients() []target.Client { clients = append(clients, slack) } + if discord := r.DiscordClient(); discord != nil { + clients = append(clients, discord) + } + return clients } diff --git a/pkg/config/resolver_test.go b/pkg/config/resolver_test.go index b3eecf01..2e073445 100644 --- a/pkg/config/resolver_test.go +++ b/pkg/config/resolver_test.go @@ -20,60 +20,72 @@ var testConfig = &config.Config{ MinimumPriority: "debug", }, Slack: config.Slack{ - Webhook: "http://localhost:80", + Webhook: "http://hook.slack:80", + SkipExisting: true, + MinimumPriority: "debug", + }, + Discord: config.Discord{ + Webhook: "http://hook.discord:80", SkipExisting: true, MinimumPriority: "debug", }, } -func Test_ResolveLokiClient(t *testing.T) { +func Test_ResolveClient(t *testing.T) { resolver := config.NewResolver(testConfig) - client := resolver.LokiClient() - if client == nil { - t.Error("Expected Client, got nil") - } + t.Run("Loki", func(t *testing.T) { + client := resolver.LokiClient() + if client == nil { + t.Error("Expected Client, got nil") + } - client2 := resolver.LokiClient() - if client != client2 { - t.Error("Error: Should reuse first instance") - } -} + client2 := resolver.LokiClient() + if client != client2 { + t.Error("Error: Should reuse first instance") + } + }) + t.Run("Elasticsearch", func(t *testing.T) { + client := resolver.ElasticsearchClient() + if client == nil { + t.Error("Expected Client, got nil") + } -func Test_ResolveElasticSearchClient(t *testing.T) { - resolver := config.NewResolver(testConfig) + client2 := resolver.ElasticsearchClient() + if client != client2 { + t.Error("Error: Should reuse first instance") + } + }) + t.Run("Slack", func(t *testing.T) { + client := resolver.SlackClient() + if client == nil { + t.Error("Expected Client, got nil") + } - client := resolver.ElasticsearchClient() - if client == nil { - t.Error("Expected Client, got nil") - } + client2 := resolver.SlackClient() + if client != client2 { + t.Error("Error: Should reuse first instance") + } + }) + t.Run("Discord", func(t *testing.T) { + client := resolver.DiscordClient() + if client == nil { + t.Error("Expected Client, got nil") + } - client2 := resolver.ElasticsearchClient() - if client != client2 { - t.Error("Error: Should reuse first instance") - } -} - -func Test_ResolveSlackClient(t *testing.T) { - resolver := config.NewResolver(testConfig) - - client := resolver.SlackClient() - if client == nil { - t.Error("Expected Client, got nil") - } - - client2 := resolver.SlackClient() - if client != client2 { - t.Error("Error: Should reuse first instance") - } + client2 := resolver.DiscordClient() + if client != client2 { + t.Error("Error: Should reuse first instance") + } + }) } func Test_ResolveTargets(t *testing.T) { resolver := config.NewResolver(testConfig) clients := resolver.TargetClients() - if count := len(clients); count != 3 { - t.Errorf("Expected 3 Clients, got %d", count) + if count := len(clients); count != 4 { + t.Errorf("Expected 4 Clients, got %d", count) } } @@ -114,25 +126,13 @@ func Test_ResolveSkipExistingOnStartup(t *testing.T) { }) } -func Test_ResolveLokiClientWithoutHost(t *testing.T) { +func Test_ResolveClientWithoutHost(t *testing.T) { config2 := &config.Config{ Loki: config.Loki{ Host: "", SkipExisting: true, MinimumPriority: "debug", }, - } - - resolver := config.NewResolver(config2) - resolver.Reset() - - if resolver.LokiClient() != nil { - t.Error("Expected Client to be nil if no host is configured") - } -} - -func Test_ResolveElasticsearchClientWithoutHost(t *testing.T) { - config2 := &config.Config{ Elasticsearch: config.Elasticsearch{ Host: "", Index: "policy-reporter", @@ -140,27 +140,48 @@ func Test_ResolveElasticsearchClientWithoutHost(t *testing.T) { SkipExisting: true, MinimumPriority: "debug", }, - } - - resolver := config.NewResolver(config2) - - if resolver.ElasticsearchClient() != nil { - t.Error("Expected Client to be nil if no host is configured") - } -} - -func Test_ResolveSlackClientWithoutHost(t *testing.T) { - config2 := &config.Config{ Slack: config.Slack{ Webhook: "", SkipExisting: true, MinimumPriority: "debug", }, + Discord: config.Discord{ + Webhook: "", + SkipExisting: true, + MinimumPriority: "debug", + }, } - resolver := config.NewResolver(config2) + t.Run("Loki", func(t *testing.T) { + resolver := config.NewResolver(config2) + resolver.Reset() - if resolver.SlackClient() != nil { - t.Error("Expected Client to be nil if no host is configured") - } + if resolver.LokiClient() != nil { + t.Error("Expected Client to be nil if no host is configured") + } + }) + t.Run("Elasticsearch", func(t *testing.T) { + resolver := config.NewResolver(config2) + resolver.Reset() + + if resolver.ElasticsearchClient() != nil { + t.Error("Expected Client to be nil if no host is configured") + } + }) + t.Run("Slack", func(t *testing.T) { + resolver := config.NewResolver(config2) + resolver.Reset() + + if resolver.SlackClient() != nil { + t.Error("Expected Client to be nil if no host is configured") + } + }) + t.Run("Discord", func(t *testing.T) { + resolver := config.NewResolver(config2) + resolver.Reset() + + if resolver.DiscordClient() != nil { + t.Error("Expected Client to be nil if no host is configured") + } + }) } diff --git a/pkg/target/discord/discord.go b/pkg/target/discord/discord.go new file mode 100644 index 00000000..2d891992 --- /dev/null +++ b/pkg/target/discord/discord.go @@ -0,0 +1,152 @@ +package discord + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/fjogeleit/policy-reporter/pkg/report" + "github.com/fjogeleit/policy-reporter/pkg/target" +) + +type payload struct { + Content string `json:"content"` + Embeds []embed `json:"embeds"` +} + +type embed struct { + Title string `json:"title"` + Description string `json:"description"` + Color string `json:"color"` + Fields []embedField `json:"fields"` +} + +type embedField struct { + Name string `json:"name"` + Value string `json:"value"` + Inline bool `json:"inline"` +} + +func newPayload(result report.Result) payload { + var color string + switch result.Priority { + case report.ErrorPriority: + color = "15158332" + case report.WarningPriority: + color = "15105570" + case report.InfoPriority: + color = "3066993" + case report.DebugPriority: + color = "12370112" + } + + embedFields := make([]embedField, 0) + + embedFields = append(embedFields, embedField{"Policy", result.Policy, true}) + + if result.Rule != "" { + embedFields = append(embedFields, embedField{"Rule", result.Rule, true}) + } + + embedFields = append(embedFields, embedField{"Priority", result.Priority.String(), true}) + + if result.Category != "" { + embedFields = append(embedFields, embedField{"Category", result.Category, true}) + } + if result.Severity != "" { + embedFields = append(embedFields, embedField{"Severity", result.Severity, true}) + } + res := report.Resource{} + + if len(result.Resources) > 0 { + res = result.Resources[0] + } + if res.Kind != "" { + embedFields = append(embedFields, embedField{"Kind", res.Kind, true}) + embedFields = append(embedFields, embedField{"Name", res.Name, true}) + if res.Namespace != "" { + embedFields = append(embedFields, embedField{"Namespace", res.Namespace, true}) + } + embedFields = append(embedFields, embedField{"API Version", res.APIVersion, true}) + } + + embeds := make([]embed, 0, 1) + embeds = append(embeds, embed{ + Title: "New Policy Report Result", + Description: result.Message, + Color: color, + Fields: embedFields, + }) + + return payload{ + Content: "", + Embeds: embeds, + } +} + +type httpClient interface { + Do(req *http.Request) (*http.Response, error) +} + +type client struct { + webhook string + minimumPriority string + skipExistingOnStartup bool + client httpClient +} + +func (d *client) Send(result report.Result) { + if result.Priority < report.NewPriority(d.minimumPriority) { + return + } + + payload := newPayload(result) + body := new(bytes.Buffer) + + if err := json.NewEncoder(body).Encode(payload); err != nil { + log.Printf("[ERROR] DISCORD : %v\n", err.Error()) + } + + req, err := http.NewRequest("POST", d.webhook, body) + if err != nil { + log.Printf("[ERROR] DISCORD : %v\n", err.Error()) + } + + req.Header.Add("Content-Type", "application/json; charset=utf-8") + req.Header.Add("User-Agent", "Policy-Reporter") + + resp, err := d.client.Do(req) + defer func() { + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + }() + + if err != nil { + log.Printf("[ERROR] DISCORD PUSH failed: %s\n", err.Error()) + } else if resp.StatusCode >= 400 { + fmt.Printf("StatusCode: %d\n", resp.StatusCode) + buf := new(bytes.Buffer) + buf.ReadFrom(resp.Body) + + log.Printf("[ERROR] DISCORD PUSH failed [%d]: %s\n", resp.StatusCode, buf.String()) + } else { + log.Println("[INFO] DISCORD PUSH OK") + } +} + +func (d *client) SkipExistingOnStartup() bool { + return d.skipExistingOnStartup +} + +// NewClient creates a new loki.client to send Results to Loki +func NewClient(webhook, minimumPriority string, skipExistingOnStartup bool, httpClient httpClient) target.Client { + return &client{ + webhook, + minimumPriority, + skipExistingOnStartup, + httpClient, + } +} diff --git a/pkg/target/discord/discord_test.go b/pkg/target/discord/discord_test.go new file mode 100644 index 00000000..e9f76830 --- /dev/null +++ b/pkg/target/discord/discord_test.go @@ -0,0 +1,90 @@ +package discord_test + +import ( + "net/http" + "testing" + + "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.ErrorPriority, + Status: report.Fail, + Severity: report.Heigh, + Category: "resources", + Scored: true, + Resources: []report.Resource{ + { + APIVersion: "v1", + Kind: "Deployment", + Name: "nginx", + Namespace: "default", + UID: "536ab69f-1b3c-4bd9-9ba4-274a56188409", + }, + }, +} + +var minimalResult = report.Result{ + Message: "validation error: label required. Rule app-label-required failed at path /spec/template/spec/containers/0/resources/requests/", + Policy: "app-label-requirement", + Priority: report.WarningPriority, + Status: report.Fail, + Scored: true, +} + +type testClient struct { + callback func(req *http.Request) + statusCode int +} + +func (c testClient) Do(req *http.Request) (*http.Response, error) { + c.callback(req) + + return &http.Response{ + StatusCode: c.statusCode, + }, nil +} + +func Test_LokiTarget(t *testing.T) { + t.Run("Send Complete Result", func(t *testing.T) { + callback := func(req *http.Request) { + if contentType := req.Header.Get("Content-Type"); contentType != "application/json; charset=utf-8" { + t.Errorf("Unexpected Content-Type: %s", contentType) + } + + if agend := req.Header.Get("User-Agent"); agend != "Policy-Reporter" { + t.Errorf("Unexpected Host: %s", agend) + } + + if url := req.URL.String(); url != "http://hook.discord:80" { + t.Errorf("Unexpected Host: %s", url) + } + } + + slack := discord.NewClient("http://hook.discord:80", "", false, testClient{callback, 200}) + slack.Send(minimalResult) + }) + + t.Run("Send Minimal Result", func(t *testing.T) { + callback := func(req *http.Request) { + if contentType := req.Header.Get("Content-Type"); contentType != "application/json; charset=utf-8" { + t.Errorf("Unexpected Content-Type: %s", contentType) + } + + if agend := req.Header.Get("User-Agent"); agend != "Policy-Reporter" { + t.Errorf("Unexpected Host: %s", agend) + } + + if url := req.URL.String(); url != "http://hook.discord:80" { + t.Errorf("Unexpected Host: %s", url) + } + } + + slack := discord.NewClient("http://hook.discord:80", "", false, testClient{callback, 200}) + slack.Send(minimalResult) + }) +} diff --git a/pkg/target/loki/loki.go b/pkg/target/loki/loki.go index 29c3fd14..511fde5e 100644 --- a/pkg/target/loki/loki.go +++ b/pkg/target/loki/loki.go @@ -60,6 +60,7 @@ func newLokiPayload(result report.Result) payload { if res.Kind != "" { labels = append(labels, "kind=\""+res.Kind+"\"") labels = append(labels, "name=\""+res.Name+"\"") + labels = append(labels, "apiVersion=\""+res.APIVersion+"\"") labels = append(labels, "uid=\""+res.UID+"\"") labels = append(labels, "namespace=\""+res.Namespace+"\"") }