From 9395235f8126ad79e0d7d1ad95354e22c852dcfb Mon Sep 17 00:00:00 2001 From: Daniel Mikusa Date: Thu, 5 May 2022 13:28:14 -0400 Subject: [PATCH] Removes `metadata.timestamp` and `serialNumber` fields from generates CycloneDX JSON These two properties generate unique values every time syft runs, thus they break reproducible builds (a scan runs on each build, so two different builds of the same application source/buildpacks/builder will result in two different CycloneDX JSON files and thus two different images). This fix is to manually read the generated CycloneDX file, parse the JSON, remove the two fields and write the JSON back out. This should be OK as the CycloneDX spec says that the fields are optional: https://cyclonedx.org/docs/1.3/json/. Signed-off-by: Daniel Mikusa --- sbom/sbom.go | 60 +++++++++++++++++++++++++++++++++++++++++++++-- sbom/sbom_test.go | 58 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 110 insertions(+), 8 deletions(-) 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() {