1
0
Fork 0
mirror of https://github.com/kyverno/policy-reporter.git synced 2024-12-15 17:50:58 +00:00

Merge pull request #341 from kyverno/telegram-target

Telegram push target support
This commit is contained in:
Frank Jogeleit 2023-09-04 13:51:39 +02:00 committed by GitHub
commit 83366ace58
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 478 additions and 2 deletions

View file

@ -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 }}

View file

@ -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: ""

View file

@ -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"`

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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 {

View file

@ -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,
}
}

View file

@ -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())
}
})
}