Add CycloneDX presenter (#157)

* add CycloneDX presenter + BOM Descriptor extension

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* add docstrings to cyclonedx presenter

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
Alex Goodman 2020-08-24 20:43:29 -04:00 committed by GitHub
parent 6b65cb6d7d
commit f892289e7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 450 additions and 25 deletions

View file

@ -253,4 +253,4 @@ clean-dist:
.PHONY: clean-json-schema-examples
clean-json-schema-examples:
rm json-schema/examples/*
rm -f json-schema/examples/*

View file

@ -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 {

1
go.mod
View file

@ -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

View file

@ -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,
},
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 /

View file

@ -0,0 +1 @@
this file has contents

View file

@ -0,0 +1 @@
file-2 contents!

View file

@ -0,0 +1,2 @@
another file!
with lines...

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.2" version="1" serialNumber="urn:uuid:1519f630-0721-40f5-8462-277e6534761d">
<components>
<component type="library">
<name>package-1</name>
<version>1.0.1</version>
</component>
<component type="library">
<name>package-2</name>
<version>2.0.1</version>
<licenses>
<license>
<name>MIT</name>
</license>
<license>
<name>Apache-v2</name>
</license>
</licenses>
</component>
</components>
<bd:metadata>
<bd:timestamp>2020-08-24T17:37:37-04:00</bd:timestamp>
<bd:tool>
<bd:vendor>anchore</bd:vendor>
<bd:name>syft</bd:name>
<bd:version>[not provided]</bd:version>
</bd:tool>
<bd:component type="file">
<name>/some/path</name>
<version></version>
</bd:component>
</bd:metadata>
</bom>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.2" version="1" serialNumber="urn:uuid:4448d650-a65e-49e4-b826-30d2010dfcd9">
<components>
<component type="library">
<name>package-1</name>
<version>1.0.1</version>
</component>
<component type="library">
<name>package-2</name>
<version>2.0.1</version>
<licenses>
<license>
<name>MIT</name>
</license>
<license>
<name>Apache-v2</name>
</license>
</licenses>
</component>
</components>
<bd:metadata>
<bd:timestamp>2020-08-24T17:37:37-04:00</bd:timestamp>
<bd:tool>
<bd:vendor>anchore</bd:vendor>
<bd:name>syft</bd:name>
<bd:version>[not provided]</bd:version>
</bd:tool>
<bd:component type="container">
<name>index.docker.io/library/anchore-fixture-image-simple</name>
<version>04e16e44161c8888a1a963720fd0443cbf7eef8101434c431de8725cd98cc9f7</version>
</bd:component>
</bd:metadata>
</bom>

View file

@ -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]
}

View file

@ -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
}