diff --git a/buildpack.go b/buildpack.go
index 124fb80..f3cd09b 100644
--- a/buildpack.go
+++ b/buildpack.go
@@ -85,6 +85,8 @@ type BuildpackDependency struct {
}
// AsBOMEntry renders a bill of materials entry describing the dependency.
+//
+// Deprecated: as of Buildpacks RFC 95, use `sherpa.BOMScanner` instead
func (b BuildpackDependency) AsBOMEntry() libcnb.BOMEntry {
return libcnb.BOMEntry{
Name: b.ID,
diff --git a/go.mod b/go.mod
index feef1c6..cd0efb3 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/paketo-buildpacks/libpak
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.0
github.com/creack/pty v1.1.17
diff --git a/go.sum b/go.sum
index 5c0b3fd..3acf3cd 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,11 @@
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/CycloneDX/cyclonedx-go v0.4.0 h1:Wz4QZ9B4RXGWIWTypVLEOVJgOdFfy5mcS5PGNzUkZxU=
+github.com/CycloneDX/cyclonedx-go v0.4.0/go.mod h1:rmRcf//gT7PIzovatusbWi377xqCg1FS4jyST0GH20E=
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
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.0 h1:jVpydlJPygweUBk4ac3WGT2X1NGeunH17eyn9tUqZuU=
github.com/buildpacks/libcnb v1.24.0/go.mod h1:wIXTSW6ybtX9XIICQQqPnIUxx6t1bSZT7iIOKbEzRH0=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
@@ -57,9 +61,11 @@ github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8=
github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
diff --git a/layer.go b/layer.go
index 1967ec0..3d8a2a4 100644
--- a/layer.go
+++ b/layer.go
@@ -128,6 +128,9 @@ type DependencyLayerContributor struct {
}
// NewDependencyLayer returns a new DependencyLayerContributor for the given BuildpackDependency and a BOMEntry describing the layer contents.
+//
+// Deprecated: this method uses `libcnb.BOMEntry` which has been deprecated upstream, a future version will drop
+// support for `libcnb.BOMEntry` which will change this method signature
func NewDependencyLayer(dependency BuildpackDependency, cache DependencyCache, types libcnb.LayerTypes) (DependencyLayerContributor, libcnb.BOMEntry) {
c := DependencyLayerContributor{
Dependency: dependency,
@@ -197,6 +200,9 @@ type HelperLayerContributor struct {
}
// NewHelperLayer returns a new HelperLayerContributor and a BOMEntry describing the layer contents.
+//
+// Deprecated: this method uses `libcnb.BOMEntry` which has been deprecated upstream, a future version will drop
+// support for `libcnb.BOMEntry` which will change this method signature
func NewHelperLayer(buildpack libcnb.Buildpack, names ...string) (HelperLayerContributor, libcnb.BOMEntry) {
c := HelperLayerContributor{
Path: filepath.Join(buildpack.Path, "bin", "helper"),
diff --git a/sherpa/init_test.go b/sherpa/init_test.go
index f524e5d..a853d48 100644
--- a/sherpa/init_test.go
+++ b/sherpa/init_test.go
@@ -29,6 +29,7 @@ func TestUnit(t *testing.T) {
suite("EnvVar", testEnvVar)
suite("FileListing", testFileListing)
suite("NodeJS", testNodeJS)
+ suite("SBOM", testSBOM)
suite("Sherpa", testSherpa)
suite.Run(t)
}
diff --git a/sherpa/mocks/bom_scanner.go b/sherpa/mocks/bom_scanner.go
new file mode 100644
index 0000000..33b0bbf
--- /dev/null
+++ b/sherpa/mocks/bom_scanner.go
@@ -0,0 +1,25 @@
+// Code generated by mockery v1.0.0. DO NOT EDIT.
+
+package mocks
+
+import (
+ libcnb "github.com/buildpacks/libcnb"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// BOMScanner is an autogenerated mock type for the BOMScanner type
+type BOMScanner struct {
+ mock.Mock
+}
+
+// Scan provides a mock function with given fields: location, scanDir, formats
+func (_m *BOMScanner) Scan(location libcnb.BOMLocation, scanDir string, formats ...libcnb.BOMFormat) {
+ _va := make([]interface{}, len(formats))
+ for _i := range formats {
+ _va[_i] = formats[_i]
+ }
+ var _ca []interface{}
+ _ca = append(_ca, location, scanDir)
+ _ca = append(_ca, _va...)
+ _m.Called(_ca...)
+}
diff --git a/sherpa/sbom.go b/sherpa/sbom.go
new file mode 100644
index 0000000..59d1721
--- /dev/null
+++ b/sherpa/sbom.go
@@ -0,0 +1,154 @@
+package sherpa
+
+import (
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/CycloneDX/cyclonedx-go"
+ "github.com/buildpacks/libcnb"
+ "github.com/paketo-buildpacks/libpak/bard"
+ "github.com/paketo-buildpacks/libpak/effect"
+)
+
+//go:generate mockery -name BOMScanner -case=underscore
+
+type BOMScanner interface {
+ Scan(location libcnb.BOMLocation, scanDir string, formats ...libcnb.BOMFormat)
+}
+
+type SyftCLIBOMScanner struct {
+ Layer libcnb.Layer
+ Executor effect.Executor
+ Logger bard.Logger
+}
+
+// Scan will use syft CLI to scan the scanDir and write it's output to the location in the given formats
+func (b SyftCLIBOMScanner) Scan(location libcnb.BOMLocation, scanDir string, formats ...libcnb.BOMFormat) error {
+ // syft doesn't presently support outputting multiple formats at once
+ // to workaround this we are running syft multiple times
+ // when syft supports multiple output formats or conversion between formats, this method should change
+ for _, format := range formats {
+ if err := b.runSyft(location, scanDir, format); err != nil {
+ return fmt.Errorf("unable to run syft\n%w", err)
+ }
+
+ if format == libcnb.CycloneDXJSON {
+ // syft doesn't presently support cyclonedx JSON output and we need to convert
+ // until https://github.com/anchore/syft/issues/631 is addressed
+ if err := b.ConvertCycloneDXXMLtoJSON(location, false); err != nil {
+ return fmt.Errorf("unable convert XML to JSON\n%w", err)
+ }
+ }
+ }
+
+ return nil
+}
+
+// ConvertCycloneDXXMLtoJSON reads input CycloneDX XML, converts to JSON and overwrites the XML optionally keeping a backup copy of the xml
+func (b SyftCLIBOMScanner) ConvertCycloneDXXMLtoJSON(location libcnb.BOMLocation, backup bool) error {
+ inputPath := b.Layer.BOMPath(location, libcnb.CycloneDXJSON)
+
+ if backup {
+ if err := b.backupXMLFile(inputPath); err != nil {
+ return fmt.Errorf("unable to backup file\n%w", err)
+ }
+ }
+
+ bom, err := b.readXMLBOM(inputPath)
+ if err != nil {
+ return fmt.Errorf("unable to backup file\n%w", err)
+ }
+
+ if err := b.writeJSONBOM(inputPath, bom); err != nil {
+ return fmt.Errorf("unable to backup file\n%w", err)
+ }
+
+ return nil
+}
+
+func (b SyftCLIBOMScanner) writeJSONBOM(outputPath string, bom cyclonedx.BOM) error {
+ outputFile, err := os.Create(outputPath)
+ if err != nil {
+ return fmt.Errorf("unable to create BOM file %s\n%w", outputPath, err)
+ }
+ defer outputFile.Close()
+
+ decoder := cyclonedx.NewBOMEncoder(outputFile, cyclonedx.BOMFileFormatJSON)
+ if err = decoder.Encode(&bom); err != nil {
+ return fmt.Errorf("unable to decode BOM\n%w", err)
+ }
+
+ return nil
+}
+
+func (b SyftCLIBOMScanner) readXMLBOM(inputPath string) (cyclonedx.BOM, error) {
+ inputFile, err := os.Open(inputPath)
+ if err != nil {
+ return cyclonedx.BOM{}, fmt.Errorf("unable to read file to convert %s\n%w", inputPath, err)
+ }
+ defer inputFile.Close()
+
+ var bom cyclonedx.BOM
+ decoder := cyclonedx.NewBOMDecoder(inputFile, cyclonedx.BOMFileFormatXML)
+ if err = decoder.Decode(&bom); err != nil {
+ return cyclonedx.BOM{}, fmt.Errorf("unable to decode BOM\n%w", err)
+ }
+
+ return bom, nil
+}
+
+func (b SyftCLIBOMScanner) backupXMLFile(inputPath string) error {
+ backupPath := fmt.Sprintf("%s.bak", inputPath)
+ outputFile, err := os.Create(backupPath)
+ if err != nil {
+ return fmt.Errorf("unable to create backup file %s\n%w", backupPath, err)
+ }
+ defer outputFile.Close()
+
+ inputFile, err := os.Open(inputPath)
+ if err != nil {
+ return fmt.Errorf("unable to read file for backup %s\n%w", inputPath, err)
+ }
+ defer inputFile.Close()
+
+ _, err = io.Copy(outputFile, inputFile)
+ return err
+}
+
+func (b SyftCLIBOMScanner) runSyft(location libcnb.BOMLocation, scanDir string, format libcnb.BOMFormat) error {
+ bomOutputPath := b.Layer.BOMPath(location, format)
+ writer, err := os.Create(bomOutputPath)
+ if err != nil {
+ return fmt.Errorf("unable to open output BOM file %s\n%w", bomOutputPath, err)
+ }
+ defer writer.Close()
+
+ err = b.Executor.Execute(effect.Execution{
+ Command: "syft",
+ Args: []string{"packges", "-o", BOMFormatToSyftOutputFormat(format), fmt.Sprintf("dir:%s", scanDir)},
+ Stdout: writer,
+ Stderr: b.Logger.TerminalErrorWriter(),
+ })
+ if err != nil {
+ return fmt.Errorf("unable to run syft on directory %s\n%w", scanDir, err)
+ }
+
+ return nil
+}
+
+// BOMFormatToSyftOutputFormat converts a libcnb.BOMFormat to the syft matching syft output format string
+func BOMFormatToSyftOutputFormat(format libcnb.BOMFormat) string {
+ var formatRaw string
+
+ switch format {
+ case libcnb.CycloneDXJSON:
+ formatRaw = "cyclonedx"
+ case libcnb.SPDXJSON:
+ formatRaw = "spdx-json"
+ case libcnb.SyftJSON:
+ formatRaw = "json"
+ }
+
+ return formatRaw
+}
diff --git a/sherpa/sbom_test.go b/sherpa/sbom_test.go
new file mode 100644
index 0000000..7d3b8dd
--- /dev/null
+++ b/sherpa/sbom_test.go
@@ -0,0 +1,196 @@
+package sherpa_test
+
+import (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/buildpacks/libcnb"
+ . "github.com/onsi/gomega"
+ "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/sclevine/spec"
+ "github.com/stretchr/testify/mock"
+)
+
+func testSBOM(t *testing.T, context spec.G, it spec.S) {
+ var (
+ Expect = NewWithT(t).Expect
+
+ testRoot string
+ layer libcnb.Layer
+ executor mocks.Executor
+ )
+
+ it.Before(func() {
+ var err error
+
+ executor = mocks.Executor{}
+
+ testRoot, err = ioutil.TempDir("", "test-root")
+ Expect(err).NotTo(HaveOccurred())
+
+ layer.Path = filepath.Join(testRoot, "layer")
+ Expect(os.MkdirAll(layer.Path, 0755)).To(Succeed())
+ })
+
+ it.After(func() {
+ Expect(os.RemoveAll(testRoot)).To(Succeed())
+ })
+
+ context("syft", func() {
+ it("runs syft once to generate JSON", func() {
+ format := libcnb.SyftJSON
+ outputPath := layer.BOMPath(libcnb.BuildBOM, 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"
+ })).Run(func(args mock.Arguments) {
+ Expect(ioutil.WriteFile(outputPath, []byte("succeed"), 0644)).To(Succeed())
+ }).Return(nil)
+
+ scanner := sherpa.SyftCLIBOMScanner{
+ Layer: layer,
+ Executor: &executor,
+ Logger: bard.NewLogger(io.Discard),
+ }
+
+ Expect(scanner.Scan(libcnb.BuildBOM, "something", format)).To(Succeed())
+
+ result, err := ioutil.ReadFile(outputPath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(result)).To(Equal("succeed"))
+ })
+
+ it("runs syft twice, once per format", func() {
+ outputPaths := map[libcnb.BOMFormat]string{
+ libcnb.SPDXJSON: layer.BOMPath(libcnb.BuildBOM, libcnb.SPDXJSON),
+ libcnb.SyftJSON: layer.BOMPath(libcnb.BuildBOM, libcnb.SyftJSON),
+ }
+
+ 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.BOMFormatToSyftOutputFormat(format) &&
+ e.Args[3] == "dir:something"
+ })).Run(func(args mock.Arguments) {
+ Expect(ioutil.WriteFile(outputPath, []byte("succeed"), 0644)).To(Succeed())
+ }).Return(nil)
+
+ scanner := sherpa.SyftCLIBOMScanner{
+ Layer: layer,
+ Executor: &executor,
+ Logger: bard.NewLogger(io.Discard),
+ }
+
+ Expect(scanner.Scan(libcnb.BuildBOM, "something", format)).To(Succeed())
+
+ result, err := ioutil.ReadFile(outputPath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(result)).To(Equal("succeed"))
+ }
+ })
+
+ it("converts between cyclonedx XML and JSON", func() {
+ outputPath := layer.BOMPath(libcnb.BuildBOM, libcnb.CycloneDXJSON)
+ Expect(ioutil.WriteFile(outputPath, []byte(`
+
+
+ 2021-11-15T16:15:46-05:00
+
+
+ anchore
+ syft
+ 0.29.0
+
+
+
+ .
+
+
+
+
+
+ github.com/BurntSushi/toml
+ v0.4.1
+ pkg:golang/github.com/BurntSushi/toml@v0.4.1
+
+
+`), 0644))
+
+ scanner := sherpa.SyftCLIBOMScanner{
+ Layer: layer,
+ Executor: &executor,
+ Logger: bard.NewLogger(io.Discard),
+ }
+
+ Expect(scanner.ConvertCycloneDXXMLtoJSON(libcnb.BuildBOM, false)).To(Succeed())
+
+ Expect(outputPath).To(BeARegularFile())
+ Expect(fmt.Sprintf("%s.bak", outputPath)).ToNot(BeARegularFile())
+
+ input, err := ioutil.ReadFile(outputPath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(input)).To(ContainSubstring(`{"type":"library","name":"github.com/BurntSushi/toml","version":"v0.4.1","purl":"pkg:golang/github.com/BurntSushi/toml@v0.4.1"}`))
+ })
+
+ it("converts between cyclonedx XML and JSON with backup", func() {
+ outputPath := layer.BOMPath(libcnb.BuildBOM, libcnb.CycloneDXJSON)
+ Expect(ioutil.WriteFile(outputPath, []byte(`
+
+
+ 2021-11-15T16:15:46-05:00
+
+
+ anchore
+ syft
+ 0.29.0
+
+
+
+ .
+
+
+
+
+
+ github.com/BurntSushi/toml
+ v0.4.1
+ pkg:golang/github.com/BurntSushi/toml@v0.4.1
+
+
+`), 0644))
+
+ scanner := sherpa.SyftCLIBOMScanner{
+ Layer: layer,
+ Executor: &executor,
+ Logger: bard.NewLogger(io.Discard),
+ }
+
+ Expect(scanner.ConvertCycloneDXXMLtoJSON(libcnb.BuildBOM, true)).To(Succeed())
+
+ Expect(outputPath).To(BeARegularFile())
+
+ input, err := ioutil.ReadFile(outputPath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(input)).To(ContainSubstring(`{"type":"library","name":"github.com/BurntSushi/toml","version":"v0.4.1","purl":"pkg:golang/github.com/BurntSushi/toml@v0.4.1"}`))
+
+ outputPath = fmt.Sprintf("%s.bak", outputPath)
+ Expect(outputPath).To(BeARegularFile())
+
+ input, err = ioutil.ReadFile(outputPath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(input)).To(ContainSubstring(``))
+ })
+ })
+
+}