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-cve-trivy.json b/examples/config-cve-trivy.json index 823fad16..d983fb80 100644 --- a/examples/config-cve-trivy.json +++ b/examples/config-cve-trivy.json @@ -18,7 +18,11 @@ "trivy": { "dbRepository": "ghcr.io/aquasecurity/trivy-db", "javaDBRepository": "ghcr.io/aquasecurity/trivy-java-db", - "vulnSeveritySources": ["auto"] + "vulnSeveritySources": ["auto"], + "sbom": { + "enable": true, + "format": "spdx-json" + } } } } 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..732defaf 100644 --- a/pkg/extensions/search/cve/trivy/scanner.go +++ b/pkg/extensions/search/cve/trivy/scanner.go @@ -1,7 +1,10 @@ package trivy import ( + "bytes" "context" + "encoding/json" + "errors" "fmt" "maps" "os" @@ -24,6 +27,7 @@ import ( "github.com/google/go-containerregistry/pkg/name" regTypes "github.com/google/go-containerregistry/pkg/v1/types" godigest "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" ispec "github.com/opencontainers/image-spec/specs-go/v1" _ "modernc.org/sqlite" @@ -40,6 +44,14 @@ import ( 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" +) + // getNewScanOptions sets trivy configuration values for our scans and returns them as // a trivy Options structure. func getNewScanOptions(dir string, dbRepositoryRef, javaDBRepositoryRef name.Reference, @@ -98,6 +110,14 @@ 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 } func NewScanner(storeController storage.StoreController, @@ -112,6 +132,8 @@ func NewScanner(storeController storage.StoreController, trivyCfg = &extconf.TrivyConfig{} } + sbomOpts := getSBOMOptions(trivyCfg.SBOM, log) + dbRepository := trivyCfg.DBRepository javaDBRepository := trivyCfg.JavaDBRepository vulnSeveritySources := trivyCfg.VulnSeveritySources @@ -186,9 +208,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 +300,15 @@ 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, []byte, error) { err := scanner.checkDBPresence() if err != nil { - return types.Report{}, err + return types.Report{}, nil, err } report := types.Report{} + sbomContent := []byte(nil) + err = scanner.withTempDir(func() error { runner, err := artifact.NewRunner(ctx, opts, artifact.TargetContainerImage) if err != nil { @@ -264,11 +322,44 @@ 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 { + sbomContent, 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, sbomContent, err +} + +func (scanner Scanner) generateSBOM(ctx context.Context, runner artifact.Runner, opts flag.Options, report types.Report, +) ([]byte, error) { + sbomTempFile, err := os.CreateTemp(xos.TempDir(), "zot-trivy-sbom-*.json") + if err != nil { + return nil, err + } + defer os.Remove(sbomTempFile.Name()) + + if err = sbomTempFile.Close(); err != nil { + return nil, err + } + + sbomOpts := opts + sbomOpts.ReportOptions.Format = scanner.sbomOptions.reportFormat + sbomOpts.ReportOptions.Output = sbomTempFile.Name() + + if err = runner.Report(ctx, sbomOpts, report); err != nil { + return nil, err + } + + return os.ReadFile(sbomTempFile.Name()) } func (scanner Scanner) IsImageFormatScannable(repo, ref string) (bool, error) { @@ -464,13 +555,17 @@ 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, sbomContent, err := scanner.runTrivy(ctx, opts) scanner.dbLock.Unlock() if err != nil { //nolint: wsl return cveidMap, err } + if err = scanner.storeSBOMAsOCIArtifact(repo, digest, sbomContent); 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 +635,92 @@ func (scanner Scanner) scanManifest(ctx context.Context, repo, digest string) (m return cveidMap, nil } +func (scanner Scanner) storeSBOMAsOCIArtifact(repo, subjectDigest string, sbomContent []byte) error { + if !scanner.sbomOptions.enabled || len(sbomContent) == 0 { + return nil + } + + imgStore := scanner.storeController.GetImageStore(repo) + if imgStore == nil { + return fmt.Errorf("image store not found for repo %q", 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 { + return nil + } + + sbomDigest := godigest.FromBytes(sbomContent) + sbomExists, _, err := imgStore.CheckBlob(repo, sbomDigest) + if err != nil && !errors.Is(err, zerr.ErrBlobNotFound) { + return err + } + + if !sbomExists { + if _, _, err = imgStore.FullBlobUpload(repo, bytes.NewReader(sbomContent), sbomDigest); err != nil { + return err + } + } + + 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 + } + } + + sbomManifest := ispec.Manifest{ + Versioned: specs.Versioned{ + SchemaVersion: 2, + }, + 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: sbomDigest, + Size: int64(len(sbomContent)), + }, + }, + } + + sbomManifestBlob, err := json.Marshal(sbomManifest) + if err != nil { + return err + } + + sbomManifestDigest := godigest.FromBytes(sbomManifestBlob) + _, _, err = imgStore.PutImageManifest(repo, sbomManifestDigest.String(), ispec.MediaTypeImageManifest, + sbomManifestBlob, nil) + + return err +} + 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..1d785951 100644 --- a/pkg/extensions/search/cve/trivy/scanner_internal_test.go +++ b/pkg/extensions/search/cve/trivy/scanner_internal_test.go @@ -4,12 +4,14 @@ package trivy import ( "context" + "encoding/json" "os" "path" "testing" "time" dbTypes "github.com/aquasecurity/trivy-db/pkg/types" + 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" @@ -214,7 +216,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 +245,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 +435,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 +529,92 @@ 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, + } + + generateTestImage(storeController, "repo:1.0") + + _, subjectDigest, _, err := store.GetImageManifest("repo", "1.0") + So(err, ShouldBeNil) + + scanner := NewScanner(storeController, nil, &extconf.CVEConfig{ + Trivy: &extconf.TrivyConfig{ + DBRepository: "ghcr.io/project-zot/trivy-db", + SBOM: &extconf.SBOMConfig{ + Enable: true, + }, + }, + }, logger) + + sbomBlob := []byte(`{"spdxVersion":"SPDX-2.3"}`) + err = scanner.storeSBOMAsOCIArtifact("repo", subjectDigest.String(), sbomBlob) + 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) + + 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("repo", subjectDigest.String(), sbomBlob) + 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/zot b/zot deleted file mode 100755 index 4903e53e..00000000 Binary files a/zot and /dev/null differ