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..23c8e43 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() { @@ -618,5 +678,36 @@ 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": "", + "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) { </components> </bom>`), 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) { </components> </bom>`), 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(`<bom xmlns="http://cyclonedx.org/schema/bom/1.2" version="1" serialNumber="urn:uuid:48051e17-8720-4503-a2ef-47efab3fc03f">`)) }) + + 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) }