migrate syft/presenter to internal/presenter

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
Alex Goodman
2021-03-22 10:52:33 -04:00
parent 4666ca8469
commit ff4ed40d50
59 changed files with 641 additions and 699 deletions
@@ -0,0 +1,69 @@
package packages
import (
"encoding/xml"
"time"
"github.com/anchore/syft/syft/source"
)
// Source: https://cyclonedx.org/ext/bom-descriptor/
// CycloneDxBomDescriptor represents all metadata surrounding the BOM report (such as when the BOM was made, with which tool, and the item being cataloged).
type CycloneDxBomDescriptor struct {
XMLName xml.Name `xml:"metadata"`
Timestamp string `xml:"timestamp,omitempty"` // The date and time (timestamp) when the document was created
Tools []CycloneDxBdTool `xml:"tools>tool"` // The tool used to create the BOM.
Component *CycloneDxBdComponent `xml:"component"` // The component that the BOM describes.
}
// CycloneDxBdTool represents the tool that created the BOM report.
type CycloneDxBdTool struct {
XMLName xml.Name `xml:"tool"`
Vendor string `xml:"vendor,omitempty"` // The vendor of the tool used to create the BOM.
Name string `xml:"name,omitempty"` // The name of the tool used to create the BOM.
Version string `xml:"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
}
// CycloneDxBdComponent represents the software/package being cataloged.
type CycloneDxBdComponent struct {
XMLName xml.Name `xml:"component"`
CycloneDxComponent
}
// NewCycloneDxBomDescriptor returns a new CycloneDxBomDescriptor tailored for the current time and "syft" tool details.
func NewCycloneDxBomDescriptor(name, version string, srcMetadata source.Metadata) *CycloneDxBomDescriptor {
descriptor := CycloneDxBomDescriptor{
XMLName: xml.Name{},
Timestamp: time.Now().Format(time.RFC3339),
Tools: []CycloneDxBdTool{
{
Vendor: "anchore",
Name: name,
Version: version,
},
},
}
switch srcMetadata.Scheme {
case source.ImageScheme:
descriptor.Component = &CycloneDxBdComponent{
CycloneDxComponent: CycloneDxComponent{
Type: "container",
Name: srcMetadata.ImageMetadata.UserInput,
Version: srcMetadata.ImageMetadata.ManifestDigest,
},
}
case source.DirectoryScheme:
descriptor.Component = &CycloneDxBdComponent{
CycloneDxComponent: CycloneDxComponent{
Type: "file",
Name: srcMetadata.Path,
},
}
}
return &descriptor
}
@@ -0,0 +1,27 @@
package packages
import "encoding/xml"
// CycloneDxComponent represents a single element in the CycloneDX BOM
type CycloneDxComponent 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 *[]CycloneDxLicense `xml:"licenses>license"` // A node describing zero or more license names, SPDX license IDs or expressions
PackageURL string `xml:"purl,omitempty"` // Specifies the package-url (PackageURL). The purl, if specified, must be valid and conform to the specification defined at: https://github.com/package-url/purl-spec
// TODO: source, hashes, copyright, cpe, purl, swid, modified, pedigree, externalReferences
// TODO: add user-defined parameters for syft-specific values (image layer index, cataloger, location path, etc.)
}
// CycloneDxLicense represents a single software license for a CycloneDxComponent
type CycloneDxLicense 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
}
@@ -0,0 +1,57 @@
package packages
import (
"encoding/xml"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/version"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
"github.com/google/uuid"
)
// Source: https://github.com/CycloneDX/specification
// CycloneDxDocument represents a CycloneDX BOM CycloneDxDocument.
type CycloneDxDocument struct {
XMLName xml.Name `xml:"bom"`
XMLNs string `xml:"xmlns,attr"`
Version int `xml:"version,attr"`
SerialNumber string `xml:"serialNumber,attr"`
BomDescriptor *CycloneDxBomDescriptor `xml:"metadata"` // The BOM descriptor extension
Components []CycloneDxComponent `xml:"components>component"` // The BOM contents
}
// NewCycloneDxDocument returns a CycloneDX CycloneDxDocument object populated with the catalog contents.
func NewCycloneDxDocument(catalog *pkg.Catalog, srcMetadata source.Metadata) CycloneDxDocument {
versionInfo := version.FromBuild()
doc := CycloneDxDocument{
XMLNs: "http://cyclonedx.org/schema/bom/1.2",
Version: 1,
SerialNumber: uuid.New().URN(),
BomDescriptor: NewCycloneDxBomDescriptor(internal.ApplicationName, versionInfo.Version, srcMetadata),
}
// attach components
for p := range catalog.Enumerate() {
component := CycloneDxComponent{
Type: "library", // TODO: this is not accurate
Name: p.Name,
Version: p.Version,
PackageURL: p.PURL,
}
var licenses []CycloneDxLicense
for _, licenseName := range p.Licenses {
licenses = append(licenses, CycloneDxLicense{
Name: licenseName,
})
}
if len(licenses) > 0 {
component.Licenses = &licenses
}
doc.Components = append(doc.Components, component)
}
return doc
}
@@ -0,0 +1,47 @@
/*
Package cyclonedx is responsible for generating a CycloneDX XML report for the given container image or file system.
*/
package packages
import (
"encoding/xml"
"io"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
)
// CycloneDxPresenter writes a CycloneDX report from the given Catalog and Locations contents
type CycloneDxPresenter struct {
catalog *pkg.Catalog
srcMetadata source.Metadata
}
// NewCycloneDxPresenter creates a CycloneDX presenter from the given Catalog and Locations objects.
func NewCycloneDxPresenter(catalog *pkg.Catalog, srcMetadata source.Metadata) *CycloneDxPresenter {
return &CycloneDxPresenter{
catalog: catalog,
srcMetadata: srcMetadata,
}
}
// Present writes the CycloneDX report to the given io.Writer.
func (pres *CycloneDxPresenter) Present(output io.Writer) error {
bom := NewCycloneDxDocument(pres.catalog, pres.srcMetadata)
encoder := xml.NewEncoder(output)
encoder.Indent("", " ")
_, err := output.Write([]byte(xml.Header))
if err != nil {
return err
}
err = encoder.Encode(bom)
if err != nil {
return err
}
_, err = output.Write([]byte("\n"))
return err
}
@@ -0,0 +1,174 @@
package packages
import (
"bytes"
"flag"
"regexp"
"testing"
"github.com/anchore/stereoscope/pkg/filetree"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/go-testutils"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
"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: "package1",
Version: "1.0.1",
Type: pkg.DebPkg,
FoundBy: "the-cataloger-1",
Locations: []source.Location{
{RealPath: "/some/path/pkg1"},
},
Metadata: pkg.DpkgMetadata{
Package: "package1",
Version: "1.0.1",
Architecture: "amd64",
},
})
catalog.Add(pkg.Package{
Name: "package2",
Version: "2.0.1",
Type: pkg.DebPkg,
FoundBy: "the-cataloger-2",
Locations: []source.Location{
{RealPath: "/some/path/pkg1"},
},
Licenses: []string{
"MIT",
"Apache-v2",
},
Metadata: pkg.DpkgMetadata{
Package: "package2",
Version: "1.0.2",
Architecture: "amd64",
},
})
s, err := source.NewFromDirectory("/some/path")
if err != nil {
t.Fatal(err)
}
pres := NewCycloneDxPresenter(catalog, s.Metadata)
// 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 := imagetest.GetFixtureImage(t, "docker-archive", "image-simple")
_, ref1, _ := img.SquashedTree().File("/somefile-1.txt", filetree.FollowBasenameLinks)
_, ref2, _ := img.SquashedTree().File("/somefile-2.txt", filetree.FollowBasenameLinks)
// populate catalog with test data
catalog.Add(pkg.Package{
Name: "package1",
Version: "1.0.1",
Locations: []source.Location{
source.NewLocationFromImage(string(ref1.RealPath), *ref1, img),
},
Type: pkg.RpmPkg,
FoundBy: "the-cataloger-1",
PURL: "the-purl-1",
})
catalog.Add(pkg.Package{
Name: "package2",
Version: "2.0.1",
Locations: []source.Location{
source.NewLocationFromImage(string(ref2.RealPath), *ref2, img),
},
Type: pkg.RpmPkg,
FoundBy: "the-cataloger-2",
Licenses: []string{
"MIT",
"Apache-v2",
},
PURL: "the-purl-2",
})
s, err := source.NewFromImage(img, "user-image-input")
if err != nil {
t.Fatal(err)
}
// This accounts for the non-deterministic digest value that we end up with when
// we build a container image dynamically during testing. Ultimately, we should
// use a golden image as a test fixture in place of building this image during
// testing. At that time, this line will no longer be necessary.
//
// This value is sourced from the "version" node in "./test-fixtures/snapshot/TestCycloneDxImgsPresenter.golden"
s.Metadata.ImageMetadata.ManifestDigest = "sha256:2731251dc34951c0e50fcc643b4c5f74922dad1a5d98f302b504cf46cd5d9368"
pres := NewCycloneDxPresenter(catalog, s.Metadata)
// 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(expected), string(actual), 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,23 @@
package packages
import "github.com/anchore/syft/syft/distro"
// JSONDistribution provides information about a detected Linux JSONDistribution.
type JSONDistribution struct {
Name string `json:"name"` // Name of the Linux distribution
Version string `json:"version"` // Version of the Linux distribution (major or major.minor version)
IDLike string `json:"idLike"` // the ID_LIKE field found within the /etc/os-release file
}
// NewJSONDistribution creates a struct with the Linux distribution to be represented in JSON.
func NewJSONDistribution(d *distro.Distro) JSONDistribution {
if d == nil {
return JSONDistribution{}
}
return JSONDistribution{
Name: d.Name(),
Version: d.FullVersion(),
IDLike: d.IDLike,
}
}
@@ -0,0 +1,62 @@
package packages
import (
"fmt"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/version"
"github.com/anchore/syft/syft/distro"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
)
// JSONDocument represents the syft cataloging findings as a JSON document
type JSONDocument struct {
Artifacts []JSONPackage `json:"artifacts"` // Artifacts is the list of packages discovered and placed into the catalog
ArtifactRelationships []JSONRelationship `json:"artifactRelationships"`
Source JSONSource `json:"source"` // Source represents the original object that was cataloged
Distro JSONDistribution `json:"distro"` // Distro represents the Linux distribution that was detected from the source
Descriptor JSONDescriptor `json:"descriptor"` // Descriptor is a block containing self-describing information about syft
Schema JSONSchema `json:"schema"` // Schema is a block reserved for defining the version for the shape of this JSON document and where to find the schema document to validate the shape
}
// NewJSONDocument creates and populates a new JSON document struct from the given cataloging results.
func NewJSONDocument(catalog *pkg.Catalog, srcMetadata source.Metadata, d *distro.Distro, scope source.Scope, configuration interface{}) (JSONDocument, error) {
src, err := NewJSONSource(srcMetadata, scope)
if err != nil {
return JSONDocument{}, err
}
artifacts, err := NewJSONPackages(catalog)
if err != nil {
return JSONDocument{}, err
}
return JSONDocument{
Artifacts: artifacts,
ArtifactRelationships: newJSONRelationships(pkg.NewRelationships(catalog)),
Source: src,
Distro: NewJSONDistribution(d),
Descriptor: JSONDescriptor{
Name: internal.ApplicationName,
Version: version.FromBuild().Version,
Configuration: configuration,
},
Schema: JSONSchema{
Version: internal.JSONSchemaVersion,
URL: fmt.Sprintf("https://raw.githubusercontent.com/anchore/syft/main/schema/json/schema-%s.json", internal.JSONSchemaVersion),
},
}, nil
}
// JSONDescriptor describes what created the document as well as surrounding metadata
type JSONDescriptor struct {
Name string `json:"name"`
Version string `json:"version"`
Configuration interface{} `json:"configuration,omitempty"`
}
type JSONSchema struct {
Version string `json:"version"`
URL string `json:"url"`
}
@@ -0,0 +1,68 @@
package packages
import (
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
)
// JSONPackage represents a pkg.Package object specialized for JSON marshaling and unmarshaling.
type JSONPackage struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
FoundBy string `json:"foundBy"`
Locations []source.Location `json:"locations"`
Licenses []string `json:"licenses"`
Language string `json:"language"`
CPEs []string `json:"cpes"`
PURL string `json:"purl"`
MetadataType string `json:"metadataType"`
Metadata interface{} `json:"metadata"`
}
func NewJSONPackages(catalog *pkg.Catalog) ([]JSONPackage, error) {
artifacts := make([]JSONPackage, 0)
for _, p := range catalog.Sorted() {
art, err := NewJSONPackage(p)
if err != nil {
return nil, err
}
artifacts = append(artifacts, art)
}
return artifacts, nil
}
// NewJSONPackage crates a new JSONPackage from the given pkg.Package.
func NewJSONPackage(p *pkg.Package) (JSONPackage, error) {
var cpes = make([]string, len(p.CPEs))
for i, c := range p.CPEs {
cpes[i] = c.BindToFmtString()
}
// ensure collections are never nil for presentation reasons
var locations = make([]source.Location, 0)
if p.Locations != nil {
locations = p.Locations
}
var licenses = make([]string, 0)
if p.Licenses != nil {
licenses = p.Licenses
}
return JSONPackage{
ID: string(p.ID),
Name: p.Name,
Version: p.Version,
Type: string(p.Type),
FoundBy: p.FoundBy,
Locations: locations,
Licenses: licenses,
Language: string(p.Language),
CPEs: cpes,
PURL: p.PURL,
MetadataType: string(p.MetadataType),
Metadata: p.Metadata,
}, nil
}
@@ -0,0 +1,43 @@
package packages
import (
"encoding/json"
"io"
"github.com/anchore/syft/syft/distro"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
)
// JSONPresenter is a JSON presentation object for the syft results
type JSONPresenter struct {
catalog *pkg.Catalog
srcMetadata source.Metadata
distro *distro.Distro
scope source.Scope
}
// NewJSONPresenter creates a new JSON presenter object for the given cataloging results.
func NewJSONPresenter(catalog *pkg.Catalog, s source.Metadata, d *distro.Distro, scope source.Scope) *JSONPresenter {
return &JSONPresenter{
catalog: catalog,
srcMetadata: s,
distro: d,
scope: scope,
}
}
// Present the catalog results to the given writer.
func (pres *JSONPresenter) Present(output io.Writer) error {
// we do not pass in configuration for backwards compatibility
doc, err := NewJSONDocument(pres.catalog, pres.srcMetadata, pres.distro, pres.scope, nil)
if err != nil {
return err
}
enc := json.NewEncoder(output)
// prevent > and < from being escaped in the payload
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(&doc)
}
@@ -0,0 +1,183 @@
package packages
import (
"bytes"
"flag"
"testing"
"github.com/anchore/stereoscope/pkg/filetree"
"github.com/anchore/go-testutils"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/syft/syft/distro"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
"github.com/sergi/go-diff/diffmatchpatch"
)
var updateJSONGoldenFiles = flag.Bool("update-json", false, "update the *.golden files for json presenters")
func must(c pkg.CPE, e error) pkg.CPE {
if e != nil {
panic(e)
}
return c
}
func TestJSONDirsPresenter(t *testing.T) {
var buffer bytes.Buffer
catalog := pkg.NewCatalog()
// populate catalog with test data
catalog.Add(pkg.Package{
ID: "package-1-id",
Name: "package-1",
Version: "1.0.1",
Type: pkg.PythonPkg,
FoundBy: "the-cataloger-1",
Locations: []source.Location{
{RealPath: "/some/path/pkg1"},
},
Language: pkg.Python,
MetadataType: pkg.PythonPackageMetadataType,
Licenses: []string{"MIT"},
Metadata: pkg.PythonPackageMetadata{
Name: "package-1",
Version: "1.0.1",
},
PURL: "a-purl-2",
CPEs: []pkg.CPE{
must(pkg.NewCPE("cpe:2.3:*:some:package:2:*:*:*:*:*:*:*")),
},
})
catalog.Add(pkg.Package{
ID: "package-2-id",
Name: "package-2",
Version: "2.0.1",
Type: pkg.DebPkg,
FoundBy: "the-cataloger-2",
Locations: []source.Location{
{RealPath: "/some/path/pkg1"},
},
MetadataType: pkg.DpkgMetadataType,
Metadata: pkg.DpkgMetadata{
Package: "package-2",
Version: "2.0.1",
},
PURL: "a-purl-2",
CPEs: []pkg.CPE{
must(pkg.NewCPE("cpe:2.3:*:some:package:2:*:*:*:*:*:*:*")),
},
})
var d *distro.Distro
s, err := source.NewFromDirectory("/some/path")
if err != nil {
t.Fatal(err)
}
pres := NewJSONPresenter(catalog, s.Metadata, d, source.SquashedScope)
// run presenter
err = pres.Present(&buffer)
if err != nil {
t.Fatal(err)
}
actual := buffer.Bytes()
if *updateJSONGoldenFiles {
testutils.UpdateGoldenFileContents(t, actual)
}
var expected = testutils.GetGoldenFileContents(t)
if !bytes.Equal(expected, actual) {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(string(expected), string(actual), true)
t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs))
}
}
func TestJSONImgsPresenter(t *testing.T) {
var buffer bytes.Buffer
testImage := "image-simple"
if *updateJSONGoldenFiles {
imagetest.UpdateGoldenFixtureImage(t, testImage)
}
catalog := pkg.NewCatalog()
img := imagetest.GetGoldenFixtureImage(t, testImage)
_, ref1, _ := img.SquashedTree().File("/somefile-1.txt", filetree.FollowBasenameLinks)
_, ref2, _ := img.SquashedTree().File("/somefile-2.txt", filetree.FollowBasenameLinks)
// populate catalog with test data
catalog.Add(pkg.Package{
ID: "package-1-id",
Name: "package-1",
Version: "1.0.1",
Locations: []source.Location{
source.NewLocationFromImage(string(ref1.RealPath), *ref1, img),
},
Type: pkg.PythonPkg,
FoundBy: "the-cataloger-1",
Language: pkg.Python,
MetadataType: pkg.PythonPackageMetadataType,
Licenses: []string{"MIT"},
Metadata: pkg.PythonPackageMetadata{
Name: "package-1",
Version: "1.0.1",
},
PURL: "a-purl-1",
CPEs: []pkg.CPE{
must(pkg.NewCPE("cpe:2.3:*:some:package:1:*:*:*:*:*:*:*")),
},
})
catalog.Add(pkg.Package{
ID: "package-2-id",
Name: "package-2",
Version: "2.0.1",
Locations: []source.Location{
source.NewLocationFromImage(string(ref2.RealPath), *ref2, img),
},
Type: pkg.DebPkg,
FoundBy: "the-cataloger-2",
MetadataType: pkg.DpkgMetadataType,
Metadata: pkg.DpkgMetadata{
Package: "package-2",
Version: "2.0.1",
},
PURL: "a-purl-2",
CPEs: []pkg.CPE{
must(pkg.NewCPE("cpe:2.3:*:some:package:2:*:*:*:*:*:*:*")),
},
})
// this is a hard coded value that is not given by the fixture helper and must be provided manually
img.Metadata.ManifestDigest = "sha256:2731251dc34951c0e50fcc643b4c5f74922dad1a5d98f302b504cf46cd5d9368"
s, err := source.NewFromImage(img, "user-image-input")
var d *distro.Distro
pres := NewJSONPresenter(catalog, s.Metadata, d, source.SquashedScope)
// run presenter
err = pres.Present(&buffer)
if err != nil {
t.Fatal(err)
}
actual := buffer.Bytes()
if *updateJSONGoldenFiles {
testutils.UpdateGoldenFileContents(t, actual)
}
var expected = testutils.GetGoldenFileContents(t)
if !bytes.Equal(expected, actual) {
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(string(expected), string(actual), true)
t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs))
}
}
@@ -0,0 +1,23 @@
package packages
import "github.com/anchore/syft/syft/pkg"
type JSONRelationship struct {
Parent string `json:"parent"`
Child string `json:"child"`
Type string `json:"type"`
Metadata interface{} `json:"metadata"`
}
func newJSONRelationships(relationships []pkg.Relationship) []JSONRelationship {
result := make([]JSONRelationship, len(relationships))
for i, r := range relationships {
result[i] = JSONRelationship{
Parent: string(r.Parent),
Child: string(r.Child),
Type: string(r.Type),
Metadata: r.Metadata,
}
}
return result
}
@@ -0,0 +1,39 @@
package packages
import (
"fmt"
"github.com/anchore/syft/syft/source"
)
// JSONSource object represents the thing that was cataloged
type JSONSource struct {
Type string `json:"type"`
Target interface{} `json:"target"`
}
type JSONImageSource struct {
source.ImageMetadata
Scope source.Scope `json:"scope"`
}
// NewJSONSource creates a new source object to be represented into JSON.
func NewJSONSource(src source.Metadata, scope source.Scope) (JSONSource, error) {
switch src.Scheme {
case source.ImageScheme:
return JSONSource{
Type: "image",
Target: JSONImageSource{
Scope: scope,
ImageMetadata: src.ImageMetadata,
},
}, nil
case source.DirectoryScheme:
return JSONSource{
Type: "directory",
Target: src.Path,
}, nil
default:
return JSONSource{}, fmt.Errorf("unsupported source: %q", src.Scheme)
}
}
+25
View File
@@ -0,0 +1,25 @@
/*
Defines a Presenter interface for displaying catalog results to an io.Writer as well as a helper utility to obtain
a specific Presenter implementation given user configuration.
*/
package packages
import (
"github.com/anchore/syft/internal/presenter"
)
// Presenter returns a presenter for images or directories
func Presenter(option PresenterOption, config PresenterConfig) presenter.Presenter {
switch option {
case JSONPresenterOption:
return NewJSONPresenter(config.Catalog, config.SourceMetadata, config.Distro, config.Scope)
case TextPresenterOption:
return NewTextPresenter(config.Catalog, config.SourceMetadata)
case TablePresenterOption:
return NewTablePresenter(config.Catalog)
case CycloneDxPresenterOption:
return NewCycloneDxPresenter(config.Catalog, config.SourceMetadata)
default:
return nil
}
}
@@ -0,0 +1,14 @@
package packages
import (
"github.com/anchore/syft/syft/distro"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
)
type PresenterConfig struct {
SourceMetadata source.Metadata
Catalog *pkg.Catalog
Distro *distro.Distro
Scope source.Scope
}
@@ -0,0 +1,35 @@
package packages
import "strings"
const (
UnknownPresenterOption PresenterOption = "UnknownPresenterOption"
JSONPresenterOption PresenterOption = "json"
TextPresenterOption PresenterOption = "text"
TablePresenterOption PresenterOption = "table"
CycloneDxPresenterOption PresenterOption = "cyclonedx"
)
var AllPresenters = []PresenterOption{
JSONPresenterOption,
TextPresenterOption,
TablePresenterOption,
CycloneDxPresenterOption,
}
type PresenterOption string
func ParsePresenterOption(userStr string) PresenterOption {
switch strings.ToLower(userStr) {
case string(JSONPresenterOption):
return JSONPresenterOption
case string(TextPresenterOption):
return TextPresenterOption
case string(TablePresenterOption):
return TablePresenterOption
case string(CycloneDxPresenterOption), "cyclone", "cyclone-dx":
return CycloneDxPresenterOption
default:
return UnknownPresenterOption
}
}
@@ -0,0 +1,90 @@
package packages
import (
"fmt"
"io"
"sort"
"strings"
"github.com/olekukonko/tablewriter"
"github.com/anchore/syft/syft/pkg"
)
type TablePresenter struct {
catalog *pkg.Catalog
}
func NewTablePresenter(catalog *pkg.Catalog) *TablePresenter {
return &TablePresenter{
catalog: catalog,
}
}
func (pres *TablePresenter) Present(output io.Writer) error {
rows := make([][]string, 0)
columns := []string{"Name", "Version", "Type"}
for p := range pres.catalog.Enumerate() {
row := []string{
p.Name,
p.Version,
string(p.Type),
}
rows = append(rows, row)
}
if len(rows) == 0 {
fmt.Fprintln(output, "No packages discovered")
return nil
}
// sort by name, version, then type
sort.SliceStable(rows, func(i, j int) bool {
for col := 0; col < len(columns); col++ {
if rows[i][col] != rows[j][col] {
return rows[i][col] < rows[j][col]
}
}
return false
})
rows = removeDuplicateRows(rows)
table := tablewriter.NewWriter(output)
table.SetHeader(columns)
table.SetHeaderLine(false)
table.SetBorder(false)
table.SetAutoWrapText(false)
table.SetAutoFormatHeaders(true)
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetColumnSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
table.AppendBulk(rows)
table.Render()
return nil
}
func removeDuplicateRows(items [][]string) [][]string {
seen := map[string][]string{}
// nolint:prealloc
var result [][]string
for _, v := range items {
key := strings.Join(v, "|")
if seen[key] != nil {
// dup!
continue
}
seen[key] = v
result = append(result, v)
}
return result
}
@@ -0,0 +1,99 @@
package packages
import (
"bytes"
"flag"
"testing"
"github.com/anchore/stereoscope/pkg/filetree"
"github.com/go-test/deep"
"github.com/anchore/go-testutils"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
"github.com/sergi/go-diff/diffmatchpatch"
)
var updateTablePresenterGoldenFiles = flag.Bool("update-table", false, "update the *.golden files for table presenters")
func TestTablePresenter(t *testing.T) {
var buffer bytes.Buffer
testImage := "image-simple"
catalog := pkg.NewCatalog()
img := imagetest.GetFixtureImage(t, "docker-archive", testImage)
_, ref1, _ := img.SquashedTree().File("/somefile-1.txt", filetree.FollowBasenameLinks)
_, ref2, _ := img.SquashedTree().File("/somefile-2.txt", filetree.FollowBasenameLinks)
// populate catalog with test data
catalog.Add(pkg.Package{
Name: "package-1",
Version: "1.0.1",
Locations: []source.Location{
source.NewLocationFromImage(string(ref1.RealPath), *ref1, img),
},
Type: pkg.DebPkg,
})
catalog.Add(pkg.Package{
Name: "package-2",
Version: "2.0.1",
Locations: []source.Location{
source.NewLocationFromImage(string(ref2.RealPath), *ref2, img),
},
Type: pkg.DebPkg,
})
pres := NewTablePresenter(catalog)
// run presenter
err := pres.Present(&buffer)
if err != nil {
t.Fatal(err)
}
actual := buffer.Bytes()
if *updateTablePresenterGoldenFiles {
testutils.UpdateGoldenFileContents(t, actual)
}
var expected = testutils.GetGoldenFileContents(t)
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 TestRemoveDuplicateRows(t *testing.T) {
data := [][]string{
{"1", "2", "3"},
{"a", "b", "c"},
{"1", "2", "3"},
{"a", "b", "c"},
{"1", "2", "3"},
{"4", "5", "6"},
{"1", "2", "1"},
}
expected := [][]string{
{"1", "2", "3"},
{"a", "b", "c"},
{"4", "5", "6"},
{"1", "2", "1"},
}
actual := removeDuplicateRows(data)
if diffs := deep.Equal(expected, actual); len(diffs) > 0 {
t.Errorf("found diffs!")
for _, d := range diffs {
t.Errorf(" diff: %+v", d)
}
}
}
@@ -0,0 +1,4 @@
# 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
@@ -0,0 +1 @@
this file has contents
@@ -0,0 +1 @@
file-2 contents!
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.2" version="1" serialNumber="urn:uuid:544b9f08-b39d-4d0c-8723-32609567e8ed">
<metadata>
<timestamp>2020-12-28T13:56:29-05:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>syft</name>
<version>[not provided]</version>
</tool>
</tools>
<component type="file">
<name>/some/path</name>
<version></version>
</component>
</metadata>
<components>
<component type="library">
<name>package1</name>
<version>1.0.1</version>
</component>
<component type="library">
<name>package2</name>
<version>2.0.1</version>
<licenses>
<license>
<name>MIT</name>
</license>
<license>
<name>Apache-v2</name>
</license>
</licenses>
</component>
</components>
</bom>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.2" version="1" serialNumber="urn:uuid:e1515688-2fd6-422a-acb2-97405a44cdfe">
<metadata>
<timestamp>2020-12-28T13:56:29-05:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>syft</name>
<version>[not provided]</version>
</tool>
</tools>
<component type="container">
<name>user-image-input</name>
<version>sha256:2731251dc34951c0e50fcc643b4c5f74922dad1a5d98f302b504cf46cd5d9368</version>
</component>
</metadata>
<components>
<component type="library">
<name>package1</name>
<version>1.0.1</version>
<purl>the-purl-1</purl>
</component>
<component type="library">
<name>package2</name>
<version>2.0.1</version>
<licenses>
<license>
<name>MIT</name>
</license>
<license>
<name>Apache-v2</name>
</license>
</licenses>
<purl>the-purl-2</purl>
</component>
</components>
</bom>
@@ -0,0 +1,3 @@
NAME VERSION TYPE
package-1 1.0.1 deb
package-2 2.0.1 deb
@@ -0,0 +1,11 @@
[Path: /some/path]
[package-1]
Version: 1.0.1
Type: deb
Found by:
[package-2]
Version: 2.0.1
Type: deb
Found by:
@@ -0,0 +1,21 @@
[Image]
Layer: 0
Digest: sha256:ad8ecdc058976c07e7e347cb89fa9ad86a294b5ceaae6d09713fb035f84115abf3c4a2388a4af3aa60f13b94f4c6846930bdf53
Size: 22
MediaType: application/vnd.docker.image.rootfs.diff.tar.gzip
Layer: 1
Digest: sha256:ad8ecdc058976c07e7e347cb89fa9ad86a294b5ceaae6d09713fb035f84115abf3c4a2388a4af3aa60f13b94f4c6846930bdf53
Size: 16
MediaType: application/vnd.docker.image.rootfs.diff.tar.gzip
[package-1]
Version: 1.0.1
Type: deb
Found by: dpkg
[package-2]
Version: 2.0.1
Type: deb
Found by: dpkg
@@ -0,0 +1,11 @@
[Path: /some/path]
[package-1]
Version: 1.0.1
Type: deb
Found by:
[package-2]
Version: 2.0.1
Type: deb
Found by:
@@ -0,0 +1,69 @@
package packages
import (
"fmt"
"io"
"text/tabwriter"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
)
// TextPresenter is a human-friendly text presenter to represent package and source data.
type TextPresenter struct {
catalog *pkg.Catalog
srcMetadata source.Metadata
}
// NewTextPresenter creates a new presenter for the given set of catalog and image data.
func NewTextPresenter(catalog *pkg.Catalog, srcMetadata source.Metadata) *TextPresenter {
return &TextPresenter{
catalog: catalog,
srcMetadata: srcMetadata,
}
}
// Present is a method that is in charge of writing to an output buffer
func (pres *TextPresenter) Present(output io.Writer) error {
// init the tabular writer
w := new(tabwriter.Writer)
w.Init(output, 0, 8, 0, '\t', tabwriter.AlignRight)
switch pres.srcMetadata.Scheme {
case source.DirectoryScheme:
fmt.Fprintln(w, fmt.Sprintf("[Path: %s]", pres.srcMetadata.Path))
case source.ImageScheme:
fmt.Fprintln(w, "[Image]")
for idx, l := range pres.srcMetadata.ImageMetadata.Layers {
fmt.Fprintln(w, " Layer:\t", idx)
fmt.Fprintln(w, " Digest:\t", l.Digest)
fmt.Fprintln(w, " Size:\t", l.Size)
fmt.Fprintln(w, " MediaType:\t", l.MediaType)
fmt.Fprintln(w)
w.Flush()
}
default:
return fmt.Errorf("unsupported source: %T", pres.srcMetadata.Scheme)
}
// populate artifacts...
rows := 0
for _, p := range pres.catalog.Sorted() {
fmt.Fprintln(w, fmt.Sprintf("[%s]", p.Name))
fmt.Fprintln(w, " Version:\t", p.Version)
fmt.Fprintln(w, " Type:\t", string(p.Type))
fmt.Fprintln(w, " Found by:\t", p.FoundBy)
fmt.Fprintln(w)
w.Flush()
rows++
}
if rows == 0 {
fmt.Fprintln(output, "No packages discovered")
return nil
}
return nil
}
@@ -0,0 +1,127 @@
package packages
import (
"bytes"
"flag"
"testing"
"github.com/anchore/stereoscope/pkg/filetree"
"github.com/anchore/go-testutils"
"github.com/anchore/stereoscope/pkg/imagetest"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
"github.com/sergi/go-diff/diffmatchpatch"
)
var updateTextPresenterGoldenFiles = flag.Bool("update-text", false, "update the *.golden files for text presenters")
func TestTextDirPresenter(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,
})
catalog.Add(pkg.Package{
Name: "package-2",
Version: "2.0.1",
Type: pkg.DebPkg,
})
s, err := source.NewFromDirectory("/some/path")
if err != nil {
t.Fatalf("unable to create source: %+v", err)
}
pres := NewTextPresenter(catalog, s.Metadata)
// run presenter
err = pres.Present(&buffer)
if err != nil {
t.Fatal(err)
}
actual := buffer.Bytes()
if *updateTextPresenterGoldenFiles {
testutils.UpdateGoldenFileContents(t, actual)
}
var expected = testutils.GetGoldenFileContents(t)
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))
}
}
type PackageInfo struct {
Name string
Version string
}
func TestTextImgPresenter(t *testing.T) {
var buffer bytes.Buffer
catalog := pkg.NewCatalog()
img := imagetest.GetFixtureImage(t, "docker-archive", "image-simple")
_, ref1, _ := img.SquashedTree().File("/somefile-1.txt", filetree.FollowBasenameLinks)
_, ref2, _ := img.SquashedTree().File("/somefile-2.txt", filetree.FollowBasenameLinks)
// populate catalog with test data
catalog.Add(pkg.Package{
Name: "package-1",
Version: "1.0.1",
Locations: []source.Location{
source.NewLocationFromImage(string(ref1.RealPath), *ref1, img),
},
FoundBy: "dpkg",
Type: pkg.DebPkg,
})
catalog.Add(pkg.Package{
Name: "package-2",
Version: "2.0.1",
Locations: []source.Location{
source.NewLocationFromImage(string(ref2.RealPath), *ref2, img),
},
FoundBy: "dpkg",
Metadata: PackageInfo{Name: "package-2", Version: "1.0.2"},
Type: pkg.DebPkg,
})
// stub out all the digests so that they don't affect tests comparisons
// TODO: update with stereoscope test utils feature when this issue is resolved: https://github.com/anchore/stereoscope/issues/43
for _, l := range img.Layers {
l.Metadata.Digest = "sha256:ad8ecdc058976c07e7e347cb89fa9ad86a294b5ceaae6d09713fb035f84115abf3c4a2388a4af3aa60f13b94f4c6846930bdf53"
}
s, err := source.NewFromImage(img, "user-image-input")
if err != nil {
t.Fatal(err)
}
pres := NewTextPresenter(catalog, s.Metadata)
// run presenter
err = pres.Present(&buffer)
if err != nil {
t.Fatal(err)
}
actual := buffer.Bytes()
if *updateTextPresenterGoldenFiles {
testutils.UpdateGoldenFileContents(t, actual)
}
var expected = testutils.GetGoldenFileContents(t)
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))
}
}
@@ -0,0 +1,32 @@
package poweruser
import (
"github.com/anchore/syft/internal/presenter/packages"
)
type JSONDocument struct {
// note: poweruser.JSONDocument is meant to always be a superset of packages.JSONDocument, any additional fields
// here should be optional by supplying "omitempty" on these fields hint to the jsonschema generator to not
// require these fields. As an accepted rule in this repo all collections should still be initialized in the
// context of being used in a JSON document.
FileMetadata []JSONFileMetadata `json:"fileMetadata,omitempty"`
packages.JSONDocument
}
// NewJSONDocument creates and populates a new JSON document struct from the given cataloging results.
func NewJSONDocument(config JSONDocumentConfig) (JSONDocument, error) {
pkgsDoc, err := packages.NewJSONDocument(config.PackageCatalog, config.SourceMetadata, config.Distro, config.ApplicationConfig.Packages.ScopeOpt, config.ApplicationConfig)
if err != nil {
return JSONDocument{}, err
}
fileMetadata, err := NewJSONFileMetadata(config.FileMetadata, config.FileDigests)
if err != nil {
return JSONDocument{}, err
}
return JSONDocument{
FileMetadata: fileMetadata,
JSONDocument: pkgsDoc,
}, nil
}
@@ -0,0 +1,18 @@
package poweruser
import (
"github.com/anchore/syft/internal/config"
"github.com/anchore/syft/syft/distro"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/source"
)
type JSONDocumentConfig struct {
ApplicationConfig config.Application
PackageCatalog *pkg.Catalog
FileMetadata map[source.Location]source.FileMetadata
FileDigests map[source.Location][]file.Digest
Distro *distro.Distro
SourceMetadata source.Metadata
}
@@ -0,0 +1,50 @@
package poweruser
import (
"fmt"
"strconv"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/source"
)
type JSONFileMetadata struct {
Location source.Location `json:"location"`
Metadata JSONFileMetadataEntry `json:"metadata"`
}
type JSONFileMetadataEntry struct {
Mode int `json:"mode"`
Type source.FileType `json:"type"`
UserID int `json:"userID"`
GroupID int `json:"groupID"`
Digests []file.Digest `json:"digests"`
}
func NewJSONFileMetadata(data map[source.Location]source.FileMetadata, digests map[source.Location][]file.Digest) ([]JSONFileMetadata, error) {
results := make([]JSONFileMetadata, 0)
for location, metadata := range data {
mode, err := strconv.Atoi(fmt.Sprintf("%o", metadata.Mode))
if err != nil {
return nil, fmt.Errorf("invalid mode found in file catalog @ location=%+v mode=%q: %w", location, metadata.Mode, err)
}
digestResults := make([]file.Digest, 0)
if digestsForLocation, exists := digests[location]; exists {
digestResults = digestsForLocation
}
results = append(results, JSONFileMetadata{
Location: location,
Metadata: JSONFileMetadataEntry{
Mode: mode,
Type: metadata.Type,
UserID: metadata.UserID,
GroupID: metadata.GroupID,
Digests: digestResults,
},
})
}
return results, nil
}
@@ -0,0 +1,32 @@
package poweruser
import (
"encoding/json"
"io"
)
// JSONPresenter is a JSON presentation object for the syft results
type JSONPresenter struct {
config JSONDocumentConfig
}
// NewJSONPresenter creates a new JSON presenter object for the given cataloging results.
func NewJSONPresenter(config JSONDocumentConfig) *JSONPresenter {
return &JSONPresenter{
config: config,
}
}
// Present the PackageCatalog results to the given writer.
func (p *JSONPresenter) Present(output io.Writer) error {
doc, err := NewJSONDocument(p.config)
if err != nil {
return err
}
enc := json.NewEncoder(output)
// prevent > and < from being escaped in the payload
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(&doc)
}
+9
View File
@@ -0,0 +1,9 @@
package presenter
import "io"
// Presenter defines the expected behavior for an object responsible for displaying arbitrary input and processed data
// to a given io.Writer.
type Presenter interface {
Present(io.Writer) error
}