mirror of
https://github.com/anchore/syft
synced 2024-11-10 14:24:12 +00:00
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:
parent
6b65cb6d7d
commit
f892289e7c
16 changed files with 450 additions and 25 deletions
2
Makefile
2
Makefile
|
@ -253,4 +253,4 @@ clean-dist:
|
|||
|
||||
.PHONY: clean-json-schema-examples
|
||||
clean-json-schema-examples:
|
||||
rm json-schema/examples/*
|
||||
rm -f json-schema/examples/*
|
|
@ -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
1
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
|
||||
|
|
49
syft/presenter/cyclonedx/bom-extension.go
Normal file
49
syft/presenter/cyclonedx/bom-extension.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
26
syft/presenter/cyclonedx/component.go
Normal file
26
syft/presenter/cyclonedx/component.go
Normal 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
|
||||
}
|
55
syft/presenter/cyclonedx/document.go
Normal file
55
syft/presenter/cyclonedx/document.go
Normal 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
|
||||
}
|
81
syft/presenter/cyclonedx/presenter.go
Normal file
81
syft/presenter/cyclonedx/presenter.go
Normal 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
|
||||
}
|
144
syft/presenter/cyclonedx/presenter_test.go
Normal file
144
syft/presenter/cyclonedx/presenter_test.go
Normal 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
|
||||
}
|
|
@ -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 /
|
|
@ -0,0 +1 @@
|
|||
this file has contents
|
|
@ -0,0 +1 @@
|
|||
file-2 contents!
|
|
@ -0,0 +1,2 @@
|
|||
another file!
|
||||
with lines...
|
|
@ -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>
|
|
@ -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>
|
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue