diff --git a/internal/formats/common/spdxhelpers/description.go b/internal/formats/common/spdxhelpers/description.go new file mode 100644 index 000000000..8b8783a26 --- /dev/null +++ b/internal/formats/common/spdxhelpers/description.go @@ -0,0 +1,14 @@ +package spdxhelpers + +import "github.com/anchore/syft/syft/pkg" + +func Description(p *pkg.Package) string { + switch metadata := p.Metadata.(type) { + case pkg.ApkMetadata: + return metadata.Description + case pkg.NpmPackageJSONMetadata: + return metadata.Description + default: + return "" + } +} diff --git a/internal/formats/common/spdxhelpers/description_test.go b/internal/formats/common/spdxhelpers/description_test.go new file mode 100644 index 000000000..77bc424ed --- /dev/null +++ b/internal/formats/common/spdxhelpers/description_test.go @@ -0,0 +1,56 @@ +package spdxhelpers + +import ( + "testing" + + "github.com/anchore/syft/syft/pkg" + "github.com/stretchr/testify/assert" +) + +func Test_Description(t *testing.T) { + tests := []struct { + name string + input pkg.Package + expected string + }{ + { + // note: since this is an optional field, no value is preferred over NONE or NOASSERTION + name: "no metadata", + input: pkg.Package{}, + expected: "", + }, + { + name: "from apk", + input: pkg.Package{ + Metadata: pkg.ApkMetadata{ + Description: "a description!", + }, + }, + expected: "a description!", + }, + { + name: "from npm", + input: pkg.Package{ + Metadata: pkg.NpmPackageJSONMetadata{ + Description: "a description!", + }, + }, + expected: "a description!", + }, + { + // note: since this is an optional field, no value is preferred over NONE or NOASSERTION + name: "empty", + input: pkg.Package{ + Metadata: pkg.NpmPackageJSONMetadata{ + Homepage: "", + }, + }, + expected: "", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, Description(&test.input)) + }) + } +} diff --git a/internal/formats/common/spdxhelpers/download_location.go b/internal/formats/common/spdxhelpers/download_location.go new file mode 100644 index 000000000..fea6b9d90 --- /dev/null +++ b/internal/formats/common/spdxhelpers/download_location.go @@ -0,0 +1,22 @@ +package spdxhelpers + +import "github.com/anchore/syft/syft/pkg" + +func DownloadLocation(p *pkg.Package) string { + // 3.7: Package Download Location + // Cardinality: mandatory, one + // NONE if there is no download location whatsoever. + // NOASSERTION if: + // (i) the SPDX file creator has attempted to but cannot reach a reasonable objective determination; + // (ii) the SPDX file creator has made no attempt to determine this field; or + // (iii) the SPDX file creator has intentionally provided no information (no meaning should be implied by doing so). + + switch metadata := p.Metadata.(type) { + case pkg.ApkMetadata: + return NoneIfEmpty(metadata.URL) + case pkg.NpmPackageJSONMetadata: + return NoneIfEmpty(metadata.URL) + default: + return "NOASSERTION" + } +} diff --git a/internal/formats/common/spdxhelpers/download_location_test.go b/internal/formats/common/spdxhelpers/download_location_test.go new file mode 100644 index 000000000..3636c7c77 --- /dev/null +++ b/internal/formats/common/spdxhelpers/download_location_test.go @@ -0,0 +1,54 @@ +package spdxhelpers + +import ( + "testing" + + "github.com/anchore/syft/syft/pkg" + "github.com/stretchr/testify/assert" +) + +func Test_DownloadLocation(t *testing.T) { + tests := []struct { + name string + input pkg.Package + expected string + }{ + { + name: "no metadata", + input: pkg.Package{}, + expected: "NOASSERTION", + }, + { + name: "from apk", + input: pkg.Package{ + Metadata: pkg.ApkMetadata{ + URL: "http://a-place.gov", + }, + }, + expected: "http://a-place.gov", + }, + { + name: "from npm", + input: pkg.Package{ + Metadata: pkg.NpmPackageJSONMetadata{ + URL: "http://a-place.gov", + }, + }, + expected: "http://a-place.gov", + }, + { + name: "empty", + input: pkg.Package{ + Metadata: pkg.NpmPackageJSONMetadata{ + URL: "", + }, + }, + expected: "NONE", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, DownloadLocation(&test.input)) + }) + } +} diff --git a/internal/formats/common/spdxhelpers/external_refs.go b/internal/formats/common/spdxhelpers/external_refs.go new file mode 100644 index 000000000..dc2ea2735 --- /dev/null +++ b/internal/formats/common/spdxhelpers/external_refs.go @@ -0,0 +1,50 @@ +package spdxhelpers + +import ( + "github.com/anchore/syft/internal/formats/spdx22json/model" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/pkg" +) + +func ExternalRefs(p *pkg.Package) (externalRefs []model.ExternalRef) { + externalRefs = make([]model.ExternalRef, 0) + for _, c := range p.CPEs { + externalRefs = append(externalRefs, model.ExternalRef{ + ReferenceCategory: model.SecurityReferenceCategory, + ReferenceLocator: c.BindToFmtString(), + ReferenceType: model.Cpe23ExternalRefType, + }) + } + + if p.PURL != "" { + externalRefs = append(externalRefs, model.ExternalRef{ + ReferenceCategory: model.PackageManagerReferenceCategory, + ReferenceLocator: p.PURL, + ReferenceType: model.PurlExternalRefType, + }) + } + return externalRefs +} + +func ExtractPURL(refs []model.ExternalRef) string { + for _, r := range refs { + if r.ReferenceType == model.PurlExternalRefType { + return r.ReferenceLocator + } + } + return "" +} + +func ExtractCPEs(refs []model.ExternalRef) (cpes []pkg.CPE) { + for _, r := range refs { + if r.ReferenceType == model.Cpe23ExternalRefType { + cpe, err := pkg.NewCPE(r.ReferenceLocator) + if err != nil { + log.Warnf("unable to extract SPDX CPE=%q: %+v", r.ReferenceLocator, err) + continue + } + cpes = append(cpes, cpe) + } + } + return cpes +} diff --git a/internal/formats/common/spdxhelpers/external_refs_test.go b/internal/formats/common/spdxhelpers/external_refs_test.go new file mode 100644 index 000000000..ba9663118 --- /dev/null +++ b/internal/formats/common/spdxhelpers/external_refs_test.go @@ -0,0 +1,46 @@ +package spdxhelpers + +import ( + "testing" + + "github.com/anchore/syft/internal/formats/common/testutils" + "github.com/anchore/syft/internal/formats/spdx22json/model" + "github.com/anchore/syft/syft/pkg" + "github.com/stretchr/testify/assert" +) + +func Test_ExternalRefs(t *testing.T) { + testCPE := testutils.MustCPE(pkg.NewCPE("cpe:2.3:a:name:name:3.2:*:*:*:*:*:*:*")) + tests := []struct { + name string + input pkg.Package + expected []model.ExternalRef + }{ + { + name: "cpe + purl", + input: pkg.Package{ + CPEs: []pkg.CPE{ + testCPE, + }, + PURL: "a-purl", + }, + expected: []model.ExternalRef{ + { + ReferenceCategory: model.SecurityReferenceCategory, + ReferenceLocator: testCPE.BindToFmtString(), + ReferenceType: model.Cpe23ExternalRefType, + }, + { + ReferenceCategory: model.PackageManagerReferenceCategory, + ReferenceLocator: "a-purl", + ReferenceType: model.PurlExternalRefType, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.ElementsMatch(t, test.expected, ExternalRefs(&test.input)) + }) + } +} diff --git a/internal/formats/common/spdxhelpers/files.go b/internal/formats/common/spdxhelpers/files.go new file mode 100644 index 000000000..0e05e321c --- /dev/null +++ b/internal/formats/common/spdxhelpers/files.go @@ -0,0 +1,47 @@ +package spdxhelpers + +import ( + "crypto/sha256" + "fmt" + "path/filepath" + + "github.com/anchore/syft/internal/formats/spdx22json/model" + "github.com/anchore/syft/syft/pkg" +) + +func Files(packageSpdxID string, p *pkg.Package) (files []model.File, fileIDs []string, relationships []model.Relationship) { + files = make([]model.File, 0) + fileIDs = make([]string, 0) + relationships = make([]model.Relationship, 0) + + pkgFileOwner, ok := p.Metadata.(pkg.FileOwner) + if !ok { + return files, fileIDs, relationships + } + + for _, ownedFilePath := range pkgFileOwner.OwnedFiles() { + baseFileName := filepath.Base(ownedFilePath) + pathHash := sha256.Sum256([]byte(ownedFilePath)) + fileSpdxID := model.ElementID(fmt.Sprintf("File-%s-%x", p.Name, pathHash)).String() + + fileIDs = append(fileIDs, fileSpdxID) + + files = append(files, model.File{ + FileName: ownedFilePath, + Item: model.Item{ + Element: model.Element{ + SPDXID: fileSpdxID, + Name: baseFileName, + }, + }, + }) + + relationships = append(relationships, model.Relationship{ + SpdxElementID: packageSpdxID, + RelationshipType: model.ContainsRelationship, + RelatedSpdxElement: fileSpdxID, + }) + } + + return files, fileIDs, relationships +} diff --git a/internal/formats/common/spdxhelpers/homepage.go b/internal/formats/common/spdxhelpers/homepage.go new file mode 100644 index 000000000..8ea1ad753 --- /dev/null +++ b/internal/formats/common/spdxhelpers/homepage.go @@ -0,0 +1,14 @@ +package spdxhelpers + +import "github.com/anchore/syft/syft/pkg" + +func Homepage(p *pkg.Package) string { + switch metadata := p.Metadata.(type) { + case pkg.GemMetadata: + return metadata.Homepage + case pkg.NpmPackageJSONMetadata: + return metadata.Homepage + default: + return "" + } +} diff --git a/internal/formats/common/spdxhelpers/homepage_test.go b/internal/formats/common/spdxhelpers/homepage_test.go new file mode 100644 index 000000000..781873f7a --- /dev/null +++ b/internal/formats/common/spdxhelpers/homepage_test.go @@ -0,0 +1,56 @@ +package spdxhelpers + +import ( + "testing" + + "github.com/anchore/syft/syft/pkg" + "github.com/stretchr/testify/assert" +) + +func Test_Homepage(t *testing.T) { + tests := []struct { + name string + input pkg.Package + expected string + }{ + { + // note: since this is an optional field, no value is preferred over NONE or NOASSERTION + name: "no metadata", + input: pkg.Package{}, + expected: "", + }, + { + name: "from gem", + input: pkg.Package{ + Metadata: pkg.GemMetadata{ + Homepage: "http://a-place.gov", + }, + }, + expected: "http://a-place.gov", + }, + { + name: "from npm", + input: pkg.Package{ + Metadata: pkg.NpmPackageJSONMetadata{ + Homepage: "http://a-place.gov", + }, + }, + expected: "http://a-place.gov", + }, + { + // note: since this is an optional field, no value is preferred over NONE or NOASSERTION + name: "empty", + input: pkg.Package{ + Metadata: pkg.NpmPackageJSONMetadata{ + Homepage: "", + }, + }, + expected: "", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, Homepage(&test.input)) + }) + } +} diff --git a/internal/formats/common/spdxhelpers/license.go b/internal/formats/common/spdxhelpers/license.go new file mode 100644 index 000000000..f2e4307ad --- /dev/null +++ b/internal/formats/common/spdxhelpers/license.go @@ -0,0 +1,37 @@ +package spdxhelpers + +import ( + "strings" + + "github.com/anchore/syft/internal/spdxlicense" + "github.com/anchore/syft/syft/pkg" +) + +func License(p *pkg.Package) string { + // source: https://spdx.github.io/spdx-spec/3-package-information/#313-concluded-license + // The options to populate this field are limited to: + // A valid SPDX License Expression as defined in Appendix IV; + // NONE, if the SPDX file creator concludes there is no license available for this package; or + // NOASSERTION if: + // (i) the SPDX file creator has attempted to but cannot reach a reasonable objective determination; + // (ii) the SPDX file creator has made no attempt to determine this field; or + // (iii) the SPDX file creator has intentionally provided no information (no meaning should be implied by doing so). + + if len(p.Licenses) == 0 { + return "NONE" + } + + // take all licenses and assume an AND expression; for information about license expressions see https://spdx.github.io/spdx-spec/appendix-IV-SPDX-license-expressions/ + var parsedLicenses []string + for _, l := range p.Licenses { + if value, exists := spdxlicense.ID(l); exists { + parsedLicenses = append(parsedLicenses, value) + } + } + + if len(parsedLicenses) == 0 { + return "NOASSERTION" + } + + return strings.Join(parsedLicenses, " AND ") +} diff --git a/internal/formats/common/spdxhelpers/license_test.go b/internal/formats/common/spdxhelpers/license_test.go new file mode 100644 index 000000000..c4762ee18 --- /dev/null +++ b/internal/formats/common/spdxhelpers/license_test.go @@ -0,0 +1,73 @@ +package spdxhelpers + +import ( + "testing" + + "github.com/anchore/syft/syft/pkg" + "github.com/stretchr/testify/assert" +) + +func Test_License(t *testing.T) { + tests := []struct { + name string + input pkg.Package + expected string + }{ + { + name: "no licenses", + input: pkg.Package{}, + expected: "NONE", + }, + { + name: "no SPDX licenses", + input: pkg.Package{ + Licenses: []string{ + "made-up", + }, + }, + expected: "NOASSERTION", + }, + { + name: "with SPDX license", + input: pkg.Package{ + Licenses: []string{ + "MIT", + }, + }, + expected: "MIT", + }, + { + name: "with SPDX license expression", + input: pkg.Package{ + Licenses: []string{ + "MIT", + "GPL-3.0", + }, + }, + expected: "MIT AND GPL-3.0", + }, + { + name: "cap insensitive", + input: pkg.Package{ + Licenses: []string{ + "gpl-3.0", + }, + }, + expected: "GPL-3.0", + }, + { + name: "debian to spdx conversion", + input: pkg.Package{ + Licenses: []string{ + "GPL-2", + }, + }, + expected: "GPL-2.0", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, License(&test.input)) + }) + } +} diff --git a/internal/formats/common/spdxhelpers/none_if_empty.go b/internal/formats/common/spdxhelpers/none_if_empty.go new file mode 100644 index 000000000..fa97a0d7e --- /dev/null +++ b/internal/formats/common/spdxhelpers/none_if_empty.go @@ -0,0 +1,12 @@ +package spdxhelpers + +import ( + "strings" +) + +func NoneIfEmpty(value string) string { + if strings.TrimSpace(value) == "" { + return "NONE" + } + return value +} diff --git a/internal/formats/common/spdxhelpers/none_if_empty_test.go b/internal/formats/common/spdxhelpers/none_if_empty_test.go new file mode 100644 index 000000000..4c447f122 --- /dev/null +++ b/internal/formats/common/spdxhelpers/none_if_empty_test.go @@ -0,0 +1,41 @@ +package spdxhelpers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_noneIfEmpty(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + { + name: "non-zero value", + value: "something", + expected: "something", + }, + { + name: "empty", + value: "", + expected: "NONE", + }, + { + name: "space", + value: " ", + expected: "NONE", + }, + { + name: "tab", + value: "\t", + expected: "NONE", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, NoneIfEmpty(test.value)) + }) + } +} diff --git a/internal/formats/common/spdxhelpers/originator_test.go b/internal/formats/common/spdxhelpers/originator_test.go new file mode 100644 index 000000000..7e3ec04ed --- /dev/null +++ b/internal/formats/common/spdxhelpers/originator_test.go @@ -0,0 +1,114 @@ +package spdxhelpers + +import ( + "testing" + + "github.com/anchore/syft/syft/pkg" + "github.com/stretchr/testify/assert" +) + +func Test_Originator(t *testing.T) { + tests := []struct { + name string + input pkg.Package + expected string + }{ + { + // note: since this is an optional field, no value is preferred over NONE or NOASSERTION + name: "no metadata", + input: pkg.Package{}, + expected: "", + }, + { + name: "from gem", + input: pkg.Package{ + Metadata: pkg.GemMetadata{ + Authors: []string{ + "auth1", + "auth2", + }, + }, + }, + expected: "auth1", + }, + { + name: "from npm", + input: pkg.Package{ + Metadata: pkg.NpmPackageJSONMetadata{ + Author: "auth", + }, + }, + expected: "auth", + }, + { + name: "from apk", + input: pkg.Package{ + Metadata: pkg.ApkMetadata{ + Maintainer: "auth", + }, + }, + expected: "auth", + }, + { + name: "from python - just name", + input: pkg.Package{ + Metadata: pkg.PythonPackageMetadata{ + Author: "auth", + }, + }, + expected: "auth", + }, + { + name: "from python - just email", + input: pkg.Package{ + Metadata: pkg.PythonPackageMetadata{ + AuthorEmail: "auth@auth.gov", + }, + }, + expected: "auth@auth.gov", + }, + { + name: "from python - both name and email", + input: pkg.Package{ + Metadata: pkg.PythonPackageMetadata{ + Author: "auth", + AuthorEmail: "auth@auth.gov", + }, + }, + expected: "auth ", + }, + { + name: "from rpm", + input: pkg.Package{ + Metadata: pkg.RpmdbMetadata{ + Vendor: "auth", + }, + }, + expected: "auth", + }, + { + name: "from dpkg", + input: pkg.Package{ + Metadata: pkg.DpkgMetadata{ + Maintainer: "auth", + }, + }, + expected: "auth", + }, + { + // note: since this is an optional field, no value is preferred over NONE or NOASSERTION + name: "empty", + input: pkg.Package{ + Metadata: pkg.NpmPackageJSONMetadata{ + Author: "", + }, + }, + expected: "", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, Originator(&test.input)) + }) + } +} diff --git a/internal/formats/common/spdxhelpers/origintor.go b/internal/formats/common/spdxhelpers/origintor.go new file mode 100644 index 000000000..f9ecbe2be --- /dev/null +++ b/internal/formats/common/spdxhelpers/origintor.go @@ -0,0 +1,36 @@ +package spdxhelpers + +import ( + "fmt" + + "github.com/anchore/syft/syft/pkg" +) + +func Originator(p *pkg.Package) string { + switch metadata := p.Metadata.(type) { + case pkg.ApkMetadata: + return metadata.Maintainer + case pkg.NpmPackageJSONMetadata: + return metadata.Author + case pkg.PythonPackageMetadata: + author := metadata.Author + if author == "" { + return metadata.AuthorEmail + } + if metadata.AuthorEmail != "" { + author += fmt.Sprintf(" <%s>", metadata.AuthorEmail) + } + return author + case pkg.GemMetadata: + if len(metadata.Authors) > 0 { + return metadata.Authors[0] + } + return "" + case pkg.RpmdbMetadata: + return metadata.Vendor + case pkg.DpkgMetadata: + return metadata.Maintainer + default: + return "" + } +} diff --git a/internal/formats/common/spdxhelpers/source_info.go b/internal/formats/common/spdxhelpers/source_info.go new file mode 100644 index 000000000..7c77696f0 --- /dev/null +++ b/internal/formats/common/spdxhelpers/source_info.go @@ -0,0 +1,39 @@ +package spdxhelpers + +import ( + "strings" + + "github.com/anchore/syft/syft/pkg" +) + +func SourceInfo(p *pkg.Package) string { + answer := "" + switch p.Type { + case pkg.RpmPkg: + answer = "acquired package info from RPM DB" + case pkg.ApkPkg: + answer = "acquired package info from APK DB" + case pkg.DebPkg: + answer = "acquired package info from DPKG DB" + case pkg.NpmPkg: + answer = "acquired package info from installed node module manifest file" + case pkg.PythonPkg: + answer = "acquired package info from installed python package manifest file" + case pkg.JavaPkg, pkg.JenkinsPluginPkg: + answer = "acquired package info from installed java archive" + case pkg.GemPkg: + answer = "acquired package info from installed gem metadata file" + case pkg.GoModulePkg: + answer = "acquired package info from go module information" + case pkg.RustPkg: + answer = "acquired package info from rust cargo manifest" + default: + answer = "acquired package info from the following paths" + } + var paths []string + for _, l := range p.Locations { + paths = append(paths, l.RealPath) + } + + return answer + ": " + strings.Join(paths, ", ") +} diff --git a/internal/formats/common/spdxhelpers/source_info_test.go b/internal/formats/common/spdxhelpers/source_info_test.go new file mode 100644 index 000000000..0faec1548 --- /dev/null +++ b/internal/formats/common/spdxhelpers/source_info_test.go @@ -0,0 +1,141 @@ +package spdxhelpers + +import ( + "testing" + + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/source" + "github.com/stretchr/testify/assert" +) + +func Test_SourceInfo(t *testing.T) { + tests := []struct { + name string + input pkg.Package + expected []string + }{ + { + name: "locations are captured", + input: pkg.Package{ + // note: no type given + Locations: []source.Location{ + { + RealPath: "/a-place", + VirtualPath: "/b-place", + }, + { + RealPath: "/c-place", + VirtualPath: "/d-place", + }, + }, + }, + expected: []string{ + "from the following paths", + "/a-place", + "/c-place", + }, + }, + { + // note: no specific support for this + input: pkg.Package{ + Type: pkg.KbPkg, + }, + expected: []string{ + "from the following paths", + }, + }, + { + input: pkg.Package{ + Type: pkg.RpmPkg, + }, + expected: []string{ + "from RPM DB", + }, + }, + { + input: pkg.Package{ + Type: pkg.ApkPkg, + }, + expected: []string{ + "from APK DB", + }, + }, + { + input: pkg.Package{ + Type: pkg.DebPkg, + }, + expected: []string{ + "from DPKG DB", + }, + }, + { + input: pkg.Package{ + Type: pkg.NpmPkg, + }, + expected: []string{ + "from installed node module manifest file", + }, + }, + { + input: pkg.Package{ + Type: pkg.PythonPkg, + }, + expected: []string{ + "from installed python package manifest file", + }, + }, + { + input: pkg.Package{ + Type: pkg.JavaPkg, + }, + expected: []string{ + "from installed java archive", + }, + }, + { + input: pkg.Package{ + Type: pkg.JenkinsPluginPkg, + }, + expected: []string{ + "from installed java archive", + }, + }, + { + input: pkg.Package{ + Type: pkg.GemPkg, + }, + expected: []string{ + "from installed gem metadata file", + }, + }, + { + input: pkg.Package{ + Type: pkg.GoModulePkg, + }, + expected: []string{ + "from go module information", + }, + }, + { + input: pkg.Package{ + Type: pkg.RustPkg, + }, + expected: []string{ + "from rust cargo manifest", + }, + }, + } + var pkgTypes []pkg.Type + for _, test := range tests { + t.Run(test.name+" "+string(test.input.Type), func(t *testing.T) { + if test.input.Type != "" { + pkgTypes = append(pkgTypes, test.input.Type) + } + actual := SourceInfo(&test.input) + for _, expected := range test.expected { + assert.Contains(t, actual, expected) + } + }) + } + assert.ElementsMatch(t, pkg.AllPkgs, pkgTypes, "missing one or more package types to test against (maybe a package type was added?)") +}