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

Email Reports (#164)

* add E-Mail reports

Signed-off-by: Frank Jogeleit <frank.jogeleit@web.de>
This commit is contained in:
Frank Jogeleit 2022-07-03 23:49:16 +02:00 committed by GitHub
parent 7d0440fbca
commit 3e443f126a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 3892 additions and 143 deletions

View file

@ -1,7 +1,16 @@
# Changelog
# 2.10.0
* Policy Reporter
* Email Reports
* Send Summary Reports over SMTP to different E-Mails
* Supports channels and filters to send different subsets of Namespaces or Sources to dedicated E-Mails
* Reports are generated and send over dedicated CronJobs, this makes it easy to send the reports as often as needed
* Currently a basic summary and a more detailed violation report is available and can be separatly enabled and configured
# 2.9.5
* Fix Policy Reporter Version in the Helm Chart values.yaml
# 2.9.4
* Policy Reporter
* Add [AWS Kinesis](https://aws.amazon.com/kinesis) compatible target

View file

@ -24,6 +24,7 @@ WORKDIR /app
USER 1234
COPY --from=builder /app/LICENSE.md .
COPY --from=builder /app/templates /app/templates
COPY --from=builder /app/build/policyreporter /app/policyreporter
# copy the debian's trusted root CA's to the final image
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

View file

@ -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: 2.9.5
appVersion: 2.6.2
version: 2.10.0
appVersion: 2.7.0
icon: https://github.com/kyverno/kyverno/raw/main/img/logo.png
home: https://kyverno.github.io/policy-reporter

View file

@ -0,0 +1,34 @@
emailReports:
clusterName: {{ .Values.emailReports.clusterName }}
{{- with .Values.emailReports.smtp }}
smtp:
{{- toYaml . | nindent 4 }}
{{- end }}
summary:
{{- with .Values.emailReports.summary.to }}
to:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with .Values.emailReports.summary.filter }}
filter:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with .Values.emailReports.violations.channels }}
channels:
{{- toYaml . | nindent 6 }}
{{- end }}
violations:
{{- with .Values.emailReports.violations.to }}
to:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with .Values.emailReports.violations.filter }}
filter:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with .Values.emailReports.violations.channels }}
channels:
{{- toYaml . | nindent 6 }}
{{- end }}

View file

@ -78,8 +78,8 @@ Create UI target host based on configuration
{{- define "kyverno.securityContext" -}}
{{- if semverCompare "<1.19" .Capabilities.KubeVersion.Version }}
{{ toYaml (omit .Values.securityContext "seccompProfile") }}
{{- toYaml (omit .Values.securityContext "seccompProfile") }}
{{- else }}
{{ toYaml .Values.securityContext }}
{{- toYaml .Values.securityContext }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,15 @@
{{- if or .Values.emailReports.summary.enabled .Values.emailReports.violations.enabled }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "policyreporter.fullname" . }}-config-email-reports
{{- if .Values.annotations }}
annotations:
{{- toYaml .Values.annotations | nindent 4 }}
{{- end }}
labels:
{{- include "policyreporter.labels" . | nindent 4 }}
type: Opaque
data:
config.yaml: {{ tpl (.Files.Get "config-email-reports.yaml") . | b64enc }}
{{- end }}

View file

@ -0,0 +1,94 @@
{{- if .Values.emailReports.summary.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "policyreporter.fullname" . }}-summary-report
labels:
{{- include "policyreporter.labels" . | nindent 4 }}
{{- if .Values.annotations }}
annotations:
{{- toYaml .Values.annotations | nindent 4 }}
{{- end }}
spec:
schedule: {{ .Values.emailReports.summary.schedule | quote }}
jobTemplate:
spec:
activeDeadlineSeconds: {{ .Values.emailReports.summary.activeDeadlineSeconds }}
backoffLimit: {{ .Values.emailReports.summary.backoffLimit }}
{{- if gt (.Values.emailReports.summary.ttlSecondsAfterFinished | toString | atoi) 0 }}
ttlSecondsAfterFinished: {{ .Values.emailReports.summary.ttlSecondsAfterFinished }}
{{- end }}
template:
metadata:
labels:
{{- include "policyreporter.selectorLabels" . | nindent 12 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.global.labels }}
{{- toYaml . | nindent 12 }}
{{- end }}
annotations:
checksum/secret: {{ include (print .Template.BasePath "/config-email-reports-secret.yaml") . | sha256sum | quote }}
{{- with .Values.annotations }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 12 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 12 }}
{{- end }}
serviceAccountName: {{ include "policyreporter.serviceAccountName" . }}
automountServiceAccountToken: true
{{- if .Values.podSecurityContext }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 12 }}
{{- end }}
restartPolicy: {{ .Values.emailReports.summary.restartPolicy }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if .Values.securityContext }}
securityContext: {{ include "kyverno.securityContext" . | nindent 16 }}
{{- end }}
command:
- /app/policyreporter
- send
- summary
args:
- --config=/app/config.yaml
- --template-dir=/app/templates
volumeMounts:
- name: sqlite
mountPath: /sqlite
- name: config-file
mountPath: /app/config.yaml
subPath: config.yaml
readOnly: true
env:
- name: NAMESPACE
value: {{ .Release.Namespace }}
volumes:
- name: sqlite
emptyDir: {}
- name: config-file
secret:
secretName: {{ include "policyreporter.fullname" . }}-config-email-reports
optional: true
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,94 @@
{{- if .Values.emailReports.violations.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "policyreporter.fullname" . }}-violations-report
labels:
{{- include "policyreporter.labels" . | nindent 4 }}
{{- if .Values.annotations }}
annotations:
{{- toYaml .Values.annotations | nindent 4 }}
{{- end }}
spec:
schedule: {{ .Values.emailReports.violations.schedule | quote }}
jobTemplate:
spec:
activeDeadlineSeconds: {{ .Values.emailReports.violations.activeDeadlineSeconds }}
backoffLimit: {{ .Values.emailReports.violations.backoffLimit }}
{{- if gt (.Values.emailReports.violations.ttlSecondsAfterFinished | toString | atoi) 0 }}
ttlSecondsAfterFinished: {{ .Values.emailReports.violations.ttlSecondsAfterFinished }}
{{- end }}
template:
metadata:
labels:
{{- include "policyreporter.selectorLabels" . | nindent 12 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.global.labels }}
{{- toYaml . | nindent 12 }}
{{- end }}
annotations:
checksum/secret: {{ include (print .Template.BasePath "/config-email-reports-secret.yaml") . | sha256sum | quote }}
{{- with .Values.annotations }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 12 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 12 }}
{{- end }}
serviceAccountName: {{ include "policyreporter.serviceAccountName" . }}
automountServiceAccountToken: true
{{- if .Values.podSecurityContext }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 12 }}
{{- end }}
restartPolicy: {{ .Values.emailReports.violations.restartPolicy }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if .Values.securityContext }}
securityContext: {{ include "kyverno.securityContext" . | nindent 16 }}
{{- end }}
command:
- /app/policyreporter
- send
- violations
args:
- --config=/app/config.yaml
- --template-dir=/app/templates
volumeMounts:
- name: sqlite
mountPath: /sqlite
- name: config-file
mountPath: /app/config.yaml
subPath: config.yaml
readOnly: true
env:
- name: NAMESPACE
value: {{ .Release.Namespace }}
volumes:
- name: sqlite
emptyDir: {}
- name: config-file
secret:
secretName: {{ include "policyreporter.fullname" . }}-config-email-reports
optional: true
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}

View file

@ -2,7 +2,7 @@ image:
registry: ghcr.io
repository: kyverno/policy-reporter
pullPolicy: IfNotPresent
tag: 2.6.2
tag: 2.7.0
imagePullSecrets: []
@ -154,6 +154,50 @@ global:
# require-ns-labels: error
policyPriorities: {}
emailReports:
clusterName: "" # (optional) - displayed in the E-Mail Report if configured
smtp:
host: ""
port: 465
username: ""
password: ""
from: "" # Displayed From E-Mail Address
encryption: "" # ssl/tls / starttls
# basic summary report
summary:
enabled: false
schedule: "* 8 * * *" # CronJob schedule defines when the report will be send
activeDeadlineSeconds: 300 # timeout in seconds
backoffLimit: 3 # retry counter
ttlSecondsAfterFinished: 0
restartPolicy: Never # pod restart policy
to: [] # list of receiver e-mail addresses
filter: {} # optional filters
# namespaces:
# include: []
# exclude: []
# sources: ['Kyverno']
channels: [] # (optional) channels can be used to to send only a subset of namespaces / sources to dedicated email addresses
# violation summary report
violations:
enabled: false
schedule: "* 8 * * *" # CronJob schedule defines when the report will be send
activeDeadlineSeconds: 300 # timeout in seconds
backoffLimit: 3 # retry counter
ttlSecondsAfterFinished: 0
restartPolicy: Never # pod restart policy
to: [] # list of receiver e-mail addresses
filter: {} # optional filters
# namespaces:
# include: []
# exclude: []
# sources: ['Kyverno']
channels: [] # (optional) channels can be used to to send only a subset of namespaces / sources to dedicated email addresses
# Reference a configuration which already exists instead of creating one
existingTargetConfig:
enabled: false

View file

@ -1,11 +1,7 @@
package cmd
import (
"log"
"github.com/kyverno/policy-reporter/pkg/config"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// NewCLI creates a new instance of the root CLI
@ -18,64 +14,7 @@ func NewCLI() *cobra.Command {
}
rootCmd.AddCommand(newRunCMD())
rootCmd.AddCommand(newSendCMD())
return rootCmd
}
func loadConfig(cmd *cobra.Command) (*config.Config, error) {
v := viper.New()
cfgFile := ""
configFlag := cmd.Flags().Lookup("config")
if configFlag != nil {
cfgFile = configFlag.Value.String()
}
if cfgFile != "" {
v.SetConfigFile(cfgFile)
} else {
v.AddConfigPath(".")
v.SetConfigName("config")
}
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
log.Println("[INFO] No configuration file found")
}
if flag := cmd.Flags().Lookup("kubeconfig"); flag != nil {
v.BindPFlag("kubeconfig", flag)
}
if flag := cmd.Flags().Lookup("port"); flag != nil {
v.BindPFlag("api.port", flag)
}
if flag := cmd.Flags().Lookup("rest-enabled"); flag != nil {
v.BindPFlag("rest.enabled", flag)
}
if flag := cmd.Flags().Lookup("metrics-enabled"); flag != nil {
v.BindPFlag("metrics.enabled", flag)
}
if flag := cmd.Flags().Lookup("profile"); flag != nil {
v.BindPFlag("profiling.enabled", flag)
}
if flag := cmd.Flags().Lookup("dbfile"); flag != nil {
v.BindPFlag("dbfile", flag)
}
c := &config.Config{}
err := v.Unmarshal(c)
if c.DBFile == "" {
c.DBFile = "sqlite-database.db"
}
return c, err
}

View file

