From f892289e7c15308d80aa7cacbae46db13fcd5a77 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Mon, 24 Aug 2020 20:43:29 -0400 Subject: [PATCH] Add CycloneDX presenter (#157) * add CycloneDX presenter + BOM Descriptor extension Signed-off-by: Alex Goodman * add docstrings to cyclonedx presenter Signed-off-by: Alex Goodman --- Makefile | 2 +- cmd/cmd.go | 2 +- go.mod | 1 + syft/presenter/cyclonedx/bom-extension.go | 49 ++++++ syft/presenter/cyclonedx/component.go | 26 ++++ syft/presenter/cyclonedx/document.go | 55 +++++++ syft/presenter/cyclonedx/presenter.go | 81 ++++++++++ syft/presenter/cyclonedx/presenter_test.go | 144 ++++++++++++++++++ .../test-fixtures/image-simple/Dockerfile | 6 + .../test-fixtures/image-simple/file-1.txt | 1 + .../test-fixtures/image-simple/file-2.txt | 1 + .../target/really/nested/file-3.txt | 2 + .../TestCycloneDxDirsPresenter.golden | 33 ++++ .../TestCycloneDxImgsPresenter.golden | 33 ++++ syft/presenter/option.go | 35 ++--- syft/presenter/presenter.go | 4 + 16 files changed, 450 insertions(+), 25 deletions(-) create mode 100644 syft/presenter/cyclonedx/bom-extension.go create mode 100644 syft/presenter/cyclonedx/component.go create mode 100644 syft/presenter/cyclonedx/document.go create mode 100644 syft/presenter/cyclonedx/presenter.go create mode 100644 syft/presenter/cyclonedx/presenter_test.go create mode 100644 syft/presenter/cyclonedx/test-fixtures/image-simple/Dockerfile create mode 100644 syft/presenter/cyclonedx/test-fixtures/image-simple/file-1.txt create mode 100644 syft/presenter/cyclonedx/test-fixtures/image-simple/file-2.txt create mode 100644 syft/presenter/cyclonedx/test-fixtures/image-simple/target/really/nested/file-3.txt create mode 100644 syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxDirsPresenter.golden create mode 100644 syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxImgsPresenter.golden diff --git a/Makefile b/Makefile index 88b29e280..076c50b43 100644 --- a/Makefile +++ b/Makefile @@ -253,4 +253,4 @@ clean-dist: .PHONY: clean-json-schema-examples clean-json-schema-examples: - rm json-schema/examples/* \ No newline at end of file + rm -f json-schema/examples/* \ No newline at end of file diff --git a/cmd/cmd.go b/cmd/cmd.go index a19178e80..5d93443bb 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -59,7 +59,7 @@ func setGlobalCliOptions() { // output & formatting options flag = "output" rootCmd.Flags().StringP( - flag, "o", presenter.TablePresenter.String(), + flag, "o", string(presenter.TablePresenter), fmt.Sprintf("report output formatter, options=%v", presenter.Options), ) if err := viper.BindPFlag(flag, rootCmd.Flags().Lookup(flag)); err != nil { diff --git a/go.mod b/go.mod index 667d6eba2..977800760 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/dustin/go-humanize v1.0.0 github.com/go-test/deep v1.0.6 github.com/google/go-containerregistry v0.1.1 // indirect + github.com/google/uuid v1.1.1 github.com/gookit/color v1.2.7 github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99 // indirect github.com/hashicorp/go-multierror v1.1.0 diff --git a/syft/presenter/cyclonedx/bom-extension.go b/syft/presenter/cyclonedx/bom-extension.go new file mode 100644 index 000000000..f25713734 --- /dev/null +++ b/syft/presenter/cyclonedx/bom-extension.go @@ -0,0 +1,49 @@ +package cyclonedx + +import ( + "encoding/xml" + "time" + + "github.com/anchore/syft/internal" + "github.com/anchore/syft/internal/version" +) + +// Source: https://cyclonedx.org/ext/bom-descriptor/ + +// BomDescriptor represents all metadata surrounding the BOM report (such as when the BOM was made, with which tool, and the item being cataloged). +type BomDescriptor struct { + XMLName xml.Name `xml:"bd:metadata"` + Timestamp string `xml:"bd:timestamp,omitempty"` // The date and time (timestamp) when the document was created + Tool *BdTool `xml:"bd:tool"` // The tool used to create the BOM. + Component *BdComponent `xml:"bd:component"` // The component that the BOM describes. +} + +// BdTool represents the tool that created the BOM report. +type BdTool struct { + XMLName xml.Name `xml:"bd:tool"` + Vendor string `xml:"bd:vendor,omitempty"` // The vendor of the tool used to create the BOM. + Name string `xml:"bd:name,omitempty"` // The name of the tool used to create the BOM. + Version string `xml:"bd:version,omitempty"` // The version of the tool used to create the BOM. + // TODO: hashes, author, manufacture, supplier + // TODO: add user-defined fields for the remaining build/version parameters +} + +// BdComponent represents the software/package being cataloged. +type BdComponent struct { + XMLName xml.Name `xml:"bd:component"` + Component +} + +// NewBomDescriptor returns a new BomDescriptor tailored for the current time and "syft" tool details. +func NewBomDescriptor() *BomDescriptor { + versionInfo := version.FromBuild() + return &BomDescriptor{ + XMLName: xml.Name{}, + Timestamp: time.Now().Format(time.RFC3339), + Tool: &BdTool{ + Vendor: "anchore", + Name: internal.ApplicationName, + Version: versionInfo.Version, + }, + } +} diff --git a/syft/presenter/cyclonedx/component.go b/syft/presenter/cyclonedx/component.go new file mode 100644 index 000000000..0683a6d80 --- /dev/null +++ b/syft/presenter/cyclonedx/component.go @@ -0,0 +1,26 @@ +package cyclonedx + +import "encoding/xml" + +// Component represents a single element in the CycloneDX BOM +type Component struct { + XMLName xml.Name `xml:"component"` + Type string `xml:"type,attr"` // Required; Describes if the component is a library, framework, application, container, operating system, firmware, hardware device, or file + Supplier string `xml:"supplier,omitempty"` // The organization that supplied the component. The supplier may often be the manufacture, but may also be a distributor or repackager. + Author string `xml:"author,omitempty"` // The person(s) or organization(s) that authored the component + Publisher string `xml:"publisher,omitempty"` // The person(s) or organization(s) that published the component + Group string `xml:"group,omitempty"` // The high-level classification that a project self-describes as. This will often be a shortened, single name of the company or project that produced the component, or the source package or domain name. + Name string `xml:"name"` // Required; The name of the component as defined by the project + Version string `xml:"version"` // Required; The version of the component as defined by the project + Description string `xml:"description,omitempty"` // A description of the component + Licenses *[]License `xml:"licenses>license"` // A node describing zero or more license names, SPDX license IDs or expressions + // TODO: scope, hashes, copyright, cpe, purl, swid, modified, pedigree, externalReferences + // TODO: add user-defined parameters for syft-specific values (image layer index, cataloger, location path, etc.) +} + +// License represents a single software license for a Component +type License struct { + XMLName xml.Name `xml:"license"` + ID string `xml:"id,omitempty"` // A valid SPDX license ID + Name string `xml:"name,omitempty"` // If SPDX does not define the license used, this field may be used to provide the license name +} diff --git a/syft/presenter/cyclonedx/document.go b/syft/presenter/cyclonedx/document.go new file mode 100644 index 000000000..74a18f467 --- /dev/null +++ b/syft/presenter/cyclonedx/document.go @@ -0,0 +1,55 @@ +package cyclonedx + +import ( + "encoding/xml" + + "github.com/anchore/syft/syft/pkg" + "github.com/google/uuid" +) + +// Source: https://github.com/CycloneDX/specification + +// Document represents a CycloneDX BOM Document. +type Document struct { + XMLName xml.Name `xml:"bom"` + XMLNs string `xml:"xmlns,attr"` + Version int `xml:"version,attr"` + SerialNumber string `xml:"serialNumber,attr"` + Components []Component `xml:"components>component"` // The BOM contents + BomDescriptor *BomDescriptor `xml:"bd:metadata"` // The BOM descriptor extension +} + +// NewDocument returns an empty CycloneDX Document object. +func NewDocument() Document { + return Document{ + XMLNs: "http://cyclonedx.org/schema/bom/1.2", + Version: 1, + SerialNumber: uuid.New().URN(), + } +} + +// NewDocumentFromCatalog returns a CycloneDX Document object populated with the catalog contents. +func NewDocumentFromCatalog(catalog *pkg.Catalog) Document { + bom := NewDocument() + for p := range catalog.Enumerate() { + component := Component{ + Type: "library", // TODO: this is not accurate + Name: p.Name, + Version: p.Version, + } + var licenses []License + for _, licenseName := range p.Licenses { + licenses = append(licenses, License{ + Name: licenseName, + }) + } + if len(licenses) > 0 { + component.Licenses = &licenses + } + bom.Components = append(bom.Components, component) + } + + bom.BomDescriptor = NewBomDescriptor() + + return bom +} diff --git a/syft/presenter/cyclonedx/presenter.go b/syft/presenter/cyclonedx/presenter.go new file mode 100644 index 000000000..3b935387a --- /dev/null +++ b/syft/presenter/cyclonedx/presenter.go @@ -0,0 +1,81 @@ +/* +Package cyclonedx is responsible for generating a CycloneDX XML report for the given container image or file system. +*/ +package cyclonedx + +import ( + "encoding/xml" + "fmt" + "io" + + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/scope" +) + +// Presenter writes a CycloneDX report from the given Catalog and Scope contents +type Presenter struct { + catalog *pkg.Catalog + scope scope.Scope +} + +// NewPresenter creates a CycloneDX presenter from the given Catalog and Scope objects. +func NewPresenter(catalog *pkg.Catalog, s scope.Scope) *Presenter { + return &Presenter{ + catalog: catalog, + scope: s, + } +} + +// Present writes the CycloneDX report to the given io.Writer. +func (pres *Presenter) Present(output io.Writer) error { + bom := NewDocumentFromCatalog(pres.catalog) + + srcObj := pres.scope.Source() + + switch src := srcObj.(type) { + case scope.DirSource: + bom.BomDescriptor.Component = &BdComponent{ + Component: Component{ + Type: "file", + Name: src.Path, + Version: "", + }, + } + case scope.ImageSource: + var imageID string + var versionStr string + if len(src.Img.Metadata.Tags) > 0 { + imageID = src.Img.Metadata.Tags[0].Context().Name() + versionStr = src.Img.Metadata.Tags[0].TagStr() + } else { + imageID = src.Img.Metadata.Digest + } + src.Img.Metadata.Tags[0].TagStr() + bom.BomDescriptor.Component = &BdComponent{ + Component: Component{ + Type: "container", + Name: imageID, + Version: versionStr, + }, + } + default: + return fmt.Errorf("unsupported source: %T", src) + } + + xmlOut, err := xml.MarshalIndent(bom, " ", " ") + if err != nil { + return err + } + + _, err = output.Write([]byte(xml.Header)) + if err != nil { + return err + } + _, err = output.Write(xmlOut) + if err != nil { + return err + } + + _, err = output.Write([]byte("\n")) + return err +} diff --git a/syft/presenter/cyclonedx/presenter_test.go b/syft/presenter/cyclonedx/presenter_test.go new file mode 100644 index 000000000..194e66eb9 --- /dev/null +++ b/syft/presenter/cyclonedx/presenter_test.go @@ -0,0 +1,144 @@ +package cyclonedx + +import ( + "bytes" + "flag" + "regexp" + "testing" + + "github.com/anchore/go-testutils" + "github.com/anchore/stereoscope/pkg/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/scope" + "github.com/sergi/go-diff/diffmatchpatch" +) + +var update = flag.Bool("update", false, "update the *.golden files for json presenters") + +func TestCycloneDxDirsPresenter(t *testing.T) { + var buffer bytes.Buffer + + catalog := pkg.NewCatalog() + + // populate catalog with test data + catalog.Add(pkg.Package{ + Name: "package-1", + Version: "1.0.1", + Type: pkg.DebPkg, + FoundBy: "the-cataloger-1", + Source: []file.Reference{ + {Path: "/some/path/pkg1"}, + }, + }) + catalog.Add(pkg.Package{ + Name: "package-2", + Version: "2.0.1", + Type: pkg.DebPkg, + FoundBy: "the-cataloger-2", + Source: []file.Reference{ + {Path: "/some/path/pkg1"}, + }, + Licenses: []string{ + "MIT", + "Apache-v2", + }, + }) + + s, err := scope.NewScopeFromDir("/some/path") + if err != nil { + t.Fatal(err) + } + pres := NewPresenter(catalog, s) + + // run presenter + err = pres.Present(&buffer) + if err != nil { + t.Fatal(err) + } + actual := buffer.Bytes() + + if *update { + testutils.UpdateGoldenFileContents(t, actual) + } + + var expected = testutils.GetGoldenFileContents(t) + + // remove dynamic values, which are tested independently + actual = redact(actual) + expected = redact(expected) + + if !bytes.Equal(expected, actual) { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(string(actual), string(expected), true) + t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs)) + } + +} + +func TestCycloneDxImgsPresenter(t *testing.T) { + var buffer bytes.Buffer + + catalog := pkg.NewCatalog() + img, cleanup := testutils.GetFixtureImage(t, "docker-archive", "image-simple") + defer cleanup() + + // populate catalog with test data + catalog.Add(pkg.Package{ + Name: "package-1", + Version: "1.0.1", + Source: []file.Reference{ + *img.SquashedTree().File("/somefile-1.txt"), + }, + Type: pkg.DebPkg, + FoundBy: "the-cataloger-1", + }) + catalog.Add(pkg.Package{ + Name: "package-2", + Version: "2.0.1", + Source: []file.Reference{ + *img.SquashedTree().File("/somefile-2.txt"), + }, + Type: pkg.DebPkg, + FoundBy: "the-cataloger-2", + Licenses: []string{ + "MIT", + "Apache-v2", + }, + }) + + s, err := scope.NewScopeFromImage(img, scope.AllLayersScope) + pres := NewPresenter(catalog, s) + + // run presenter + err = pres.Present(&buffer) + if err != nil { + t.Fatal(err) + } + actual := buffer.Bytes() + + if *update { + testutils.UpdateGoldenFileContents(t, actual) + } + + var expected = testutils.GetGoldenFileContents(t) + + // remove dynamic values, which are tested independently + actual = redact(actual) + expected = redact(expected) + + if !bytes.Equal(expected, actual) { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(string(actual), string(expected), true) + t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs)) + } +} + +func redact(s []byte) []byte { + serialPattern := regexp.MustCompile(`serialNumber="[a-zA-Z0-9\-:]+"`) + rfc3339Pattern := regexp.MustCompile(`([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\.[0-9]+)?(([Zz])|([\+|\-]([01][0-9]|2[0-3]):[0-5][0-9]))`) + + for _, pattern := range []*regexp.Regexp{serialPattern, rfc3339Pattern} { + s = pattern.ReplaceAll(s, []byte("redacted")) + } + return s +} diff --git a/syft/presenter/cyclonedx/test-fixtures/image-simple/Dockerfile b/syft/presenter/cyclonedx/test-fixtures/image-simple/Dockerfile new file mode 100644 index 000000000..62fb151e4 --- /dev/null +++ b/syft/presenter/cyclonedx/test-fixtures/image-simple/Dockerfile @@ -0,0 +1,6 @@ +# Note: changes to this file will result in updating several test values. Consider making a new image fixture instead of editing this one. +FROM scratch +ADD file-1.txt /somefile-1.txt +ADD file-2.txt /somefile-2.txt +# note: adding a directory will behave differently on docker engine v18 vs v19 +ADD target / diff --git a/syft/presenter/cyclonedx/test-fixtures/image-simple/file-1.txt b/syft/presenter/cyclonedx/test-fixtures/image-simple/file-1.txt new file mode 100644 index 000000000..985d3408e --- /dev/null +++ b/syft/presenter/cyclonedx/test-fixtures/image-simple/file-1.txt @@ -0,0 +1 @@ +this file has contents \ No newline at end of file diff --git a/syft/presenter/cyclonedx/test-fixtures/image-simple/file-2.txt b/syft/presenter/cyclonedx/test-fixtures/image-simple/file-2.txt new file mode 100644 index 000000000..396d08bbc --- /dev/null +++ b/syft/presenter/cyclonedx/test-fixtures/image-simple/file-2.txt @@ -0,0 +1 @@ +file-2 contents! \ No newline at end of file diff --git a/syft/presenter/cyclonedx/test-fixtures/image-simple/target/really/nested/file-3.txt b/syft/presenter/cyclonedx/test-fixtures/image-simple/target/really/nested/file-3.txt new file mode 100644 index 000000000..f85472c93 --- /dev/null +++ b/syft/presenter/cyclonedx/test-fixtures/image-simple/target/really/nested/file-3.txt @@ -0,0 +1,2 @@ +another file! +with lines... \ No newline at end of file diff --git a/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxDirsPresenter.golden b/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxDirsPresenter.golden new file mode 100644 index 000000000..e238f8626 --- /dev/null +++ b/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxDirsPresenter.golden @@ -0,0 +1,33 @@ + + + + + package-1 + 1.0.1 + + + package-2 + 2.0.1 + + + MIT + + + Apache-v2 + + + + + + 2020-08-24T17:37:37-04:00 + + anchore + syft + [not provided] + + + /some/path + + + + diff --git a/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxImgsPresenter.golden b/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxImgsPresenter.golden new file mode 100644 index 000000000..875d40546 --- /dev/null +++ b/syft/presenter/cyclonedx/test-fixtures/snapshot/TestCycloneDxImgsPresenter.golden @@ -0,0 +1,33 @@ + + + + + package-1 + 1.0.1 + + + package-2 + 2.0.1 + + + MIT + + + Apache-v2 + + + + + + 2020-08-24T17:37:37-04:00 + + anchore + syft + [not provided] + + + index.docker.io/library/anchore-fixture-image-simple + 04e16e44161c8888a1a963720fd0443cbf7eef8101434c431de8725cd98cc9f7 + + + diff --git a/syft/presenter/option.go b/syft/presenter/option.go index daa0f6275..fe9b125d9 100644 --- a/syft/presenter/option.go +++ b/syft/presenter/option.go @@ -3,44 +3,33 @@ package presenter import "strings" const ( - UnknownPresenter Option = iota - JSONPresenter - TextPresenter - TablePresenter + UnknownPresenter Option = "UnknownPresenter" + JSONPresenter Option = "json" + TextPresenter Option = "text" + TablePresenter Option = "table" + CycloneDxPresenter Option = "cyclonedx" ) -var optionStr = []string{ - "UnknownPresenter", - "json", - "text", - "table", -} - var Options = []Option{ JSONPresenter, TextPresenter, TablePresenter, + CycloneDxPresenter, } -type Option int +type Option string func ParseOption(userStr string) Option { switch strings.ToLower(userStr) { - case strings.ToLower(JSONPresenter.String()): + case string(JSONPresenter): return JSONPresenter - case strings.ToLower(TextPresenter.String()): + case string(TextPresenter): return TextPresenter - case strings.ToLower(TablePresenter.String()): + case string(TablePresenter): return TablePresenter + case string(CycloneDxPresenter), "cyclone", "cyclone-dx": + return CycloneDxPresenter default: return UnknownPresenter } } - -func (o Option) String() string { - if int(o) >= len(optionStr) || o < 0 { - return optionStr[0] - } - - return optionStr[o] -} diff --git a/syft/presenter/presenter.go b/syft/presenter/presenter.go index bf37a21ae..1807cd351 100644 --- a/syft/presenter/presenter.go +++ b/syft/presenter/presenter.go @@ -7,6 +7,8 @@ package presenter import ( "io" + "github.com/anchore/syft/syft/presenter/cyclonedx" + "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/presenter/json" "github.com/anchore/syft/syft/presenter/table" @@ -29,6 +31,8 @@ func GetPresenter(option Option, s scope.Scope, catalog *pkg.Catalog) Presenter return text.NewPresenter(catalog, s) case TablePresenter: return table.NewPresenter(catalog, s) + case CycloneDxPresenter: + return cyclonedx.NewPresenter(catalog, s) default: return nil }