├── CODEOWNERS ├── main.go ├── renovate.json ├── .github ├── workflows │ ├── dco.yml │ ├── test.yml │ ├── golangci-lint.yml │ ├── scan.yml │ ├── build.yml │ └── release.yml └── ISSUE_TEMPLATE │ └── config.yml ├── .gitignore ├── cmd ├── root_test.go ├── schema.go ├── version.go ├── version_test.go ├── format.go ├── root.go ├── generate.go ├── cyclonexdx.go ├── schema_test.go └── generate_test.go ├── SECURITY.md ├── internal ├── utils │ ├── utils.go │ └── utils_test.go ├── config │ └── config.go ├── model │ ├── kbom_test.go │ └── kbom.go └── kube │ └── kube.go ├── .pre-commit-config.yaml ├── docs ├── schema.md └── taxonomy.md ├── scripts └── version ├── .golangci.yml ├── Makefile ├── README.md ├── CONTRIBUTING.md ├── .goreleaser.yml ├── go.mod ├── LICENSE └── go.sum /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @rad-security/engineering 2 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/rad-security/kbom/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/dco.yml: -------------------------------------------------------------------------------- 1 | name: DCO Check 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | check: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: KineticCafe/actions-dco@v1 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Ask a question 4 | url: https://github.com/rad-security/kbom/discussions 5 | about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | vendor 3 | release 4 | archive 5 | 6 | # Files 7 | .envrc 8 | 9 | # KBOM Binaries 10 | /kbom* 11 | 12 | # BOM file 13 | bom.json 14 | 15 | # Coverage 16 | coverage.out 17 | coverage_report.html 18 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestRoot(t *testing.T) { 9 | var buf bytes.Buffer 10 | rootCmd.SetOut(&buf) 11 | 12 | Execute() 13 | 14 | // Check if output contains expected string 15 | expected := "KBOM - Kubernetes Bill of Materials" 16 | if !bytes.Contains(buf.Bytes(), []byte(expected)) { 17 | t.Errorf("Execute() output = %q, want %q", buf.String(), expected) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We currently support the latest release for security patching and will deploy forward releases. 6 | 7 | For example if there is a vulnerability in release `v0.1.0` we will fix that release in version `v0.1.1-fix` or `v0.1.1` 8 | 9 | ## Reporting a Vulnerability 10 | 11 | If you are aware of a vulnverability please feel free to disclose it responsibly [here](https://github.com/rad-security/kbom/security/advisories/new). 12 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - uses: actions/setup-go@v5 13 | with: 14 | go-version: '1.24' 15 | check-latest: true # https://github.com/actions/setup-go#check-latest-version 16 | cache: true # https://github.com/actions/setup-go#caching-dependency-files-and-build-outputs 17 | 18 | - name: Test 19 | run: go test -race ./... 20 | -------------------------------------------------------------------------------- /cmd/schema.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/invopop/jsonschema" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/rad-security/kbom/internal/model" 10 | ) 11 | 12 | var schemaCmd = &cobra.Command{ 13 | Use: "schema", 14 | Short: "Print the KBOM json file schema", 15 | RunE: runGenerateSchema, 16 | } 17 | 18 | func runGenerateSchema(cmd *cobra.Command, _ []string) error { 19 | schema := jsonschema.Reflect(&model.KBOM{}) 20 | enc := json.NewEncoder(out) 21 | enc.SetIndent("", " ") 22 | 23 | return enc.Encode(schema) 24 | } 25 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/pflag" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | // BindFlags binds the viper config values to the flags 13 | func BindFlags(cmd *cobra.Command) { 14 | cmd.Flags().VisitAll(func(f *pflag.Flag) { 15 | configName := f.Name 16 | 17 | if !f.Changed && viper.IsSet(configName) { 18 | val := viper.Get(configName) 19 | err := cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) 20 | if err != nil { 21 | fmt.Println(err) 22 | os.Exit(1) 23 | } 24 | } 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | var ( 4 | // AppName - variable injected by -X flag during build 5 | AppName = "unknown" 6 | 7 | // AppVersion - variable injected by -X flag during build 8 | AppVersion = "unknown" 9 | 10 | // LastCommitTime - variable injected by -X flag during build 11 | LastCommitTime = "unknown" 12 | 13 | // LastCommitUser - variable injected by -X flag during build 14 | LastCommitUser = "unknown" 15 | 16 | // LastCommitHash - variable injected by -X flag during build 17 | LastCommitHash = "unknown" 18 | 19 | // BuildTime - variable injected by -X flag during build 20 | BuildTime = "unknown" 21 | ) 22 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/rad-security/kbom/internal/config" 9 | ) 10 | 11 | var versionCmd = &cobra.Command{ 12 | Use: "version", 13 | Short: "Print the KBOM generator version", 14 | Long: `All software has versions. This is KBOM's`, 15 | RunE: runPrintVersion, 16 | } 17 | 18 | func runPrintVersion(cmd *cobra.Command, _ []string) error { 19 | fmt.Fprintf(out, "%s version %s\n", config.AppName, config.AppVersion) 20 | fmt.Fprintf(out, "build date: %s\n", config.BuildTime) 21 | fmt.Fprintf(out, "commit: %s\n\n", config.LastCommitHash) 22 | fmt.Fprintln(out, "https://github.com/rad-security/kbom") 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | golangci: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: '1.24' 20 | check-latest: true # https://github.com/actions/setup-go#check-latest-version 21 | cache: true # https://github.com/actions/setup-go#caching-dependency-files-and-build-outputs 22 | 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v6 25 | with: 26 | version: v1.64.8 27 | args: --timeout=5m 28 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: check-byte-order-marker 6 | - id: check-case-conflict 7 | - id: check-merge-conflict 8 | - id: check-symlinks 9 | - id: detect-aws-credentials 10 | args: ['--allow-missing-credentials'] 11 | - id: detect-private-key 12 | - id: end-of-file-fixer 13 | - id: mixed-line-ending 14 | - id: trailing-whitespace 15 | - repo: https://github.com/dnephin/pre-commit-golang.git 16 | rev: v0.5.0 17 | hooks: 18 | - id: go-fmt 19 | - id: go-imports 20 | - id: go-mod-tidy 21 | - id: go-build 22 | - id: go-unit-tests 23 | - id: golangci-lint 24 | -------------------------------------------------------------------------------- /docs/schema.md: -------------------------------------------------------------------------------- 1 | # KBOM Schema 2 | 3 | The section below describes the high level object model for KBOM. 4 | 5 | ## Cluster Details 6 | 7 | Instances: 8 | 9 | - Name 10 | - Hostname 11 | - CloudType 12 | - Creation Timestamp 13 | - Capacity 14 | - Allocatable resources 15 | - OS Version 16 | - Kernel Version 17 | - Architecture 18 | - CRI Version 19 | - Kubelet Version 20 | - Kube Proxy Version 21 | 22 | Images: 23 | 24 | - Name 25 | - FullName 26 | - Version 27 | - Digest 28 | 29 | KubeObjects: 30 | 31 | - Kind 32 | - Api Version 33 | - Count 34 | - Details 35 | 36 | This overall structure provides a base spec to be expanded upon by the community. 37 | 38 | The intent of the standard is to be extensible to support various use cases across the industry. 39 | -------------------------------------------------------------------------------- /cmd/version_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/rad-security/kbom/internal/config" 10 | ) 11 | 12 | func TestVersion(t *testing.T) { 13 | mock := &stdoutMock{buf: bytes.Buffer{}} 14 | out = mock 15 | 16 | config.AppName = "kbom" 17 | config.AppVersion = "1.0.0" 18 | config.BuildTime = "2021-01-01T00:00:00Z" 19 | config.LastCommitHash = "1234567890" 20 | 21 | err := runPrintVersion(nil, []string{}) 22 | assert.NoError(t, err) 23 | 24 | assert.Equal(t, expectedVersion, mock.buf.String()) 25 | } 26 | 27 | var expectedVersion = `kbom version 1.0.0 28 | build date: 2021-01-01T00:00:00Z 29 | commit: 1234567890 30 | 31 | https://github.com/rad-security/kbom 32 | ` 33 | -------------------------------------------------------------------------------- /scripts/version: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | SEP=${SEP:-"-"} 6 | 7 | exact_tag=$(git describe --exact-match 2>/dev/null | sed -e 's/^v//g' || echo '') 8 | last_tag=$(git describe --tags --abbrev=0 2>/dev/null) 9 | commits=$(git log --oneline "${last_tag}"..HEAD | wc -l | tr -d ' ') 10 | revision=$(git rev-parse --short HEAD || echo unknown) 11 | 12 | if echo "${exact_tag}" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+$"; then 13 | echo "$exact_tag" 14 | exit 0 15 | fi 16 | 17 | if echo "${exact_tag}" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$"; then 18 | echo "$exact_tag" 19 | exit 0 20 | fi 21 | 22 | if echo "${exact_tag}" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+-rc[0-9]+$"; then 23 | echo "$exact_tag" 24 | exit 0 25 | fi 26 | 27 | if [ "$commits" -eq 0 ]; then 28 | if [ -n "$last_tag" ]; then 29 | echo "${last_tag//v/}" 30 | exit 0 31 | fi 32 | fi 33 | 34 | echo "${last_tag//v/}${SEP}${commits}-${revision}" 35 | -------------------------------------------------------------------------------- /.github/workflows/scan.yml: -------------------------------------------------------------------------------- 1 | name: scan 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | schedule: 10 | - cron: '18 10 * * 3' 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | scan-codeql: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | security-events: write 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | - name: Setup Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: '1.24' 27 | cache-dependency-path: | 28 | **/go.sum 29 | **/go.mod 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v3 32 | with: 33 | languages: go 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v3 36 | - name: Perform CodeQL Analysis 37 | uses: github/codeql-action/analyze@v3 38 | -------------------------------------------------------------------------------- /internal/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | func TestBindFlags(t *testing.T) { 11 | // Set up a new cobra command 12 | cmd := &cobra.Command{ 13 | Use: "test", 14 | Run: func(cmd *cobra.Command, args []string) {}, 15 | } 16 | 17 | // Add some flags to the command 18 | cmd.Flags().String("foo", "", "foo flag") 19 | cmd.Flags().Int("bar", 0, "bar flag") 20 | 21 | // Initialize viper with some values 22 | viper.Set("foo", "foo-value") 23 | viper.Set("bar", 123) 24 | 25 | // Bind the viper config values to the command flags 26 | BindFlags(cmd) 27 | 28 | // Check that the flag values were set correctly 29 | fooFlag := cmd.Flags().Lookup("foo") 30 | if fooFlag.Value.String() != "foo-value" { 31 | t.Errorf("expected foo flag value to be 'foo-value', but got '%s'", fooFlag.Value.String()) 32 | } 33 | 34 | barFlag := cmd.Flags().Lookup("bar") 35 | if barFlag.Value.String() != "123" { 36 | t.Errorf("expected bar flag value to be '123', but got '%s'", barFlag.Value.String()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cmd/format.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "fmt" 4 | 5 | type Format struct { 6 | Name string 7 | FileExtension string 8 | } 9 | 10 | var JSONFormat = Format{ 11 | Name: "json", 12 | FileExtension: "json", 13 | } 14 | 15 | var YAMLFormat = Format{ 16 | Name: "yaml", 17 | FileExtension: "yaml", 18 | } 19 | 20 | var CycloneDXJsonFormat = Format{ 21 | Name: "cyclonedx-json", 22 | FileExtension: "json", 23 | } 24 | 25 | var CycloneDXXMLFormat = Format{ 26 | Name: "cyclonedx-xml", 27 | FileExtension: "xml", 28 | } 29 | 30 | func formatNames() []string { 31 | return []string{ 32 | JSONFormat.Name, 33 | YAMLFormat.Name, 34 | CycloneDXJsonFormat.Name, 35 | CycloneDXXMLFormat.Name, 36 | } 37 | } 38 | 39 | func formatFromName(name string) (Format, error) { 40 | switch name { 41 | case JSONFormat.Name: 42 | return JSONFormat, nil 43 | case YAMLFormat.Name: 44 | return YAMLFormat, nil 45 | case CycloneDXJsonFormat.Name: 46 | return CycloneDXJsonFormat, nil 47 | case CycloneDXXMLFormat.Name: 48 | return CycloneDXXMLFormat, nil 49 | default: 50 | return Format{}, fmt.Errorf("format %q is not supported", name) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/model/kbom_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestImagePkgID(t *testing.T) { 8 | testCases := []struct { 9 | name string 10 | image Image 11 | expectedID string 12 | expectedErr error 13 | }{ 14 | { 15 | name: "VersionAndDigest", 16 | image: Image{ 17 | FullName: "full_name", 18 | Name: "repo/name", 19 | Version: "version", 20 | Digest: "sha256:digest", 21 | }, 22 | expectedID: "pkg:oci/name@sha256%3Adigest?repository_url=repo%2Fname&tag=version", 23 | }, 24 | { 25 | name: "VersionOnly", 26 | image: Image{ 27 | FullName: "full_name", 28 | Name: "repo/name", 29 | Version: "version", 30 | }, 31 | expectedID: "pkg:oci/name?repository_url=repo%2Fname&tag=version", 32 | }, 33 | { 34 | name: "DigestOnly", 35 | image: Image{ 36 | FullName: "full_name", 37 | Name: "repo/subrepo/name", 38 | Digest: "sha256:digest", 39 | }, 40 | expectedID: "pkg:oci/name@sha256%3Adigest?repository_url=repo%2Fsubrepo%2Fname", 41 | }, 42 | { 43 | name: "NoVersionOrDigest", 44 | image: Image{ 45 | FullName: "full_name", 46 | Name: "repo/name", 47 | }, 48 | expectedID: "pkg:oci/name?repository_url=repo%2Fname", 49 | }, 50 | } 51 | 52 | for _, tc := range testCases { 53 | t.Run(tc.name, func(t *testing.T) { 54 | result := tc.image.PkgID() 55 | if result != tc.expectedID { 56 | t.Errorf("Expected %s, but got %s", tc.expectedID, result) 57 | } 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | dupl: 3 | threshold: 110 4 | funlen: 5 | lines: 100 6 | statements: 50 7 | goconst: 8 | min-len: 2 9 | min-occurrences: 3 10 | gocritic: 11 | enabled-tags: 12 | - diagnostic 13 | - experimental 14 | - opinionated 15 | - performance 16 | - style 17 | disabled-checks: 18 | - dupImport # https://github.com/go-critic/go-critic/issues/845 19 | - ifElseChain 20 | - octalLiteral 21 | - whyNoLint 22 | gocyclo: 23 | min-complexity: 15 24 | goimports: 25 | local-prefixes: github.com/rad-security 26 | lll: 27 | line-length: 140 28 | misspell: 29 | locale: US 30 | 31 | linters: 32 | # please, do not use `enable-all`: it's deprecated and will be removed soon. 33 | # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint 34 | disable-all: true 35 | enable: 36 | - bodyclose 37 | - dogsled 38 | - dupl 39 | - errcheck 40 | - copyloopvar 41 | - funlen 42 | - goconst 43 | - gocritic 44 | - gocyclo 45 | - gofmt 46 | - goimports 47 | - goprintffuncname 48 | - gosec 49 | - gosimple 50 | - govet 51 | - ineffassign 52 | - lll 53 | - misspell 54 | - nakedret 55 | - noctx 56 | - nolintlint 57 | - staticcheck 58 | - stylecheck 59 | - typecheck 60 | - unconvert 61 | - unparam 62 | - unused 63 | - whitespace 64 | 65 | issues: 66 | exclude-rules: 67 | - path: _test\.go 68 | linters: 69 | - funlen 70 | 71 | run: 72 | timeout: 2m 73 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | version: 8 | runs-on: ubuntu-latest 9 | if: startsWith(github.head_ref, 'renovate') == false 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | - name: Detect Version for Docker 16 | id: docker-version 17 | run: echo "VERSION=$(SEP="-" scripts/version)" >> $GITHUB_OUTPUT 18 | - name: Detect Version 19 | id: version 20 | run: echo "VERSION=$(scripts/version)" >> $GITHUB_OUTPUT 21 | outputs: 22 | docker-version: ${{ steps.docker-version.outputs.VERSION }} 23 | version: ${{ steps.version.outputs.VERSION }} 24 | 25 | build: 26 | runs-on: ubuntu-latest 27 | needs: 28 | - version 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - uses: actions/setup-go@v5 33 | with: 34 | go-version: 1.24 35 | check-latest: true # https://github.com/actions/setup-go#check-latest-version 36 | cache: true # https://github.com/actions/setup-go#caching-dependency-files-and-build-outputs 37 | 38 | - name: Build 39 | run: go build -race ./... 40 | 41 | - name: Install GoReleaser 42 | uses: goreleaser/goreleaser-action@v5 43 | with: 44 | version: latest 45 | install-only: true 46 | 47 | - name: Snapshot 48 | run: make snapshot 49 | env: 50 | GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.docker-version }} 51 | 52 | - name: Grype scan 53 | id: scan 54 | uses: anchore/scan-action@v5 55 | with: 56 | path: "." 57 | fail-build: true 58 | severity-cutoff: medium 59 | output-format: sarif 60 | 61 | - name: Upload SARIF report 62 | uses: github/codeql-action/upload-sarif@v3 63 | with: 64 | sarif_file: ${{ steps.scan.outputs.sarif }} 65 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | APP_NAME := kbom 2 | GCR_ORG := ksoc-public 3 | GITHUB_ORG := rad-security 4 | GIT_REPO ?= github.com/$(GITHUB_ORG)/$(APP_NAME) 5 | VERSION := $(shell SEP="-" bash scripts/version) 6 | 7 | BUILD_TIME ?= $(shell date -u '+%Y-%m-%d %H:%M:%S') 8 | LAST_COMMIT_USER ?= $(shell git log -1 --format='%cn <%ce>') 9 | LAST_COMMIT_HASH ?= $(shell git log -1 --format=%H) 10 | LAST_COMMIT_TIME ?= $(shell git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S') 11 | 12 | export APP_NAME 13 | export GCR_ORG 14 | export GITHUB_ORG 15 | export VERSION 16 | export BUILD_TIME 17 | export LAST_COMMIT_USER 18 | export LAST_COMMIT_HASH 19 | export LAST_COMMIT_TIME 20 | 21 | .PHONY: initialise 22 | initialise: ## Initialises the project, set ups git hooks 23 | pre-commit install 24 | 25 | .PHONY: release 26 | release: ## Builds a release 27 | goreleaser release --clean --timeout 90m 28 | 29 | .PHONY: semtag-% 30 | semtag-%: ## Creates a new tag using semtag 31 | semtag final -s $* 32 | 33 | .PHONY: snapshot 34 | snapshot: ## Builds a snapshot release 35 | GORELEASER_CURRENT_TAG=$(GORELEASER_CURRENT_TAG) \ 36 | goreleaser build --snapshot --clean --single-target --timeout 90m 37 | 38 | .PHONY: docker_push_all 39 | docker_push_all: ## Pushes all docker images 40 | docker push $$(docker images -a | grep $(APP_NAME) | awk '{ print $$1 ":" $$2 }') 41 | 42 | .PHONY: build 43 | build: ## Builds kbom binary 44 | CGO_ENABLED=0 \ 45 | go build \ 46 | -v \ 47 | -ldflags "-s -w \ 48 | -X '$(GIT_REPO)/internal/config.AppName=$(APP_NAME)' \ 49 | -X '$(GIT_REPO)/internal/config.AppVersion=$(VERSION)' \ 50 | -X '$(GIT_REPO)/internal/config.BuildTime=$(BUILD_TIME)' \ 51 | -X '$(GIT_REPO)/internal/config.LastCommitUser=$(LAST_COMMIT_USER)' \ 52 | -X '$(GIT_REPO)/internal/config.LastCommitHash=$(LAST_COMMIT_HASH)' \ 53 | -X '$(GIT_REPO)/internal/config.LastCommitTime=$(LAST_COMMIT_TIME)'" \ 54 | -o $(APP_NAME) . 55 | 56 | .PHONY: test 57 | test: ## Runs unit tests 58 | go test -coverprofile coverage.out -v --race ./... 59 | go tool cover -html=coverage.out -o coverage_report.html 60 | 61 | .PHONY: help 62 | help: ## Displays this help screen 63 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} /^[a-zA-Z_-]+:.*?##/ \ 64 | { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) 65 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | "github.com/rs/zerolog" 11 | "github.com/rs/zerolog/log" 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/viper" 14 | "golang.org/x/term" 15 | 16 | "github.com/rad-security/kbom/internal/utils" 17 | ) 18 | 19 | const ( 20 | confDir = ".config/rad" 21 | ) 22 | 23 | var ( 24 | verbose bool 25 | k8sContext string 26 | 27 | out io.WriteCloser = os.Stdout 28 | ) 29 | 30 | var rootCmd = &cobra.Command{ 31 | Use: "kbom", 32 | Short: "KBOM - Kubernetes Bill of Materials", 33 | 34 | PersistentPreRun: setup, 35 | } 36 | 37 | func Execute() { 38 | if err := rootCmd.Execute(); err != nil { 39 | fmt.Println(err) 40 | os.Exit(1) 41 | } 42 | } 43 | 44 | func init() { 45 | rootCmd.AddCommand(GenerateCmd) 46 | rootCmd.AddCommand(versionCmd) 47 | rootCmd.AddCommand(schemaCmd) 48 | 49 | rootCmd.PersistentFlags().StringVarP(&k8sContext, "context", "c", "", "Kubernetes context to use, defaults to current context") 50 | rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging (DEBUG and below)") 51 | 52 | rootCmd.SilenceErrors = true 53 | rootCmd.SilenceUsage = true 54 | } 55 | 56 | func setup(cmd *cobra.Command, _ []string) { 57 | initLogger() 58 | initConfig() 59 | utils.BindFlags(cmd) 60 | } 61 | 62 | func initConfig() { 63 | home, err := os.UserHomeDir() 64 | cobra.CheckErr(err) 65 | 66 | viper.AddConfigPath(home) 67 | viper.SetConfigType("json") 68 | viper.SetConfigName(path.Join(confDir, "kbom.json")) 69 | 70 | if err := viper.ReadInConfig(); err != nil { 71 | // It's okay if there isn't a config file 72 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 73 | fmt.Println("error reading config file") 74 | os.Exit(1) 75 | } 76 | } 77 | 78 | // Environment variables can't have dashes 79 | viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) 80 | viper.AutomaticEnv() 81 | } 82 | 83 | func initLogger() { 84 | defaultLogger := zerolog.New(os.Stderr) 85 | 86 | logLevel := zerolog.InfoLevel 87 | if verbose { 88 | logLevel = zerolog.TraceLevel 89 | } 90 | 91 | zerolog.SetGlobalLevel(logLevel) 92 | 93 | // use color logger when run in terminal 94 | if isTerminal() { 95 | defaultLogger = zerolog.New(zerolog.NewConsoleWriter()) 96 | } 97 | 98 | log.Logger = defaultLogger.With().Timestamp().Stack().Logger() 99 | } 100 | 101 | func isTerminal() bool { 102 | return term.IsTerminal(int(os.Stdout.Fd())) 103 | } 104 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_call: 8 | secrets: 9 | PERSONAL_ACCESS_TOKEN: 10 | required: true 11 | PUBLIC_GCR_JSON_KEY: 12 | required: true 13 | 14 | permissions: 15 | contents: write # needed to write releases 16 | id-token: write # needed for keyless signing 17 | 18 | jobs: 19 | version: 20 | runs-on: ubuntu-latest 21 | if: startsWith(github.head_ref, 'renovate') == false 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - name: Detect Version for Docker 28 | id: docker-version 29 | run: echo "VERSION=$(SEP="-" scripts/version)" >> $GITHUB_OUTPUT 30 | - name: Detect Version 31 | id: version 32 | run: echo "VERSION=$(scripts/version)" >> $GITHUB_OUTPUT 33 | outputs: 34 | docker-version: ${{ steps.docker-version.outputs.VERSION }} 35 | version: ${{ steps.version.outputs.VERSION }} 36 | 37 | goreleaser: 38 | timeout-minutes: 90 39 | runs-on: ubuntu-latest 40 | needs: 41 | - version 42 | env: 43 | SUMMARY: ${{ needs.version.outputs.docker-version }} 44 | VERSION: ${{ needs.version.outputs.version }} 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | with: 49 | fetch-depth: 0 50 | 51 | - name: Set up Go 52 | uses: actions/setup-go@v5 53 | with: 54 | go-version: '1.24' 55 | check-latest: true # https://github.com/actions/setup-go#check-latest-version 56 | cache: true # https://github.com/actions/setup-go#caching-dependency-files-and-build-outputs 57 | 58 | - uses: sigstore/cosign-installer@v3 59 | - uses: anchore/sbom-action/download-syft@v0 60 | 61 | - name: Login to GCR 62 | uses: docker/login-action@v3 63 | with: 64 | registry: us.gcr.io 65 | username: _json_key 66 | password: ${{ secrets.PUBLIC_GCR_JSON_KEY }} 67 | 68 | - name: Install GoReleaser 69 | uses: goreleaser/goreleaser-action@v5 70 | with: 71 | version: latest 72 | install-only: true 73 | 74 | - name: Generate SBOM 75 | uses: CycloneDX/gh-gomod-generate-sbom@v2 76 | with: 77 | args: mod -licenses -json -output bom.json 78 | version: ^v1 79 | 80 | - name: Release 81 | if: startsWith(github.ref , 'refs/tags/v') == true 82 | run: make release 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 85 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KBOM - Kubernetes Bill of Materials 2 | 3 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/rad-security/kbom) 4 | ![Hex.pm](https://img.shields.io/hexpm/l/apa) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/rad-security/kbom)](https://goreportcard.com/report/github.com/rad-security/kbom) 6 | [![OpenSSF Best Practices](https://bestpractices.coreinfrastructure.org/projects/7273/badge)](https://bestpractices.coreinfrastructure.org/projects/7273) 7 | 8 | The Kubernetes Bill of Materials (KBOM) standard provides insight into container orchestration tools widely used across the industry. 9 | 10 | As a first draft, we have created a rough specification which should fall in line with other Bill of Materials (BOM) standards. 11 | 12 | The KBOM project provides an initial specification in JSON and has been constructed for extensibilty across various cloud service providers (CSPs) as well as DIY Kubernetes. 13 | 14 | ## Getting Started 15 | 16 | ### Download KBOM 17 | If you prefer to download the binary, you can do so from the [releases page](https://github.com/rad-security/kbom/releases). 18 | 19 | ### Installation 20 | 21 | ```sh 22 | brew install rad-security/homebrew-kbom/kbom 23 | ``` 24 | 25 | or 26 | 27 | ```sh 28 | make build 29 | ``` 30 | 31 | ### Usage 32 | 33 | `KBOM generate` generates a KBOM file for your Kubernetes cluster 34 | 35 | ```sh 36 | kbom generate [flags] 37 | ``` 38 | 39 | Optional flags include: 40 | 41 | ```plain 42 | Flags: 43 | -f, --format string Format (json, yaml, cyclonedx-json, cyclonedx-xml) (default "json") 44 | -h, --help help for generate 45 | -p, --out-path string Path to write KBOM file to. Works only with --output=file (default ".") 46 | -o, --output string Output (stdout, file) (default "stdout") 47 | --short Short - only include metadata, nodes, images and resources counters 48 | ``` 49 | 50 | ## Schema 51 | 52 | The high level object model can be found [here](docs/schema.md). 53 | 54 | ## Supported Kubernetes Versions 55 | 56 | We have tested *kbom* with all versions newer than *v1.19*, and can confirm that it is fully compatible with each of these versions. This means that you can use our tool with confidence, knowing that it has been thoroughly tested with. 57 | 58 | ## Supported Cloud Providers 59 | 60 | We have tested our tool with all of the main cloud providers, including `Azure`, `AWS`, and `Google Cloud`. Of course it's possible to generate `kbom` file for any K8s cluster, but please have in mind that in some cases not all metadata entries will be set. 61 | 62 | ## Contributing 63 | 64 | KBOM is Apache 2.0 licensed and accepts contributions via GitHub pull requests. See the [CONTRIBUTING](CONTRIBUTING.md) file for details. 65 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | KBOM is [Apache 2.0 licensed](https://github.com/rad-security/kbom/blob/main/LICENSE) and 4 | accepts contributions via GitHub pull requests. This document outlines 5 | some of the conventions on to make it easier to get your contribution 6 | accepted. 7 | 8 | We gratefully welcome improvements to issues and documentation as well as to 9 | code. 10 | 11 | ## Certificate of Origin 12 | 13 | By contributing to this project you agree to the Developer Certificate of 14 | Origin (DCO). This document was created by the Linux Kernel community and is a 15 | simple statement that you, as a contributor, have the legal right to make the 16 | contribution. 17 | 18 | We require all commits to be signed. By signing off with your signature, you 19 | certify that you wrote the patch or otherwise have the right to contribute the 20 | material by the rules of the [DCO](DCO): 21 | 22 | `Signed-off-by: Firstname Lastname ` 23 | 24 | The signature must contain your real name 25 | (sorry, no pseudonyms or anonymous contributions) 26 | If your `user.name` and `user.email` are configured in your Git config, 27 | you can sign your commit automatically with `git commit -s`. 28 | 29 | ## Communications 30 | 31 | To discuss ideas and specifications we use [GitHub Discussions](https://github.com/rad-security/kbom/discussions). 32 | 33 | ## How to run the KBOM generator in local environment 34 | 35 | Prerequisites: 36 | 37 | * go >= 1.20 38 | * kind 39 | * golangci-lint 40 | 41 | Initialise repo: 42 | 43 | ```bash 44 | make initialise 45 | ``` 46 | 47 | To generate your first KBOM file we need to have access to a Kubernetes cluster. 48 | If you don't have any you could create your local cluster with `Kind`. 49 | 50 | Create kind cluster(optional): 51 | 52 | ```bash 53 | kind create cluster --name kbom-test 54 | ``` 55 | 56 | Build `kbom` binary: 57 | 58 | ```bash 59 | make build 60 | ``` 61 | 62 | Generate your first `kbom` file: 63 | 64 | ```bash 65 | ./kbom generate 66 | ``` 67 | 68 | ## Acceptance policy 69 | 70 | These things will make a PR more likely to be accepted: 71 | 72 | * a well-described requirement 73 | * tests for new code 74 | * tests for old code! 75 | * new code and tests follow the conventions in old code and tests 76 | * a good commit message (see below) 77 | * all code must abide [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments) 78 | * names should abide [What's in a name](https://talks.golang.org/2014/names.slide#1) 79 | * code must build on both Linux and Darwin, via plain `go build` 80 | * code should have appropriate test coverage and tests should be written to work with `go test` 81 | 82 | In general, we will merge a PR once one maintainer has endorsed it. 83 | For substantial changes, more people may become involved, and you might 84 | get asked to resubmit the PR or divide the changes into more than one PR. 85 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | dist: release 2 | env: 3 | - PACKAGE_NAME=github.com/{{.Env.GITHUB_ORG}}/{{.Env.APP_NAME}} 4 | - CFG_PACKAGE_NAME=github.com/{{.Env.GITHUB_ORG}}/{{.Env.APP_NAME}}/internal/config 5 | before: 6 | hooks: 7 | - go mod tidy 8 | 9 | release: 10 | extra_files: 11 | - glob: ./bom.json 12 | github: 13 | name: kbom 14 | owner: rad-security 15 | discussion_category_name: Announcements 16 | 17 | builds: 18 | - binary: kbom 19 | goos: 20 | - darwin 21 | - linux 22 | - windows 23 | goarch: 24 | - "386" 25 | - amd64 26 | - arm 27 | - arm64 28 | goarm: 29 | - "7" 30 | ignore: 31 | - goos: darwin 32 | goarch: "386" 33 | ldflags: 34 | - -X "{{.Env.CFG_PACKAGE_NAME}}.AppName=kbom" 35 | - -X "{{.Env.CFG_PACKAGE_NAME}}.AppVersion={{.Env.VERSION}}" 36 | - -X "{{.Env.CFG_PACKAGE_NAME}}.BuildTime={{.Env.BUILD_TIME}}" 37 | - -X "{{.Env.CFG_PACKAGE_NAME}}.LastCommitUser={{.Env.LAST_COMMIT_USER}}" 38 | - -X "{{.Env.CFG_PACKAGE_NAME}}.LastCommitHash={{.Env.LAST_COMMIT_HASH}}" 39 | - -X "{{.Env.CFG_PACKAGE_NAME}}.LastCommitTime={{.Env.LAST_COMMIT_TIME}}" 40 | main: ./ 41 | env: 42 | - CGO_ENABLED=0 43 | 44 | # create a source tarball 45 | # https://goreleaser.com/customization/source/ 46 | source: 47 | enabled: true 48 | 49 | # creates SBOMs of all archives and the source tarball using syft 50 | # https://goreleaser.com/customization/sbom 51 | sboms: 52 | - artifacts: archive 53 | - id: source # Two different sbom configurations need two different IDs 54 | artifacts: source 55 | 56 | # signs the checksum file 57 | # all files (including the sboms) are included in the checksum, so we don't need to sign each one if we don't want to 58 | # https://goreleaser.com/customization/sign 59 | signs: 60 | - cmd: cosign 61 | env: 62 | - COSIGN_EXPERIMENTAL=1 63 | certificate: '${artifact}.pem' 64 | args: 65 | - sign-blob 66 | - '--output-certificate=${certificate}' 67 | - '--output-signature=${signature}' 68 | - '${artifact}' 69 | - "--yes" # needed on cosign 2.0.0+ 70 | artifacts: checksum 71 | output: true 72 | 73 | brews: 74 | - repository: 75 | owner: rad-security 76 | name: homebrew-kbom 77 | homepage: "https://github.com/rad-security/kbom" 78 | description: "The Kubernetes Bill of Materials (KBOM) standard provides insight into container orchestration tools widely used across the industry." 79 | license: "Apache 2" 80 | test: | 81 | system "#{bin}/kbom", "version" 82 | 83 | dockers: 84 | - goos: linux 85 | goarch: amd64 86 | dockerfile: build/package/Dockerfile.gorelease 87 | image_templates: 88 | - us.gcr.io/{{.Env.GCR_ORG}}/{{.Env.APP_NAME}}:{{- if .IsSnapshot -}}{{ .Env.VERSION }}{{- else -}}{{ .Tag }}{{- end -}} 89 | build_flag_templates: 90 | - "--pull" 91 | - "--label=org.opencontainers.image.created={{.Date}}" 92 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 93 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 94 | - "--label=org.opencontainers.image.version={{.Version}}" 95 | - "--label=org.opencontainers.image.source=https://{{.Env.PACKAGE_NAME}}" 96 | - "--platform=linux/amd64" 97 | 98 | checksum: 99 | name_template: "checksums.txt" 100 | 101 | snapshot: 102 | name_template: "snapshot-{{ .ShortCommit }}-{{ .Timestamp }}" 103 | 104 | changelog: 105 | sort: asc 106 | filters: 107 | exclude: 108 | - "^docs:" 109 | - "^test:" 110 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rad-security/kbom 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/CycloneDX/cyclonedx-go v0.7.2 7 | github.com/Masterminds/semver v1.5.0 8 | github.com/distribution/reference v0.6.0 9 | github.com/google/uuid v1.6.0 10 | github.com/invopop/jsonschema v0.12.0 11 | github.com/mitchellh/hashstructure/v2 v2.0.2 12 | github.com/rs/zerolog v1.33.0 13 | github.com/spf13/cobra v1.8.0 14 | github.com/spf13/pflag v1.0.5 15 | github.com/spf13/viper v1.18.2 16 | github.com/stretchr/testify v1.9.0 17 | golang.org/x/term v0.29.0 18 | gopkg.in/yaml.v3 v3.0.1 19 | k8s.io/api v0.29.0 20 | k8s.io/apimachinery v0.30.1 21 | k8s.io/client-go v0.29.0 22 | ) 23 | 24 | require ( 25 | github.com/bahlo/generic-list-go v0.2.0 // indirect 26 | github.com/buger/jsonparser v1.1.1 // indirect 27 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 28 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 29 | github.com/fsnotify/fsnotify v1.7.0 // indirect 30 | github.com/go-logr/logr v1.4.1 // indirect 31 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 32 | github.com/go-openapi/jsonreference v0.20.2 // indirect 33 | github.com/go-openapi/swag v0.22.3 // indirect 34 | github.com/gogo/protobuf v1.3.2 // indirect 35 | github.com/golang/protobuf v1.5.4 // indirect 36 | github.com/google/gnostic-models v0.6.8 // indirect 37 | github.com/google/gofuzz v1.2.0 // indirect 38 | github.com/hashicorp/hcl v1.0.0 // indirect 39 | github.com/imdario/mergo v0.3.6 // indirect 40 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 41 | github.com/josharian/intern v1.0.0 // indirect 42 | github.com/json-iterator/go v1.1.12 // indirect 43 | github.com/magiconair/properties v1.8.7 // indirect 44 | github.com/mailru/easyjson v0.7.7 // indirect 45 | github.com/mattn/go-colorable v0.1.13 // indirect 46 | github.com/mattn/go-isatty v0.0.19 // indirect 47 | github.com/mitchellh/mapstructure v1.5.0 // indirect 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 49 | github.com/modern-go/reflect2 v1.0.2 // indirect 50 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 51 | github.com/opencontainers/go-digest v1.0.0 // indirect 52 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 53 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 54 | github.com/sagikazarmark/locafero v0.4.0 // indirect 55 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 56 | github.com/sourcegraph/conc v0.3.0 // indirect 57 | github.com/spf13/afero v1.11.0 // indirect 58 | github.com/spf13/cast v1.6.0 // indirect 59 | github.com/subosito/gotenv v1.6.0 // indirect 60 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 61 | go.uber.org/atomic v1.9.0 // indirect 62 | go.uber.org/multierr v1.9.0 // indirect 63 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 64 | golang.org/x/net v0.36.0 // indirect 65 | golang.org/x/oauth2 v0.15.0 // indirect 66 | golang.org/x/sys v0.30.0 // indirect 67 | golang.org/x/text v0.22.0 // indirect 68 | golang.org/x/time v0.5.0 // indirect 69 | google.golang.org/appengine v1.6.7 // indirect 70 | google.golang.org/protobuf v1.33.0 // indirect 71 | gopkg.in/inf.v0 v0.9.1 // indirect 72 | gopkg.in/ini.v1 v1.67.0 // indirect 73 | gopkg.in/yaml.v2 v2.4.0 // indirect 74 | k8s.io/klog/v2 v2.120.1 // indirect 75 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 76 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect 77 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 78 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 79 | sigs.k8s.io/yaml v1.3.0 // indirect 80 | ) 81 | -------------------------------------------------------------------------------- /docs/taxonomy.md: -------------------------------------------------------------------------------- 1 | # Custom RAD KBOM Taxonomy 2 | 3 | This is the RAD KBOM CycloneDX property namespace and name taxonomy. All of the namespaces are prefixed with `rad:kbom:`. 4 | 5 | Following Taxonomy is used by the `KBOM` tool as extension to: [https://github.com/CycloneDX/cyclonedx-property-taxonomy](https://github.com/CycloneDX/cyclonedx-property-taxonomy). 6 | 7 | ## `rad:kbom:k8s:component` Namespace Taxonomy 8 | 9 | | Namespace | Description | 10 | | ------------------------------------ | ----------------------------------------------------------------- | 11 | | `rad:kbom:k8s:component:apiVersion` | API Version of the Kubernetes component. | 12 | | `rad:kbom:k8s:component:namespace` | Namespace of the Kubernetes component. | 13 | 14 | ## `rad:kbom:k8s:cluster` Namespace Taxonomy 15 | 16 | | Property | Description | 17 | | ----------------------------------------- | ------------------------------ | 18 | | `rad:kbom:k8s:cluster:location:name` | Name of the location. | 19 | | `rad:kbom:k8s:cluster:location:region` | Region of the cluster. | 20 | | `rad:kbom:k8s:cluster:location:zone` | Zone where cluster is located. | 21 | 22 | ## `rad:kbom:k8s:node` Namespace Taxonomy 23 | 24 | | Property | Description | 25 | | -------------------------------------------------- | ------------------------------------ | 26 | | `rad:kbom:k8s:node:osImage` | Node's operating system image | 27 | | `rad:kbom:k8s:node:arch` | Node's architecture | 28 | | `rad:kbom:k8s:node:kernel` | Node's kernel version | 29 | | `rad:kbom:k8s:node:bootId` | Node's Boot identifier | 30 | | `rad:kbom:k8s:node:type` | Node's type | 31 | | `rad:kbom:k8s:node:operatingSystem` | Node's operating system | 32 | | `rad:kbom:k8s:node:machineId` | Node's machine identifier | 33 | | `rad:kbom:k8s:node:hostname` | Node's hostname | 34 | | `rad:kbom:k8s:node:containerRuntimeVersion` | Node's container runtime version | 35 | | `rad:kbom:k8s:node:kubeletVersion` | Node's kubelet version | 36 | | `rad:kbom:k8s:node:kubeProxyVersion` | Node's kube proxy version | 37 | | `rad:kbom:k8s:node:capacity:cpu` | Node's CPU capacity | 38 | | `rad:kbom:k8s:node:capacity:memory` | Node's Memory capacity | 39 | | `rad:kbom:k8s:node:capacity:pods` | Node's Pods capacity | 40 | | `rad:kbom:k8s:node:capacity:ephemeralStorage` | Node's ephemeral storage capacity | 41 | | `rad:kbom:k8s:node:allocatable:cpu` | Node's allocatable CPU | 42 | | `rad:kbom:k8s:node:allocatable:memory` | Node's allocatable Memory | 43 | | `rad:kbom:k8s:node:allocatable:pods` | Node's allocatable Pods | 44 | | `rad:kbom:k8s:node:allocatable:ephemeralStorage` | Node's allocatable ephemeral storage | 45 | 46 | ## `rad:kbom:pkg` Namespace Taxonomy 47 | 48 | | Property | Description | 49 | | --------------------------------- | -------------------------------------------------- | 50 | | `rad:kbom:pkg:type` | Type of the package. | 51 | | `rad:kbom:pkg:name` | Name of the package. | 52 | | `rad:kbom:pkg:version` | Version of the package. | 53 | | `rad:kbom:pkg:digest` | Digest of the package. | 54 | -------------------------------------------------------------------------------- /internal/model/kbom.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | const ( 11 | ociPrefix = "oci" 12 | k8sPrefix = "k8s" 13 | pkgPrefix = "pkg" 14 | kubernetesPkgName = "k8s.io/kubernetes" 15 | ) 16 | 17 | type KBOM struct { 18 | ID string `json:"id"` 19 | BOMFormat string `json:"bom_format"` 20 | SpecVersion string `json:"spec_version"` 21 | GeneratedAt time.Time `json:"generated_at"` 22 | GeneratedBy Tool `json:"generated_by"` 23 | 24 | Cluster Cluster `json:"cluster"` 25 | } 26 | 27 | type Tool struct { 28 | Vendor string `json:"vendor"` 29 | Name string `json:"name"` 30 | BuildTime string `json:"build_time"` 31 | Version string `json:"version"` 32 | Commit string `json:"commit"` 33 | CommitTime string `json:"commit_time"` 34 | } 35 | 36 | type Cluster struct { 37 | Name string `json:"name"` 38 | CACertDigest string `json:"ca_cert_digest"` 39 | K8sVersion string `json:"k8s_version"` 40 | CNIVersion string `json:"cni_version,omitempty"` 41 | Location *Location `json:"location"` 42 | NodesCount int `json:"nodes_count"` 43 | Nodes []Node `json:"nodes"` 44 | Components Components `json:"components"` 45 | } 46 | 47 | func (c *Cluster) BOMRef() string { 48 | return fmt.Sprintf("%s:%s/%s@%s", pkgPrefix, k8sPrefix, url.QueryEscape(kubernetesPkgName), c.K8sVersion) 49 | } 50 | 51 | func (c *Cluster) BOMName() string { 52 | return kubernetesPkgName 53 | } 54 | 55 | type Components struct { 56 | Images []Image `json:"images,omitempty"` 57 | Resources map[string]ResourceList `json:"resources"` 58 | } 59 | 60 | type Resource struct { 61 | Kind string `json:"kind,omitempty"` 62 | APIVersion string `json:"api_version,omitempty"` 63 | Name string `json:"name"` 64 | Namespace string `json:"namespace,omitempty"` 65 | AdditionalProperties map[string]string `json:"additional_properties,omitempty"` 66 | } 67 | 68 | type ResourceList struct { 69 | Kind string `json:"kind"` 70 | APIVersion string `json:"api_version"` 71 | Namespaced bool `json:"namespaced"` 72 | ResourcesCount int `json:"count"` 73 | Resources []Resource `json:"resources,omitempty"` 74 | } 75 | 76 | type Location struct { 77 | Name string `json:"name"` 78 | Region string `json:"region"` 79 | Zone string `json:"zone"` 80 | } 81 | 82 | type Node struct { 83 | Name string `json:"name"` 84 | Type string `json:"type"` 85 | Hostname string `json:"hostname"` 86 | Capacity *Capacity `json:"capacity"` 87 | Allocatable *Capacity `json:"allocatable"` 88 | Labels map[string]string `json:"labels"` 89 | Annotations map[string]string `json:"annotations"` 90 | MachineID string `json:"machine_id"` 91 | Architecture string `json:"architecture"` 92 | ContainerRuntimeVersion string `json:"container_runtime_version"` 93 | BootID string `json:"boot_id"` 94 | KernelVersion string `json:"kernel_version"` 95 | KubeProxyVersion string `json:"kube_proxy_version"` 96 | KubeletVersion string `json:"kubelet_version"` 97 | OperatingSystem string `json:"operating_system"` 98 | OsImage string `json:"os_image"` 99 | } 100 | 101 | type Image struct { 102 | FullName string `json:"full_name"` 103 | Name string `json:"name"` 104 | Version string `json:"version"` 105 | Digest string `json:"digest"` 106 | ControlPlane bool `json:"-"` 107 | } 108 | 109 | func (i *Image) PkgID() string { 110 | parts := strings.Split(i.Name, "/") 111 | baseName := fmt.Sprintf("%s:%s/%s", pkgPrefix, ociPrefix, parts[len(parts)-1]) 112 | 113 | urlValues := url.Values{ 114 | "repository_url": []string{i.Name}, 115 | } 116 | 117 | if i.Version != "" { 118 | urlValues.Add("tag", i.Version) 119 | } 120 | 121 | if i.Digest != "" { 122 | baseName = fmt.Sprintf("%s@%s", baseName, url.QueryEscape(i.Digest)) 123 | } 124 | 125 | return fmt.Sprintf("%s?%s", baseName, urlValues.Encode()) 126 | } 127 | 128 | type Capacity struct { 129 | CPU string `json:"cpu"` 130 | Memory string `json:"memory"` 131 | Pods string `json:"pods"` 132 | EphemeralStorage string `json:"ephemeral_storage"` 133 | } 134 | -------------------------------------------------------------------------------- /cmd/generate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path" 10 | "strings" 11 | "time" 12 | 13 | "github.com/CycloneDX/cyclonedx-go" 14 | "github.com/google/uuid" 15 | "github.com/spf13/cobra" 16 | "gopkg.in/yaml.v3" 17 | 18 | "github.com/rad-security/kbom/internal/config" 19 | "github.com/rad-security/kbom/internal/kube" 20 | "github.com/rad-security/kbom/internal/model" 21 | "github.com/rad-security/kbom/internal/utils" 22 | ) 23 | 24 | const ( 25 | Company = "RAD Security" 26 | BOMFormat = "rad" 27 | SpecVersion = "0.3" 28 | 29 | StdOutput = "stdout" 30 | FileOutput = "file" 31 | ) 32 | 33 | var ( 34 | short bool 35 | output string 36 | format string 37 | outPath string 38 | 39 | generatedAt = time.Now() 40 | kbomID = uuid.New().String() 41 | ) 42 | 43 | var GenerateCmd = &cobra.Command{ 44 | Use: "generate", 45 | Short: "Generate KBOM for the provided K8s cluster", 46 | RunE: runGenerate, 47 | } 48 | 49 | func init() { 50 | GenerateCmd.Flags().BoolVar(&short, "short", false, "Short - only include metadata, nodes, images and resources counters") 51 | GenerateCmd.Flags().StringVarP(&output, "output", "o", StdOutput, "Output (stdout, file)") 52 | GenerateCmd.Flags().StringVarP(&format, "format", "f", JSONFormat.Name, fmt.Sprintf("Format (%s)", strings.Join(formatNames(), ", "))) 53 | GenerateCmd.Flags().StringVarP(&outPath, "out-path", "p", ".", "Path to write KBOM file to. Works only with --output=file") 54 | 55 | utils.BindFlags(GenerateCmd) 56 | } 57 | 58 | func runGenerate(cmd *cobra.Command, _ []string) error { 59 | k8sClient, err := kube.NewClient(k8sContext) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | return generateKBOM(k8sClient) 65 | } 66 | 67 | func generateKBOM(k8sClient kube.K8sClient) error { 68 | parsedFormat, err := formatFromName(format) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | ctx := context.Background() 74 | k8sVersion, caCertDigest, err := k8sClient.Metadata(ctx) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | clusterName, err := k8sClient.ClusterName(ctx) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | full := !short 85 | nodes, err := k8sClient.AllNodes(ctx, full) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | loc, err := k8sClient.Location(ctx) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | allImages, err := k8sClient.AllImages(ctx) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | resources, err := k8sClient.AllResources(ctx, full) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | kbom := model.KBOM{ 106 | ID: kbomID, 107 | BOMFormat: BOMFormat, 108 | SpecVersion: SpecVersion, 109 | GeneratedAt: generatedAt, 110 | GeneratedBy: model.Tool{ 111 | Vendor: Company, 112 | BuildTime: config.BuildTime, 113 | Name: config.AppName, 114 | Version: config.AppVersion, 115 | Commit: config.LastCommitHash, 116 | CommitTime: config.LastCommitTime, 117 | }, 118 | Cluster: model.Cluster{ 119 | Name: clusterName, 120 | Location: loc, 121 | CNIVersion: "", // TODO: get CNI version 122 | K8sVersion: k8sVersion, 123 | CACertDigest: caCertDigest, 124 | NodesCount: len(nodes), 125 | Nodes: nodes, 126 | Components: model.Components{ 127 | Images: allImages, 128 | Resources: resources, 129 | }, 130 | }, 131 | } 132 | 133 | if err := printKBOM(&kbom, parsedFormat); err != nil { 134 | return err 135 | } 136 | 137 | return nil 138 | } 139 | 140 | func printKBOM(kbom *model.KBOM, f Format) error { 141 | writer, err := getWriter(kbom, f) 142 | if err != nil { 143 | return err 144 | } 145 | defer writer.Close() 146 | 147 | switch format { 148 | case JSONFormat.Name: 149 | enc := json.NewEncoder(writer) 150 | enc.SetIndent("", " ") 151 | return enc.Encode(kbom) 152 | case YAMLFormat.Name: 153 | enc := yaml.NewEncoder(writer) 154 | enc.SetIndent(2) 155 | return enc.Encode(kbom) 156 | case CycloneDXJsonFormat.Name: 157 | cyclonexKbom := transformToCycloneDXBOM(kbom) 158 | enc := cyclonedx.NewBOMEncoder(writer, cyclonedx.BOMFileFormatJSON) 159 | enc.SetPretty(true) 160 | enc.SetEscapeHTML(false) 161 | return enc.Encode(cyclonexKbom) 162 | case CycloneDXXMLFormat.Name: 163 | cyclonexKbom := transformToCycloneDXBOM(kbom) 164 | enc := cyclonedx.NewBOMEncoder(writer, cyclonedx.BOMFileFormatXML) 165 | enc.SetPretty(true) 166 | enc.SetEscapeHTML(false) 167 | return enc.Encode(cyclonexKbom) 168 | default: 169 | return fmt.Errorf("format %q is not supported", format) 170 | } 171 | } 172 | 173 | func getWriter(kbom *model.KBOM, format Format) (io.WriteCloser, error) { 174 | switch output { 175 | case StdOutput: 176 | return out, nil 177 | case FileOutput: 178 | formattedTime := kbom.GeneratedAt.Format("2006-01-02-15-04-05") 179 | key := kbom.ID[:8] 180 | if len(kbom.Cluster.CACertDigest) > 8 { 181 | key = kbom.Cluster.CACertDigest[:8] 182 | } 183 | 184 | f, err := os.Create(path.Join(outPath, fmt.Sprintf("kbom-%s-%s.%s", key, formattedTime, format.FileExtension))) 185 | if err != nil { 186 | return nil, err 187 | } 188 | 189 | return f, nil 190 | default: 191 | return nil, fmt.Errorf("output %q is not supported", output) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /cmd/cyclonexdx.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "hash/fnv" 6 | "slices" 7 | "time" 8 | 9 | "github.com/CycloneDX/cyclonedx-go" 10 | "github.com/google/uuid" 11 | "github.com/mitchellh/hashstructure/v2" 12 | 13 | "github.com/rad-security/kbom/internal/model" 14 | ) 15 | 16 | const ( 17 | CdxPrefix = "cdx:" 18 | RADPrefix = "rad:kbom:" 19 | K8sComponentType = "k8s:component:type" 20 | K8sComponentName = "k8s:component:name" 21 | K8sComponentVersion = "k8s:component:version" 22 | 23 | ClusterType = "cluster" 24 | NodeType = "node" 25 | ContainerType = "container" 26 | ) 27 | 28 | func transformToCycloneDXBOM(kbom *model.KBOM) *cyclonedx.BOM { //nolint:funlen 29 | cdxBOM := cyclonedx.NewBOM() 30 | 31 | cdxBOM.SerialNumber = uuid.New().URN() 32 | cdxBOM.Metadata = &cyclonedx.Metadata{ 33 | Timestamp: time.Now().Format(time.RFC3339), 34 | Tools: &[]cyclonedx.Tool{ 35 | { 36 | Vendor: kbom.GeneratedBy.Vendor, 37 | Name: kbom.GeneratedBy.Name, 38 | Version: kbom.GeneratedBy.Version, 39 | }, 40 | }, 41 | } 42 | 43 | components := []cyclonedx.Component{} 44 | dependencies := []cyclonedx.Dependency{} 45 | clusterProperties := []cyclonedx.Property{ 46 | { 47 | Name: CdxPrefix + K8sComponentType, 48 | Value: ClusterType, 49 | }, 50 | { 51 | Name: CdxPrefix + "k8s:component:name", 52 | Value: kbom.Cluster.Name, 53 | }, 54 | { 55 | Name: RADPrefix + "k8s:cluster:nodes", 56 | Value: fmt.Sprintf("%d", kbom.Cluster.NodesCount), 57 | }, 58 | } 59 | 60 | if kbom.Cluster.Location.Name != "" && kbom.Cluster.Location.Name != "unknown" { 61 | clusterProperties = append(clusterProperties, cyclonedx.Property{ 62 | Name: RADPrefix + "k8s:cluster:location:name", 63 | Value: kbom.Cluster.Location.Name, 64 | }) 65 | } 66 | 67 | if kbom.Cluster.Location.Region != "" { 68 | clusterProperties = append(clusterProperties, cyclonedx.Property{ 69 | Name: RADPrefix + "k8s:cluster:location:region", 70 | Value: kbom.Cluster.Location.Region, 71 | }) 72 | } 73 | 74 | if kbom.Cluster.Location.Zone != "" { 75 | clusterProperties = append(clusterProperties, cyclonedx.Property{ 76 | Name: RADPrefix + "k8s:cluster:location:zone", 77 | Value: kbom.Cluster.Location.Zone, 78 | }) 79 | } 80 | 81 | clusterComponent := cyclonedx.Component{ 82 | BOMRef: kbom.Cluster.BOMRef(), 83 | Type: cyclonedx.ComponentTypePlatform, 84 | Name: kbom.Cluster.BOMName(), 85 | Version: kbom.Cluster.K8sVersion, 86 | Properties: &clusterProperties, 87 | } 88 | cdxBOM.Metadata.Component = &clusterComponent 89 | 90 | clusterDependencies := make(map[string]string) 91 | for i := range kbom.Cluster.Nodes { 92 | n := kbom.Cluster.Nodes[i] 93 | bomRef := id(n) 94 | components = append(components, cyclonedx.Component{ 95 | BOMRef: bomRef, 96 | Type: cyclonedx.ComponentTypePlatform, 97 | Name: n.Name, 98 | Properties: &[]cyclonedx.Property{ 99 | { 100 | Name: CdxPrefix + K8sComponentType, 101 | Value: NodeType, 102 | }, 103 | { 104 | Name: CdxPrefix + K8sComponentName, 105 | Value: n.Name, 106 | }, 107 | { 108 | Name: RADPrefix + "k8s:node:osImage", 109 | Value: n.OsImage, 110 | }, 111 | { 112 | Name: RADPrefix + "k8s:node:arch", 113 | Value: n.Architecture, 114 | }, 115 | { 116 | Name: RADPrefix + "k8s:node:kernel", 117 | Value: n.KernelVersion, 118 | }, 119 | { 120 | Name: RADPrefix + "k8s:node:bootId", 121 | Value: n.BootID, 122 | }, 123 | { 124 | Name: RADPrefix + "k8s:node:type", 125 | Value: n.Type, 126 | }, 127 | { 128 | Name: RADPrefix + "k8s:node:operatingSystem", 129 | Value: n.OperatingSystem, 130 | }, 131 | { 132 | Name: RADPrefix + "k8s:node:machineId", 133 | Value: n.MachineID, 134 | }, 135 | { 136 | Name: RADPrefix + "k8s:node:hostname", 137 | Value: n.Hostname, 138 | }, 139 | { 140 | Name: RADPrefix + "k8s:node:containerRuntimeVersion", 141 | Value: n.ContainerRuntimeVersion, 142 | }, 143 | { 144 | Name: RADPrefix + "k8s:node:kubeletVersion", 145 | Value: n.KubeletVersion, 146 | }, 147 | { 148 | Name: RADPrefix + "k8s:node:kubeProxyVersion", 149 | Value: n.KubeProxyVersion, 150 | }, 151 | { 152 | Name: RADPrefix + "k8s:node:capacity:cpu", 153 | Value: n.Capacity.CPU, 154 | }, 155 | { 156 | Name: RADPrefix + "k8s:node:capacity:memory", 157 | Value: n.Capacity.Memory, 158 | }, 159 | { 160 | Name: RADPrefix + "k8s:node:capacity:pods", 161 | Value: n.Capacity.Pods, 162 | }, 163 | { 164 | Name: RADPrefix + "k8s:node:capacity:ephemeralStorage", 165 | Value: n.Capacity.EphemeralStorage, 166 | }, 167 | { 168 | Name: RADPrefix + "k8s:node:allocatable:cpu", 169 | Value: n.Allocatable.CPU, 170 | }, 171 | { 172 | Name: RADPrefix + "k8s:node:allocatable:memory", 173 | Value: n.Allocatable.Memory, 174 | }, 175 | { 176 | Name: RADPrefix + "k8s:node:allocatable:pods", 177 | Value: n.Allocatable.Pods, 178 | }, 179 | { 180 | Name: RADPrefix + "k8s:node:allocatable:ephemeralStorage", 181 | Value: n.Allocatable.EphemeralStorage, 182 | }, 183 | }, 184 | }) 185 | clusterDependencies[bomRef] = bomRef 186 | } 187 | 188 | for _, img := range kbom.Cluster.Components.Images { 189 | bomRef := img.PkgID() 190 | container := cyclonedx.Component{ 191 | BOMRef: bomRef, 192 | Type: cyclonedx.ComponentTypeContainer, 193 | Name: img.Name, 194 | Version: img.Digest, 195 | PackageURL: bomRef, 196 | Properties: &[]cyclonedx.Property{ 197 | { 198 | Name: CdxPrefix + K8sComponentType, 199 | Value: ContainerType, 200 | }, 201 | { 202 | Name: CdxPrefix + K8sComponentName, 203 | Value: img.Name, 204 | }, 205 | { 206 | Name: RADPrefix + "pkg:type", 207 | Value: "oci", 208 | }, 209 | { 210 | Name: RADPrefix + "pkg:name", 211 | Value: img.Name, 212 | }, 213 | { 214 | Name: RADPrefix + "pkg:version", 215 | Value: img.Version, 216 | }, 217 | { 218 | Name: RADPrefix + "pkg:digest", 219 | Value: img.Digest, 220 | }, 221 | }, 222 | } 223 | 224 | components = append(components, container) 225 | 226 | if img.ControlPlane { 227 | clusterDependencies[bomRef] = bomRef 228 | } 229 | } 230 | 231 | for _, resList := range kbom.Cluster.Components.Resources { 232 | for _, res := range resList.Resources { 233 | properties := []cyclonedx.Property{ 234 | { 235 | Name: CdxPrefix + K8sComponentType, 236 | Value: resList.Kind, 237 | }, 238 | { 239 | Name: CdxPrefix + K8sComponentName, 240 | Value: res.Name, 241 | }, 242 | { 243 | Name: RADPrefix + "k8s:component:apiVersion", 244 | Value: resList.APIVersion, 245 | }, 246 | } 247 | 248 | if version, ok := res.AdditionalProperties["version"]; ok { 249 | properties = append(properties, cyclonedx.Property{ 250 | Name: RADPrefix + K8sComponentVersion, 251 | Value: version, 252 | }) 253 | } 254 | 255 | if resList.Namespaced { 256 | properties = append(properties, cyclonedx.Property{ 257 | Name: RADPrefix + "k8s:component:namespace", 258 | Value: res.Namespace, 259 | }) 260 | } 261 | 262 | resource := cyclonedx.Component{ 263 | BOMRef: id(res), 264 | Type: cyclonedx.ComponentTypeApplication, // TODO: this is not perfect but we don't have a better option 265 | Name: res.Name, 266 | Version: res.APIVersion, 267 | Properties: &properties, 268 | } 269 | 270 | components = append(components, resource) 271 | } 272 | } 273 | 274 | clusterDependenciesArr := make([]string, 0) 275 | for _, dep := range clusterDependencies { 276 | clusterDependenciesArr = append(clusterDependenciesArr, dep) 277 | } 278 | slices.Sort(clusterDependenciesArr) 279 | 280 | dependencies = append(dependencies, 281 | cyclonedx.Dependency{ 282 | Ref: clusterComponent.BOMRef, 283 | Dependencies: &clusterDependenciesArr, 284 | }, 285 | ) 286 | 287 | cdxBOM.Components = &components 288 | cdxBOM.Dependencies = &dependencies 289 | 290 | return cdxBOM 291 | } 292 | 293 | func id(obj interface{}) string { 294 | f, err := hashstructure.Hash(obj, hashstructure.FormatV2, &hashstructure.HashOptions{ 295 | ZeroNil: true, 296 | SlicesAsSets: true, 297 | Hasher: fnv.New64(), 298 | }) 299 | 300 | // this should never happen, but if it does, we don't want to crash - use empty string 301 | if err != nil { 302 | fmt.Printf("failed to hash object: %v", err) 303 | return "" 304 | } 305 | 306 | return fmt.Sprintf("%016x", f) 307 | } 308 | -------------------------------------------------------------------------------- /cmd/schema_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestRunGenerateSchema(t *testing.T) { 11 | mock := &stdoutMock{buf: bytes.Buffer{}} 12 | out = mock 13 | 14 | err := runGenerateSchema(nil, []string{}) 15 | assert.NoError(t, err) 16 | 17 | assert.Equal(t, expectedSchema, mock.buf.String()) 18 | } 19 | 20 | type stdoutMock struct { 21 | buf bytes.Buffer 22 | } 23 | 24 | func (m *stdoutMock) Write(p []byte) (n int, err error) { 25 | return m.buf.Write(p) 26 | } 27 | 28 | func (m *stdoutMock) Close() error { 29 | return nil 30 | } 31 | 32 | var expectedSchema = `{ 33 | "$schema": "https://json-schema.org/draft/2020-12/schema", 34 | "$id": "https://github.com/rad-security/kbom/internal/model/kbom", 35 | "$ref": "#/$defs/KBOM", 36 | "$defs": { 37 | "Capacity": { 38 | "properties": { 39 | "cpu": { 40 | "type": "string" 41 | }, 42 | "memory": { 43 | "type": "string" 44 | }, 45 | "pods": { 46 | "type": "string" 47 | }, 48 | "ephemeral_storage": { 49 | "type": "string" 50 | } 51 | }, 52 | "additionalProperties": false, 53 | "type": "object", 54 | "required": [ 55 | "cpu", 56 | "memory", 57 | "pods", 58 | "ephemeral_storage" 59 | ] 60 | }, 61 | "Cluster": { 62 | "properties": { 63 | "name": { 64 | "type": "string" 65 | }, 66 | "ca_cert_digest": { 67 | "type": "string" 68 | }, 69 | "k8s_version": { 70 | "type": "string" 71 | }, 72 | "cni_version": { 73 | "type": "string" 74 | }, 75 | "location": { 76 | "$ref": "#/$defs/Location" 77 | }, 78 | "nodes_count": { 79 | "type": "integer" 80 | }, 81 | "nodes": { 82 | "items": { 83 | "$ref": "#/$defs/Node" 84 | }, 85 | "type": "array" 86 | }, 87 | "components": { 88 | "$ref": "#/$defs/Components" 89 | } 90 | }, 91 | "additionalProperties": false, 92 | "type": "object", 93 | "required": [ 94 | "name", 95 | "ca_cert_digest", 96 | "k8s_version", 97 | "location", 98 | "nodes_count", 99 | "nodes", 100 | "components" 101 | ] 102 | }, 103 | "Components": { 104 | "properties": { 105 | "images": { 106 | "items": { 107 | "$ref": "#/$defs/Image" 108 | }, 109 | "type": "array" 110 | }, 111 | "resources": { 112 | "additionalProperties": { 113 | "$ref": "#/$defs/ResourceList" 114 | }, 115 | "type": "object" 116 | } 117 | }, 118 | "additionalProperties": false, 119 | "type": "object", 120 | "required": [ 121 | "resources" 122 | ] 123 | }, 124 | "Image": { 125 | "properties": { 126 | "full_name": { 127 | "type": "string" 128 | }, 129 | "name": { 130 | "type": "string" 131 | }, 132 | "version": { 133 | "type": "string" 134 | }, 135 | "digest": { 136 | "type": "string" 137 | } 138 | }, 139 | "additionalProperties": false, 140 | "type": "object", 141 | "required": [ 142 | "full_name", 143 | "name", 144 | "version", 145 | "digest" 146 | ] 147 | }, 148 | "KBOM": { 149 | "properties": { 150 | "id": { 151 | "type": "string" 152 | }, 153 | "bom_format": { 154 | "type": "string" 155 | }, 156 | "spec_version": { 157 | "type": "string" 158 | }, 159 | "generated_at": { 160 | "type": "string", 161 | "format": "date-time" 162 | }, 163 | "generated_by": { 164 | "$ref": "#/$defs/Tool" 165 | }, 166 | "cluster": { 167 | "$ref": "#/$defs/Cluster" 168 | } 169 | }, 170 | "additionalProperties": false, 171 | "type": "object", 172 | "required": [ 173 | "id", 174 | "bom_format", 175 | "spec_version", 176 | "generated_at", 177 | "generated_by", 178 | "cluster" 179 | ] 180 | }, 181 | "Location": { 182 | "properties": { 183 | "name": { 184 | "type": "string" 185 | }, 186 | "region": { 187 | "type": "string" 188 | }, 189 | "zone": { 190 | "type": "string" 191 | } 192 | }, 193 | "additionalProperties": false, 194 | "type": "object", 195 | "required": [ 196 | "name", 197 | "region", 198 | "zone" 199 | ] 200 | }, 201 | "Node": { 202 | "properties": { 203 | "name": { 204 | "type": "string" 205 | }, 206 | "type": { 207 | "type": "string" 208 | }, 209 | "hostname": { 210 | "type": "string" 211 | }, 212 | "capacity": { 213 | "$ref": "#/$defs/Capacity" 214 | }, 215 | "allocatable": { 216 | "$ref": "#/$defs/Capacity" 217 | }, 218 | "labels": { 219 | "additionalProperties": { 220 | "type": "string" 221 | }, 222 | "type": "object" 223 | }, 224 | "annotations": { 225 | "additionalProperties": { 226 | "type": "string" 227 | }, 228 | "type": "object" 229 | }, 230 | "machine_id": { 231 | "type": "string" 232 | }, 233 | "architecture": { 234 | "type": "string" 235 | }, 236 | "container_runtime_version": { 237 | "type": "string" 238 | }, 239 | "boot_id": { 240 | "type": "string" 241 | }, 242 | "kernel_version": { 243 | "type": "string" 244 | }, 245 | "kube_proxy_version": { 246 | "type": "string" 247 | }, 248 | "kubelet_version": { 249 | "type": "string" 250 | }, 251 | "operating_system": { 252 | "type": "string" 253 | }, 254 | "os_image": { 255 | "type": "string" 256 | } 257 | }, 258 | "additionalProperties": false, 259 | "type": "object", 260 | "required": [ 261 | "name", 262 | "type", 263 | "hostname", 264 | "capacity", 265 | "allocatable", 266 | "labels", 267 | "annotations", 268 | "machine_id", 269 | "architecture", 270 | "container_runtime_version", 271 | "boot_id", 272 | "kernel_version", 273 | "kube_proxy_version", 274 | "kubelet_version", 275 | "operating_system", 276 | "os_image" 277 | ] 278 | }, 279 | "Resource": { 280 | "properties": { 281 | "kind": { 282 | "type": "string" 283 | }, 284 | "api_version": { 285 | "type": "string" 286 | }, 287 | "name": { 288 | "type": "string" 289 | }, 290 | "namespace": { 291 | "type": "string" 292 | }, 293 | "additional_properties": { 294 | "additionalProperties": { 295 | "type": "string" 296 | }, 297 | "type": "object" 298 | } 299 | }, 300 | "additionalProperties": false, 301 | "type": "object", 302 | "required": [ 303 | "name" 304 | ] 305 | }, 306 | "ResourceList": { 307 | "properties": { 308 | "kind": { 309 | "type": "string" 310 | }, 311 | "api_version": { 312 | "type": "string" 313 | }, 314 | "namespaced": { 315 | "type": "boolean" 316 | }, 317 | "count": { 318 | "type": "integer" 319 | }, 320 | "resources": { 321 | "items": { 322 | "$ref": "#/$defs/Resource" 323 | }, 324 | "type": "array" 325 | } 326 | }, 327 | "additionalProperties": false, 328 | "type": "object", 329 | "required": [ 330 | "kind", 331 | "api_version", 332 | "namespaced", 333 | "count" 334 | ] 335 | }, 336 | "Tool": { 337 | "properties": { 338 | "vendor": { 339 | "type": "string" 340 | }, 341 | "name": { 342 | "type": "string" 343 | }, 344 | "build_time": { 345 | "type": "string" 346 | }, 347 | "version": { 348 | "type": "string" 349 | }, 350 | "commit": { 351 | "type": "string" 352 | }, 353 | "commit_time": { 354 | "type": "string" 355 | } 356 | }, 357 | "additionalProperties": false, 358 | "type": "object", 359 | "required": [ 360 | "vendor", 361 | "name", 362 | "build_time", 363 | "version", 364 | "commit", 365 | "commit_time" 366 | ] 367 | } 368 | } 369 | } 370 | ` 371 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /internal/kube/kube.go: -------------------------------------------------------------------------------- 1 | package kube 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | "github.com/Masterminds/semver" 11 | "github.com/distribution/reference" 12 | "github.com/rs/zerolog/log" 13 | v1 "k8s.io/api/core/v1" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 16 | "k8s.io/apimachinery/pkg/runtime/schema" 17 | "k8s.io/client-go/dynamic" 18 | "k8s.io/client-go/kubernetes" 19 | _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" 20 | "k8s.io/client-go/rest" 21 | "k8s.io/client-go/tools/clientcmd" 22 | 23 | "github.com/rad-security/kbom/internal/model" 24 | ) 25 | 26 | type K8sClient interface { 27 | ClusterName(ctx context.Context) (string, error) 28 | Metadata(ctx context.Context) (string, string, error) 29 | Location(ctx context.Context) (*model.Location, error) 30 | AllImages(ctx context.Context) ([]model.Image, error) 31 | AllNodes(ctx context.Context, full bool) ([]model.Node, error) 32 | AllResources(ctx context.Context, full bool) (map[string]model.ResourceList, error) 33 | } 34 | 35 | func NewClient(k8sContext string) (K8sClient, error) { 36 | currentK8sContext := k8sContext 37 | 38 | cfg, err := rest.InClusterConfig() 39 | if err != nil { 40 | kubeConfigPath := os.Getenv("KUBECONFIG") 41 | if kubeConfigPath == "" { 42 | kubeConfigPath = os.Getenv("HOME") + "/.kube/config" 43 | } 44 | 45 | clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 46 | &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeConfigPath}, 47 | &clientcmd.ConfigOverrides{ 48 | CurrentContext: k8sContext, 49 | }) 50 | 51 | rawConfig, err := clientConfig.RawConfig() 52 | if err != nil { 53 | return nil, fmt.Errorf("failed to get kubernetes out-cluster client: %w", err) 54 | } 55 | 56 | if k8sContext == "" { 57 | currentK8sContext = rawConfig.CurrentContext 58 | } 59 | 60 | cfg, err = clientConfig.ClientConfig() 61 | if err != nil { 62 | return nil, fmt.Errorf("failed to get kubernetes out-cluster client: %w", err) 63 | } 64 | } 65 | 66 | clientset, err := kubernetes.NewForConfig(cfg) 67 | if err != nil { 68 | return nil, fmt.Errorf("can not create kubernetes client: %w", err) 69 | } 70 | 71 | dynamicClient, err := dynamic.NewForConfig(cfg) 72 | if err != nil { 73 | return nil, fmt.Errorf("can not create kubernetes dynamic client: %w", err) 74 | } 75 | 76 | rest.SetDefaultWarningHandler(rest.NoWarnings{}) 77 | 78 | return &k8sDB{ 79 | k8sContext: currentK8sContext, 80 | cfg: cfg, 81 | client: clientset, 82 | dynamicClient: dynamicClient, 83 | }, nil 84 | } 85 | 86 | type k8sDB struct { 87 | k8sContext string 88 | cfg *rest.Config 89 | client kubernetes.Interface 90 | dynamicClient dynamic.Interface 91 | } 92 | 93 | func (k *k8sDB) ClusterName(ctx context.Context) (string, error) { 94 | // TODO: find a better way to get cluster name, but for now use cluster context name 95 | return k.k8sContext, nil 96 | } 97 | 98 | func (k *k8sDB) Location(ctx context.Context) (*model.Location, error) { 99 | // fetch first node 100 | node, err := k.client.CoreV1().Nodes().List(ctx, metav1.ListOptions{Limit: 1}) 101 | if err != nil { 102 | return nil, fmt.Errorf("failed to list nodes: %v", err) 103 | } 104 | 105 | if len(node.Items) == 0 { 106 | return nil, fmt.Errorf("no node found") 107 | } 108 | 109 | // get location from node labels 110 | return &model.Location{ 111 | Name: getCloudName(node.Items[0].Labels), 112 | Region: getLabelValue(node.Items[0].Labels, "topology.kubernetes.io/region"), 113 | Zone: getLabelValue(node.Items[0].Labels, "topology.kubernetes.io/zone"), 114 | }, nil 115 | } 116 | 117 | // AllNodes returns all nodes in the cluster 118 | func (k *k8sDB) AllNodes(ctx context.Context, full bool) ([]model.Node, error) { 119 | nodes, err := k.client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) 120 | if err != nil { 121 | return nil, fmt.Errorf("failed to list nodes: %v", err) 122 | } 123 | 124 | modelNodes := make([]model.Node, 0) 125 | for i := range nodes.Items { 126 | var labels, annotations map[string]string 127 | if full { 128 | labels = nodes.Items[i].Labels 129 | annotations = nodes.Items[i].Annotations 130 | } 131 | 132 | modelNodes = append(modelNodes, model.Node{ 133 | Name: nodes.Items[i].Name, 134 | OsImage: nodes.Items[i].Status.NodeInfo.OSImage, 135 | Hostname: getLabelValue(nodes.Items[i].Labels, "kubernetes.io/hostname"), 136 | Type: getLabelValue(nodes.Items[i].Labels, "node.kubernetes.io/instance-type"), 137 | Capacity: &model.Capacity{ 138 | CPU: nodes.Items[i].Status.Capacity.Cpu().String(), 139 | Memory: nodes.Items[i].Status.Capacity.Memory().String(), 140 | EphemeralStorage: nodes.Items[i].Status.Capacity.StorageEphemeral().String(), 141 | Pods: nodes.Items[i].Status.Capacity.Pods().String(), 142 | }, 143 | Allocatable: &model.Capacity{ 144 | CPU: nodes.Items[i].Status.Allocatable.Cpu().String(), 145 | Memory: nodes.Items[i].Status.Allocatable.Memory().String(), 146 | EphemeralStorage: nodes.Items[i].Status.Allocatable.StorageEphemeral().String(), 147 | Pods: nodes.Items[i].Status.Allocatable.Pods().String(), 148 | }, 149 | Labels: labels, 150 | Annotations: annotations, 151 | MachineID: nodes.Items[i].Status.NodeInfo.MachineID, 152 | Architecture: nodes.Items[i].Status.NodeInfo.Architecture, 153 | KernelVersion: nodes.Items[i].Status.NodeInfo.KernelVersion, 154 | ContainerRuntimeVersion: nodes.Items[i].Status.NodeInfo.ContainerRuntimeVersion, 155 | BootID: nodes.Items[i].Status.NodeInfo.BootID, 156 | KubeProxyVersion: nodes.Items[i].Status.NodeInfo.KubeProxyVersion, 157 | KubeletVersion: nodes.Items[i].Status.NodeInfo.KubeletVersion, 158 | OperatingSystem: nodes.Items[i].Status.NodeInfo.OperatingSystem, 159 | }) 160 | } 161 | 162 | return modelNodes, nil 163 | } 164 | 165 | func (k *k8sDB) AllImages(ctx context.Context) ([]model.Image, error) { 166 | namespaces, err := k.client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) 167 | if err != nil { 168 | return nil, fmt.Errorf("failed to list namespaces: %v", err) 169 | } 170 | 171 | images := make(map[string]model.Image) 172 | for i := range namespaces.Items { 173 | pods, err := k.client.CoreV1().Pods(namespaces.Items[i].Name).List(ctx, metav1.ListOptions{}) 174 | if err != nil { 175 | return nil, fmt.Errorf("failed to list pods: %w", err) 176 | } 177 | namespace := namespaces.Items[i].Name 178 | 179 | log.Debug().Str("namespace", namespace).Int("count", len(pods.Items)).Msg("Found pods in namespace") 180 | 181 | for j := range pods.Items { 182 | pod := pods.Items[j] 183 | 184 | for k := range pod.Spec.InitContainers { 185 | img, err := containerToImage(pod.Spec.InitContainers[k].Image, 186 | pod.Spec.InitContainers[k].Name, pod.Status.InitContainerStatuses, namespace) 187 | if err != nil { 188 | return nil, err 189 | } 190 | 191 | images[img.FullName] = *img 192 | } 193 | 194 | for k := range pod.Spec.Containers { 195 | img, err := containerToImage(pod.Spec.Containers[k].Image, pod.Spec.Containers[k].Name, pod.Status.ContainerStatuses, namespace) 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | images[img.FullName] = *img 201 | } 202 | 203 | for k := range pod.Spec.EphemeralContainers { 204 | img, err := containerToImage(pod.Spec.EphemeralContainers[k].Image, 205 | pod.Spec.EphemeralContainers[k].Name, pod.Status.EphemeralContainerStatuses, namespace) 206 | if err != nil { 207 | return nil, err 208 | } 209 | 210 | images[img.FullName] = *img 211 | } 212 | } 213 | } 214 | 215 | toReturn := make([]model.Image, 0) 216 | for _, v := range images { 217 | toReturn = append(toReturn, v) 218 | } 219 | 220 | return toReturn, nil 221 | } 222 | 223 | func containerToImage(img, imgName string, statuses []v1.ContainerStatus, namespace string) (*model.Image, error) { 224 | if img == "" { 225 | return nil, fmt.Errorf("container %s has no image", img) 226 | } 227 | 228 | named, err := reference.ParseNormalizedNamed(img) 229 | if err != nil { 230 | return nil, err 231 | } 232 | 233 | controlPlane := false 234 | if namespace == "kube-system" { 235 | controlPlane = true 236 | } 237 | 238 | res := &model.Image{ 239 | FullName: img, 240 | ControlPlane: controlPlane, 241 | } 242 | 243 | res.Name = named.Name() 244 | tagged, ok := named.(reference.Tagged) 245 | if ok { 246 | res.Version = tagged.Tag() 247 | } 248 | 249 | digested, ok := named.(reference.Digested) 250 | if ok { 251 | res.Digest = digested.Digest().String() 252 | } 253 | 254 | // search in statuses for ImageID to get digest 255 | for i := range statuses { 256 | if imgName == statuses[i].Name { 257 | if statuses[i].State.Running == nil && statuses[i].State.Terminated == nil { 258 | break // We can get valid digest only from running or terminated containers 259 | } 260 | if strings.Contains(statuses[i].ImageID, "@") { 261 | res.Digest = strings.Split(statuses[i].ImageID, "@")[1] 262 | } else if strings.HasPrefix(statuses[i].ImageID, "sha256:") { 263 | res.Digest = statuses[i].ImageID 264 | } 265 | break 266 | } 267 | } 268 | 269 | return res, nil 270 | } 271 | 272 | // Metadata returns the kubernetes version 273 | func (k *k8sDB) Metadata(ctx context.Context) (k8sVersion, caDigest string, err error) { 274 | if _, err := rest.InClusterConfig(); err != nil { 275 | hash := sha256.Sum256(k.cfg.CAData) 276 | caDigest = fmt.Sprintf("%x", hash[:]) 277 | } else { 278 | caConfigMap, err := k.client.CoreV1().ConfigMaps("kube-system").Get(ctx, "kube-root-ca.crt", metav1.GetOptions{}) 279 | if err != nil { 280 | log.Debug().Err(err).Msg("failed to get kube-root-ca.crt") 281 | } else { 282 | caCert, ok := caConfigMap.Data["ca.crt"] 283 | if !ok { 284 | return "", "", fmt.Errorf("can't find 'ca.crt' in configMap 'kube-root-ca.crt'") 285 | } 286 | 287 | caDigest = fmt.Sprintf("%x", sha256.Sum256([]byte(caCert))) 288 | } 289 | } 290 | 291 | version, err := k.client.Discovery().ServerVersion() 292 | if err != nil { 293 | return caDigest, "", fmt.Errorf("error getting k8s version: %w", err) 294 | } 295 | 296 | ver := strings.Trim(version.GitVersion, "v") 297 | 298 | sVer, err := semver.NewVersion(ver) 299 | if err != nil { 300 | return caDigest, "", fmt.Errorf("error parsing k8s version: %w", err) 301 | } 302 | 303 | ver = fmt.Sprintf("%d.%d.%d", sVer.Major(), sVer.Minor(), sVer.Patch()) 304 | 305 | return ver, caDigest, nil 306 | } 307 | 308 | func (k *k8sDB) AllResources(ctx context.Context, full bool) (map[string]model.ResourceList, error) { 309 | apiResourceList, err := k.client.Discovery().ServerPreferredResources() 310 | if err != nil { 311 | return nil, fmt.Errorf("failed to get api groups: %w", err) 312 | } 313 | 314 | resourceMap := make(map[string]model.ResourceList) 315 | for _, apiResource := range apiResourceList { 316 | gv, err := schema.ParseGroupVersion(apiResource.GroupVersion) 317 | if err != nil { 318 | return nil, fmt.Errorf("failed to parse group version: %w", err) 319 | } 320 | 321 | for i := range apiResource.APIResources { 322 | res := apiResource.APIResources[i] 323 | gvr := schema.GroupVersionResource{ 324 | Group: gv.Group, 325 | Version: gv.Version, 326 | Resource: res.Name, 327 | } 328 | 329 | resourceList, err := k.dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{}) 330 | if err != nil { 331 | log.Debug().Err(err).Interface("gvr", gvr).Msg("Failed to list resources") 332 | continue 333 | } 334 | 335 | log.Debug().Interface("gvr", gvr).Int("count", len(resourceList.Items)).Msg("Found resources") 336 | 337 | if len(resourceList.Items) > 0 { 338 | resourceMap[gvr.String()] = model.ResourceList{ 339 | Kind: resourceList.Items[0].GetKind(), 340 | APIVersion: gvr.GroupVersion().String(), 341 | Namespaced: res.Namespaced, 342 | ResourcesCount: len(resourceList.Items), 343 | Resources: make([]model.Resource, 0), 344 | } 345 | } 346 | 347 | if full { 348 | for _, item := range resourceList.Items { 349 | res := model.Resource{ 350 | Name: item.GetName(), 351 | Namespace: item.GetNamespace(), 352 | AdditionalProperties: map[string]string{}, 353 | } 354 | if version, ok := getVersion(item); ok { 355 | res.AdditionalProperties["version"] = version 356 | } 357 | val := resourceMap[gvr.String()] 358 | val.Resources = append(val.Resources, res) 359 | resourceMap[gvr.String()] = val 360 | } 361 | } 362 | } 363 | } 364 | 365 | return resourceMap, nil 366 | } 367 | 368 | func getVersion(item unstructured.Unstructured) (version string, ok bool) { 369 | obj := item.Object 370 | if obj == nil { 371 | return "", false 372 | } 373 | 374 | spec, ok := obj["spec"].(map[string]interface{}) 375 | if !ok { 376 | return "", false 377 | } 378 | 379 | version, ok = spec["version"].(string) 380 | return 381 | } 382 | 383 | func getLabelValue(labels map[string]string, key string) string { 384 | for k, v := range labels { 385 | if k == key { 386 | return v 387 | } 388 | } 389 | 390 | return "" 391 | } 392 | 393 | func getCloudName(labels map[string]string) string { 394 | if labels == nil { 395 | return "unknown" 396 | } 397 | 398 | if _, ok := labels["k8s.io/cloud-provider-aws"]; ok { 399 | return "aws" 400 | } 401 | 402 | if _, ok := labels["topology.gke.io/zone"]; ok { 403 | return "gcloud" 404 | } 405 | 406 | if _, ok := labels["kubernetes.azure.com/cluster"]; ok { 407 | return "azure" 408 | } 409 | 410 | return "unknown" 411 | } 412 | -------------------------------------------------------------------------------- /cmd/generate_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/rad-security/kbom/internal/kube" 14 | "github.com/rad-security/kbom/internal/model" 15 | ) 16 | 17 | func TestGenerateKBOM(t *testing.T) { 18 | type testCase struct { 19 | name string 20 | 21 | // mocks 22 | clientMock kube.K8sClient 23 | idMock string 24 | timeMock string 25 | 26 | // flags 27 | output string 28 | format string 29 | 30 | expectedOut string 31 | expectedErr error 32 | } 33 | 34 | testCases := []testCase{ 35 | { 36 | name: "metadata error", 37 | clientMock: &mockedK8sClient{ 38 | metadata: func(context.Context) (string, string, error) { 39 | return "", "", fmt.Errorf("metadata error") 40 | }, 41 | }, 42 | expectedErr: fmt.Errorf("metadata error"), 43 | }, 44 | { 45 | name: "location error", 46 | clientMock: &mockedK8sClient{ 47 | location: func(context.Context) (*model.Location, error) { 48 | return nil, fmt.Errorf("location error") 49 | }, 50 | }, 51 | expectedErr: fmt.Errorf("location error"), 52 | }, 53 | { 54 | name: "all nodes error", 55 | clientMock: &mockedK8sClient{ 56 | allNodes: func(context.Context, bool) ([]model.Node, error) { 57 | return nil, fmt.Errorf("all nodes error") 58 | }, 59 | }, 60 | expectedErr: fmt.Errorf("all nodes error"), 61 | }, 62 | { 63 | name: "all resources error", 64 | clientMock: &mockedK8sClient{ 65 | allResources: func(context.Context, bool) (map[string]model.ResourceList, error) { 66 | return nil, fmt.Errorf("all resources error") 67 | }, 68 | }, 69 | expectedErr: fmt.Errorf("all resources error"), 70 | }, 71 | { 72 | name: "all images error", 73 | clientMock: &mockedK8sClient{ 74 | allImages: func(context.Context) ([]model.Image, error) { 75 | return nil, fmt.Errorf("all images error") 76 | }, 77 | }, 78 | expectedErr: fmt.Errorf("all images error"), 79 | }, 80 | { 81 | name: "print KBOM - stdout - wrong format", 82 | clientMock: &mockedK8sClient{}, 83 | timeMock: "2023-04-26T10:00:00.000000+00:00", 84 | idMock: "00000001", 85 | format: "wrong", 86 | expectedErr: fmt.Errorf("format \"wrong\" is not supported"), 87 | }, 88 | { 89 | name: "print KBOM - wrong output - JSON", 90 | clientMock: &mockedK8sClient{}, 91 | timeMock: "2023-04-26T10:00:00.000000+00:00", 92 | idMock: "00000001", 93 | output: "wrong", 94 | expectedErr: fmt.Errorf("output \"wrong\" is not supported"), 95 | }, 96 | { 97 | name: "print full KBOM - stdout - json", 98 | clientMock: &mockedK8sClient{ 99 | clusterName: func(context.Context) (string, error) { 100 | return "test-cluster", nil 101 | }, 102 | metadata: func(context.Context) (string, string, error) { 103 | return "012345678", "1.25.1", nil 104 | }, 105 | location: func(context.Context) (*model.Location, error) { 106 | return &model.Location{ 107 | Name: "aws", 108 | Region: "us-east-1", 109 | Zone: "us-east-1a", 110 | }, nil 111 | }, 112 | allNodes: func(context.Context, bool) ([]model.Node, error) { 113 | return []model.Node{ 114 | { 115 | Name: "ip-10-0-65-00.us-east-1.compute.internal", 116 | Type: "t3.small", 117 | Hostname: "ip-10-0-65-00.us-east-1.compute.internal", 118 | Capacity: &model.Capacity{ 119 | CPU: "2", 120 | Memory: "1970512Ki", 121 | Pods: "11", 122 | EphemeralStorage: "524275692Ki", 123 | }, 124 | Allocatable: &model.Capacity{ 125 | CPU: "1930m", 126 | Memory: "1483088Ki", 127 | Pods: "11", 128 | EphemeralStorage: "482098735124", 129 | }, 130 | Labels: map[string]string{ 131 | "beta.kubernetes.io/arch": "amd64", 132 | "beta.kubernetes.io/instance-type": "t3.small", 133 | "beta.kubernetes.io/os": "linux", 134 | "topology.kubernetes.io/region": "us-west-2", 135 | "topology.kubernetes.io/zone": "us-west-2a", 136 | }, 137 | Annotations: map[string]string{ 138 | "node.alpha.kubernetes.io/ttl": "0", 139 | }, 140 | MachineID: "00001", 141 | Architecture: "amd64", 142 | ContainerRuntimeVersion: "containerd://1.6.8+bottlerocket", 143 | BootID: "00001", 144 | KernelVersion: "5.15.59", 145 | KubeProxyVersion: "v1.24.6", 146 | KubeletVersion: "v1.24.6", 147 | OperatingSystem: "linux", 148 | OsImage: "Bottlerocket OS 1.11.1 (aws-k8s-1.24)", 149 | }, 150 | { 151 | Name: "ip-10-0-65-01.us-east-1.compute.internal", 152 | Type: "t3.small", 153 | Hostname: "ip-10-0-65-01.us-east-1.compute.internal", 154 | Capacity: &model.Capacity{ 155 | CPU: "2", 156 | Memory: "1970512Ki", 157 | Pods: "11", 158 | EphemeralStorage: "524275692Ki", 159 | }, 160 | Allocatable: &model.Capacity{ 161 | CPU: "1930m", 162 | Memory: "1483088Ki", 163 | Pods: "11", 164 | EphemeralStorage: "482098735124", 165 | }, 166 | Labels: map[string]string{ 167 | "beta.kubernetes.io/arch": "amd64", 168 | "beta.kubernetes.io/instance-type": "t3.small", 169 | "beta.kubernetes.io/os": "linux", 170 | "topology.kubernetes.io/region": "us-west-2", 171 | "topology.kubernetes.io/zone": "us-west-2a", 172 | }, 173 | Annotations: map[string]string{ 174 | "node.alpha.kubernetes.io/ttl": "0", 175 | }, 176 | MachineID: "00002", 177 | Architecture: "amd64", 178 | ContainerRuntimeVersion: "containerd://1.6.8+bottlerocket", 179 | BootID: "00002", 180 | KernelVersion: "5.15.59", 181 | KubeProxyVersion: "v1.24.6", 182 | KubeletVersion: "v1.24.6", 183 | OperatingSystem: "linux", 184 | OsImage: "Bottlerocket OS 1.11.1 (aws-k8s-1.24)", 185 | }, 186 | }, nil 187 | }, 188 | allImages: func(context.Context) ([]model.Image, error) { 189 | return []model.Image{ 190 | { 191 | Name: "nginx", 192 | Version: "1.17.1", 193 | FullName: "nginx:1.17.1", 194 | Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000001", 195 | }, 196 | { 197 | Name: "redis", 198 | Version: "7.0.1", 199 | FullName: "redis:7.0.1", 200 | Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000002", 201 | }, 202 | }, nil 203 | }, 204 | allResources: func(context.Context, bool) (map[string]model.ResourceList, error) { 205 | return map[string]model.ResourceList{ 206 | "/v1, Resource=namespaces": { 207 | Kind: "Namespace", 208 | APIVersion: "v1", 209 | Namespaced: false, 210 | ResourcesCount: 2, 211 | Resources: []model.Resource{ 212 | { 213 | Name: "backend", 214 | AdditionalProperties: map[string]string{"version": "v1.0.0"}, 215 | }, 216 | { 217 | Name: "frontend", 218 | AdditionalProperties: map[string]string{"version": "v2.0.0"}, 219 | }, 220 | }, 221 | }, 222 | }, nil 223 | }, 224 | }, 225 | timeMock: "2023-04-26T10:00:00.000000+00:00", 226 | idMock: "00000001", 227 | expectedErr: nil, 228 | expectedOut: expectedOutJSON, 229 | }, 230 | { 231 | name: "print KBOM - stdout - yaml", 232 | clientMock: &mockedK8sClient{}, 233 | timeMock: "2023-04-26T10:00:00.000000+00:00", 234 | idMock: "00000001", 235 | format: YAMLFormat.Name, 236 | expectedOut: expectedOutYAML, 237 | }, 238 | { 239 | name: "print KBOM - file - yaml", 240 | clientMock: &mockedK8sClient{}, 241 | timeMock: "2023-04-26T10:00:00.000000+00:00", 242 | idMock: "00000001", 243 | format: YAMLFormat.Name, 244 | output: FileOutput, 245 | expectedOut: expectedOutYAML, 246 | }, 247 | } 248 | 249 | for _, tc := range testCases { 250 | t.Run(tc.name, func(t *testing.T) { 251 | mock := &stdoutMock{buf: bytes.Buffer{}} 252 | out = mock 253 | kbomID = tc.idMock 254 | if tc.timeMock != "" { 255 | mockedTime, err := time.Parse(time.RFC3339, tc.timeMock) 256 | assert.NoError(t, err) 257 | generatedAt = mockedTime 258 | } 259 | 260 | if tc.format != "" { 261 | format = tc.format 262 | } else { 263 | format = JSONFormat.Name 264 | } 265 | 266 | if tc.output != "" { 267 | output = tc.output 268 | } else { 269 | output = StdOutput 270 | } 271 | 272 | err := generateKBOM(tc.clientMock) 273 | if tc.expectedErr != nil { 274 | assert.EqualError(t, err, tc.expectedErr.Error()) 275 | } else { 276 | assert.NoError(t, err) 277 | } 278 | 279 | if output == FileOutput { 280 | filename := fmt.Sprintf("kbom-%s-2023-04-26-10-00-00.%s", mockCACert[:8], format) 281 | assert.FileExists(t, filename) 282 | file, err := os.Open(filename) 283 | assert.NoError(t, err) 284 | 285 | buf := new(bytes.Buffer) 286 | _, err = buf.ReadFrom(file) 287 | assert.NoError(t, err) 288 | file.Close() 289 | 290 | assert.Equal(t, tc.expectedOut, buf.String()) 291 | assert.NoError(t, os.Remove(filename)) 292 | } else { 293 | assert.Equal(t, tc.expectedOut, mock.buf.String()) 294 | } 295 | }) 296 | } 297 | } 298 | 299 | type mockedK8sClient struct { 300 | clusterName func(context.Context) (string, error) 301 | metadata func(context.Context) (string, string, error) 302 | location func(context.Context) (*model.Location, error) 303 | allImages func(context.Context) ([]model.Image, error) 304 | allNodes func(context.Context, bool) ([]model.Node, error) 305 | allResources func(context.Context, bool) (map[string]model.ResourceList, error) 306 | } 307 | 308 | func (m *mockedK8sClient) ClusterName(ctx context.Context) (clusterName string, err error) { 309 | if m.clusterName == nil { 310 | return "test-cluster", nil 311 | } 312 | return m.clusterName(ctx) 313 | } 314 | 315 | func (m *mockedK8sClient) Metadata(ctx context.Context) (ver, ca string, err error) { 316 | if m.metadata == nil { 317 | return "1.25.1", mockCACert, nil 318 | } 319 | return m.metadata(ctx) 320 | } 321 | 322 | func (m *mockedK8sClient) Location(ctx context.Context) (*model.Location, error) { 323 | if m.location == nil { 324 | return nil, nil 325 | } 326 | return m.location(ctx) 327 | } 328 | 329 | func (m *mockedK8sClient) AllImages(ctx context.Context) ([]model.Image, error) { 330 | if m.allImages == nil { 331 | return nil, nil 332 | } 333 | return m.allImages(ctx) 334 | } 335 | 336 | func (m *mockedK8sClient) AllNodes(ctx context.Context, full bool) ([]model.Node, error) { 337 | if m.allNodes == nil { 338 | return nil, nil 339 | } 340 | return m.allNodes(ctx, full) 341 | } 342 | 343 | func (m *mockedK8sClient) AllResources(ctx context.Context, full bool) (map[string]model.ResourceList, error) { 344 | if m.allResources == nil { 345 | return nil, nil 346 | } 347 | return m.allResources(ctx, full) 348 | } 349 | 350 | var mockCACert = "1234567890" 351 | 352 | var expectedOutJSON = `{ 353 | "id": "00000001", 354 | "bom_format": "rad", 355 | "spec_version": "0.3", 356 | "generated_at": "2023-04-26T10:00:00Z", 357 | "generated_by": { 358 | "vendor": "RAD Security", 359 | "name": "unknown", 360 | "build_time": "unknown", 361 | "version": "unknown", 362 | "commit": "unknown", 363 | "commit_time": "unknown" 364 | }, 365 | "cluster": { 366 | "name": "test-cluster", 367 | "ca_cert_digest": "1.25.1", 368 | "k8s_version": "012345678", 369 | "location": { 370 | "name": "aws", 371 | "region": "us-east-1", 372 | "zone": "us-east-1a" 373 | }, 374 | "nodes_count": 2, 375 | "nodes": [ 376 | { 377 | "name": "ip-10-0-65-00.us-east-1.compute.internal", 378 | "type": "t3.small", 379 | "hostname": "ip-10-0-65-00.us-east-1.compute.internal", 380 | "capacity": { 381 | "cpu": "2", 382 | "memory": "1970512Ki", 383 | "pods": "11", 384 | "ephemeral_storage": "524275692Ki" 385 | }, 386 | "allocatable": { 387 | "cpu": "1930m", 388 | "memory": "1483088Ki", 389 | "pods": "11", 390 | "ephemeral_storage": "482098735124" 391 | }, 392 | "labels": { 393 | "beta.kubernetes.io/arch": "amd64", 394 | "beta.kubernetes.io/instance-type": "t3.small", 395 | "beta.kubernetes.io/os": "linux", 396 | "topology.kubernetes.io/region": "us-west-2", 397 | "topology.kubernetes.io/zone": "us-west-2a" 398 | }, 399 | "annotations": { 400 | "node.alpha.kubernetes.io/ttl": "0" 401 | }, 402 | "machine_id": "00001", 403 | "architecture": "amd64", 404 | "container_runtime_version": "containerd://1.6.8+bottlerocket", 405 | "boot_id": "00001", 406 | "kernel_version": "5.15.59", 407 | "kube_proxy_version": "v1.24.6", 408 | "kubelet_version": "v1.24.6", 409 | "operating_system": "linux", 410 | "os_image": "Bottlerocket OS 1.11.1 (aws-k8s-1.24)" 411 | }, 412 | { 413 | "name": "ip-10-0-65-01.us-east-1.compute.internal", 414 | "type": "t3.small", 415 | "hostname": "ip-10-0-65-01.us-east-1.compute.internal", 416 | "capacity": { 417 | "cpu": "2", 418 | "memory": "1970512Ki", 419 | "pods": "11", 420 | "ephemeral_storage": "524275692Ki" 421 | }, 422 | "allocatable": { 423 | "cpu": "1930m", 424 | "memory": "1483088Ki", 425 | "pods": "11", 426 | "ephemeral_storage": "482098735124" 427 | }, 428 | "labels": { 429 | "beta.kubernetes.io/arch": "amd64", 430 | "beta.kubernetes.io/instance-type": "t3.small", 431 | "beta.kubernetes.io/os": "linux", 432 | "topology.kubernetes.io/region": "us-west-2", 433 | "topology.kubernetes.io/zone": "us-west-2a" 434 | }, 435 | "annotations": { 436 | "node.alpha.kubernetes.io/ttl": "0" 437 | }, 438 | "machine_id": "00002", 439 | "architecture": "amd64", 440 | "container_runtime_version": "containerd://1.6.8+bottlerocket", 441 | "boot_id": "00002", 442 | "kernel_version": "5.15.59", 443 | "kube_proxy_version": "v1.24.6", 444 | "kubelet_version": "v1.24.6", 445 | "operating_system": "linux", 446 | "os_image": "Bottlerocket OS 1.11.1 (aws-k8s-1.24)" 447 | } 448 | ], 449 | "components": { 450 | "images": [ 451 | { 452 | "full_name": "nginx:1.17.1", 453 | "name": "nginx", 454 | "version": "1.17.1", 455 | "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000001" 456 | }, 457 | { 458 | "full_name": "redis:7.0.1", 459 | "name": "redis", 460 | "version": "7.0.1", 461 | "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000002" 462 | } 463 | ], 464 | "resources": { 465 | "/v1, Resource=namespaces": { 466 | "kind": "Namespace", 467 | "api_version": "v1", 468 | "namespaced": false, 469 | "count": 2, 470 | "resources": [ 471 | { 472 | "name": "backend", 473 | "additional_properties": { 474 | "version": "v1.0.0" 475 | } 476 | }, 477 | { 478 | "name": "frontend", 479 | "additional_properties": { 480 | "version": "v2.0.0" 481 | } 482 | } 483 | ] 484 | } 485 | } 486 | } 487 | } 488 | } 489 | ` 490 | var expectedOutYAML = `id: "00000001" 491 | bomformat: rad 492 | specversion: "0.3" 493 | generatedat: 2023-04-26T10:00:00Z 494 | generatedby: 495 | vendor: RAD Security 496 | name: unknown 497 | buildtime: unknown 498 | version: unknown 499 | commit: unknown 500 | committime: unknown 501 | cluster: 502 | name: test-cluster 503 | cacertdigest: "1234567890" 504 | k8sversion: 1.25.1 505 | cniversion: "" 506 | location: null 507 | nodescount: 0 508 | nodes: [] 509 | components: 510 | images: [] 511 | resources: {} 512 | ` 513 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/CycloneDX/cyclonedx-go v0.7.2 h1:kKQ0t1dPOlugSIYVOMiMtFqeXI2wp/f5DBIdfux8gnQ= 2 | github.com/CycloneDX/cyclonedx-go v0.7.2/go.mod h1:K2bA+324+Og0X84fA8HhN2X066K7Bxz4rpMQ4ZhjtSk= 3 | github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= 4 | github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= 5 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 6 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 7 | github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= 8 | github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= 9 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 10 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 11 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 12 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 13 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 17 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 19 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 20 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 21 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 22 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 23 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 24 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 25 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 26 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 27 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 28 | github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= 29 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 30 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 31 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 32 | github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= 33 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 34 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 35 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 36 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 37 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 38 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 39 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 40 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 41 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 42 | github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= 43 | github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= 44 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 45 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 46 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 47 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 48 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 49 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 50 | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= 51 | github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 52 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 53 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 54 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 55 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 56 | github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= 57 | github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= 58 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 59 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 60 | github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= 61 | github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= 62 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 63 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 64 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 65 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 66 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 67 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 68 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 69 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 70 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 71 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 72 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 73 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 74 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 75 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 76 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 77 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 78 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 79 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 80 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 81 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 82 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 83 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 84 | github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 85 | github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 86 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 87 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 88 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 89 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 90 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 91 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 92 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 93 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 94 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 95 | github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY= 96 | github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM= 97 | github.com/onsi/gomega v1.31.0 h1:54UJxxj6cPInHS3a35wm6BK/F9nHYueZ1NVujHDrnXE= 98 | github.com/onsi/gomega v1.31.0/go.mod h1:DW9aCi7U6Yi40wNVAvT6kzFnEVEI5n3DloYBiKiT6zk= 99 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 100 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 101 | github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= 102 | github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 103 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 104 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 105 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 106 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 107 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 108 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 109 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 110 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 111 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 112 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 113 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 114 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 115 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 116 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 117 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 118 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 119 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 120 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 121 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 122 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 123 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 124 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 125 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 126 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 127 | github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= 128 | github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= 129 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 130 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 131 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 132 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 133 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 134 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 135 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 136 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 137 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 138 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 139 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 140 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 141 | github.com/terminalstatic/go-xsd-validate v0.1.5 h1:RqpJnf6HGE2CB/lZB1A8BYguk8uRtcvYAPLCF15qguo= 142 | github.com/terminalstatic/go-xsd-validate v0.1.5/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= 143 | github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= 144 | github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 145 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= 146 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 147 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= 148 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= 149 | github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= 150 | github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= 151 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 152 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 153 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 154 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 155 | go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 156 | go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 157 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 158 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 159 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 160 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 161 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 162 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 163 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 164 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 165 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 166 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 167 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 168 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 169 | golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= 170 | golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= 171 | golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= 172 | golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= 173 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 174 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 175 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 176 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 177 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 178 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 179 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 180 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 181 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 182 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 183 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 184 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 185 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 186 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 187 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 188 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 189 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 190 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 191 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 192 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 193 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 194 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 195 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 196 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 197 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 198 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 199 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 200 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 201 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 202 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 203 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 204 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 205 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 206 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 207 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 208 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 209 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 210 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 211 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 212 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 213 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 214 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 215 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 216 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 217 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 218 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 219 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 220 | k8s.io/api v0.29.0 h1:NiCdQMY1QOp1H8lfRyeEf8eOwV6+0xA6XEE44ohDX2A= 221 | k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA= 222 | k8s.io/apimachinery v0.30.1 h1:ZQStsEfo4n65yAdlGTfP/uSHMQSoYzU/oeEbkmF7P2U= 223 | k8s.io/apimachinery v0.30.1/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= 224 | k8s.io/client-go v0.29.0 h1:KmlDtFcrdUzOYrBhXHgKw5ycWzc3ryPX5mQe0SkG3y8= 225 | k8s.io/client-go v0.29.0/go.mod h1:yLkXH4HKMAywcrD82KMSmfYg2DlE8mepPR4JGSo5n38= 226 | k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= 227 | k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 228 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= 229 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= 230 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= 231 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 232 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= 233 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 234 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= 235 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= 236 | sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= 237 | sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= 238 | --------------------------------------------------------------------------------