@ -4,10 +4,10 @@ import (
"flag"
"log"
"golang.org/x/sync/errgroup"
"github.com/kyverno/policy-reporter/pkg/config"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
@ -17,7 +17,7 @@ func newRunCMD() *cobra.Command {
Use: "run",
Short: "Run PolicyReporter Watcher & HTTP Metrics Server",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := loadConfig(cmd)
c, err := config.Load(cmd)
if err != nil {
return err
}

26
cmd/send.go Normal file
View file

@ -0,0 +1,26 @@
package cmd
import (
"flag"
"github.com/kyverno/policy-reporter/cmd/send"
"github.com/spf13/cobra"
)
func newSendCMD() *cobra.Command {
cmd := &cobra.Command{
Use: "send",
Short: "Send different kinds of email reports",
}
// For local usage
cmd.PersistentFlags().StringP("kubeconfig", "k", "", "absolute path to the kubeconfig file")
cmd.PersistentFlags().StringP("config", "c", "", "target configuration file")
cmd.PersistentFlags().StringP("template-dir", "t", "./templates", "template directory for email reports")
cmd.AddCommand(send.NewSummaryCMD())
cmd.AddCommand(send.NewViolationsCMD())
flag.Parse()
return cmd
}

106
cmd/send/summary.go Normal file
View file

@ -0,0 +1,106 @@
package send
import (
"log"
"sync"
"github.com/kyverno/policy-reporter/pkg/config"
"github.com/kyverno/policy-reporter/pkg/email/summary"
"github.com/spf13/cobra"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
func NewSummaryCMD() *cobra.Command {
cmd := &cobra.Command{
Use: "summary",
Short: "Send a summary e-mail to the configured emails",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := config.Load(cmd)
if err != nil {
return err
}
var k8sConfig *rest.Config
if c.Kubeconfig != "" {
k8sConfig, err = clientcmd.BuildConfigFromFlags("", c.Kubeconfig)
} else {
k8sConfig, err = rest.InClusterConfig()
}
if err != nil {
return err
}
resolver := config.NewResolver(c, k8sConfig)
generator, err := resolver.SummaryGenerator()
if err != nil {
return err
}
data, err := generator.GenerateData(cmd.Context())
if err != nil {
log.Printf("[ERROR] failed to generate report data: %s\n", err)
return err
}
reporter := resolver.SummaryReporter()
wg := &sync.WaitGroup{}
wg.Add(1 + len(c.EmailReports.Summary.Channels))
go func() {
defer wg.Done()
if len(c.EmailReports.Summary.To) == 0 {
return
}
report, err := reporter.Report(data, c.EmailReports.Summary.Format)
if err != nil {
log.Printf("[ERROR] failed to create report: %s\n", err)
return
}
err = resolver.EmailClient().Send(report, c.EmailReports.Summary.To)
if err != nil {
log.Printf("[ERROR] failed to send report: %s\n", err)
}
}()
for _, ch := range c.EmailReports.Violations.Channels {
go func(channel config.EmailReport) {
defer wg.Done()
if len(channel.To) == 0 {
return
}
sources := summary.FilterSources(data, config.EmailReportFilterFromConfig(channel.Filter), !channel.DisableClusterReports)
if len(sources) == 0 {
log.Printf("[INFO] skip email - no results to send")
return
}
report, err := reporter.Report(sources, channel.Format)
if err != nil {
log.Printf("[ERROR] failed to create report: %s\n", err)
return
}
err = resolver.EmailClient().Send(report, channel.To)
if err != nil {
log.Printf("[ERROR] failed to send report: %s\n", err)
}
}(ch)
}
wg.Wait()
return nil
},
}
return cmd
}

105
cmd/send/violations.go Normal file
View file

@ -0,0 +1,105 @@
package send
import (
"log"
"sync"
"github.com/kyverno/policy-reporter/pkg/config"
"github.com/kyverno/policy-reporter/pkg/email/violations"
"github.com/spf13/cobra"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
func NewViolationsCMD() *cobra.Command {
cmd := &cobra.Command{
Use: "violations",
Short: "Send a violations e-mail to the configured emails",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := config.Load(cmd)
if err != nil {
return err
}
var k8sConfig *rest.Config
if c.Kubeconfig != "" {
k8sConfig, err = clientcmd.BuildConfigFromFlags("", c.Kubeconfig)
} else {
k8sConfig, err = rest.InClusterConfig()
}
if err != nil {
return err
}
resolver := config.NewResolver(c, k8sConfig)
generator, err := resolver.ViolationsGenerator()
if err != nil {
return err
}
data, err := generator.GenerateData(cmd.Context())
if err != nil {
log.Printf("[ERROR] failed to generate report data: %s\n", err)
return err
}
reporter := resolver.ViolationsReporter()
wg := &sync.WaitGroup{}
wg.Add(1 + len(c.EmailReports.Violations.Channels))
go func() {
defer wg.Done()
if len(c.EmailReports.Violations.To) == 0 {
return
}
report, err := reporter.Report(data, c.EmailReports.Violations.Format)
if err != nil {
log.Printf("[ERROR] failed to create report: %s\n", err)
return
}
err = resolver.EmailClient().Send(report, c.EmailReports.Violations.To)
if err != nil {
log.Printf("[ERROR] failed to send report: %s\n", err)
}
}()
for _, ch := range c.EmailReports.Violations.Channels {
go func(channel config.EmailReport) {
defer wg.Done()
if len(channel.To) == 0 {
return
}
sources := violations.FilterSources(data, config.EmailReportFilterFromConfig(channel.Filter), !channel.DisableClusterReports)
if len(sources) == 0 {
log.Printf("[INFO] skip email - no results to send")
}
report, err := reporter.Report(sources, channel.Format)
if err != nil {
log.Printf("[ERROR] failed to create report: %s\n", err)
return
}
err = resolver.EmailClient().Send(report, channel.To)
if err != nil {
log.Printf("[ERROR] failed to send report: %s\n", err)
}
}(ch)
}
wg.Wait()
return nil
},
}
return cmd
}

2
go.mod
View file

@ -54,6 +54,8 @@ require (
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
github.com/xhit/go-simple-mail/v2 v2.11.0 // indirect
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/appengine v1.6.7 // indirect

4
go.sum
View file

@ -504,9 +504,13 @@ github.com/subosito/gotenv v1.4.0 h1:yAzM1+SmVcz5R4tXGsNMu1jUl2aOJXoiWUCEwwnGrvs
github.com/subosito/gotenv v1.4.0/go.mod h1:mZd6rFysKEcUhUHXJk0C/08wAgyDBFuwEYL7vWWGaGo=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xhit/go-simple-mail/v2 v2.11.0 h1:o/056V50zfkO3Mm5tVdo9rG3ryg4ZmJ2XW5GMinHfVs=
github.com/xhit/go-simple-mail/v2 v2.11.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

View file

@ -5,6 +5,11 @@ type ValueFilter struct {
Exclude []string `mapstructure:"exclude"`
}
type EmailReportFilter struct {
Namespaces ValueFilter `mapstructure:"namespaces"`
Sources []string `mapstructure:"sources"`
}
type TargetFilter struct {
Namespaces ValueFilter `mapstructure:"namespaces"`
Priorities ValueFilter `mapstructure:"priorities"`
@ -97,6 +102,7 @@ type Webhook struct {
Channels []Webhook `mapstructure:"channels"`
}
// S3 configuration
type S3 struct {
Name string `mapstructure:"name"`
AccessKeyID string `mapstructure:"accessKeyID"`
@ -112,6 +118,7 @@ type S3 struct {
Channels []S3 `mapstructure:"channels"`
}
// Kinesis configuration
type Kinesis struct {
Name string `mapstructure:"name"`
AccessKeyID string `mapstructure:"accessKeyID"`
@ -126,6 +133,39 @@ type Kinesis struct {
Channels []Kinesis `mapstructure:"channels"`
}
// SMTP configuration
type SMTP struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
From string `mapstructure:"from"`
Encryption string `mapstructure:"encryption"`
}
// EmailReport configuration
type EmailReport struct {
To []string `mapstructure:"to"`
Format string `mapstructure:"format"`
Filter EmailReportFilter `mapstructure:"filter"`
Channels []EmailReport `mapstructure:"channels"`
DisableClusterReports bool `mapstructure:"disableClusterReports"`
}
// EmailReport configuration
type EmailTemplates struct {
Dir string `mapstructure:"dir"`
}
// EmailReports configuration
type EmailReports struct {
SMTP SMTP `mapstructure:"smtp"`
Templates EmailTemplates `mapstructure:"templates"`
Summary EmailReport `mapstructure:"summary"`
Violations EmailReport `mapstructure:"violations"`
ClusterName string `mapstructure:"clusterName"`
}
// API configuration
type API struct {
Port int `mapstructure:"port"`
@ -191,4 +231,5 @@ type Config struct {
ReportFilter ReportFilter `mapstructure:"reportFilter"`
Redis Redis `mapstructure:"redis"`
Profiling Profiling `mapstructure:"profiling"`
EmailReports EmailReports `mapstructure:"emailReports"`
}

70
pkg/config/load.go Normal file
View file

@ -0,0 +1,70 @@
package config
import (
"log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func Load(cmd *cobra.Command) (*Config, error) {
v := viper.New()
cfgFile := ""
configFlag := cmd.Flags().Lookup("config")
if configFlag != nil {
cfgFile = configFlag.Value.String()
}
if cfgFile != "" {
v.SetConfigFile(cfgFile)
} else {
v.AddConfigPath(".")
v.SetConfigName("config")
}
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
log.Println("[INFO] No configuration file found")
}
if flag := cmd.Flags().Lookup("kubeconfig"); flag != nil {
v.BindPFlag("kubeconfig", flag)
}
if flag := cmd.Flags().Lookup("port"); flag != nil {
v.BindPFlag("api.port", flag)
}
if flag := cmd.Flags().Lookup("rest-enabled"); flag != nil {
v.BindPFlag("rest.enabled", flag)
}
if flag := cmd.Flags().Lookup("metrics-enabled"); flag != nil {
v.BindPFlag("metrics.enabled", flag)
}
if flag := cmd.Flags().Lookup("profile"); flag != nil {
v.BindPFlag("profiling.enabled", flag)
}
if flag := cmd.Flags().Lookup("dbfile"); flag != nil {
v.BindPFlag("dbfile", flag)
}
if flag := cmd.Flags().Lookup("template-dir"); flag != nil {
v.BindPFlag("emailReports.templates.dir", flag)
}
c := &Config{}
err := v.Unmarshal(c)
if c.DBFile == "" {
c.DBFile = "sqlite-database.db"
}
return c, err
}

62
pkg/config/load_test.go Normal file
View file

@ -0,0 +1,62 @@
package config_test
import (
"testing"
"github.com/kyverno/policy-reporter/pkg/config"
"github.com/spf13/cobra"
)
func createCMD() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().StringP("kubeconfig", "k", "", "absolute path to the kubeconfig file")
cmd.Flags().StringP("config", "c", "", "target configuration file")
cmd.Flags().IntP("port", "p", 8080, "http port for the optional rest api")
cmd.Flags().StringP("dbfile", "d", "sqlite-database.db", "path to the SQLite DB File")
cmd.Flags().BoolP("metrics-enabled", "m", false, "Enable Policy Reporter's Metrics API")
cmd.Flags().BoolP("rest-enabled", "r", false, "Enable Policy Reporter's REST API")
cmd.Flags().Bool("profile", false, "Enable application profiling with pprof")
cmd.Flags().StringP("template-dir", "t", "./templates", "template directory for email reports")
return cmd
}
func Test_Load(t *testing.T) {
cmd := createCMD()
_ = cmd.Flags().Set("kubeconfig", "./config")
_ = cmd.Flags().Set("port", "8081")
_ = cmd.Flags().Set("rest-enabled", "1")
_ = cmd.Flags().Set("metrics-enabled", "1")
_ = cmd.Flags().Set("profile", "1")
_ = cmd.Flags().Set("template-dir", "/app/templates")
_ = cmd.Flags().Set("dbfile", "")
c, err := config.Load(cmd)
if err != nil {
t.Errorf("Unexpected Error: %s", err)
}
if c.Kubeconfig != "./config" {
t.Errorf("Unexpected TemplateDir Config: %s", c.Kubeconfig)
}
if c.API.Port != 8081 {
t.Errorf("Unexpected Port Config: %d", c.API.Port)
}
if c.REST.Enabled != true {
t.Errorf("Unexpected REST Config: %v", c.REST.Enabled)
}
if c.Metrics.Enabled != true {
t.Errorf("Unexpected Metrics Config: %v", c.Metrics.Enabled)
}
if c.Profiling.Enabled != true {
t.Errorf("Unexpected Profiling Config: %v", c.Profiling.Enabled)
}
if c.EmailReports.Templates.Dir != "/app/templates" {
t.Errorf("Unexpected TemplateDir Config: %s", c.EmailReports.Templates.Dir)
}
if c.DBFile != "sqlite-database.db" {
t.Errorf("Unexpected DBFile Config: %s", c.DBFile)
}
}

View file

@ -9,6 +9,10 @@ import (
"github.com/kyverno/policy-reporter/pkg/api"
"github.com/kyverno/policy-reporter/pkg/cache"
"github.com/kyverno/policy-reporter/pkg/email"
"github.com/kyverno/policy-reporter/pkg/email/summary"
"github.com/kyverno/policy-reporter/pkg/email/violations"
"github.com/kyverno/policy-reporter/pkg/filter"
"github.com/kyverno/policy-reporter/pkg/helper"
"github.com/kyverno/policy-reporter/pkg/kubernetes"
"github.com/kyverno/policy-reporter/pkg/listener"
@ -26,9 +30,11 @@ import (
"github.com/kyverno/policy-reporter/pkg/target/teams"
"github.com/kyverno/policy-reporter/pkg/target/ui"
"github.com/kyverno/policy-reporter/pkg/target/webhook"
mail "github.com/xhit/go-simple-mail/v2"
goredis "github.com/go-redis/redis/v8"
"github.com/kyverno/kyverno/pkg/client/clientset/versioned"
wgpolicyk8sv1alpha2 "github.com/kyverno/kyverno/pkg/client/clientset/versioned/typed/policyreport/v1alpha2"
_ "github.com/mattn/go-sqlite3"
"k8s.io/client-go/rest"
)
@ -376,6 +382,72 @@ func (r *Resolver) SkipExistingOnStartup() bool {
return true
}
func (r *Resolver) CRDClient() (wgpolicyk8sv1alpha2.Wgpolicyk8sV1alpha2Interface, error) {
client, err := versioned.NewForConfig(r.k8sConfig)
if err != nil {
return nil, err
}
return client.Wgpolicyk8sV1alpha2(), nil
}
func (r *Resolver) SummaryGenerator() (*summary.Generator, error) {
client, err := r.CRDClient()
if err != nil {
return nil, err
}
return summary.NewGenerator(
client,
EmailReportFilterFromConfig(r.config.EmailReports.Summary.Filter),
!r.config.EmailReports.Summary.DisableClusterReports,
), nil
}
func (r *Resolver) SummaryReporter() *summary.Reporter {
return summary.NewReporter(
r.config.EmailReports.Templates.Dir,
r.config.EmailReports.ClusterName,
)
}
func (r *Resolver) ViolationsGenerator() (*violations.Generator, error) {
client, err := r.CRDClient()
if err != nil {
return nil, err
}
return violations.NewGenerator(
client,
EmailReportFilterFromConfig(r.config.EmailReports.Violations.Filter),
!r.config.EmailReports.Violations.DisableClusterReports,
), nil
}
func (r *Resolver) ViolationsReporter() *violations.Reporter {
return violations.NewReporter(
r.config.EmailReports.Templates.Dir,
r.config.EmailReports.ClusterName,
)
}
func (r *Resolver) SMTPServer() *mail.SMTPServer {
server := mail.NewSMTPClient()
server.Host = r.config.EmailReports.SMTP.Host
server.Port = r.config.EmailReports.SMTP.Port
server.Username = r.config.EmailReports.SMTP.Username
server.Password = r.config.EmailReports.SMTP.Password
server.ConnectTimeout = 10 * time.Second
server.SendTimeout = 10 * time.Second
server.Encryption = email.EncryptionFromString(r.config.EmailReports.SMTP.Encryption)
return server
}
func (r *Resolver) EmailClient() *email.Client {
return email.NewClient(r.config.EmailReports.SMTP.From, r.SMTPServer())
}
func (r *Resolver) PolicyReportClient() (report.PolicyReportClient, error) {
if r.policyReportClient != nil {
return r.policyReportClient, nil
@ -736,21 +808,31 @@ func createKinesisClient(config Kinesis, parent Kinesis) target.Client {
)
}
func createTargetFilter(filter TargetFilter, minimumPriority string, sources []string) *target.Filter {
func createTargetFilter(fil TargetFilter, minimumPriority string, sources []string) *target.Filter {
return &target.Filter{
MinimumPriority: minimumPriority,
Sources: sources,
Namespace: target.Rules{
Include: filter.Namespaces.Include,
Exclude: filter.Namespaces.Exclude,
Namespace: filter.Rules{
Include: fil.Namespaces.Include,
Exclude: fil.Namespaces.Exclude,
},
Priority: target.Rules{
Include: filter.Priorities.Include,
Exclude: filter.Priorities.Exclude,
Priority: filter.Rules{
Include: fil.Priorities.Include,
Exclude: fil.Priorities.Exclude,
},
Policy: target.Rules{
Include: filter.Policies.Include,
Exclude: filter.Policies.Exclude,
Policy: filter.Rules{
Include: fil.Policies.Include,
Exclude: fil.Policies.Exclude,
},
}
}
func EmailReportFilterFromConfig(config EmailReportFilter) filter.Filter {
return filter.New(
filter.Rules{
Include: config.Namespaces.Include,
Exclude: config.Namespaces.Exclude,
},
config.Sources,
)
}

View file

@ -94,6 +94,19 @@ var testConfig = &config.Config{
Region: "ru-central1",
Channels: []config.Kinesis{{}},
},
EmailReports: config.EmailReports{
Templates: config.EmailTemplates{
Dir: "../../templates",
},
SMTP: config.SMTP{
Host: "localhost",
Port: 465,
Username: "policy-reporter@kyverno.io",
Password: "password",
From: "policy-reporter@kyverno.io",
Encryption: "ssl/tls",
},
},
}
func Test_ResolveTarget(t *testing.T) {
@ -487,6 +500,27 @@ func Test_ResolveClientWithInvalidK8sConfig(t *testing.T) {
}
}
func Test_ResolveCRDClient(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
_, err := resolver.CRDClient()
if err != nil {
t.Error("unexpected error")
}
}
func Test_ResolveCRDClientWithInvalidK8sConfig(t *testing.T) {
k8sConfig := &rest.Config{}
k8sConfig.Host = "invalid/url"
resolver := config.NewResolver(testConfig, k8sConfig)
_, err := resolver.CRDClient()
if err == nil {
t.Error("Error: 'host must be a URL or a host:port pair' was expected")
}
}
func Test_RegisterStoreListener(t *testing.T) {
t.Run("Register StoreListener", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
@ -528,3 +562,82 @@ func Test_RegisterSendResultListener(t *testing.T) {
}
})
}
func Test_SummaryReportServices(t *testing.T) {
t.Run("Generator", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
generator, err := resolver.SummaryGenerator()
if err != nil {
t.Errorf("Unexpected error: %s", err)
}
if generator == nil {
t.Error("Should return Generator Pointer")
}
})
t.Run("Generator.Error", func(t *testing.T) {
k8sConfig := &rest.Config{}
k8sConfig.Host = "invalid/url"
resolver := config.NewResolver(testConfig, k8sConfig)
_, err := resolver.SummaryGenerator()
if err == nil {
t.Error("Error: 'host must be a URL or a host:port pair' was expected")
}
})
t.Run("Reporter", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
reporter := resolver.SummaryReporter()
if reporter == nil {
t.Error("Should return Reporter Pointer")
}
})
}
func Test_ViolationReportServices(t *testing.T) {
t.Run("Generator", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
generator, err := resolver.ViolationsGenerator()
if err != nil {
t.Errorf("Unexpected error: %s", err)
}
if generator == nil {
t.Error("Should return Generator Pointer")
}
})
t.Run("Generator.Error", func(t *testing.T) {
k8sConfig := &rest.Config{}
k8sConfig.Host = "invalid/url"
resolver := config.NewResolver(testConfig, k8sConfig)
_, err := resolver.ViolationsGenerator()
if err == nil {
t.Error("Error: 'host must be a URL or a host:port pair' was expected")
}
})
t.Run("Reporter", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
reporter := resolver.ViolationsReporter()
if reporter == nil {
t.Error("Should return Reporter Pointer")
}
})
}
func Test_SMTP(t *testing.T) {
t.Run("SMTP", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
smtp := resolver.SMTPServer()
if smtp == nil {
t.Error("Should return SMTP Pointer")
}
})
t.Run("EmailClient", func(t *testing.T) {
resolver := config.NewResolver(testConfig, &rest.Config{})
client := resolver.EmailClient()
if client == nil {
t.Error("Should return EmailClient Pointer")
}
})
}

63
pkg/email/client.go Normal file
View file

@ -0,0 +1,63 @@
package email
import (
"fmt"
"strings"
mail "github.com/xhit/go-simple-mail/v2"
)
func EncryptionFromString(enc string) mail.Encryption {
switch strings.ToLower(enc) {
case "ssl/tls":
return mail.EncryptionSSLTLS
case "starttls":
return mail.EncryptionSTARTTLS
default:
return mail.EncryptionNone
}
}
type Client struct {
server *mail.SMTPServer
from string
}
func (c *Client) Send(report Report, to []string) error {
if len(to) > 1 {
c.server.KeepAlive = true
}
client, err := c.server.Connect()
if err != nil {
return err
}
for _, to := range to {
msg := mail.NewMSG().
SetFrom(fmt.Sprintf("Policy Reporter <%s>", c.from)).
AddTo(to).
SetSubject(report.Title)
if strings.ToLower(report.Format) == "html" || report.Format == "" {
msg.SetBody(mail.TextHTML, report.Message)
} else {
msg.SetBody(mail.TextPlain, report.Message)
}
if msg.Error != nil {
return msg.Error
}
err = msg.Send(client)
if err != nil {
return err
}
}
return nil
}
func NewClient(from string, server *mail.SMTPServer) *Client {
return &Client{server: server, from: from}
}

36
pkg/email/client_test.go Normal file
View file

@ -0,0 +1,36 @@
package email_test
import (
"testing"
"github.com/kyverno/policy-reporter/pkg/email"
mail "github.com/xhit/go-simple-mail/v2"
)
func Test_EncryptionFromString(t *testing.T) {
t.Run("EncryptionFromString.SSLTLS", func(t *testing.T) {
encryption := email.EncryptionFromString("ssl/tls")
if encryption != mail.EncryptionSSLTLS {
t.Errorf("Unexpected encryption mapping: %d", encryption)
}
})
t.Run("EncryptionFromString.STARTTLS", func(t *testing.T) {
encryption := email.EncryptionFromString("starttls")
if encryption != mail.EncryptionSTARTTLS {
t.Errorf("Unexpected encryption mapping: %d", encryption)
}
})
t.Run("EncryptionFromString.Default", func(t *testing.T) {
encryption := email.EncryptionFromString("")
if encryption != mail.EncryptionNone {
t.Errorf("Unexpected encryption mapping: %d", encryption)
}
})
}
func Test_NewClient(t *testing.T) {
client := email.NewClient("policy-reporter@kyverno.io", nil)
if client == nil {
t.Errorf("Unexpected client result")
}
}

26
pkg/email/functions.go Normal file
View file

@ -0,0 +1,26 @@
package email
import "github.com/kyverno/kyverno/api/policyreport/v1alpha2"
const (
PassColor = "#198754"
WarnColor = "#fd7e14"
FailColor = "#dc3545"
ErrorColor = "#b02a37"
DefaultColor = "#cccccc"
)
func ColorFromStatus(status string) string {
switch status {
case v1alpha2.StatusPass:
return PassColor
case v1alpha2.StatusWarn:
return WarnColor
case v1alpha2.StatusFail:
return FailColor
case v1alpha2.StatusError:
return ErrorColor
default:
return DefaultColor
}
}

View file

@ -0,0 +1,41 @@
package email_test
import (
"testing"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/email"
)
func Test_ColorFromStatus(t *testing.T) {
t.Run("ColorFromStatus.Pass", func(t *testing.T) {
color := email.ColorFromStatus(v1alpha2.StatusPass)
if color != email.PassColor {
t.Errorf("Unexpected pass color: %s", color)
}
})
t.Run("ColorFromStatus.Warn", func(t *testing.T) {
color := email.ColorFromStatus(v1alpha2.StatusWarn)
if color != email.WarnColor {
t.Errorf("Unexpected warn color: %s", color)
}
})
t.Run("ColorFromStatus.Fail", func(t *testing.T) {
color := email.ColorFromStatus(v1alpha2.StatusFail)
if color != email.FailColor {
t.Errorf("Unexpected fail color: %s", color)
}
})
t.Run("ColorFromStatus.Error", func(t *testing.T) {
color := email.ColorFromStatus(v1alpha2.StatusError)
if color != email.ErrorColor {
t.Errorf("Unexpected error color: %s", color)
}
})
t.Run("ColorFromStatus.Default", func(t *testing.T) {
color := email.ColorFromStatus("")
if color != email.DefaultColor {
t.Errorf("Unexpected error color: %s", color)
}
})
}

16
pkg/email/model.go Normal file
View file

@ -0,0 +1,16 @@
package email
import (
"context"
)
type Report struct {
Title string
Message string
Format string
ClusterName string
}
type Reporter interface {
Report(ctx context.Context) (Report, error)
}

View file

@ -0,0 +1,222 @@
package summary_test
import (
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
"github.com/kyverno/kyverno/pkg/client/clientset/versioned/fake"
v1alpha2client "github.com/kyverno/kyverno/pkg/client/clientset/versioned/typed/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/filter"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var Filter = filter.New(filter.Rules{}, make([]string, 0, 0))
func NewFakeCilent() (v1alpha2client.Wgpolicyk8sV1alpha2Interface, v1alpha2client.PolicyReportInterface, v1alpha2client.ClusterPolicyReportInterface) {
client := fake.NewSimpleClientset().Wgpolicyk8sV1alpha2()
return client, client.PolicyReports("test"), client.ClusterPolicyReports()
}
var PolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "policy-report",
Namespace: "test",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 0,
Skip: 0,
Warn: 0,
Fail: 3,
Error: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "required-label",
Rule: "app-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
Properties: map[string]string{"version": "1.2.0"},
},
{
Message: "message 2",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "priority-test",
Timestamp: v1.Timestamp{Seconds: 1614093000},
},
{
Message: "message 3",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "required-label",
Rule: "app-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "name",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988b3",
},
},
},
},
}
var KyvernoPolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "kyverno-policy-report",
Namespace: "kyverno",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 1,
Skip: 0,
Warn: 0,
Fail: 0,
Error: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "required-limit",
Rule: "resource-limit-required",
Timestamp: v1.Timestamp{Seconds: 1614093003},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx",
Namespace: "kyverno",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var EmptyPolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "empty-policy-report",
Namespace: "test",
},
Summary: v1alpha2.PolicyReportSummary{},
}
var ClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 1,
Warn: 2,
Error: 3,
Pass: 4,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "cluster-required-label",
Rule: "ns-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Namespace",
Name: "policy-reporter",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var KyvernoClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "kyverno-cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 0,
Warn: 0,
Error: 0,
Pass: 1,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "cluster-required-quota",
Rule: "ns-quota-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Namespace",
Name: "kyverno",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var MinClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 1,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "cluster-policy",
Rule: "cluster-role",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
},
},
}
var EmptyClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "empty-cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{},
}

