diff --git a/sbom/sbom.go b/sbom/sbom.go index 475fe1b..b3a04df 100644 --- a/sbom/sbom.go +++ b/sbom/sbom.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io/ioutil" + "os" "github.com/buildpacks/libcnb" "github.com/mitchellh/hashstructure/v2" @@ -146,12 +147,67 @@ func (b SyftCLISBOMScanner) scan(sbomPathCreator func(libcnb.SBOMFormat) string, args = append(args, fmt.Sprintf("dir:%s", scanDir)) - return b.Executor.Execute(effect.Execution{ + if err := b.Executor.Execute(effect.Execution{ Command: "syft", Args: args, Stdout: b.Logger.TerminalErrorWriter(), Stderr: b.Logger.TerminalErrorWriter(), - }) + }); err != nil { + return fmt.Errorf("unable to run `syft %s`\n%w", args, err) + } + + // cleans cyclonedx file which has a timestamp and unique id which always change + for _, format := range formats { + if format == libcnb.CycloneDXJSON { + if err := b.makeCycloneDXReproducible(sbomPathCreator(format)); err != nil { + return fmt.Errorf("unable to make cyclone dx file reproducible\n%w", err) + } + } + } + + return nil +} + +func (b SyftCLISBOMScanner) makeCycloneDXReproducible(path string) error { + input, err := loadCycloneDXFile(path) + if err != nil { + return err + } + + delete(input, "serialNumber") + + if md, exists := input["metadata"]; exists { + if metadata, ok := md.(map[string]interface{}); ok { + delete(metadata, "timestamp") + } + } + + out, err := os.Create(path) + if err != nil { + return fmt.Errorf("unable to open CycloneDX JSON for writing %s\n%w", path, err) + } + defer out.Close() + + if err := json.NewEncoder(out).Encode(input); err != nil { + return fmt.Errorf("unable to encode CycloneDX\n%w", err) + } + + return nil +} + +func loadCycloneDXFile(path string) (map[string]interface{}, error) { + in, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("unable to read CycloneDX JSON file %s\n%w", path, err) + } + defer in.Close() + + raw := map[string]interface{}{} + if err := json.NewDecoder(in).Decode(&raw); err != nil { + return nil, fmt.Errorf("unable to decode CycloneDX JSON %s\n%w", path, err) + } + + return raw, nil } // SBOMFormatToSyftOutputFormat converts a libcnb.SBOMFormat to the syft matching syft output format string diff --git a/sbom/sbom_test.go b/sbom/sbom_test.go index 0b29e1e..8484a11 100644 --- a/sbom/sbom_test.go +++ b/sbom/sbom_test.go @@ -79,6 +79,52 @@ func testSBOM(t *testing.T, context spec.G, it spec.S) { Expect(string(result)).To(Equal("succeed1")) }) + it("runs syft to generate reproducible cycloneDX JSON", func() { + format := libcnb.CycloneDXJSON + outputPath := layers.BuildSBOMPath(format) + + executor.On("Execute", mock.MatchedBy(func(e effect.Execution) bool { + return e.Command == "syft" && + len(e.Args) == 5 && + strings.HasPrefix(e.Args[3], "cyclonedx-json=") && + e.Args[4] == "dir:something" + })).Run(func(args mock.Arguments) { + Expect(ioutil.WriteFile(outputPath, []byte(`{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:fcfa5e19-bf49-47b4-8c85-ab61e2728f8e", + "version": 1, + "metadata": { + "timestamp": "2022-05-05T11:33:13-04:00", + "tools": [ + { + "vendor": "anchore", + "name": "syft", + "version": "0.45.1" + } + ], + "component": { + "bom-ref": "555d623e4777b7ae", + "type": "file", + "name": "target/demo-0.0.1-SNAPSHOT.jar" + } + } +}`), 0644)).To(Succeed()) + }).Return(nil) + + // uses interface here intentionally, to force that inteface and implementation match + scanner = sbom.NewSyftCLISBOMScanner(layers, &executor, bard.NewLogger(io.Discard)) + + Expect(scanner.ScanBuild("something", format)).To(Succeed()) + + result, err := ioutil.ReadFile(outputPath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(result)).ToNot(ContainSubstring("serialNumber")) + Expect(string(result)).ToNot(ContainSubstring("urn:uuid:fcfa5e19-bf49-47b4-8c85-ab61e2728f8e")) + Expect(string(result)).ToNot(ContainSubstring("timestamp")) + Expect(string(result)).ToNot(ContainSubstring("2022-05-05T11:33:13-04:00")) + }) + it("runs syft once to generate layer-specific JSON", func() { format := libcnb.SyftJSON outputPath := layer.SBOMPath(format) @@ -114,9 +160,9 @@ func testSBOM(t *testing.T, context spec.G, it spec.S) { strings.HasPrefix(e.Args[7], sbom.SBOMFormatToSyftOutputFormat(libcnb.SPDXJSON)) && e.Args[8] == "dir:something" })).Run(func(args mock.Arguments) { - Expect(ioutil.WriteFile(layers.LaunchSBOMPath(libcnb.CycloneDXJSON), []byte("succeed1"), 0644)).To(Succeed()) - Expect(ioutil.WriteFile(layers.LaunchSBOMPath(libcnb.SyftJSON), []byte("succeed2"), 0644)).To(Succeed()) - Expect(ioutil.WriteFile(layers.LaunchSBOMPath(libcnb.SPDXJSON), []byte("succeed3"), 0644)).To(Succeed()) + Expect(ioutil.WriteFile(layers.LaunchSBOMPath(libcnb.CycloneDXJSON), []byte(`{"succeed":1}`), 0644)).To(Succeed()) + Expect(ioutil.WriteFile(layers.LaunchSBOMPath(libcnb.SyftJSON), []byte(`{"succeed":2}`), 0644)).To(Succeed()) + Expect(ioutil.WriteFile(layers.LaunchSBOMPath(libcnb.SPDXJSON), []byte(`{"succeed":3}`), 0644)).To(Succeed()) }).Return(nil) scanner := sbom.SyftCLISBOMScanner{ @@ -129,15 +175,15 @@ func testSBOM(t *testing.T, context spec.G, it spec.S) { result, err := ioutil.ReadFile(layers.LaunchSBOMPath(libcnb.CycloneDXJSON)) Expect(err).ToNot(HaveOccurred()) - Expect(string(result)).To(Equal("succeed1")) + Expect(string(result)).To(HavePrefix(`{"succeed":1}`)) result, err = ioutil.ReadFile(layers.LaunchSBOMPath(libcnb.SyftJSON)) Expect(err).ToNot(HaveOccurred()) - Expect(string(result)).To(Equal("succeed2")) + Expect(string(result)).To(HavePrefix(`{"succeed":2}`)) result, err = ioutil.ReadFile(layers.LaunchSBOMPath(libcnb.SPDXJSON)) Expect(err).ToNot(HaveOccurred()) - Expect(string(result)).To(Equal("succeed3")) + Expect(string(result)).To(HavePrefix(`{"succeed":3}`)) }) it("writes out a manual BOM entry", func() {