diff --git a/integration/client_server_test.go b/integration/client_server_test.go index e8c8151c6a2..d858a7aa66a 100644 --- a/integration/client_server_test.go +++ b/integration/client_server_test.go @@ -12,10 +12,9 @@ import ( "testing" "time" - "github.com/samber/lo" - cdx "github.com/CycloneDX/cyclonedx-go" "github.com/docker/go-connections/nat" + "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" testcontainers "github.com/testcontainers/testcontainers-go" diff --git a/integration/docker_engine_test.go b/integration/docker_engine_test.go index ea1fb92722a..3dc30830914 100644 --- a/integration/docker_engine_test.go +++ b/integration/docker_engine_test.go @@ -11,7 +11,7 @@ import ( "strings" "testing" - "github.com/docker/docker/api/types" + api "github.com/docker/docker/api/types" "github.com/docker/docker/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -213,7 +213,7 @@ func TestDockerEngine(t *testing.T) { require.NoError(t, err, tt.name) // ensure image doesnt already exists - _, _ = cli.ImageRemove(ctx, tt.input, types.ImageRemoveOptions{ + _, _ = cli.ImageRemove(ctx, tt.input, api.ImageRemoveOptions{ Force: true, PruneChildren: true, }) @@ -264,11 +264,11 @@ func TestDockerEngine(t *testing.T) { compareReports(t, tt.golden, output) // cleanup - _, err = cli.ImageRemove(ctx, tt.input, types.ImageRemoveOptions{ + _, err = cli.ImageRemove(ctx, tt.input, api.ImageRemoveOptions{ Force: true, PruneChildren: true, }) - _, err = cli.ImageRemove(ctx, tt.imageTag, types.ImageRemoveOptions{ + _, err = cli.ImageRemove(ctx, tt.imageTag, api.ImageRemoveOptions{ Force: true, PruneChildren: true, }) diff --git a/integration/sbom_test.go b/integration/sbom_test.go index 1f44310ca22..f9e2c5f4c11 100644 --- a/integration/sbom_test.go +++ b/integration/sbom_test.go @@ -8,23 +8,28 @@ import ( "testing" cdx "github.com/CycloneDX/cyclonedx-go" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" + "github.com/aquasecurity/trivy/pkg/types" ) -func TestCycloneDX(t *testing.T) { +func TestSBOM(t *testing.T) { type args struct { input string format string artifactType string } tests := []struct { - name string - args args - golden string + name string + args args + golden string + override types.Report }{ { - name: "centos7-bom by trivy", + name: "centos7 cyclonedx", args: args{ input: "testdata/fixtures/sbom/centos-7-cyclonedx.json", format: "cyclonedx", @@ -33,7 +38,7 @@ func TestCycloneDX(t *testing.T) { golden: "testdata/centos-7-cyclonedx.json.golden", }, { - name: "fluentd-multiple-lockfiles-bom by trivy", + name: "fluentd-multiple-lockfiles cyclonedx", args: args{ input: "testdata/fixtures/sbom/fluentd-multiple-lockfiles-cyclonedx.json", format: "cyclonedx", @@ -42,7 +47,7 @@ func TestCycloneDX(t *testing.T) { golden: "testdata/fluentd-multiple-lockfiles-cyclonedx.json.golden", }, { - name: "centos7-bom in in-toto attestation", + name: "centos7 in in-toto attestation", args: args{ input: "testdata/fixtures/sbom/centos-7-cyclonedx.intoto.jsonl", format: "cyclonedx", @@ -50,6 +55,52 @@ func TestCycloneDX(t *testing.T) { }, golden: "testdata/centos-7-cyclonedx.json.golden", }, + { + name: "centos7 spdx tag-value", + args: args{ + input: "testdata/fixtures/sbom/centos-7-spdx.txt", + format: "json", + artifactType: "spdx", + }, + golden: "testdata/centos-7.json.golden", + override: types.Report{ + ArtifactName: "testdata/fixtures/sbom/centos-7-spdx.txt", + ArtifactType: ftypes.ArtifactType("spdx"), + Results: types.Results{ + { + Target: "testdata/fixtures/sbom/centos-7-spdx.txt (centos 7.6.1810)", + Vulnerabilities: []types.DetectedVulnerability{ + {Ref: "pkg:rpm/centos/bash@4.2.46-31.el7?arch=x86_64&distro=centos-7.6.1810"}, + {Ref: "pkg:rpm/centos/openssl-libs@1:1.0.2k-16.el7?arch=x86_64&distro=centos-7.6.1810"}, + {Ref: "pkg:rpm/centos/openssl-libs@1:1.0.2k-16.el7?arch=x86_64&distro=centos-7.6.1810"}, + }, + }, + }, + }, + }, + { + name: "centos7 spdx json", + args: args{ + input: "testdata/fixtures/sbom/centos-7-spdx.json", + format: "json", + artifactType: "spdx", + }, + golden: "testdata/centos-7.json.golden", + override: types.Report{ + ArtifactName: "testdata/fixtures/sbom/centos-7-spdx.json", + ArtifactType: ftypes.ArtifactType("spdx"), + Results: types.Results{ + { + Target: "testdata/fixtures/sbom/centos-7-spdx.json (centos 7.6.1810)", + Vulnerabilities: []types.DetectedVulnerability{ + {Ref: "pkg:rpm/centos/bash@4.2.46-31.el7?arch=x86_64&distro=centos-7.6.1810"}, + {Ref: "pkg:rpm/centos/openssl-libs@1:1.0.2k-16.el7?arch=x86_64&distro=centos-7.6.1810"}, + {Ref: "pkg:rpm/centos/openssl-libs@1:1.0.2k-16.el7?arch=x86_64&distro=centos-7.6.1810"}, + }, + }, + }, + }, + }, } // Set up testing DB @@ -61,7 +112,7 @@ func TestCycloneDX(t *testing.T) { "--cache-dir", cacheDir, "sbom", "-q", "--skip-db-update", "--format", tt.args.format, } - // Setup the output file + // Set up the output file outputFile := filepath.Join(t.TempDir(), "output.json") if *update { outputFile = tt.golden @@ -75,13 +126,46 @@ func TestCycloneDX(t *testing.T) { assert.NoError(t, err) // Compare want and got - want := decodeCycloneDX(t, tt.golden) - got := decodeCycloneDX(t, outputFile) - assert.Equal(t, want, got) + switch tt.args.format { + case "cyclonedx": + want := decodeCycloneDX(t, tt.golden) + got := decodeCycloneDX(t, outputFile) + assert.Equal(t, want, got) + case "json": + compareSBOMReports(t, tt.golden, outputFile, tt.override) + default: + require.Fail(t, "invalid format", "format: %s", tt.args.format) + } }) } } +// TODO(teppei): merge into compareReports +func compareSBOMReports(t *testing.T, wantFile, gotFile string, overrideWant types.Report) { + want := readReport(t, wantFile) + + want.ArtifactName = overrideWant.ArtifactName + want.ArtifactType = overrideWant.ArtifactType + want.Metadata.ImageID = "" + want.Metadata.ImageConfig = v1.ConfigFile{} + want.Metadata.DiffIDs = nil + for i, result := range want.Results { + for j := range result.Vulnerabilities { + want.Results[i].Vulnerabilities[j].Layer.DiffID = "" + } + } + + for i, result := range overrideWant.Results { + want.Results[i].Target = result.Target + for j, vuln := range result.Vulnerabilities { + want.Results[i].Vulnerabilities[j].Ref = vuln.Ref + } + } + + got := readReport(t, gotFile) + assert.Equal(t, want, got) +} + func decodeCycloneDX(t *testing.T, filePath string) *cdx.BOM { f, err := os.Open(filePath) require.NoError(t, err) diff --git a/integration/testdata/fixtures/sbom/centos-7-spdx.json b/integration/testdata/fixtures/sbom/centos-7-spdx.json new file mode 100644 index 00000000000..2b8e2011c90 --- /dev/null +++ b/integration/testdata/fixtures/sbom/centos-7-spdx.json @@ -0,0 +1,94 @@ +{ + "SPDXID": "SPDXRef-DOCUMENT", + "creationInfo": { + "created": "2022-09-13T13:27:55.874784Z", + "creators": [ + "Tool: trivy", + "Organization: aquasecurity" + ] + }, + "dataLicense": "CC0-1.0", + "documentNamespace": "http://aquasecurity.github.io/trivy/container_image/integration/testdata/fixtures/images/centos-7.tar.gz-2906855d-5098-4a22-9a72-4f7099ea3d66", + "name": "integration/testdata/fixtures/images/centos-7.tar.gz", + "packages": [ + { + "SPDXID": "SPDXRef-ContainerImage-dd5cad897c6263", + "attributionTexts": [ + "SchemaVersion: 2", + "ImageID: sha256:f1cb7c7d58b73eac859c395882eec49d50651244e342cd6c68a5c7809785f427", + "DiffID: sha256:89169d87dbe2b72ba42bfbb3579c957322baca28e03a1e558076542a1c1b2b4a" + ], + "filesAnalyzed": false, + "name": "integration/testdata/fixtures/images/centos-7.tar.gz" + }, + { + "SPDXID": "SPDXRef-OperatingSystem-2e91c856c499a371", + "filesAnalyzed": false, + "name": "centos", + "versionInfo": "7.6.1810" + }, + { + "SPDXID": "SPDXRef-Package-5a18334f22149877", + "attributionTexts": [ + "LayerDigest: sha256:ac9208207adaac3a48e54a4dc6b49c69e78c3072d2b3add7efdabf814db2133b", + "LayerDiffID: sha256:89169d87dbe2b72ba42bfbb3579c957322baca28e03a1e558076542a1c1b2b4a" + ], + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:rpm/centos/bash@4.2.46-31.el7?arch=x86_64\u0026distro=centos-7.6.1810", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "GPLv3+", + "licenseDeclared": "GPLv3+", + "name": "bash", + "sourceInfo": "built package from: bash 4.2.46-31.el7", + "versionInfo": "4.2.46" + }, + { + "SPDXID": "SPDXRef-Package-e16b1cbaa5186199", + "attributionTexts": [ + "LayerDigest: sha256:ac9208207adaac3a48e54a4dc6b49c69e78c3072d2b3add7efdabf814db2133b", + "LayerDiffID: sha256:89169d87dbe2b72ba42bfbb3579c957322baca28e03a1e558076542a1c1b2b4a" + ], + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:rpm/centos/openssl-libs@1:1.0.2k-16.el7?arch=x86_64\u0026distro=centos-7.6.1810", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "OpenSSL", + "licenseDeclared": "OpenSSL", + "name": "openssl-libs", + "sourceInfo": "built package from: openssl-libs 1:1.0.2k-16.el7", + "versionInfo": "1.0.2k" + } + ], + "relationships": [ + { + "relatedSpdxElement": "SPDXRef-ContainerImage-dd5cad897c6263", + "relationshipType": "DESCRIBE", + "spdxElementId": "SPDXRef-DOCUMENT" + }, + { + "relatedSpdxElement": "SPDXRef-OperatingSystem-2e91c856c499a371", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-ContainerImage-dd5cad897c6263" + }, + { + "relatedSpdxElement": "SPDXRef-Package-5a18334f22149877", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-OperatingSystem-2e91c856c499a371" + }, + { + "relatedSpdxElement": "SPDXRef-Package-e16b1cbaa5186199", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-OperatingSystem-2e91c856c499a371" + } + ], + "spdxVersion": "SPDX-2.2" +} \ No newline at end of file diff --git a/integration/testdata/fixtures/sbom/centos-7-spdx.txt b/integration/testdata/fixtures/sbom/centos-7-spdx.txt new file mode 100644 index 00000000000..7032af92b1b --- /dev/null +++ b/integration/testdata/fixtures/sbom/centos-7-spdx.txt @@ -0,0 +1,57 @@ +SPDXVersion: SPDX-2.2 +DataLicense: CC0-1.0 +SPDXID: SPDXRef-DOCUMENT +DocumentName: integration/testdata/fixtures/images/centos-7.tar.gz +DocumentNamespace: http://aquasecurity.github.io/trivy/container_image/integration/testdata/fixtures/images/centos-7.tar.gz-6a2c050f-bc12-46dc-b2df-1f4e3e0b5e1d +Creator: Organization: aquasecurity +Creator: Tool: trivy +Created: 2022-09-13T13:24:58.796907Z + +##### Package: integration/testdata/fixtures/images/centos-7.tar.gz + +PackageName: integration/testdata/fixtures/images/centos-7.tar.gz +SPDXID: SPDXRef-ContainerImage-dd5cad897c6263 +FilesAnalyzed: false +PackageAttributionText: SchemaVersion: 2 +PackageAttributionText: ImageID: sha256:f1cb7c7d58b73eac859c395882eec49d50651244e342cd6c68a5c7809785f427 +PackageAttributionText: DiffID: sha256:89169d87dbe2b72ba42bfbb3579c957322baca28e03a1e558076542a1c1b2b4a + +##### Package: centos + +PackageName: centos +SPDXID: SPDXRef-OperatingSystem-2e91c856c499a371 +PackageVersion: 7.6.1810 +FilesAnalyzed: false + +##### Package: bash + +PackageName: bash +SPDXID: SPDXRef-Package-5a18334f22149877 +PackageVersion: 4.2.46 +FilesAnalyzed: false +PackageSourceInfo: built package from: bash 4.2.46-31.el7 +PackageLicenseConcluded: GPLv3+ +PackageLicenseDeclared: GPLv3+ +ExternalRef: PACKAGE-MANAGER purl pkg:rpm/centos/bash@4.2.46-31.el7?arch=x86_64&distro=centos-7.6.1810 +PackageAttributionText: LayerDigest: sha256:ac9208207adaac3a48e54a4dc6b49c69e78c3072d2b3add7efdabf814db2133b +PackageAttributionText: LayerDiffID: sha256:89169d87dbe2b72ba42bfbb3579c957322baca28e03a1e558076542a1c1b2b4a + +##### Package: openssl-libs + +PackageName: openssl-libs +SPDXID: SPDXRef-Package-e16b1cbaa5186199 +PackageVersion: 1.0.2k +FilesAnalyzed: false +PackageSourceInfo: built package from: openssl-libs 1:1.0.2k-16.el7 +PackageLicenseConcluded: OpenSSL +PackageLicenseDeclared: OpenSSL +ExternalRef: PACKAGE-MANAGER purl pkg:rpm/centos/openssl-libs@1:1.0.2k-16.el7?arch=x86_64&distro=centos-7.6.1810 +PackageAttributionText: LayerDigest: sha256:ac9208207adaac3a48e54a4dc6b49c69e78c3072d2b3add7efdabf814db2133b +PackageAttributionText: LayerDiffID: sha256:89169d87dbe2b72ba42bfbb3579c957322baca28e03a1e558076542a1c1b2b4a + +##### Relationships + +Relationship: SPDXRef-DOCUMENT DESCRIBE SPDXRef-ContainerImage-dd5cad897c6263 +Relationship: SPDXRef-ContainerImage-dd5cad897c6263 CONTAINS SPDXRef-OperatingSystem-2e91c856c499a371 +Relationship: SPDXRef-OperatingSystem-2e91c856c499a371 DEPENDS_ON SPDXRef-Package-5a18334f22149877 +Relationship: SPDXRef-OperatingSystem-2e91c856c499a371 DEPENDS_ON SPDXRef-Package-e16b1cbaa5186199 \ No newline at end of file diff --git a/pkg/fanal/artifact/sbom/sbom.go b/pkg/fanal/artifact/sbom/sbom.go index bc5310d98f3..c6599668843 100644 --- a/pkg/fanal/artifact/sbom/sbom.go +++ b/pkg/fanal/artifact/sbom/sbom.go @@ -20,6 +20,7 @@ import ( "github.com/aquasecurity/trivy/pkg/log" "github.com/aquasecurity/trivy/pkg/sbom" "github.com/aquasecurity/trivy/pkg/sbom/cyclonedx" + "github.com/aquasecurity/trivy/pkg/sbom/spdx" ) type Artifact struct { @@ -83,6 +84,9 @@ func (a Artifact) Inspect(_ context.Context) (types.ArtifactReference, error) { switch format { case sbom.FormatCycloneDXJSON, sbom.FormatCycloneDXXML, sbom.FormatAttestCycloneDXJSON: artifactType = types.ArtifactCycloneDX + case sbom.FormatSPDXTV, sbom.FormatSPDXJSON: + artifactType = types.ArtifactSPDX + } return types.ArtifactReference{ @@ -117,6 +121,13 @@ func (a Artifact) Decode(f io.Reader, format sbom.Format) (sbom.SBOM, error) { }, } decoder = json.NewDecoder(f) + case sbom.FormatSPDXJSON: + v = &spdx.SPDX{SBOM: &bom} + decoder = json.NewDecoder(f) + case sbom.FormatSPDXTV: + v = &spdx.SPDX{SBOM: &bom} + decoder = spdx.NewTVDecoder(f) + default: return sbom.SBOM{}, xerrors.Errorf("%s scanning is not yet supported", format) diff --git a/pkg/fanal/types/artifact.go b/pkg/fanal/types/artifact.go index e1e6aea8962..636cf39aaaf 100644 --- a/pkg/fanal/types/artifact.go +++ b/pkg/fanal/types/artifact.go @@ -95,6 +95,7 @@ const ( ArtifactFilesystem ArtifactType = "filesystem" ArtifactRemoteRepository ArtifactType = "repository" ArtifactCycloneDX ArtifactType = "cyclonedx" + ArtifactSPDX ArtifactType = "spdx" ArtifactAWSAccount ArtifactType = "aws_account" ) diff --git a/pkg/sbom/sbom.go b/pkg/sbom/sbom.go index 04699c27ef0..e789758340f 100644 --- a/pkg/sbom/sbom.go +++ b/pkg/sbom/sbom.go @@ -1,12 +1,14 @@ package sbom import ( + "bufio" "encoding/json" "encoding/xml" "io" "strings" "github.com/in-toto/in-toto-golang/in_toto" + stypes "github.com/spdx/tools-golang/spdx" "golang.org/x/xerrors" "github.com/aquasecurity/trivy/pkg/attestation" @@ -19,6 +21,7 @@ type SBOM struct { Applications []types.Application CycloneDX *types.CycloneDX + SPDX *stypes.Document2_2 } type Format string @@ -27,19 +30,26 @@ const ( FormatCycloneDXJSON Format = "cyclonedx-json" FormatCycloneDXXML Format = "cyclonedx-xml" FormatSPDXJSON Format = "spdx-json" + FormatSPDXTV Format = "spdx-tv" FormatSPDXXML Format = "spdx-xml" FormatAttestCycloneDXJSON Format = "attest-cyclonedx-json" FormatUnknown Format = "unknown" ) func DetectFormat(r io.ReadSeeker) (Format, error) { - type cyclonedx struct { - // XML specific field - XMLNS string `json:"-" xml:"xmlns,attr"` + type ( + cyclonedx struct { + // XML specific field + XMLNS string `json:"-" xml:"xmlns,attr"` - // JSON specific field - BOMFormat string `json:"bomFormat" xml:"-"` - } + // JSON specific field + BOMFormat string `json:"bomFormat" xml:"-"` + } + + spdx struct { + SpdxID string `json:"SPDXID"` + } + ) // Try CycloneDX JSON var cdxBom cyclonedx @@ -64,7 +74,28 @@ func DetectFormat(r io.ReadSeeker) (Format, error) { return FormatUnknown, xerrors.Errorf("seek error: %w", err) } - // TODO: implement SPDX + // Try SPDX json + var spdxBom spdx + if err := json.NewDecoder(r).Decode(&spdxBom); err == nil { + if strings.HasPrefix(spdxBom.SpdxID, "SPDX") { + return FormatSPDXJSON, nil + } + } + + if _, err := r.Seek(0, io.SeekStart); err != nil { + return FormatUnknown, xerrors.Errorf("seek error: %w", err) + } + + // Try SPDX tag-value + if scanner := bufio.NewScanner(r); scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "SPDX") { + return FormatSPDXTV, nil + } + } + + if _, err := r.Seek(0, io.SeekStart); err != nil { + return FormatUnknown, xerrors.Errorf("seek error: %w", err) + } // Try in-toto attestation var s attestation.Statement diff --git a/pkg/sbom/spdx/testdata/happy/bom.json b/pkg/sbom/spdx/testdata/happy/bom.json new file mode 100644 index 00000000000..f0405a9ded2 --- /dev/null +++ b/pkg/sbom/spdx/testdata/happy/bom.json @@ -0,0 +1,230 @@ +{ + "SPDXID": "SPDXRef-DOCUMENT", + "creationInfo": { + "created": "2022-09-12T17:02:46.826609Z", + "creators": [ + "Tool: trivy", + "Organization: aquasecurity" + ] + }, + "dataLicense": "CC0-1.0", + "documentNamespace": "http://aquasecurity.github.io/trivy/container/meven-test-project-eb7a0384-b04a-4fc6-8afb-1662fe59ca79", + "name": "maven-test-projecct", + "packages": [ + { + "SPDXID": "SPDXRef-Application-150e605f5f17224d", + "filesAnalyzed": false, + "name": "jar", + "sourceInfo": "Java" + }, + { + "SPDXID": "SPDXRef-Application-24f8a80152e2c0fc", + "filesAnalyzed": false, + "name": "node-pkg", + "sourceInfo": "Node.js" + }, + { + "SPDXID": "SPDXRef-Application-36324ee492e03f0a", + "filesAnalyzed": false, + "name": "gobinary", + "sourceInfo": "app/gobinary/gobinary" + }, + { + "SPDXID": "SPDXRef-Application-4af197c15114fb0e", + "filesAnalyzed": false, + "name": "composer", + "sourceInfo": "app/composer/composer.lock" + }, + { + "SPDXID": "SPDXRef-ContainerImage-b5d81cde5f95c8fc", + "attributionTexts": [ + "SchemaVersion: 2", + "ImageID: sha256:49193a2310dbad4c02382da87ac624a80a92387a4f7536235f9ba590e5bcd7b5", + "DiffID: sha256:dd565ff850e7003356e2b252758f9bdc1ff2803f61e995e24c7844f6297f8fc3", + "DiffID: sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", + "RepoTag: maven-test-project:latest", + "RepoTag: tmp-test:latest" + ], + "filesAnalyzed": false, + "name": "meven-test-project" + }, + { + "SPDXID": "SPDXRef-OperatingSystem-bd17bf9010aa612c", + "filesAnalyzed": false, + "name": "alpine", + "versionInfo": "3.16.0" + }, + { + "SPDXID": "SPDXRef-Package-2906575950df652b", + "attributionTexts": [ + "LayerDiffID: sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1" + ], + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:composer/pear/log@1.13.1", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "NONE", + "licenseDeclared": "NONE", + "name": "pear/log", + "versionInfo": "1.13.1" + }, + { + "SPDXID": "SPDXRef-Package-2a53baa495b9ddaf", + "attributionTexts": [ + "LayerDiffID: sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1" + ], + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:maven/org.codehaus.mojo/child-project@1.0", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "NONE", + "licenseDeclared": "NONE", + "name": "org.codehaus.mojo:child-project", + "versionInfo": "1.0" + }, + { + "SPDXID": "SPDXRef-Package-5e2e255ac76747ef", + "attributionTexts": [ + "LayerDiffID: sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1" + ], + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:composer/pear/pear_exception@v1.0.0", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "NONE", + "licenseDeclared": "NONE", + "name": "pear/pear_exception", + "versionInfo": "v1.0.0" + }, + { + "SPDXID": "SPDXRef-Package-5f1dbaff8de5eb06", + "attributionTexts": [ + "LayerDiffID: sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1" + ], + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:npm/bootstrap@5.0.2", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "name": "bootstrap", + "versionInfo": "5.0.2" + }, + { + "SPDXID": "SPDXRef-Package-84ebffe38343d949", + "attributionTexts": [ + "LayerDiffID: sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1" + ], + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:golang/github.com/package-url/packageurl-go@v0.1.1-0.20220203205134-d70459300c8a", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "NONE", + "licenseDeclared": "NONE", + "name": "github.com/package-url/packageurl-go", + "versionInfo": "v0.1.1-0.20220203205134-d70459300c8a" + }, + { + "SPDXID": "SPDXRef-Package-b7ebaf0233f1ef7b", + "attributionTexts": [ + "LayerDiffID: sha256:dd565ff850e7003356e2b252758f9bdc1ff2803f61e995e24c7844f6297f8fc3" + ], + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "name": "musl", + "sourceInfo": "built package from: musl 1.2.3-r0", + "versionInfo": "1.2.3-r0" + } + ], + "relationships": [ + { + "relatedSpdxElement": "SPDXRef-ContainerImage-b5d81cde5f95c8fc", + "relationshipType": "DESCRIBE", + "spdxElementId": "SPDXRef-DOCUMENT" + }, + { + "relatedSpdxElement": "SPDXRef-OperatingSystem-bd17bf9010aa612c", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-ContainerImage-b5d81cde5f95c8fc" + }, + { + "relatedSpdxElement": "SPDXRef-Package-b7ebaf0233f1ef7b", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-OperatingSystem-bd17bf9010aa612c" + }, + { + "relatedSpdxElement": "SPDXRef-Application-150e605f5f17224d", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-ContainerImage-b5d81cde5f95c8fc" + }, + { + "relatedSpdxElement": "SPDXRef-Package-2a53baa495b9ddaf", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Application-150e605f5f17224d" + }, + { + "relatedSpdxElement": "SPDXRef-Application-24f8a80152e2c0fc", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-ContainerImage-b5d81cde5f95c8fc" + }, + { + "relatedSpdxElement": "SPDXRef-Package-5f1dbaff8de5eb06", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Application-24f8a80152e2c0fc" + }, + { + "relatedSpdxElement": "SPDXRef-Application-4af197c15114fb0e", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-ContainerImage-b5d81cde5f95c8fc" + }, + { + "relatedSpdxElement": "SPDXRef-Package-2906575950df652b", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Application-4af197c15114fb0e" + }, + { + "relatedSpdxElement": "SPDXRef-Package-5e2e255ac76747ef", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Application-4af197c15114fb0e" + }, + { + "relatedSpdxElement": "SPDXRef-Application-36324ee492e03f0a", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-ContainerImage-b5d81cde5f95c8fc" + }, + { + "relatedSpdxElement": "SPDXRef-Package-84ebffe38343d949", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Application-36324ee492e03f0a" + } + ], + "spdxVersion": "SPDX-2.2" +} \ No newline at end of file diff --git a/pkg/sbom/spdx/testdata/happy/empty-bom.json b/pkg/sbom/spdx/testdata/happy/empty-bom.json new file mode 100644 index 00000000000..e32e2f4e692 --- /dev/null +++ b/pkg/sbom/spdx/testdata/happy/empty-bom.json @@ -0,0 +1,34 @@ +{ + "SPDXID": "SPDXRef-DOCUMENT", + "creationInfo": { + "created": "2022-09-12T17:03:35.840861Z", + "creators": [ + "Tool: trivy", + "Organization: aquasecurity" + ] + }, + "dataLicense": "CC0-1.0", + "documentDescribes": [ + "SPDXRef-ContainerImage-7f428bd075b13fe8" + ], + "documentNamespace": "http://aquasecurity.github.io/trivy/container/maven-test-project-3e87878b-eac3-4baa-af11-bdf2c5eab8ea", + "name": "maven-test-project", + "packages": [ + { + "SPDXID": "SPDXRef-ContainerImage-7f428bd075b13fe8", + "attributionTexts": [ + "SchemaVersion: 2" + ], + "filesAnalyzed": false, + "name": "maven-test-project" + } + ], + "relationships": [ + { + "relatedSpdxElement": "SPDXRef-ContainerImage-7f428bd075b13fe8", + "relationshipType": "DESCRIBE", + "spdxElementId": "SPDXRef-DOCUMENT" + } + ], + "spdxVersion": "SPDX-2.2" +} \ No newline at end of file diff --git a/pkg/sbom/spdx/testdata/happy/os-only-bom.json b/pkg/sbom/spdx/testdata/happy/os-only-bom.json new file mode 100644 index 00000000000..c6faec2d014 --- /dev/null +++ b/pkg/sbom/spdx/testdata/happy/os-only-bom.json @@ -0,0 +1,42 @@ +{ + "SPDXID": "SPDXRef-DOCUMENT", + "creationInfo": { + "created": "2022-09-12T17:04:09.262672Z", + "creators": [ + "Tool: trivy", + "Organization: aquasecurity" + ] + }, + "dataLicense": "CC0-1.0", + "documentNamespace": "http://aquasecurity.github.io/trivy/container/maven-test-project-1a255568-e896-498f-93de-7975a5cb0212", + "name": "maven-test-project", + "packages": [ + { + "SPDXID": "SPDXRef-ContainerImage-fbbee9b988766322", + "attributionTexts": [ + "SchemaVersion: 2" + ], + "filesAnalyzed": false, + "name": "maven-test-project" + }, + { + "SPDXID": "SPDXRef-OperatingSystem-bd17bf9010aa612c", + "filesAnalyzed": false, + "name": "alpine", + "versionInfo": "3.16.0" + } + ], + "relationships": [ + { + "relatedSpdxElement": "SPDXRef-ContainerImage-fbbee9b988766322", + "relationshipType": "DESCRIBE", + "spdxElementId": "SPDXRef-DOCUMENT" + }, + { + "relatedSpdxElement": "SPDXRef-OperatingSystem-bd17bf9010aa612c", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-ContainerImage-fbbee9b988766322" + } + ], + "spdxVersion": "SPDX-2.2" +} \ No newline at end of file diff --git a/pkg/sbom/spdx/testdata/happy/unrelated-bom.json b/pkg/sbom/spdx/testdata/happy/unrelated-bom.json new file mode 100644 index 00000000000..1eaac40af7a --- /dev/null +++ b/pkg/sbom/spdx/testdata/happy/unrelated-bom.json @@ -0,0 +1,82 @@ +{ + "SPDXID": "SPDXRef-DOCUMENT", + "creationInfo": { + "created": "2022-09-12T17:04:28.43059Z", + "creators": [ + "Tool: trivy", + "Organization: aquasecurity" + ] + }, + "dataLicense": "CC0-1.0", + "documentNamespace": "http://aquasecurity.github.io/trivy/application/maven-test-project-8aa3b6db-bae7-4755-b43e-00b4bcf46410", + "name": "maven-test-project", + "packages": [ + { + "SPDXID": "SPDXRef-Application-193be12e25033404", + "filesAnalyzed": false, + "name": "composer", + "sourceInfo": "app/composer/composer.lock" + }, + { + "SPDXID": "SPDXRef-Filesystem-12d960c003a8275b", + "attributionTexts": [ + "SchemaVersion: 2" + ], + "filesAnalyzed": false, + "name": "maven-test-project" + }, + { + "SPDXID": "SPDXRef-Package-2906575950df652b", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:composer/pear/log@1.13.1", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "NONE", + "licenseDeclared": "NONE", + "name": "pear/log", + "versionInfo": "1.13.1" + }, + { + "SPDXID": "SPDXRef-Package-5e2e255ac76747ef", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:composer/pear/pear_exception@v1.0.0", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "licenseConcluded": "NONE", + "licenseDeclared": "NONE", + "name": "pear/pear_exception", + "versionInfo": "v1.0.0" + } + ], + "relationships": [ + { + "relatedSpdxElement": "SPDXRef-Filesystem-12d960c003a8275b", + "relationshipType": "DESCRIBE", + "spdxElementId": "SPDXRef-DOCUMENT" + }, + { + "relatedSpdxElement": "SPDXRef-Application-193be12e25033404", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-Filesystem-12d960c003a8275b" + }, + { + "relatedSpdxElement": "SPDXRef-Package-2906575950df652b", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Application-193be12e25033404" + }, + { + "relatedSpdxElement": "SPDXRef-Package-5e2e255ac76747ef", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-Application-193be12e25033404" + } + ], + "spdxVersion": "SPDX-2.2" +} \ No newline at end of file diff --git a/pkg/sbom/spdx/testdata/sad/invalid-source-info.json b/pkg/sbom/spdx/testdata/sad/invalid-source-info.json new file mode 100644 index 00000000000..949d7e11701 --- /dev/null +++ b/pkg/sbom/spdx/testdata/sad/invalid-source-info.json @@ -0,0 +1,58 @@ +{ + "SPDXID": "SPDXRef-DOCUMENT", + "creationInfo": { + "created": "2022-09-12T17:02:46.826609Z", + "creators": [ + "Tool: trivy", + "Organization: aquasecurity" + ] + }, + "dataLicense": "CC0-1.0", + "documentNamespace": "http://aquasecurity.github.io/trivy/container/meven-test-project-eb7a0384-b04a-4fc6-8afb-1662fe59ca79", + "name": "maven-test-projecct", + "packages": [ + { + "SPDXID": "SPDXRef-ContainerImage-b5d81cde5f95c8fc", + "filesAnalyzed": false, + "name": "meven-test-project" + }, + { + "SPDXID": "SPDXRef-OperatingSystem-bd17bf9010aa612c", + "filesAnalyzed": false, + "name": "alpine", + "versionInfo": "3.16.0" + }, + { + "SPDXID": "SPDXRef-Package-b7ebaf0233f1ef7b", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "name": "musl", + "sourceInfo": "built package from: invalid", + "versionInfo": "1.2.3-r0" + } + ], + "relationships": [ + { + "relatedSpdxElement": "SPDXRef-ContainerImage-b5d81cde5f95c8fc", + "relationshipType": "DESCRIBE", + "spdxElementId": "SPDXRef-DOCUMENT" + }, + { + "relatedSpdxElement": "SPDXRef-OperatingSystem-bd17bf9010aa612c", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-ContainerImage-b5d81cde5f95c8fc" + }, + { + "relatedSpdxElement": "SPDXRef-Package-b7ebaf0233f1ef7b", + "relationshipType": "DEPENDS_ON", + "spdxElementId": "SPDXRef-OperatingSystem-bd17bf9010aa612c" + } + ], + "spdxVersion": "SPDX-2.2" +} \ No newline at end of file diff --git a/pkg/sbom/spdx/unmarshal.go b/pkg/sbom/spdx/unmarshal.go new file mode 100644 index 00000000000..e610cf1cacc --- /dev/null +++ b/pkg/sbom/spdx/unmarshal.go @@ -0,0 +1,228 @@ +package spdx + +import ( + "bytes" + "fmt" + "io" + "strings" + + version "github.com/knqyf263/go-rpm-version" + "github.com/package-url/packageurl-go" + "github.com/samber/lo" + "github.com/spdx/tools-golang/jsonloader" + "github.com/spdx/tools-golang/spdx" + "github.com/spdx/tools-golang/tvloader" + "golang.org/x/xerrors" + + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" + "github.com/aquasecurity/trivy/pkg/purl" + "github.com/aquasecurity/trivy/pkg/sbom" +) + +var ( + errUnknownPackageFormat = xerrors.New("unknown package format") +) + +type SPDX struct { + *sbom.SBOM +} + +func NewTVDecoder(r io.Reader) *TVDecoder { + return &TVDecoder{r: r} +} + +type TVDecoder struct { + r io.Reader +} + +func (tv *TVDecoder) Decode(v interface{}) error { + spdxDocument, err := tvloader.Load2_2(tv.r) + if err != nil { + return xerrors.Errorf("failed to load tag-value spdx: %w", err) + } + + a, ok := v.(*SPDX) + if !ok { + return xerrors.Errorf("invalid struct type tag-value decoder needed SPDX struct") + } + err = a.unmarshal(spdxDocument) + if err != nil { + return xerrors.Errorf("failed to unmarshal spdx: %w", err) + } + + return nil +} + +func (s *SPDX) UnmarshalJSON(b []byte) error { + spdxDocument, err := jsonloader.Load2_2(bytes.NewReader(b)) + if err != nil { + return xerrors.Errorf("failed to load spdx json: %w", err) + } + err = s.unmarshal(spdxDocument) + if err != nil { + return xerrors.Errorf("failed to unmarshal spdx: %w", err) + } + return nil +} + +func (s *SPDX) unmarshal(spdxDocument *spdx.Document2_2) error { + var osPkgs []ftypes.Package + apps := map[spdx.ElementID]*ftypes.Application{} + + // Package relationships would be as belows: + // - Root (container image, filesystem, etc.) + // - Operating System (debian 10) + // - OS package A + // - OS package B + // - Application 1 (package-lock.json) + // - Node.js package A + // - Node.js package B + // - Application 2 (Pipfile.lock) + // - Python package A + // - Python package B + for _, rel := range spdxDocument.Relationships { + pkgA := lo.FromPtr(spdxDocument.Packages[rel.RefA.ElementRefID]) + pkgB := lo.FromPtr(spdxDocument.Packages[rel.RefB.ElementRefID]) + + switch { + // Relationship: root package => OS + case isOperatingSystem(pkgB.PackageSPDXIdentifier): + s.SBOM.OS = parseOS(pkgB) + // Relationship: OS => OS package + case isOperatingSystem(pkgA.PackageSPDXIdentifier): + pkg, err := parsePkg(pkgB) + if err != nil { + return xerrors.Errorf("failed to parse os package: %w", err) + } + osPkgs = append(osPkgs, *pkg) + // Relationship: root package => application + case isApplication(pkgB.PackageSPDXIdentifier): + // pass + // Relationship: application => language-specific package + case isApplication(pkgA.PackageSPDXIdentifier): + app, ok := apps[pkgA.PackageSPDXIdentifier] + if !ok { + app = initApplication(pkgA) + apps[pkgA.PackageSPDXIdentifier] = app + } + + lib, err := parsePkg(pkgB) + if err != nil { + return xerrors.Errorf("failed to parse language-specific package: %w", err) + } + app.Libraries = append(app.Libraries, *lib) + } + } + + // Fill OS packages + if len(osPkgs) > 0 { + s.Packages = []ftypes.PackageInfo{{Packages: osPkgs}} + } + + // Fill applications + for _, app := range apps { + s.SBOM.Applications = append(s.SBOM.Applications, *app) + } + + // Keep the original document + s.SPDX = spdxDocument + return nil +} + +func isOperatingSystem(elementID spdx.ElementID) bool { + return strings.HasPrefix(string(elementID), ElementOperatingSystem) +} + +func isApplication(elementID spdx.ElementID) bool { + return strings.HasPrefix(string(elementID), ElementApplication) +} + +func initApplication(pkg spdx.Package2_2) *ftypes.Application { + app := &ftypes.Application{ + Type: pkg.PackageName, + FilePath: pkg.PackageSourceInfo, + } + if pkg.PackageName == ftypes.NodePkg || pkg.PackageName == ftypes.PythonPkg || + pkg.PackageName == ftypes.GemSpec || pkg.PackageName == ftypes.Jar { + app.FilePath = "" + } + return app +} + +func parseOS(pkg spdx.Package2_2) *ftypes.OS { + return &ftypes.OS{ + Family: pkg.PackageName, + Name: pkg.PackageVersion, + } +} + +func parsePkg(spdxPkg spdx.Package2_2) (*ftypes.Package, error) { + pkg, pkgType, err := parseExternalReferences(spdxPkg.PackageExternalReferences) + if err != nil { + return nil, xerrors.Errorf("external references error: %w", err) + } + + if spdxPkg.PackageLicenseDeclared != "NONE" { + pkg.Licenses = strings.Split(spdxPkg.PackageLicenseDeclared, ",") + } + + if strings.HasPrefix(spdxPkg.PackageSourceInfo, SourcePackagePrefix) { + srcPkgName := strings.TrimPrefix(spdxPkg.PackageSourceInfo, fmt.Sprintf("%s: ", SourcePackagePrefix)) + pkg.SrcEpoch, pkg.SrcName, pkg.SrcVersion, pkg.SrcRelease, err = parseSourceInfo(pkgType, srcPkgName) + if err != nil { + return nil, xerrors.Errorf("failed to parse source info: %w", err) + } + } + for _, f := range spdxPkg.Files { + pkg.FilePath = f.FileName + break // Take the first file name + } + + pkg.Layer.Digest = lookupAttributionTexts(spdxPkg.PackageAttributionTexts, PropertyLayerDigest) + pkg.Layer.DiffID = lookupAttributionTexts(spdxPkg.PackageAttributionTexts, PropertyLayerDiffID) + + return pkg, nil +} + +func parseExternalReferences(refs []*spdx.PackageExternalReference2_2) (*ftypes.Package, string, error) { + for _, ref := range refs { + // Extract the package information from PURL + if ref.RefType == RefTypePurl && ref.Category == CategoryPackageManager { + packageURL, err := purl.FromString(ref.Locator) + if err != nil { + return nil, "", xerrors.Errorf("failed to parse purl from string: %w", err) + } + pkg := packageURL.Package() + pkg.Ref = ref.Locator + return pkg, packageURL.Type, nil + } + } + return nil, "", errUnknownPackageFormat +} + +func lookupAttributionTexts(attributionTexts []string, key string) string { + for _, text := range attributionTexts { + if strings.HasPrefix(text, key) { + return strings.TrimPrefix(text, fmt.Sprintf("%s: ", key)) + } + } + return "" +} + +func parseSourceInfo(pkgType, sourceInfo string) (epoch int, name, ver, rel string, err error) { + srcNameVersion := strings.TrimPrefix(sourceInfo, fmt.Sprintf("%s: ", SourcePackagePrefix)) + ss := strings.Split(srcNameVersion, " ") + if len(ss) != 2 { + return 0, "", "", "", xerrors.Errorf("invalid source info (%s)", sourceInfo) + } + name = ss[0] + if pkgType == packageurl.TypeRPM { + v := version.NewVersion(ss[1]) + epoch = v.Epoch() + ver = v.Version() + rel = v.Release() + } else { + ver = ss[1] + } + return epoch, name, ver, rel, nil +} diff --git a/pkg/sbom/spdx/unmarshal_test.go b/pkg/sbom/spdx/unmarshal_test.go new file mode 100644 index 00000000000..c5089ac802d --- /dev/null +++ b/pkg/sbom/spdx/unmarshal_test.go @@ -0,0 +1,184 @@ +package spdx_test + +import ( + "encoding/json" + "os" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" + "github.com/aquasecurity/trivy/pkg/sbom" + "github.com/aquasecurity/trivy/pkg/sbom/spdx" +) + +func TestUnmarshaler_Unmarshal(t *testing.T) { + tests := []struct { + name string + inputFile string + want sbom.SBOM + wantErr string + }{ + { + name: "happy path", + inputFile: "testdata/happy/bom.json", + want: sbom.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", + Layer: ftypes.Layer{ + DiffID: "sha256:dd565ff850e7003356e2b252758f9bdc1ff2803f61e995e24c7844f6297f8fc3", + }, + }, + }, + }, + }, + Applications: []ftypes.Application{ + { + Type: "composer", + FilePath: "app/composer/composer.lock", + Libraries: []ftypes.Package{ + { + Name: "pear/log", + Version: "1.13.1", + Ref: "pkg:composer/pear/log@1.13.1", + Layer: ftypes.Layer{ + DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", + }, + }, + { + + Name: "pear/pear_exception", + Version: "v1.0.0", + Ref: "pkg:composer/pear/pear_exception@v1.0.0", + Layer: ftypes.Layer{ + DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", + }, + }, + }, + }, + { + Type: "gobinary", + FilePath: "app/gobinary/gobinary", + Libraries: []ftypes.Package{ + { + Name: "github.com/package-url/packageurl-go", + Version: "v0.1.1-0.20220203205134-d70459300c8a", + Ref: "pkg:golang/github.com/package-url/packageurl-go@v0.1.1-0.20220203205134-d70459300c8a", + Layer: ftypes.Layer{ + DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", + }, + }, + }, + }, + { + Type: "jar", + Libraries: []ftypes.Package{ + { + Name: "org.codehaus.mojo:child-project", + Ref: "pkg:maven/org.codehaus.mojo/child-project@1.0", + Version: "1.0", + Layer: ftypes.Layer{ + DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", + }, + }, + }, + }, + { + Type: "node-pkg", + Libraries: []ftypes.Package{ + { + Name: "bootstrap", + Version: "5.0.2", + Ref: "pkg:npm/bootstrap@5.0.2", + Licenses: []string{"MIT"}, + Layer: ftypes.Layer{ + DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", + }, + }, + }, + }, + }, + }, + }, + { + name: "happy path for unrelated bom", + inputFile: "testdata/happy/unrelated-bom.json", + want: sbom.SBOM{ + Applications: []ftypes.Application{ + { + Type: "composer", + FilePath: "app/composer/composer.lock", + 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 only os component", + inputFile: "testdata/happy/os-only-bom.json", + want: sbom.SBOM{ + OS: &ftypes.OS{ + Family: "alpine", + Name: "3.16.0", + }, + }, + }, + { + name: "happy path empty component", + inputFile: "testdata/happy/empty-bom.json", + want: sbom.SBOM{}, + }, + { + name: "sad path invalid purl", + inputFile: "testdata/sad/invalid-source-info.json", + wantErr: "failed to parse source info:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := os.Open(tt.inputFile) + require.NoError(t, err) + defer f.Close() + + v := &spdx.SPDX{SBOM: &sbom.SBOM{}} + err = json.NewDecoder(f).Decode(v) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + // Not compare the CycloneDX field + v.SPDX = nil + + sort.Slice(v.Applications, func(i, j int) bool { + return v.Applications[i].Type < v.Applications[j].Type + }) + require.NoError(t, err) + assert.Equal(t, tt.want, *v.SBOM) + }) + } +}