View file

@ -0,0 +1,147 @@
package summary
import (
"context"
"sync"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
api "github.com/kyverno/kyverno/pkg/client/clientset/versioned/typed/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/filter"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type Generator struct {
client api.Wgpolicyk8sV1alpha2Interface
filter filter.Filter
clusterReports bool
}
func (o *Generator) GenerateData(ctx context.Context) ([]Source, error) {
mx := &sync.Mutex{}
sources := make(map[string]*Source)
wg := &sync.WaitGroup{}
if o.clusterReports {
clusterReports, err := o.client.ClusterPolicyReports().List(ctx, v1.ListOptions{})
if err != nil {
return make([]Source, 0, 0), err
}
wg.Add(len(clusterReports.Items))
for _, rep := range clusterReports.Items {
go func(report v1alpha2.ClusterPolicyReport) {
defer wg.Done()
if len(report.Results) == 0 {
return
}
rs := report.Results[0].Source
if !o.filter.ValidateSource(rs) {
return
}
mx.Lock()
s, ok := sources[rs]
if !ok {
s = NewSource(rs, o.clusterReports)
sources[rs] = s
}
mx.Unlock()
s.AddClusterSummary(report.Summary)
}(rep)
}
}
reports, err := o.client.PolicyReports(v1.NamespaceAll).List(ctx, v1.ListOptions{})
if err != nil {
return make([]Source, 0, 0), err
}
wg.Add(len(reports.Items))
for _, rep := range reports.Items {
go func(report v1alpha2.PolicyReport) {
defer wg.Done()
if len(report.Results) == 0 || !o.filter.ValidateNamespace(report.Namespace) {
return
}
rs := report.Results[0].Source
if !o.filter.ValidateSource(rs) {
return
}
mx.Lock()
s, ok := sources[rs]
if !ok {
s = NewSource(rs, o.clusterReports)
sources[rs] = s
}
mx.Unlock()
s.AddNamespacedSummary(report.Namespace, report.Summary)
}(rep)
}
wg.Wait()
list := make([]Source, 0, len(sources))
for _, s := range sources {
list = append(list, *s)
}
return list, nil
}
func NewGenerator(client api.Wgpolicyk8sV1alpha2Interface, filter filter.Filter, clusterReports bool) *Generator {
return &Generator{client, filter, clusterReports}
}
func FilterSources(sources []Source, filter filter.Filter, clusterReports bool) []Source {
newSources := make([]Source, 0)
mx := sync.Mutex{}
wg := &sync.WaitGroup{}
wg.Add(len(sources))
for _, s := range sources {
go func(source Source) {
defer wg.Done()
if !filter.ValidateSource(source.Name) {
return
}
newSource := NewSource(source.Name, clusterReports)
if clusterReports {
newSource.ClusterScopeSummary = source.ClusterScopeSummary
}
for ns, results := range source.NamespaceScopeSummary {
if !filter.ValidateNamespace(ns) {
continue
}
newSource.NamespaceScopeSummary[ns] = results
}
if !clusterReports && len(newSource.NamespaceScopeSummary) == 0 {
return
}
mx.Lock()
newSources = append(newSources, *newSource)
mx.Unlock()
}(s)
}
wg.Wait()
return newSources
}

View file

@ -0,0 +1,174 @@
package summary_test
import (
"context"
"testing"
"github.com/kyverno/policy-reporter/pkg/email/summary"
"github.com/kyverno/policy-reporter/pkg/filter"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func Test_GenerateDataWithSingleSource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 1 {
t.Fatalf("expected one source got: %d", len(data))
}
source := data[0]
if source.Name != "test" {
t.Fatalf("expected source name 'test', got: %s", source.Name)
}
if source.ClusterScopeSummary.Fail != 1 {
t.Fatalf("unexpected Summary Mapping: %d", source.ClusterScopeSummary.Fail)
}
if source.NamespaceScopeSummary["test"].Fail != 3 {
t.Fatalf("unexpected Summary Mapping: %d", source.NamespaceScopeSummary["test"].Fail)
}
}
func Test_GenerateDataWithMultipleSource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 2 {
t.Fatalf("expected two sources, got: %d", len(data))
}
}
func Test_GenerateDataWithSourceFilter(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, filter.New(filter.Rules{}, []string{"test"}), true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 1 {
t.Fatalf("expected one source, got: %d", len(data))
}
}
func Test_FilterSourcesBySource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = summary.FilterSources(data, filter.New(filter.Rules{}, []string{"Kyverno"}), true)
if len(data) != 1 {
t.Fatalf("expected one source left, got: %d", len(data))
}
}
func Test_FilterSourcesByNamespace(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = summary.FilterSources(data, filter.New(filter.Rules{Exclude: []string{"kyverno"}}, []string{}), true)
source := data[0]
if source.Name != "Kyverno" {
source = data[1]
}
if _, ok := source.NamespaceScopeSummary["kyverno"]; ok {
t.Fatal("expected namespace kyverno to be excluded")
}
}
func Test_RemoveEmptySource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = summary.FilterSources(data, filter.New(filter.Rules{Exclude: []string{"kyverno"}}, []string{}), false)
if len(data) != 1 {
t.Fatalf("expected one source left, got: %d", len(data))
}
}

