From 8a4886ec0ed455a5551739a8bcae78669d8acfbc Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Sun, 30 Aug 2020 21:40:19 -0400 Subject: [PATCH] Add package URL support to the CycloneDX presenter (#164) * add package URL support to the CycloneDX presenter Signed-off-by: Alex Goodman * wrap license tags with licenses Signed-off-by: Alex Goodman --- .bouncer.yaml | 5 +- cmd/root.go | 4 +- go.mod | 1 + go.sum | 2 + syft/cataloger/dpkg/parse_dpkg_status_test.go | 19 +-- syft/cataloger/rpmdb/parse_rpmdb.go | 1 + syft/cataloger/rpmdb/parse_rpmdb_test.go | 1 + syft/pkg/apk_metadata.go | 50 +++++++ syft/pkg/apk_metadata_test.go | 33 +++++ syft/pkg/dpkg_metadata.go | 33 +++++ syft/pkg/dpkg_metadata_test.go | 51 +++++++ syft/pkg/java_metadata.go | 50 +++++++ syft/pkg/java_metadata_test.go | 41 ++++++ syft/pkg/metadata.go | 79 ---------- syft/pkg/package.go | 43 ++++++ syft/pkg/package_test.go | 136 ++++++++++++++++++ syft/pkg/rpm_metadata.go | 37 +++++ syft/pkg/rpm_metadata_test.go | 53 +++++++ syft/pkg/type.go | 26 ++++ syft/presenter/cyclonedx/component.go | 1 + syft/presenter/cyclonedx/document.go | 11 +- syft/presenter/cyclonedx/presenter.go | 8 +- syft/presenter/cyclonedx/presenter_test.go | 64 +++++++-- .../TestCycloneDxDirsPresenter.golden | 10 +- .../TestCycloneDxImgsPresenter.golden | 10 +- syft/presenter/presenter.go | 6 +- test/integration/json_schema_test.go | 10 +- 27 files changed, 669 insertions(+), 116 deletions(-) create mode 100644 syft/pkg/apk_metadata.go create mode 100644 syft/pkg/apk_metadata_test.go create mode 100644 syft/pkg/dpkg_metadata.go create mode 100644 syft/pkg/dpkg_metadata_test.go create mode 100644 syft/pkg/java_metadata.go create mode 100644 syft/pkg/java_metadata_test.go delete mode 100644 syft/pkg/metadata.go create mode 100644 syft/pkg/package_test.go create mode 100644 syft/pkg/rpm_metadata.go create mode 100644 syft/pkg/rpm_metadata_test.go diff --git a/.bouncer.yaml b/.bouncer.yaml index af6e762a5..119d1c7ce 100644 --- a/.bouncer.yaml +++ b/.bouncer.yaml @@ -3,4 +3,7 @@ permit: - MIT.* - Apache.* - MPL.* - - ISC \ No newline at end of file + - ISC +ignore-packages: + # packageurl-go is released under the MIT license located in the root of the repo at /mit.LICENSE + - github.com/package-url/packageurl-go \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index bba1328f5..af02fd981 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -68,7 +68,7 @@ func startWorker(userInput string) <-chan error { } } - catalog, scope, _, err := syft.Catalog(userInput, appConfig.ScopeOpt) + catalog, scope, distro, err := syft.Catalog(userInput, appConfig.ScopeOpt) if err != nil { errs <- fmt.Errorf("failed to catalog input: %+v", err) return @@ -76,7 +76,7 @@ func startWorker(userInput string) <-chan error { bus.Publish(partybus.Event{ Type: event.CatalogerFinished, - Value: presenter.GetPresenter(appConfig.PresenterOpt, *scope, catalog), + Value: presenter.GetPresenter(appConfig.PresenterOpt, *scope, catalog, distro), }) }() return errs diff --git a/go.mod b/go.mod index 977800760..754f964db 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/mapstructure v1.3.1 github.com/olekukonko/tablewriter v0.0.4 + github.com/package-url/packageurl-go v0.1.0 github.com/pelletier/go-toml v1.8.0 github.com/rogpeppe/go-internal v1.5.2 github.com/sergi/go-diff v1.1.0 diff --git a/go.sum b/go.sum index 5142a4956..9c65daa48 100644 --- a/go.sum +++ b/go.sum @@ -668,6 +668,8 @@ github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5X github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y= github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/package-url/packageurl-go v0.1.0 h1:efWBc98O/dBZRg1pw2xiDzovnlMjCa9NPnfaiBduh8I= +github.com/package-url/packageurl-go v0.1.0/go.mod h1:C/ApiuWpmbpni4DIOECf6WCjFUZV7O1Fx7VAzrZHgBw= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= diff --git a/syft/cataloger/dpkg/parse_dpkg_status_test.go b/syft/cataloger/dpkg/parse_dpkg_status_test.go index bec3e5920..1532650a4 100644 --- a/syft/cataloger/dpkg/parse_dpkg_status_test.go +++ b/syft/cataloger/dpkg/parse_dpkg_status_test.go @@ -24,9 +24,10 @@ func TestSinglePackage(t *testing.T) { { name: "Test Single Package", expected: pkg.DpkgMetadata{ - Package: "apt", - Source: "apt-dev", - Version: "1.8.2", + Package: "apt", + Source: "apt-dev", + Version: "1.8.2", + Architecture: "amd64", }, }, } @@ -65,13 +66,15 @@ func TestMultiplePackages(t *testing.T) { name: "Test Multiple Package", expected: []pkg.DpkgMetadata{ { - Package: "tzdata", - Version: "2020a-0+deb10u1", - Source: "tzdata-dev", + Package: "tzdata", + Version: "2020a-0+deb10u1", + Source: "tzdata-dev", + Architecture: "all", }, { - Package: "util-linux", - Version: "2.33.1-0.1", + Package: "util-linux", + Version: "2.33.1-0.1", + Architecture: "amd64", }, }, }, diff --git a/syft/cataloger/rpmdb/parse_rpmdb.go b/syft/cataloger/rpmdb/parse_rpmdb.go index c899910ae..1fcf5e12a 100644 --- a/syft/cataloger/rpmdb/parse_rpmdb.go +++ b/syft/cataloger/rpmdb/parse_rpmdb.go @@ -54,6 +54,7 @@ func parseRpmDB(_ string, reader io.Reader) ([]pkg.Package, error) { //Version: fmt.Sprintf("%d:%s-%s.%s", entry.Epoch, entry.Version, entry.Release, entry.Arch), Type: pkg.RpmPkg, Metadata: pkg.RpmMetadata{ + Name: entry.Name, Version: entry.Version, Epoch: entry.Epoch, Arch: entry.Arch, diff --git a/syft/cataloger/rpmdb/parse_rpmdb_test.go b/syft/cataloger/rpmdb/parse_rpmdb_test.go index bbcb25b8d..2a4100f94 100644 --- a/syft/cataloger/rpmdb/parse_rpmdb_test.go +++ b/syft/cataloger/rpmdb/parse_rpmdb_test.go @@ -14,6 +14,7 @@ func TestParseRpmDB(t *testing.T) { Version: "0.9.2-1", Type: pkg.RpmPkg, Metadata: pkg.RpmMetadata{ + Name: "dive", Epoch: 0, Arch: "x86_64", Release: "1", diff --git a/syft/pkg/apk_metadata.go b/syft/pkg/apk_metadata.go new file mode 100644 index 000000000..c3606701f --- /dev/null +++ b/syft/pkg/apk_metadata.go @@ -0,0 +1,50 @@ +package pkg + +import ( + "github.com/package-url/packageurl-go" +) + +// ApkMetadata represents all captured data for a Alpine DB package entry. See https://wiki.alpinelinux.org/wiki/Apk_spec for more information. +type ApkMetadata struct { + Package string `mapstructure:"P" json:"package"` + OriginPackage string `mapstructure:"o" json:"origin-package"` + Maintainer string `mapstructure:"m" json:"maintainer"` + Version string `mapstructure:"V" json:"version"` + License string `mapstructure:"L" json:"license"` + Architecture string `mapstructure:"A" json:"architecture"` + URL string `mapstructure:"U" json:"url"` + Description string `mapstructure:"T" json:"description"` + Size int `mapstructure:"S" json:"size"` + InstalledSize int `mapstructure:"I" json:"installed-size"` + PullDependencies string `mapstructure:"D" json:"pull-dependencies"` + PullChecksum string `mapstructure:"C" json:"pull-checksum"` + GitCommitOfAport string `mapstructure:"c" json:"git-commit-of-apk-port"` + Files []ApkFileRecord `json:"files"` +} + +// ApkFileRecord represents a single file listing and metadata from a APK DB entry (which may have many of these file records). +type ApkFileRecord struct { + Path string `json:"path"` + OwnerUID string `json:"owner-uid"` + OwnerGUI string `json:"owner-gid"` + Permissions string `json:"permissions"` + Checksum string `json:"checksum"` +} + +func (m ApkMetadata) PackageURL() string { + pURL := packageurl.NewPackageURL( + // note: this is currently a candidate and not technically within spec + // see https://github.com/package-url/purl-spec#other-candidate-types-to-define + "alpine", + "", + m.Package, + m.Version, + packageurl.Qualifiers{ + { + Key: "arch", + Value: m.Architecture, + }, + }, + "") + return pURL.ToString() +} diff --git a/syft/pkg/apk_metadata_test.go b/syft/pkg/apk_metadata_test.go new file mode 100644 index 000000000..ddf87cf95 --- /dev/null +++ b/syft/pkg/apk_metadata_test.go @@ -0,0 +1,33 @@ +package pkg + +import ( + "github.com/sergi/go-diff/diffmatchpatch" + "testing" +) + +func TestApkMetadata_pURL(t *testing.T) { + tests := []struct { + metadata ApkMetadata + expected string + }{ + { + metadata: ApkMetadata{ + Package: "p", + Version: "v", + Architecture: "a", + }, + expected: "pkg:alpine/p@v?arch=a", + }, + } + + for _, test := range tests { + t.Run(test.expected, func(t *testing.T) { + actual := test.metadata.PackageURL() + if actual != test.expected { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(test.expected, actual, true) + t.Errorf("diff: %s", dmp.DiffPrettyText(diffs)) + } + }) + } +} diff --git a/syft/pkg/dpkg_metadata.go b/syft/pkg/dpkg_metadata.go new file mode 100644 index 000000000..63db0f7e4 --- /dev/null +++ b/syft/pkg/dpkg_metadata.go @@ -0,0 +1,33 @@ +package pkg + +import ( + "github.com/anchore/syft/syft/distro" + "github.com/package-url/packageurl-go" +) + +// DpkgMetadata represents all captured data for a Debian package DB entry; available fields are described +// at http://manpages.ubuntu.com/manpages/xenial/man1/dpkg-query.1.html in the --showformat section. +type DpkgMetadata struct { + Package string `mapstructure:"Package" json:"package"` + Source string `mapstructure:"Source" json:"source"` + Version string `mapstructure:"Version" json:"version"` + Architecture string `mapstructure:"Architecture" json:"architecture"` + // TODO: consider keeping the remaining values as an embedded map +} + +func (m DpkgMetadata) PackageURL(d distro.Distro) string { + pURL := packageurl.NewPackageURL( + // TODO: replace with `packageurl.TypeDebian` upon merge of https://github.com/package-url/packageurl-go/pull/21 + "deb", + d.Type.String(), + m.Package, + m.Version, + packageurl.Qualifiers{ + { + Key: "arch", + Value: m.Architecture, + }, + }, + "") + return pURL.ToString() +} diff --git a/syft/pkg/dpkg_metadata_test.go b/syft/pkg/dpkg_metadata_test.go new file mode 100644 index 000000000..ebbcf9a9b --- /dev/null +++ b/syft/pkg/dpkg_metadata_test.go @@ -0,0 +1,51 @@ +package pkg + +import ( + "github.com/anchore/syft/syft/distro" + "github.com/sergi/go-diff/diffmatchpatch" + "testing" +) + +func TestDpkgMetadata_pURL(t *testing.T) { + tests := []struct { + distro distro.Distro + metadata DpkgMetadata + expected string + }{ + { + distro: distro.Distro{ + Type: distro.Debian, + }, + metadata: DpkgMetadata{ + Package: "p", + Source: "s", + Version: "v", + Architecture: "a", + }, + expected: "pkg:deb/debian/p@v?arch=a", + }, + { + distro: distro.Distro{ + Type: distro.Ubuntu, + }, + metadata: DpkgMetadata{ + Package: "p", + Source: "s", + Version: "v", + Architecture: "a", + }, + expected: "pkg:deb/ubuntu/p@v?arch=a", + }, + } + + for _, test := range tests { + t.Run(test.expected, func(t *testing.T) { + actual := test.metadata.PackageURL(test.distro) + if actual != test.expected { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(test.expected, actual, true) + t.Errorf("diff: %s", dmp.DiffPrettyText(diffs)) + } + }) + } +} diff --git a/syft/pkg/java_metadata.go b/syft/pkg/java_metadata.go new file mode 100644 index 000000000..16947002d --- /dev/null +++ b/syft/pkg/java_metadata.go @@ -0,0 +1,50 @@ +package pkg + +import "github.com/package-url/packageurl-go" + +// JavaMetadata encapsulates all Java ecosystem metadata for a package as well as an (optional) parent relationship. +type JavaMetadata struct { + Manifest *JavaManifest `mapstructure:"Manifest" json:"manifest"` + PomProperties *PomProperties `mapstructure:"PomProperties" json:"pom-properties"` + Parent *Package `json:"parent-package"` // TODO: should this be included in the json output? +} + +// PomProperties represents the fields of interest extracted from a Java archive's pom.xml file. +type PomProperties struct { + Path string + Name string `mapstructure:"name" json:"name"` + GroupID string `mapstructure:"groupId" json:"group-id"` + ArtifactID string `mapstructure:"artifactId" json:"artifact-id"` + Version string `mapstructure:"version" json:"version"` + Extra map[string]string `mapstructure:",remain" json:"extra-fields"` +} + +// JavaManifest represents the fields of interest extracted from a Java archive's META-INF/MANIFEST.MF file. +type JavaManifest struct { + Name string `mapstructure:"Name" json:"name"` + ManifestVersion string `mapstructure:"Manifest-Version" json:"manifest-version"` + SpecTitle string `mapstructure:"Specification-Title" json:"specification-title"` + SpecVersion string `mapstructure:"Specification-Version" json:"specification-version"` + SpecVendor string `mapstructure:"Specification-Vendor" json:"specification-vendor"` + ImplTitle string `mapstructure:"Implementation-Title" json:"implementation-title"` + ImplVersion string `mapstructure:"Implementation-Version" json:"implementation-version"` + ImplVendor string `mapstructure:"Implementation-Vendor" json:"implementation-vendor"` + Extra map[string]string `mapstructure:",remain" json:"extra-fields"` +} + +func (m JavaMetadata) PackageURL() string { + if m.PomProperties != nil { + pURL := packageurl.NewPackageURL( + packageurl.TypeMaven, + m.PomProperties.GroupID, + m.PomProperties.ArtifactID, + m.PomProperties.Version, + nil, // TODO: there are probably several qualifiers that can be specified here + "") + return pURL.ToString() + } + + // TODO: support non-maven artifacts + + return "" +} diff --git a/syft/pkg/java_metadata_test.go b/syft/pkg/java_metadata_test.go new file mode 100644 index 000000000..446d30820 --- /dev/null +++ b/syft/pkg/java_metadata_test.go @@ -0,0 +1,41 @@ +package pkg + +import ( + "github.com/sergi/go-diff/diffmatchpatch" + "testing" +) + +func TestJavaMetadata_pURL(t *testing.T) { + tests := []struct { + metadata JavaMetadata + expected string + }{ + { + metadata: JavaMetadata{ + PomProperties: &PomProperties{ + Path: "p", + Name: "n", + GroupID: "g.id", + ArtifactID: "a", + Version: "v", + }, + }, + expected: "pkg:maven/g.id/a@v", + }, + { + metadata: JavaMetadata{}, + expected: "", + }, + } + + for _, test := range tests { + t.Run(test.expected, func(t *testing.T) { + actual := test.metadata.PackageURL() + if actual != test.expected { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(test.expected, actual, true) + t.Errorf("diff: %s", dmp.DiffPrettyText(diffs)) + } + }) + } +} diff --git a/syft/pkg/metadata.go b/syft/pkg/metadata.go deleted file mode 100644 index 283a66c95..000000000 --- a/syft/pkg/metadata.go +++ /dev/null @@ -1,79 +0,0 @@ -package pkg - -// DpkgMetadata represents all captured data for a Debian package DB entry; available fields are described -// at http://manpages.ubuntu.com/manpages/xenial/man1/dpkg-query.1.html in the --showformat section. -type DpkgMetadata struct { - Package string `mapstructure:"Package" json:"package"` - Source string `mapstructure:"Source" json:"source"` - Version string `mapstructure:"Version" json:"version"` - // TODO: consider keeping the remaining values as an embedded map -} - -// RpmMetadata represents all captured data for a RPM DB package entry. -type RpmMetadata struct { - Version string `mapstructure:"Version" json:"version"` - Epoch int `mapstructure:"Epoch" json:"epoch"` - Arch string `mapstructure:"Arch" json:"architecture"` - Release string `mapstructure:"Release" json:"release"` - SourceRpm string `mapstructure:"SourceRpm" json:"source-rpm"` - Size int `mapstructure:"Size" json:"size"` - License string `mapstructure:"License" json:"license"` - Vendor string `mapstructure:"Vendor" json:"vendor"` -} - -// JavaManifest represents the fields of interest extracted from a Java archive's META-INF/MANIFEST.MF file. -type JavaManifest struct { - Name string `mapstructure:"Name" json:"name"` - ManifestVersion string `mapstructure:"Manifest-Version" json:"manifest-version"` - SpecTitle string `mapstructure:"Specification-Title" json:"specification-title"` - SpecVersion string `mapstructure:"Specification-Version" json:"specification-version"` - SpecVendor string `mapstructure:"Specification-Vendor" json:"specification-vendor"` - ImplTitle string `mapstructure:"Implementation-Title" json:"implementation-title"` - ImplVersion string `mapstructure:"Implementation-Version" json:"implementation-version"` - ImplVendor string `mapstructure:"Implementation-Vendor" json:"implementation-vendor"` - Extra map[string]string `mapstructure:",remain" json:"extra-fields"` -} - -// PomProperties represents the fields of interest extracted from a Java archive's pom.xml file. -type PomProperties struct { - Path string - Name string `mapstructure:"name" json:"name"` - GroupID string `mapstructure:"groupId" json:"group-id"` - ArtifactID string `mapstructure:"artifactId" json:"artifact-id"` - Version string `mapstructure:"version" json:"version"` - Extra map[string]string `mapstructure:",remain" json:"extra-fields"` -} - -// JavaMetadata encapsulates all Java ecosystem metadata for a package as well as an (optional) parent relationship. -type JavaMetadata struct { - Manifest *JavaManifest `mapstructure:"Manifest" json:"manifest"` - PomProperties *PomProperties `mapstructure:"PomProperties" json:"pom-properties"` - Parent *Package `json:"parent-package"` -} - -// ApkMetadata represents all captured data for a Alpine DB package entry. See https://wiki.alpinelinux.org/wiki/Apk_spec for more information. -type ApkMetadata struct { - Package string `mapstructure:"P" json:"package"` - OriginPackage string `mapstructure:"o" json:"origin-package"` - Maintainer string `mapstructure:"m" json:"maintainer"` - Version string `mapstructure:"V" json:"version"` - License string `mapstructure:"L" json:"license"` - Architecture string `mapstructure:"A" json:"architecture"` - URL string `mapstructure:"U" json:"url"` - Description string `mapstructure:"T" json:"description"` - Size int `mapstructure:"S" json:"size"` - InstalledSize int `mapstructure:"I" json:"installed-size"` - PullDependencies string `mapstructure:"D" json:"pull-dependencies"` - PullChecksum string `mapstructure:"C" json:"pull-checksum"` - GitCommitOfAport string `mapstructure:"c" json:"git-commit-of-apk-port"` - Files []ApkFileRecord `json:"files"` -} - -// ApkFileRecord represents a single file listing and metadata from a APK DB entry (which may have many of these file records). -type ApkFileRecord struct { - Path string `json:"path"` - OwnerUID string `json:"owner-uid"` - OwnerGUI string `json:"owner-gid"` - Permissions string `json:"permissions"` - Checksum string `json:"checksum"` -} diff --git a/syft/pkg/package.go b/syft/pkg/package.go index ea16fd6ec..8a7bb0b13 100644 --- a/syft/pkg/package.go +++ b/syft/pkg/package.go @@ -5,8 +5,12 @@ package pkg import ( "fmt" + "regexp" + "strings" "github.com/anchore/stereoscope/pkg/file" + "github.com/anchore/syft/syft/distro" + "github.com/package-url/packageurl-go" ) type ID int64 @@ -34,3 +38,42 @@ func (p Package) ID() ID { func (p Package) String() string { return fmt.Sprintf("Pkg(type=%s, name=%s, version=%s)", p.Type, p.Name, p.Version) } + +// PackageURL returns a package-URL representation of the given package (see https://github.com/package-url/purl-spec) +func (p Package) PackageURL(d distro.Distro) string { + // default to pURLs on the metadata + if p.Metadata != nil { + if i, ok := p.Metadata.(interface{ PackageURL() string }); ok { + return i.PackageURL() + } else if i, ok := p.Metadata.(interface{ PackageURL(distro.Distro) string }); ok { + return i.PackageURL(d) + } + } + + var purlType = p.Type.PackageURLType() + var name = p.Name + var namespace = "" + + switch { + case purlType == "": + // there is no purl type, don't attempt to craft a purl + // TODO: should this be a "generic" purl type instead? + return "" + case p.Type == GoModulePkg: + re := regexp.MustCompile(`(\/)[^\/]*$`) + fields := re.Split(p.Name, -1) + namespace = fields[0] + name = strings.TrimPrefix(p.Name, namespace+"/") + } + + // generate a purl from the package data + pURL := packageurl.NewPackageURL( + purlType, + namespace, + name, + p.Version, + nil, + "") + + return pURL.ToString() +} diff --git a/syft/pkg/package_test.go b/syft/pkg/package_test.go new file mode 100644 index 000000000..89a712eee --- /dev/null +++ b/syft/pkg/package_test.go @@ -0,0 +1,136 @@ +package pkg + +import ( + "github.com/anchore/syft/syft/distro" + "github.com/sergi/go-diff/diffmatchpatch" + "testing" +) + +func TestPackage_pURL(t *testing.T) { + tests := []struct { + pkg Package + distro distro.Distro + expected string + }{ + { + pkg: Package{ + Name: "github.com/anchore/syft", + Version: "v0.1.0", + Type: GoModulePkg, + }, + expected: "pkg:golang/github.com/anchore/syft@v0.1.0", + }, + { + pkg: Package{ + Name: "name", + Version: "v0.1.0", + Type: WheelPkg, + }, + expected: "pkg:pypi/name@v0.1.0", + }, + { + pkg: Package{ + Name: "name", + Version: "v0.1.0", + Type: EggPkg, + }, + expected: "pkg:pypi/name@v0.1.0", + }, + { + pkg: Package{ + Name: "name", + Version: "v0.1.0", + Type: PythonSetupPkg, + }, + expected: "pkg:pypi/name@v0.1.0", + }, + { + pkg: Package{ + Name: "name", + Version: "v0.1.0", + Type: PythonRequirementsPkg, + }, + expected: "pkg:pypi/name@v0.1.0", + }, + { + pkg: Package{ + Name: "name", + Version: "v0.1.0", + Type: BundlerPkg, + }, + expected: "pkg:gem/name@v0.1.0", + }, + { + pkg: Package{ + Name: "name", + Version: "v0.1.0", + Type: NpmPkg, + }, + expected: "pkg:npm/name@v0.1.0", + }, + { + pkg: Package{ + Name: "name", + Version: "v0.1.0", + Type: YarnPkg, + }, + expected: "pkg:npm/name@v0.1.0", + }, + { + distro: distro.Distro{ + Type: distro.Ubuntu, + }, + pkg: Package{ + Name: "bad-name", + Version: "bad-v0.1.0", + Type: DebPkg, + Metadata: DpkgMetadata{ + Package: "name", + Version: "v0.1.0", + Architecture: "amd64", + }, + }, + expected: "pkg:deb/ubuntu/name@v0.1.0?arch=amd64", + }, + { + distro: distro.Distro{ + Type: distro.CentOS, + }, + pkg: Package{ + Name: "bad-name", + Version: "bad-v0.1.0", + Type: RpmPkg, + Metadata: RpmMetadata{ + Name: "name", + Version: "v0.1.0", + Epoch: 2, + Arch: "amd64", + Release: "3", + }, + }, + expected: "pkg:rpm/centos/name@2:v0.1.0-3?arch=amd64", + }, + { + distro: distro.Distro{ + Type: distro.UnknownDistroType, + }, + pkg: Package{ + Name: "name", + Version: "v0.1.0", + Type: DebPkg, + }, + expected: "pkg:deb/name@v0.1.0", + }, + } + + for _, test := range tests { + t.Run(string(test.pkg.Type)+"|"+test.expected, func(t *testing.T) { + actual := test.pkg.PackageURL(test.distro) + if actual != test.expected { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(test.expected, actual, true) + t.Errorf("diff: %s", dmp.DiffPrettyText(diffs)) + } + }) + } +} diff --git a/syft/pkg/rpm_metadata.go b/syft/pkg/rpm_metadata.go new file mode 100644 index 000000000..63af04198 --- /dev/null +++ b/syft/pkg/rpm_metadata.go @@ -0,0 +1,37 @@ +package pkg + +import ( + "fmt" + + "github.com/anchore/syft/syft/distro" + "github.com/package-url/packageurl-go" +) + +// RpmMetadata represents all captured data for a RPM DB package entry. +type RpmMetadata struct { + Name string `json:"name"` + Version string `json:"version"` + Epoch int `json:"epoch"` + Arch string `json:"architecture"` + Release string `json:"release"` + SourceRpm string `json:"source-rpm"` + Size int `json:"size"` + License string `json:"license"` + Vendor string `json:"vendor"` +} + +func (m RpmMetadata) PackageURL(d distro.Distro) string { + pURL := packageurl.NewPackageURL( + packageurl.TypeRPM, + d.Type.String(), + m.Name, + fmt.Sprintf("%d:%s-%s", m.Epoch, m.Version, m.Release), + packageurl.Qualifiers{ + { + Key: "arch", + Value: m.Arch, + }, + }, + "") + return pURL.ToString() +} diff --git a/syft/pkg/rpm_metadata_test.go b/syft/pkg/rpm_metadata_test.go new file mode 100644 index 000000000..8b1c99d81 --- /dev/null +++ b/syft/pkg/rpm_metadata_test.go @@ -0,0 +1,53 @@ +package pkg + +import ( + "github.com/anchore/syft/syft/distro" + "github.com/sergi/go-diff/diffmatchpatch" + "testing" +) + +func TestRpmMetadata_pURL(t *testing.T) { + tests := []struct { + distro distro.Distro + metadata RpmMetadata + expected string + }{ + { + distro: distro.Distro{ + Type: distro.CentOS, + }, + metadata: RpmMetadata{ + Name: "p", + Version: "v", + Arch: "a", + Release: "r", + Epoch: 1, + }, + expected: "pkg:rpm/centos/p@1:v-r?arch=a", + }, + { + distro: distro.Distro{ + Type: distro.RedHat, + }, + metadata: RpmMetadata{ + Name: "p", + Version: "v", + Arch: "a", + Release: "r", + Epoch: 1, + }, + expected: "pkg:rpm/redhat/p@1:v-r?arch=a", + }, + } + + for _, test := range tests { + t.Run(test.expected, func(t *testing.T) { + actual := test.metadata.PackageURL(test.distro) + if actual != test.expected { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(test.expected, actual, true) + t.Errorf("diff: %s", dmp.DiffPrettyText(diffs)) + } + }) + } +} diff --git a/syft/pkg/type.go b/syft/pkg/type.go index 3c43d0613..08a126c8f 100644 --- a/syft/pkg/type.go +++ b/syft/pkg/type.go @@ -1,5 +1,7 @@ package pkg +import "github.com/package-url/packageurl-go" + // Type represents a Package Type for or within a language ecosystem (there may be multiple package types within a language ecosystem) type Type string @@ -38,3 +40,27 @@ var AllPkgs = []Type{ JenkinsPluginPkg, GoModulePkg, } + +func (t Type) PackageURLType() string { + switch t { + case ApkPkg: + return "alpine" + case BundlerPkg: + return packageurl.TypeGem + case DebPkg: + return "deb" + case EggPkg, WheelPkg, PythonRequirementsPkg, PythonSetupPkg: + return packageurl.TypePyPi + case NpmPkg, YarnPkg: + return packageurl.TypeNPM + case JavaPkg, JenkinsPluginPkg: + return packageurl.TypeMaven + case RpmPkg: + return packageurl.TypeRPM + case GoModulePkg: + return packageurl.TypeGolang + default: + // TODO: should this be a "generic" purl type instead? + return "" + } +} diff --git a/syft/presenter/cyclonedx/component.go b/syft/presenter/cyclonedx/component.go index 0683a6d80..2217bb5d1 100644 --- a/syft/presenter/cyclonedx/component.go +++ b/syft/presenter/cyclonedx/component.go @@ -14,6 +14,7 @@ type Component struct { Version string `xml:"version"` // Required; The version of the component as defined by the project Description string `xml:"description,omitempty"` // A description of the component Licenses *[]License `xml:"licenses>license"` // A node describing zero or more license names, SPDX license IDs or expressions + PackageURL string `xml:"purl,omitempty"` // Specifies the package-url (PackageURL). The purl, if specified, must be valid and conform to the specification defined at: https://github.com/package-url/purl-spec // TODO: scope, hashes, copyright, cpe, purl, swid, modified, pedigree, externalReferences // TODO: add user-defined parameters for syft-specific values (image layer index, cataloger, location path, etc.) } diff --git a/syft/presenter/cyclonedx/document.go b/syft/presenter/cyclonedx/document.go index cc3f68161..4539ab73e 100644 --- a/syft/presenter/cyclonedx/document.go +++ b/syft/presenter/cyclonedx/document.go @@ -3,6 +3,8 @@ package cyclonedx import ( "encoding/xml" + "github.com/anchore/syft/syft/distro" + "github.com/anchore/syft/syft/pkg" "github.com/google/uuid" ) @@ -31,13 +33,14 @@ func NewDocument() Document { } // NewDocumentFromCatalog returns a CycloneDX Document object populated with the catalog contents. -func NewDocumentFromCatalog(catalog *pkg.Catalog) Document { +func NewDocumentFromCatalog(catalog *pkg.Catalog, d distro.Distro) Document { bom := NewDocument() for p := range catalog.Enumerate() { component := Component{ - Type: "library", // TODO: this is not accurate - Name: p.Name, - Version: p.Version, + Type: "library", // TODO: this is not accurate + Name: p.Name, + Version: p.Version, + PackageURL: p.PackageURL(d), } var licenses []License for _, licenseName := range p.Licenses { diff --git a/syft/presenter/cyclonedx/presenter.go b/syft/presenter/cyclonedx/presenter.go index 3b935387a..27332ae36 100644 --- a/syft/presenter/cyclonedx/presenter.go +++ b/syft/presenter/cyclonedx/presenter.go @@ -8,6 +8,8 @@ import ( "fmt" "io" + "github.com/anchore/syft/syft/distro" + "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/scope" ) @@ -16,19 +18,21 @@ import ( type Presenter struct { catalog *pkg.Catalog scope scope.Scope + distro distro.Distro } // NewPresenter creates a CycloneDX presenter from the given Catalog and Scope objects. -func NewPresenter(catalog *pkg.Catalog, s scope.Scope) *Presenter { +func NewPresenter(catalog *pkg.Catalog, s scope.Scope, d distro.Distro) *Presenter { return &Presenter{ catalog: catalog, scope: s, + distro: d, } } // Present writes the CycloneDX report to the given io.Writer. func (pres *Presenter) Present(output io.Writer) error { - bom := NewDocumentFromCatalog(pres.catalog) + bom := NewDocumentFromCatalog(pres.catalog, pres.distro) srcObj := pres.scope.Source() diff --git a/syft/presenter/cyclonedx/presenter_test.go b/syft/presenter/cyclonedx/presenter_test.go index 194e66eb9..0ec73ef17 100644 --- a/syft/presenter/cyclonedx/presenter_test.go +++ b/syft/presenter/cyclonedx/presenter_test.go @@ -3,6 +3,7 @@ package cyclonedx import ( "bytes" "flag" + "github.com/anchore/syft/syft/distro" "regexp" "testing" @@ -22,16 +23,21 @@ func TestCycloneDxDirsPresenter(t *testing.T) { // populate catalog with test data catalog.Add(pkg.Package{ - Name: "package-1", + Name: "package1", Version: "1.0.1", Type: pkg.DebPkg, FoundBy: "the-cataloger-1", Source: []file.Reference{ {Path: "/some/path/pkg1"}, }, + Metadata: pkg.DpkgMetadata{ + Package: "package1", + Version: "1.0.1", + Architecture: "amd64", + }, }) catalog.Add(pkg.Package{ - Name: "package-2", + Name: "package2", Version: "2.0.1", Type: pkg.DebPkg, FoundBy: "the-cataloger-2", @@ -42,13 +48,24 @@ func TestCycloneDxDirsPresenter(t *testing.T) { "MIT", "Apache-v2", }, + Metadata: pkg.DpkgMetadata{ + Package: "package2", + Version: "1.0.2", + Architecture: "amd64", + }, }) s, err := scope.NewScopeFromDir("/some/path") if err != nil { t.Fatal(err) } - pres := NewPresenter(catalog, s) + + d, err := distro.NewDistro(distro.Ubuntu, "20.04") + if err != nil { + t.Fatal(err) + } + + pres := NewPresenter(catalog, s, d) // run presenter err = pres.Present(&buffer) @@ -84,30 +101,61 @@ func TestCycloneDxImgsPresenter(t *testing.T) { // populate catalog with test data catalog.Add(pkg.Package{ - Name: "package-1", + Name: "package1", Version: "1.0.1", Source: []file.Reference{ *img.SquashedTree().File("/somefile-1.txt"), }, - Type: pkg.DebPkg, + Type: pkg.RpmPkg, FoundBy: "the-cataloger-1", + Metadata: pkg.RpmMetadata{ + Name: "package1", + Epoch: 0, + Arch: "x86_64", + Release: "1", + Version: "1.0.1", + SourceRpm: "package1-1.0.1-1.src.rpm", + Size: 12406784, + License: "MIT", + Vendor: "", + }, }) catalog.Add(pkg.Package{ - Name: "package-2", + Name: "package2", Version: "2.0.1", Source: []file.Reference{ *img.SquashedTree().File("/somefile-2.txt"), }, - Type: pkg.DebPkg, + Type: pkg.RpmPkg, FoundBy: "the-cataloger-2", Licenses: []string{ "MIT", "Apache-v2", }, + Metadata: pkg.RpmMetadata{ + Name: "package2", + Epoch: 0, + Arch: "x86_64", + Release: "1", + Version: "1.0.2", + SourceRpm: "package2-1.0.2-1.src.rpm", + Size: 12406784, + License: "MIT", + Vendor: "", + }, }) s, err := scope.NewScopeFromImage(img, scope.AllLayersScope) - pres := NewPresenter(catalog, s) + if err != nil { + t.Fatal(err) + } + + d, err := distro.NewDistro(distro.RedHat, "8") + if err != nil { + t.Fatal(err) + } + + pres := NewPresenter(catalog, s, d) // run presenter err = pres.Present(&buffer) diff --git a/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxDirsPresenter.golden b/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxDirsPresenter.golden index 70937744b..f38d078ac 100644 --- a/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxDirsPresenter.golden +++ b/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxDirsPresenter.golden @@ -1,12 +1,13 @@ - + - package-1 + package1 1.0.1 + pkg:deb/ubuntu/package1@1.0.1?arch=amd64 - package-2 + package2 2.0.1 @@ -16,10 +17,11 @@ Apache-v2 + pkg:deb/ubuntu/package2@1.0.2?arch=amd64 - 2020-08-26T15:31:39-04:00 + 2020-08-29T20:17:49-04:00 anchore syft diff --git a/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxImgsPresenter.golden b/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxImgsPresenter.golden index df69a93b7..e04a46593 100644 --- a/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxImgsPresenter.golden +++ b/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxImgsPresenter.golden @@ -1,12 +1,13 @@ - + - package-1 + package1 1.0.1 + pkg:rpm/redhat/package1@0:1.0.1-1?arch=x86_64 - package-2 + package2 2.0.1 @@ -16,10 +17,11 @@ Apache-v2 + pkg:rpm/redhat/package2@0:1.0.2-1?arch=x86_64 - 2020-08-26T15:31:39-04:00 + 2020-08-29T20:17:49-04:00 anchore syft diff --git a/syft/presenter/presenter.go b/syft/presenter/presenter.go index 1807cd351..49bd1e25b 100644 --- a/syft/presenter/presenter.go +++ b/syft/presenter/presenter.go @@ -7,6 +7,8 @@ package presenter import ( "io" + "github.com/anchore/syft/syft/distro" + "github.com/anchore/syft/syft/presenter/cyclonedx" "github.com/anchore/syft/syft/pkg" @@ -23,7 +25,7 @@ type Presenter interface { } // GetPresenter returns a presenter for images or directories -func GetPresenter(option Option, s scope.Scope, catalog *pkg.Catalog) Presenter { +func GetPresenter(option Option, s scope.Scope, catalog *pkg.Catalog, d *distro.Distro) Presenter { switch option { case JSONPresenter: return json.NewPresenter(catalog, s) @@ -32,7 +34,7 @@ func GetPresenter(option Option, s scope.Scope, catalog *pkg.Catalog) Presenter case TablePresenter: return table.NewPresenter(catalog, s) case CycloneDxPresenter: - return cyclonedx.NewPresenter(catalog, s) + return cyclonedx.NewPresenter(catalog, s, *d) default: return nil } diff --git a/test/integration/json_schema_test.go b/test/integration/json_schema_test.go index 187b2941c..3b8119024 100644 --- a/test/integration/json_schema_test.go +++ b/test/integration/json_schema_test.go @@ -14,6 +14,7 @@ import ( "github.com/anchore/go-testutils" "github.com/anchore/syft/syft" + "github.com/anchore/syft/syft/distro" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/presenter" "github.com/anchore/syft/syft/scope" @@ -63,12 +64,17 @@ func testJsonSchema(t *testing.T, catalog *pkg.Catalog, theScope *scope.Scope, p output := bytes.NewBufferString("") - p := presenter.GetPresenter(presenter.JSONPresenter, *theScope, catalog) + d, err := distro.NewDistro(distro.CentOS, "5") + if err != nil { + t.Fatalf("bad distro: %+v", err) + } + + p := presenter.GetPresenter(presenter.JSONPresenter, *theScope, catalog, &d) if p == nil { t.Fatal("unable to get presenter") } - err := p.Present(output) + err = p.Present(output) if err != nil { t.Fatalf("unable to present: %+v", err) }