1
0
Fork 0
mirror of https://github.com/kyverno/policy-reporter.git synced 2024-12-14 11:57:32 +00:00

Development (#8)

* Implement Slack as Target
This commit is contained in:
Frank Jogeleit 2021-02-28 10:13:42 +01:00 committed by GitHub
parent 2872a259ec
commit cf69957a5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 441 additions and 68 deletions

View file

@ -1,5 +1,9 @@
# Changelog
## 0.8.0
* Implement Slack as Target for PolicyReportResults
## 0.7.0
* Implement Elasticsearch as Target for PolicyReportResults

View file

@ -3,7 +3,7 @@
## Motivation
Kyverno ships with two types of validation. You can either enforce a rule or audit it. If you don't want to block developers or if you want to try out a new rule, you can use the audit functionality. The audit configuration creates [PolicyReports](https://kyverno.io/docs/policy-reports/) which you can access with `kubectl`. Because I can't find a simple solution to get a general overview of this PolicyReports and PolicyReportResults, I created this tool to send information from PolicyReports to different targets like [Grafana Loki](https://grafana.com/oss/loki/). This tool provides by default an HTTP server with Prometheus Metrics on `http://localhost:2112/metrics` about ReportPolicy Summaries and ReportPolicyRules.
Kyverno ships with two types of validation. You can either enforce a rule or audit it. If you don't want to block developers or if you want to try out a new rule, you can use the audit functionality. The audit configuration creates [PolicyReports](https://kyverno.io/docs/policy-reports/) which you can access with `kubectl`. Because I can't find a simple solution to get a general overview of this PolicyReports and PolicyReportResults, I created this tool to send information from PolicyReports to different targets like [Grafana Loki](https://grafana.com/oss/loki/), [Elasticsearch](https://www.elastic.co/de/elasticsearch/) or [Slack](https://slack.com/). This tool provides by default an HTTP server with Prometheus Metrics on `http://localhost:2112/metrics` about ReportPolicy Summaries and ReportPolicyRules.
This project is in an early stage. Please let me know if anything did not work as expected or if you want to send your audits to other targets then Loki.
@ -35,6 +35,12 @@ 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
@ -63,6 +69,17 @@ elasticsearch:
skipExistingOnStartup: true
```
### Additional configurations for Slack
* Configure `slack.minimumPriority` to send only results with the configured minimumPriority or above, empty means all results. (info < warning < error)
* Configure `slack.skipExistingOnStartup` to skip all results who already existed before the PolicyReporter started (default: `true`).
```yaml
slack:
minimumPriority: ""
skipExistingOnStartup: true
```
### 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.

View file

@ -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.8.0
appVersion: 0.7.0
version: 0.9.0
appVersion: 0.8.0

View file

@ -17,3 +17,8 @@ data:
rotation: {{ .Values.elasticsearch.rotation | default "dayli" | quote }}
minimumPriority: {{ .Values.elasticsearch.minimumPriority | quote }}
skipExistingOnStartup: {{ .Values.elasticsearch.skipExistingOnStartup }}
slack:
webhook: {{ .Values.slack.webhook | quote }}
minimumPriority: {{ .Values.loki.minimumPriority | quote }}
skipExistingOnStartup: {{ .Values.loki.skipExistingOnStartup }}

View file

@ -19,6 +19,13 @@ elasticsearch:
# Skip already existing PolicyReportResults on startup
skipExistingOnStartup: true
slack:
# slack app webhook address
webhook: ""
# minimum priority "" < info < warning < error
minimumPriority: ""
# Skip already existing PolicyReportResults on startup
skipExistingOnStartup: true
metrics:
serviceMonitor: false
@ -29,7 +36,7 @@ metrics:
image:
repository: fjogeleit/policy-reporter
pullPolicy: IfNotPresent
tag: 0.7.0
tag: 0.8.0
imagePullSecrets: []

View file

@ -1,19 +1,33 @@
package config
// Loki configuration
type Loki struct {
Host string `mapstructure:"host"`
SkipExisting bool `mapstructure:"skipExistingOnStartup"`
MinimumPriority string `mapstructure:"minimumPriority"`
}
// Elasticsearch configuration
type Elasticsearch struct {
Host string `mapstructure:"host"`
Index string `mapstructure:"index"`
Rotation string `mapstructure:"rotation"`
SkipExisting bool `mapstructure:"skipExistingOnStartup"`
MinimumPriority string `mapstructure:"minimumPriority"`
}
// Slack configuration
type Slack struct {
Webhook string `mapstructure:"webhook"`
SkipExisting bool `mapstructure:"skipExistingOnStartup"`
MinimumPriority string `mapstructure:"minimumPriority"`
}
// Config of the PolicyReporter
type Config struct {
Loki struct {
Host string `mapstructure:"host"`
SkipExisting bool `mapstructure:"skipExistingOnStartup"`
MinimumPriority string `mapstructure:"minimumPriority"`
} `mapstructure:"loki"`
Elasticsearch struct {
Host string `mapstructure:"host"`
Index string `mapstructure:"index"`
Rotation string `mapstructure:"rotation"`
SkipExisting bool `mapstructure:"skipExistingOnStartup"`
MinimumPriority string `mapstructure:"minimumPriority"`
} `mapstructure:"elasticsearch"`
Kubeconfig string `mapstructure:"kubeconfig"`
Namespace string `mapstructure:"namespace"`
Loki Loki `mapstructure:"loki"`
Elasticsearch Elasticsearch `mapstructure:"elasticsearch"`
Slack Slack `mapstructure:"slack"`
Kubeconfig string `mapstructure:"kubeconfig"`
Namespace string `mapstructure:"namespace"`
}

View file

@ -11,6 +11,7 @@ import (
"github.com/fjogeleit/policy-reporter/pkg/target"
"github.com/fjogeleit/policy-reporter/pkg/target/elasticsearch"
"github.com/fjogeleit/policy-reporter/pkg/target/loki"
"github.com/fjogeleit/policy-reporter/pkg/target/slack"
)
// Resolver manages dependencies
@ -19,6 +20,7 @@ type Resolver struct {
kubeClient report.Client
lokiClient target.Client
elasticsearchClient target.Client
slackClient target.Client
policyReportMetrics metrics.Metrics
clusterPolicyReportMetrics metrics.Metrics
}
@ -89,6 +91,26 @@ func (r *Resolver) ElasticsearchClient() target.Client {
return r.elasticsearchClient
}
// ElasticsearchClient resolver method
func (r *Resolver) SlackClient() target.Client {
if r.slackClient != nil {
return r.slackClient
}
if r.config.Slack.Webhook == "" {
return nil
}
r.slackClient = slack.NewClient(
r.config.Slack.Webhook,
r.config.Slack.MinimumPriority,
r.config.Slack.SkipExisting,
&http.Client{},
)
return r.slackClient
}
// PolicyReportMetrics resolver method
func (r *Resolver) PolicyReportMetrics() (metrics.Metrics, error) {
if r.policyReportMetrics != nil {
@ -132,6 +154,10 @@ func (r *Resolver) TargetClients() []target.Client {
clients = append(clients, elasticsearch)
}
if slack := r.SlackClient(); slack != nil {
clients = append(clients, slack)
}
return clients
}

View file

@ -7,28 +7,23 @@ import (
)
var testConfig = &config.Config{
Loki: struct {
Host string "mapstructure:\"host\""
SkipExisting bool "mapstructure:\"skipExistingOnStartup\""
MinimumPriority string "mapstructure:\"minimumPriority\""
}{
Loki: config.Loki{
Host: "http://localhost:3100",
SkipExisting: true,
MinimumPriority: "debug",
},
Elasticsearch: struct {
Host string "mapstructure:\"host\""
Index string "mapstructure:\"index\""
Rotation string "mapstructure:\"rotation\""
SkipExisting bool "mapstructure:\"skipExistingOnStartup\""
MinimumPriority string "mapstructure:\"minimumPriority\""
}{
Elasticsearch: config.Elasticsearch{
Host: "http://localhost:9200",
Index: "policy-reporter",
Rotation: "dayli",
SkipExisting: true,
MinimumPriority: "debug",
},
Slack: config.Slack{
Webhook: "http://localhost:80",
SkipExisting: true,
MinimumPriority: "debug",
},
}
func Test_ResolveLokiClient(t *testing.T) {
@ -59,33 +54,37 @@ func Test_ResolveElasticSearchClient(t *testing.T) {
}
}
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")
}
}
func Test_ResolveTargets(t *testing.T) {
resolver := config.NewResolver(testConfig)
clients := resolver.TargetClients()
if count := len(clients); count != 2 {
t.Errorf("Expected 2 Clients, got %d", count)
if count := len(clients); count != 3 {
t.Errorf("Expected 3 Clients, got %d", count)
}
}
func Test_ResolveSkipExistingOnStartup(t *testing.T) {
var testConfig = &config.Config{
Loki: struct {
Host string "mapstructure:\"host\""
SkipExisting bool "mapstructure:\"skipExistingOnStartup\""
MinimumPriority string "mapstructure:\"minimumPriority\""
}{
Loki: config.Loki{
Host: "http://localhost:3100",
SkipExisting: true,
MinimumPriority: "debug",
},
Elasticsearch: struct {
Host string "mapstructure:\"host\""
Index string "mapstructure:\"index\""
Rotation string "mapstructure:\"rotation\""
SkipExisting bool "mapstructure:\"skipExistingOnStartup\""
MinimumPriority string "mapstructure:\"minimumPriority\""
}{
Elasticsearch: config.Elasticsearch{
Host: "http://localhost:9200",
Index: "policy-reporter",
Rotation: "dayli",
@ -117,11 +116,7 @@ func Test_ResolveSkipExistingOnStartup(t *testing.T) {
func Test_ResolveLokiClientWithoutHost(t *testing.T) {
config2 := &config.Config{
Loki: struct {
Host string "mapstructure:\"host\""
SkipExisting bool "mapstructure:\"skipExistingOnStartup\""
MinimumPriority string "mapstructure:\"minimumPriority\""
}{
Loki: config.Loki{
Host: "",
SkipExisting: true,
MinimumPriority: "debug",
@ -138,13 +133,7 @@ func Test_ResolveLokiClientWithoutHost(t *testing.T) {
func Test_ResolveElasticsearchClientWithoutHost(t *testing.T) {
config2 := &config.Config{
Elasticsearch: struct {
Host string "mapstructure:\"host\""
Index string "mapstructure:\"index\""
Rotation string "mapstructure:\"rotation\""
SkipExisting bool "mapstructure:\"skipExistingOnStartup\""
MinimumPriority string "mapstructure:\"minimumPriority\""
}{
Elasticsearch: config.Elasticsearch{
Host: "",
Index: "policy-reporter",
Rotation: "dayli",
@ -159,3 +148,19 @@ func Test_ResolveElasticsearchClientWithoutHost(t *testing.T) {
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",
},
}
resolver := config.NewResolver(config2)
if resolver.SlackClient() != nil {
t.Error("Expected Client to be nil if no host is configured")
}
}

View file

@ -43,7 +43,7 @@ func (e *client) Send(result report.Result) {
body := new(bytes.Buffer)
if err := json.NewEncoder(body).Encode(result); err != nil {
log.Printf("[ERROR] : %v\n", err.Error())
log.Printf("[ERROR] ELASTICSEARCH : %v\n", err.Error())
}
var host string
@ -60,7 +60,7 @@ func (e *client) Send(result report.Result) {
req, err := http.NewRequest("POST", host, body)
if err != nil {
log.Printf("[ERROR] : %v\n", err.Error())
log.Printf("[ERROR] ELASTICSEARCH : %v\n", err.Error())
}
req.Header.Add("Content-Type", "application/json; charset=utf-8")
@ -74,15 +74,15 @@ func (e *client) Send(result report.Result) {
}()
if err != nil {
log.Printf("[ERROR] PUSH failed: %s\n", err.Error())
} else if resp.StatusCode > 400 {
log.Printf("[ERROR] ELASTICSEARCH 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] PUSH failed [%d]: %s\n", resp.StatusCode, buf.String())
log.Printf("[ERROR] ELASTICSEARCH PUSH failed [%d]: %s\n", resp.StatusCode, buf.String())
} else {
log.Println("[INFO] PUSH OK")
log.Println("[INFO] ELASTICSEARCH PUSH OK")
}
}

View file

@ -85,12 +85,12 @@ func (l *client) Send(result report.Result) {
body := new(bytes.Buffer)
if err := json.NewEncoder(body).Encode(payload); err != nil {
log.Printf("[ERROR] : %v\n", err.Error())
log.Printf("[ERROR] LOKI : %v\n", err.Error())
}
req, err := http.NewRequest("POST", l.host, body)
if err != nil {
log.Printf("[ERROR] : %v\n", err.Error())
log.Printf("[ERROR] LOKI : %v\n", err.Error())
}
req.Header.Add("Content-Type", "application/json")
@ -104,15 +104,15 @@ func (l *client) Send(result report.Result) {
}()
if err != nil {
log.Printf("[ERROR] PUSH failed: %s\n", err.Error())
} else if resp.StatusCode > 400 {
log.Printf("[ERROR] LOKI 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] PUSH failed [%d]: %s\n", resp.StatusCode, buf.String())
log.Printf("[ERROR] LOKI PUSH failed [%d]: %s\n", resp.StatusCode, buf.String())
} else {
log.Println("[INFO] PUSH OK")
log.Println("[INFO] LOKI PUSH OK")
}
}

205
pkg/target/slack/slack.go Normal file
View file

@ -0,0 +1,205 @@
package slack
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/fjogeleit/policy-reporter/pkg/report"
"github.com/fjogeleit/policy-reporter/pkg/target"
)
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
type text struct {
Type string `json:"type"`
Text string `json:"text"`
}
type block struct {
Type string `json:"type"`
Text *text `json:"text,omitempty"`
Fields []field `json:"fields,omitempty"`
}
type field struct {
Type string `json:"type"`
Text string `json:"text"`
}
type attachment struct {
Color string `json:"color"`
Blocks []block `json:"blocks"`
}
type payload struct {
Username string `json:"username,omitempty"`
Attachments []attachment `json:"attachments,omitempty"`
}
type client struct {
webhook string
minimumPriority string
skipExistingOnStartup bool
client httpClient
}
func colorFromPriority(p report.Priority) string {
if p == report.ErrorPriority {
return "#e20b0b"
}
if p == report.WarningPriority {
return "#f2c744"
}
if p == report.InfoPriority {
return "#36a64f"
}
return "#68c2ff"
}
func (s *client) newPayload(result report.Result) payload {
p := payload{
Attachments: make([]attachment, 0, 1),
}
att := attachment{
Color: colorFromPriority(result.Priority),
Blocks: make([]block, 0),
}
policyBlock := block{
Type: "section",
Fields: []field{{Type: "mrkdwn", Text: "*Policy*\n" + result.Policy}},
}
if result.Rule != "" {
policyBlock.Fields = append(policyBlock.Fields, field{Type: "mrkdwn", Text: "*Rule*\n" + result.Rule})
}
att.Blocks = append(
att.Blocks,
block{Type: "header", Text: &text{Type: "plain_text", Text: "New Policy Report Result"}},
policyBlock,
)
att.Blocks = append(
att.Blocks,
block{Type: "section", Text: &text{Type: "mrkdwn", Text: "*Message*\n" + result.Message}},
block{
Type: "section",
Fields: []field{
{Type: "mrkdwn", Text: "*Priority*\n" + result.Priority.String()},
{Type: "mrkdwn", Text: "*Status*\n" + result.Status},
},
},
)
b := block{
Type: "section",
Fields: make([]field, 0, 2),
}
if result.Category != "" {
b.Fields = append(b.Fields, field{Type: "mrkdwn", Text: "*Category*\n" + result.Category})
}
if result.Severity != "" {
b.Fields = append(b.Fields, field{Type: "mrkdwn", Text: "*Severity*\n" + result.Severity})
}
if len(b.Fields) > 0 {
att.Blocks = append(att.Blocks, b)
}
res := report.Resource{}
if len(result.Resources) > 0 {
res = result.Resources[0]
}
if res.Kind != "" {
att.Blocks = append(
att.Blocks,
block{Type: "section", Text: &text{Type: "mrkdwn", Text: "*Resource*"}},
block{
Type: "section",
Fields: []field{
{Type: "mrkdwn", Text: "*Kind*\n" + res.Kind},
{Type: "mrkdwn", Text: "*API Version*\n" + res.APIVersion},
},
},
block{
Type: "section",
Fields: []field{
{Type: "mrkdwn", Text: "*Name*\n" + res.Name},
{Type: "mrkdwn", Text: "*UID*\n" + res.UID},
},
},
)
}
if res.Namespace != "" {
att.Blocks = append(att.Blocks, block{Type: "section", Fields: []field{{Type: "mrkdwn", Text: "*Namespace*\n" + res.Namespace}}})
}
p.Attachments = append(p.Attachments, att)
return p
}
func (s *client) Send(result report.Result) {
if result.Priority < report.NewPriority(s.minimumPriority) {
return
}
payload := s.newPayload(result)
body := new(bytes.Buffer)
if err := json.NewEncoder(body).Encode(payload); err != nil {
log.Printf("[ERROR] SLACK : %v\n", err.Error())
}
req, err := http.NewRequest("POST", s.webhook, body)
if err != nil {
log.Printf("[ERROR] SLACK : %v\n", err.Error())
}
req.Header.Add("Content-Type", "application/json; charset=utf-8")
req.Header.Add("User-Agent", "Policy-Reporter")
resp, err := s.client.Do(req)
defer func() {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
}()
if err != nil {
log.Printf("[ERROR] SLACK 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] PUSH failed [%d]: %s\n", resp.StatusCode, buf.String())
} else {
log.Println("[INFO] SLACK PUSH OK")
}
}
func (s *client) SkipExistingOnStartup() bool {
return s.skipExistingOnStartup
}
// NewClient creates a new slack.client to send Results to Loki
func NewClient(host, minimumPriority string, skipExistingOnStartup bool, httpClient httpClient) target.Client {
return &client{
host,
minimumPriority,
skipExistingOnStartup,
httpClient,
}
}

View file

@ -0,0 +1,90 @@
package slack_test
import (
"net/http"
"testing"
"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.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.slack:80" {
t.Errorf("Unexpected Host: %s", url)
}
}
slack := slack.NewClient("http://hook.slack: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.slack:80" {
t.Errorf("Unexpected Host: %s", url)
}
}
slack := slack.NewClient("http://hook.slack:80", "", false, testClient{callback, 200})
slack.Send(minimalResult)
})
}