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
}