From 2b9b558b499f1325206ba56fb143193a7226a282 Mon Sep 17 00:00:00 2001 From: Daniel Mikusa Date: Thu, 18 Nov 2021 21:14:50 -0500 Subject: [PATCH] Adds support for DependencyLayerContributor and HelperLayerContributor to generate SBOMs - DependencyLayerContributor will write an SBOM with the dependency's metadata - HelperLayerContributor will write an SBOM entry with the helper's metadata - Both are written to the layer's SBOM file - The DependencyLayerContributor support adds two new metadata fields to the BuildpackDependency object: `purl` and `cpes`. - purl is a string for the package URL, based on https://github.com/package-url/purl-spec - cpes is a list of strings for multiple CPE, or Common Platform Enumeration, identifiers - the values for these two fields are loaded from dependencies entries in buildpack.toml - if not specified, they default to the Go empty values - a single artifact entry is added for each dependency - The HelpLayerContributor support generates a SBOM file with the following information. It is automatic and no additional metadata is required. - Name is `helper` - Version is the buildpack version - Licenses contains the buildpack's license - Locations contains a list of the helper names that are setup for this helper - CPEs is a list of `cpe:2.3:a:::@` - ID hashes for all artifacts are calculated using `github.com/mitchellh/hashstructure/v2` and are a hash of the SyftArtifact object before setting the ID. - NewDependencyLayerContributor will return an empty BOMEntry if the dependency in question has an empty PURL and an empty CPEs list. This prevents old style and new style BOM entries from both being written, which causes an error under buildpacks API 0.7 and platform API 0.8 but allows for backwards compatibility. - NewHelperLayerContributor will also return an empty BOMEntry but it is based on the API version for the buildpack that contributes the helpers. If that is 0.7, it will be empty. If it's 0.6 or less, it'll return an entry as before. This prevents old style and new style BOM entries from both being written, which causes an error under buildpacks API 0.7 and platform API 0.8 but allows for backwards compatibility. - Old style BOM entries are deprecated and will be removed in a future version of libpak. This functionality requires buildpack API 0.7, for older buildpack versions you may call these methods and they will function properly, however, the lifecycle will not persist any of the information generated. Other incidental changes in this commit: - Fixes a bug with `syft packages`. There was a typo in the command and th `-q` argument was missing. - Moves SBOM functionality from the `sherpa` package into its own package `sbom`. - Bumps to libcnb 1.25.0, which is required for the buildpack API 0.7 support. Signed-off-by: Daniel Mikusa --- buildpack.go | 48 +++++++++++- buildpack_test.go | 36 +++++++++ dependency_cache_test.go | 2 + go.mod | 3 +- go.sum | 6 +- layer.go | 96 ++++++++++++++++++++---- layer_test.go | 137 ++++++++++++++++++++++++++++------ sbom/init_test.go | 30 ++++++++ {sherpa => sbom}/sbom.go | 90 +++++++++++++++++++++- {sherpa => sbom}/sbom_test.go | 122 +++++++++++++++++++++++++----- sherpa/init_test.go | 1 - 11 files changed, 509 insertions(+), 62 deletions(-) create mode 100644 sbom/init_test.go rename {sherpa => sbom}/sbom.go (75%) rename {sherpa => sbom}/sbom_test.go (63%) diff --git a/buildpack.go b/buildpack.go index 2814644..2a99a48 100644 --- a/buildpack.go +++ b/buildpack.go @@ -28,6 +28,7 @@ import ( "github.com/heroku/color" "github.com/paketo-buildpacks/libpak/bard" + "github.com/paketo-buildpacks/libpak/sbom" ) // BuildpackConfiguration represents a build or launch configuration parameter. @@ -80,13 +81,19 @@ type BuildpackDependency struct { // Stacks are the stacks the dependency is compatible with. Stacks []string `toml:"stacks"` - // Licenses are the stacks the dependency is distributed under. + // Licenses are the licenses the dependency is distributed under. Licenses []BuildpackDependencyLicense `toml:"licenses"` + + // CPEs are the Common Platform Enumeration identifiers for the dependency + CPEs []string `toml:"cpes"` + + // PURL is the package URL that identifies the dependency + PURL string `toml:"purl"` } // AsBOMEntry renders a bill of materials entry describing the dependency. // -// Deprecated: as of Buildpacks RFC 95, use `sherpa.SBOMScanner` instead +// Deprecated: as of Buildpacks RFC 95, use `BuildpackDependency.AsSyftArtifact` instead func (b BuildpackDependency) AsBOMEntry() libcnb.BOMEntry { return libcnb.BOMEntry{ Name: b.ID, @@ -101,6 +108,33 @@ func (b BuildpackDependency) AsBOMEntry() libcnb.BOMEntry { } } +// AsSyftArtifact renders a bill of materials entry describing the dependency as Syft. +func (b BuildpackDependency) AsSyftArtifact() (sbom.SyftArtifact, error) { + licenses := []string{} + for _, license := range b.Licenses { + licenses = append(licenses, license.Type) + } + + sbomArtifact := sbom.SyftArtifact{ + Name: b.Name, + Version: b.Version, + Type: "UnknownPackage", + FoundBy: "libpak", + Licenses: licenses, + Locations: []sbom.SyftLocation{{Path: "buildpack.toml"}}, + CPEs: b.CPEs, + PURL: b.PURL, + } + + var err error + sbomArtifact.ID, err = sbomArtifact.Hash() + if err != nil { + return sbom.SyftArtifact{}, fmt.Errorf("unable to generate hash\n%w", err) + } + + return sbomArtifact, nil +} + // BuildpackMetadata is an extension to libcnb.Buildpack's metadata with opinions. type BuildpackMetadata struct { @@ -196,6 +230,16 @@ func NewBuildpackMetadata(metadata map[string]interface{}) (BuildpackMetadata, e } } + if v, ok := v["cpes"].([]interface{}); ok { + for _, v := range v { + d.CPEs = append(d.CPEs, v.(string)) + } + } + + if v, ok := v["purl"].(string); ok { + d.PURL = v + } + m.Dependencies = append(m.Dependencies, d) } } diff --git a/buildpack_test.go b/buildpack_test.go index 3160f85..739a38e 100644 --- a/buildpack_test.go +++ b/buildpack_test.go @@ -26,6 +26,7 @@ import ( "github.com/sclevine/spec" "github.com/paketo-buildpacks/libpak" + "github.com/paketo-buildpacks/libpak/sbom" ) func testBuildpack(t *testing.T, context spec.G, it spec.S) { @@ -62,6 +63,37 @@ func testBuildpack(t *testing.T, context spec.G, it spec.S) { })) }) + it("renders dependency as a SyftArtifact", func() { + dependency := libpak.BuildpackDependency{ + ID: "test-id", + Name: "test-name", + Version: "1.1.1", + URI: "test-uri", + SHA256: "test-sha256", + Stacks: []string{"test-stack"}, + Licenses: []libpak.BuildpackDependencyLicense{ + { + Type: "test-type", + URI: "test-uri", + }, + }, + CPEs: []string{"test-cpe1", "test-cpe2"}, + PURL: "test-purl", + } + + Expect(dependency.AsSyftArtifact()).To(Equal(sbom.SyftArtifact{ + ID: "46713835f08d90b7", + Name: "test-name", + Version: "1.1.1", + Type: "UnknownPackage", + FoundBy: "libpak", + Licenses: []string{"test-type"}, + Locations: []sbom.SyftLocation{{Path: "buildpack.toml"}}, + CPEs: []string{"test-cpe1", "test-cpe2"}, + PURL: "test-purl", + })) + }) + context("NewBuildpackMetadata", func() { it("deserializes metadata", func() { actual := map[string]interface{}{ @@ -86,6 +118,8 @@ func testBuildpack(t *testing.T, context spec.G, it spec.S) { "uri": "test-uri", }, }, + "cpes": []interface{}{"cpe:2.3:a:test-id:1.1.1"}, + "purl": "pkg:generic:test-id@1.1.1", }, }, "include-files": []interface{}{"test-include-file"}, @@ -114,6 +148,8 @@ func testBuildpack(t *testing.T, context spec.G, it spec.S) { URI: "test-uri", }, }, + CPEs: []string{"cpe:2.3:a:test-id:1.1.1"}, + PURL: "pkg:generic:test-id@1.1.1", }, }, IncludeFiles: []string{"test-include-file"}, diff --git a/dependency_cache_test.go b/dependency_cache_test.go index 1448a9c..7a7613b 100644 --- a/dependency_cache_test.go +++ b/dependency_cache_test.go @@ -153,6 +153,8 @@ func testDependencyCache(t *testing.T, context spec.G, it spec.S) { URI: "test-uri", }, }, + CPEs: []string{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + PURL: "pkg:generic/some-java11@11.0.2?arch=amd64", } dependencyCache = libpak.DependencyCache{ diff --git a/go.mod b/go.mod index 0856213..bd971fc 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,11 @@ go 1.15 require ( github.com/CycloneDX/cyclonedx-go v0.4.0 github.com/Masterminds/semver/v3 v3.1.1 - github.com/buildpacks/libcnb v1.24.1-0.20211118031525-6aa81e50810d + github.com/buildpacks/libcnb v1.25.0 github.com/creack/pty v1.1.17 github.com/heroku/color v0.0.6 github.com/imdario/mergo v0.3.12 + github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/onsi/gomega v1.17.0 github.com/pelletier/go-toml v1.9.4 github.com/sclevine/spec v1.4.0 diff --git a/go.sum b/go.sum index 8a33242..6683915 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030I github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/bradleyjkemp/cupaloy/v2 v2.6.0 h1:knToPYa2xtfg42U3I6punFEjaGFKWQRXJwj0JTv4mTs= github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= -github.com/buildpacks/libcnb v1.24.1-0.20211118031525-6aa81e50810d h1:rAJsgF0p6rtUPGSTOCnCt/ofXKQM34wN+XKMXH3bNBE= -github.com/buildpacks/libcnb v1.24.1-0.20211118031525-6aa81e50810d/go.mod h1:XX0+zHW8CNLNwiiwowgydAgWWfyDt8Lj1NcuWtkkBJQ= +github.com/buildpacks/libcnb v1.25.0 h1:f0UWYUbXQ/vTX6SztGn+sP/F6cVSAbBQO4B5/R1LEP8= +github.com/buildpacks/libcnb v1.25.0/go.mod h1:XX0+zHW8CNLNwiiwowgydAgWWfyDt8Lj1NcuWtkkBJQ= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -41,6 +41,8 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= diff --git a/layer.go b/layer.go index 3d8a2a4..89f732f 100644 --- a/layer.go +++ b/layer.go @@ -27,6 +27,7 @@ import ( "github.com/buildpacks/libcnb" + "github.com/paketo-buildpacks/libpak/sbom" "github.com/paketo-buildpacks/libpak/sherpa" "github.com/paketo-buildpacks/libpak/bard" @@ -139,15 +140,18 @@ func NewDependencyLayer(dependency BuildpackDependency, cache DependencyCache, t ExpectedTypes: types, } - entry := dependency.AsBOMEntry() - entry.Metadata["layer"] = c.LayerName() + var entry libcnb.BOMEntry + if dependency.PURL == "" && len(dependency.CPEs) == 0 { + entry = dependency.AsBOMEntry() + entry.Metadata["layer"] = c.LayerName() - if types.Launch { - entry.Launch = true - } - if !(types.Launch && !types.Cache && !types.Build) { - // launch-only layers are the only layers NOT guaranteed to be present in the build environment - entry.Build = true + if types.Launch { + entry.Launch = true + } + if !(types.Launch && !types.Cache && !types.Build) { + // launch-only layers are the only layers NOT guaranteed to be present in the build environment + entry.Build = true + } } return c, entry @@ -168,6 +172,18 @@ func (d *DependencyLayerContributor) Contribute(layer libcnb.Layer, f Dependency } defer artifact.Close() + sbomArtifact, err := d.Dependency.AsSyftArtifact() + if err != nil { + return libcnb.Layer{}, fmt.Errorf("unable to get SBOM artifact %s\n%w", d.Dependency.ID, err) + } + + sbomPath := layer.SBOMPath(libcnb.SyftJSON) + dep := sbom.NewSyftDependency(layer.Path, []sbom.SyftArtifact{sbomArtifact}) + d.Logger.Debugf("Writing Syft SBOM at %s: %+v", sbomPath, dep) + if err := dep.WriteTo(sbomPath); err != nil { + return libcnb.Layer{}, fmt.Errorf("unable to write SBOM\n%w", err) + } + return f(artifact) }) } @@ -210,14 +226,17 @@ func NewHelperLayer(buildpack libcnb.Buildpack, names ...string) (HelperLayerCon BuildpackInfo: buildpack.Info, } - entry := libcnb.BOMEntry{ - Name: "helper", - Metadata: map[string]interface{}{ - "layer": c.Name(), - "names": names, - "version": buildpack.Info.Version, - }, - Launch: true, + var entry libcnb.BOMEntry + if buildpack.API == "0.6" || buildpack.API == "0.5" || buildpack.API == "0.4" || buildpack.API == "0.3" || buildpack.API == "0.2" || buildpack.API == "0.1" { + entry = libcnb.BOMEntry{ + Name: "helper", + Metadata: map[string]interface{}{ + "layer": c.Name(), + "names": names, + "version": buildpack.Info.Version, + }, + Launch: true, + } } return c, entry @@ -261,6 +280,51 @@ func (h HelperLayerContributor) Contribute(layer libcnb.Layer) (libcnb.Layer, er } } + sbomArtifact, err := h.AsSyftArtifact() + if err != nil { + return libcnb.Layer{}, fmt.Errorf("unable to get SBOM artifact for helper\n%w", err) + } + + sbomPath := layer.SBOMPath(libcnb.SyftJSON) + dep := sbom.NewSyftDependency(layer.Path, []sbom.SyftArtifact{sbomArtifact}) + h.Logger.Debugf("Writing Syft SBOM at %s: %+v", sbomPath, dep) + if err := dep.WriteTo(sbomPath); err != nil { + return libcnb.Layer{}, fmt.Errorf("unable to write SBOM\n%w", err) + } + return layer, nil }) } + +func (h HelperLayerContributor) AsSyftArtifact() (sbom.SyftArtifact, error) { + licenses := []string{} + for _, license := range h.BuildpackInfo.Licenses { + licenses = append(licenses, license.Type) + } + + locations := []sbom.SyftLocation{} + cpes := []string{} + for _, name := range h.Names { + locations = append(locations, sbom.SyftLocation{Path: name}) + cpes = append(cpes, fmt.Sprintf("cpe:2.3:a:%s:%s:%s:*:*:*:*:*:*:*", + h.BuildpackInfo.ID, name, h.BuildpackInfo.Version)) + } + + artifact := sbom.SyftArtifact{ + Name: "helper", + Version: h.BuildpackInfo.Version, + Type: "UnknownPackage", + FoundBy: "libpak", + Licenses: licenses, + Locations: locations, + CPEs: cpes, + PURL: fmt.Sprintf("pkg:generic/%s@%s", h.BuildpackInfo.ID, h.BuildpackInfo.Version), + } + var err error + artifact.ID, err = artifact.Hash() + if err != nil { + return sbom.SyftArtifact{}, fmt.Errorf("unable to generate hash\n%w", err) + } + + return artifact, nil +} diff --git a/layer_test.go b/layer_test.go index bc10d77..a00cf6a 100644 --- a/layer_test.go +++ b/layer_test.go @@ -215,6 +215,7 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { }, } }) + it("returns a BOM entry for the layer", func() { _, entry := libpak.NewDependencyLayer(dep, libpak.DependencyCache{}, libcnb.LayerTypes{}) Expect(entry.Name).To(Equal("test-id")) @@ -229,6 +230,7 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { }, })) }) + context("launch layer type", func() { it("only sets launch on the entry", func() { _, entry := libpak.NewDependencyLayer(dep, libpak.DependencyCache{}, libcnb.LayerTypes{ @@ -288,6 +290,23 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { Expect(entry.Build).To(BeTrue()) }) }) + + context("no BOM entry when PURL is set", func() { + it("sets build on the entry", func() { + dep.PURL = "pkg:generic/fake@1.0.0" + _, entry := libpak.NewDependencyLayer(dep, libpak.DependencyCache{}, libcnb.LayerTypes{}) + Expect(entry).To(Equal(libcnb.BOMEntry{})) + }) + + it("sets build on the entry", func() { + dep.CPEs = []string{ + "cpe:1", + "cpe:2", + } + _, entry := libpak.NewDependencyLayer(dep, libpak.DependencyCache{}, libcnb.LayerTypes{}) + Expect(entry).To(Equal(libcnb.BOMEntry{})) + }) + }) }) context("DependencyLayerContributor", func() { @@ -314,6 +333,8 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { URI: "test-uri", }, }, + CPEs: []string{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + PURL: "pkg:generic/some-java11@11.0.2?arch=amd64", } layer.Metadata = map[string]interface{}{} @@ -394,6 +415,8 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { "uri": dependency.Licenses[0].URI, }, }, + "cpes": []interface{}{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + "purl": "pkg:generic/some-java11@11.0.2?arch=amd64", } var called bool @@ -442,6 +465,8 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { "uri": dependency.Licenses[0].URI, }, }, + "cpes": []interface{}{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + "purl": "pkg:generic/some-java11@11.0.2?arch=amd64", })) }) @@ -459,6 +484,8 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { "uri": dependency.Licenses[0].URI, }, }, + "cpes": []interface{}{"cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*"}, + "purl": "pkg:generic/some-java11@11.0.2?arch=amd64", } dlc.ExpectedTypes.Launch = true dlc.ExpectedTypes.Cache = true @@ -480,11 +507,34 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { Expect(layer.LayerTypes.Cache).To(BeTrue()) Expect(layer.LayerTypes.Build).To(BeTrue()) }) + + it("adds expected Syft SBOM file", func() { + server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "test-fixture")) + + layer, err := dlc.Contribute(layer, func(artifact *os.File) (libcnb.Layer, error) { + defer artifact.Close() + return layer, nil + }) + Expect(err).NotTo(HaveOccurred()) + + outputFile := layer.SBOMPath(libcnb.SyftJSON) + Expect(outputFile).To(BeARegularFile()) + + data, err := ioutil.ReadFile(outputFile) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring(`"Artifacts":[`)) + Expect(string(data)).To(ContainSubstring(`"FoundBy":"libpak",`)) + Expect(string(data)).To(ContainSubstring(`"PURL":"pkg:generic/some-java11@11.0.2?arch=amd64"`)) + Expect(string(data)).To(ContainSubstring(`"Schema":{`)) + Expect(string(data)).To(ContainSubstring(`"Descriptor":{`)) + Expect(string(data)).To(ContainSubstring(`"Source":{`)) + }) }) context("NewHelperLayer", func() { it("returns a BOM entry with version equal to buildpack version", func() { _, entry := libpak.NewHelperLayer(libcnb.Buildpack{ + API: "0.6", Info: libcnb.BuildpackInfo{ Version: "test-version", }, @@ -502,6 +552,16 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { }, )) }) + + it("returns empty BOM entry on API 0.7", func() { + _, entry := libpak.NewHelperLayer(libcnb.Buildpack{ + API: "0.7", + Info: libcnb.BuildpackInfo{ + Version: "test-version", + }, + }, "test-name-1", "test-name-2") + Expect(entry).To(Equal(libcnb.BOMEntry{})) + }) }) context("HelperLayerContributor", func() { @@ -565,13 +625,14 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { it("does not call function with matching metadata", func() { layer.Metadata = map[string]interface{}{ - "id": buildpack.Info.ID, - "name": buildpack.Info.Name, - "version": buildpack.Info.Version, - "homepage": buildpack.Info.Homepage, - "clear-env": buildpack.Info.ClearEnvironment, - "description": "", - "keywords": []interface{}{}, + "id": buildpack.Info.ID, + "name": buildpack.Info.Name, + "version": buildpack.Info.Version, + "homepage": buildpack.Info.Homepage, + "clear-env": buildpack.Info.ClearEnvironment, + "description": "", + "sbom-formats": []interface{}{}, + "keywords": []interface{}{}, } _, err := hlc.Contribute(layer) @@ -586,25 +647,27 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { Expect(err).NotTo(HaveOccurred()) Expect(layer.Metadata).To(Equal(map[string]interface{}{ - "id": buildpack.Info.ID, - "name": buildpack.Info.Name, - "version": buildpack.Info.Version, - "homepage": buildpack.Info.Homepage, - "clear-env": buildpack.Info.ClearEnvironment, - "description": "", - "keywords": []interface{}{}, + "id": buildpack.Info.ID, + "name": buildpack.Info.Name, + "version": buildpack.Info.Version, + "homepage": buildpack.Info.Homepage, + "clear-env": buildpack.Info.ClearEnvironment, + "description": "", + "sbom-formats": []interface{}{}, + "keywords": []interface{}{}, })) }) it("sets layer flags regardless of caching behavior (required for 0.6 API)", func() { layer.Metadata = map[string]interface{}{ - "id": buildpack.Info.ID, - "name": buildpack.Info.Name, - "version": buildpack.Info.Version, - "homepage": buildpack.Info.Homepage, - "clear-env": buildpack.Info.ClearEnvironment, - "description": "", - "keywords": []interface{}{}, + "id": buildpack.Info.ID, + "name": buildpack.Info.Name, + "version": buildpack.Info.Version, + "homepage": buildpack.Info.Homepage, + "clear-env": buildpack.Info.ClearEnvironment, + "description": "", + "sbom-formats": []interface{}{}, + "keywords": []interface{}{}, } // Launch is the only one set & always true @@ -618,5 +681,37 @@ func testLayer(t *testing.T, context spec.G, it spec.S) { Expect(layer.LayerTypes.Cache).To(BeFalse()) Expect(layer.LayerTypes.Build).To(BeFalse()) }) + + it("adds expected Syft SBOM file", func() { + layer.Metadata = map[string]interface{}{ + "id": buildpack.Info.ID, + "name": buildpack.Info.Name, + "version": buildpack.Info.Version, + "homepage": buildpack.Info.Homepage, + "clear-env": buildpack.Info.ClearEnvironment, + "description": "", + "sbom-formats": []interface{}{}, + "keywords": []interface{}{}, + } + + _, err := hlc.Contribute(layer) + Expect(err).NotTo(HaveOccurred()) + + Expect(filepath.Join(layer.Exec.FilePath("test-name-1"))).NotTo(BeAnExistingFile()) + Expect(filepath.Join(layer.Exec.FilePath("test-name-2"))).NotTo(BeAnExistingFile()) + + outputFile := layer.SBOMPath(libcnb.SyftJSON) + Expect(outputFile).To(BeARegularFile()) + + data, err := ioutil.ReadFile(outputFile) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring(`"Artifacts":[`)) + Expect(string(data)).To(ContainSubstring(`"FoundBy":"libpak",`)) + Expect(string(data)).To(ContainSubstring(`"PURL":"pkg:generic/test-id@test-version"`)) + Expect(string(data)).To(ContainSubstring(`"CPEs":["cpe:2.3:a:test-id:test-name-1:test-version:*:*:*:*:*:*:*","cpe:2.3:a:test-id:test-name-2:test-version:*:*:*:*:*:*:*"]`)) + Expect(string(data)).To(ContainSubstring(`"Schema":{`)) + Expect(string(data)).To(ContainSubstring(`"Descriptor":{`)) + Expect(string(data)).To(ContainSubstring(`"Source":{`)) + }) }) } diff --git a/sbom/init_test.go b/sbom/init_test.go new file mode 100644 index 0000000..01de6c6 --- /dev/null +++ b/sbom/init_test.go @@ -0,0 +1,30 @@ +/* + * Copyright 2018-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sbom_test + +import ( + "testing" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" +) + +func TestUnit(t *testing.T) { + suite := spec.New("libpak/sbom", spec.Report(report.Terminal{})) + suite("SBOM", testSBOM) + suite.Run(t) +} diff --git a/sherpa/sbom.go b/sbom/sbom.go similarity index 75% rename from sherpa/sbom.go rename to sbom/sbom.go index 5b29834..68d1daf 100644 --- a/sherpa/sbom.go +++ b/sbom/sbom.go @@ -1,12 +1,15 @@ -package sherpa +package sbom import ( + "encoding/json" "fmt" "io" + "io/ioutil" "os" "github.com/CycloneDX/cyclonedx-go" "github.com/buildpacks/libcnb" + "github.com/mitchellh/hashstructure/v2" "github.com/paketo-buildpacks/libpak/bard" "github.com/paketo-buildpacks/libpak/effect" ) @@ -19,6 +22,89 @@ type SBOMScanner interface { ScanLaunch(scanDir string, formats ...libcnb.SBOMFormat) error } +type SyftDependency struct { + Artifacts []SyftArtifact + Source SyftSource + Descriptor SyftDescriptor + Schema SyftSchema +} + +func NewSyftDependency(dependencyPath string, artifacts []SyftArtifact) SyftDependency { + return SyftDependency{ + Artifacts: artifacts, + Source: SyftSource{ + Type: "directory", + Target: dependencyPath, + }, + Descriptor: SyftDescriptor{ + Name: "syft", + Version: "0.30.1", + }, + Schema: SyftSchema{ + Version: "1.1.0", + URL: "https://raw.githubusercontent.com/anchore/syft/main/schema/json/schema-1.1.0.json", + }, + } +} + +func (s SyftDependency) WriteTo(path string) error { + output, err := json.Marshal(&s) + if err != nil { + return fmt.Errorf("unable to marshal to JSON\n%w", err) + } + + err = ioutil.WriteFile(path, output, 0644) + if err != nil { + return fmt.Errorf("unable to write to path %s\n%w", path, err) + } + + return nil +} + +type SyftArtifact struct { + ID string + Name string + Version string + Type string + FoundBy string + Locations []SyftLocation + Licenses []string + Language string + CPEs []string + PURL string +} + +func (s SyftArtifact) Hash() (string, error) { + f, err := hashstructure.Hash(s, hashstructure.FormatV2, &hashstructure.HashOptions{ + ZeroNil: true, + SlicesAsSets: true, + }) + if err != nil { + return "", fmt.Errorf("could not build ID for artifact=%+v: %+v", s, err) + } + + return fmt.Sprintf("%x", f), nil +} + +type SyftLocation struct { + Path string +} + +type SyftSource struct { + Type string + Target string +} + +type SyftDescriptor struct { + Name string + Version string +} + +type SyftSchema struct { + Version string + URL string +} + type SyftCLISBOMScanner struct { Executor effect.Executor Layers libcnb.Layers @@ -155,7 +241,7 @@ func (b SyftCLISBOMScanner) runSyft(sbomOutputPath string, scanDir string, forma err = b.Executor.Execute(effect.Execution{ Command: "syft", - Args: []string{"packges", "-o", SBOMFormatToSyftOutputFormat(format), fmt.Sprintf("dir:%s", scanDir)}, + Args: []string{"packages", "-q", "-o", SBOMFormatToSyftOutputFormat(format), fmt.Sprintf("dir:%s", scanDir)}, Stdout: writer, Stderr: b.Logger.TerminalErrorWriter(), }) diff --git a/sherpa/sbom_test.go b/sbom/sbom_test.go similarity index 63% rename from sherpa/sbom_test.go rename to sbom/sbom_test.go index 436af9b..34aae96 100644 --- a/sherpa/sbom_test.go +++ b/sbom/sbom_test.go @@ -1,4 +1,4 @@ -package sherpa_test +package sbom_test import ( "fmt" @@ -13,7 +13,7 @@ import ( "github.com/paketo-buildpacks/libpak/bard" "github.com/paketo-buildpacks/libpak/effect" "github.com/paketo-buildpacks/libpak/effect/mocks" - "github.com/paketo-buildpacks/libpak/sherpa" + "github.com/paketo-buildpacks/libpak/sbom" "github.com/sclevine/spec" "github.com/stretchr/testify/mock" ) @@ -25,7 +25,7 @@ func testSBOM(t *testing.T, context spec.G, it spec.S) { layers libcnb.Layers layer libcnb.Layer executor mocks.Executor - scanner sherpa.SBOMScanner + scanner sbom.SBOMScanner ) it.Before(func() { @@ -49,21 +49,28 @@ func testSBOM(t *testing.T, context spec.G, it spec.S) { }) context("syft", func() { + it("generates artifact id", func() { + artifact := sbom.SyftArtifact{Name: "foo", Version: "1.2.3"} + ID, err := artifact.Hash() + Expect(err).ToNot(HaveOccurred()) + Expect(ID).To(Equal("7f6c18a85645bd7c")) + }) + it("runs syft once to generate JSON", func() { format := libcnb.SyftJSON outputPath := layers.BuildSBOMPath(format) executor.On("Execute", mock.MatchedBy(func(e effect.Execution) bool { return e.Command == "syft" && - len(e.Args) == 4 && - e.Args[2] == "json" && - e.Args[3] == "dir:something" + len(e.Args) == 5 && + e.Args[3] == "json" && + e.Args[4] == "dir:something" })).Run(func(args mock.Arguments) { Expect(ioutil.WriteFile(outputPath, []byte("succeed1"), 0644)).To(Succeed()) }).Return(nil) // uses interface here intentionally, to force that inteface and implementation match - scanner = sherpa.NewSyftCLISBOMScanner(layers, &executor, bard.NewLogger(io.Discard)) + scanner = sbom.NewSyftCLISBOMScanner(layers, &executor, bard.NewLogger(io.Discard)) Expect(scanner.ScanBuild("something", format)).To(Succeed()) @@ -78,14 +85,14 @@ func testSBOM(t *testing.T, context spec.G, it spec.S) { executor.On("Execute", mock.MatchedBy(func(e effect.Execution) bool { return e.Command == "syft" && - len(e.Args) == 4 && - e.Args[2] == "json" && - e.Args[3] == "dir:something" + len(e.Args) == 5 && + e.Args[3] == "json" && + e.Args[4] == "dir:something" })).Run(func(args mock.Arguments) { Expect(ioutil.WriteFile(outputPath, []byte("succeed2"), 0644)).To(Succeed()) }).Return(nil) - scanner := sherpa.SyftCLISBOMScanner{ + scanner := sbom.SyftCLISBOMScanner{ Executor: &executor, Layers: layers, Logger: bard.NewLogger(io.Discard), @@ -107,14 +114,14 @@ func testSBOM(t *testing.T, context spec.G, it spec.S) { for format, outputPath := range outputPaths { executor.On("Execute", mock.MatchedBy(func(e effect.Execution) bool { return e.Command == "syft" && - len(e.Args) == 4 && - e.Args[2] == sherpa.SBOMFormatToSyftOutputFormat(format) && - e.Args[3] == "dir:something" + len(e.Args) == 5 && + e.Args[3] == sbom.SBOMFormatToSyftOutputFormat(format) && + e.Args[4] == "dir:something" })).Run(func(args mock.Arguments) { Expect(ioutil.WriteFile(outputPath, []byte("succeed3"), 0644)).To(Succeed()) }).Return(nil) - scanner := sherpa.SyftCLISBOMScanner{ + scanner := sbom.SyftCLISBOMScanner{ Executor: &executor, Layers: layers, Logger: bard.NewLogger(io.Discard), @@ -155,7 +162,7 @@ func testSBOM(t *testing.T, context spec.G, it spec.S) { `), 0644)) - scanner := sherpa.SyftCLISBOMScanner{ + scanner := sbom.SyftCLISBOMScanner{ Executor: &executor, Layers: layers, Logger: bard.NewLogger(io.Discard), @@ -198,7 +205,7 @@ func testSBOM(t *testing.T, context spec.G, it spec.S) { `), 0644)) - scanner := sherpa.SyftCLISBOMScanner{ + scanner := sbom.SyftCLISBOMScanner{ Executor: &executor, Layers: layers, Logger: bard.NewLogger(io.Discard), @@ -219,6 +226,87 @@ func testSBOM(t *testing.T, context spec.G, it spec.S) { Expect(err).ToNot(HaveOccurred()) Expect(string(input)).To(ContainSubstring(``)) }) + + it("writes out a manual BOM entry", func() { + dep := sbom.SyftDependency{ + Artifacts: []sbom.SyftArtifact{ + { + ID: "1234", + Name: "test-dep", + Version: "1.2.3", + Type: "UnknownPackage", + FoundBy: "java-buildpack", + Locations: []sbom.SyftLocation{ + {Path: "/some/path"}, + }, + Licenses: []string{"GPL-2.0 WITH Classpath-exception-2.0"}, + Language: "java", + CPEs: []string{ + "cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*", + }, + PURL: "pkg:generic/some-java11@11.0.2?arch=amd64", + }, + }, + Source: sbom.SyftSource{ + Type: "directory", + Target: "path/to/layer", + }, + Descriptor: sbom.SyftDescriptor{ + Name: "syft", + Version: "0.30.1", + }, + Schema: sbom.SyftSchema{ + Version: "1.1.0", + URL: "https://raw.githubusercontent.com/anchore/syft/main/schema/json/schema-1.1.0.json", + }, + } + outputFile := filepath.Join(layers.Path, "test-bom.json") + Expect(dep.WriteTo(outputFile)).To(Succeed()) + Expect(outputFile).To(BeARegularFile()) + + data, err := ioutil.ReadFile(outputFile) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring(`"Artifacts":[`)) + Expect(string(data)).To(ContainSubstring(`"FoundBy":"java-buildpack",`)) + Expect(string(data)).To(ContainSubstring(`"PURL":"pkg:generic/some-java11@11.0.2?arch=amd64"`)) + Expect(string(data)).To(ContainSubstring(`"Schema":{`)) + Expect(string(data)).To(ContainSubstring(`"Descriptor":{`)) + Expect(string(data)).To(ContainSubstring(`"Source":{`)) + }) + + it("writes out a manual BOM entry with help", func() { + dep := sbom.NewSyftDependency("path/to/layer", []sbom.SyftArtifact{ + { + ID: "1234", + Name: "test-dep", + Version: "1.2.3", + Type: "UnknownPackage", + FoundBy: "java-buildpack", + Locations: []sbom.SyftLocation{ + {Path: "/some/path"}, + }, + Licenses: []string{"GPL-2.0 WITH Classpath-exception-2.0"}, + Language: "java", + CPEs: []string{ + "cpe:2.3:a:some:jre:11.0.2:*:*:*:*:*:*:*", + }, + PURL: "pkg:generic/some-java11@11.0.2?arch=amd64", + }, + }) + + outputFile := filepath.Join(layers.Path, "test-bom.json") + Expect(dep.WriteTo(outputFile)).To(Succeed()) + Expect(outputFile).To(BeARegularFile()) + + data, err := ioutil.ReadFile(outputFile) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring(`"Artifacts":[`)) + Expect(string(data)).To(ContainSubstring(`"FoundBy":"java-buildpack",`)) + Expect(string(data)).To(ContainSubstring(`"PURL":"pkg:generic/some-java11@11.0.2?arch=amd64"`)) + Expect(string(data)).To(ContainSubstring(`"Schema":{`)) + Expect(string(data)).To(ContainSubstring(`"Descriptor":{`)) + Expect(string(data)).To(ContainSubstring(`"Source":{`)) + }) }) } diff --git a/sherpa/init_test.go b/sherpa/init_test.go index a853d48..f524e5d 100644 --- a/sherpa/init_test.go +++ b/sherpa/init_test.go @@ -29,7 +29,6 @@ func TestUnit(t *testing.T) { suite("EnvVar", testEnvVar) suite("FileListing", testFileListing) suite("NodeJS", testNodeJS) - suite("SBOM", testSBOM) suite("Sherpa", testSherpa) suite.Run(t) }