diff --git a/internal/output/__snapshots__/spdx_test.snap b/internal/output/__snapshots__/spdx_test.snap index 7d2a306b295..b7f5ac38a08 100755 --- a/internal/output/__snapshots__/spdx_test.snap +++ b/internal/output/__snapshots__/spdx_test.snap @@ -2423,6 +2423,182 @@ --- +[TestPrintSPDXResults_WithOSVScannerJSONSource/multiple_ecosystems_from_osv_scanner_json - 1] +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "SCALIBR-generated SPDX", + "documentNamespace": "https://spdx.google/", + "creationInfo": { + "creators": [ + "Tool: SCALIBR" + ], + "created": "" + }, + "packages": [ + { + "name": "main", + "SPDXID": "SPDXRef-Package-main-", + "versionInfo": "0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false + }, + { + "name": "requests", + "SPDXID": "SPDXRef-Package-requests-", + "versionInfo": "2.25.1", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:pypi/requests@2.25.1" + } + ] + }, + { + "name": "mylib", + "SPDXID": "SPDXRef-Package-mylib-", + "versionInfo": "1.0.0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:maven/com.example/mylib@1.0.0" + } + ] + }, + { + "name": "bar", + "SPDXID": "SPDXRef-Package-bar-", + "versionInfo": "v1.2.3", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:golang/github.com/foo/bar@v1.2.3" + } + ] + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-Package-main-", + "relationshipType": "DESCRIBES" + }, + { + "spdxElementId": "SPDXRef-Package-main-", + "relatedSpdxElement": "SPDXRef-Package-requests-", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-Package-requests-", + "relatedSpdxElement": "NOASSERTION", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-Package-main-", + "relatedSpdxElement": "SPDXRef-Package-mylib-", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-Package-mylib-", + "relatedSpdxElement": "NOASSERTION", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-Package-main-", + "relatedSpdxElement": "SPDXRef-Package-bar-", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-Package-bar-", + "relatedSpdxElement": "NOASSERTION", + "relationshipType": "CONTAINS" + } + ] +} + +--- + +[TestPrintSPDXResults_WithOSVScannerJSONSource/npm_package_from_osv_scanner_json - 1] +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "SCALIBR-generated SPDX", + "documentNamespace": "https://spdx.google/", + "creationInfo": { + "creators": [ + "Tool: SCALIBR" + ], + "created": "" + }, + "packages": [ + { + "name": "main", + "SPDXID": "SPDXRef-Package-main-", + "versionInfo": "0", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false + }, + { + "name": "lodash", + "SPDXID": "SPDXRef-Package-lodash-", + "versionInfo": "4.17.11", + "supplier": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/lodash@4.17.11" + } + ] + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relatedSpdxElement": "SPDXRef-Package-main-", + "relationshipType": "DESCRIBES" + }, + { + "spdxElementId": "SPDXRef-Package-main-", + "relatedSpdxElement": "SPDXRef-Package-lodash-", + "relationshipType": "CONTAINS" + }, + { + "spdxElementId": "SPDXRef-Package-lodash-", + "relatedSpdxElement": "NOASSERTION", + "relationshipType": "CONTAINS" + } + ] +} + +--- + [TestPrintSPDXResults_WithVulnerabilities/multiple_sources_with_a_mixed_count_of_grouped_packages,_and_multiple_vulnerabilities - 1] { "spdxVersion": "SPDX-2.3", diff --git a/internal/output/spdx.go b/internal/output/spdx.go index affe51a517c..322dc416775 100644 --- a/internal/output/spdx.go +++ b/internal/output/spdx.go @@ -2,10 +2,17 @@ package output import ( "encoding/json" + "fmt" "io" + "strings" scalibr "github.com/google/osv-scalibr" "github.com/google/osv-scalibr/converter/spdx" + "github.com/google/osv-scalibr/extractor" + "github.com/google/osv-scalibr/extractor/filesystem/language/java/javalockfile" + "github.com/google/osv-scalibr/inventory/osvecosystem" + scalibrpurl "github.com/google/osv-scalibr/purl" + purlutil "github.com/google/osv-scanner/v2/internal/utility/purl" "github.com/google/osv-scanner/v2/pkg/models" ) @@ -15,7 +22,17 @@ func PrintSPDXResults(vulnResult *models.VulnerabilityResults, outputWriter io.W for _, source := range vulnResult.Results { for _, pkg := range source.Packages { - scanResult.Inventory.Packages = append(scanResult.Inventory.Packages, pkg.Package.Inventory) + inv := pkg.Package.Inventory + if inv == nil { + var err error + inv, err = inventoryFromPackageInfo(pkg.Package) + if err != nil { + return err + } + } + if inv != nil { + scanResult.Inventory.Packages = append(scanResult.Inventory.Packages, inv) + } } } @@ -27,3 +44,38 @@ func PrintSPDXResults(vulnResult *models.VulnerabilityResults, outputWriter io.W return encoder.Encode(doc) } + +// inventoryFromPackageInfo constructs a synthetic extractor.Package from a PackageInfo +// for packages that don't have a populated Inventory field (e.g., loaded from osv-scanner.json). +func inventoryFromPackageInfo(pkg models.PackageInfo) (*extractor.Package, error) { + eco, err := osvecosystem.Parse(pkg.Ecosystem) + if err != nil { + return nil, err + } + + purlType, ok := purlutil.EcosystemToPURLMapper[eco.Ecosystem] + if !ok { + return nil, fmt.Errorf("unsupported ecosystem: %s", pkg.Ecosystem) + } + + inv := &extractor.Package{ + Name: pkg.Name, + Version: pkg.Version, + PURLType: purlType, + } + + // Maven names in osv-scanner are "groupId:artifactId". + // The scalibr Maven PURL converter requires Metadata with GroupID/ArtifactID + // to produce the correct PURL (pkg:maven/groupId/artifactId@version). + if purlType == scalibrpurl.TypeMaven { + parts := strings.SplitN(pkg.Name, ":", 2) + if len(parts) == 2 { + inv.Metadata = &javalockfile.Metadata{ + GroupID: parts[0], + ArtifactID: parts[1], + } + } + } + + return inv, nil +} diff --git a/internal/output/spdx_test.go b/internal/output/spdx_test.go index 910f81bbead..c1d27c80128 100644 --- a/internal/output/spdx_test.go +++ b/internal/output/spdx_test.go @@ -7,6 +7,7 @@ import ( "github.com/google/osv-scanner/v2/internal/cachedregexp" "github.com/google/osv-scanner/v2/internal/output" "github.com/google/osv-scanner/v2/internal/testutility" + "github.com/google/osv-scanner/v2/pkg/models" "github.com/jedib0t/go-pretty/v6/text" ) @@ -70,3 +71,84 @@ func TestPrintSPDXResults_WithMixedIssues(t *testing.T) { testutility.NewSnapshot().MatchText(t, normalizeSPDXOutput(t, outputWriter.String())) }) } + +// TestPrintSPDXResults_WithOSVScannerJSONSource tests that packages loaded from an +// osv-scanner.json results file (which have Inventory == nil) are correctly included +// in the SPDX output with the right PURL types. This is a regression test for issue #2192. +func TestPrintSPDXResults_WithOSVScannerJSONSource(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + vulnResult *models.VulnerabilityResults + }{ + { + name: "npm_package_from_osv_scanner_json", + vulnResult: &models.VulnerabilityResults{ + Results: []models.PackageSource{ + { + Source: models.SourceInfo{Path: "/path/to/osv-scanner.json", Type: models.SourceTypeProjectPackage}, + Packages: []models.PackageVulns{ + { + // Inventory is nil, as it would be when loaded from osv-scanner.json + Package: models.PackageInfo{ + Name: "lodash", + Version: "4.17.11", + Ecosystem: "npm", + }, + }, + }, + }, + }, + }, + }, + { + name: "multiple_ecosystems_from_osv_scanner_json", + vulnResult: &models.VulnerabilityResults{ + Results: []models.PackageSource{ + { + Source: models.SourceInfo{Path: "/path/to/osv-scanner.json", Type: models.SourceTypeProjectPackage}, + Packages: []models.PackageVulns{ + { + Package: models.PackageInfo{ + Name: "requests", + Version: "2.25.1", + Ecosystem: "PyPI", + }, + }, + { + Package: models.PackageInfo{ + Name: "com.example:mylib", + Version: "1.0.0", + Ecosystem: "Maven", + }, + }, + { + Package: models.PackageInfo{ + Name: "github.com/foo/bar", + Version: "v1.2.3", + Ecosystem: "Go", + }, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + outputWriter := &bytes.Buffer{} + err := output.PrintSPDXResults(tt.vulnResult, outputWriter) + + if err != nil { + t.Errorf("%v", err) + } + + testutility.NewSnapshot().MatchText(t, normalizeSPDXOutput(t, outputWriter.String())) + }) + } +}