View file

@ -0,0 +1,62 @@
package summary
import (
"sync"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
)
type Summary struct {
Skip int
Pass int
Warn int
Fail int
Error int
}
type Source struct {
Name string
ClusterScopeSummary *Summary
NamespaceScopeSummary map[string]*Summary
ClusterReports bool
mx *sync.Mutex
}
func (s *Source) AddClusterSummary(sum v1alpha2.PolicyReportSummary) {
s.ClusterScopeSummary.Skip += sum.Skip
s.ClusterScopeSummary.Pass += sum.Pass
s.ClusterScopeSummary.Warn += sum.Warn
s.ClusterScopeSummary.Fail += sum.Fail
s.ClusterScopeSummary.Error += sum.Error
}
func (s *Source) AddNamespacedSummary(ns string, sum v1alpha2.PolicyReportSummary) {
s.mx.Lock()
if d, ok := s.NamespaceScopeSummary[ns]; ok {
d.Skip += sum.Skip
d.Pass += sum.Pass
d.Warn += sum.Warn
d.Fail += sum.Fail
d.Error += sum.Error
} else {
s.NamespaceScopeSummary[ns] = &Summary{
Skip: sum.Skip,
Pass: sum.Pass,
Fail: sum.Fail,
Warn: sum.Warn,
Error: sum.Error,
}
}
s.mx.Unlock()
}
func NewSource(name string, clusterReports bool) *Source {
return &Source{
Name: name,
ClusterScopeSummary: &Summary{},
NamespaceScopeSummary: map[string]*Summary{},
ClusterReports: clusterReports,
mx: new(sync.Mutex),
}
}

View file

