diff --git a/internal/kube/exposedsecretreport.go b/internal/kube/exposedsecretreport.go new file mode 100644 index 0000000..b0926b5 --- /dev/null +++ b/internal/kube/exposedsecretreport.go @@ -0,0 +1,24 @@ +package kube + +import ( + "context" + + "github.com/aquasecurity/trivy-operator/pkg/apis/aquasecurity/v1alpha1" +) + +const exposedSecretReportsResource = "exposedsecretreports" + +// GetExposedSecretReportList retrieves all resources of type exposedsecretreports in all namespaces. +func GetExposedSecretReportList() (*v1alpha1.ExposedSecretReportList, error) { + var list v1alpha1.ExposedSecretReportList + err := client. + Get(). + Resource(exposedSecretReportsResource). + Do(context.TODO()). + Into(&list) + if err != nil { + return nil, err + } + + return &list, nil +} diff --git a/internal/web/server.go b/internal/web/server.go index 5986ac7..4a5a3f4 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -16,6 +16,8 @@ import ( clusterrolesview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/clusterroles" configauditview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/configaudit" configauditsview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/configaudits" + exposedsecretview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/exposedsecret" + exposedsecretsview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/exposedsecrets" imageview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/image" imagesview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/images" roleview "github.com/starttoaster/trivy-operator-explorer/internal/web/views/role" @@ -27,14 +29,16 @@ func Start(port string) error { mux := http.NewServeMux() mux.HandleFunc("/", imagesHandler) mux.HandleFunc("/image", imageHandler) - mux.HandleFunc("/roles", rolesHandler) - mux.HandleFunc("/role", roleHandler) - mux.HandleFunc("/clusterroles", clusterrolesHandler) - mux.HandleFunc("/clusterrole", clusterroleHandler) mux.HandleFunc("/configaudits", configauditsHandler) mux.HandleFunc("/configaudit", configauditHandler) mux.HandleFunc("/clusteraudits", clusterauditsHandler) mux.HandleFunc("/clusteraudit", clusterauditHandler) + mux.HandleFunc("/clusterroles", clusterrolesHandler) + mux.HandleFunc("/clusterrole", clusterroleHandler) + mux.HandleFunc("/exposedsecrets", exposedsecretsHandler) + mux.HandleFunc("/exposedsecret", exposedsecretHandler) + mux.HandleFunc("/roles", rolesHandler) + mux.HandleFunc("/role", roleHandler) mux.Handle("/static/", http.FileServer(http.FS(content.Static))) return http.ListenAndServe(fmt.Sprintf(":%s", port), mux) } @@ -441,3 +445,82 @@ func clusterauditHandler(w http.ResponseWriter, r *http.Request) { return } } + +func exposedsecretsHandler(w http.ResponseWriter, r *http.Request) { + tmpl := template.Must(template.ParseFS(content.Static, "static/exposedsecrets.html", "static/sidebar.html")) + if tmpl == nil { + log.Logger.Error("encountered error parsing exposed secrets html template") + http.Error(w, "Internal Server Error, check server logs", http.StatusInternalServerError) + return + } + + data, err := kube.GetExposedSecretReportList() + if err != nil { + log.Logger.Error("error getting ExposedSecretReports", "error", err.Error()) + return + } + imageData := exposedsecretsview.GetView(data) + + err = tmpl.Execute(w, imageData) + if err != nil { + log.Logger.Error("encountered error executing exposed secrets html template", "error", err) + http.Error(w, "Internal Server Error, check server logs", http.StatusInternalServerError) + return + } +} + +func exposedsecretHandler(w http.ResponseWriter, r *http.Request) { + tmpl := template.Must(template.ParseFS(content.Static, "static/exposedsecret.html", "static/sidebar.html")) + if tmpl == nil { + log.Logger.Error("encountered error parsing exposed secret html template") + http.Error(w, "Internal Server Error, check server logs", http.StatusInternalServerError) + return + } + + // Parse URL query params + q := r.URL.Query() + + // Check query params -- 404 if required params not passed + imageName := q.Get("image") + if imageName == "" { + log.Logger.Error("image name query param missing from request") + http.NotFound(w, r) + return + } + imageDigest := q.Get("digest") + if imageDigest == "" { + log.Logger.Error("image digest query param missing from request") + http.NotFound(w, r) + return + } + severity := q.Get("severity") + + // Get secret reports + data, err := kube.GetExposedSecretReportList() + if err != nil { + log.Logger.Error("error getting ExposedSecretReports", "error", err.Error()) + return + } + + // Get image view from reports + view, found := exposedsecretview.GetView(data, exposedsecretview.Filters{ + Name: imageName, + Digest: imageDigest, + Severity: severity, + }) + + // If the selected image from query params was not found, 404 + if !found { + log.Logger.Error("image name and digest query params did not produce a valid result from scraped data", "image", imageName, "digest", imageDigest) + http.NotFound(w, r) + return + } + + // Execute html template + err = tmpl.Execute(w, view) + if err != nil { + log.Logger.Error("encountered error executing exposed secret html template", "error", err) + http.Error(w, "Internal Server Error, check server logs", http.StatusInternalServerError) + return + } +} diff --git a/internal/web/views/exposedsecret/image.go b/internal/web/views/exposedsecret/image.go new file mode 100644 index 0000000..7cc1a08 --- /dev/null +++ b/internal/web/views/exposedsecret/image.go @@ -0,0 +1,97 @@ +package image + +import ( + "fmt" + "sort" + "strings" + + "github.com/aquasecurity/trivy-operator/pkg/apis/aquasecurity/v1alpha1" +) + +// Filters contains the supported filters for the image view +type Filters struct { + Name string + Digest string + + // optional filters + Severity string +} + +// GetView converts some report data to the /exposedsecret view +// returns view data and "true" if the image was found in the report list +func GetView(data *v1alpha1.ExposedSecretReportList, filters Filters) (View, bool) { + for _, item := range data.Items { + itemImageName := getImageNameFromLabels(item.Report.Registry.Server, item.Report.Artifact.Repository, item.Report.Artifact.Tag) + if filters.Name != itemImageName || filters.Digest != item.Report.Artifact.Digest { + continue + } + + i := View{ + Name: itemImageName, + Digest: item.Report.Artifact.Digest, + } + + for _, v := range item.Report.Secrets { + secret := Secret{ + Severity: string(v.Severity), + Title: v.Title, + Target: v.Target, + Match: v.Match, + } + + uniqueSecret := i.isUniqueImageSecret(secret.Severity, secret.Title, secret.Target, secret.Match) + if uniqueSecret { + // Skip vulnerability if any filters don't match + // Filter severity + if filters.Severity != "" && !strings.EqualFold(secret.Severity, filters.Severity) { + continue + } + + i.Secrets = append(i.Secrets, secret) + } + } + + i = sortView(i) + + return i, true + } + + return View{}, false +} + +func getImageNameFromLabels(registry, repo, tag string) string { + if registry == "index.docker.io" { + // If Docker Hub, trim the registry prefix for readability + // Also trims `library/` from the prefix of the image name, which is a hidden username for Docker Hub official images + return fmt.Sprintf("%s:%s", strings.TrimPrefix(repo, "library/"), tag) + } + return fmt.Sprintf("%s/%s:%s", registry, repo, tag) +} + +func (i View) isUniqueImageSecret(severity, title, target, match string) bool { + for _, secret := range i.Secrets { + if severity == secret.Severity && title == secret.Title && target == secret.Target && match == secret.Match { + return false + } + } + + return true +} + +func sortView(v View) View { + // Create an order for severities to sort by + // Define custom priority order + severityOrder := map[string]int{ + "CRITICAL": 3, + "HIGH": 2, + "MEDIUM": 1, + "LOW": 0, + } + + // Sort the slice by severity in descending order + sort.Slice(v.Secrets, func(j, k int) bool { + return severityOrder[v.Secrets[j].Severity] > severityOrder[v.Secrets[k].Severity] + }) + + return v +} diff --git a/internal/web/views/exposedsecret/types.go b/internal/web/views/exposedsecret/types.go new file mode 100644 index 0000000..b81f4e8 --- /dev/null +++ b/internal/web/views/exposedsecret/types.go @@ -0,0 +1,19 @@ +package image + +// View data about an image and their exposed secrets +type View Data + +// Data contains data about an image and its exposed secrets +type Data struct { + Name string // name of the image + Digest string // sha digest of the image + Secrets []Secret +} + +// Secret data related to an exposed secret +type Secret struct { + Severity string + Title string + Target string + Match string +} diff --git a/internal/web/views/exposedsecrets/images.go b/internal/web/views/exposedsecrets/images.go new file mode 100644 index 0000000..96b708b --- /dev/null +++ b/internal/web/views/exposedsecrets/images.go @@ -0,0 +1,146 @@ +package images + +import ( + "fmt" + "sort" + "strings" + + "github.com/aquasecurity/trivy-operator/pkg/apis/aquasecurity/v1alpha1" +) + +// GetView converts some report data to the /exposedsecrets view +func GetView(data *v1alpha1.ExposedSecretReportList) View { + var i View + + for _, item := range data.Items { + image := Data{ + Name: getImageNameFromLabels(item.Report.Registry.Server, item.Report.Artifact.Repository, item.Report.Artifact.Tag), + Digest: item.Report.Artifact.Digest, + } + resourceData := ResourceMetadata{ + Kind: item.ObjectMeta.Labels["trivy-operator.resource.kind"], + Name: item.ObjectMeta.Labels["trivy-operator.resource.name"], + Namespace: item.ObjectMeta.Labels["trivy-operator.resource.namespace"], + } + image.Resources = make(map[ResourceMetadata]struct{}) + image.Resources[resourceData] = struct{}{} + + // Check if the image used is unique, by fullname (registry/repository:tag) and digest + // This check is a little inefficient because it loops through the whole image list + // I was previously using maps for uniqueness and then converted over to slices because they're easier to sort server-side + imageIndex, uniqueImage := i.isUniqueImage(image.Name, image.Digest) + + // Add image if unique, retrieving the new image's data index in the slice + if uniqueImage { + i = append(i, image) + imageIndex = len(i) - 1 + } else { + // Add this resource to the image at the given index if image data already present + i[imageIndex].Resources[resourceData] = struct{}{} + } + + for _, v := range item.Report.Secrets { + // Construct this secret's view data + secret := Secret{ + Severity: string(v.Severity), + Title: v.Title, + Target: v.Target, + Match: v.Match, + } + + uniqueSecret := i[imageIndex].isUniqueImageSecret(secret.Severity, secret.Title, secret.Target, secret.Match) + if uniqueSecret { + i[imageIndex].addSecretData(secret) + } + } + } + + i = sortView(i) + + return i +} + +func sortView(i View) View { + // Sort the slice by severity in descending order + sort.Slice(i, func(j, k int) bool { + if len(i[j].Critical) != len(i[k].Critical) { + return len(i[j].Critical) > len(i[k].Critical) + } + + if len(i[j].High) != len(i[k].High) { + return len(i[j].High) > len(i[k].High) + } + + if len(i[j].Medium) != len(i[k].Medium) { + return len(i[j].Medium) > len(i[k].Medium) + } + + return len(i[j].Low) > len(i[k].Low) + }) + + return i +} + +func (i View) isUniqueImage(name, digest string) (int, bool) { + for index, image := range i { + if name == image.Name && digest == image.Digest { + return index, false + } + } + + return 0, true +} + +func (i Data) isUniqueImageSecret(severity, title, target, match string) bool { + switch strings.ToLower(severity) { + case "critical": + for _, secret := range i.Critical { + if title == secret.Title && target == secret.Target && match == secret.Match { + return false + } + } + case "high": + for _, secret := range i.High { + if title == secret.Title && target == secret.Target && match == secret.Match { + return false + } + } + case "medium": + for _, secret := range i.Medium { + if title == secret.Title && target == secret.Target && match == secret.Match { + return false + } + } + case "low": + for _, secret := range i.Low { + if title == secret.Title && target == secret.Target && match == secret.Match { + return false + } + } + } + + return true +} + +func (i *Data) addSecretData(v Secret) { + switch strings.ToLower(v.Severity) { + case "critical": + i.Critical = append(i.Critical, v) + case "high": + i.High = append(i.High, v) + case "medium": + i.Medium = append(i.Medium, v) + case "low": + i.Low = append(i.Low, v) + } + +} + +func getImageNameFromLabels(registry, repo, tag string) string { + if registry == "index.docker.io" { + // If Docker Hub, trim the registry prefix for readability + // Also trims `library/` from the prefix of the image name, which is a hidden username for Docker Hub official images + return fmt.Sprintf("%s:%s", strings.TrimPrefix(repo, "library/"), tag) + } + return fmt.Sprintf("%s/%s:%s", registry, repo, tag) +} diff --git a/internal/web/views/exposedsecrets/types.go b/internal/web/views/exposedsecrets/types.go new file mode 100644 index 0000000..0d10582 --- /dev/null +++ b/internal/web/views/exposedsecrets/types.go @@ -0,0 +1,30 @@ +package images + +// View a list of data about images and their secrets +type View []Data + +// Data contains data about an image and its exposed secrets +type Data struct { + Name string // name of the image + Digest string // sha digest of the image + Resources map[ResourceMetadata]struct{} // data about resources using this image + Critical []Secret + High []Secret + Medium []Secret + Low []Secret +} + +// ResourceMetadata data related to a k8s resource using an image +type ResourceMetadata struct { + Kind string + Name string + Namespace string +} + +// Secret data related to an exposed secret +type Secret struct { + Severity string + Title string + Target string + Match string +} diff --git a/main.go b/main.go index a36dc5f..213c19d 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,8 @@ import ( //go:embed static/role.html //go:embed static/clusterroles.html //go:embed static/clusterrole.html +//go:embed static/exposedsecrets.html +//go:embed static/exposedsecret.html //go:embed static/images.html //go:embed static/image.html //go:embed static/css/output.css diff --git a/static/exposedsecret.html b/static/exposedsecret.html new file mode 100644 index 0000000..06c40cc --- /dev/null +++ b/static/exposedsecret.html @@ -0,0 +1,72 @@ + + + {{ .Name }} + + + + + + + {{template "sidebar.html"}} + + + + + +
+
+ + + + + + + + + + {{ range $data := .Secrets }} + + + + + + {{ end }} + +
+ Severity + + Title + + Target +
+ {{if eq $data.Severity "CRITICAL"}} + {{ $data.Severity }} + {{else if eq $data.Severity "HIGH"}} + {{ $data.Severity }} + {{else if eq $data.Severity "MEDIUM"}} + {{ $data.Severity }} + {{else if eq $data.Severity "LOW"}} + {{ $data.Severity }} + {{end}} + + {{ $data.Title }} + + {{ $data.Target }} +
+
+
+ + diff --git a/static/exposedsecrets.html b/static/exposedsecrets.html new file mode 100644 index 0000000..9eb03a4 --- /dev/null +++ b/static/exposedsecrets.html @@ -0,0 +1,92 @@ + + + Explorer: Exposed Secrets + + + + + + + + + {{template "sidebar.html"}} + + +
+
+ + + + + + + + + + + + {{ range $data := . }} + + + + + + + + + {{ end }} + +
+ Image + + Affected Resources + + Exposed Secrets +
+ + {{ $data.Name }} + + + + + {{ if $data.Critical }} + + {{ len $data.Critical }} + + {{ end }} + {{ if $data.High }} + + {{ len $data.High }} + + {{ end }} + {{ if $data.Medium }} + + {{ len $data.Medium }} + + {{ end }} + {{ if $data.Low }} + + {{ len $data.Low }} + + {{ end }} +
+
+
+ + diff --git a/static/sidebar.html b/static/sidebar.html index 45e2ec5..8ee38ad 100644 --- a/static/sidebar.html +++ b/static/sidebar.html @@ -11,6 +11,12 @@ Images +
  • + + + Exposed Secrets + +