mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 21:17:58 +08:00
feat: add trivy-based sbom artifact generation support
Agent-Logs-Url: https://github.com/project-zot/zot/sessions/eb3437af-edc8-4846-a9d9-f92bfe579c1e Co-authored-by: rchincha <45800463+rchincha@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
6009d768e9
commit
4e66891b72
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user