@ -0,0 +1,79 @@
package summary_test
import (
"testing"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/email/summary"
)
func Test_Source(t *testing.T) {
source := summary.NewSource("kyverno", true)
t.Run("Source.ClusterReports", func(t *testing.T) {
if !source.ClusterReports {
t.Errorf("Expected Surce.ClusterReports to be true")
}
})
t.Run("Source.AddClusterSummary", func(t *testing.T) {
source.AddClusterSummary(v1alpha2.PolicyReportSummary{
Pass: 1,
Warn: 2,
Fail: 4,
Error: 3,
})
if source.ClusterScopeSummary.Pass != 1 {
t.Errorf("Unexpected Pass Summary: %d", source.ClusterScopeSummary.Pass)
}
if source.ClusterScopeSummary.Warn != 2 {
t.Errorf("Unexpected Warn Summary: %d", source.ClusterScopeSummary.Warn)
}
if source.ClusterScopeSummary.Fail != 4 {
t.Errorf("Unexpected Fail Summary: %d", source.ClusterScopeSummary.Fail)
}
if source.ClusterScopeSummary.Error != 3 {
t.Errorf("Unexpected Errpr Summary: %d", source.ClusterScopeSummary.Error)
}
})
t.Run("Source.AddNamespacedSummary", func(t *testing.T) {
source.AddNamespacedSummary("test", v1alpha2.PolicyReportSummary{
Pass: 5,
Warn: 6,
Fail: 7,
Error: 8,
})
if source.NamespaceScopeSummary["test"].Pass != 5 {
t.Errorf("Unexpected Pass Summary: %d", source.ClusterScopeSummary.Pass)
}
if source.NamespaceScopeSummary["test"].Warn != 6 {
t.Errorf("Unexpected Warn Summary: %d", source.ClusterScopeSummary.Warn)
}
if source.NamespaceScopeSummary["test"].Fail != 7 {
t.Errorf("Unexpected Fail Summary: %d", source.ClusterScopeSummary.Fail)
}
if source.NamespaceScopeSummary["test"].Error != 8 {
t.Errorf("Unexpected Errpr Summary: %d", source.ClusterScopeSummary.Error)
}
source.AddNamespacedSummary("test", v1alpha2.PolicyReportSummary{
Pass: 2,
Warn: 1,
Fail: 0,
Error: 3,
})
if source.NamespaceScopeSummary["test"].Pass != 7 {
t.Errorf("Unexpected Pass Summary: %d", source.ClusterScopeSummary.Pass)
}
if source.NamespaceScopeSummary["test"].Warn != 7 {
t.Errorf("Unexpected Warn Summary: %d", source.ClusterScopeSummary.Warn)
}
if source.NamespaceScopeSummary["test"].Fail != 7 {
t.Errorf("Unexpected Fail Summary: %d", source.ClusterScopeSummary.Fail)
}
if source.NamespaceScopeSummary["test"].Error != 11 {
t.Errorf("Unexpected Errpr Summary: %d", source.ClusterScopeSummary.Error)
}
})
}

View file

@ -0,0 +1,42 @@
package summary
import (
"html/template"
"strings"
"time"
"github.com/kyverno/policy-reporter/pkg/email"
)
type Reporter struct {
templateDir string
clusterName string
}
func (o *Reporter) Report(sources []Source, format string) (email.Report, error) {
b := new(strings.Builder)
templ, err := template.ParseFiles(o.templateDir + "/summary.html")
if err != nil {
return email.Report{}, err
}
err = templ.Execute(b, struct {
Sources []Source
ClusterName string
}{Sources: sources, ClusterName: o.clusterName})
if err != nil {
return email.Report{}, err
}
return email.Report{
ClusterName: o.clusterName,
Title: "Summary Report from " + time.Now().Format("2006-02-01"),
Message: b.String(),
Format: format,
}, nil
}
func NewReporter(templateDir, clusterName string) *Reporter {
return &Reporter{templateDir, clusterName}
}

View file

@ -0,0 +1,54 @@
package summary_test
import (
"context"
"fmt"
"os"
"testing"
"github.com/kyverno/policy-reporter/pkg/email/summary"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func Test_CreateReport(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := summary.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
path, err := os.Getwd()
if err != nil {
t.Error(err)
}
fmt.Println(path)
reporter := summary.NewReporter("../../../templates", "Cluster")
report, err := reporter.Report(data, "html")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if report.Message == "" {
t.Fatal("expected validate report message")
}
if report.ClusterName != "Cluster" {
t.Fatal("expected clustername to be set")
}
if report.Format != "html" {
t.Fatal("expected format to be set")
}
}

View file

@ -0,0 +1,321 @@
package violations_test
import (
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
"github.com/kyverno/kyverno/pkg/client/clientset/versioned/fake"
v1alpha2client "github.com/kyverno/kyverno/pkg/client/clientset/versioned/typed/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/filter"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var Filter = filter.New(filter.Rules{}, make([]string, 0, 0))
func NewFakeCilent() (v1alpha2client.Wgpolicyk8sV1alpha2Interface, v1alpha2client.PolicyReportInterface, v1alpha2client.ClusterPolicyReportInterface) {
client := fake.NewSimpleClientset().Wgpolicyk8sV1alpha2()
return client, client.PolicyReports("test"), client.ClusterPolicyReports()
}
var PolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "policy-report",
Namespace: "test",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 0,
Skip: 0,
Warn: 0,
Fail: 3,
Error: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "required-label",
Rule: "app-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
Properties: map[string]string{"version": "1.2.0"},
},
{
Message: "message 2",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "priority-test",
Timestamp: v1.Timestamp{Seconds: 1614093000},
},
{
Message: "message 3",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "required-label",
Rule: "app-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "name",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988b3",
},
},
},
},
}
var KyvernoPolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "kyverno-policy-report",
Namespace: "kyverno",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 1,
Skip: 0,
Warn: 1,
Fail: 0,
Error: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "required-limit",
Rule: "resource-limit-required",
Timestamp: v1.Timestamp{Seconds: 1614093003},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx",
Namespace: "kyverno",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
{
Message: "message",
Result: v1alpha2.StatusWarn,
Scored: true,
Policy: "required-limit",
Rule: "resource-limit-required",
Timestamp: v1.Timestamp{Seconds: 1614093003},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx2",
Namespace: "kyverno",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d2",
},
},
},
},
}
var PassPolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "pass-policy-report",
Namespace: "test",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 1,
Skip: 0,
Warn: 0,
Fail: 0,
Error: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "required-limit",
Rule: "resource-limit-required",
Timestamp: v1.Timestamp{Seconds: 1614093003},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Deployment",
Name: "nginx",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var EmptyPolicyReportCRD = &v1alpha2.PolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "empty-policy-report",
Namespace: "test",
},
Summary: v1alpha2.PolicyReportSummary{},
}
var ClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 1,
Warn: 0,
Error: 0,
Pass: 1,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "cluster-required-label",
Rule: "ns-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Namespace",
Name: "policy-reporter",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "cluster-required-label",
Rule: "ns-label-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Namespace",
Name: "test",
Namespace: "test",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var KyvernoClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "kyverno-cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 1,
Warn: 0,
Error: 0,
Pass: 0,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "cluster-required-quota",
Rule: "ns-quota-required",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "Kyverno",
Category: "test",
Severity: v1alpha2.SeverityHigh,
Resources: []corev1.ObjectReference{
{
APIVersion: "v1",
Kind: "Namespace",
Name: "kyverno",
UID: "dfd57c50-f30c-4729-b63f-b1954d8988d1",
},
},
},
},
}
var MinClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Fail: 1,
Pass: 1,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusFail,
Scored: true,
Policy: "cluster-policy",
Rule: "cluster-role",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
},
},
}
var PassClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "pass-cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{
Pass: 1,
},
Results: []v1alpha2.PolicyReportResult{
{
Message: "message",
Result: v1alpha2.StatusPass,
Scored: true,
Policy: "cluster-policy-pass",
Rule: "cluster-role-pass",
Timestamp: v1.Timestamp{Seconds: 1614093000},
Source: "test",
Category: "test",
Severity: v1alpha2.SeverityHigh,
},
},
}
var EmptyClusterPolicyReportCRD = &v1alpha2.ClusterPolicyReport{
ObjectMeta: v1.ObjectMeta{
Name: "empty-cluster-policy-report",
},
Summary: v1alpha2.PolicyReportSummary{},
}

View file

@ -0,0 +1,184 @@
package violations
import (
"context"
"sync"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
api "github.com/kyverno/kyverno/pkg/client/clientset/versioned/typed/policyreport/v1alpha2"
"github.com/kyverno/policy-reporter/pkg/filter"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type Generator struct {
client api.Wgpolicyk8sV1alpha2Interface
filter filter.Filter
clusterReports bool
}
func (o *Generator) GenerateData(ctx context.Context) ([]Source, error) {
mx := &sync.Mutex{}
sources := make(map[string]*Source)
wg := &sync.WaitGroup{}
if o.clusterReports {
clusterReports, err := o.client.ClusterPolicyReports().List(ctx, v1.ListOptions{})
if err != nil {
return make([]Source, 0, 0), err
}
wg.Add(len(clusterReports.Items))
for _, rep := range clusterReports.Items {
go func(report v1alpha2.ClusterPolicyReport) {
defer wg.Done()
if len(report.Results) == 0 {
return
}
rs := report.Results[0].Source
if !o.filter.ValidateSource(rs) {
return
}
mx.Lock()
s, ok := sources[rs]
if !ok {
s = NewSource(rs, o.clusterReports)
sources[rs] = s
}
mx.Unlock()
s.AddClusterPassed(report.Summary.Pass)
length := len(report.Results)
if length == 0 || length == report.Summary.Pass+report.Summary.Skip {
return
}
for _, result := range report.Results {
if result.Result == v1alpha2.StatusPass || result.Result == v1alpha2.StatusSkip {
continue
}
s.AddClusterResults(mapResult(result))
}
}(rep)
}
}
reports, err := o.client.PolicyReports(v1.NamespaceAll).List(ctx, v1.ListOptions{})
if err != nil {
return make([]Source, 0, 0), err
}
wg.Add(len(reports.Items))
for _, rep := range reports.Items {
go func(report v1alpha2.PolicyReport) {
defer wg.Done()
if len(report.Results) == 0 {
return
}
rs := report.Results[0].Source
if !o.filter.ValidateSource(rs) || !o.filter.ValidateNamespace(report.Namespace) {
return
}
mx.Lock()
s, ok := sources[rs]
if !ok {
s = NewSource(rs, o.clusterReports)
sources[rs] = s
}
mx.Unlock()
s.AddNamespacedPassed(report.Namespace, report.Summary.Pass)
length := len(report.Results)
if length == 0 || length == report.Summary.Pass+report.Summary.Skip {
s.InitResults(report.Namespace)
return
}
for _, result := range report.Results {
if result.Result == v1alpha2.StatusPass || result.Result == v1alpha2.StatusSkip {
continue
}
s.AddNamespacedResults(report.Namespace, mapResult(result))
}
}(rep)
}
wg.Wait()
list := make([]Source, 0, len(sources))
for _, s := range sources {
list = append(list, *s)
}
return list, nil
}
func NewGenerator(client api.Wgpolicyk8sV1alpha2Interface, filter filter.Filter, clusterReports bool) *Generator {
return &Generator{client, filter, clusterReports}
}
func FilterSources(sources []Source, filter filter.Filter, clusterReports bool) []Source {
newSources := make([]Source, 0)
mx := sync.Mutex{}
wg := &sync.WaitGroup{}
wg.Add(len(sources))
for _, s := range sources {
go func(source Source) {
defer wg.Done()
if !filter.ValidateSource(source.Name) {
return
}
newSource := NewSource(source.Name, clusterReports)
if clusterReports {
newSource.ClusterPassed = source.ClusterPassed
newSource.ClusterResults = source.ClusterResults
}
for ns, passed := range source.NamespacePassed {
if !filter.ValidateNamespace(ns) {
continue
}
newSource.AddNamespacedPassed(ns, passed)
}
for ns, results := range source.NamespaceResults {
if !filter.ValidateNamespace(ns) {
continue
}
newSource.NamespaceResults[ns] = results
}
if !clusterReports && len(newSource.NamespaceResults) == 0 {
return
}
mx.Lock()
newSources = append(newSources, *newSource)
mx.Unlock()
}(s)
}
wg.Wait()
return newSources
}

View file

@ -0,0 +1,193 @@
package violations_test
import (
"context"
"testing"
"github.com/kyverno/policy-reporter/pkg/email/violations"
"github.com/kyverno/policy-reporter/pkg/filter"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func Test_GenerateDataWithSingleSource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 1 {
t.Fatalf("expected one source got: %d", len(data))
}
source := data[0]
if source.Name != "test" {
t.Fatalf("expected source name 'test', got: %s", source.Name)
}
if source.ClusterPassed != 1 {
t.Fatalf("unexpected Summary Mapping: %d", source.ClusterPassed)
}
if len(source.NamespaceResults["test"]["fail"]) != 3 {
t.Fatalf("unexpected Summary Mapping: %d", len(source.NamespaceResults["test"]["fail"]))
}
result := source.NamespaceResults["test"]["fail"][0]
if result.Kind != "Deployment" {
t.Fatalf("unexpected kind: %s", result.Kind)
}
if result.Name != "nginx" {
t.Fatalf("unexpected name: %s", result.Kind)
}
if result.Policy != "required-label" {
t.Fatalf("unexpected policy: %s", result.Kind)
}
if result.Rule != "app-label-required" {
t.Fatalf("unexpected rule: %s", result.Kind)
}
if result.Status != "fail" {
t.Fatalf("unexpected status: %s", result.Status)
}
}
func Test_GenerateDataWithMultipleSource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, PassPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, PassClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 2 {
t.Fatalf("expected two sources, got: %d", len(data))
}
}
func Test_GenerateDataWithSourceFilter(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, filter.New(filter.Rules{}, []string{"test"}), true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if len(data) != 1 {
t.Fatalf("expected one source, got: %d", len(data))
}
}
func Test_FilterSourcesBySource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = violations.FilterSources(data, filter.New(filter.Rules{}, []string{"Kyverno"}), true)
if len(data) != 1 {
t.Fatalf("expected one source left, got: %d", len(data))
}
}
func Test_FilterSourcesByNamespace(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = violations.FilterSources(data, filter.New(filter.Rules{Exclude: []string{"kyverno"}}, []string{}), true)
source := data[0]
if source.Name != "Kyverno" {
source = data[1]
}
if _, ok := source.NamespaceResults["kyverno"]; ok {
t.Fatal("expected namespace kyverno to be excluded")
}
}
func Test_RemoveEmptySource(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
data = violations.FilterSources(data, filter.New(filter.Rules{Exclude: []string{"kyverno"}}, []string{}), false)
if len(data) != 1 {
t.Fatalf("expected one source left, got: %d", len(data))
}
}

