diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7ba1683970a85a..9f3eefdfbc366d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -220,6 +220,7 @@ /pkg/collector/corechecks/embed/apm*.go @Datadog/agent-platform @DataDog/agent-apm /pkg/collector/corechecks/embed/process_agent*.go @Datadog/agent-platform @DataDog/processes /pkg/collector/corechecks/net/ @DataDog/agent-platform +/pkg/collector/corechecks/sbom/ @DataDog/container-integrations /pkg/collector/corechecks/snmp/ @DataDog/network-device-monitoring /pkg/collector/corechecks/system/ @DataDog/agent-platform /pkg/collector/corechecks/system/**/*_windows*.go @DataDog/agent-platform @DataDog/windows-agent diff --git a/cmd/agent/subcommands/run/command.go b/cmd/agent/subcommands/run/command.go index cab66563f728b1..e6cc9b5b4843c8 100644 --- a/cmd/agent/subcommands/run/command.go +++ b/cmd/agent/subcommands/run/command.go @@ -81,6 +81,7 @@ import ( _ "github.com/DataDog/datadog-agent/pkg/collector/corechecks/embed" _ "github.com/DataDog/datadog-agent/pkg/collector/corechecks/net" _ "github.com/DataDog/datadog-agent/pkg/collector/corechecks/nvidia/jetson" + _ "github.com/DataDog/datadog-agent/pkg/collector/corechecks/sbom" _ "github.com/DataDog/datadog-agent/pkg/collector/corechecks/snmp" _ "github.com/DataDog/datadog-agent/pkg/collector/corechecks/system/cpu" _ "github.com/DataDog/datadog-agent/pkg/collector/corechecks/system/disk" @@ -376,6 +377,7 @@ func startAgent(cliParams *cliParams, flare flare.Component) error { opts.EnableNoAggregationPipeline = pkgconfig.Datadog.GetBool("dogstatsd_no_aggregation_pipeline") opts.UseContainerLifecycleForwarder = pkgconfig.Datadog.GetBool("container_lifecycle.enabled") opts.UseContainerImageForwarder = pkgconfig.Datadog.GetBool("container_image.enabled") + opts.UseSBOMForwarder = pkgconfig.Datadog.GetBool("sbom.enabled") demux = aggregator.InitAndStartAgentDemultiplexer(opts, hostnameDetected) // Setup stats telemetry handler diff --git a/go.mod b/go.mod index 33f56952a984c1..ff062688c04906 100644 --- a/go.mod +++ b/go.mod @@ -427,6 +427,7 @@ require ( ) require ( + github.com/CycloneDX/cyclonedx-go v0.6.0 github.com/DataDog/go-libddwaf v0.0.0-20221118110754-0372d7c76b8a github.com/go-redis/redis/v9 v9.0.0-rc.2 github.com/safchain/baloum v0.0.0-20221229104256-b1fc8f70a86b diff --git a/go.sum b/go.sum index d5f357b5ee240e..356e92fac0894b 100644 --- a/go.sum +++ b/go.sum @@ -119,6 +119,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/CycloneDX/cyclonedx-go v0.6.0 h1:SizWGbZzFTC/O/1yh072XQBMxfvsoWqd//oKCIyzFyE= +github.com/CycloneDX/cyclonedx-go v0.6.0/go.mod h1:nQCiF4Tvrg5Ieu8qPhYMvzPGMu5I7fANZkrSsJjl5mg= github.com/DataDog/agent-payload/v5 v5.0.61 h1:3HC4B1NpHgAedZHmM9/oCJvFo6pu/ugDAjrISK5AJsk= github.com/DataDog/agent-payload/v5 v5.0.61/go.mod h1:oQZi1VZp1e3QvlSUX4iphZCpJaFepUxWq0hNXxihKBM= github.com/DataDog/aptly v1.5.0 h1:Oy6JVRC9iDgnmpeVYa4diXwP/exU7wJ/U1kuI4Zacxg= @@ -313,6 +315,8 @@ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dR github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40 h1:y4B3+GPxKlrigF1ha5FFErxK+sr6sWxQovRMzwMhejo= github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= +github.com/bradleyjkemp/cupaloy/v2 v2.7.0 h1:AT0vOjO68RcLyenLCHOGZzSNiuto7ziqzq6Q1/3xzMQ= +github.com/bradleyjkemp/cupaloy/v2 v2.7.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/buger/jsonparser v0.0.0-20180808090653-f4dd9f5a6b44/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= github.com/bytecodealliance/wasmtime-go v1.0.0 h1:9u9gqaUiaJeN5IoD1L7egD8atOnTGyJcNp8BhkL9cUU= github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4= diff --git a/pkg/collector/corechecks/sbom/check.go b/pkg/collector/corechecks/sbom/check.go new file mode 100644 index 00000000000000..d587d8e9109384 --- /dev/null +++ b/pkg/collector/corechecks/sbom/check.go @@ -0,0 +1,166 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2022-present Datadog, Inc. + +package sbom + +import ( + "errors" + "time" + + yaml "gopkg.in/yaml.v2" + + "github.com/DataDog/datadog-agent/pkg/autodiscovery/integration" + "github.com/DataDog/datadog-agent/pkg/collector/check" + core "github.com/DataDog/datadog-agent/pkg/collector/corechecks" + ddConfig "github.com/DataDog/datadog-agent/pkg/config" + "github.com/DataDog/datadog-agent/pkg/util/log" + "github.com/DataDog/datadog-agent/pkg/workloadmeta" +) + +const ( + checkName = "sbom" +) + +func init() { + core.RegisterCheck(checkName, CheckFactory) +} + +// Config holds the container_image check configuration +type Config struct { + chunkSize int `yaml:"chunk_size"` + newSBOMMaxLatencySeconds int `yaml:"new_images_max_latency_seconds"` + periodicRefreshSeconds int `yaml:"periodic_refresh_seconds"` +} + +type configValueRange struct { + min int + max int + default_ int +} + +var /* const */ ( + chunkSizeValueRange = &configValueRange{ + min: 1, + max: 100, + default_: 1, + } + + newSBOMMaxLatencySecondsValueRange = &configValueRange{ + min: 1, // 1 s + max: 300, // 5 min + default_: 30, // 30 s + } + + periodicRefreshSecondsValueRange = &configValueRange{ + min: 60, // 1 min + max: 604800, // 1 week + default_: 3600, // 1h + } +) + +func validateValue(val *int, range_ *configValueRange) { + if *val == 0 { + *val = range_.default_ + } else if *val < range_.min { + *val = range_.min + } else if *val > range_.max { + *val = range_.max + } +} + +func (c *Config) Parse(data []byte) error { + if err := yaml.Unmarshal(data, c); err != nil { + return err + } + + validateValue(&c.chunkSize, chunkSizeValueRange) + validateValue(&c.newSBOMMaxLatencySeconds, newSBOMMaxLatencySecondsValueRange) + validateValue(&c.periodicRefreshSeconds, periodicRefreshSecondsValueRange) + + return nil +} + +// Check reports SBOM +type Check struct { + core.CheckBase + workloadmetaStore workloadmeta.Store + instance *Config + processor *processor + stopCh chan struct{} +} + +// CheckFactory registers the sbom check +func CheckFactory() check.Check { + return &Check{ + CheckBase: core.NewCheckBase(checkName), + workloadmetaStore: workloadmeta.GetGlobalStore(), + instance: &Config{}, + stopCh: make(chan struct{}), + } +} + +// Configure parses the check configuration and initializes the sbom check +func (c *Check) Configure(integrationConfigDigest uint64, config, initConfig integration.Data, source string) error { + if !ddConfig.Datadog.GetBool("sbom.enabled") { + return errors.New("collection of SBOM is disabled") + } + + if err := c.CommonConfigure(integrationConfigDigest, initConfig, config, source); err != nil { + return err + } + + if err := c.instance.Parse(config); err != nil { + return err + } + + sender, err := c.GetSender() + if err != nil { + return err + } + + c.processor = newProcessor(sender, c.instance.chunkSize, time.Duration(c.instance.newSBOMMaxLatencySeconds)*time.Second) + + return nil +} + +// Run starts the sbom check +func (c *Check) Run() error { + log.Infof("Starting long-running check %q", c.ID()) + defer log.Infof("Shutting down long-running check %q", c.ID()) + + imgEventsCh := c.workloadmetaStore.Subscribe( + checkName, + workloadmeta.NormalPriority, + workloadmeta.NewFilter( + []workloadmeta.Kind{workloadmeta.KindContainerImageMetadata}, + workloadmeta.SourceAll, + workloadmeta.EventTypeSet, // We don’t care about SBOM removal because we just have to wait for them to expire on BE side once we stopped refreshing them periodically. + ), + ) + + imgRefreshTicker := time.NewTicker(time.Duration(c.instance.periodicRefreshSeconds) * time.Second) + + for { + select { + case eventBundle := <-imgEventsCh: + c.processor.processEvents(eventBundle) + case <-imgRefreshTicker.C: + c.processor.processRefresh(c.workloadmetaStore.ListImages()) + case <-c.stopCh: + c.processor.stop() + return nil + } + } +} + +// Stop stops the sbom check +func (c *Check) Stop() { + close(c.stopCh) +} + +// Interval returns 0. It makes sbom a long-running check +func (c *Check) Interval() time.Duration { + return 0 +} diff --git a/pkg/collector/corechecks/sbom/convert.go b/pkg/collector/corechecks/sbom/convert.go new file mode 100644 index 00000000000000..1fff3601052fc0 --- /dev/null +++ b/pkg/collector/corechecks/sbom/convert.go @@ -0,0 +1,685 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2022-present Datadog, Inc. + +package sbom + +import ( + "time" + "unsafe" + + "github.com/DataDog/datadog-agent/pkg/util/pointer" + + "github.com/CycloneDX/cyclonedx-go" + "github.com/DataDog/agent-payload/v5/cyclonedx_v1_4" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func stringPtr(in string) *string { + if in == "" { + return nil + } else { + return &in + } +} + +func strSliceDeref(in *[]string) []string { + if in == nil { + return nil + } else { + return *in + } +} + +type inArrayElement interface { + cyclonedx.Component | cyclonedx.Service | cyclonedx.ExternalReference | cyclonedx.Dependency | cyclonedx.Composition | cyclonedx.Vulnerability | cyclonedx.Tool | cyclonedx.OrganizationalContact | cyclonedx.Property | cyclonedx.Hash | cyclonedx.LicenseChoice | cyclonedx.DataClassification | cyclonedx.Commit | cyclonedx.Patch | cyclonedx.Copyright | cyclonedx.Issue | cyclonedx.Note +} + +type outArrayElement interface { + cyclonedx_v1_4.Component | cyclonedx_v1_4.Service | cyclonedx_v1_4.ExternalReference | cyclonedx_v1_4.Dependency | cyclonedx_v1_4.Composition | cyclonedx_v1_4.Vulnerability | cyclonedx_v1_4.Tool | cyclonedx_v1_4.OrganizationalContact | cyclonedx_v1_4.Property | cyclonedx_v1_4.Hash | cyclonedx_v1_4.LicenseChoice | cyclonedx_v1_4.DataClassification | cyclonedx_v1_4.Commit | cyclonedx_v1_4.Patch | cyclonedx_v1_4.EvidenceCopyright | cyclonedx_v1_4.Issue | cyclonedx_v1_4.Note +} + +func convertArray[In inArrayElement, Out outArrayElement](in *[]In, convert func(*In) *Out) (out []*Out) { + if in == nil { + return nil + } + + out = make([]*Out, 0, len(*in)) + for _, e := range *in { + out = append(out, convert(&e)) + } + return out +} + +func convertAttachedText(in *cyclonedx.AttachedText) *cyclonedx_v1_4.AttachedText { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.AttachedText{ + ContentType: stringPtr(in.ContentType), + Encoding: stringPtr(in.Encoding), + Value: in.Content, + } +} + +func convertBOM(in *cyclonedx.BOM) *cyclonedx_v1_4.Bom { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Bom{ + SpecVersion: in.SpecVersion, + Version: pointer.Int32Ptr(int32(in.Version)), + SerialNumber: stringPtr(in.SerialNumber), + Metadata: convertMetadata(in.Metadata), + Components: convertArray(in.Components, convertComponent), + Services: convertArray(in.Services, convertService), + ExternalReferences: convertArray(in.ExternalReferences, convertExternalReference), + Dependencies: convertArray(in.Dependencies, convertDependency), + Compositions: convertArray(in.Compositions, convertComposition), + Vulnerabilities: nil, // convertArray(in.Vulnerabilities, convertVulnerability), + } +} + +func convertCommit(in *cyclonedx.Commit) *cyclonedx_v1_4.Commit { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Commit{ + Uid: stringPtr(in.UID), + Url: stringPtr(in.URL), + Author: convertIdentifiableAction(in.Author), + Committer: convertIdentifiableAction(in.Committer), + Message: stringPtr(in.Message), + } +} + +func convertComponent(in *cyclonedx.Component) *cyclonedx_v1_4.Component { + if in == nil { + return nil + } + + var evidence []*cyclonedx_v1_4.Evidence + if in.Evidence != nil { + evidence = []*cyclonedx_v1_4.Evidence{convertEvidence(in.Evidence)} + } + + return &cyclonedx_v1_4.Component{ + Type: convertComponentType(in.Type), + MimeType: stringPtr(in.MIMEType), + BomRef: stringPtr(in.BOMRef), + Supplier: convertOrganizationalEntity(in.Supplier), + Author: stringPtr(in.Author), + Publisher: stringPtr(in.Publisher), + Group: stringPtr(in.Group), + Name: in.Name, + Version: in.Version, + Description: stringPtr(in.Description), + Scope: convertScope(in.Scope), + Hashes: convertArray(in.Hashes, convertHash), + Licenses: convertArray(castLicenses(in.Licenses), convertLicenseChoice), + Copyright: stringPtr(in.Copyright), + Cpe: stringPtr(in.CPE), + Purl: stringPtr(in.PackageURL), + Swid: convertSwid(in.SWID), + Modified: in.Modified, + Pedigree: convertPedigree(in.Pedigree), + ExternalReferences: convertArray(in.ExternalReferences, convertExternalReference), + Components: convertArray(in.Components, convertComponent), + Properties: convertArray(in.Properties, convertProperty), + Evidence: evidence, + ReleaseNotes: convertReleaseNotes(in.ReleaseNotes), + } +} + +func convertComponentType(in cyclonedx.ComponentType) cyclonedx_v1_4.Classification { + switch in { + case cyclonedx.ComponentTypeApplication: + return cyclonedx_v1_4.Classification_CLASSIFICATION_APPLICATION + case cyclonedx.ComponentTypeContainer: + return cyclonedx_v1_4.Classification_CLASSIFICATION_CONTAINER + case cyclonedx.ComponentTypeDevice: + return cyclonedx_v1_4.Classification_CLASSIFICATION_DEVICE + case cyclonedx.ComponentTypeFile: + return cyclonedx_v1_4.Classification_CLASSIFICATION_FILE + case cyclonedx.ComponentTypeFirmware: + return cyclonedx_v1_4.Classification_CLASSIFICATION_FIRMWARE + case cyclonedx.ComponentTypeFramework: + return cyclonedx_v1_4.Classification_CLASSIFICATION_FRAMEWORK + case cyclonedx.ComponentTypeLibrary: + return cyclonedx_v1_4.Classification_CLASSIFICATION_LIBRARY + case cyclonedx.ComponentTypeOS: + return cyclonedx_v1_4.Classification_CLASSIFICATION_OPERATING_SYSTEM + default: + return cyclonedx_v1_4.Classification_CLASSIFICATION_NULL + } +} + +func convertComposition(in *cyclonedx.Composition) (out *cyclonedx_v1_4.Composition) { + if in == nil { + return nil + } + + out = &cyclonedx_v1_4.Composition{ + Aggregate: convertCompositionAggregate(in.Aggregate), + } + + if in.Assemblies != nil { + out.Assemblies = *(*[]string)(unsafe.Pointer(in.Assemblies)) + } + + if in.Dependencies != nil { + out.Dependencies = *(*[]string)(unsafe.Pointer(in.Dependencies)) + } + + return out +} + +func convertCompositionAggregate(in cyclonedx.CompositionAggregate) cyclonedx_v1_4.Aggregate { + switch in { + case cyclonedx.CompositionAggregateComplete: + return cyclonedx_v1_4.Aggregate_AGGREGATE_COMPLETE + case cyclonedx.CompositionAggregateIncomplete: + return cyclonedx_v1_4.Aggregate_AGGREGATE_INCOMPLETE + case cyclonedx.CompositionAggregateIncompleteFirstPartyOnly: + return cyclonedx_v1_4.Aggregate_AGGREGATE_INCOMPLETE_FIRST_PARTY_ONLY + case cyclonedx.CompositionAggregateIncompleteThirdPartyOnly: + return cyclonedx_v1_4.Aggregate_AGGREGATE_INCOMPLETE_THIRD_PARTY_ONLY + case cyclonedx.CompositionAggregateUnknown: + return cyclonedx_v1_4.Aggregate_AGGREGATE_UNKNOWN + case cyclonedx.CompositionAggregateNotSpecified: + return cyclonedx_v1_4.Aggregate_AGGREGATE_NOT_SPECIFIED + default: + return cyclonedx_v1_4.Aggregate_AGGREGATE_NOT_SPECIFIED + } +} + +func convertCopyright(in *cyclonedx.Copyright) *cyclonedx_v1_4.EvidenceCopyright { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.EvidenceCopyright{ + Text: in.Text, + } +} + +func convertDataClassification(in *cyclonedx.DataClassification) *cyclonedx_v1_4.DataClassification { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.DataClassification{ + Flow: convertDataFlow(in.Flow), + Value: in.Classification, + } +} + +func convertDataFlow(in cyclonedx.DataFlow) cyclonedx_v1_4.DataFlow { + switch in { + case cyclonedx.DataFlowBidirectional: + return cyclonedx_v1_4.DataFlow_DATA_FLOW_BI_DIRECTIONAL + case cyclonedx.DataFlowInbound: + return cyclonedx_v1_4.DataFlow_DATA_FLOW_INBOUND + case cyclonedx.DataFlowOutbound: + return cyclonedx_v1_4.DataFlow_DATA_FLOW_OUTBOUND + case cyclonedx.DataFlowUnknown: + return cyclonedx_v1_4.DataFlow_DATA_FLOW_UNKNOWN + default: + return cyclonedx_v1_4.DataFlow_DATA_FLOW_NULL + } +} + +func convertDependency(in *cyclonedx.Dependency) *cyclonedx_v1_4.Dependency { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Dependency{ + Ref: in.Ref, + Dependencies: convertArray(in.Dependencies, convertDependency), + } +} + +func convertDiff(in *cyclonedx.Diff) *cyclonedx_v1_4.Diff { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Diff{ + Text: convertAttachedText(in.Text), + Url: stringPtr(in.URL), + } +} + +func convertEvidence(in *cyclonedx.Evidence) *cyclonedx_v1_4.Evidence { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Evidence{ + Licenses: convertArray(castLicenses(in.Licenses), convertLicenseChoice), + Copyright: convertArray(in.Copyright, convertCopyright), + } +} + +func convertExternalReference(in *cyclonedx.ExternalReference) *cyclonedx_v1_4.ExternalReference { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.ExternalReference{ + Type: convertExternalReferenceType(in.Type), + Url: in.URL, + Comment: stringPtr(in.Comment), + Hashes: convertArray(in.Hashes, convertHash), + } +} + +func convertExternalReferenceType(in cyclonedx.ExternalReferenceType) cyclonedx_v1_4.ExternalReferenceType { + switch in { + case cyclonedx.ERTypeAdvisories: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_ADVISORIES + case cyclonedx.ERTypeBOM: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_BOM + case cyclonedx.ERTypeBuildMeta: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_BUILD_META + case cyclonedx.ERTypeBuildSystem: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_BUILD_SYSTEM + case cyclonedx.ERTypeChat: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_CHAT + case cyclonedx.ERTypeDistribution: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_DISTRIBUTION + case cyclonedx.ERTypeDocumentation: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_DOCUMENTATION + case cyclonedx.ERTypeLicense: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_LICENSE + case cyclonedx.ERTypeMailingList: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_MAILING_LIST + case cyclonedx.ERTypeOther: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_OTHER + case cyclonedx.ERTypeIssueTracker: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_ISSUE_TRACKER + case cyclonedx.ERTypeReleaseNotes: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_OTHER // ?? + case cyclonedx.ERTypeSocial: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_SOCIAL + case cyclonedx.ERTypeSupport: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_SUPPORT + case cyclonedx.ERTypeVCS: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_VCS + case cyclonedx.ERTypeWebsite: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_WEBSITE + default: + return cyclonedx_v1_4.ExternalReferenceType_EXTERNAL_REFERENCE_TYPE_OTHER + } +} + +func convertHash(in *cyclonedx.Hash) *cyclonedx_v1_4.Hash { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Hash{ + Alg: convertHashAlgo(in.Algorithm), + Value: in.Value, + } +} + +func convertHashAlgo(in cyclonedx.HashAlgorithm) cyclonedx_v1_4.HashAlg { + switch in { + case cyclonedx.HashAlgoMD5: + return cyclonedx_v1_4.HashAlg_HASH_ALG_MD_5 + case cyclonedx.HashAlgoSHA1: + return cyclonedx_v1_4.HashAlg_HASH_ALG_SHA_1 + case cyclonedx.HashAlgoSHA256: + return cyclonedx_v1_4.HashAlg_HASH_ALG_SHA_256 + case cyclonedx.HashAlgoSHA384: + return cyclonedx_v1_4.HashAlg_HASH_ALG_SHA_384 + case cyclonedx.HashAlgoSHA512: + return cyclonedx_v1_4.HashAlg_HASH_ALG_SHA_512 + case cyclonedx.HashAlgoSHA3_256: + return cyclonedx_v1_4.HashAlg_HASH_ALG_SHA_3_256 + case cyclonedx.HashAlgoSHA3_512: + return cyclonedx_v1_4.HashAlg_HASH_ALG_SHA_3_512 + case cyclonedx.HashAlgoBlake2b_256: + return cyclonedx_v1_4.HashAlg_HASH_ALG_BLAKE_2_B_256 + case cyclonedx.HashAlgoBlake2b_384: + return cyclonedx_v1_4.HashAlg_HASH_ALG_BLAKE_2_B_384 + case cyclonedx.HashAlgoBlake2b_512: + return cyclonedx_v1_4.HashAlg_HASH_ALG_BLAKE_2_B_512 + case cyclonedx.HashAlgoBlake3: + return cyclonedx_v1_4.HashAlg_HASH_ALG_BLAKE_3 + default: + return cyclonedx_v1_4.HashAlg_HASH_ALG_NULL + } +} + +func convertIdentifiableAction(in *cyclonedx.IdentifiableAction) *cyclonedx_v1_4.IdentifiableAction { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.IdentifiableAction{ + Timestamp: convertTimestamp(in.Timestamp), + Name: stringPtr(in.Name), + Email: stringPtr(in.Email), + } +} + +func convertIssue(in *cyclonedx.Issue) *cyclonedx_v1_4.Issue { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Issue{ + Type: convertIssueType(in.Type), + Id: stringPtr(in.ID), + Name: stringPtr(in.Name), + Description: stringPtr(in.Description), + Source: convertSource(in.Source), + References: strSliceDeref(in.References), + } +} + +func convertIssueType(in cyclonedx.IssueType) cyclonedx_v1_4.IssueClassification { + switch in { + case cyclonedx.IssueTypeDefect: + return cyclonedx_v1_4.IssueClassification_ISSUE_CLASSIFICATION_DEFECT + case cyclonedx.IssueTypeEnhancement: + return cyclonedx_v1_4.IssueClassification_ISSUE_CLASSIFICATION_ENHANCEMENT + case cyclonedx.IssueTypeSecurity: + return cyclonedx_v1_4.IssueClassification_ISSUE_CLASSIFICATION_SECURITY + default: + return cyclonedx_v1_4.IssueClassification_ISSUE_CLASSIFICATION_NULL + } +} + +func castLicenses(in *cyclonedx.Licenses) *[]cyclonedx.LicenseChoice { + if in == nil { + return nil + } + + var l []cyclonedx.LicenseChoice = *in + return &l +} + +func convertLicense(in *cyclonedx.License) (out *cyclonedx_v1_4.License) { + if in == nil { + return nil + } + + out = &cyclonedx_v1_4.License{ + Text: convertAttachedText(in.Text), + Url: stringPtr(in.URL), + } + + if in.ID != "" { + out.License = &cyclonedx_v1_4.License_Id{ + Id: in.ID, + } + } + + if in.Name != "" { + out.License = &cyclonedx_v1_4.License_Name{ + Name: in.Name, + } + } + + return out +} + +func convertLicenseChoice(in *cyclonedx.LicenseChoice) *cyclonedx_v1_4.LicenseChoice { + if in == nil { + return nil + } + + if in.License != nil { + return &cyclonedx_v1_4.LicenseChoice{ + Choice: &cyclonedx_v1_4.LicenseChoice_License{ + License: convertLicense(in.License), + }, + } + } + + if in.Expression != "" { + return &cyclonedx_v1_4.LicenseChoice{ + Choice: &cyclonedx_v1_4.LicenseChoice_Expression{ + Expression: in.Expression, + }, + } + } + + return nil +} + +func convertMetadata(in *cyclonedx.Metadata) *cyclonedx_v1_4.Metadata { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Metadata{ + Timestamp: convertTimestamp(in.Timestamp), + Tools: convertArray(in.Tools, convertTool), + Authors: convertArray(in.Authors, convertOrganizationalContact), + Component: convertComponent(in.Component), + Manufacture: convertOrganizationalEntity(in.Manufacture), + Supplier: convertOrganizationalEntity(in.Supplier), + Licenses: convertLicenseChoice(&(*in.Licenses)[0]), + Properties: convertArray(in.Properties, convertProperty), + } +} + +func convertNote(in *cyclonedx.Note) *cyclonedx_v1_4.Note { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Note{ + Locale: stringPtr(in.Locale), + Text: convertAttachedText(&in.Text), + } +} + +func convertOrganizationalContact(in *cyclonedx.OrganizationalContact) *cyclonedx_v1_4.OrganizationalContact { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.OrganizationalContact{ + Name: stringPtr(in.Name), + Email: stringPtr(in.Email), + Phone: stringPtr(in.Phone), + } +} + +func convertOrganizationalEntity(in *cyclonedx.OrganizationalEntity) *cyclonedx_v1_4.OrganizationalEntity { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.OrganizationalEntity{ + Name: stringPtr(in.Name), + Url: strSliceDeref(in.URL), + Contact: convertArray(in.Contact, convertOrganizationalContact), + } +} + +func convertPatch(in *cyclonedx.Patch) *cyclonedx_v1_4.Patch { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Patch{ + Type: convertPatchType(in.Type), + Diff: convertDiff(in.Diff), + Resolves: convertArray(in.Resolves, convertIssue), + } +} + +func convertPatchType(in cyclonedx.PatchType) cyclonedx_v1_4.PatchClassification { + switch in { + case cyclonedx.PatchTypeBackport: + return cyclonedx_v1_4.PatchClassification_PATCH_CLASSIFICATION_BACKPORT + case cyclonedx.PatchTypeCherryPick: + return cyclonedx_v1_4.PatchClassification_PATCH_CLASSIFICATION_CHERRY_PICK + case cyclonedx.PatchTypeMonkey: + return cyclonedx_v1_4.PatchClassification_PATCH_CLASSIFICATION_MONKEY + case cyclonedx.PatchTypeUnofficial: + return cyclonedx_v1_4.PatchClassification_PATCH_CLASSIFICATION_UNOFFICIAL + default: + return cyclonedx_v1_4.PatchClassification_PATCH_CLASSIFICATION_NULL + } +} + +func convertPedigree(in *cyclonedx.Pedigree) *cyclonedx_v1_4.Pedigree { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Pedigree{ + Ancestors: convertArray(in.Ancestors, convertComponent), + Descendants: convertArray(in.Descendants, convertComponent), + Variants: convertArray(in.Variants, convertComponent), + Commits: convertArray(in.Commits, convertCommit), + Patches: convertArray(in.Patches, convertPatch), + Notes: stringPtr(in.Notes), + } +} + +func convertProperty(in *cyclonedx.Property) *cyclonedx_v1_4.Property { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Property{ + Name: in.Name, + Value: stringPtr(in.Value), + } +} + +func convertReleaseNotes(in *cyclonedx.ReleaseNotes) *cyclonedx_v1_4.ReleaseNotes { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.ReleaseNotes{ + Type: in.Type, + Title: stringPtr(in.Title), + FeaturedImage: stringPtr(in.FeaturedImage), + SocialImage: stringPtr(in.SocialImage), + Description: stringPtr(in.Description), + Timestamp: convertTimestamp(in.Timestamp), + Aliases: strSliceDeref(in.Aliases), + Tags: strSliceDeref(in.Tags), + Resolves: convertArray(in.Resolves, convertIssue), + Notes: convertArray(in.Notes, convertNote), + Properties: convertArray(in.Properties, convertProperty), + } +} + +func convertScope(in cyclonedx.Scope) *cyclonedx_v1_4.Scope { + if in == "" { + return nil + } + + ptr := func(v cyclonedx_v1_4.Scope) *cyclonedx_v1_4.Scope { + return &v + } + + switch in { + case cyclonedx.ScopeExcluded: + return ptr(cyclonedx_v1_4.Scope_SCOPE_EXCLUDED) + case cyclonedx.ScopeOptional: + return ptr(cyclonedx_v1_4.Scope_SCOPE_OPTIONAL) + case cyclonedx.ScopeRequired: + return ptr(cyclonedx_v1_4.Scope_SCOPE_REQUIRED) + default: + return ptr(cyclonedx_v1_4.Scope_SCOPE_UNSPECIFIED) + } +} + +func convertService(in *cyclonedx.Service) *cyclonedx_v1_4.Service { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Service{ + BomRef: stringPtr(in.BOMRef), + Provider: convertOrganizationalEntity(in.Provider), + Group: stringPtr(in.Group), + Name: in.Name, + Version: stringPtr(in.Version), + Description: stringPtr(in.Description), + Endpoints: strSliceDeref(in.Endpoints), + Authenticated: in.Authenticated, + XTrustBoundary: in.CrossesTrustBoundary, + Data: convertArray(in.Data, convertDataClassification), + Licenses: convertArray(castLicenses(in.Licenses), convertLicenseChoice), + ExternalReferences: convertArray(in.ExternalReferences, convertExternalReference), + Services: convertArray(in.Services, convertService), + Properties: convertArray(in.Properties, convertProperty), + ReleaseNotes: convertReleaseNotes(in.ReleaseNotes), + } +} + +func convertSource(in *cyclonedx.Source) *cyclonedx_v1_4.Source { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Source{ + Name: stringPtr(in.Name), + Url: stringPtr(in.URL), + } +} + +func convertSwid(in *cyclonedx.SWID) *cyclonedx_v1_4.Swid { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Swid{ + TagId: in.TagID, + Name: in.Name, + Version: stringPtr(in.Version), + TagVersion: pointer.Int32Ptr(int32(*in.TagVersion)), + Patch: in.Patch, + Text: convertAttachedText(in.Text), + Url: stringPtr(in.URL), + } +} + +func convertTimestamp(in string) *timestamppb.Timestamp { + ts, err := time.Parse("CHECK FORMAT", in) + if err != nil { + return nil + } else { + return timestamppb.New(ts) + } +} + +func convertTool(in *cyclonedx.Tool) *cyclonedx_v1_4.Tool { + if in == nil { + return nil + } + + return &cyclonedx_v1_4.Tool{ + Vendor: stringPtr(in.Vendor), + Name: stringPtr(in.Name), + Version: stringPtr(in.Version), + Hashes: convertArray(in.Hashes, convertHash), + ExternalReferences: convertArray(in.ExternalReferences, convertExternalReference), + } +} + +// func convertVulnerability(in *cyclonedx.Vulnerability) *cyclonedx_v1_4.Vulnerability { +// if in == nil { +// return nil +// } + +// return &cyclonedx_v1_4.Vulnerability{} +// } diff --git a/pkg/collector/corechecks/sbom/processor.go b/pkg/collector/corechecks/sbom/processor.go new file mode 100644 index 00000000000000..8f4e15c12a1422 --- /dev/null +++ b/pkg/collector/corechecks/sbom/processor.go @@ -0,0 +1,77 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2022-present Datadog, Inc. + +package sbom + +import ( + "time" + + "github.com/DataDog/datadog-agent/pkg/aggregator" + "github.com/DataDog/datadog-agent/pkg/util/log" + "github.com/DataDog/datadog-agent/pkg/workloadmeta" + + "github.com/DataDog/agent-payload/v5/sbom" + model "github.com/DataDog/agent-payload/v5/sbom" +) + +var /* const */ ( + sourceAgent = "agent" +) + +type processor struct { + queue chan *model.SBOMEntity +} + +func newProcessor(sender aggregator.Sender, maxNbItem int, maxRetentionTime time.Duration) *processor { + return &processor{ + queue: newQueue(maxNbItem, maxRetentionTime, func(entities []*model.SBOMEntity) { + sender.SBOM([]sbom.SBOMPayload{ + { + Version: 1, + Source: &sourceAgent, + Entities: entities, + }, + }) + }), + } +} + +func (p *processor) processEvents(evBundle workloadmeta.EventBundle) { + close(evBundle.Ch) + + log.Tracef("Processing %d events", len(evBundle.Events)) + + for _, event := range evBundle.Events { + p.processSBOM(event.Entity.(*workloadmeta.ContainerImageMetadata)) + } +} + +func (p *processor) processRefresh(allImages []*workloadmeta.ContainerImageMetadata) { + // So far, the check is refreshing all the images every 5 minutes all together. + for _, img := range allImages { + p.processSBOM(img) + } +} + +func (p *processor) processSBOM(img *workloadmeta.ContainerImageMetadata) { + if img.CycloneDXBOM == nil { + return + } + + p.queue <- &model.SBOMEntity{ + Type: model.SBOMSourceType_CONTAINER_IMAGE_LAYERS, + Id: img.ID, + GeneratedAt: nil, + Tags: img.RepoTags, + InUse: true, // TODO: compute this field + Sbom: &sbom.SBOMEntity_Cyclonedx{ + Cyclonedx: convertBOM(img.CycloneDXBOM), + }, + } +} + +func (p *processor) stop() { + close(p.queue) +} diff --git a/pkg/collector/corechecks/sbom/processor_test.go b/pkg/collector/corechecks/sbom/processor_test.go new file mode 100644 index 00000000000000..bdc24ede84c38a --- /dev/null +++ b/pkg/collector/corechecks/sbom/processor_test.go @@ -0,0 +1,149 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2022-present Datadog, Inc. + +package sbom + +import ( + "strconv" + "testing" + "time" + + "github.com/CycloneDX/cyclonedx-go" + "github.com/DataDog/agent-payload/v5/cyclonedx_v1_4" + model "github.com/DataDog/agent-payload/v5/sbom" + "github.com/DataDog/datadog-agent/pkg/aggregator/mocksender" + "github.com/DataDog/datadog-agent/pkg/collector/check" + "github.com/DataDog/datadog-agent/pkg/util/pointer" + "github.com/DataDog/datadog-agent/pkg/workloadmeta" + "github.com/stretchr/testify/mock" +) + +func TestProcessEvents(t *testing.T) { + sender := mocksender.NewMockSender(check.ID("")) + sender.On("SBOM", mock.Anything, mock.Anything).Return() + p := newProcessor(sender, 2, 50*time.Millisecond) + + for i := 0; i < 3; i++ { + p.processEvents(workloadmeta.EventBundle{ + Events: []workloadmeta.Event{ + { + Type: workloadmeta.EventTypeSet, + Entity: &workloadmeta.ContainerImageMetadata{ + EntityID: workloadmeta.EntityID{ + Kind: workloadmeta.KindContainerImageMetadata, + ID: strconv.Itoa(i), + }, + CycloneDXBOM: &cyclonedx.BOM{ + SpecVersion: "1.4", + Version: 42, + Components: &[]cyclonedx.Component{ + { + Name: strconv.Itoa(100 * i), + }, + { + Name: strconv.Itoa(100*i + 1), + }, + { + Name: strconv.Itoa(100*i + 2), + }, + }, + }, + }, + }, + }, + Ch: make(chan struct{}), + }) + } + + sender.AssertNumberOfCalls(t, "SBOM", 1) + sender.AssertSBOM(t, []model.SBOMPayload{ + { + Version: 1, + Source: &sourceAgent, + Entities: []*model.SBOMEntity{ + { + Type: model.SBOMSourceType_CONTAINER_IMAGE_LAYERS, + Id: "0", + InUse: true, + Sbom: &model.SBOMEntity_Cyclonedx{ + Cyclonedx: &cyclonedx_v1_4.Bom{ + SpecVersion: "1.4", + Version: pointer.Int32Ptr(42), + Components: []*cyclonedx_v1_4.Component{ + { + Name: "0", + }, + { + Name: "1", + }, + { + Name: "2", + }, + }, + }, + }, + }, + { + Type: model.SBOMSourceType_CONTAINER_IMAGE_LAYERS, + Id: "1", + InUse: true, + Sbom: &model.SBOMEntity_Cyclonedx{ + Cyclonedx: &cyclonedx_v1_4.Bom{ + SpecVersion: "1.4", + Version: pointer.Int32Ptr(42), + Components: []*cyclonedx_v1_4.Component{ + { + Name: "100", + }, + { + Name: "101", + }, + { + Name: "102", + }, + }, + }, + }, + }, + }, + }, + }) + + time.Sleep(100 * time.Millisecond) + + sender.AssertNumberOfCalls(t, "SBOM", 2) + sender.AssertSBOM(t, []model.SBOMPayload{ + { + Version: 1, + Source: &sourceAgent, + Entities: []*model.SBOMEntity{ + { + Type: model.SBOMSourceType_CONTAINER_IMAGE_LAYERS, + Id: "2", + InUse: true, + Sbom: &model.SBOMEntity_Cyclonedx{ + Cyclonedx: &cyclonedx_v1_4.Bom{ + SpecVersion: "1.4", + Version: pointer.Int32Ptr(42), + Components: []*cyclonedx_v1_4.Component{ + { + Name: "200", + }, + { + Name: "201", + }, + { + Name: "202", + }, + }, + }, + }, + }, + }, + }, + }) + + p.stop() +} diff --git a/pkg/collector/corechecks/sbom/queue.go b/pkg/collector/corechecks/sbom/queue.go new file mode 100644 index 00000000000000..8fd3b763a9fad1 --- /dev/null +++ b/pkg/collector/corechecks/sbom/queue.go @@ -0,0 +1,71 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2022-present Datadog, Inc. + +package sbom + +import ( + "time" + + model "github.com/DataDog/agent-payload/v5/sbom" +) + +type queue struct { + maxNbItem int + maxRetentionTime time.Duration + flushCB func([]*model.SBOMEntity) + enqueueCh chan *model.SBOMEntity + data []*model.SBOMEntity + timer *time.Timer +} + +// newQueue returns a chan to enqueue newly discovered container images +func newQueue(maxNbItem int, maxRetentionTime time.Duration, flushCB func([]*model.SBOMEntity)) chan *model.SBOMEntity { + q := queue{ + maxNbItem: maxNbItem, + maxRetentionTime: maxRetentionTime, + flushCB: flushCB, + enqueueCh: make(chan *model.SBOMEntity), + data: make([]*model.SBOMEntity, 0, maxNbItem), + timer: time.NewTimer(maxRetentionTime), + } + + if !q.timer.Stop() { + <-q.timer.C + } + + go func() { + for { + select { + case <-q.timer.C: + q.flush() + case sbom, more := <-q.enqueueCh: + if !more { + return + } + q.enqueue(sbom) + } + } + }() + + return q.enqueueCh +} + +func (q *queue) enqueue(elem *model.SBOMEntity) { + if len(q.data) == 0 { + q.timer.Reset(q.maxRetentionTime) + } + + q.data = append(q.data, elem) + + if len(q.data) == q.maxNbItem { + q.flush() + } +} + +func (q *queue) flush() { + q.timer.Stop() + q.flushCB(q.data) + q.data = make([]*model.SBOMEntity, 0, q.maxNbItem) +} diff --git a/pkg/collector/corechecks/sbom/queue_test.go b/pkg/collector/corechecks/sbom/queue_test.go new file mode 100644 index 00000000000000..4a7d2beb0a8738 --- /dev/null +++ b/pkg/collector/corechecks/sbom/queue_test.go @@ -0,0 +1,71 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2022-present Datadog, Inc. + +package sbom + +import ( + "strconv" + "sync" + "testing" + "time" + + model "github.com/DataDog/agent-payload/v5/sbom" + "github.com/stretchr/testify/assert" +) + +func newMockFlush() (callback func([]*model.SBOMEntity), getAccumulator func() [][]*model.SBOMEntity) { + accumulator := [][]*model.SBOMEntity{} + var mutex sync.RWMutex + + callback = func(sbom []*model.SBOMEntity) { + mutex.Lock() + defer mutex.Unlock() + accumulator = append(accumulator, sbom) + } + + getAccumulator = func() [][]*model.SBOMEntity { + mutex.RLock() + defer mutex.RUnlock() + return accumulator + } + + return +} + +func TestQueue(t *testing.T) { + callback, accumulator := newMockFlush() + queue := newQueue(3, 50*time.Millisecond, callback) + + for i := 0; i <= 10; i++ { + queue <- &model.SBOMEntity{ + Id: strconv.Itoa(i), + } + } + + assert.Equal( + t, + accumulator(), + [][]*model.SBOMEntity{ + {{Id: "0"}, {Id: "1"}, {Id: "2"}}, + {{Id: "3"}, {Id: "4"}, {Id: "5"}}, + {{Id: "6"}, {Id: "7"}, {Id: "8"}}, + }, + ) + + time.Sleep(100 * time.Millisecond) + + assert.Equal( + t, + accumulator(), + [][]*model.SBOMEntity{ + {{Id: "0"}, {Id: "1"}, {Id: "2"}}, + {{Id: "3"}, {Id: "4"}, {Id: "5"}}, + {{Id: "6"}, {Id: "7"}, {Id: "8"}}, + {{Id: "9"}, {Id: "10"}}, + }, + ) + + close(queue) +} diff --git a/pkg/util/pointer/pointer.go b/pkg/util/pointer/pointer.go index ee1f86d2efbe5f..9abdff5278750b 100644 --- a/pkg/util/pointer/pointer.go +++ b/pkg/util/pointer/pointer.go @@ -10,6 +10,11 @@ func Int64Ptr(v int64) *int64 { return &v } +// Int32Ptr returns a pointer from a value. It will allocate a new heap object for it. +func Int32Ptr(v int32) *int32 { + return &v +} + // UInt16Ptr returns a pointer from a value. It will allocate a new heap object for it. func UInt16Ptr(v uint16) *uint16 { return &v diff --git a/pkg/workloadmeta/types.go b/pkg/workloadmeta/types.go index ec41085ac3a03b..0b526736f6a52a 100644 --- a/pkg/workloadmeta/types.go +++ b/pkg/workloadmeta/types.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/CycloneDX/cyclonedx-go" "github.com/mohae/deepcopy" v1 "github.com/opencontainers/image-spec/specs-go/v1" @@ -636,6 +637,7 @@ type ContainerImageMetadata struct { Architecture string Variant string Layers []ContainerImageLayer + CycloneDXBOM *cyclonedx.BOM } // ContainerImageLayer represents a layer of a container image diff --git a/releasenotes/notes/sbom-01e923031b7d118b.yaml b/releasenotes/notes/sbom-01e923031b7d118b.yaml new file mode 100644 index 00000000000000..6b5be8a38e6373 --- /dev/null +++ b/releasenotes/notes/sbom-01e923031b7d118b.yaml @@ -0,0 +1,11 @@ +# Each section from every release note are combined when the +# CHANGELOG.rst is rendered. So the text needs to be worded so that +# it does not depend on any information only available in another +# section. This may mean repeating some details, but each section +# must be readable independently of the other. +# +# Each section note must be formatted as reStructuredText. +--- +features: + - | + Add a new ``sbom`` core check to collect the software bill of materials of containers.