From bbccb4484a58b3529f2580a19a5a3e4a88017f6e Mon Sep 17 00:00:00 2001 From: Masahiro331 Date: Thu, 15 Dec 2022 23:20:21 +0900 Subject: [PATCH] feat(sbom): better support for third-party SBOMs (#3262) Co-authored-by: knqyf263 --- pkg/fanal/analyzer/analyzer.go | 6 +- pkg/fanal/analyzer/analyzer_test.go | 2 +- pkg/fanal/analyzer/command/apk/apk.go | 2 +- pkg/fanal/analyzer/command/apk/apk_test.go | 6 +- pkg/fanal/analyzer/pkg/dpkg/dpkg_test.go | 12 +- pkg/fanal/analyzer/pkg/rpm/rpm.go | 2 +- pkg/fanal/analyzer/pkg/rpm/rpm_test.go | 10 +- pkg/fanal/applier/applier_test.go | 4 +- pkg/fanal/applier/docker_test.go | 4 +- pkg/fanal/test/integration/containerd_test.go | 4 +- pkg/fanal/test/integration/library_test.go | 9 +- pkg/fanal/types/artifact.go | 24 ++- pkg/purl/purl.go | 23 ++- pkg/sbom/cyclonedx/marshal.go | 8 +- .../testdata/happy/third-party-bom-no-os.json | 36 ++++ .../testdata/happy/third-party-bom.json | 49 +++++ pkg/sbom/cyclonedx/unmarshal.go | 185 +++++++++++++----- pkg/sbom/cyclonedx/unmarshal_test.go | 60 +++++- pkg/scanner/local/scan.go | 4 +- 19 files changed, 342 insertions(+), 108 deletions(-) create mode 100644 pkg/sbom/cyclonedx/testdata/happy/third-party-bom-no-os.json create mode 100644 pkg/sbom/cyclonedx/testdata/happy/third-party-bom.json diff --git a/pkg/fanal/analyzer/analyzer.go b/pkg/fanal/analyzer/analyzer.go index 8685c73d79d3..213d5e0ec09f 100644 --- a/pkg/fanal/analyzer/analyzer.go +++ b/pkg/fanal/analyzer/analyzer.go @@ -74,7 +74,7 @@ type analyzer interface { type configAnalyzer interface { Type() Type Version() int - Analyze(targetOS types.OS, content []byte) ([]types.Package, error) + Analyze(targetOS types.OS, content []byte) (types.Packages, error) Required(osFound types.OS) bool } @@ -179,9 +179,7 @@ func (r *AnalysisResult) Sort() { }) for _, pi := range r.PackageInfos { - sort.Slice(pi.Packages, func(i, j int) bool { - return pi.Packages[i].Name < pi.Packages[j].Name - }) + sort.Sort(pi.Packages) } // Language-specific packages diff --git a/pkg/fanal/analyzer/analyzer_test.go b/pkg/fanal/analyzer/analyzer_test.go index cf1ef2aaff68..a267eb66540e 100644 --- a/pkg/fanal/analyzer/analyzer_test.go +++ b/pkg/fanal/analyzer/analyzer_test.go @@ -33,7 +33,7 @@ func (mockConfigAnalyzer) Required(targetOS types.OS) bool { return targetOS.Family == "alpine" } -func (mockConfigAnalyzer) Analyze(targetOS types.OS, configBlob []byte) ([]types.Package, error) { +func (mockConfigAnalyzer) Analyze(targetOS types.OS, configBlob []byte) (types.Packages, error) { if string(configBlob) != `foo` { return nil, errors.New("error") } diff --git a/pkg/fanal/analyzer/command/apk/apk.go b/pkg/fanal/analyzer/command/apk/apk.go index 5201e5b3d33f..a05f94019ba0 100644 --- a/pkg/fanal/analyzer/command/apk/apk.go +++ b/pkg/fanal/analyzer/command/apk/apk.go @@ -59,7 +59,7 @@ type pkg struct { type version map[string]int -func (a alpineCmdAnalyzer) Analyze(targetOS types.OS, configBlob []byte) ([]types.Package, error) { +func (a alpineCmdAnalyzer) Analyze(targetOS types.OS, configBlob []byte) (types.Packages, error) { var apkIndexArchive *apkIndex var err error if apkIndexArchive, err = a.fetchApkIndexArchive(targetOS); err != nil { diff --git a/pkg/fanal/analyzer/command/apk/apk_test.go b/pkg/fanal/analyzer/command/apk/apk_test.go index 6ef043b8ea53..3f49bf1927dc 100644 --- a/pkg/fanal/analyzer/command/apk/apk_test.go +++ b/pkg/fanal/analyzer/command/apk/apk_test.go @@ -37,7 +37,7 @@ func TestAnalyze(t *testing.T) { var tests = map[string]struct { args args apkIndexArchivePath string - expected []types.Package + expected types.Packages }{ "old": { args: args{ @@ -306,9 +306,7 @@ func TestAnalyze(t *testing.T) { t.Run(testName, func(t *testing.T) { apkIndexArchiveURL = v.apkIndexArchivePath actual, _ := analyzer.Analyze(v.args.targetOS, v.args.configBlob) - sort.Slice(actual, func(i, j int) bool { - return actual[i].Name < actual[j].Name - }) + sort.Sort(actual) assert.Equal(t, v.expected, actual) }) } diff --git a/pkg/fanal/analyzer/pkg/dpkg/dpkg_test.go b/pkg/fanal/analyzer/pkg/dpkg/dpkg_test.go index f40ad75198de..1fff8e880330 100644 --- a/pkg/fanal/analyzer/pkg/dpkg/dpkg_test.go +++ b/pkg/fanal/analyzer/pkg/dpkg/dpkg_test.go @@ -1028,7 +1028,7 @@ func Test_dpkgAnalyzer_Analyze(t *testing.T) { // Sort the result for consistency for i := range got.PackageInfos { - got.PackageInfos[i].Packages = sortPkgs(got.PackageInfos[i].Packages) + sort.Sort(got.PackageInfos[i].Packages) } assert.Equal(t, tt.wantErr, err != nil, err) @@ -1037,16 +1037,6 @@ func Test_dpkgAnalyzer_Analyze(t *testing.T) { } } -func sortPkgs(pkgs []types.Package) []types.Package { - sort.Slice(pkgs, func(i, j int) bool { - if pkgs[i].Name != pkgs[j].Name { - return pkgs[i].Name < pkgs[j].Name - } - return pkgs[i].Version < pkgs[j].Version - }) - return pkgs -} - func Test_dpkgAnalyzer_Required(t *testing.T) { tests := []struct { name string diff --git a/pkg/fanal/analyzer/pkg/rpm/rpm.go b/pkg/fanal/analyzer/pkg/rpm/rpm.go index 3741f58285e5..9fe693dfa932 100644 --- a/pkg/fanal/analyzer/pkg/rpm/rpm.go +++ b/pkg/fanal/analyzer/pkg/rpm/rpm.go @@ -79,7 +79,7 @@ func (a rpmPkgAnalyzer) Analyze(_ context.Context, input analyzer.AnalysisInput) }, nil } -func (a rpmPkgAnalyzer) parsePkgInfo(rc io.Reader) ([]types.Package, []string, error) { +func (a rpmPkgAnalyzer) parsePkgInfo(rc io.Reader) (types.Packages, []string, error) { filePath, err := writeToTempFile(rc) if err != nil { return nil, nil, xerrors.Errorf("temp file error: %w", err) diff --git a/pkg/fanal/analyzer/pkg/rpm/rpm_test.go b/pkg/fanal/analyzer/pkg/rpm/rpm_test.go index c0a624710269..73dc9dda4721 100644 --- a/pkg/fanal/analyzer/pkg/rpm/rpm_test.go +++ b/pkg/fanal/analyzer/pkg/rpm/rpm_test.go @@ -14,7 +14,7 @@ import ( func TestParseRpmInfo(t *testing.T) { var tests = map[string]struct { path string - pkgs []types.Package + pkgs types.Packages }{ "Valid": { path: "./testdata/valid", @@ -588,12 +588,8 @@ func TestParseRpmInfo(t *testing.T) { got, _, err := a.parsePkgInfo(f) require.NoError(t, err) - sort.Slice(tc.pkgs, func(i, j int) bool { - return tc.pkgs[i].Name < tc.pkgs[j].Name - }) - sort.Slice(got, func(i, j int) bool { - return got[i].Name < got[j].Name - }) + sort.Sort(tc.pkgs) + sort.Sort(got) for i := range got { got[i].ID = "" diff --git a/pkg/fanal/applier/applier_test.go b/pkg/fanal/applier/applier_test.go index c69e88d8108f..970d86a3faf7 100644 --- a/pkg/fanal/applier/applier_test.go +++ b/pkg/fanal/applier/applier_test.go @@ -691,9 +691,7 @@ func TestApplier_ApplyLayers(t *testing.T) { require.NoError(t, err, tt.name) } - sort.Slice(got.Packages, func(i, j int) bool { - return got.Packages[i].Name < got.Packages[j].Name - }) + sort.Sort(got.Packages) for _, app := range got.Applications { sort.Slice(app.Libraries, func(i, j int) bool { return app.Libraries[i].Name < app.Libraries[j].Name diff --git a/pkg/fanal/applier/docker_test.go b/pkg/fanal/applier/docker_test.go index dbee2bd3b0f1..761912eafefd 100644 --- a/pkg/fanal/applier/docker_test.go +++ b/pkg/fanal/applier/docker_test.go @@ -771,9 +771,7 @@ func TestApplyLayers(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := applier.ApplyLayers(tt.inputLayers) - sort.Slice(got.Packages, func(i, j int) bool { - return got.Packages[i].Name < got.Packages[j].Name - }) + sort.Sort(got.Packages) sort.Slice(got.Applications, func(i, j int) bool { return got.Applications[i].FilePath < got.Applications[j].FilePath }) diff --git a/pkg/fanal/test/integration/containerd_test.go b/pkg/fanal/test/integration/containerd_test.go index 46c1f776ed40..7b00b387622c 100644 --- a/pkg/fanal/test/integration/containerd_test.go +++ b/pkg/fanal/test/integration/containerd_test.go @@ -642,7 +642,7 @@ func localImageTestWithNamespace(t *testing.T, namespace string) { require.NoError(t, err) defer golden.Close() - var wantPkgs []types.Package + var wantPkgs types.Packages err = json.NewDecoder(golden).Decode(&wantPkgs) require.NoError(t, err) @@ -768,7 +768,7 @@ func TestContainerd_PullImage(t *testing.T) { golden, err := os.Open(fmt.Sprintf("testdata/goldens/packages/%s.json.golden", tag)) require.NoError(t, err) - var wantPkgs []types.Package + var wantPkgs types.Packages err = json.NewDecoder(golden).Decode(&wantPkgs) require.NoError(t, err) diff --git a/pkg/fanal/test/integration/library_test.go b/pkg/fanal/test/integration/library_test.go index 77ffa723ba03..bbcbb36bcf90 100644 --- a/pkg/fanal/test/integration/library_test.go +++ b/pkg/fanal/test/integration/library_test.go @@ -251,12 +251,7 @@ func commonChecks(t *testing.T, detail types.ArtifactDetail, tc testCase) { func checkOSPackages(t *testing.T, detail types.ArtifactDetail, tc testCase) { // Sort OS packages for consistency - sort.Slice(detail.Packages, func(i, j int) bool { - if detail.Packages[i].Name != detail.Packages[j].Name { - return detail.Packages[i].Name < detail.Packages[j].Name - } - return detail.Packages[i].Version < detail.Packages[j].Version - }) + sort.Sort(detail.Packages) splitted := strings.Split(tc.remoteImageName, ":") goldenFile := fmt.Sprintf("testdata/goldens/packages/%s.json.golden", splitted[len(splitted)-1]) @@ -277,7 +272,7 @@ func checkOSPackages(t *testing.T, detail types.ArtifactDetail, tc testCase) { require.Equal(t, len(expectedPkgs), len(detail.Packages), tc.name) sort.Slice(expectedPkgs, func(i, j int) bool { return expectedPkgs[i].Name < expectedPkgs[j].Name }) - sort.Slice(detail.Packages, func(i, j int) bool { return detail.Packages[i].Name < detail.Packages[j].Name }) + sort.Sort(detail.Packages) for i := 0; i < len(expectedPkgs); i++ { require.Equal(t, expectedPkgs[i].Name, detail.Packages[i].Name, tc.name) diff --git a/pkg/fanal/types/artifact.go b/pkg/fanal/types/artifact.go index e90beab45663..0ea9279688aa 100644 --- a/pkg/fanal/types/artifact.go +++ b/pkg/fanal/types/artifact.go @@ -72,6 +72,26 @@ func (pkg *Package) Empty() bool { return pkg.Name == "" || pkg.Version == "" } +type Packages []Package + +func (pkgs Packages) Len() int { + return len(pkgs) +} + +func (pkgs Packages) Swap(i, j int) { + pkgs[i], pkgs[j] = pkgs[j], pkgs[i] +} + +func (pkgs Packages) Less(i, j int) bool { + switch { + case pkgs[i].Name != pkgs[j].Name: + return pkgs[i].Name < pkgs[j].Name + case pkgs[i].Version != pkgs[j].Version: + return pkgs[i].Version < pkgs[j].Version + } + return pkgs[i].FilePath < pkgs[j].FilePath +} + type SrcPackage struct { Name string `json:"name"` Version string `json:"version"` @@ -80,7 +100,7 @@ type SrcPackage struct { type PackageInfo struct { FilePath string - Packages []Package + Packages Packages } type Application struct { @@ -198,7 +218,7 @@ func (b *BlobInfo) ToArtifactDetail() ArtifactDetail { type ArtifactDetail struct { OS *OS `json:",omitempty"` Repository *Repository `json:",omitempty"` - Packages []Package `json:",omitempty"` + Packages Packages `json:",omitempty"` Applications []Application `json:",omitempty"` Misconfigurations []Misconfiguration `json:",omitempty"` Secrets []Secret `json:",omitempty"` diff --git a/pkg/purl/purl.go b/pkg/purl/purl.go index 7fa8e2652029..474ed67cc86c 100644 --- a/pkg/purl/purl.go +++ b/pkg/purl/purl.go @@ -9,7 +9,6 @@ import ( packageurl "github.com/package-url/packageurl-go" "golang.org/x/xerrors" - "github.com/aquasecurity/trivy/pkg/fanal/analyzer" "github.com/aquasecurity/trivy/pkg/fanal/analyzer/os" ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" "github.com/aquasecurity/trivy/pkg/scanner/utils" @@ -17,6 +16,7 @@ import ( ) const ( + TypeAPK = "apk" // not defined in github.com/package-url/packageurl-go TypeOCI = "oci" ) @@ -28,7 +28,7 @@ type PackageURL struct { func FromString(purl string) (*PackageURL, error) { p, err := packageurl.FromString(purl) if err != nil { - return nil, xerrors.Errorf("failed to parse purl: %w", err) + return nil, xerrors.Errorf("failed to parse purl(%s): %w", purl, err) } return &PackageURL{ @@ -57,10 +57,9 @@ func (p *PackageURL) Package() *ftypes.Package { pkg.Epoch = rpmVer.Epoch() } - // TODO: replace with packageurl.TypeApk once they add it. - // Return of packages without Namespace. - // OS packages does not have namespace. - if p.Namespace == "" || p.Type == packageurl.TypeRPM || p.Type == packageurl.TypeDebian || p.Type == string(analyzer.TypeApk) { + // Return packages without namespace. + // OS packages are not supposed to have namespace. + if p.Namespace == "" || p.IsOSPkg() { return pkg } @@ -76,8 +75,8 @@ func (p *PackageURL) Package() *ftypes.Package { return pkg } -// AppType returns an application type in Trivy -func (p *PackageURL) AppType() string { +// PackageType returns an application type in Trivy +func (p *PackageURL) PackageType() string { switch p.Type { case packageurl.TypeComposer: return ftypes.Composer @@ -101,6 +100,10 @@ func (p *PackageURL) AppType() string { return p.Type } +func (p *PackageURL) IsOSPkg() bool { + return p.Type == TypeAPK || p.Type == packageurl.TypeDebian || p.Type == packageurl.TypeRPM +} + func (p *PackageURL) BOMRef() string { // 'bom-ref' must be unique within BOM, but PURLs may conflict // when the same packages are installed in an artifact. @@ -141,7 +144,7 @@ func NewPackageURL(t string, metadata types.Metadata, pkg ftypes.Package) (Packa if metadata.OS != nil { namespace = metadata.OS.Family } - case string(analyzer.TypeApk): // TODO: replace with packageurl.TypeApk once they add it. + case TypeAPK: // TODO: replace with packageurl.TypeApk once they add it. qualifiers = append(qualifiers, parseApk(metadata.OS)...) if metadata.OS != nil { namespace = metadata.OS.Family @@ -306,7 +309,7 @@ func purlType(t string) string { case ftypes.Cocoapods: return packageurl.TypeSwift case os.Alpine: - return string(analyzer.TypeApk) + return TypeAPK case os.Debian, os.Ubuntu: return packageurl.TypeDebian case os.RedHat, os.CentOS, os.Rocky, os.Alma, diff --git a/pkg/sbom/cyclonedx/marshal.go b/pkg/sbom/cyclonedx/marshal.go index cc2f569ef661..714e0d38e7ea 100644 --- a/pkg/sbom/cyclonedx/marshal.go +++ b/pkg/sbom/cyclonedx/marshal.go @@ -23,7 +23,9 @@ import ( ) const ( - Namespace = "aquasecurity:trivy:" + ToolVendor = "aquasecurity" + ToolName = "trivy" + Namespace = ToolVendor + ":" + ToolName + ":" PropertySchemaVersion = "SchemaVersion" PropertyType = "Type" @@ -169,8 +171,8 @@ func (e *Marshaler) cdxMetadata() *cdx.Metadata { Timestamp: e.clock.Now().UTC().Format(timeLayout), Tools: &[]cdx.Tool{ { - Vendor: "aquasecurity", - Name: "trivy", + Vendor: ToolVendor, + Name: ToolName, Version: e.appVersion, }, }, diff --git a/pkg/sbom/cyclonedx/testdata/happy/third-party-bom-no-os.json b/pkg/sbom/cyclonedx/testdata/happy/third-party-bom-no-os.json new file mode 100644 index 000000000000..5996bd7fa952 --- /dev/null +++ b/pkg/sbom/cyclonedx/testdata/happy/third-party-bom-no-os.json @@ -0,0 +1,36 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:c986ba94-e37d-49c8-9e30-96daccd0415b", + "version": 1, + "metadata": { + "timestamp": "2022-05-28T10:20:03.79527Z", + "component": { + "bom-ref": "0f585d64-4815-4b72-92c5-97dae191fa4a", + "type": "container", + "name": "test-project" + } + }, + "components": [ + { + "bom-ref": "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0", + "type": "library", + "name": "musl", + "version": "1.2.3-r0", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0" + }, + { + "bom-ref": "pkg:composer/pear/log@1.13.1", + "type": "library", + "name": "pear/log", + "version": "1.13.1", + "purl": "pkg:composer/pear/log@1.13.1" + } + ], + "vulnerabilities": [] +} \ No newline at end of file diff --git a/pkg/sbom/cyclonedx/testdata/happy/third-party-bom.json b/pkg/sbom/cyclonedx/testdata/happy/third-party-bom.json new file mode 100644 index 000000000000..d3b41ba9be61 --- /dev/null +++ b/pkg/sbom/cyclonedx/testdata/happy/third-party-bom.json @@ -0,0 +1,49 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:c986ba94-e37d-49c8-9e30-96daccd0415b", + "version": 1, + "metadata": { + "timestamp": "2022-05-28T10:20:03.79527Z", + "component": { + "bom-ref": "0f585d64-4815-4b72-92c5-97dae191fa4a", + "type": "container", + "name": "test-project" + } + }, + "components": [ + { + "bom-ref": "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0", + "type": "library", + "name": "musl", + "version": "1.2.3-r0", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0" + }, + { + "bom-ref": "60e9f57b-d4a6-4f71-ad14-0893ac609182", + "type": "operating-system", + "name": "alpine", + "version": "3.16.0" + }, + { + "bom-ref": "pkg:composer/pear/log@1.13.1", + "type": "library", + "name": "pear/log", + "version": "1.13.1", + "purl": "pkg:composer/pear/log@1.13.1" + }, + { + "bom-ref": "pkg:composer/pear/pear_exception@v1.0.0", + "type": "library", + "name": "pear/pear_exception", + "version": "v1.0.0", + "purl": "pkg:composer/pear/pear_exception@v1.0.0" + } + ], + "vulnerabilities": [] +} \ No newline at end of file diff --git a/pkg/sbom/cyclonedx/unmarshal.go b/pkg/sbom/cyclonedx/unmarshal.go index 112e5b82f7be..e0bd68c21c47 100644 --- a/pkg/sbom/cyclonedx/unmarshal.go +++ b/pkg/sbom/cyclonedx/unmarshal.go @@ -2,12 +2,14 @@ package cyclonedx import ( "bytes" + "errors" "sort" "strconv" "strings" cdx "github.com/CycloneDX/cyclonedx-go" "github.com/samber/lo" + "golang.org/x/exp/maps" "golang.org/x/xerrors" ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" @@ -16,6 +18,10 @@ import ( "github.com/aquasecurity/trivy/pkg/types" ) +var ( + ErrPURLEmpty = errors.New("purl empty error") +) + type CycloneDX struct { *types.SBOM @@ -34,14 +40,56 @@ func (c *CycloneDX) UnmarshalJSON(b []byte) error { return xerrors.Errorf("CycloneDX decode error: %w", err) } + if !isTrivySBOM(bom) { + log.Logger.Warnf("Third-party SBOM may lead to inaccurate vulnerability detection") + log.Logger.Warnf("Recommend using Trivy to generate SBOMs") + } + + if err := c.parseSBOM(bom); err != nil { + return xerrors.Errorf("failed to parse sbom: %w", err) + } + + sort.Slice(c.Applications, func(i, j int) bool { + if c.Applications[i].Type != c.Applications[j].Type { + return c.Applications[i].Type < c.Applications[j].Type + } + return c.Applications[i].FilePath < c.Applications[j].FilePath + }) + + var metadata ftypes.Metadata + if bom.Metadata != nil { + metadata.Timestamp = bom.Metadata.Timestamp + if bom.Metadata.Component != nil { + metadata.Component = toTrivyCdxComponent(lo.FromPtr(bom.Metadata.Component)) + } + } + + var components []ftypes.Component + for _, component := range lo.FromPtr(bom.Components) { + components = append(components, toTrivyCdxComponent(component)) + } + + // Keep the original SBOM + c.CycloneDX = &ftypes.CycloneDX{ + BOMFormat: bom.BOMFormat, + SpecVersion: bom.SpecVersion, + SerialNumber: bom.SerialNumber, + Version: bom.Version, + Metadata: metadata, + Components: components, + } + return nil +} + +func (c *CycloneDX) parseSBOM(bom *cdx.BOM) error { c.dependencies = dependencyMap(bom.Dependencies) c.components = componentMap(bom.Metadata, bom.Components) - var seen = make(map[string]struct{}) for bomRef := range c.dependencies { component := c.components[bomRef] switch component.Type { case cdx.ComponentTypeOS: // OS info and OS packages + seen[component.BOMRef] = struct{}{} c.OS = toOS(component) pkgInfo, err := c.parseOSPkgs(component, seen) if err != nil { @@ -72,43 +120,34 @@ func (c *CycloneDX) UnmarshalJSON(b []byte) error { if component.Type == cdx.ComponentTypeLibrary { libComponents = append(libComponents, component) } + + // For third-party SBOMs. + // If there are no operating-system dependent libraries, make them implicitly dependent. + if component.Type == cdx.ComponentTypeOS { + if c.OS != nil { + return xerrors.New("multiple OSes are not supported") + } + c.OS = toOS(component) + } } - aggregatedApps, err := aggregateLangPkgs(libComponents) + pkgInfos, aggregatedApps, err := aggregatePkgs(libComponents) if err != nil { return xerrors.Errorf("failed to aggregate packages: %w", err) } - c.Applications = append(c.Applications, aggregatedApps...) - - sort.Slice(c.Applications, func(i, j int) bool { - if c.Applications[i].Type != c.Applications[j].Type { - return c.Applications[i].Type < c.Applications[j].Type - } - return c.Applications[i].FilePath < c.Applications[j].FilePath - }) - var metadata ftypes.Metadata - if bom.Metadata != nil { - metadata.Timestamp = bom.Metadata.Timestamp - if bom.Metadata.Component != nil { - metadata.Component = toTrivyCdxComponent(lo.FromPtr(bom.Metadata.Component)) + // For third party SBOMs. + // If a package that depends on the operating-system did not exist, + // but an os package is found during aggregate, it is used. + if len(c.Packages) == 0 && len(pkgInfos) != 0 { + if c.OS == nil { + log.Logger.Warnf("Ignore the OS package as no OS information is found.") + } else { + c.Packages = pkgInfos } } + c.Applications = append(c.Applications, aggregatedApps...) - var components []ftypes.Component - for _, component := range lo.FromPtr(bom.Components) { - components = append(components, toTrivyCdxComponent(component)) - } - - // Keep the original SBOM - c.CycloneDX = &ftypes.CycloneDX{ - BOMFormat: bom.BOMFormat, - SpecVersion: bom.SpecVersion, - SerialNumber: bom.SerialNumber, - Version: bom.Version, - Metadata: metadata, - Components: components, - } return nil } @@ -144,8 +183,11 @@ func parsePkgs(components []cdx.Component, seen map[string]struct{}) ([]ftypes.P var pkgs []ftypes.Package for _, com := range components { seen[com.BOMRef] = struct{}{} - _, pkg, err := toPackage(com) + _, _, pkg, err := toPackage(com) if err != nil { + if errors.Is(err, ErrPURLEmpty) { + continue + } return nil, xerrors.Errorf("failed to parse language package: %w", err) } pkgs = append(pkgs, *pkg) @@ -211,28 +253,47 @@ func dependencyMap(deps *[]cdx.Dependency) map[string][]string { return depMap } -func aggregateLangPkgs(libs []cdx.Component) ([]ftypes.Application, error) { - pkgMap := map[string][]ftypes.Package{} +func aggregatePkgs(libs []cdx.Component) ([]ftypes.PackageInfo, []ftypes.Application, error) { + osPkgMap := map[string]ftypes.Packages{} + langPkgMap := map[string]ftypes.Packages{} for _, lib := range libs { - appType, pkg, err := toPackage(lib) + isOSPkg, pkgType, pkg, err := toPackage(lib) if err != nil { - return nil, xerrors.Errorf("failed to parse purl to package: %w", err) + if errors.Is(err, ErrPURLEmpty) { + continue + } + return nil, nil, xerrors.Errorf("failed to parse the component: %w", err) + } + + if isOSPkg { + osPkgMap[pkgType] = append(osPkgMap[pkgType], *pkg) + } else { + langPkgMap[pkgType] = append(langPkgMap[pkgType], *pkg) } + } + + if len(osPkgMap) > 1 { + return nil, nil, xerrors.Errorf("multiple types of OS packages in SBOM are not supported (%q)", + maps.Keys(osPkgMap)) + } - pkgMap[appType] = append(pkgMap[appType], *pkg) + var osPkgs ftypes.PackageInfo + for _, pkgs := range osPkgMap { + // Just take the first element + sort.Sort(pkgs) + osPkgs = ftypes.PackageInfo{Packages: pkgs} + break } var apps []ftypes.Application - for appType, pkgs := range pkgMap { - sort.Slice(pkgs, func(i, j int) bool { - return pkgs[i].Name < pkgs[j].Name - }) + for pkgType, pkgs := range langPkgMap { + sort.Sort(pkgs) apps = append(apps, ftypes.Application{ - Type: appType, + Type: pkgType, Libraries: pkgs, }) } - return apps, nil + return []ftypes.PackageInfo{osPkgs}, apps, nil } func toOS(component cdx.Component) *ftypes.OS { @@ -249,10 +310,14 @@ func toApplication(component cdx.Component) *ftypes.Application { } } -func toPackage(component cdx.Component) (string, *ftypes.Package, error) { +func toPackage(component cdx.Component) (bool, string, *ftypes.Package, error) { + if component.PackageURL == "" { + log.Logger.Warnf("Skip the component (BOM-Ref: %s) as the PURL is empty", component.BOMRef) + return false, "", nil, ErrPURLEmpty + } p, err := purl.FromString(component.PackageURL) if err != nil { - return "", nil, xerrors.Errorf("failed to parse purl: %w", err) + return false, "", nil, xerrors.Errorf("failed to parse purl: %w", err) } pkg := p.Package() @@ -276,7 +341,7 @@ func toPackage(component cdx.Component) (string, *ftypes.Package, error) { case PropertySrcEpoch: pkg.SrcEpoch, err = strconv.Atoi(prop.Value) if err != nil { - return "", nil, xerrors.Errorf("failed to parse source epoch: %w", err) + return false, "", nil, xerrors.Errorf("failed to parse source epoch: %w", err) } case PropertyModularitylabel: pkg.Modularitylabel = prop.Value @@ -286,7 +351,24 @@ func toPackage(component cdx.Component) (string, *ftypes.Package, error) { } } - return p.AppType(), pkg, nil + isOSPkg := p.IsOSPkg() + if isOSPkg { + // Fill source package information for components in third-party SBOMs . + if pkg.SrcName == "" { + pkg.SrcName = pkg.Name + } + if pkg.SrcVersion == "" { + pkg.SrcVersion = pkg.Version + } + if pkg.SrcRelease == "" { + pkg.SrcRelease = pkg.Release + } + if pkg.SrcEpoch == 0 { + pkg.SrcEpoch = pkg.Epoch + } + } + + return isOSPkg, p.PackageType(), pkg, nil } func toTrivyCdxComponent(component cdx.Component) ftypes.Component { @@ -308,3 +390,16 @@ func lookupProperty(properties *[]cdx.Property, key string) string { } return "" } + +func isTrivySBOM(c *cdx.BOM) bool { + if c == nil || c.Metadata == nil || c.Metadata.Tools == nil { + return false + } + + for _, tool := range *c.Metadata.Tools { + if tool.Vendor == ToolVendor && tool.Name == ToolName { + return true + } + } + return false +} diff --git a/pkg/sbom/cyclonedx/unmarshal_test.go b/pkg/sbom/cyclonedx/unmarshal_test.go index 4c209e0bab78..ef640758ee77 100644 --- a/pkg/sbom/cyclonedx/unmarshal_test.go +++ b/pkg/sbom/cyclonedx/unmarshal_test.go @@ -123,6 +123,64 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { }, }, }, + { + name: "happy path for third party sbom", + inputFile: "testdata/happy/third-party-bom.json", + want: types.SBOM{ + OS: &ftypes.OS{ + Family: "alpine", + Name: "3.16.0", + }, + Packages: []ftypes.PackageInfo{ + { + Packages: []ftypes.Package{ + { + Name: "musl", Version: "1.2.3-r0", SrcName: "musl", SrcVersion: "1.2.3-r0", Licenses: []string{"MIT"}, + Ref: "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0", + }, + }, + }, + }, + Applications: []ftypes.Application{ + { + Type: "composer", + FilePath: "", + Libraries: []ftypes.Package{ + { + Name: "pear/log", + Version: "1.13.1", + Ref: "pkg:composer/pear/log@1.13.1", + }, + { + + Name: "pear/pear_exception", + Version: "v1.0.0", + Ref: "pkg:composer/pear/pear_exception@v1.0.0", + }, + }, + }, + }, + }, + }, + { + name: "happy path for third party sbom, no operation-system component", + inputFile: "testdata/happy/third-party-bom-no-os.json", + want: types.SBOM{ + Applications: []ftypes.Application{ + { + Type: "composer", + FilePath: "", + Libraries: []ftypes.Package{ + { + Name: "pear/log", + Version: "1.13.1", + Ref: "pkg:composer/pear/log@1.13.1", + }, + }, + }, + }, + }, + }, { name: "happy path for unrelated bom", inputFile: "testdata/happy/unrelated-bom.json", @@ -221,12 +279,12 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { assert.Contains(t, err.Error(), tt.wantErr) return } + require.NoError(t, err) // Not compare the CycloneDX field got := *cdx.SBOM got.CycloneDX = nil - require.NoError(t, err) assert.Equal(t, tt.want, got) }) } diff --git a/pkg/scanner/local/scan.go b/pkg/scanner/local/scan.go index 73f79a39cce2..fdb5154a219b 100644 --- a/pkg/scanner/local/scan.go +++ b/pkg/scanner/local/scan.go @@ -181,9 +181,7 @@ func (s Scanner) osPkgsToResult(target string, detail ftypes.ArtifactDetail, opt if options.ScanRemovedPackages { pkgs = mergePkgs(pkgs, detail.HistoryPackages) } - sort.Slice(pkgs, func(i, j int) bool { - return strings.Compare(pkgs[i].Name, pkgs[j].Name) <= 0 - }) + sort.Sort(pkgs) return &types.Result{ Target: fmt.Sprintf("%s (%s %s)", target, detail.OS.Family, detail.OS.Name), Class: types.ClassOSPkg,