From 4e4d00a0a65161806640516a5357ad93d276b4ea Mon Sep 17 00:00:00 2001 From: Ramkumar Chinchani <45800463+rchincha@users.noreply.github.com> Date: Sat, 23 May 2026 23:24:12 -0700 Subject: [PATCH] feat: add trivy-based sbom artifact generation support (#4088) fixes issue #4067 Signed-off-by: Ramkumar Chinchani --- .gitignore | 2 + Makefile | 4 +- examples/README.md | 4 +- examples/config-sbom-trivy.json | 34 ++ pkg/cli/server/config_reloader_test.go | 20 +- pkg/cli/server/extensions_test.go | 2 +- pkg/extensions/config/config.go | 8 + pkg/extensions/search/cve/trivy/scanner.go | 306 +++++++++++++++++- .../search/cve/trivy/scanner_internal_test.go | 219 ++++++++++++- test/blackbox/ci.sh | 2 +- test/blackbox/sbom.bats | 134 ++++++++ test/ports.json | 6 + 12 files changed, 712 insertions(+), 29 deletions(-) create mode 100644 examples/config-sbom-trivy.json create mode 100644 test/blackbox/sbom.bats diff --git a/.gitignore b/.gitignore index 49d69077..3236c1d6 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ vendor/ examples/config-sync-localhost.json node_modules zot-test + +.gotmp/ diff --git a/Makefile b/Makefile index 2c1ea120..44723d77 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ ZUI_BUILD_PATH := "" ZUI_VERSION := commit-34deb3d ZUI_REPO_OWNER := project-zot ZUI_REPO_NAME := zui -SWAGGER_VERSION := v1.16.2 +SWAGGER_VERSION := v1.16.6 STACKER := $(TOOLSDIR)/bin/stacker STACKER_VERSION := v1.1.0-rc3 KIND := $(TOOLSDIR)/bin/kind @@ -364,7 +364,7 @@ check: ./.golangci.yaml $(GOLINTER) .PHONY: swagger swagger: swag -v || go install github.com/swaggo/swag/cmd/swag@$(SWAGGER_VERSION) - swag init --parseDependency -o swagger -g pkg/api/routes.go -q + swag init --parseDependency --exclude pkg/extensions/search/cve/trivy -o swagger -g pkg/api/routes.go -q .PHONY: update-licenses # note: for predictable output of below sort command we use locale LC_ALL=C diff --git a/examples/README.md b/examples/README.md index ab51845d..f5874481 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1296,6 +1296,8 @@ A minimal configuration only sets how often the DB is refreshed; zot applies def To set those options explicitly (for example to mirror standalone Trivy’s `--vuln-severity-source` behavior), use a `trivy` object under `cve`: -- [config-cve-trivy.json](config-cve-trivy.json) — shows optional `dbRepository`, `javaDBRepository`, and `vulnSeveritySources`. +- [config-cve-trivy.json](config-cve-trivy.json) — shows optional `dbRepository`, `javaDBRepository`, `vulnSeveritySources`, and `sbom`. `vulnSeveritySources` is a list of source names in priority order (for example `auto`, `nvd`, or vendor IDs such as `redhat`, `alpine`). If omitted, zot defaults it to `["auto"]`, consistent with the Trivy CLI. See [Trivy: severity selection](https://trivy.dev/docs/latest/scanner/vulnerability/#severity-selection). + +`sbom.enable` lets zot generate SBOMs while scanning and store them as OCI artifacts attached to the scanned image. `sbom.format` supports `spdx-json` (default) and `cyclonedx`. diff --git a/examples/config-sbom-trivy.json b/examples/config-sbom-trivy.json new file mode 100644 index 00000000..41aebe19 --- /dev/null +++ b/examples/config-sbom-trivy.json @@ -0,0 +1,34 @@ +{ + "distSpecVersion": "1.1.1", + "storage": { + "rootDirectory": "/tmp/zot" + }, + "http": { + "address": "127.0.0.1", + "port": "8080" + }, + "log": { + "level": "debug", + "output": "/tmp/zot.log" + }, + "extensions": { + "ui": { + "enable": true + }, + "search": { + "enable": true, + "cve": { + "updateInterval": "24h", + "trivy": { + "dbRepository": "ghcr.io/aquasecurity/trivy-db", + "javaDBRepository": "ghcr.io/aquasecurity/trivy-java-db", + "vulnSeveritySources": ["auto"], + "sbom": { + "enable": true, + "format": "spdx-json" + } + } + } + } + } +} diff --git a/pkg/cli/server/config_reloader_test.go b/pkg/cli/server/config_reloader_test.go index bf531948..e66ca48d 100644 --- a/pkg/cli/server/config_reloader_test.go +++ b/pkg/cli/server/config_reloader_test.go @@ -20,7 +20,7 @@ func TestConfigReloader(t *testing.T) { defer func() { os.Args = oldArgs }() - Convey("reload access control config", t, func(c C) { + Convey("reload access control config", t, func(conveyCtx C) { port := test.GetFreePort() baseURL := test.GetBaseURL(port) @@ -79,7 +79,7 @@ func TestConfigReloader(t *testing.T) { go func() { err = cli.NewServerRootCmd().Execute() - So(err, ShouldBeNil) + conveyCtx.So(err, ShouldBeNil) }() test.WaitTillServerReady(baseURL) @@ -173,7 +173,7 @@ func TestConfigReloader(t *testing.T) { }) }) - Convey("reload gc config", t, func(c C) { + Convey("reload gc config", t, func(ctx C) { port := test.GetFreePort() baseURL := test.GetBaseURL(port) @@ -214,7 +214,7 @@ func TestConfigReloader(t *testing.T) { go func() { err = cli.NewServerRootCmd().Execute() - So(err, ShouldBeNil) + ctx.So(err, ShouldBeNil) }() test.WaitTillServerReady(baseURL) @@ -300,7 +300,7 @@ func TestConfigReloader(t *testing.T) { }) }) - Convey("reload sync config", t, func(c C) { + Convey("reload sync config", t, func(ctx C) { port := test.GetFreePort() baseURL := test.GetBaseURL(port) @@ -352,7 +352,7 @@ func TestConfigReloader(t *testing.T) { go func() { err = cli.NewServerRootCmd().Execute() - So(err, ShouldBeNil) + ctx.So(err, ShouldBeNil) }() test.WaitTillServerReady(baseURL) @@ -450,7 +450,7 @@ func TestConfigReloader(t *testing.T) { }) }) - Convey("reload scrub and CVE config", t, func(c C) { + Convey("reload scrub and CVE config", t, func(ctx C) { port := test.GetFreePort() baseURL := test.GetBaseURL(port) @@ -495,7 +495,7 @@ func TestConfigReloader(t *testing.T) { go func() { err = cli.NewServerRootCmd().Execute() - So(err, ShouldBeNil) + ctx.So(err, ShouldBeNil) }() test.WaitTillServerReady(baseURL) @@ -591,7 +591,7 @@ func TestConfigReloader(t *testing.T) { So(found, ShouldBeTrue) }) - Convey("reload bad config", t, func(c C) { + Convey("reload bad config", t, func(conveyCtx C) { port := test.GetFreePort() baseURL := test.GetBaseURL(port) @@ -643,7 +643,7 @@ func TestConfigReloader(t *testing.T) { go func() { err = cli.NewServerRootCmd().Execute() - So(err, ShouldBeNil) + conveyCtx.So(err, ShouldBeNil) }() test.WaitTillServerReady(baseURL) diff --git a/pkg/cli/server/extensions_test.go b/pkg/cli/server/extensions_test.go index 083eb975..62dbc2b2 100644 --- a/pkg/cli/server/extensions_test.go +++ b/pkg/cli/server/extensions_test.go @@ -968,7 +968,7 @@ func TestServeSearchEnabledDefaultCVEDB(t *testing.T) { // The default config handling logic will convert the 1h interval to a 2h interval substring := "\"Search\":{\"Enable\":true,\"CVE\":{\"UpdateInterval\":7200000000000,\"Trivy\":" + "{\"DBRepository\":\"ghcr.io/aquasecurity/trivy-db\",\"JavaDBRepository\":\"ghcr.io/aquasecurity/trivy-java-db\"," + - "\"VulnSeveritySources\":[\"auto\"]}}}" + "\"VulnSeveritySources\":[\"auto\"],\"SBOM\":null}}}" found, err := ReadLogFileAndSearchString(logPath, substring, readLogFileTimeout) diff --git a/pkg/extensions/config/config.go b/pkg/extensions/config/config.go index 15b442cd..94bf291f 100644 --- a/pkg/extensions/config/config.go +++ b/pkg/extensions/config/config.go @@ -63,6 +63,14 @@ type TrivyConfig struct { // VulnSeveritySources controls Trivy's severity source selection (same as Trivy's --vuln-severity-source). // If empty, zot will default it to ["auto"]. VulnSeveritySources []string + SBOM *SBOMConfig +} + +type SBOMConfig struct { + Enable bool + // Format controls the generated SBOM output format. + // Supported values are "spdx-json" (default) and "cyclonedx". + Format string } type MetricsConfig struct { diff --git a/pkg/extensions/search/cve/trivy/scanner.go b/pkg/extensions/search/cve/trivy/scanner.go index d27a52c5..5518c127 100644 --- a/pkg/extensions/search/cve/trivy/scanner.go +++ b/pkg/extensions/search/cve/trivy/scanner.go @@ -1,8 +1,12 @@ package trivy import ( + "bytes" "context" + "encoding/json" + "errors" "fmt" + "io" "maps" "os" "path" @@ -24,6 +28,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" regTypes "github.com/google/go-containerregistry/pkg/v1/types" godigest "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" ispec "github.com/opencontainers/image-spec/specs-go/v1" _ "modernc.org/sqlite" @@ -34,16 +39,28 @@ import ( cvecache "zotregistry.dev/zot/v2/pkg/extensions/search/cve/cache" cvemodel "zotregistry.dev/zot/v2/pkg/extensions/search/cve/model" "zotregistry.dev/zot/v2/pkg/log" + "zotregistry.dev/zot/v2/pkg/meta" mTypes "zotregistry.dev/zot/v2/pkg/meta/types" "zotregistry.dev/zot/v2/pkg/storage" + storageConstants "zotregistry.dev/zot/v2/pkg/storage/constants" ) const cacheSize = 1000000 +const ( + defaultSBOMFormat = types.FormatSPDXJSON + defaultSBOMArtifactType = "application/spdx+json" + defaultSBOMLayerMediaType = "application/spdx+json" + cycloneDXArtifactType = "application/vnd.cyclonedx+json" + cycloneDXLayerMediaType = "application/vnd.cyclonedx+json" +) + +var errImageStoreNotFound = errors.New("image store not found") + // getNewScanOptions sets trivy configuration values for our scans and returns them as // a trivy Options structure. func getNewScanOptions(dir string, dbRepositoryRef, javaDBRepositoryRef name.Reference, - vulnSeveritySources []dbTypes.SourceID, + vulnSeveritySources []dbTypes.SourceID, sbomEnabled bool, ) *flag.Options { scanOptions := flag.Options{ GlobalOptions: flag.GlobalOptions{ @@ -80,6 +97,14 @@ func getNewScanOptions(dir string, dbRepositoryRef, javaDBRepositoryRef name.Ref }, } + if sbomEnabled { + scanOptions.ScanOptions.Scanners = types.Scanners{types.VulnerabilityScanner, types.LicenseScanner} + scanOptions.ScanOptions.DetectionPriority = fanalTypes.PriorityComprehensive + scanOptions.ImageOptions.ScanRemovedPkgs = true + scanOptions.LicenseOptions.LicenseFull = true + scanOptions.PackageOptions.IncludeDevDeps = true + } + return &scanOptions } @@ -98,6 +123,20 @@ type Scanner struct { dbRepositoryRef name.Reference javaDBRepositoryRef name.Reference vulnSeveritySources []dbTypes.SourceID + sbomOptions sbomOptions +} + +type sbomOptions struct { + enabled bool + reportFormat types.Format + artifactType string + layerMediaType string +} + +type generatedSBOM struct { + filePath string + digest godigest.Digest + size int64 } func NewScanner(storeController storage.StoreController, @@ -112,6 +151,8 @@ func NewScanner(storeController storage.StoreController, trivyCfg = &extconf.TrivyConfig{} } + sbomOpts := getSBOMOptions(trivyCfg.SBOM, log) + dbRepository := trivyCfg.DBRepository javaDBRepository := trivyCfg.JavaDBRepository vulnSeveritySources := trivyCfg.VulnSeveritySources @@ -158,7 +199,7 @@ func NewScanner(storeController storage.StoreController, rootDir := imageStore.RootDir() cacheDir := path.Join(rootDir, "_trivy") - opts := getNewScanOptions(cacheDir, dbRepositoryRef, javaDBRepositoryRef, sevSources) + opts := getNewScanOptions(cacheDir, dbRepositoryRef, javaDBRepositoryRef, sevSources, sbomOpts.enabled) cveController.DefaultCveConfig = opts } @@ -168,7 +209,7 @@ func NewScanner(storeController storage.StoreController, rootDir := storage.RootDir() cacheDir := path.Join(rootDir, "_trivy") - opts := getNewScanOptions(cacheDir, dbRepositoryRef, javaDBRepositoryRef, sevSources) + opts := getNewScanOptions(cacheDir, dbRepositoryRef, javaDBRepositoryRef, sevSources, sbomOpts.enabled) subCveConfig[route] = opts } @@ -186,9 +227,43 @@ func NewScanner(storeController storage.StoreController, dbRepositoryRef: dbRepositoryRef, javaDBRepositoryRef: javaDBRepositoryRef, vulnSeveritySources: sevSources, + sbomOptions: sbomOpts, } } +func getSBOMOptions(cfg *extconf.SBOMConfig, logger log.Logger) sbomOptions { + options := sbomOptions{ + enabled: false, + reportFormat: defaultSBOMFormat, + artifactType: defaultSBOMArtifactType, + layerMediaType: defaultSBOMLayerMediaType, + } + + if cfg == nil || !cfg.Enable { + return options + } + + options.enabled = true + + format := strings.ToLower(cfg.Format) + if format == "" || format == string(types.FormatSPDXJSON) { + return options + } + + if format == string(types.FormatCycloneDX) { + options.reportFormat = types.FormatCycloneDX + options.artifactType = cycloneDXArtifactType + options.layerMediaType = cycloneDXLayerMediaType + + return options + } + + logger.Warn().Str("format", cfg.Format). + Msg("unsupported trivy sbom format, defaulting to spdx-json") + + return options +} + func (scanner Scanner) getTrivyOptions(image string) flag.Options { // Split image to get route prefix prefixName := storage.GetRoutePrefix(image) @@ -244,13 +319,16 @@ func (scanner Scanner) withTempDir(wrappedFunc func() error) error { return wrappedFunc() } -func (scanner Scanner) runTrivy(ctx context.Context, opts flag.Options) (types.Report, error) { +func (scanner Scanner) runTrivy(ctx context.Context, opts flag.Options) (types.Report, *generatedSBOM, error) { err := scanner.checkDBPresence() if err != nil { - return types.Report{}, err + return types.Report{}, nil, err } report := types.Report{} + + var sbom *generatedSBOM + err = scanner.withTempDir(func() error { runner, err := artifact.NewRunner(ctx, opts, artifact.TargetContainerImage) if err != nil { @@ -264,11 +342,74 @@ func (scanner Scanner) runTrivy(ctx context.Context, opts flag.Options) (types.R } report, err = runner.Filter(ctx, opts, report) + if err != nil { + return err + } + + if scanner.sbomOptions.enabled { + sbom, err = scanner.generateSBOM(ctx, runner, opts, report) + if err != nil { + scanner.log.Warn().Err(err).Str("image", opts.ImageOptions.Input).Msg("failed to generate sbom") + } + } return err }) - return report, err + return report, sbom, err +} + +func (scanner Scanner) generateSBOM(ctx context.Context, runner artifact.Runner, opts flag.Options, report types.Report, +) (*generatedSBOM, error) { + sbomTempFile, err := os.CreateTemp("", "zot-trivy-sbom-*.json") + if err != nil { + return nil, err + } + + sbomPath := sbomTempFile.Name() + + if err = sbomTempFile.Close(); err != nil { + _ = os.Remove(sbomPath) + + return nil, err + } + + sbomOpts := opts + sbomOpts.ReportOptions.Format = scanner.sbomOptions.reportFormat + sbomOpts.ReportOptions.Output = sbomTempFile.Name() + sbomOpts.ReportOptions.ListAllPkgs = true + sbomOpts.ReportOptions.DependencyTree = true + + if err = runner.Report(ctx, sbomOpts, report); err != nil { + _ = os.Remove(sbomPath) + + return nil, err + } + + sbomDigest, sbomSize, err := digestAndSizeFromFile(sbomPath) + if err != nil { + _ = os.Remove(sbomPath) + + return nil, err + } + + return &generatedSBOM{filePath: sbomPath, digest: sbomDigest, size: sbomSize}, nil +} + +func digestAndSizeFromFile(filePath string) (godigest.Digest, int64, error) { + file, err := os.Open(filePath) + if err != nil { + return "", 0, err + } + defer file.Close() + + digester := godigest.Canonical.Digester() + size, err := io.Copy(digester.Hash(), file) + if err != nil { + return "", 0, err + } + + return digester.Digest(), size, nil } func (scanner Scanner) IsImageFormatScannable(repo, ref string) (bool, error) { @@ -464,13 +605,22 @@ func (scanner Scanner) scanManifest(ctx context.Context, repo, digest string) (m scanner.dbLock.Lock() opts := scanner.getTrivyOptions(image) - report, err := scanner.runTrivy(ctx, opts) + report, sbom, err := scanner.runTrivy(ctx, opts) scanner.dbLock.Unlock() + if sbom != nil && sbom.filePath != "" { + defer os.Remove(sbom.filePath) + } if err != nil { //nolint: wsl return cveidMap, err } + // SBOM persistence is best-effort: CVE scanning should still complete even if + // SBOM artifact upload fails. + if err = scanner.storeSBOMAsOCIArtifact(ctx, repo, digest, sbom); err != nil { + scanner.log.Warn().Err(err).Str("image", image).Msg("failed to store generated sbom as OCI artifact") + } + for _, result := range report.Results { for _, vulnerability := range result.Vulnerabilities { pkgName := vulnerability.PkgName @@ -540,6 +690,148 @@ func (scanner Scanner) scanManifest(ctx context.Context, repo, digest string) (m return cveidMap, nil } +func (scanner Scanner) storeSBOMAsOCIArtifact(ctx context.Context, + repo, subjectDigest string, sbom *generatedSBOM, +) error { + if !scanner.sbomOptions.enabled { + scanner.log.Debug().Str("repo", repo).Str("subject", subjectDigest). + Msg("skipping sbom artifact persistence: sbom support disabled") + + return nil + } + + if sbom == nil || sbom.filePath == "" { + scanner.log.Debug().Str("repo", repo).Str("subject", subjectDigest). + Msg("skipping sbom artifact persistence: no sbom file available") + + return nil + } + + imgStore := scanner.storeController.GetImageStore(repo) + if imgStore == nil { + scanner.log.Warn().Str("repo", repo).Msg("skipping sbom artifact persistence: image store not found") + + return fmt.Errorf("%w for repo %q", errImageStoreNotFound, repo) + } + + subject := godigest.Digest(subjectDigest) + if err := subject.Validate(); err != nil { + return err + } + + subjectManifestBlob, _, subjectMediaType, err := imgStore.GetImageManifest(repo, subjectDigest) + if err != nil { + return err + } + + referrers, err := imgStore.GetReferrers(repo, subject, []string{scanner.sbomOptions.artifactType}) + if err == nil && len(referrers.Manifests) > 0 { + scanner.log.Debug().Str("repo", repo).Str("subject", subjectDigest). + Str("artifactType", scanner.sbomOptions.artifactType). + Msg("skipping sbom artifact persistence: referrer already exists") + + return nil + } + + sbomExists, _, err := imgStore.CheckBlob(repo, sbom.digest) + if err != nil && !errors.Is(err, zerr.ErrBlobNotFound) { + return err + } + + if !sbomExists { + sbomFile, openErr := os.Open(sbom.filePath) + if openErr != nil { + return openErr + } + + defer sbomFile.Close() + + if _, _, err = imgStore.FullBlobUpload(repo, sbomFile, sbom.digest); err != nil { + return err + } + + scanner.log.Debug().Str("repo", repo).Str("subject", subjectDigest).Str("digest", sbom.digest.String()). + Msg("uploaded sbom blob") + } + + emptyConfigExists, _, err := imgStore.CheckBlob(repo, ispec.DescriptorEmptyJSON.Digest) + if err != nil && !errors.Is(err, zerr.ErrBlobNotFound) { + return err + } + + if !emptyConfigExists { + if _, _, err = imgStore.FullBlobUpload(repo, bytes.NewReader(ispec.DescriptorEmptyJSON.Data), + ispec.DescriptorEmptyJSON.Digest); err != nil { + return err + } + + scanner.log.Debug().Str("repo", repo).Str("digest", ispec.DescriptorEmptyJSON.Digest.String()). + Msg("uploaded empty config blob for sbom artifact") + } + + sbomManifest := ispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: storageConstants.SchemaVersion}, + MediaType: ispec.MediaTypeImageManifest, + ArtifactType: scanner.sbomOptions.artifactType, + Subject: &ispec.Descriptor{ + MediaType: subjectMediaType, + Digest: subject, + Size: int64(len(subjectManifestBlob)), + }, + Config: ispec.Descriptor{ + MediaType: ispec.MediaTypeEmptyJSON, + Digest: ispec.DescriptorEmptyJSON.Digest, + Size: int64(len(ispec.DescriptorEmptyJSON.Data)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: scanner.sbomOptions.layerMediaType, + Digest: sbom.digest, + Size: sbom.size, + }, + }, + } + + sbomManifestBlob, err := json.Marshal(sbomManifest) + if err != nil { + return err + } + + sbomManifestDigest := godigest.FromBytes(sbomManifestBlob) + _, _, err = imgStore.PutImageManifest(repo, sbomManifestDigest.String(), ispec.MediaTypeImageManifest, + sbomManifestBlob, nil) + if err != nil { + return err + } + + scanner.log.Debug().Str("repo", repo).Str("subject", subjectDigest). + Str("manifestDigest", sbomManifestDigest.String()). + Str("artifactType", scanner.sbomOptions.artifactType). + Msg("stored sbom as oci artifact manifest") + + if scanner.metaDB != nil { + err = meta.OnUpdateManifest(ctx, repo, sbomManifestDigest.String(), + ispec.MediaTypeImageManifest, sbomManifestDigest, sbomManifestBlob, + scanner.storeController, scanner.metaDB, scanner.log) + if err != nil { + scanner.log.Warn().Err(err).Str("repo", repo).Str("subject", subjectDigest). + Str("manifestDigest", sbomManifestDigest.String()). + Msg("failed to persist sbom artifact metadata") + + return err + } + + scanner.log.Debug().Str("repo", repo).Str("subject", subjectDigest). + Str("manifestDigest", sbomManifestDigest.String()). + Msg("persisted sbom artifact metadata") + } else { + scanner.log.Debug().Str("repo", repo).Str("subject", subjectDigest). + Msg("skipping sbom artifact metadata persistence: metadb not configured") + } + + return nil +} + func getCVEReference(primaryURL string, references []string) string { if primaryURL != "" { return primaryURL diff --git a/pkg/extensions/search/cve/trivy/scanner_internal_test.go b/pkg/extensions/search/cve/trivy/scanner_internal_test.go index 5dae0a9e..caaefb2b 100644 --- a/pkg/extensions/search/cve/trivy/scanner_internal_test.go +++ b/pkg/extensions/search/cve/trivy/scanner_internal_test.go @@ -4,12 +4,16 @@ package trivy import ( "context" + "encoding/json" "os" "path" "testing" "time" dbTypes "github.com/aquasecurity/trivy-db/pkg/types" + "github.com/aquasecurity/trivy/pkg/commands/artifact" + "github.com/aquasecurity/trivy/pkg/flag" + trivyTypes "github.com/aquasecurity/trivy/pkg/types" godigest "github.com/opencontainers/go-digest" ispec "github.com/opencontainers/image-spec/specs-go/v1" . "github.com/smartystreets/goconvey/convey" @@ -33,6 +37,52 @@ import ( "zotregistry.dev/zot/v2/pkg/test/mocks" ) +type fakeArtifactRunner struct { + reportFn func(ctx context.Context, opts flag.Options, report trivyTypes.Report) error +} + +func (f fakeArtifactRunner) ScanImage(ctx context.Context, opts flag.Options) (trivyTypes.Report, error) { + return trivyTypes.Report{}, nil +} + +func (f fakeArtifactRunner) ScanFilesystem(ctx context.Context, opts flag.Options) (trivyTypes.Report, error) { + return trivyTypes.Report{}, nil +} + +func (f fakeArtifactRunner) ScanRootfs(ctx context.Context, opts flag.Options) (trivyTypes.Report, error) { + return trivyTypes.Report{}, nil +} + +func (f fakeArtifactRunner) ScanRepository(ctx context.Context, opts flag.Options) (trivyTypes.Report, error) { + return trivyTypes.Report{}, nil +} + +func (f fakeArtifactRunner) ScanSBOM(ctx context.Context, opts flag.Options) (trivyTypes.Report, error) { + return trivyTypes.Report{}, nil +} + +func (f fakeArtifactRunner) ScanVM(ctx context.Context, opts flag.Options) (trivyTypes.Report, error) { + return trivyTypes.Report{}, nil +} + +func (f fakeArtifactRunner) Filter(ctx context.Context, opts flag.Options, report trivyTypes.Report) (trivyTypes.Report, error) { + return report, nil +} + +func (f fakeArtifactRunner) Report(ctx context.Context, opts flag.Options, report trivyTypes.Report) error { + if f.reportFn != nil { + return f.reportFn(ctx, opts, report) + } + + return nil +} + +func (f fakeArtifactRunner) Close(ctx context.Context) error { + return nil +} + +var _ artifact.Runner = fakeArtifactRunner{} + func generateTestImage(storeController storage.StoreController, imageName string) { repoName, tag := common.GetImageDirAndTag(imageName) @@ -43,6 +93,45 @@ func generateTestImage(storeController storage.StoreController, imageName string So(err, ShouldBeNil) } +func TestGenerateSBOM(t *testing.T) { + Convey("generateSBOM writes report to file and returns digest metadata", t, func() { + logger := log.NewTestLogger() + scanner := Scanner{ + log: logger, + sbomOptions: sbomOptions{ + enabled: true, + reportFormat: trivyTypes.FormatSPDXJSON, + artifactType: defaultSBOMArtifactType, + layerMediaType: defaultSBOMLayerMediaType, + }, + } + + expectedSBOM := []byte(`{"spdxVersion":"SPDX-2.3"}`) + mockRunner := fakeArtifactRunner{ + reportFn: func(ctx context.Context, opts flag.Options, report trivyTypes.Report) error { + So(opts.ReportOptions.Output, ShouldNotEqual, "") + So(opts.ReportOptions.Format, ShouldEqual, trivyTypes.FormatSPDXJSON) + So(opts.ReportOptions.ListAllPkgs, ShouldBeTrue) + So(opts.ReportOptions.DependencyTree, ShouldBeTrue) + + return os.WriteFile(opts.ReportOptions.Output, expectedSBOM, 0o600) + }, + } + + generated, err := scanner.generateSBOM(context.Background(), mockRunner, flag.Options{}, trivyTypes.Report{}) + So(err, ShouldBeNil) + So(generated, ShouldNotBeNil) + So(generated.filePath, ShouldNotEqual, "") + defer os.Remove(generated.filePath) + + storedSBOM, err := os.ReadFile(generated.filePath) + So(err, ShouldBeNil) + So(storedSBOM, ShouldResemble, expectedSBOM) + So(generated.size, ShouldEqual, int64(len(expectedSBOM))) + So(generated.digest, ShouldEqual, godigest.FromBytes(expectedSBOM)) + }) +} + func TestMultipleStoragePath(t *testing.T) { Convey("Test multiple storage path", t, func() { // Create temporary directory @@ -214,7 +303,7 @@ func TestTrivyLibraryErrors(t *testing.T) { // Try to scan without a valid DB being downloaded opts := scanner.getTrivyOptions(img) - _, err = scanner.runTrivy(ctx, opts) + _, _, err = scanner.runTrivy(ctx, opts) So(err, ShouldNotBeNil) So(err, ShouldWrap, zerr.ErrCVEDBNotFound) @@ -243,25 +332,25 @@ func TestTrivyLibraryErrors(t *testing.T) { // Scanning image with correct options opts = scanner.getTrivyOptions(img) - _, err = scanner.runTrivy(ctx, opts) + _, _, err = scanner.runTrivy(ctx, opts) So(err, ShouldBeNil) // Scanning image with incorrect cache options // to trigger runner initialization errors opts.CacheOptions.CacheBackend = "redis://asdf!$%&!*)(" - _, err = scanner.runTrivy(ctx, opts) + _, _, err = scanner.runTrivy(ctx, opts) So(err, ShouldNotBeNil) // Scanning image with invalid input to trigger a scanner error opts = scanner.getTrivyOptions("nilnonexisting_image:0.0.1") - _, err = scanner.runTrivy(ctx, opts) + _, _, err = scanner.runTrivy(ctx, opts) So(err, ShouldNotBeNil) // Scanning image with incorrect report options // to trigger report filtering errors opts = scanner.getTrivyOptions(img) opts.ReportOptions.IgnorePolicy = "invalid file path" - _, err = scanner.runTrivy(ctx, opts) + _, _, err = scanner.runTrivy(ctx, opts) So(err, ShouldNotBeNil) }) } @@ -433,14 +522,14 @@ func TestTrivyDBUrl(t *testing.T) { img := "zot-test:0.0.1" //nolint:goconst opts := scanner.getTrivyOptions(img) - _, err = scanner.runTrivy(ctx, opts) + _, _, err = scanner.runTrivy(ctx, opts) So(err, ShouldBeNil) // Scanning image containing a jar file img = "zot-cve-java-test:0.0.1" opts = scanner.getTrivyOptions(img) - _, err = scanner.runTrivy(ctx, opts) + _, _, err = scanner.runTrivy(ctx, opts) So(err, ShouldBeNil) }) } @@ -527,6 +616,122 @@ func TestVulnSeveritySourcesDefaulting(t *testing.T) { So(scanner, ShouldNotBeNil) So(scanner.vulnSeveritySources, ShouldResemble, []dbTypes.SourceID{"nvd", "ghsa"}) }) + + Convey("NewScanner enables SBOM generation with default options", t, func() { + scanner := NewScanner(storage.StoreController{}, nil, &extconf.CVEConfig{ + Trivy: &extconf.TrivyConfig{ + DBRepository: "ghcr.io/project-zot/trivy-db", + SBOM: &extconf.SBOMConfig{ + Enable: true, + }, + }, + }, log.NewTestLogger()) + So(scanner, ShouldNotBeNil) + So(scanner.sbomOptions.enabled, ShouldBeTrue) + So(scanner.sbomOptions.reportFormat, ShouldEqual, trivyTypes.FormatSPDXJSON) + So(scanner.sbomOptions.artifactType, ShouldEqual, defaultSBOMArtifactType) + So(scanner.sbomOptions.layerMediaType, ShouldEqual, defaultSBOMLayerMediaType) + }) + + Convey("NewScanner supports CycloneDX SBOM format", t, func() { + scanner := NewScanner(storage.StoreController{}, nil, &extconf.CVEConfig{ + Trivy: &extconf.TrivyConfig{ + DBRepository: "ghcr.io/project-zot/trivy-db", + SBOM: &extconf.SBOMConfig{ + Enable: true, + Format: string(trivyTypes.FormatCycloneDX), + }, + }, + }, log.NewTestLogger()) + So(scanner, ShouldNotBeNil) + So(scanner.sbomOptions.reportFormat, ShouldEqual, trivyTypes.FormatCycloneDX) + So(scanner.sbomOptions.artifactType, ShouldEqual, cycloneDXArtifactType) + So(scanner.sbomOptions.layerMediaType, ShouldEqual, cycloneDXLayerMediaType) + }) +} + +func TestStoreSBOMAsOCIArtifact(t *testing.T) { + Convey("storeSBOMAsOCIArtifact stores SBOM once as OCI referrer", t, func() { + rootDir := t.TempDir() + + logger := log.NewTestLogger() + metrics := monitoring.NewMetricsServer(false, logger) + defer metrics.Stop() + store := local.NewImageStore(rootDir, false, false, logger, metrics, nil, nil, nil, nil) + + storeController := storage.StoreController{ + DefaultStore: store, + } + + params := boltdb.DBParameters{RootDir: rootDir} + boltDriver, err := boltdb.GetBoltDriver(params) + So(err, ShouldBeNil) + + metaDB, err := boltdb.New(boltDriver, logger) + So(err, ShouldBeNil) + + generateTestImage(storeController, "repo:1.0") + + _, subjectDigest, _, err := store.GetImageManifest("repo", "1.0") + So(err, ShouldBeNil) + + scanner := NewScanner(storeController, metaDB, &extconf.CVEConfig{ + Trivy: &extconf.TrivyConfig{ + DBRepository: "ghcr.io/project-zot/trivy-db", + SBOM: &extconf.SBOMConfig{ + Enable: true, + }, + }, + }, logger) + + ctx := context.Background() + + sbomBlob := []byte(`{"spdxVersion":"SPDX-2.3"}`) + sbomFile, err := os.CreateTemp("", "zot-trivy-sbom-test-*.json") + So(err, ShouldBeNil) + + _, err = sbomFile.Write(sbomBlob) + So(err, ShouldBeNil) + + err = sbomFile.Close() + So(err, ShouldBeNil) + defer os.Remove(sbomFile.Name()) + + sbom := &generatedSBOM{ + filePath: sbomFile.Name(), + digest: godigest.FromBytes(sbomBlob), + size: int64(len(sbomBlob)), + } + + err = scanner.storeSBOMAsOCIArtifact(ctx, "repo", subjectDigest.String(), sbom) + So(err, ShouldBeNil) + + referrers, err := store.GetReferrers("repo", subjectDigest, []string{defaultSBOMArtifactType}) + So(err, ShouldBeNil) + So(len(referrers.Manifests), ShouldEqual, 1) + So(referrers.Manifests[0].ArtifactType, ShouldEqual, defaultSBOMArtifactType) + + metaReferrers, err := metaDB.GetReferrersInfo("repo", subjectDigest, []string{defaultSBOMArtifactType}) + So(err, ShouldBeNil) + So(len(metaReferrers), ShouldEqual, 1) + So(metaReferrers[0].Digest, ShouldEqual, referrers.Manifests[0].Digest.String()) + + refManifestBlob, _, _, err := store.GetImageManifest("repo", referrers.Manifests[0].Digest.String()) + So(err, ShouldBeNil) + + var refManifest ispec.Manifest + err = json.Unmarshal(refManifestBlob, &refManifest) + So(err, ShouldBeNil) + So(refManifest.Subject.Digest, ShouldEqual, subjectDigest) + So(refManifest.Layers[0].MediaType, ShouldEqual, defaultSBOMLayerMediaType) + + err = scanner.storeSBOMAsOCIArtifact(ctx, "repo", subjectDigest.String(), sbom) + So(err, ShouldBeNil) + + referrers, err = store.GetReferrers("repo", subjectDigest, []string{defaultSBOMArtifactType}) + So(err, ShouldBeNil) + So(len(referrers.Manifests), ShouldEqual, 1) + }) } func TestGetCVEReference(t *testing.T) { diff --git a/test/blackbox/ci.sh b/test/blackbox/ci.sh index 8f0d3da2..84be6ff6 100755 --- a/test/blackbox/ci.sh +++ b/test/blackbox/ci.sh @@ -15,7 +15,7 @@ PATH=$PATH:${SCRIPTPATH}/../../hack/tools/bin echo "Setting up Docker images..." ${SCRIPTPATH}/setup_images.sh -tests=("pushpull" "pushpull_authn" "delete_images" "referrers" "metadata" "anonymous_policy" +tests=("pushpull" "pushpull_authn" "delete_images" "referrers" "sbom" "metadata" "anonymous_policy" "annotations" "detect_manifest_collision" "cve" "sync" "sync_docker" "sync_replica_cluster" "scrub" "garbage_collect" "metrics" "metrics_minimal" "multiarch_index" "docker_compat" "redis_local" "redis_session_store" "events_nats" "events_http" "events_nats_lint_failure" "events_http_lint_failure" "events_sink_failure" "events_config_decoding" diff --git a/test/blackbox/sbom.bats b/test/blackbox/sbom.bats new file mode 100644 index 00000000..7720954d --- /dev/null +++ b/test/blackbox/sbom.bats @@ -0,0 +1,134 @@ +# Note: Intended to be run as "make run-blackbox-tests" or "make run-blackbox-ci" +# Makefile target installs & checks all necessary tooling. + +load helpers_zot +load ../port_helper + +function verify_prerequisites { + if [ ! $(command -v curl) ]; then + echo "you need to install curl as a prerequisite to running the tests" >&3 + return 1 + fi + + if [ ! $(command -v jq) ]; then + echo "you need to install jq as a prerequisite to running the tests" >&3 + return 1 + fi + + if [ ! $(command -v skopeo) ]; then + echo "you need to install skopeo as a prerequisite to running the tests" >&3 + return 1 + fi + + return 0 +} + +function setup_file() { + if ! $(verify_prerequisites); then + exit 1 + fi + + skopeo --insecure-policy copy --format=oci \ + docker://ghcr.io/project-zot/golang:1.20 \ + oci:${TEST_DATA_DIR}/golang:1.20 + + local zot_root_dir=${BATS_FILE_TMPDIR}/zot + local zot_config_file=${BATS_FILE_TMPDIR}/zot_config.json + mkdir -p ${zot_root_dir} + + zot_port=$(get_free_port_for_service "zot") + echo ${zot_port} > ${BATS_FILE_TMPDIR}/zot.port + + cat >${zot_config_file} <