View file

@ -0,0 +1,114 @@
package violations
import (
"sync"
"github.com/kyverno/kyverno/api/policyreport/v1alpha2"
)
type Result struct {
Policy string
Rule string
Kind string
Name string
Status string
}
func mapResult(res v1alpha2.PolicyReportResult) []Result {
count := len(res.Resources)
if count == 0 {
return []Result{{
Policy: res.Policy,
Rule: res.Rule,
Status: string(res.Result),
}}
}
list := make([]Result, 0, count)
for _, re := range res.Resources {
list = append(list, Result{
Policy: res.Policy,
Rule: res.Rule,
Name: re.Name,
Kind: re.Kind,
Status: string(res.Result),
})
}
return list
}
type Source struct {
Name string
ClusterPassed int
NamespacePassed map[string]int
ClusterResults map[string][]Result
NamespaceResults map[string]map[string][]Result
ClusterReports bool
passMX *sync.Mutex
crMX *sync.Mutex
nrMX *sync.Mutex
}
func (s *Source) AddClusterResults(result []Result) {
s.crMX.Lock()
s.ClusterResults[result[0].Status] = append(s.ClusterResults[result[0].Status], result...)
s.crMX.Unlock()
}
func (s *Source) AddClusterPassed(results int) {
s.ClusterPassed += results
}
func (s *Source) AddNamespacedPassed(ns string, results int) {
s.passMX.Lock()
s.NamespacePassed[ns] += results
s.passMX.Unlock()
}
func (s *Source) AddNamespacedResults(ns string, result []Result) {
s.nrMX.Lock()
if nr, ok := s.NamespaceResults[ns]; ok {
s.NamespaceResults[ns][result[0].Status] = append(nr[result[0].Status], result...)
} else {
s.NamespaceResults[ns] = map[string][]Result{
v1alpha2.StatusWarn: make([]Result, 0),
v1alpha2.StatusFail: make([]Result, 0),
v1alpha2.StatusError: make([]Result, 0),
}
s.NamespaceResults[ns][result[0].Status] = result
}
s.nrMX.Unlock()
}
func (s Source) InitResults(ns string) {
s.nrMX.Lock()
if _, ok := s.NamespaceResults[ns]; !ok {
s.NamespaceResults[ns] = map[string][]Result{
v1alpha2.StatusWarn: make([]Result, 0),
v1alpha2.StatusFail: make([]Result, 0),
v1alpha2.StatusError: make([]Result, 0),
}
}
s.nrMX.Unlock()
}
func NewSource(name string, clusterReports bool) *Source {
return &Source{
Name: name,
ClusterReports: clusterReports,
ClusterResults: map[string][]Result{
v1alpha2.StatusWarn: make([]Result, 0),
v1alpha2.StatusFail: make([]Result, 0),
v1alpha2.StatusError: make([]Result, 0),
},
NamespaceResults: map[string]map[string][]Result{},
NamespacePassed: map[string]int{},
passMX: new(sync.Mutex),
crMX: new(sync.Mutex),
nrMX: new(sync.Mutex),
}
}

View file

@ -0,0 +1,101 @@
package violations_test
import (
"testing"
"github.com/kyverno/policy-reporter/pkg/email/violations"
)
func Test_Source(t *testing.T) {
source := violations.NewSource("kyverno", true)
t.Run("Source.ClusterReports", func(t *testing.T) {
if !source.ClusterReports {
t.Errorf("Expected Surce.ClusterReports to be true")
}
})
t.Run("Source.AddClusterPassed", func(t *testing.T) {
source.AddClusterPassed(3)
if source.ClusterPassed != 3 {
t.Errorf("Unexpected Summary: %d", source.ClusterPassed)
}
})
t.Run("Source.AddClusterResults", func(t *testing.T) {
source.AddClusterResults([]violations.Result{{
Name: "policy-reporter",
Kind: "Namespace",
Policy: "require-label",
Rule: "require-label",
Status: "fail",
}, {
Name: "develop",
Kind: "Namespace",
Policy: "require-label",
Rule: "require-label",
Status: "fail",
}})
if len(source.ClusterResults["fail"]) != 2 {
t.Errorf("Unexpected amount of failing Cluster Results: %d", len(source.ClusterResults["fail"]))
}
})
t.Run("Source.AddNamespacedPassed", func(t *testing.T) {
source.AddNamespacedPassed("test", 2)
if source.NamespacePassed["test"] != 2 {
t.Errorf("Unexpected amount of passed Results in Namespace: %d", source.NamespacePassed["test"])
}
source.AddNamespacedPassed("test", 3)
if source.NamespacePassed["test"] != 5 {
t.Errorf("Unexpected amount of passed Results in Namespace: %d", source.NamespacePassed["test"])
}
})
t.Run("Source.AddNamespacedResults", func(t *testing.T) {
source.AddNamespacedResults("test", []violations.Result{{
Name: "policy-reporter",
Kind: "Deployment",
Policy: "require-label",
Rule: "require-label",
Status: "fail",
}})
if len(source.NamespaceResults["test"]["fail"]) != 1 {
t.Errorf("Unexpected amount of failing Results in namespace after init: %d", len(source.ClusterResults["fail"]))
}
if source.NamespaceResults["test"]["warn"] == nil {
t.Errorf("Expected warn map is initialized")
}
if source.NamespaceResults["test"]["error"] == nil {
t.Errorf("Expected warn map is initialized")
}
source.AddNamespacedResults("test", []violations.Result{{
Name: "policy-reporter-ui",
Kind: "Deployment",
Policy: "require-label",
Rule: "require-label",
Status: "fail",
}})
if len(source.NamespaceResults["test"]["fail"]) != 2 {
t.Errorf("Unexpected amount of failing Results in namespace after add: %d", len(source.ClusterResults["fail"]))
}
})
t.Run("Source.InitResults", func(t *testing.T) {
source.InitResults("test")
if source.NamespaceResults["test"]["fail"] == nil {
t.Errorf("Expected fail map is initialized")
}
if source.NamespaceResults["test"]["warn"] == nil {
t.Errorf("Expected warn map is initialized")
}
if source.NamespaceResults["test"]["error"] == nil {
t.Errorf("Expected warn map is initialized")
}
})
}

View file

@ -0,0 +1,54 @@
package violations
import (
"html/template"
"strings"
"time"
"github.com/kyverno/policy-reporter/pkg/email"
)
type Reporter struct {
templateDir string
clusterName string
}
func (o *Reporter) Report(sources []Source, format string) (email.Report, error) {
b := new(strings.Builder)
vioTempl := template.New("violations.html").Funcs(template.FuncMap{
"color": email.ColorFromStatus,
"title": strings.Title,
"hasViolations": func(results map[string][]Result) bool {
return (len(results["warn"]) + len(results["fail"]) + len(results["error"])) > 0
},
"lenNamespaceResults": func(source Source, ns, status string) int {
return len(source.NamespaceResults[ns][status])
},
})
templ, err := vioTempl.ParseFiles(o.templateDir + "/violations.html")
if err != nil {
return email.Report{}, err
}
err = templ.Execute(b, struct {
Sources []Source
Status []string
ClusterName string
}{Sources: sources, Status: []string{"warn", "fail", "error"}, ClusterName: o.clusterName})
if err != nil {
return email.Report{}, err
}
return email.Report{
ClusterName: o.clusterName,
Title: "Summary Report from " + time.Now().Format("2006-02-01"),
Message: b.String(),
Format: format,
}, nil
}
func NewReporter(templateDir string, clusterName string) *Reporter {
return &Reporter{templateDir, clusterName}
}

View file

