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
- needed to position the dropdown content */ +.dropdown { + position: relative; + display: inline-block; +} + +/* Dropdown Content (Hidden by Default) */ +.dropdown-content { + display: none; + position: absolute; + min-width: 300px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + z-index: 1; +} + +/* Links inside the dropdown */ +.dropdown-content a { + padding: 12px 16px; + border-radius: 0.5rem; +} + +/* Show the dropdown menu on hover */ +.dropdown:hover .dropdown-content {display: block;} diff --git a/static/css/input.css b/static/css/input.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/static/css/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/static/css/output.css b/static/css/output.css new file mode 100644 index 0000000..9a5399b --- /dev/null +++ b/static/css/output.css @@ -0,0 +1,1199 @@ +/* +! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden] { + display: none; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +.fixed { + position: fixed; +} + +.relative { + position: relative; +} + +.left-0 { + left: 0px; +} + +.top-0 { + top: 0px; +} + +.z-40 { + z-index: 40; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.mb-5 { + margin-bottom: 1.25rem; +} + +.me-1 { + margin-inline-end: 0.25rem; +} + +.me-2 { + margin-inline-end: 0.5rem; +} + +.ms-3 { + margin-inline-start: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.block { + display: block; +} + +.flex { + display: flex; +} + +.inline-flex { + display: inline-flex; +} + +.table { + display: table; +} + +.hidden { + display: none; +} + +.h-2 { + height: 0.5rem; +} + +.h-2\.5 { + height: 0.625rem; +} + +.h-5 { + height: 1.25rem; +} + +.h-full { + height: 100%; +} + +.h-screen { + height: 100vh; +} + +.min-h-screen { + min-height: 100vh; +} + +.w-2 { + width: 0.5rem; +} + +.w-2\.5 { + width: 0.625rem; +} + +.w-5 { + width: 1.25rem; +} + +.w-64 { + width: 16rem; +} + +.w-full { + width: 100%; +} + +.max-w-screen-xl { + max-width: 1280px; +} + +.-translate-x-full { + --tw-translate-x: -100%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.items-center { + align-items: center; +} + +.justify-between { + justify-content: space-between; +} + +.space-y-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); +} + +.self-center { + align-self: center; +} + +.overflow-x-auto { + overflow-x: auto; +} + +.overflow-y-auto { + overflow-y: auto; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.border { + border-width: 1px; +} + +.border-b { + border-bottom-width: 1px; +} + +.border-gray-100 { + --tw-border-opacity: 1; + border-color: rgb(243 244 246 / var(--tw-border-opacity)); +} + +.border-gray-200 { + --tw-border-opacity: 1; + border-color: rgb(229 231 235 / var(--tw-border-opacity)); +} + +.bg-blue-100 { + --tw-bg-opacity: 1; + background-color: rgb(219 234 254 / var(--tw-bg-opacity)); +} + +.bg-blue-700 { + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity)); +} + +.bg-blue-800 { + --tw-bg-opacity: 1; + background-color: rgb(30 64 175 / var(--tw-bg-opacity)); +} + +.bg-gray-50 { + --tw-bg-opacity: 1; + background-color: rgb(249 250 251 / var(--tw-bg-opacity)); +} + +.bg-indigo-900 { + --tw-bg-opacity: 1; + background-color: rgb(49 46 129 / var(--tw-bg-opacity)); +} + +.bg-orange-100 { + --tw-bg-opacity: 1; + background-color: rgb(255 237 213 / var(--tw-bg-opacity)); +} + +.bg-red-100 { + --tw-bg-opacity: 1; + background-color: rgb(254 226 226 / var(--tw-bg-opacity)); +} + +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.bg-yellow-100 { + --tw-bg-opacity: 1; + background-color: rgb(254 249 195 / var(--tw-bg-opacity)); +} + +.p-2 { + padding: 0.5rem; +} + +.p-4 { + padding: 1rem; +} + +.px-1 { + padding-left: 0.25rem; + padding-right: 0.25rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.px-2\.5 { + padding-left: 0.625rem; + padding-right: 0.625rem; +} + +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.px-5 { + padding-left: 1.25rem; + padding-right: 1.25rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.py-0 { + padding-top: 0px; + padding-bottom: 0px; +} + +.py-0\.5 { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-2\.5 { + padding-top: 0.625rem; + padding-bottom: 0.625rem; +} + +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.ps-2 { + padding-inline-start: 0.5rem; +} + +.ps-2\.5 { + padding-inline-start: 0.625rem; +} + +.text-left { + text-align: left; +} + +.text-center { + text-align: center; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.font-medium { + font-weight: 500; +} + +.font-semibold { + font-weight: 600; +} + +.uppercase { + text-transform: uppercase; +} + +.text-blue-800 { + --tw-text-opacity: 1; + color: rgb(30 64 175 / var(--tw-text-opacity)); +} + +.text-gray-500 { + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity)); +} + +.text-gray-700 { + --tw-text-opacity: 1; + color: rgb(55 65 81 / var(--tw-text-opacity)); +} + +.text-gray-900 { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity)); +} + +.text-orange-800 { + --tw-text-opacity: 1; + color: rgb(154 52 18 / var(--tw-text-opacity)); +} + +.text-red-800 { + --tw-text-opacity: 1; + color: rgb(153 27 27 / var(--tw-text-opacity)); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.text-yellow-800 { + --tw-text-opacity: 1; + color: rgb(133 77 14 / var(--tw-text-opacity)); +} + +.shadow-md { + --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-transform { + transition-property: transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.duration-75 { + transition-duration: 75ms; +} + +.hover\:bg-blue-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(30 64 175 / var(--tw-bg-opacity)); +} + +.hover\:bg-gray-100:hover { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + +.focus\:outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.focus\:ring-4:focus { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.focus\:ring-blue-300:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity)); +} + +.group:hover .group-hover\:text-gray-900 { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity)); +} + +@media (min-width: 640px) { + .sm\:ml-64 { + margin-left: 16rem; + } + + .sm\:translate-x-0 { + --tw-translate-x: 0px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + } +} + +@media (min-width: 768px) { + .md\:mt-0 { + margin-top: 0px; + } + + .md\:block { + display: block; + } + + .md\:w-auto { + width: auto; + } + + .md\:flex-row { + flex-direction: row; + } + + .md\:space-x-8 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(2rem * var(--tw-space-x-reverse)); + margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse))); + } + + .md\:border-0 { + border-width: 0px; + } + + .md\:bg-transparent { + background-color: transparent; + } + + .md\:bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + } + + .md\:p-0 { + padding: 0px; + } + + .md\:text-blue-400 { + --tw-text-opacity: 1; + color: rgb(96 165 250 / var(--tw-text-opacity)); + } + + .md\:text-blue-700 { + --tw-text-opacity: 1; + color: rgb(29 78 216 / var(--tw-text-opacity)); + } +} + +.rtl\:space-x-reverse:where([dir="rtl"], [dir="rtl"] *) > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 1; +} + +.rtl\:text-right:where([dir="rtl"], [dir="rtl"] *) { + text-align: right; +} + +@media (prefers-color-scheme: dark) { + .dark\:border-gray-700 { + --tw-border-opacity: 1; + border-color: rgb(55 65 81 / var(--tw-border-opacity)); + } + + .dark\:bg-blue-800 { + --tw-bg-opacity: 1; + background-color: rgb(30 64 175 / var(--tw-bg-opacity)); + } + + .dark\:bg-blue-900 { + --tw-bg-opacity: 1; + background-color: rgb(30 58 138 / var(--tw-bg-opacity)); + } + + .dark\:bg-gray-700 { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); + } + + .dark\:bg-gray-800 { + --tw-bg-opacity: 1; + background-color: rgb(31 41 55 / var(--tw-bg-opacity)); + } + + .dark\:bg-gray-900 { + --tw-bg-opacity: 1; + background-color: rgb(17 24 39 / var(--tw-bg-opacity)); + } + + .dark\:bg-indigo-800 { + --tw-bg-opacity: 1; + background-color: rgb(55 48 163 / var(--tw-bg-opacity)); + } + + .dark\:bg-indigo-950 { + --tw-bg-opacity: 1; + background-color: rgb(30 27 75 / var(--tw-bg-opacity)); + } + + .dark\:bg-orange-900 { + --tw-bg-opacity: 1; + background-color: rgb(124 45 18 / var(--tw-bg-opacity)); + } + + .dark\:bg-red-900 { + --tw-bg-opacity: 1; + background-color: rgb(127 29 29 / var(--tw-bg-opacity)); + } + + .dark\:bg-yellow-900 { + --tw-bg-opacity: 1; + background-color: rgb(113 63 18 / var(--tw-bg-opacity)); + } + + .dark\:text-blue-100 { + --tw-text-opacity: 1; + color: rgb(219 234 254 / var(--tw-text-opacity)); + } + + .dark\:text-gray-400 { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); + } + + .dark\:text-orange-100 { + --tw-text-opacity: 1; + color: rgb(255 237 213 / var(--tw-text-opacity)); + } + + .dark\:text-red-100 { + --tw-text-opacity: 1; + color: rgb(254 226 226 / var(--tw-text-opacity)); + } + + .dark\:text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); + } + + .dark\:text-yellow-100 { + --tw-text-opacity: 1; + color: rgb(254 249 195 / var(--tw-text-opacity)); + } + + .dark\:hover\:bg-blue-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity)); + } + + .dark\:hover\:bg-gray-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); + } + + .dark\:hover\:bg-indigo-900:hover { + --tw-bg-opacity: 1; + background-color: rgb(49 46 129 / var(--tw-bg-opacity)); + } + + .dark\:focus\:ring-blue-800:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity)); + } + + .group:hover .dark\:group-hover\:text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); + } +} + +@media (min-width: 768px) { + @media (prefers-color-scheme: dark) { + .md\:dark\:bg-gray-900 { + --tw-bg-opacity: 1; + background-color: rgb(17 24 39 / var(--tw-bg-opacity)); + } + + .md\:dark\:text-blue-200 { + --tw-text-opacity: 1; + color: rgb(191 219 254 / var(--tw-text-opacity)); + } + + .md\:dark\:text-blue-500 { + --tw-text-opacity: 1; + color: rgb(59 130 246 / var(--tw-text-opacity)); + } + } +} diff --git a/static/image.html b/static/image.html new file mode 100644 index 0000000..2f77a7f --- /dev/null +++ b/static/image.html @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + {{ range $data := .Data }} + + + + + + + + + + {{ end }} + +
+ 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 }} +
+
+
+ + diff --git a/static/images.html b/static/images.html new file mode 100644 index 0000000..e4668a5 --- /dev/null +++ b/static/images.html @@ -0,0 +1,108 @@ + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + {{ range $image, $data := .Images }} + + + + + + + + + {{ end }} + +
+ Image + + Affected Pods + + Vulnerabilities +
+ + {{ $image.Image }} + + + + + {{ if ne $data.CriticalVulnerabilities 0 }} + + {{ $data.CriticalVulnerabilities }} + + {{ end }} + {{ if ne $data.HighVulnerabilities 0 }} + + {{ $data.HighVulnerabilities }} + + {{ end }} + {{ if ne $data.MediumVulnerabilities 0 }} + + {{ $data.MediumVulnerabilities }} + + {{ end }} + {{ if ne $data.LowVulnerabilities 0 }} + + {{ $data.LowVulnerabilities }} + + {{ end }} +
+
+
+ + diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..bf103ee --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,1067 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './static/*.html', + './static/js/*.js', + ], + presets: [], + darkMode: 'media', // or 'class' + theme: { + accentColor: ({ theme }) => ({ + ...theme('colors'), + auto: 'auto', + }), + animation: { + none: 'none', + spin: 'spin 1s linear infinite', + ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite', + pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', + bounce: 'bounce 1s infinite', + }, + aria: { + busy: 'busy="true"', + checked: 'checked="true"', + disabled: 'disabled="true"', + expanded: 'expanded="true"', + hidden: 'hidden="true"', + pressed: 'pressed="true"', + readonly: 'readonly="true"', + required: 'required="true"', + selected: 'selected="true"', + }, + aspectRatio: { + auto: 'auto', + square: '1 / 1', + video: '16 / 9', + }, + backdropBlur: ({ theme }) => theme('blur'), + backdropBrightness: ({ theme }) => theme('brightness'), + backdropContrast: ({ theme }) => theme('contrast'), + backdropGrayscale: ({ theme }) => theme('grayscale'), + backdropHueRotate: ({ theme }) => theme('hueRotate'), + backdropInvert: ({ theme }) => theme('invert'), + backdropOpacity: ({ theme }) => theme('opacity'), + backdropSaturate: ({ theme }) => theme('saturate'), + backdropSepia: ({ theme }) => theme('sepia'), + backgroundColor: ({ theme }) => theme('colors'), + backgroundImage: { + none: 'none', + 'gradient-to-t': 'linear-gradient(to top, var(--tw-gradient-stops))', + 'gradient-to-tr': 'linear-gradient(to top right, var(--tw-gradient-stops))', + 'gradient-to-r': 'linear-gradient(to right, var(--tw-gradient-stops))', + 'gradient-to-br': 'linear-gradient(to bottom right, var(--tw-gradient-stops))', + 'gradient-to-b': 'linear-gradient(to bottom, var(--tw-gradient-stops))', + 'gradient-to-bl': 'linear-gradient(to bottom left, var(--tw-gradient-stops))', + 'gradient-to-l': 'linear-gradient(to left, var(--tw-gradient-stops))', + 'gradient-to-tl': 'linear-gradient(to top left, var(--tw-gradient-stops))', + }, + backgroundOpacity: ({ theme }) => theme('opacity'), + backgroundPosition: { + bottom: 'bottom', + center: 'center', + left: 'left', + 'left-bottom': 'left bottom', + 'left-top': 'left top', + right: 'right', + 'right-bottom': 'right bottom', + 'right-top': 'right top', + top: 'top', + }, + backgroundSize: { + auto: 'auto', + cover: 'cover', + contain: 'contain', + }, + blur: { + 0: '0', + none: '0', + sm: '4px', + DEFAULT: '8px', + md: '12px', + lg: '16px', + xl: '24px', + '2xl': '40px', + '3xl': '64px', + }, + borderColor: ({ theme }) => ({ + ...theme('colors'), + DEFAULT: theme('colors.gray.200', 'currentColor'), + }), + borderOpacity: ({ theme }) => theme('opacity'), + borderRadius: { + none: '0px', + sm: '0.125rem', + DEFAULT: '0.25rem', + md: '0.375rem', + lg: '0.5rem', + xl: '0.75rem', + '2xl': '1rem', + '3xl': '1.5rem', + full: '9999px', + }, + borderSpacing: ({ theme }) => ({ + ...theme('spacing'), + }), + borderWidth: { + DEFAULT: '1px', + 0: '0px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + boxShadow: { + sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', + DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', + md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', + xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)', + '2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)', + inner: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)', + none: 'none', + }, + boxShadowColor: ({ theme }) => theme('colors'), + brightness: { + 0: '0', + 50: '.5', + 75: '.75', + 90: '.9', + 95: '.95', + 100: '1', + 105: '1.05', + 110: '1.1', + 125: '1.25', + 150: '1.5', + 200: '2', + }, + caretColor: ({ theme }) => theme('colors'), + colors: ({ colors }) => ({ + inherit: colors.inherit, + current: colors.current, + transparent: colors.transparent, + black: colors.black, + white: colors.white, + slate: colors.slate, + gray: colors.gray, + zinc: colors.zinc, + neutral: colors.neutral, + stone: colors.stone, + red: colors.red, + orange: colors.orange, + amber: colors.amber, + yellow: colors.yellow, + lime: colors.lime, + green: colors.green, + emerald: colors.emerald, + teal: colors.teal, + cyan: colors.cyan, + sky: colors.sky, + blue: colors.blue, + indigo: colors.indigo, + violet: colors.violet, + purple: colors.purple, + fuchsia: colors.fuchsia, + pink: colors.pink, + rose: colors.rose, + }), + columns: { + auto: 'auto', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + '3xs': '16rem', + '2xs': '18rem', + xs: '20rem', + sm: '24rem', + md: '28rem', + lg: '32rem', + xl: '36rem', + '2xl': '42rem', + '3xl': '48rem', + '4xl': '56rem', + '5xl': '64rem', + '6xl': '72rem', + '7xl': '80rem', + }, + container: {}, + content: { + none: 'none', + }, + contrast: { + 0: '0', + 50: '.5', + 75: '.75', + 100: '1', + 125: '1.25', + 150: '1.5', + 200: '2', + }, + cursor: { + auto: 'auto', + default: 'default', + pointer: 'pointer', + wait: 'wait', + text: 'text', + move: 'move', + help: 'help', + 'not-allowed': 'not-allowed', + none: 'none', + 'context-menu': 'context-menu', + progress: 'progress', + cell: 'cell', + crosshair: 'crosshair', + 'vertical-text': 'vertical-text', + alias: 'alias', + copy: 'copy', + 'no-drop': 'no-drop', + grab: 'grab', + grabbing: 'grabbing', + 'all-scroll': 'all-scroll', + 'col-resize': 'col-resize', + 'row-resize': 'row-resize', + 'n-resize': 'n-resize', + 'e-resize': 'e-resize', + 's-resize': 's-resize', + 'w-resize': 'w-resize', + 'ne-resize': 'ne-resize', + 'nw-resize': 'nw-resize', + 'se-resize': 'se-resize', + 'sw-resize': 'sw-resize', + 'ew-resize': 'ew-resize', + 'ns-resize': 'ns-resize', + 'nesw-resize': 'nesw-resize', + 'nwse-resize': 'nwse-resize', + 'zoom-in': 'zoom-in', + 'zoom-out': 'zoom-out', + }, + divideColor: ({ theme }) => theme('borderColor'), + divideOpacity: ({ theme }) => theme('borderOpacity'), + divideWidth: ({ theme }) => theme('borderWidth'), + dropShadow: { + sm: '0 1px 1px rgb(0 0 0 / 0.05)', + DEFAULT: ['0 1px 2px rgb(0 0 0 / 0.1)', '0 1px 1px rgb(0 0 0 / 0.06)'], + md: ['0 4px 3px rgb(0 0 0 / 0.07)', '0 2px 2px rgb(0 0 0 / 0.06)'], + lg: ['0 10px 8px rgb(0 0 0 / 0.04)', '0 4px 3px rgb(0 0 0 / 0.1)'], + xl: ['0 20px 13px rgb(0 0 0 / 0.03)', '0 8px 5px rgb(0 0 0 / 0.08)'], + '2xl': '0 25px 25px rgb(0 0 0 / 0.15)', + none: '0 0 #0000', + }, + fill: ({ theme }) => ({ + none: 'none', + ...theme('colors'), + }), + flex: { + 1: '1 1 0%', + auto: '1 1 auto', + initial: '0 1 auto', + none: 'none', + }, + flexBasis: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + '1/5': '20%', + '2/5': '40%', + '3/5': '60%', + '4/5': '80%', + '1/6': '16.666667%', + '2/6': '33.333333%', + '3/6': '50%', + '4/6': '66.666667%', + '5/6': '83.333333%', + '1/12': '8.333333%', + '2/12': '16.666667%', + '3/12': '25%', + '4/12': '33.333333%', + '5/12': '41.666667%', + '6/12': '50%', + '7/12': '58.333333%', + '8/12': '66.666667%', + '9/12': '75%', + '10/12': '83.333333%', + '11/12': '91.666667%', + full: '100%', + }), + flexGrow: { + 0: '0', + DEFAULT: '1', + }, + flexShrink: { + 0: '0', + DEFAULT: '1', + }, + fontFamily: { + sans: [ + 'ui-sans-serif', + 'system-ui', + 'sans-serif', + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + '"Noto Color Emoji"', + ], + serif: ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'], + mono: [ + 'ui-monospace', + 'SFMono-Regular', + 'Menlo', + 'Monaco', + 'Consolas', + '"Liberation Mono"', + '"Courier New"', + 'monospace', + ], + }, + fontSize: { + xs: ['0.75rem', { lineHeight: '1rem' }], + sm: ['0.875rem', { lineHeight: '1.25rem' }], + base: ['1rem', { lineHeight: '1.5rem' }], + lg: ['1.125rem', { lineHeight: '1.75rem' }], + xl: ['1.25rem', { lineHeight: '1.75rem' }], + '2xl': ['1.5rem', { lineHeight: '2rem' }], + '3xl': ['1.875rem', { lineHeight: '2.25rem' }], + '4xl': ['2.25rem', { lineHeight: '2.5rem' }], + '5xl': ['3rem', { lineHeight: '1' }], + '6xl': ['3.75rem', { lineHeight: '1' }], + '7xl': ['4.5rem', { lineHeight: '1' }], + '8xl': ['6rem', { lineHeight: '1' }], + '9xl': ['8rem', { lineHeight: '1' }], + }, + fontWeight: { + thin: '100', + extralight: '200', + light: '300', + normal: '400', + medium: '500', + semibold: '600', + bold: '700', + extrabold: '800', + black: '900', + }, + gap: ({ theme }) => theme('spacing'), + gradientColorStops: ({ theme }) => theme('colors'), + gradientColorStopPositions: { + '0%': '0%', + '5%': '5%', + '10%': '10%', + '15%': '15%', + '20%': '20%', + '25%': '25%', + '30%': '30%', + '35%': '35%', + '40%': '40%', + '45%': '45%', + '50%': '50%', + '55%': '55%', + '60%': '60%', + '65%': '65%', + '70%': '70%', + '75%': '75%', + '80%': '80%', + '85%': '85%', + '90%': '90%', + '95%': '95%', + '100%': '100%', + }, + grayscale: { + 0: '0', + DEFAULT: '100%', + }, + gridAutoColumns: { + auto: 'auto', + min: 'min-content', + max: 'max-content', + fr: 'minmax(0, 1fr)', + }, + gridAutoRows: { + auto: 'auto', + min: 'min-content', + max: 'max-content', + fr: 'minmax(0, 1fr)', + }, + gridColumn: { + auto: 'auto', + 'span-1': 'span 1 / span 1', + 'span-2': 'span 2 / span 2', + 'span-3': 'span 3 / span 3', + 'span-4': 'span 4 / span 4', + 'span-5': 'span 5 / span 5', + 'span-6': 'span 6 / span 6', + 'span-7': 'span 7 / span 7', + 'span-8': 'span 8 / span 8', + 'span-9': 'span 9 / span 9', + 'span-10': 'span 10 / span 10', + 'span-11': 'span 11 / span 11', + 'span-12': 'span 12 / span 12', + 'span-full': '1 / -1', + }, + gridColumnEnd: { + auto: 'auto', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + 13: '13', + }, + gridColumnStart: { + auto: 'auto', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + 13: '13', + }, + gridRow: { + auto: 'auto', + 'span-1': 'span 1 / span 1', + 'span-2': 'span 2 / span 2', + 'span-3': 'span 3 / span 3', + 'span-4': 'span 4 / span 4', + 'span-5': 'span 5 / span 5', + 'span-6': 'span 6 / span 6', + 'span-7': 'span 7 / span 7', + 'span-8': 'span 8 / span 8', + 'span-9': 'span 9 / span 9', + 'span-10': 'span 10 / span 10', + 'span-11': 'span 11 / span 11', + 'span-12': 'span 12 / span 12', + 'span-full': '1 / -1', + }, + gridRowEnd: { + auto: 'auto', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + 13: '13', + }, + gridRowStart: { + auto: 'auto', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + 13: '13', + }, + gridTemplateColumns: { + none: 'none', + subgrid: 'subgrid', + 1: 'repeat(1, minmax(0, 1fr))', + 2: 'repeat(2, minmax(0, 1fr))', + 3: 'repeat(3, minmax(0, 1fr))', + 4: 'repeat(4, minmax(0, 1fr))', + 5: 'repeat(5, minmax(0, 1fr))', + 6: 'repeat(6, minmax(0, 1fr))', + 7: 'repeat(7, minmax(0, 1fr))', + 8: 'repeat(8, minmax(0, 1fr))', + 9: 'repeat(9, minmax(0, 1fr))', + 10: 'repeat(10, minmax(0, 1fr))', + 11: 'repeat(11, minmax(0, 1fr))', + 12: 'repeat(12, minmax(0, 1fr))', + }, + gridTemplateRows: { + none: 'none', + subgrid: 'subgrid', + 1: 'repeat(1, minmax(0, 1fr))', + 2: 'repeat(2, minmax(0, 1fr))', + 3: 'repeat(3, minmax(0, 1fr))', + 4: 'repeat(4, minmax(0, 1fr))', + 5: 'repeat(5, minmax(0, 1fr))', + 6: 'repeat(6, minmax(0, 1fr))', + 7: 'repeat(7, minmax(0, 1fr))', + 8: 'repeat(8, minmax(0, 1fr))', + 9: 'repeat(9, minmax(0, 1fr))', + 10: 'repeat(10, minmax(0, 1fr))', + 11: 'repeat(11, minmax(0, 1fr))', + 12: 'repeat(12, minmax(0, 1fr))', + }, + height: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + '1/5': '20%', + '2/5': '40%', + '3/5': '60%', + '4/5': '80%', + '1/6': '16.666667%', + '2/6': '33.333333%', + '3/6': '50%', + '4/6': '66.666667%', + '5/6': '83.333333%', + full: '100%', + screen: '100vh', + svh: '100svh', + lvh: '100lvh', + dvh: '100dvh', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + hueRotate: { + 0: '0deg', + 15: '15deg', + 30: '30deg', + 60: '60deg', + 90: '90deg', + 180: '180deg', + }, + inset: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + full: '100%', + }), + invert: { + 0: '0', + DEFAULT: '100%', + }, + keyframes: { + spin: { + to: { + transform: 'rotate(360deg)', + }, + }, + ping: { + '75%, 100%': { + transform: 'scale(2)', + opacity: '0', + }, + }, + pulse: { + '50%': { + opacity: '.5', + }, + }, + bounce: { + '0%, 100%': { + transform: 'translateY(-25%)', + animationTimingFunction: 'cubic-bezier(0.8,0,1,1)', + }, + '50%': { + transform: 'none', + animationTimingFunction: 'cubic-bezier(0,0,0.2,1)', + }, + }, + }, + letterSpacing: { + tighter: '-0.05em', + tight: '-0.025em', + normal: '0em', + wide: '0.025em', + wider: '0.05em', + widest: '0.1em', + }, + lineHeight: { + none: '1', + tight: '1.25', + snug: '1.375', + normal: '1.5', + relaxed: '1.625', + loose: '2', + 3: '.75rem', + 4: '1rem', + 5: '1.25rem', + 6: '1.5rem', + 7: '1.75rem', + 8: '2rem', + 9: '2.25rem', + 10: '2.5rem', + }, + listStyleType: { + none: 'none', + disc: 'disc', + decimal: 'decimal', + }, + listStyleImage: { + none: 'none', + }, + margin: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + }), + lineClamp: { + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + }, + maxHeight: ({ theme }) => ({ + ...theme('spacing'), + none: 'none', + full: '100%', + screen: '100vh', + svh: '100svh', + lvh: '100lvh', + dvh: '100dvh', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + maxWidth: ({ theme, breakpoints }) => ({ + ...theme('spacing'), + none: 'none', + xs: '20rem', + sm: '24rem', + md: '28rem', + lg: '32rem', + xl: '36rem', + '2xl': '42rem', + '3xl': '48rem', + '4xl': '56rem', + '5xl': '64rem', + '6xl': '72rem', + '7xl': '80rem', + full: '100%', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + prose: '65ch', + ...breakpoints(theme('screens')), + }), + minHeight: ({ theme }) => ({ + ...theme('spacing'), + full: '100%', + screen: '100vh', + svh: '100svh', + lvh: '100lvh', + dvh: '100dvh', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + minWidth: ({ theme }) => ({ + ...theme('spacing'), + full: '100%', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + objectPosition: { + bottom: 'bottom', + center: 'center', + left: 'left', + 'left-bottom': 'left bottom', + 'left-top': 'left top', + right: 'right', + 'right-bottom': 'right bottom', + 'right-top': 'right top', + top: 'top', + }, + opacity: { + 0: '0', + 5: '0.05', + 10: '0.1', + 15: '0.15', + 20: '0.2', + 25: '0.25', + 30: '0.3', + 35: '0.35', + 40: '0.4', + 45: '0.45', + 50: '0.5', + 55: '0.55', + 60: '0.6', + 65: '0.65', + 70: '0.7', + 75: '0.75', + 80: '0.8', + 85: '0.85', + 90: '0.9', + 95: '0.95', + 100: '1', + }, + order: { + first: '-9999', + last: '9999', + none: '0', + 1: '1', + 2: '2', + 3: '3', + 4: '4', + 5: '5', + 6: '6', + 7: '7', + 8: '8', + 9: '9', + 10: '10', + 11: '11', + 12: '12', + }, + outlineColor: ({ theme }) => theme('colors'), + outlineOffset: { + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + outlineWidth: { + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + padding: ({ theme }) => theme('spacing'), + placeholderColor: ({ theme }) => theme('colors'), + placeholderOpacity: ({ theme }) => theme('opacity'), + ringColor: ({ theme }) => ({ + DEFAULT: theme('colors.blue.500', '#3b82f6'), + ...theme('colors'), + }), + ringOffsetColor: ({ theme }) => theme('colors'), + ringOffsetWidth: { + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + ringOpacity: ({ theme }) => ({ + DEFAULT: '0.5', + ...theme('opacity'), + }), + ringWidth: { + DEFAULT: '3px', + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + rotate: { + 0: '0deg', + 1: '1deg', + 2: '2deg', + 3: '3deg', + 6: '6deg', + 12: '12deg', + 45: '45deg', + 90: '90deg', + 180: '180deg', + }, + saturate: { + 0: '0', + 50: '.5', + 100: '1', + 150: '1.5', + 200: '2', + }, + scale: { + 0: '0', + 50: '.5', + 75: '.75', + 90: '.9', + 95: '.95', + 100: '1', + 105: '1.05', + 110: '1.1', + 125: '1.25', + 150: '1.5', + }, + screens: { + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + '2xl': '1536px', + }, + scrollMargin: ({ theme }) => ({ + ...theme('spacing'), + }), + scrollPadding: ({ theme }) => theme('spacing'), + sepia: { + 0: '0', + DEFAULT: '100%', + }, + skew: { + 0: '0deg', + 1: '1deg', + 2: '2deg', + 3: '3deg', + 6: '6deg', + 12: '12deg', + }, + space: ({ theme }) => ({ + ...theme('spacing'), + }), + spacing: { + px: '1px', + 0: '0px', + 0.5: '0.125rem', + 1: '0.25rem', + 1.5: '0.375rem', + 2: '0.5rem', + 2.5: '0.625rem', + 3: '0.75rem', + 3.5: '0.875rem', + 4: '1rem', + 5: '1.25rem', + 6: '1.5rem', + 7: '1.75rem', + 8: '2rem', + 9: '2.25rem', + 10: '2.5rem', + 11: '2.75rem', + 12: '3rem', + 14: '3.5rem', + 16: '4rem', + 20: '5rem', + 24: '6rem', + 28: '7rem', + 32: '8rem', + 36: '9rem', + 40: '10rem', + 44: '11rem', + 48: '12rem', + 52: '13rem', + 56: '14rem', + 60: '15rem', + 64: '16rem', + 72: '18rem', + 80: '20rem', + 96: '24rem', + }, + stroke: ({ theme }) => ({ + none: 'none', + ...theme('colors'), + }), + strokeWidth: { + 0: '0', + 1: '1', + 2: '2', + }, + supports: {}, + data: {}, + textColor: ({ theme }) => theme('colors'), + textDecorationColor: ({ theme }) => theme('colors'), + textDecorationThickness: { + auto: 'auto', + 'from-font': 'from-font', + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + textIndent: ({ theme }) => ({ + ...theme('spacing'), + }), + textOpacity: ({ theme }) => theme('opacity'), + textUnderlineOffset: { + auto: 'auto', + 0: '0px', + 1: '1px', + 2: '2px', + 4: '4px', + 8: '8px', + }, + transformOrigin: { + center: 'center', + top: 'top', + 'top-right': 'top right', + right: 'right', + 'bottom-right': 'bottom right', + bottom: 'bottom', + 'bottom-left': 'bottom left', + left: 'left', + 'top-left': 'top left', + }, + transitionDelay: { + 0: '0s', + 75: '75ms', + 100: '100ms', + 150: '150ms', + 200: '200ms', + 300: '300ms', + 500: '500ms', + 700: '700ms', + 1000: '1000ms', + }, + transitionDuration: { + DEFAULT: '150ms', + 0: '0s', + 75: '75ms', + 100: '100ms', + 150: '150ms', + 200: '200ms', + 300: '300ms', + 500: '500ms', + 700: '700ms', + 1000: '1000ms', + }, + transitionProperty: { + none: 'none', + all: 'all', + DEFAULT: + 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter', + colors: 'color, background-color, border-color, text-decoration-color, fill, stroke', + opacity: 'opacity', + shadow: 'box-shadow', + transform: 'transform', + }, + transitionTimingFunction: { + DEFAULT: 'cubic-bezier(0.4, 0, 0.2, 1)', + linear: 'linear', + in: 'cubic-bezier(0.4, 0, 1, 1)', + out: 'cubic-bezier(0, 0, 0.2, 1)', + 'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)', + }, + translate: ({ theme }) => ({ + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + full: '100%', + }), + size: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + '1/5': '20%', + '2/5': '40%', + '3/5': '60%', + '4/5': '80%', + '1/6': '16.666667%', + '2/6': '33.333333%', + '3/6': '50%', + '4/6': '66.666667%', + '5/6': '83.333333%', + '1/12': '8.333333%', + '2/12': '16.666667%', + '3/12': '25%', + '4/12': '33.333333%', + '5/12': '41.666667%', + '6/12': '50%', + '7/12': '58.333333%', + '8/12': '66.666667%', + '9/12': '75%', + '10/12': '83.333333%', + '11/12': '91.666667%', + full: '100%', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + width: ({ theme }) => ({ + auto: 'auto', + ...theme('spacing'), + '1/2': '50%', + '1/3': '33.333333%', + '2/3': '66.666667%', + '1/4': '25%', + '2/4': '50%', + '3/4': '75%', + '1/5': '20%', + '2/5': '40%', + '3/5': '60%', + '4/5': '80%', + '1/6': '16.666667%', + '2/6': '33.333333%', + '3/6': '50%', + '4/6': '66.666667%', + '5/6': '83.333333%', + '1/12': '8.333333%', + '2/12': '16.666667%', + '3/12': '25%', + '4/12': '33.333333%', + '5/12': '41.666667%', + '6/12': '50%', + '7/12': '58.333333%', + '8/12': '66.666667%', + '9/12': '75%', + '10/12': '83.333333%', + '11/12': '91.666667%', + full: '100%', + screen: '100vw', + svw: '100svw', + lvw: '100lvw', + dvw: '100dvw', + min: 'min-content', + max: 'max-content', + fit: 'fit-content', + }), + willChange: { + auto: 'auto', + scroll: 'scroll-position', + contents: 'contents', + transform: 'transform', + }, + zIndex: { + auto: 'auto', + 0: '0', + 10: '10', + 20: '20', + 30: '30', + 40: '40', + 50: '50', + }, + }, + plugins: [], +} +