diff --git a/charts/policy-reporter/config.yaml b/charts/policy-reporter/config.yaml index fc8af8d2..0094b7dd 100644 --- a/charts/policy-reporter/config.yaml +++ b/charts/policy-reporter/config.yaml @@ -150,6 +150,33 @@ webhook: {{- toYaml . | nindent 4 }} {{- end }} +telegram: + token: {{ .Values.target.telegram.token | quote }} + chatID: {{ .Values.target.telegram.chatID | quote }} + host: {{ .Values.target.telegram.host | quote }} + certificate: {{ .Values.target.telegram.certificate | quote }} + skipTLS: {{ .Values.target.telegram.skipTLS }} + secretRef: {{ .Values.target.telegram.secretRef | quote }} + mountedSecret: {{ .Values.target.telegram.mountedSecret | quote }} + minimumPriority: {{ .Values.target.telegram.minimumPriority | quote }} + skipExistingOnStartup: {{ .Values.target.telegram.skipExistingOnStartup }} + {{- with .Values.target.telegram.sources }} + sources: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.target.telegram.customFields }} + customFields: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.target.telegram.filter }} + filter: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.target.telegram.channels }} + channels: + {{- toYaml . | nindent 4 }} + {{- end }} + ui: host: {{ include "policyreporter.uihost" . }} certificate: {{ .Values.target.ui.certificate | quote }} diff --git a/charts/policy-reporter/values.yaml b/charts/policy-reporter/values.yaml index 6cc9d488..3ff0d439 100644 --- a/charts/policy-reporter/values.yaml +++ b/charts/policy-reporter/values.yaml @@ -483,6 +483,37 @@ target: # add additional webhook channels with different configurations and filters channels: [] + telegram: + # telegram bot token + token: "" + # telegram chat id + chatID: "" + # optional telegram proxy host + host: "" + # path to your custom certificate + # can be added under extraVolumes + certificate: "" + # skip TLS verification if necessary + skipTLS: false + # receive the host and/or token from an existing secret, the token is added as Authorization header + secretRef: "" + # Mounted secret path by Secrets Controller, secret should be in json format + mountedSecret: "" + # additional http headers + headers: {} + # minimum priority "" < info < warning < critical < error + minimumPriority: "" + # list of sources which should send to telegram + sources: [] + # Skip already existing PolicyReportResults on startup + skipExistingOnStartup: true + # Added as additional properties to each notification + customFields: {} + # filter results send by namespaces, policies and priorities + filter: {} + # add additional telegram channels with different configurations and filters + channels: [] + s3: # S3 access key accessKeyID: "" diff --git a/pkg/config/config.go b/pkg/config/config.go index 98614a05..c58a8fe7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -103,6 +103,18 @@ type Webhook struct { Channels []Webhook `mapstructure:"channels"` } +// Telegram configuration +type Telegram struct { + TargetBaseOptions `mapstructure:",squash"` + Host string `mapstructure:"host"` + Token string `mapstructure:"token"` + ChatID string `mapstructure:"chatID"` + SkipTLS bool `mapstructure:"skipTLS"` + Certificate string `mapstructure:"certificate"` + Headers map[string]string `mapstructure:"headers"` + Channels []Telegram `mapstructure:"channels"` +} + type AWSConfig struct { AccessKeyID string `mapstructure:"accessKeyID"` SecretAccessKey string `mapstructure:"secretAccessKey"` @@ -282,6 +294,7 @@ type Config struct { GCS GCS `mapstructure:"gcs"` UI UI `mapstructure:"ui"` Webhook Webhook `mapstructure:"webhook"` + Telegram Telegram `mapstructure:"telegram"` API API `mapstructure:"api"` WorkerCount int `mapstructure:"worker"` DBFile string `mapstructure:"dbfile"` diff --git a/pkg/config/resolver.go b/pkg/config/resolver.go index a09a6ae6..5c2226a7 100644 --- a/pkg/config/resolver.go +++ b/pkg/config/resolver.go @@ -279,6 +279,7 @@ func (r *Resolver) TargetClients() []target.Client { clients = append(clients, factory.SecurityHubs(r.config.SecurityHub)...) clients = append(clients, factory.WebhookClients(r.config.Webhook)...) clients = append(clients, factory.GCSClients(r.config.GCS)...) + clients = append(clients, factory.TelegramClients(r.config.Telegram)...) if ui := factory.UIClient(r.config.UI); ui != nil { clients = append(clients, ui) diff --git a/pkg/config/resolver_test.go b/pkg/config/resolver_test.go index be8bd6f0..f37d7e0a 100644 --- a/pkg/config/resolver_test.go +++ b/pkg/config/resolver_test.go @@ -175,13 +175,22 @@ var testConfig = &config.Config{ Encryption: "ssl/tls", }, }, + Telegram: config.Telegram{ + Token: "XXX", + ChatID: "123456", + Channels: []config.Telegram{ + { + ChatID: "1234567", + }, + }, + }, } func Test_ResolveTargets(t *testing.T) { resolver := config.NewResolver(testConfig, &rest.Config{}) - if count := len(resolver.TargetClients()); count != 22 { - t.Errorf("Expected 22 Clients, got %d", count) + if count := len(resolver.TargetClients()); count != 24 { + t.Errorf("Expected 24 Clients, got %d", count) } } diff --git a/pkg/config/target_factory.go b/pkg/config/target_factory.go index ad0914a8..852c040f 100644 --- a/pkg/config/target_factory.go +++ b/pkg/config/target_factory.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strings" _ "github.com/mattn/go-sqlite3" "go.uber.org/zap" @@ -23,6 +24,7 @@ import ( "github.com/kyverno/policy-reporter/pkg/target/securityhub" "github.com/kyverno/policy-reporter/pkg/target/slack" "github.com/kyverno/policy-reporter/pkg/target/teams" + "github.com/kyverno/policy-reporter/pkg/target/telegram" "github.com/kyverno/policy-reporter/pkg/target/ui" "github.com/kyverno/policy-reporter/pkg/target/webhook" ) @@ -284,6 +286,29 @@ func (f *TargetFactory) GCSClients(config GCS) []target.Client { return clients } +// TelegramClients resolver method +func (f *TargetFactory) TelegramClients(config Telegram) []target.Client { + clients := make([]target.Client, 0) + if config.Name == "" { + config.Name = "Telegram" + } + + if es := f.createTelegramClient(config, Telegram{}); es != nil { + clients = append(clients, es) + } + for i, channel := range config.Channels { + if channel.Name == "" { + channel.Name = fmt.Sprintf("Webhook Channel %d", i+1) + } + + if es := f.createTelegramClient(channel, config); es != nil { + clients = append(clients, es) + } + } + + return clients +} + func (f *TargetFactory) createSlackClient(config Slack, parent Slack) target.Client { if (config.SecretRef != "" && f.secretClient != nil) || config.MountedSecret != "" { f.mapSecretValues(&config, config.SecretRef, config.MountedSecret) @@ -576,6 +601,73 @@ func (f *TargetFactory) createWebhookClient(config Webhook, parent Webhook) targ }) } +func (f *TargetFactory) createTelegramClient(config Telegram, parent Telegram) target.Client { + if (config.SecretRef != "" && f.secretClient != nil) || config.MountedSecret != "" { + f.mapSecretValues(&config, config.SecretRef, config.MountedSecret) + } + + if config.Token == "" { + config.Token = parent.Token + } + + if config.ChatID == "" || config.Token == "" { + return nil + } + + if config.Host == "" { + config.Host = parent.Host + } + + if config.Certificate == "" { + config.Certificate = parent.Certificate + } + + if !config.SkipTLS { + config.SkipTLS = parent.SkipTLS + } + + if config.MinimumPriority == "" { + config.MinimumPriority = parent.MinimumPriority + } + + if !config.SkipExisting { + config.SkipExisting = parent.SkipExisting + } + + if len(parent.Headers) > 0 { + headers := map[string]string{} + for header, value := range parent.Headers { + headers[header] = value + } + for header, value := range config.Headers { + headers[header] = value + } + + config.Headers = headers + } + + host := "https://api.telegram.org" + if config.Host != "" { + host = strings.TrimSuffix(config.Host, "/") + } + + zap.S().Infof("%s configured", config.Name) + + return telegram.NewClient(telegram.Options{ + ClientOptions: target.ClientOptions{ + Name: config.Name, + SkipExistingOnStartup: config.SkipExisting, + ResultFilter: createResultFilter(config.Filter, config.MinimumPriority, config.Sources), + ReportFilter: createReportFilter(config.Filter), + }, + Host: fmt.Sprintf("%s/bot%s/sendMessage", host, config.Token), + ChatID: config.ChatID, + Headers: config.Headers, + CustomFields: config.CustomFields, + HTTPClient: http.NewClient(config.Certificate, config.SkipTLS), + }) +} + func (f *TargetFactory) createS3Client(config S3, parent S3) target.Client { if (config.SecretRef != "" && f.secretClient != nil) || config.MountedSecret != "" { f.mapSecretValues(&config, config.SecretRef, config.MountedSecret) @@ -959,6 +1051,13 @@ func (f *TargetFactory) mapSecretValues(config any, ref, mountedSecret string) { c.Headers["Authorization"] = values.Token } + case *Telegram: + if values.Token != "" { + c.Token = values.Token + } + if values.Host != "" { + c.Host = values.Host + } } } diff --git a/pkg/config/target_factory_test.go b/pkg/config/target_factory_test.go index c0cb9f40..a1efb476 100644 --- a/pkg/config/target_factory_test.go +++ b/pkg/config/target_factory_test.go @@ -102,6 +102,12 @@ func Test_ResolveTarget(t *testing.T) { t.Errorf("Expected 2 Client, got %d clients", len(clients)) } }) + t.Run("Telegram", func(t *testing.T) { + clients := factory.TelegramClients(testConfig.Telegram) + if len(clients) != 2 { + t.Errorf("Expected 2 Client, got %d clients", len(clients)) + } + }) t.Run("S3", func(t *testing.T) { clients := factory.S3Clients(testConfig.S3) if len(clients) != 2 { @@ -161,6 +167,11 @@ func Test_ResolveTargetWithoutHost(t *testing.T) { t.Error("Expected Client to be nil if no host is configured") } }) + t.Run("Telegram", func(t *testing.T) { + if len(factory.TelegramClients(config.Telegram{})) != 0 { + t.Error("Expected Client to be nil if no chatID is configured") + } + }) t.Run("S3.Endoint", func(t *testing.T) { if len(factory.S3Clients(config.S3{})) != 0 { t.Error("Expected Client to be nil if no endpoint is configured") @@ -358,6 +369,20 @@ func Test_GetValuesFromSecret(t *testing.T) { } }) + t.Run("Get Telegram Token from Secret", func(t *testing.T) { + clients := factory.TelegramClients(config.Telegram{TargetBaseOptions: config.TargetBaseOptions{SecretRef: secretName}, ChatID: "1234"}) + if len(clients) != 1 { + t.Error("Expected one client created") + } + + client := reflect.ValueOf(clients[0]).Elem() + + host := client.FieldByName("host").String() + if host != "http://localhost:9200/bottoken/sendMessage" { + t.Errorf("Expected host with token from secret, got %s", host) + } + }) + t.Run("Get S3 values from Secret", func(t *testing.T) { clients := factory.S3Clients(config.S3{TargetBaseOptions: config.TargetBaseOptions{SecretRef: secretName}, AWSConfig: config.AWSConfig{Endpoint: "endoint", Region: "region"}, Bucket: "bucket"}) if len(clients) != 1 { @@ -458,6 +483,19 @@ func Test_GetValuesFromSecret(t *testing.T) { t.Errorf("Expected customFields are added") } }) + t.Run("Get CustomFields from Telegram", func(t *testing.T) { + clients := factory.TelegramClients(config.Telegram{TargetBaseOptions: config.TargetBaseOptions{CustomFields: map[string]string{"field": "value"}}, Token: "XXX", ChatID: "1234"}) + if len(clients) != 1 { + t.Error("Expected one client created") + } + + client := reflect.ValueOf(clients[0]).Elem() + + customFields := client.FieldByName("customFields").MapKeys() + if customFields[0].String() != "field" { + t.Errorf("Expected customFields are added") + } + }) t.Run("Get CustomFields from Kinesis", func(t *testing.T) { clients := factory.KinesisClients(testConfig.Kinesis) if len(clients) < 1 { @@ -612,6 +650,20 @@ func Test_GetValuesFromMountedSecret(t *testing.T) { } }) + t.Run("Get Telegram Token from MountedSecret", func(t *testing.T) { + clients := factory.TelegramClients(config.Telegram{TargetBaseOptions: config.TargetBaseOptions{MountedSecret: mountedSecret}, ChatID: "123"}) + if len(clients) != 1 { + t.Error("Expected one client created") + } + + client := reflect.ValueOf(clients[0]).Elem() + + token := client.FieldByName("host").String() + if token != "http://localhost:9200/bottoken/sendMessage" { + t.Errorf("Expected token from mounted secret, got %s", token) + } + }) + t.Run("Get S3 values from MountedSecret", func(t *testing.T) { clients := factory.S3Clients(config.S3{TargetBaseOptions: config.TargetBaseOptions{MountedSecret: mountedSecret}, AWSConfig: config.AWSConfig{Endpoint: "endpoint", Region: "region"}, Bucket: "bucket"}) if len(clients) != 1 { diff --git a/pkg/target/telegram/telegram.go b/pkg/target/telegram/telegram.go new file mode 100644 index 00000000..7911c1bb --- /dev/null +++ b/pkg/target/telegram/telegram.go @@ -0,0 +1,165 @@ +package telegram + +import ( + "bytes" + "fmt" + "strings" + "text/template" + "time" + + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + + "github.com/kyverno/policy-reporter/pkg/crd/api/policyreport/v1alpha2" + "github.com/kyverno/policy-reporter/pkg/target" + "github.com/kyverno/policy-reporter/pkg/target/http" +) + +var replacer = strings.NewReplacer( + "_", "\\_", "*", "\\*", "[", "\\[", "]", "\\]", "(", + "\\(", ")", "\\)", "~", "\\~", "`", "\\`", ">", "\\>", + "#", "\\#", "+", "\\+", "-", "\\-", "=", "\\=", "|", + "\\|", "{", "\\{", "}", "\\}", ".", "\\.", "!", "\\!", +) + +func escape(text interface{}) string { + return replacer.Replace(fmt.Sprintf("%v", text)) +} + +var ( + notificationTempl = `*\[Policy Reporter\] \[{{ .Priority }}\] {{ escape (or .Result.Policy .Result.Rule) }}* +{{- if .Resource }} + +*Resource*: {{ .Resource.Kind }} {{ if .Resource.Namespace }}{{ escape .Resource.Namespace }}/{{ end }}{{ escape .Resource.Name }} + +{{- end }} + +*Status*: {{ escape .Result.Result }} +*Time*: {{ escape (.Time.Format "02 Jan 06 15:04 MST") }} + +{{ if .Result.Category }}*Category*: {{ escape .Result.Category }}{{ end }} +{{ if .Result.Policy }}*Rule*: {{ escape .Result.Rule }}{{ end }} +*Source*: {{ escape .Result.Source }} + +*Message*: + +{{ escape .Result.Message }} + +*Properties*: +{{ range $key, $value := .Result.Properties }}• *{{ escape $key }}*: {{ escape $value }} +{{ end }} +` +) + +type Payload struct { + Text string `json:"text,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + DisableWebPagePreview bool `json:"disable_web_page_preview,omitempty"` + ChatID string `json:"chat_id,omitempty"` +} + +type values struct { + Result v1alpha2.PolicyReportResult + Time time.Time + Resource *corev1.ObjectReference + Props map[string]string + Priority string +} + +// Options to configure the Discord target +type Options struct { + target.ClientOptions + ChatID string + Host string + Headers map[string]string + CustomFields map[string]string + HTTPClient http.Client +} + +type client struct { + target.BaseClient + chatID string + host string + headers map[string]string + customFields map[string]string + client http.Client +} + +func (e *client) Send(result v1alpha2.PolicyReportResult) { + if len(e.customFields) > 0 { + props := make(map[string]string, 0) + + for property, value := range e.customFields { + props[property] = value + } + + for property, value := range result.Properties { + props[property] = value + } + + result.Properties = props + } + + payload := Payload{ + ParseMode: "MarkdownV2", + DisableWebPagePreview: true, + ChatID: e.chatID, + } + + var textBuffer bytes.Buffer + + ttmpl, err := template.New("telegram").Funcs(template.FuncMap{"escape": escape}).Parse(notificationTempl) + if err != nil { + zap.L().Error(e.Name()+": PUSH FAILED", zap.Error(err)) + return + } + + var res *corev1.ObjectReference + if result.HasResource() { + res = result.GetResource() + } + + var prio = result.Priority.String() + if prio == "" { + prio = v1alpha2.DebugPriority.String() + } + + err = ttmpl.Execute(&textBuffer, values{ + Result: result, + Time: time.Now(), + Resource: res, + Priority: prio, + }) + if err != nil { + zap.L().Error(e.Name()+": PUSH FAILED", zap.Error(err)) + return + } + + payload.Text = textBuffer.String() + + req, err := http.CreateJSONRequest(e.Name(), "POST", e.host, payload) + if err != nil { + zap.L().Error(e.Name()+": PUSH FAILED", zap.Error(err)) + fmt.Println(err) + return + } + + for header, value := range e.headers { + req.Header.Set(header, value) + } + + resp, err := e.client.Do(req) + http.ProcessHTTPResponse(e.Name(), resp, err) +} + +// NewClient creates a new loki.client to send Results to Elasticsearch +func NewClient(options Options) target.Client { + return &client{ + target.NewBaseClient(options.ClientOptions), + options.ChatID, + options.Host, + options.Headers, + options.CustomFields, + options.HTTPClient, + } +} diff --git a/pkg/target/telegram/telegram_test.go b/pkg/target/telegram/telegram_test.go new file mode 100644 index 00000000..9f5bd3ab --- /dev/null +++ b/pkg/target/telegram/telegram_test.go @@ -0,0 +1,79 @@ +package telegram_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/kyverno/policy-reporter/pkg/fixtures" + "github.com/kyverno/policy-reporter/pkg/target" + "github.com/kyverno/policy-reporter/pkg/target/telegram" +) + +type testClient struct { + callback func(req *http.Request) error + statusCode int +} + +func (c testClient) Do(req *http.Request) (*http.Response, error) { + err := c.callback(req) + + return &http.Response{ + StatusCode: c.statusCode, + Body: io.NopCloser(strings.NewReader("")), + }, err +} + +func Test_TelegramTarget(t *testing.T) { + t.Run("Send", func(t *testing.T) { + callback := func(req *http.Request) error { + 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 != "https://api.telegram.org/botXXX/sendMessage" { + t.Errorf("Unexpected Host: %s", url) + } + + if value := req.Header.Get("X-Code"); value != "1234" { + t.Errorf("Unexpected Header X-Code: %s", value) + } + + return nil + } + + client := telegram.NewClient(telegram.Options{ + ClientOptions: target.ClientOptions{ + Name: "Telegram", + }, + Host: "https://api.telegram.org/botXXX/sendMessage", + Headers: map[string]string{"X-Code": "1234"}, + CustomFields: map[string]string{"cluster": "name"}, + HTTPClient: testClient{callback, 200}, + }) + client.Send(fixtures.CompleteTargetSendResult) + + if len(fixtures.CompleteTargetSendResult.Properties) > 1 || fixtures.CompleteTargetSendResult.Properties["cluster"] != "" { + t.Error("expected customFields are not added to the actuel result") + } + }) + t.Run("Name", func(t *testing.T) { + client := telegram.NewClient(telegram.Options{ + ClientOptions: target.ClientOptions{ + Name: "Telegram", + }, + Host: "https://api.telegram.org/botXXX/sendMessage", + Headers: map[string]string{"X-Code": "1234"}, + HTTPClient: testClient{}, + }) + + if client.Name() != "Telegram" { + t.Errorf("Unexpected Name %s", client.Name()) + } + }) +}