diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4c8d17f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + global: + patterns: + - "*" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..3522dfe --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,61 @@ +name: Build docker image + +on: + push: + branches: + - main + tags: + - '**' + workflow_dispatch: + schedule: + - cron: '0 9 * * 1' + +permissions: + id-token: write + contents: read + packages: write + +jobs: + package: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ github.repository }} + flavor: | + latest=auto + prefix= + suffix= + tags: | + type=raw,value=main,enable={{is_default_branch}} + type=semver,pattern=v{{version}} + type=semver,pattern=v{{major}}.{{minor}} + type=semver,pattern=v{{major}} + type=sha,format=long + + - name: Build Docker Container + uses: docker/build-push-action@v5 + with: + platforms: "linux/amd64,linux/arm64" + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml new file mode 100644 index 0000000..a40dbb4 --- /dev/null +++ b/.github/workflows/helm.yml @@ -0,0 +1,31 @@ +name: Release Charts + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - name: Run chart-releaser + uses: helm/chart-releaser-action@v1.6.0 + env: + CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + with: + charts_dir: ./chart + mark_as_latest: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0d1455a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,54 @@ +name: Test/Lint/Fmt/Vet +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + container: golang:1 + env: + GOFLAGS: "-buildvcs=false" + steps: + - uses: actions/checkout@v4 + + - name: Build + run: go build -o /dev/null ./... + + - name: Test + run: go test ./... + + - name: Check gofmt changes + run: | + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "Check local formatting with gofmt, the following files have formatting deviations:" + gofmt -s -l . + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Verify Go modules + run: | + go mod download + go mod verify + + - name: Run govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck -test ./... + + - name: Run errcheck + run: | + go install github.com/kisielk/errcheck@latest + errcheck ./... + + - name: Run staticcheck + run: | + go install honnef.co/go/tools/cmd/staticcheck@latest + staticcheck ./... + + - name: Run golint + run: | + go install golang.org/x/lint/golint@latest + golint -set_exit_status ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67ae283 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/trivy-operator-explorer +/bin +/testdata.txt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..44f3c4b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1 as build + +WORKDIR /go/src/app +COPY . . + +RUN go mod download +RUN CGO_ENABLED=0 go build -o /go/bin/app + +FROM gcr.io/distroless/static-debian12 +COPY --from=build /go/bin/app / +CMD ["/app"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..17940de --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024 Brandon Butler + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 751a3fb..ff23d0d 100644 --- a/README.md +++ b/README.md @@ -1 +1,26 @@ # Trivy Operator Explorer + +This is a web explorer that scrapes the data exported by the [Trivy Operator for Kubernetes.](https://github.com/aquasecurity/trivy-operator) The Trivy Operator exports a LOT of metrics about vulnerabilities in a kubernetes cluster; so many that some people may consider not storing all of that in Prometheus because metrics with high levels of cardinality in label sets can cause query performance issues. Because of this, instead of relying on Prometheus to scrape the metrics, and have this query Prometheus, this explorer scrapes the operator's metrics itself and parses it for dashboarding. + +## Usage + +This is still heavily in progress. This is just local dev usage for now. Assuming your Trivy Operator installation is in the trivy-system namespace, in one shell: +``` +kubectl port-forward -n trivy-system service/trivy-operator 8081:80 +``` + +Then in another shell window: +``` +export TRIVY_OPERATOR_EXPLORER_METRICS_ENDPOINT="http://localhost:8081/metrics" +go generate && go build && ./trivy-operator-explorer +``` + +## TODO + +- Graphical elements for setting filters, currently they're just URL query parameters. +- Add Role/ClusterRole vulnerabilities to the dashboard. +- Support different vulnerability IDs - currently GHSA vulnerabilities link to NIST just like normal CVEs, where NIST 404s. +- Add ability to connect to cluster to check for images or roles not scanned yet. +- Find out more about exposed secrets scanning. Add it to the dashboard? +- Does a JSON API make sense for this? Is there a use case for it? +- Testing. Pretty sure by law no new product has testing and it gets added later. diff --git a/chart/trivy-operator-explorer/.helmignore b/chart/trivy-operator-explorer/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/chart/trivy-operator-explorer/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/chart/trivy-operator-explorer/Chart.yaml b/chart/trivy-operator-explorer/Chart.yaml new file mode 100644 index 0000000..69b52ec --- /dev/null +++ b/chart/trivy-operator-explorer/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: trivy-operator-explorer +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.0.1 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "latest" diff --git a/chart/trivy-operator-explorer/templates/NOTES.txt b/chart/trivy-operator-explorer/templates/NOTES.txt new file mode 100644 index 0000000..6147886 --- /dev/null +++ b/chart/trivy-operator-explorer/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "trivy-operator-explorer.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "trivy-operator-explorer.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "trivy-operator-explorer.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "trivy-operator-explorer.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/chart/trivy-operator-explorer/templates/_helpers.tpl b/chart/trivy-operator-explorer/templates/_helpers.tpl new file mode 100644 index 0000000..781fb13 --- /dev/null +++ b/chart/trivy-operator-explorer/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "trivy-operator-explorer.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "trivy-operator-explorer.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "trivy-operator-explorer.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "trivy-operator-explorer.labels" -}} +helm.sh/chart: {{ include "trivy-operator-explorer.chart" . }} +{{ include "trivy-operator-explorer.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "trivy-operator-explorer.selectorLabels" -}} +app.kubernetes.io/name: {{ include "trivy-operator-explorer.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "trivy-operator-explorer.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "trivy-operator-explorer.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/chart/trivy-operator-explorer/templates/deployment.yaml b/chart/trivy-operator-explorer/templates/deployment.yaml new file mode 100644 index 0000000..62121a2 --- /dev/null +++ b/chart/trivy-operator-explorer/templates/deployment.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "trivy-operator-explorer.fullname" . }} + labels: + {{- include "trivy-operator-explorer.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "trivy-operator-explorer.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "trivy-operator-explorer.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "trivy-operator-explorer.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.config.port }} + protocol: TCP + env: + - name: TRIVY_OPERATOR_EXPLORER_METRICS_ENDPOINT + value: '{{ .Values.config.metrics_endpoint }}' + - name: TRIVY_OPERATOR_EXPLORER_LOG_LEVEL + value: '{{ .Values.config.log_level }}' + - name: TRIVY_OPERATOR_EXPLORER_SERVER_PORT + value: '{{ .Values.config.port }}' + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/chart/trivy-operator-explorer/templates/ingress.yaml b/chart/trivy-operator-explorer/templates/ingress.yaml new file mode 100644 index 0000000..78ce0c0 --- /dev/null +++ b/chart/trivy-operator-explorer/templates/ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "trivy-operator-explorer.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "trivy-operator-explorer.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/chart/trivy-operator-explorer/templates/service.yaml b/chart/trivy-operator-explorer/templates/service.yaml new file mode 100644 index 0000000..468061e --- /dev/null +++ b/chart/trivy-operator-explorer/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "trivy-operator-explorer.fullname" . }} + labels: + {{- include "trivy-operator-explorer.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "trivy-operator-explorer.selectorLabels" . | nindent 4 }} diff --git a/chart/trivy-operator-explorer/templates/serviceaccount.yaml b/chart/trivy-operator-explorer/templates/serviceaccount.yaml new file mode 100644 index 0000000..83b4e11 --- /dev/null +++ b/chart/trivy-operator-explorer/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "trivy-operator-explorer.serviceAccountName" . }} + labels: + {{- include "trivy-operator-explorer.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/chart/trivy-operator-explorer/templates/tests/test-connection.yaml b/chart/trivy-operator-explorer/templates/tests/test-connection.yaml new file mode 100644 index 0000000..0f8a485 --- /dev/null +++ b/chart/trivy-operator-explorer/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "trivy-operator-explorer.fullname" . }}-test-connection" + labels: + {{- include "trivy-operator-explorer.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "trivy-operator-explorer.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/chart/trivy-operator-explorer/values.yaml b/chart/trivy-operator-explorer/values.yaml new file mode 100644 index 0000000..72c4c20 --- /dev/null +++ b/chart/trivy-operator-explorer/values.yaml @@ -0,0 +1,85 @@ +# Default values for trivy-operator-explorer. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: ghcr.io/starttoaster/trivy-operator-explorer + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +config: + # REQUIRED: Set to your metrics endpoint. eg. 'http://trivy-operator.trivy-system.svc.cluster.local/metrics' + metrics_endpoint: '' + + # Set to 'debug' for more logs + log_level: 'info' + + # If you change this, change the service.port value too + port: '8080' + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 8080 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..5ea84f3 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + log "github.com/starttoaster/trivy-operator-explorer/internal/logger" + "github.com/starttoaster/trivy-operator-explorer/internal/web" +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "trivy-operator-explorer", + Short: "An explorer for metrics exported from AquaSecurity's Trivy operator", + + Run: func(cmd *cobra.Command, args []string) { + if viper.GetString("log-level") == "" { + // Logger is nil still so need to use fmt + fmt.Println("Log level flag not set. Should be info by default. This likely means it was overridden by user input with no value.") + os.Exit(1) + } + log.Init(viper.GetString("log-level")) + + if viper.GetString("server-port") == "" { + log.Logger.Error("server port flag not set. Should be 8080 by default. This likely means it was overridden by user input with no value.") + os.Exit(1) + } + if viper.GetString("metrics-endpoint") == "" { + log.Logger.Error("metrics endpoint flag not set. Set with the --metrics-endpoint flag or TRIVY_OPERATOR_EXPLORER_METRICS_ENDPOINT environment variable.") + os.Exit(1) + } + cobra.CheckErr(web.Start(viper.GetString("server-port"), viper.GetString("metrics-endpoint"))) + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + cobra.CheckErr(rootCmd.Execute()) +} + +func init() { + // Read in environment variables that match defined config pattern + viper.SetEnvPrefix("TRIVY_OPERATOR_EXPLORER") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.AutomaticEnv() + + rootCmd.PersistentFlags().String("log-level", "info", "The log-level for the application, can be one of info, warn, error, debug.") + rootCmd.PersistentFlags().String("metrics-endpoint", "", "The URL to your Trivy Operator metrics endpoint (eg. http://trivy-operator.trivy-system.svc.cluster.local/metrics)") + rootCmd.PersistentFlags().Uint16("server-port", 8080, "The port the metrics server binds to.") + + err := viper.BindPFlag("log-level", rootCmd.PersistentFlags().Lookup("log-level")) + if err != nil { + log.Logger.Error(err.Error()) + os.Exit(1) + } + + err = viper.BindPFlag("metrics-endpoint", rootCmd.PersistentFlags().Lookup("metrics-endpoint")) + if err != nil { + log.Logger.Error(err.Error()) + os.Exit(1) + } + + err = viper.BindPFlag("server-port", rootCmd.PersistentFlags().Lookup("server-port")) + if err != nil { + log.Logger.Error(err.Error()) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..54fb2e2 --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/starttoaster/trivy-operator-explorer + +go 1.22.1 + +require ( + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.18.2 + github.com/starttoaster/prometheus-exporter-scraper v0.0.1 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a0f3350 --- /dev/null +++ b/go.sum @@ -0,0 +1,74 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/starttoaster/prometheus-exporter-scraper v0.0.1 h1:E3Zl3ho3v3Yt2Yk7i9z3noEPco9wggSOsPezOn4Q+mI= +github.com/starttoaster/prometheus-exporter-scraper v0.0.1/go.mod h1:qNccVOSo0np2jYi6DolRyUICFcMkhH5k1Gs7CYxav1w= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..2a7fc6c --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,40 @@ +package logger + +import ( + "log" + "log/slog" + "os" + "strings" +) + +// Logger is a custom logger from the stdlib slog package +var Logger *slog.Logger + +// Init custom init function that accepts the log level for the application +func Init(level string) { + Logger = slog.New( + slog.NewTextHandler( + os.Stdout, + &slog.HandlerOptions{ + Level: parseLogLevel(level), + }, + ), + ) +} + +// Function to convert log level string to slog.Level +func parseLogLevel(level string) slog.Level { + switch strings.ToLower(level) { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + log.Printf("unknown log level specified \"%s\", defaulting to info level", level) + return slog.LevelInfo + } +} diff --git a/internal/web/content/static.go b/internal/web/content/static.go new file mode 100644 index 0000000..bdacbc3 --- /dev/null +++ b/internal/web/content/static.go @@ -0,0 +1,11 @@ +package content + +import "embed" + +// Static contains an embedded filesystem +var Static embed.FS + +// Init accepts an embedded filesystem for the web package content +func Init(fs embed.FS) { + Static = fs +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..5d53288 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,165 @@ +package web + +import ( + "fmt" + "html/template" + "net/http" + "strings" + + scraper "github.com/starttoaster/prometheus-exporter-scraper" + log "github.com/starttoaster/trivy-operator-explorer/internal/logger" + "github.com/starttoaster/trivy-operator-explorer/internal/web/content" + "github.com/starttoaster/trivy-operator-explorer/internal/web/views" +) + +var scrpr *scraper.WebScraper + +// Start starts the webserver +func Start(port string, metricsURL string) error { + // Create scraper + scrp, err := scraper.NewWebScraper(metricsURL) + if err != nil { + return fmt.Errorf("encountered error creating new file scraper: %w", err) + } + scrpr = scrp + + mux := http.NewServeMux() + mux.HandleFunc("/", imagesHandler) + mux.HandleFunc("/image", imageHandler) + mux.Handle("/static/", http.FileServer(http.FS(content.Static))) + return http.ListenAndServe(fmt.Sprintf(":%s", port), mux) +} + +func imagesHandler(w http.ResponseWriter, r *http.Request) { + tmpl := template.Must(template.ParseFS(content.Static, "static/images.html")) + if tmpl == nil { + log.Logger.Error("encountered error parsing images html template") + http.Error(w, "Internal Server Error, check server logs", http.StatusInternalServerError) + return + } + + // Get scrape data from exporter + data, err := scrapeImageData(w) + if err != nil { + return + } + imageData := views.GetImagesView(data) + + err = tmpl.Execute(w, imageData) + if err != nil { + log.Logger.Error("encountered error executing images html template", "error", err) + http.Error(w, "Internal Server Error, check server logs", http.StatusInternalServerError) + return + } +} + +func imageHandler(w http.ResponseWriter, r *http.Request) { + tmpl := template.Must(template.ParseFS(content.Static, "static/image.html")) + if tmpl == nil { + log.Logger.Error("encountered error parsing image 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") + hasFix := q.Get("hasfix") + resources := q.Get("resources") + notResources := q.Get("notresources") + + // Get scrape data from exporter + data, err := scrapeImageData(w) + if err != nil { + return + } + imageData := views.GetImagesView(data) + v, ok := imageData.Images[views.Image{ + Image: imageName, + Digest: imageDigest, + }] + if !ok { + 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 + } + + // Get vulnerability list that matches filters + view := views.ImageVulnerabilityView{ + Image: imageName, + Digest: imageDigest, + } + for id, vuln := range v.Vulnerabilities { + // filter by severity in query param + if severity != "" && !strings.EqualFold(severity, vuln.Severity) { + continue + } + + // Filter if no fix version if hasfix=true + if strings.EqualFold(hasFix, "true") && vuln.FixedVersion == "" { + continue + } + + // Filter if a fix version if hasfix=false + if strings.EqualFold(hasFix, "false") && vuln.FixedVersion != "" { + continue + } + + // Filter if vulnerability resource does not equal resource in resources list + if resources != "" { + filters := strings.Split(resources, ",") + found := filterByList(filters, vuln.Resource) + if !found { + continue + } + } + + // Filter if vulnerability resource equals specified resource in the notresource list + if notResources != "" { + filters := strings.Split(notResources, ",") + found := filterByList(filters, vuln.Resource) + if found { + continue + } + } + + // append to data list to pass to template + view.Data = append(view.Data, views.ImageVulnerabilityData{ + ID: id, + Vulnerability: vuln, + }) + } + view = views.SortImageVulnerabilityView(view) + + err = tmpl.Execute(w, view) + if err != nil { + log.Logger.Error("encountered error executing image html template", "error", err) + http.Error(w, "Internal Server Error, check server logs", http.StatusInternalServerError) + return + } +} + +func filterByList(filters []string, item string) bool { + var found bool + for _, filter := range filters { + if strings.EqualFold(filter, item) { + found = true + break + } + } + return found +} diff --git a/internal/web/util.go b/internal/web/util.go new file mode 100644 index 0000000..627d066 --- /dev/null +++ b/internal/web/util.go @@ -0,0 +1,19 @@ +package web + +import ( + "net/http" + + scraper "github.com/starttoaster/prometheus-exporter-scraper" + log "github.com/starttoaster/trivy-operator-explorer/internal/logger" +) + +func scrapeImageData(w http.ResponseWriter) (*scraper.ScrapeData, error) { + data, err := scrpr.ScrapeWeb() + if err != nil { + log.Logger.Error("encountered error scraping file", "error", err.Error()) + http.Error(w, "Internal Server Error, check server logs", http.StatusInternalServerError) + return nil, err + } + + return data, nil +} diff --git a/internal/web/views/images.go b/internal/web/views/images.go new file mode 100644 index 0000000..5a1babb --- /dev/null +++ b/internal/web/views/images.go @@ -0,0 +1,158 @@ +package views + +import ( + "fmt" + "sort" + "strconv" + "strings" + + scraper "github.com/starttoaster/prometheus-exporter-scraper" + log "github.com/starttoaster/trivy-operator-explorer/internal/logger" +) + +// TrivyImageVulnerabilityMetricName the metric key for image vulnerability data +const TrivyImageVulnerabilityMetricName = "trivy_vulnerability_id" + +// GetImagesView converts some scrape data to the /images view +func GetImagesView(data *scraper.ScrapeData) ImagesView { + var i ImagesView + i.Images = make(map[Image]ImageData) + + for _, gauge := range data.Gauges { + if gauge.Key == TrivyImageVulnerabilityMetricName { + // TODO -- grab each label into variables individually and check that they're not empty + // Construct all data types from metric data + var image Image + if gauge.Labels["image_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 + image.Image = fmt.Sprintf("%s:%s", strings.TrimPrefix(gauge.Labels["image_repository"], "library/"), gauge.Labels["image_tag"]) + } else { + image.Image = fmt.Sprintf("%s/%s:%s", gauge.Labels["image_registry"], gauge.Labels["image_repository"], gauge.Labels["image_tag"]) + } + image.Digest = gauge.Labels["image_digest"] + podData := PodMetadata{ + Pod: gauge.Labels["resource_name"], + Namespace: gauge.Labels["namespace"], + } + score := float32(0.0) + if gauge.Labels["vuln_score"] != "" { + scoreVar, err := strconv.ParseFloat(gauge.Labels["vuln_score"], 32) + if err != nil { + log.Logger.Error("could not convert string to float32", + "error", err.Error(), + "score", gauge.Labels["vuln_score"], + "image", image.Image, + ) + continue + } + score = float32(scoreVar) + } + cveID := gauge.Labels["vuln_id"] + vuln := Vulnerability{ + Severity: gauge.Labels["severity"], + Score: float32(score), + Resource: gauge.Labels["resource"], + Title: gauge.Labels["vuln_title"], + VulnerableVersion: gauge.Labels["installed_version"], + FixedVersion: gauge.Labels["fixed_version"], + } + + // Check if this image is already in the map + _, ok := i.Images[image] + if ok { + // Add to the image's vulnerability list if it hasn't been yet + _, ok := i.Images[image].Vulnerabilities[cveID] + if !ok { + i.Images[image].Vulnerabilities[cveID] = vuln + } + + // Add to the list of Pods using this image if it hasn't been yet + _, ok = i.Images[image].Pods[podData] + if !ok { + i.Images[image].Pods[podData] = struct{}{} + } + } else { + podMap := make(map[PodMetadata]struct{}) + vulnMap := make(map[string]Vulnerability) + imageData := ImageData{ + Vulnerabilities: vulnMap, + Pods: podMap, + } + imageData.Vulnerabilities[cveID] = vuln + imageData.Pods[podData] = struct{}{} + i.Images[image] = imageData + } + } + } + + i = setImagesViewVulnerabilityCounters(i) + + return i +} + +func setImagesViewVulnerabilityCounters(i ImagesView) ImagesView { + for k, v := range i.Images { + for _, vuln := range v.Vulnerabilities { + switch vuln.Severity { + case "Critical": + v.CriticalVulnerabilities++ + case "High": + v.HighVulnerabilities++ + case "Medium": + v.MediumVulnerabilities++ + case "Low": + v.LowVulnerabilities++ + } + i.Images[k] = v + } + } + return i +} + +// SortImageVulnerabilityView sorts the provided ImageVulnerabilityView's data slice +func SortImageVulnerabilityView(v ImageVulnerabilityView) ImageVulnerabilityView { + // Sort by vulnerability severity separately + // Because sometimes low or other tier vulnerabilities also have high scores + var ( + crit []ImageVulnerabilityData + high []ImageVulnerabilityData + med []ImageVulnerabilityData + low []ImageVulnerabilityData + ) + for _, data := range v.Data { + switch data.Severity { + case "Critical": + crit = append(crit, data) + case "High": + high = append(high, data) + case "Medium": + med = append(med, data) + case "Low": + low = append(low, data) + } + } + + // Sort each severity tier by score separately + sort.SliceStable(crit, func(i, j int) bool { + return crit[i].Score > crit[j].Score + }) + sort.SliceStable(high, func(i, j int) bool { + return high[i].Score > high[j].Score + }) + sort.SliceStable(med, func(i, j int) bool { + return med[i].Score > med[j].Score + }) + sort.SliceStable(low, func(i, j int) bool { + return low[i].Score > low[j].Score + }) + + // Combine now to one mega slice + var data []ImageVulnerabilityData + data = append(data, crit...) + data = append(data, high...) + data = append(data, med...) + data = append(data, low...) + v.Data = data + return v +} diff --git a/internal/web/views/types.go b/internal/web/views/types.go new file mode 100644 index 0000000..6f09f8c --- /dev/null +++ b/internal/web/views/types.go @@ -0,0 +1,63 @@ +package views + +// ImagesView contains data about images running in a kubernetes cluster with vulnerabilities +type ImagesView struct { + Images map[Image]ImageData +} + +// Image contains data about an image +type Image struct { + Image string + Digest string +} + +// ImageData contains data about image vulnerabilities and metadata about the Pods running those images +type ImageData struct { + Pods map[PodMetadata]struct{} + Vulnerabilities map[string]Vulnerability + CriticalVulnerabilities int + HighVulnerabilities int + MediumVulnerabilities int + LowVulnerabilities int +} + +// PodMetadata data related to a k8s Pod +type PodMetadata struct { + Pod string + Namespace string +} + +// Vulnerability data related to a CVE +type Vulnerability struct { + // CVE severity level (eg. Critical/High/Medium/Low) + Severity string + // CVE score from 0-10 with with one decimal place + Score float32 + // CVE vulnerable resource (eg. curl, libcurl) + Resource string + // CVE title (eg. libcarlsjr: remote code execution) + Title string + // The vulnerable installed resource version + VulnerableVersion string + // The version this vulnerability is fixed in + FixedVersion string +} + +// ImageVulnerabilityView contains the view data for the `/image` server path +type ImageVulnerabilityView struct { + // Image is the name of the image containing vulnerabilities + Image string + // Digest is the string image hash + Digest string + + // Data contains a slice of all the vulnerabilities for the given image + Data []ImageVulnerabilityData +} + +// ImageVulnerabilityData contains data on the vulnerabilities in a given image +type ImageVulnerabilityData struct { + // ID is the CVE's ID + ID string + + Vulnerability // inherits fields from the Vulnerability struct +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..8aed5ec --- /dev/null +++ b/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "embed" + + "github.com/starttoaster/trivy-operator-explorer/cmd" + "github.com/starttoaster/trivy-operator-explorer/internal/web/content" +) + +//go:generate tailwindcss build -i ./static/css/input.css -o ./static/css/output.css + +//go:embed static/images.html +//go:embed static/image.html +//go:embed static/css/output.css +//go:embed static/css/extra.css +var static embed.FS + +func main() { + content.Init(static) + cmd.Execute() +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..0fc8116 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/static/css/extra.css b/static/css/extra.css new file mode 100644 index 0000000..2d155aa --- /dev/null +++ b/static/css/extra.css @@ -0,0 +1,23 @@ +/* The container
+ CVE + | ++ Severity + | ++ Score + | ++ Resource + | ++ Title + | ++ Installed + | ++ Fixed In + | +
---|---|---|---|---|---|---|
+ + {{ $data.ID }} + + | ++ {{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.Score }} + | ++ {{ $data.Resource }} + | ++ {{ $data.Title }} + | ++ {{ $data.VulnerableVersion }} + | ++ {{ $data.FixedVersion }} + | +