@ -0,0 +1,54 @@
package violations_test
import (
"context"
"fmt"
"os"
"testing"
"github.com/kyverno/policy-reporter/pkg/email/violations"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func Test_CreateReport(t *testing.T) {
ctx := context.Background()
client, pClient, cClient := NewFakeCilent()
_, _ = pClient.Create(ctx, PolicyReportCRD, v1.CreateOptions{})
_, _ = pClient.Create(ctx, EmptyPolicyReportCRD, v1.CreateOptions{})
_, _ = client.PolicyReports("kyverno").Create(ctx, KyvernoPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, ClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, EmptyClusterPolicyReportCRD, v1.CreateOptions{})
_, _ = cClient.Create(ctx, KyvernoClusterPolicyReportCRD, v1.CreateOptions{})
generator := violations.NewGenerator(client, Filter, true)
data, err := generator.GenerateData(ctx)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
path, err := os.Getwd()
if err != nil {
t.Error(err)
}
fmt.Println(path)
reporter := violations.NewReporter("../../../templates", "Cluster")
report, err := reporter.Report(data, "html")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if report.Message == "" {
t.Fatal("expected validate report message")
}
if report.ClusterName != "Cluster" {
t.Fatal("expected clustername to be set")
}
if report.Format != "html" {
t.Fatal("expected format to be set")
}
}

67
pkg/filter/filter.go Normal file
View file

@ -0,0 +1,67 @@
package filter
import (
"github.com/kyverno/go-wildcard"
"github.com/kyverno/policy-reporter/pkg/helper"
)
type Filter struct {
namespace Rules
sources []string
}
func (f Filter) ValidateSource(source string) bool {
return ValidateSource(source, f.sources)
}
func (f Filter) ValidateNamespace(namespace string) bool {
return ValidateNamespace(namespace, f.namespace)
}
func New(namespaces Rules, sources []string) Filter {
return Filter{namespaces, sources}
}
func ValidateNamespace(namespace string, namespaces Rules) bool {
if namespace != "" && len(namespaces.Include) > 0 {
for _, ns := range namespaces.Include {
if wildcard.Match(ns, namespace) {
return true
}
}
return false
} else if namespace != "" && len(namespaces.Exclude) > 0 {
for _, ns := range namespaces.Exclude {
if wildcard.Match(ns, namespace) {
return false
}
}
}
return true
}
func ValidateRule(value string, rules Rules) bool {
if len(rules.Include) > 0 {
for _, ns := range rules.Include {
if wildcard.Match(ns, value) {
return true
}
}
return false
} else if len(rules.Exclude) > 0 {
for _, ns := range rules.Exclude {
if wildcard.Match(ns, value) {
return false
}
}
}
return true
}
func ValidateSource(source string, sources []string) bool {
return len(sources) == 0 || helper.Contains(source, sources)
}

88
pkg/filter/filter_test.go Normal file
View file

@ -0,0 +1,88 @@
package filter_test
import (
"testing"
"github.com/kyverno/policy-reporter/pkg/filter"
)
func Test_BaseClient(t *testing.T) {
t.Run("Validate Default", func(t *testing.T) {
filter := filter.New(filter.Rules{}, []string{})
if !filter.ValidateNamespace("test") {
t.Errorf("Unexpected Validation Result without configured rules")
}
if !filter.ValidateSource("Kyverno") {
t.Errorf("Unexpected Validation Result without configured rules")
}
})
t.Run("Validate Source", func(t *testing.T) {
filter := filter.New(filter.Rules{}, []string{"jsPolicy"})
if filter.ValidateSource("test") {
t.Errorf("Unexpected Validation Result")
}
if !filter.ValidateSource("jsPolicy") {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Namespace match", func(t *testing.T) {
filter := filter.New(filter.Rules{Exclude: []string{"default"}}, []string{})
if filter.ValidateNamespace("default") {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Namespace mismatch", func(t *testing.T) {
filter := filter.New(filter.Rules{Exclude: []string{"team-a"}}, []string{})
if !filter.ValidateNamespace("default") {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Include Namespace match", func(t *testing.T) {
filter := filter.New(filter.Rules{Include: []string{"default"}}, []string{})
if !filter.ValidateNamespace("default") {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Namespace mismatch", func(t *testing.T) {
filter := filter.New(filter.Rules{Include: []string{"team-a"}}, []string{})
if filter.ValidateNamespace("default") {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Rule match", func(t *testing.T) {
result := filter.ValidateRule("test", filter.Rules{Exclude: []string{"team-a"}})
if !result {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Rule mismatch", func(t *testing.T) {
result := filter.ValidateRule("test", filter.Rules{Exclude: []string{"test"}})
if result {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Include Rule match", func(t *testing.T) {
result := filter.ValidateRule("test", filter.Rules{Include: []string{"test"}})
if !result {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Include Rule mismatch", func(t *testing.T) {
result := filter.ValidateRule("test", filter.Rules{Include: []string{"team-a"}})
if result {
t.Errorf("Unexpected Validation Result")
}
})
}

6
pkg/filter/model.go Normal file
View file

@ -0,0 +1,6 @@
package filter
type Rules struct {
Exclude []string
Include []string
}

13
pkg/helper/utils.go Normal file
View file

@ -0,0 +1,13 @@
package helper
import "strings"
func Contains(source string, sources []string) bool {
for _, s := range sources {
if strings.EqualFold(s, source) {
return true
}
}
return false
}

View file

@ -1,10 +1,9 @@
package target
import (
"strings"
"github.com/kyverno/policy-reporter/pkg/filter"
"github.com/kyverno/policy-reporter/pkg/helper"
"github.com/kyverno/policy-reporter/pkg/report"
"github.com/minio/pkg/wildcard"
)
// Client for a provided Target
@ -29,15 +28,15 @@ type Rules struct {
}
type Filter struct {
Namespace Rules
Priority Rules
Policy Rules
Namespace filter.Rules
Priority filter.Rules
Policy filter.Rules
MinimumPriority string
Sources []string
}
func (f *Filter) Validate(result report.Result) bool {
if len(f.Sources) > 0 && !contains(result.Source, f.Sources) {
if len(f.Sources) > 0 && !helper.Contains(result.Source, f.Sources) {
return false
}
@ -61,49 +60,21 @@ func (f *Filter) Validate(result report.Result) bool {
}
func (f *Filter) validateNamespaceRules(result report.Result) bool {
if result.HasResource() && len(f.Namespace.Include) > 0 {
for _, ns := range f.Namespace.Include {
if wildcard.Match(ns, result.Resource.Namespace) {
if !result.HasResource() {
return true
}
}
return false
} else if result.HasResource() && len(f.Namespace.Exclude) > 0 {
for _, ns := range f.Namespace.Exclude {
if wildcard.Match(ns, result.Resource.Namespace) {
return false
}
}
}
return true
return filter.ValidateNamespace(result.Resource.Namespace, f.Namespace)
}
func (f *Filter) validatePolicyRules(result report.Result) bool {
if len(f.Policy.Include) > 0 {
for _, ns := range f.Policy.Include {
if wildcard.Match(ns, result.Policy) {
return true
}
}
return false
} else if len(f.Policy.Exclude) > 0 {
for _, ns := range f.Policy.Exclude {
if wildcard.Match(ns, result.Policy) {
return false
}
}
}
return true
return filter.ValidateRule(result.Policy, f.Policy)
}
func (f *Filter) validatePriorityRules(result report.Result) bool {
if len(f.Priority.Include) > 0 {
return contains(result.Priority.String(), f.Priority.Include)
} else if len(f.Priority.Exclude) > 0 && contains(result.Priority.String(), f.Priority.Exclude) {
return helper.Contains(result.Priority.String(), f.Priority.Include)
} else if len(f.Priority.Exclude) > 0 && helper.Contains(result.Priority.String(), f.Priority.Exclude) {
return false
}
@ -139,13 +110,3 @@ func (c *BaseClient) SkipExistingOnStartup() bool {
func NewBaseClient(name string, skipExistingOnStartup bool, filter *Filter) BaseClient {
return BaseClient{name, skipExistingOnStartup, filter}
}
func contains(source string, sources []string) bool {
for _, s := range sources {
if strings.EqualFold(s, source) {
return true
}
}
return false
}

View file

@ -3,6 +3,7 @@ package target_test
import (
"testing"
"github.com/kyverno/policy-reporter/pkg/filter"
"github.com/kyverno/policy-reporter/pkg/report"
"github.com/kyverno/policy-reporter/pkg/target"
)
@ -26,6 +27,18 @@ var result = report.Result{
},
}
var result2 = 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,
Source: "Kyverno",
}
func Test_BaseClient(t *testing.T) {
t.Run("Validate Default", func(t *testing.T) {
filter := &target.Filter{}
@ -49,29 +62,37 @@ func Test_BaseClient(t *testing.T) {
}
})
t.Run("Validate ClusterResult", func(t *testing.T) {
filter := &target.Filter{Namespace: filter.Rules{Include: []string{"default"}}}
if !filter.Validate(result2) {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Namespace match", func(t *testing.T) {
filter := &target.Filter{Namespace: target.Rules{Exclude: []string{"default"}}}
filter := &target.Filter{Namespace: filter.Rules{Exclude: []string{"default"}}}
if filter.Validate(result) {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Namespace mismatch", func(t *testing.T) {
filter := &target.Filter{Namespace: target.Rules{Exclude: []string{"team-a"}}}
filter := &target.Filter{Namespace: filter.Rules{Exclude: []string{"team-a"}}}
if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Include Namespace match", func(t *testing.T) {
filter := &target.Filter{Namespace: target.Rules{Include: []string{"default"}}}
filter := &target.Filter{Namespace: filter.Rules{Include: []string{"default"}}}
if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Namespace mismatch", func(t *testing.T) {
filter := &target.Filter{Namespace: target.Rules{Include: []string{"team-a"}}}
filter := &target.Filter{Namespace: filter.Rules{Include: []string{"team-a"}}}
if filter.Validate(result) {
t.Errorf("Unexpected Validation Result")
@ -79,28 +100,28 @@ func Test_BaseClient(t *testing.T) {
})
t.Run("Validate Exclude Priority match", func(t *testing.T) {
filter := &target.Filter{Priority: target.Rules{Exclude: []string{report.WarningPriority.String()}}}
filter := &target.Filter{Priority: filter.Rules{Exclude: []string{report.WarningPriority.String()}}}
if filter.Validate(result) {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Priority mismatch", func(t *testing.T) {
filter := &target.Filter{Priority: target.Rules{Exclude: []string{report.ErrorPriority.String()}}}
filter := &target.Filter{Priority: filter.Rules{Exclude: []string{report.ErrorPriority.String()}}}
if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Include Priority match", func(t *testing.T) {
filter := &target.Filter{Priority: target.Rules{Include: []string{report.WarningPriority.String()}}}
filter := &target.Filter{Priority: filter.Rules{Include: []string{report.WarningPriority.String()}}}
if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Priority mismatch", func(t *testing.T) {
filter := &target.Filter{Priority: target.Rules{Include: []string{report.ErrorPriority.String()}}}
filter := &target.Filter{Priority: filter.Rules{Include: []string{report.ErrorPriority.String()}}}
if filter.Validate(result) {
t.Errorf("Unexpected Validation Result")
@ -108,28 +129,28 @@ func Test_BaseClient(t *testing.T) {
})
t.Run("Validate Exclude Policy match", func(t *testing.T) {
filter := &target.Filter{Policy: target.Rules{Exclude: []string{"require-requests-and-limits-required"}}}
filter := &target.Filter{Policy: filter.Rules{Exclude: []string{"require-requests-and-limits-required"}}}
if filter.Validate(result) {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Policy mismatch", func(t *testing.T) {
filter := &target.Filter{Policy: target.Rules{Exclude: []string{"policy-test"}}}
filter := &target.Filter{Policy: filter.Rules{Exclude: []string{"policy-test"}}}
if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Include Policy match", func(t *testing.T) {
filter := &target.Filter{Policy: target.Rules{Include: []string{"require-requests-and-limits-required"}}}
filter := &target.Filter{Policy: filter.Rules{Include: []string{"require-requests-and-limits-required"}}}
if !filter.Validate(result) {
t.Errorf("Unexpected Validation Result")
}
})
t.Run("Validate Exclude Policy mismatch", func(t *testing.T) {
filter := &target.Filter{Policy: target.Rules{Include: []string{"policy-test"}}}
filter := &target.Filter{Policy: filter.Rules{Include: []string{"policy-test"}}}
if filter.Validate(result) {
t.Errorf("Unexpected Validation Result")

311
templates/summary.html Normal file
View file

@ -0,0 +1,311 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style type="text/css">
body,table,td{font-family:Helvetica,Arial,sans-serif !important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body a,#MessageViewBody a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-]) td{border-spacing:0px;border-collapse:collapse}@media screen and (max-width: 600px){.row-responsive.row{margin-right:0 !important}td.col-lg-3{display:block;width:100% !important;padding-left:0 !important;padding-right:0 !important}.w-full,.w-full>tbody>tr>td{width:100% !important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td a{padding:16px !important}.pt-6:not(table),.pt-6:not(.btn)>tbody>tr>td,.pt-6.btn td a,.py-6:not(table),.py-6:not(.btn)>tbody>tr>td,.py-6.btn td a{padding-top:24px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0 !important;line-height:0 !important;height:0 !important}.s-0>tbody>tr>td{font-size:0 !important;line-height:0 !important;height:0 !important}.s-4>tbody>tr>td{font-size:16px !important;line-height:16px !important;height:16px !important}.s-6>tbody>tr>td{font-size:24px !important;line-height:24px !important;height:24px !important}.s-8>tbody>tr>td{font-size:32px !important;line-height:32px !important;height:32px !important}}
</style>
</head>
<body class="bg-light" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
<table class="bg-light body" valign="top" role="presentation" border="0" cellpadding="0" cellspacing="0" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
<tbody>
<tr>
<td valign="top" style="line-height: 24px; font-size: 16px; margin: 0;" align="left" bgcolor="#f7fafc">
<table class="container-fluid" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 0 16px;" align="left">
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if .ClusterName }}
<h1 class="h1" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 36px; line-height: 43.2px; margin: 0;" align="left">{{ .ClusterName}}: Summary Report</h1>
{{ else }}
<h1 class="h1" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 36px; line-height: 43.2px; margin: 0;" align="left">Summary Report</h1>
{{ end }}
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ range $key, $source := .Sources }}
<table class="card p-4 pt-6 my-4" role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-radius: 6px; border-collapse: separate !important; width: 100%; overflow: hidden; border: 1px solid #e2e8f0;" bgcolor="#ffffff">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 24px 16px 16px;" align="left" bgcolor="#ffffff">
<h2 class="h2" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 32px; line-height: 38.4px; margin: 0;" align="left">{{ $source.Name }}</h2>
<table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;" align="left" width="100%" height="24">
&#160;
</td>
</tr>
</tbody>
</table>
<h3 class="h4" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">ClusterPolicyReport Summary</h3>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if $source.ClusterReports }}
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="container-fluid" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 0 16px;" align="left">
<div class="row row-responsive" style="margin-right: -24px;">
<table class="" role="presentation" border="0" cellpadding="0" cellspacing="0" style="table-layout: fixed; width: 100%;" width="100%">
<tbody>
<tr>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-green-500 text-center" style="color: #198754; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Pass
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ $source.ClusterScopeSummary.Pass }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-orange-500 text-center" style="color: #fd7e14; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Warning
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ $source.ClusterScopeSummary.Warn }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-red-500 text-center" style="color: #dc3545; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Fail
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ $source.ClusterScopeSummary.Fail }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-red-600 text-center" style="color: #b02a37; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Error
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ $source.ClusterScopeSummary.Error }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
{{ end }}
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-8 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 32px; font-size: 32px; width: 100%; height: 32px; margin: 0;" align="left" width="100%" height="32">
&#160;
</td>
</tr>
</tbody>
</table>
{{if $source.NamespaceScopeSummary }}
<h3 class="h4" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">PolicyReport Summary</h3>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="table table-striped thead-default table-bordered" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 100%; border: 1px solid #e2e8f0;">
<thead>
<tr>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Namespace</th>
<th class="text-right text-green-500" style="line-height: 24px; font-size: 16px; color: #198754; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="right" valign="top">Pass</th>
<th class="text-right text-orange-500" style="line-height: 24px; font-size: 16px; color: #fd7e14; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="right" valign="top">Warning</th>
<th class="text-right text-red-500" style="line-height: 24px; font-size: 16px; color: #dc3545; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="right" valign="top">Fail</th>
<th class="text-right text-red-600" style="line-height: 24px; font-size: 16px; color: #b02a37; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="right" valign="top">Error</th>
</tr>
</thead>
<tbody>
{{ range $ns, $sum := $source.NamespaceScopeSummary }}
<tr style="" bgcolor="#f2f2f2">
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $ns }}</td>
<td class="text-right" style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="right" valign="top">{{ $sum.Pass }}</td>
<td class="text-right" style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="right" valign="top">{{ $sum.Warn }}</td>
<td class="text-right" style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="right" valign="top">{{ $sum.Fail }}</td>
<td class="text-right" style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="right" valign="top">{{ $sum.Error }}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</td>
</tr>
</tbody>
</table>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ end }}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

458
templates/violations.html Normal file
View file

@ -0,0 +1,458 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style type="text/css">
body,table,td{font-family:Helvetica,Arial,sans-serif !important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body a,#MessageViewBody a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-]) td{border-spacing:0px;border-collapse:collapse}@media screen and (max-width: 600px){.row-responsive.row{margin-right:0 !important}td.col-lg-3{display:block;width:100% !important;padding-left:0 !important;padding-right:0 !important}.w-full,.w-full>tbody>tr>td{width:100% !important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td a{padding:16px !important}.pt-6:not(table),.pt-6:not(.btn)>tbody>tr>td,.pt-6.btn td a,.py-6:not(table),.py-6:not(.btn)>tbody>tr>td,.py-6.btn td a{padding-top:24px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0 !important;line-height:0 !important;height:0 !important}.s-0>tbody>tr>td{font-size:0 !important;line-height:0 !important;height:0 !important}.s-2>tbody>tr>td{font-size:8px !important;line-height:8px !important;height:8px !important}.s-4>tbody>tr>td{font-size:16px !important;line-height:16px !important;height:16px !important}.s-6>tbody>tr>td{font-size:24px !important;line-height:24px !important;height:24px !important}.s-8>tbody>tr>td{font-size:32px !important;line-height:32px !important;height:32px !important}}
</style>
</head>
<body class="bg-light" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
<table class="bg-light body" valign="top" role="presentation" border="0" cellpadding="0" cellspacing="0" style="outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;" bgcolor="#f7fafc">
<tbody>
<tr>
<td valign="top" style="line-height: 24px; font-size: 16px; margin: 0;" align="left" bgcolor="#f7fafc">
<table class="container-fluid" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 0 16px;" align="left">
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if .ClusterName }}
<h1 class="h1" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 36px; line-height: 43.2px; margin: 0;" align="left">{{ .ClusterName}}: Summary Report</h1>
{{ else }}
<h1 class="h1" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 36px; line-height: 43.2px; margin: 0;" align="left">Summary Report</h1>
{{ end }}
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ range $key, $source := .Sources }}
<table class="card p-4 pt-6" role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-radius: 6px; border-collapse: separate !important; width: 100%; overflow: hidden; border: 1px solid #e2e8f0;" bgcolor="#ffffff">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 24px 16px 16px;" align="left" bgcolor="#ffffff">
<h2 class="h2" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 32px; line-height: 38.4px; margin: 0;" align="left">{{ $source.Name }}</h2>
<table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;" align="left" width="100%" height="24">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if $source.ClusterReports }}
<h3 class="h4" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">ClusterPolicyReport Summary</h3>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<h4 class="h4 text-green-500" style="color: #198754; padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">Pass Results: {{ $source.ClusterPassed }}</h4>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
{{ range $status := $.Status }}
{{ $results := index $source.ClusterResults $status }}
{{ $length := len $results }}
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<h4 class="h4 text-orange-500" style="color: {{ color $status }}; padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">{{ title $status }} Results: {{ $length }} </h4>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if $results }}
<table class="table table-striped thead-default table-bordered" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 100%; border: 1px solid #e2e8f0;">
<thead>
<tr>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Kind</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Name</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Policy</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Valdiation Rule</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Result</th>
</tr>
</thead>
<tbody>
{{ range $key, $result := $results }}
<tr style="" bgcolor="#f2f2f2">
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Kind }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Name }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Policy }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Rule }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Status }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="hr" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; border-top-width: 1px; border-top-color: #e2e8f0; border-top-style: solid; height: 1px; width: 100%; margin: 0;" align="left">
</td>
</tr>
</tbody>
</table>
<table class="s-0 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 0; font-size: 0; width: 100%; height: 0; margin: 0;" align="left" width="100%" height="0">
&#160;
</td>
</tr>
</tbody>
</table>
{{ end }}
{{ end }}
{{ end }}
{{ if or $source.NamespaceResults $source.NamespacePassed }}
<table class="s-8 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 32px; font-size: 32px; width: 100%; height: 32px; margin: 0;" align="left" width="100%" height="32">
&#160;
</td>
</tr>
</tbody>
</table>
<h3 class="h4" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="left">PolicyReport Summary</h3>
{{ end }}
{{ range $namespace, $list := $source.NamespaceResults }}
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height:32px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="32">
&#160;
</td>
</tr>
</tbody>
</table>
<h3 class="h5" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 20px; line-height: 24px; margin: 0;" align="left">Namespace: {{ $namespace }}</h3>
<table class="s-8 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 32px; font-size: 32px; width: 100%; height: 32px; margin: 0;" align="left" width="100%" height="32">
&#160;
</td>
</tr>
</tbody>
</table>
<table class="card" role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-radius: 6px; border-collapse: separate !important; width: 100%; overflow: hidden; border: 1px solid #e2e8f0;" bgcolor="#ffffff">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0;" align="left" bgcolor="#ffffff">
<table class="container-fluid" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 0 16px;" align="left">
<div class="row row-responsive" style="margin-right: -24px;">
<table class="" role="presentation" border="0" cellpadding="0" cellspacing="0" style="table-layout: fixed; width: 100%;" width="100%">
<tbody>
<tr>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-green-500 text-center" style="color: #198754; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Pass
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{index $source.NamespacePassed $namespace }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-orange-500 text-center" style="color: #fd7e14; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Warning
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ lenNamespaceResults $source $namespace "warn" }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-red-500 text-center" style="color: #dc3545; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Fail
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ lenNamespaceResults $source $namespace "fail" }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="col-lg-3 col-6" style="line-height: 24px; font-size: 16px; min-height: 1px; font-weight: normal; padding-right: 24px; width: 50%; margin: 0;" align="left" valign="top">
<table class="p-4" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 16px;" align="left">
<div class="space-y-4">
<h1 class="h4 fw-500 text-red-600 text-center" style="color: #b02a37; padding-top: 0; padding-bottom: 0; font-weight: 500 !important; vertical-align: baseline; font-size: 24px; line-height: 28.8px; margin: 0;" align="center">
Error
</h1>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
<p class="text-4xl text-center" style="line-height: 43.2px; font-size: 36px; width: 100%; margin: 0;" align="center">{{ lenNamespaceResults $source $namespace "error" }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table class="s-2 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 8px; font-size: 8px; width: 100%; height: 8px; margin: 0;" align="left" width="100%" height="8">
&#160;
</td>
</tr>
</tbody>
</table>
{{ if hasViolations $list }}
<table class="table table-striped thead-default table-bordered" border="0" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 100%; border: 1px solid #e2e8f0;">
<thead>
<tr>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Kind</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Name</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Policy</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Valdiation Rule</th>
<th style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border-color: #e2e8f0; border-style: solid; border-width: 1px 1px 2px;" align="left" valign="top">Result</th>
</tr>
</thead>
<tbody>
{{ range $status, $results := $list }}
{{ range $key, $result := $results }}
<tr style="" bgcolor="#f2f2f2">
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Kind }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Name }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Policy }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">{{ $result.Rule }}</td>
<td style="line-height: 24px; font-size: 16px; margin: 0; padding: 12px; border: 1px solid #e2e8f0;" align="left" valign="top">
<table class="badge bg-orange-500 text-white" align="left" role="presentation" border="0" cellpadding="0" cellspacing="0" style="color: #ffffff;" bgcolor="{{ color $status }}">
<tbody>
<tr>
<td style="line-height: 1; font-size: 75%; display: inline-block; font-weight: 700; white-space: nowrap; border-radius: 4px; color: #ffffff; margin: 0; padding: 4px 6.4px;" align="center" bgcolor="{{ color $status }}" valign="baseline">
<span>{{ $result.Status }}</span>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
{{end}}
{{end}}
</td>
</tr>
</tbody>
</table>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>
<tr>
<td style="line-height: 16px; font-size: 16px; width: 100%; height: 16px; margin: 0;" align="left" width="100%" height="16">
&#160;
</td>
</tr>
</tbody>
</table>
{{ end }}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>