Split the sbom.Format interface by encode and decode use cases (#2186)

* split up sbom.Format into encode and decode ops

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* update cmd pkg to inject format configs

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* bump cyclonedx schema to 1.5

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* redact image metadata from github encoder tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* add more testing around format decoder identify

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* add test case for format version options

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix cli tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix CLI test

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* [wip] - review comments

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* keep encoder creation out of post load function

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* keep decider and identify functions

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* add a few more doc comments

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* remove format encoder default function helpers

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* address PR feedback

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* move back to streaming based decode functions

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* with common convention for encoder constructors

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix tests and allow for encoders to be created from cli options

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix cli tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix linting

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* buffer reads from stdin to support seeking

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman 2023-10-25 09:43:06 -04:00 committed by GitHub
parent 7315f83f9d
commit 7392d607b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
254 changed files with 10450 additions and 2714 deletions

View file

@ -19,12 +19,10 @@ import (
"github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/event/monitor" "github.com/anchore/syft/syft/event/monitor"
"github.com/anchore/syft/syft/formats" "github.com/anchore/syft/syft/format/cyclonedxjson"
"github.com/anchore/syft/syft/formats/github" "github.com/anchore/syft/syft/format/spdxjson"
"github.com/anchore/syft/syft/formats/syftjson" "github.com/anchore/syft/syft/format/spdxtagvalue"
"github.com/anchore/syft/syft/formats/table" "github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/formats/template"
"github.com/anchore/syft/syft/formats/text"
"github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source"
) )
@ -37,30 +35,33 @@ const (
) )
type attestOptions struct { type attestOptions struct {
options.Config `yaml:",inline" mapstructure:",squash"` options.Config `yaml:",inline" mapstructure:",squash"`
options.SingleOutput `yaml:",inline" mapstructure:",squash"` options.Output `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"` options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
options.Catalog `yaml:",inline" mapstructure:",squash"` options.Catalog `yaml:",inline" mapstructure:",squash"`
options.Attest `yaml:",inline" mapstructure:",squash"` options.Attest `yaml:",inline" mapstructure:",squash"`
} }
func Attest(app clio.Application) *cobra.Command { func Attest(app clio.Application) *cobra.Command {
id := app.ID() id := app.ID()
var allowableOutputs []string
for _, f := range formats.AllIDs() {
switch f {
case table.ID, text.ID, github.ID, template.ID:
continue
}
allowableOutputs = append(allowableOutputs, f.String())
}
opts := &attestOptions{ opts := &attestOptions{
UpdateCheck: options.DefaultUpdateCheck(), UpdateCheck: options.DefaultUpdateCheck(),
SingleOutput: options.SingleOutput{ Output: options.Output{
AllowableOptions: allowableOutputs, AllowMultipleOutputs: false,
Output: syftjson.ID.String(), AllowableOptions: []string{
string(syftjson.ID),
string(cyclonedxjson.ID),
string(spdxjson.ID),
string(spdxtagvalue.ID),
},
Outputs: []string{syftjson.ID.String()},
OutputFile: options.OutputFile{ // nolint:staticcheck
Enabled: false, // explicitly not allowed
},
OutputTemplate: options.OutputTemplate{
Enabled: false, // explicitly not allowed
},
}, },
Catalog: options.DefaultCatalog(), Catalog: options.DefaultCatalog(),
} }
@ -95,15 +96,13 @@ func runAttest(id clio.Identification, opts *attestOptions, userInput string) er
return fmt.Errorf("unable to build SBOM: %w", err) return fmt.Errorf("unable to build SBOM: %w", err)
} }
o := opts.Output f, err := os.CreateTemp("", "syft-attest-")
f, err := os.CreateTemp("", o)
if err != nil { if err != nil {
return fmt.Errorf("unable to create temp file: %w", err) return fmt.Errorf("unable to create temp file: %w", err)
} }
defer os.Remove(f.Name()) defer os.Remove(f.Name())
writer, err := opts.SBOMWriter(f.Name()) writer, err := opts.SBOMWriter()
if err != nil { if err != nil {
return fmt.Errorf("unable to create SBOM writer: %w", err) return fmt.Errorf("unable to create SBOM writer: %w", err)
} }
@ -118,10 +117,21 @@ func runAttest(id clio.Identification, opts *attestOptions, userInput string) er
return fmt.Errorf("unable to find cosign in PATH; make sure you have it installed") return fmt.Errorf("unable to find cosign in PATH; make sure you have it installed")
} }
outputNames := opts.OutputNameSet()
var outputName string
switch outputNames.Size() {
case 0:
return fmt.Errorf("no output format specified")
case 1:
outputName = outputNames.List()[0]
default:
return fmt.Errorf("multiple output formats specified: %s", strings.Join(outputNames.List(), ", "))
}
// Select Cosign predicate type based on defined output type // Select Cosign predicate type based on defined output type
// As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go // As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go
var predicateType string var predicateType string
switch strings.ToLower(o) { switch strings.ToLower(outputName) {
case "cyclonedx-json": case "cyclonedx-json":
predicateType = "cyclonedx" predicateType = "cyclonedx"
case "spdx-tag-value", "spdx-tv": case "spdx-tag-value", "spdx-tv":

View file

@ -11,7 +11,7 @@ import (
"github.com/anchore/syft/cmd/syft/cli/options" "github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/internal" "github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/formats" "github.com/anchore/syft/syft/format"
) )
const ( const (
@ -23,7 +23,7 @@ const (
type ConvertOptions struct { type ConvertOptions struct {
options.Config `yaml:",inline" mapstructure:",squash"` options.Config `yaml:",inline" mapstructure:",squash"`
options.MultiOutput `yaml:",inline" mapstructure:",squash"` options.Output `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"` options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
} }
@ -33,6 +33,7 @@ func Convert(app clio.Application) *cobra.Command {
opts := &ConvertOptions{ opts := &ConvertOptions{
UpdateCheck: options.DefaultUpdateCheck(), UpdateCheck: options.DefaultUpdateCheck(),
Output: options.DefaultOutput(),
} }
return app.SetupCommand(&cobra.Command{ return app.SetupCommand(&cobra.Command{
@ -63,10 +64,13 @@ func RunConvert(opts *ConvertOptions, userInput string) error {
return err return err
} }
var reader io.ReadCloser var reader io.ReadSeekCloser
if userInput == "-" { if userInput == "-" {
reader = os.Stdin // though os.Stdin is an os.File, it does not support seeking
// you will get errors such as "seek /dev/stdin: illegal seek".
// We need to buffer what we read.
reader = internal.NewBufferedSeeker(os.Stdin)
} else { } else {
f, err := os.Open(userInput) f, err := os.Open(userInput)
if err != nil { if err != nil {
@ -78,7 +82,7 @@ func RunConvert(opts *ConvertOptions, userInput string) error {
reader = f reader = f
} }
s, _, err := formats.Decode(reader) s, _, _, err := format.Decode(reader)
if err != nil { if err != nil {
return fmt.Errorf("failed to decode SBOM: %w", err) return fmt.Errorf("failed to decode SBOM: %w", err)
} }

View file

@ -14,7 +14,6 @@ import (
"github.com/anchore/syft/internal/file" "github.com/anchore/syft/internal/file"
"github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/formats/template"
"github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source"
) )
@ -55,14 +54,14 @@ const (
type packagesOptions struct { type packagesOptions struct {
options.Config `yaml:",inline" mapstructure:",squash"` options.Config `yaml:",inline" mapstructure:",squash"`
options.MultiOutput `yaml:",inline" mapstructure:",squash"` options.Output `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"` options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
options.Catalog `yaml:",inline" mapstructure:",squash"` options.Catalog `yaml:",inline" mapstructure:",squash"`
} }
func defaultPackagesOptions() *packagesOptions { func defaultPackagesOptions() *packagesOptions {
return &packagesOptions{ return &packagesOptions{
MultiOutput: options.DefaultOutput(), Output: options.DefaultOutput(),
UpdateCheck: options.DefaultUpdateCheck(), UpdateCheck: options.DefaultUpdateCheck(),
Catalog: options.DefaultCatalog(), Catalog: options.DefaultCatalog(),
} }
@ -108,11 +107,6 @@ func validateArgs(cmd *cobra.Command, args []string, error string) error {
// nolint:funlen // nolint:funlen
func runPackages(id clio.Identification, opts *packagesOptions, userInput string) error { func runPackages(id clio.Identification, opts *packagesOptions, userInput string) error {
err := validatePackageOutputOptions(&opts.MultiOutput)
if err != nil {
return err
}
writer, err := opts.SBOMWriter() writer, err := opts.SBOMWriter()
if err != nil { if err != nil {
return err return err
@ -235,19 +229,3 @@ func mergeRelationships(cs ...<-chan artifact.Relationship) (relationships []art
return relationships return relationships
} }
func validatePackageOutputOptions(cfg *options.MultiOutput) error {
var usesTemplateOutput bool
for _, o := range cfg.Outputs {
if o == template.ID.String() {
usesTemplateOutput = true
break
}
}
if usesTemplateOutput && cfg.OutputTemplatePath == "" {
return fmt.Errorf(`must specify path to template file when using "template" output format`)
}
return nil
}

View file

@ -14,7 +14,7 @@ import (
"github.com/anchore/syft/cmd/syft/cli/options" "github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/internal" "github.com/anchore/syft/internal"
"github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/formats/syftjson" "github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source"
) )
@ -42,6 +42,9 @@ func PowerUser(app clio.Application) *cobra.Command {
pkgs.FileClassification.Cataloger.Enabled = true pkgs.FileClassification.Cataloger.Enabled = true
opts := &powerUserOptions{ opts := &powerUserOptions{
Catalog: pkgs, Catalog: pkgs,
OutputFile: options.OutputFile{ // nolint:staticcheck
Enabled: true,
},
} }
return app.SetupCommand(&cobra.Command{ return app.SetupCommand(&cobra.Command{
@ -62,7 +65,7 @@ func PowerUser(app clio.Application) *cobra.Command {
//nolint:funlen //nolint:funlen
func runPowerUser(id clio.Identification, opts *powerUserOptions, userInput string) error { func runPowerUser(id clio.Identification, opts *powerUserOptions, userInput string) error {
writer, err := opts.SBOMWriter(syftjson.Format()) writer, err := opts.SBOMWriter(syftjson.NewFormatEncoder())
if err != nil { if err != nil {
return err return err
} }

View file

@ -2,99 +2,232 @@ package options
import ( import (
"fmt" "fmt"
"slices" "sort"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/scylladb/go-set/strset"
"github.com/anchore/clio" "github.com/anchore/clio"
"github.com/anchore/fangs" "github.com/anchore/syft/syft/format/cyclonedxjson"
"github.com/anchore/syft/syft/formats" "github.com/anchore/syft/syft/format/cyclonedxxml"
"github.com/anchore/syft/syft/formats/table" "github.com/anchore/syft/syft/format/github"
"github.com/anchore/syft/syft/formats/template" "github.com/anchore/syft/syft/format/spdxjson"
"github.com/anchore/syft/syft/format/spdxtagvalue"
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/format/table"
"github.com/anchore/syft/syft/format/template"
"github.com/anchore/syft/syft/format/text"
"github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/sbom"
) )
// MultiOutput has the standard output options syft accepts: multiple -o, --file, --template
type MultiOutput struct {
Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output
OutputFile `yaml:",inline" json:"" mapstructure:",squash"`
OutputTemplatePath string `yaml:"output-template-path" json:"output-template-path" mapstructure:"output-template-path"` // -t template file to use for output
}
var _ interface {
clio.FlagAdder
} = (*MultiOutput)(nil)
func DefaultOutput() MultiOutput {
return MultiOutput{
Outputs: []string{string(table.ID)},
}
}
func (o *MultiOutput) AddFlags(flags clio.FlagSet) {
flags.StringArrayVarP(&o.Outputs, "output", "o",
fmt.Sprintf("report output format (<format>=<file> to output to a file), formats=%v", formats.AllIDs()))
flags.StringVarP(&o.OutputTemplatePath, "template", "t",
"specify the path to a Go template file")
}
func (o *MultiOutput) SBOMWriter() (sbom.Writer, error) {
return makeSBOMWriter(o.Outputs, o.File, o.OutputTemplatePath)
}
// SingleOutput allows only 1 output to be specified, with a user able to set what options are allowed by setting AllowableOptions
type SingleOutput struct {
AllowableOptions []string `yaml:"-" json:"-" mapstructure:"-"`
Output string `yaml:"output" json:"output" mapstructure:"output"`
OutputTemplatePath string `yaml:"output-template-path" json:"output-template-path" mapstructure:"output-template-path"` // -t template file to use for output
}
var _ clio.FlagAdder = (*SingleOutput)(nil)
func (o *SingleOutput) AddFlags(flags clio.FlagSet) {
flags.StringVarP(&o.Output, "output", "o",
fmt.Sprintf("report output format, options=%v", o.AllowableOptions))
if slices.Contains(o.AllowableOptions, template.ID.String()) {
flags.StringVarP(&o.OutputTemplatePath, "template", "t",
"specify the path to a Go template file")
}
}
func (o *SingleOutput) SBOMWriter(file string) (sbom.Writer, error) {
return makeSBOMWriter([]string{o.Output}, file, o.OutputTemplatePath)
}
// Deprecated: OutputFile is only the --file argument
type OutputFile struct {
File string `yaml:"file" json:"file" mapstructure:"file"` // --file, the file to write report output to
}
var _ interface { var _ interface {
clio.FlagAdder clio.FlagAdder
clio.PostLoader clio.PostLoader
} = (*OutputFile)(nil) } = (*Output)(nil)
func (o *OutputFile) AddFlags(flags clio.FlagSet) { // Output has the standard output options syft accepts: multiple -o, --file, --template
flags.StringVarP(&o.File, "file", "", type Output struct {
"file to write the default report output to (default is STDOUT)") AllowableOptions []string `yaml:"-" json:"-" mapstructure:"-"`
AllowMultipleOutputs bool `yaml:"-" json:"-" mapstructure:"-"`
Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output
OutputFile `yaml:",inline" json:"" mapstructure:",squash"`
OutputTemplate `yaml:"template" json:"template" mapstructure:"template"`
}
if pfp, ok := flags.(fangs.PFlagSetProvider); ok { func DefaultOutput() Output {
flagSet := pfp.PFlagSet() return Output{
flagSet.Lookup("file").Deprecated = "use: output" AllowMultipleOutputs: true,
Outputs: []string{string(table.ID)},
OutputFile: OutputFile{
Enabled: true,
},
OutputTemplate: OutputTemplate{
Enabled: true,
},
} }
} }
func (o *OutputFile) PostLoad() error { func (o *Output) AddFlags(flags clio.FlagSet) {
if o.File != "" { var names []string
file, err := expandFilePath(o.File) for _, id := range supportedIDs() {
names = append(names, id.String())
}
sort.Strings(names)
flags.StringArrayVarP(&o.Outputs, "output", "o",
fmt.Sprintf("report output format (<format>=<file> to output to a file), formats=%v", names))
}
func (o Output) SBOMWriter() (sbom.Writer, error) {
names := o.OutputNameSet()
if len(o.Outputs) > 1 && !o.AllowMultipleOutputs {
return nil, fmt.Errorf("only one output format is allowed (given %d: %s)", len(o.Outputs), names)
}
usesTemplateOutput := names.Has(string(template.ID))
if usesTemplateOutput && o.OutputTemplate.Path == "" {
return nil, fmt.Errorf(`must specify path to template file when using "template" output format`)
}
encoders, err := o.Encoders()
if err != nil {
return nil, err
}
return makeSBOMWriter(o.Outputs, o.File, encoders)
}
func (o *Output) Encoders() ([]sbom.FormatEncoder, error) {
// setup all encoders based on the configuration
var list encoderList
// in the future there will be application configuration options that can be used to set the default output format
list.addWithErr(template.ID)(o.OutputTemplate.formatEncoders())
list.add(syftjson.ID)(syftjson.NewFormatEncoder())
list.add(table.ID)(table.NewFormatEncoder())
list.add(text.ID)(text.NewFormatEncoder())
list.add(github.ID)(github.NewFormatEncoder())
list.addWithErr(cyclonedxxml.ID)(cycloneDxXMLEncoders())
list.addWithErr(cyclonedxjson.ID)(cycloneDxJSONEncoders())
list.addWithErr(spdxjson.ID)(spdxJSONEncoders())
list.addWithErr(spdxtagvalue.ID)(spdxTagValueEncoders())
return list.encoders, list.err
}
func (o Output) OutputNameSet() *strset.Set {
names := strset.New()
for _, output := range o.Outputs {
fields := strings.Split(output, "=")
names.Add(fields[0])
}
return names
}
type encoderList struct {
encoders []sbom.FormatEncoder
err error
}
func (l *encoderList) addWithErr(name sbom.FormatID) func([]sbom.FormatEncoder, error) {
return func(encs []sbom.FormatEncoder, err error) {
if err != nil { if err != nil {
return err l.err = multierror.Append(l.err, fmt.Errorf("unable to configure %q format encoder: %w", name, err))
return
}
for _, enc := range encs {
if enc == nil {
l.err = multierror.Append(l.err, fmt.Errorf("unable to configure %q format encoder: nil encoder returned", name))
continue
}
l.encoders = append(l.encoders, enc)
} }
o.File = file
} }
return nil
} }
func (o *OutputFile) SBOMWriter(f sbom.Format) (sbom.Writer, error) { func (l *encoderList) add(name sbom.FormatID) func(...sbom.FormatEncoder) {
return makeSBOMWriterForFormat(f, o.File) return func(encs ...sbom.FormatEncoder) {
for _, enc := range encs {
if enc == nil {
l.err = multierror.Append(l.err, fmt.Errorf("unable to configure %q format encoder: nil encoder returned", name))
continue
}
l.encoders = append(l.encoders, enc)
}
}
}
// TODO: when application configuration is made for this format then this should be ported to the options object
// that is created for that configuration (as done with the template output option)
func cycloneDxXMLEncoders() ([]sbom.FormatEncoder, error) {
var (
encs []sbom.FormatEncoder
errs error
)
for _, v := range cyclonedxxml.SupportedVersions() {
enc, err := cyclonedxxml.NewFormatEncoderWithConfig(cyclonedxxml.EncoderConfig{Version: v})
if err != nil {
errs = multierror.Append(errs, err)
} else {
encs = append(encs, enc)
}
}
return encs, errs
}
// TODO: when application configuration is made for this format then this should be ported to the options object
// that is created for that configuration (as done with the template output option)
func cycloneDxJSONEncoders() ([]sbom.FormatEncoder, error) {
var (
encs []sbom.FormatEncoder
errs error
)
for _, v := range cyclonedxjson.SupportedVersions() {
enc, err := cyclonedxjson.NewFormatEncoderWithConfig(cyclonedxjson.EncoderConfig{Version: v})
if err != nil {
errs = multierror.Append(errs, err)
} else {
encs = append(encs, enc)
}
}
return encs, errs
}
// TODO: when application configuration is made for this format then this should be ported to the options object
// that is created for that configuration (as done with the template output option)
func spdxJSONEncoders() ([]sbom.FormatEncoder, error) {
var (
encs []sbom.FormatEncoder
errs error
)
for _, v := range spdxjson.SupportedVersions() {
enc, err := spdxjson.NewFormatEncoderWithConfig(spdxjson.EncoderConfig{Version: v})
if err != nil {
errs = multierror.Append(errs, err)
} else {
encs = append(encs, enc)
}
}
return encs, errs
}
// TODO: when application configuration is made for this format then this should be ported to the options object
// that is created for that configuration (as done with the template output option)
func spdxTagValueEncoders() ([]sbom.FormatEncoder, error) {
var (
encs []sbom.FormatEncoder
errs error
)
for _, v := range spdxtagvalue.SupportedVersions() {
enc, err := spdxtagvalue.NewFormatEncoderWithConfig(spdxtagvalue.EncoderConfig{Version: v})
if err != nil {
errs = multierror.Append(errs, err)
} else {
encs = append(encs, enc)
}
}
return encs, errs
}
func supportedIDs() []sbom.FormatID {
encs := []sbom.FormatID{
// encoders that support a single version
syftjson.ID,
github.ID,
table.ID,
text.ID,
template.ID,
// encoders that support multiple versions
cyclonedxxml.ID,
cyclonedxjson.ID,
spdxtagvalue.ID,
spdxjson.ID,
}
return encs
} }

View file

@ -0,0 +1,56 @@
package options
import (
"github.com/anchore/clio"
"github.com/anchore/fangs"
"github.com/anchore/syft/syft/sbom"
)
var _ interface {
clio.FlagAdder
clio.PostLoader
} = (*OutputFile)(nil)
// Deprecated: OutputFile supports the --file to write the SBOM output to
type OutputFile struct {
Enabled bool `yaml:"-" json:"-" mapstructure:"-"`
File string `yaml:"file" json:"file" mapstructure:"file"`
}
func (o *OutputFile) AddFlags(flags clio.FlagSet) {
if o.Enabled {
flags.StringVarP(&o.File, "file", "",
"file to write the default report output to (default is STDOUT)")
if pfp, ok := flags.(fangs.PFlagSetProvider); ok {
flagSet := pfp.PFlagSet()
flagSet.Lookup("file").Deprecated = "use: output"
}
}
}
func (o *OutputFile) PostLoad() error {
if !o.Enabled {
return nil
}
if o.File != "" {
file, err := expandFilePath(o.File)
if err != nil {
return err
}
o.File = file
}
return nil
}
func (o *OutputFile) SBOMWriter(f sbom.FormatEncoder) (sbom.Writer, error) {
if !o.Enabled {
return nil, nil
}
writer, err := newSBOMMultiWriter(newSBOMWriterDescription(f, o.File))
if err != nil {
return nil, err
}
return writer, nil
}

View file

@ -0,0 +1,31 @@
package options
import (
"github.com/anchore/clio"
"github.com/anchore/syft/syft/format/template"
"github.com/anchore/syft/syft/sbom"
)
var _ clio.FlagAdder = (*OutputTemplate)(nil)
type OutputTemplate struct {
Enabled bool `yaml:"-" json:"-" mapstructure:"-"`
Path string `yaml:"path" json:"path" mapstructure:"path"` // -t template file to use for output
}
func (o *OutputTemplate) AddFlags(flags clio.FlagSet) {
if o.Enabled {
flags.StringVarP(&o.Path, "template", "t",
"specify the path to a Go template file")
}
}
func (o OutputTemplate) formatEncoders() ([]sbom.FormatEncoder, error) {
if !o.Enabled {
return nil, nil
}
enc, err := template.NewFormatEncoder(template.EncoderConfig{
TemplatePath: o.Path,
})
return []sbom.FormatEncoder{enc}, err
}

View file

@ -0,0 +1,179 @@
package options
import (
"testing"
"github.com/scylladb/go-set/strset"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/format/cyclonedxjson"
"github.com/anchore/syft/syft/format/cyclonedxxml"
"github.com/anchore/syft/syft/format/github"
"github.com/anchore/syft/syft/format/spdxjson"
"github.com/anchore/syft/syft/format/spdxtagvalue"
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/format/table"
"github.com/anchore/syft/syft/format/template"
"github.com/anchore/syft/syft/format/text"
"github.com/anchore/syft/syft/sbom"
)
func Test_getEncoders(t *testing.T) {
allDefaultEncoderNames := strset.New()
for _, id := range supportedIDs() {
allDefaultEncoderNames.Add(id.String())
}
opts := DefaultOutput()
opts.OutputTemplate.Path = "somewhere"
encoders, err := opts.Encoders()
require.NoError(t, err)
require.NotEmpty(t, encoders)
encoderNames := strset.New()
for _, e := range encoders {
encoderNames.Add(e.ID().String())
}
assert.ElementsMatch(t, allDefaultEncoderNames.List(), encoderNames.List(), "not all encoders are expressed")
}
func Test_EncoderCollection_ByString_IDOnly_Defaults(t *testing.T) {
tests := []struct {
name string
want sbom.FormatID
}{
// SPDX Tag-Value
{
name: "spdx",
want: spdxtagvalue.ID,
},
{
name: "spdx-tag-value",
want: spdxtagvalue.ID,
},
{
name: "spdx-tv",
want: spdxtagvalue.ID,
},
{
name: "spdxtv", // clean variant
want: spdxtagvalue.ID,
},
// SPDX JSON
{
name: "spdx-json",
want: spdxjson.ID,
},
{
name: "spdxjson", // clean variant
want: spdxjson.ID,
},
// Cyclonedx JSON
{
name: "cyclonedx-json",
want: cyclonedxjson.ID,
},
{
name: "cyclonedxjson", // clean variant
want: cyclonedxjson.ID,
},
// Cyclonedx XML
{
name: "cdx",
want: cyclonedxxml.ID,
},
{
name: "cyclone",
want: cyclonedxxml.ID,
},
{
name: "cyclonedx",
want: cyclonedxxml.ID,
},
{
name: "cyclonedx-xml",
want: cyclonedxxml.ID,
},
{
name: "cyclonedxxml", // clean variant
want: cyclonedxxml.ID,
},
// Syft Table
{
name: "table",
want: table.ID,
},
{
name: "syft-table",
want: table.ID,
},
// Syft Text
{
name: "text",
want: text.ID,
},
{
name: "syft-text",
want: text.ID,
},
// Syft JSON
{
name: "json",
want: syftjson.ID,
},
{
name: "syft-json",
want: syftjson.ID,
},
{
name: "syftjson", // clean variant
want: syftjson.ID,
},
// GitHub JSON
{
name: "github",
want: github.ID,
},
{
name: "github-json",
want: github.ID,
},
// Syft template
{
name: "template",
want: template.ID,
},
}
opts := DefaultOutput()
opts.OutputTemplate.Path = "somewhere"
defaultEncoders, err := opts.Encoders()
require.NoError(t, err)
encoders := format.NewEncoderCollection(defaultEncoders...)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := encoders.GetByString(tt.name)
if tt.want == "" {
require.Nil(t, f)
return
}
require.NotNil(t, f)
assert.Equal(t, tt.want, f.ID())
})
}
}

View file

@ -6,16 +6,17 @@ import (
"io" "io"
"os" "os"
"path" "path"
"sort"
"strings" "strings"
"github.com/hashicorp/go-multierror" "github.com/hashicorp/go-multierror"
"github.com/mitchellh/go-homedir" "github.com/mitchellh/go-homedir"
"github.com/scylladb/go-set/strset"
"github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/formats" "github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/formats/table" "github.com/anchore/syft/syft/format/table"
"github.com/anchore/syft/syft/formats/template"
"github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/sbom"
) )
@ -28,8 +29,8 @@ var _ interface {
// makeSBOMWriter creates a sbom.Writer for output or returns an error. this will either return a valid writer // makeSBOMWriter creates a sbom.Writer for output or returns an error. this will either return a valid writer
// or an error but neither both and if there is no error, sbom.Writer.Close() should be called // or an error but neither both and if there is no error, sbom.Writer.Close() should be called
func makeSBOMWriter(outputs []string, defaultFile, templateFilePath string) (sbom.Writer, error) { func makeSBOMWriter(outputs []string, defaultFile string, encoders []sbom.FormatEncoder) (sbom.Writer, error) {
outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, templateFilePath) outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, encoders)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -42,18 +43,10 @@ func makeSBOMWriter(outputs []string, defaultFile, templateFilePath string) (sbo
return writer, nil return writer, nil
} }
// makeSBOMWriterForFormat creates a sbom.Writer for for the given format or returns an error.
func makeSBOMWriterForFormat(format sbom.Format, path string) (sbom.Writer, error) {
writer, err := newSBOMMultiWriter(newSBOMWriterDescription(format, path))
if err != nil {
return nil, err
}
return writer, nil
}
// parseSBOMOutputFlags utility to parse command-line option strings and retain the existing behavior of default format and file // parseSBOMOutputFlags utility to parse command-line option strings and retain the existing behavior of default format and file
func parseSBOMOutputFlags(outputs []string, defaultFile, templateFilePath string) (out []sbomWriterDescription, errs error) { func parseSBOMOutputFlags(outputs []string, defaultFile string, encoders []sbom.FormatEncoder) (out []sbomWriterDescription, errs error) {
encoderCollection := format.NewEncoderCollection(encoders...)
// always should have one option -- we generally get the default of "table", but just make sure // always should have one option -- we generally get the default of "table", but just make sure
if len(outputs) == 0 { if len(outputs) == 0 {
outputs = append(outputs, table.ID.String()) outputs = append(outputs, table.ID.String())
@ -76,29 +69,77 @@ func parseSBOMOutputFlags(outputs []string, defaultFile, templateFilePath string
file = parts[1] file = parts[1]
} }
format := formats.ByName(name) enc := encoderCollection.GetByString(name)
if format == nil { if enc == nil {
errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, formats.AllIDs())) errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, formatVersionOptions(encoderCollection.NameVersions())))
continue continue
} }
if tmpl, ok := format.(template.OutputFormat); ok { out = append(out, newSBOMWriterDescription(enc, file))
tmpl.SetTemplatePath(templateFilePath)
format = tmpl
}
out = append(out, newSBOMWriterDescription(format, file))
} }
return out, errs return out, errs
} }
// formatVersionOptions takes a list like ["github-json", "syft-json@11.0.0", "cyclonedx-xml@1.0", "cyclondx-xml@1.1"...]
// and formats it into a human-readable string like:
//
// Available formats:
// - cyclonedx-json @ 1.2, 1.3, 1.4, 1.5
// - cyclonedx-xml @ 1.0, 1.1, 1.2, 1.3, 1.4, 1.5
// - github-json
// - spdx-json @ 2.2, 2.3
// - spdx-tag-value @ 2.1, 2.2, 2.3
// - syft-json
// - syft-table
// - syft-text
// - template
func formatVersionOptions(nameVersionPairs []string) string {
availableVersions := make(map[string][]string)
availableFormats := strset.New()
for _, nameVersion := range nameVersionPairs {
fields := strings.SplitN(nameVersion, "@", 2)
if len(fields) == 2 {
availableVersions[fields[0]] = append(availableVersions[fields[0]], fields[1])
}
availableFormats.Add(fields[0])
}
// find any formats with exactly one version -- remove them from the version map
for name, versions := range availableVersions {
if len(versions) == 1 {
delete(availableVersions, name)
}
}
sortedAvailableFormats := availableFormats.List()
sort.Strings(sortedAvailableFormats)
var s strings.Builder
s.WriteString("\n")
s.WriteString("Available formats:")
for _, name := range sortedAvailableFormats {
s.WriteString("\n")
s.WriteString(fmt.Sprintf(" - %s", name))
if len(availableVersions[name]) > 0 {
s.WriteString(" @ ")
s.WriteString(strings.Join(availableVersions[name], ", "))
}
}
return s.String()
}
// sbomWriterDescription Format and path strings used to create sbom.Writer // sbomWriterDescription Format and path strings used to create sbom.Writer
type sbomWriterDescription struct { type sbomWriterDescription struct {
Format sbom.Format Format sbom.FormatEncoder
Path string Path string
} }
func newSBOMWriterDescription(f sbom.Format, p string) sbomWriterDescription { func newSBOMWriterDescription(f sbom.FormatEncoder, p string) sbomWriterDescription {
expandedPath, err := homedir.Expand(p) expandedPath, err := homedir.Expand(p)
if err != nil { if err != nil {
log.Warnf("could not expand given writer output path=%q: %w", p, err) log.Warnf("could not expand given writer output path=%q: %w", p, err)
@ -171,7 +212,7 @@ func (m *sbomMultiWriter) Write(s sbom.SBOM) (errs error) {
// sbomStreamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup // sbomStreamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup
type sbomStreamWriter struct { type sbomStreamWriter struct {
format sbom.Format format sbom.FormatEncoder
out io.Writer out io.Writer
} }
@ -191,7 +232,7 @@ func (w *sbomStreamWriter) Close() error {
// sbomPublisher implements sbom.Writer that publishes results to the event bus // sbomPublisher implements sbom.Writer that publishes results to the event bus
type sbomPublisher struct { type sbomPublisher struct {
format sbom.Format format sbom.FormatEncoder
} }
// Write the provided SBOM to the data stream // Write the provided SBOM to the data stream

View file

@ -8,43 +8,71 @@ import (
"github.com/docker/docker/pkg/homedir" "github.com/docker/docker/pkg/homedir"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/sbom"
) )
func Test_MakeSBOMWriter(t *testing.T) { func Test_MakeSBOMWriter(t *testing.T) {
tests := []struct { tests := []struct {
name string
outputs []string outputs []string
wantErr assert.ErrorAssertionFunc wantErr assert.ErrorAssertionFunc
}{ }{
{ {
name: "go case",
outputs: []string{"json"}, outputs: []string{"json"},
wantErr: assert.NoError, wantErr: assert.NoError,
}, },
{ {
name: "multiple",
outputs: []string{"table", "json"}, outputs: []string{"table", "json"},
wantErr: assert.NoError, wantErr: assert.NoError,
}, },
{ {
name: "unknown format",
outputs: []string{"unknown"}, outputs: []string{"unknown"},
wantErr: func(t assert.TestingT, err error, bla ...interface{}) bool { wantErr: func(t assert.TestingT, err error, bla ...interface{}) bool {
return assert.ErrorContains(t, err, `unsupported output format "unknown", supported formats are: [`) return assert.ErrorContains(t, err, `unsupported output format "unknown", supported formats are:`)
}, },
}, },
} }
for _, tt := range tests { for _, tt := range tests {
_, err := makeSBOMWriter(tt.outputs, "", "") t.Run(tt.name, func(t *testing.T) {
tt.wantErr(t, err) opt := DefaultOutput()
encoders, err := opt.Encoders()
require.NoError(t, err)
_, err = makeSBOMWriter(tt.outputs, "", encoders)
tt.wantErr(t, err)
})
} }
} }
func dummyEncoder(io.Writer, sbom.SBOM) error { func dummyFormat(name string) sbom.FormatEncoder {
return dummyEncoder{name: name}
}
var _ sbom.FormatEncoder = (*dummyEncoder)(nil)
type dummyEncoder struct {
name string
}
func (d dummyEncoder) ID() sbom.FormatID {
return sbom.FormatID(d.name)
}
func (d dummyEncoder) Aliases() []string {
return nil return nil
} }
func dummyFormat(name string) sbom.Format { func (d dummyEncoder) Version() string {
return sbom.NewFormat(sbom.AnyVersion, dummyEncoder, nil, nil, sbom.FormatID(name)) return sbom.AnyVersion
}
func (d dummyEncoder) Encode(writer io.Writer, s sbom.SBOM) error {
return nil
} }
func Test_newSBOMMultiWriter(t *testing.T) { func Test_newSBOMMultiWriter(t *testing.T) {
@ -227,3 +255,39 @@ func Test_newSBOMWriterDescription(t *testing.T) {
}) })
} }
} }
func Test_formatVersionOptions(t *testing.T) {
tests := []struct {
name string
nameVersionPairs []string
want string
}{
{
name: "gocase",
nameVersionPairs: []string{
"cyclonedx-json@1.2", "cyclonedx-json@1.3", "cyclonedx-json@1.4", "cyclonedx-json@1.5",
"cyclonedx-xml@1.0", "cyclonedx-xml@1.1", "cyclonedx-xml@1.2", "cyclonedx-xml@1.3",
"cyclonedx-xml@1.4", "cyclonedx-xml@1.5", "github-json", "spdx-json@2.2", "spdx-json@2.3",
"spdx-tag-value@2.1", "spdx-tag-value@2.2", "spdx-tag-value@2.3", "syft-json@11.0.0",
"syft-table", "syft-text", "template",
},
want: `
Available formats:
- cyclonedx-json @ 1.2, 1.3, 1.4, 1.5
- cyclonedx-xml @ 1.0, 1.1, 1.2, 1.3, 1.4, 1.5
- github-json
- spdx-json @ 2.2, 2.3
- spdx-tag-value @ 2.1, 2.2, 2.3
- syft-json
- syft-table
- syft-text
- template`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, formatVersionOptions(tt.nameVersionPairs))
})
}
}

11
go.mod
View file

@ -25,7 +25,7 @@ require (
github.com/charmbracelet/lipgloss v0.9.1 github.com/charmbracelet/lipgloss v0.9.1
github.com/dave/jennifer v1.7.0 github.com/dave/jennifer v1.7.0
github.com/deitch/magic v0.0.0-20230404182410-1ff89d7342da github.com/deitch/magic v0.0.0-20230404182410-1ff89d7342da
github.com/docker/distribution v2.8.3+incompatible // indirect github.com/distribution/reference v0.5.0
github.com/docker/docker v24.0.6+incompatible github.com/docker/docker v24.0.6+incompatible
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
github.com/facebookincubator/nvdtools v0.1.5 github.com/facebookincubator/nvdtools v0.1.5
@ -55,6 +55,7 @@ require (
github.com/pelletier/go-toml v1.9.5 github.com/pelletier/go-toml v1.9.5
github.com/saferwall/pe v1.4.7 github.com/saferwall/pe v1.4.7
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d
github.com/sanity-io/litter v1.5.5
github.com/sassoftware/go-rpmutils v0.2.0 github.com/sassoftware/go-rpmutils v0.2.0
// pinned to pull in 386 arch fix: https://github.com/scylladb/go-set/commit/cc7b2070d91ebf40d233207b633e28f5bd8f03a5 // pinned to pull in 386 arch fix: https://github.com/scylladb/go-set/commit/cc7b2070d91ebf40d233207b633e28f5bd8f03a5
github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e
@ -71,16 +72,10 @@ require (
github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1
golang.org/x/mod v0.13.0 golang.org/x/mod v0.13.0
golang.org/x/net v0.17.0 golang.org/x/net v0.17.0
golang.org/x/term v0.13.0 // indirect
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.26.0 modernc.org/sqlite v1.26.0
) )
require (
github.com/distribution/reference v0.5.0
github.com/sanity-io/litter v1.5.5
)
require ( require (
dario.cat/mergo v1.0.0 // indirect dario.cat/mergo v1.0.0 // indirect
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230106234847-43070de90fa1 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20230106234847-43070de90fa1 // indirect
@ -113,6 +108,7 @@ require (
github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/docker/cli v24.0.0+incompatible // indirect github.com/docker/cli v24.0.0+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.7.0 // indirect github.com/docker/docker-credential-helpers v0.7.0 // indirect
github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
@ -209,6 +205,7 @@ require (
golang.org/x/crypto v0.14.0 // indirect golang.org/x/crypto v0.14.0 // indirect
golang.org/x/sync v0.3.0 // indirect golang.org/x/sync v0.3.0 // indirect
golang.org/x/sys v0.13.0 // indirect golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.13.0 // indirect golang.org/x/tools v0.13.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect

View file

@ -0,0 +1,84 @@
package internal
import (
"bytes"
"errors"
"io"
"github.com/anchore/syft/internal/log"
)
var _ io.ReadSeekCloser = (*bufferedSeekReader)(nil)
// bufferedSeekReader wraps an io.ReadCloser to provide io.Seeker functionality.
// It only supports seeking from the start and cannot seek past what has already been read.
type bufferedSeekReader struct {
r io.ReadCloser
buf *bytes.Reader
data []byte
pos int64
closed bool
}
func NewBufferedSeeker(rc io.ReadCloser) io.ReadSeekCloser {
return &bufferedSeekReader{
r: rc,
}
}
func (bs *bufferedSeekReader) Read(p []byte) (int, error) {
if bs.closed {
return 0, errors.New("cannot read from closed reader")
}
if bs.pos == int64(len(bs.data)) {
// if we're at the end of our buffer, read more data into it
tmp := make([]byte, len(p))
n, err := bs.r.Read(tmp)
if err != nil && err != io.EOF {
return 0, err
} else if err == io.EOF {
bs.closed = true
}
bs.data = append(bs.data, tmp[:n]...)
bs.buf = bytes.NewReader(bs.data)
}
n, err := bs.buf.ReadAt(p, bs.pos)
if err != nil && err != io.EOF {
log.WithFields("error", err).Trace("buffered seek reader failed to read from underlying reader")
}
bs.pos += int64(n)
return n, nil
}
func (bs *bufferedSeekReader) Seek(offset int64, whence int) (int64, error) {
var abs int64
switch whence {
case io.SeekStart:
abs = offset
case io.SeekCurrent:
abs = bs.pos + offset
case io.SeekEnd:
return 0, errors.New("'SeekEnd' not supported")
default:
return 0, errors.New("invalid seek option")
}
if abs < 0 {
return 0, errors.New("unable to seek before start")
}
if abs > int64(len(bs.data)) {
return 0, errors.New("unable to seek past read data")
}
bs.pos = abs
return bs.pos, nil
}
func (bs *bufferedSeekReader) Close() error {
bs.closed = true
return bs.r.Close()
}

View file

@ -0,0 +1,141 @@
package internal
import (
"bytes"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBufferedSeeker_Read(t *testing.T) {
tests := []struct {
name string
initialData string
readLengths []int
expectedReads []string
expectError bool
}{
{
name: "go case (read)",
initialData: "Hello, World!",
readLengths: []int{5},
expectedReads: []string{"Hello"},
},
{
name: "multiple reads",
initialData: "Hello, World!",
readLengths: []int{5, 8},
expectedReads: []string{"Hello", ", World!"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bs := NewBufferedSeeker(io.NopCloser(bytes.NewBufferString(tt.initialData)))
for i, length := range tt.readLengths {
buf := make([]byte, length)
n, err := bs.Read(buf)
if !tt.expectError {
assert.NoError(t, err)
assert.Equalf(t, tt.expectedReads[i], string(buf[:n]), "read index %d", i)
} else {
assert.Error(t, err)
}
}
})
}
}
func TestBufferedSeeker_Seek(t *testing.T) {
tests := []struct {
name string
initialData string
readLengths []int
seekOffsets []int64
seekWhence []int
expectedReads []string
seekError require.ErrorAssertionFunc
readError require.ErrorAssertionFunc
}{
{
name: "seek start 0 without read first",
initialData: "Hello, World!",
readLengths: []int{5},
seekOffsets: []int64{0},
seekWhence: []int{io.SeekStart},
expectedReads: []string{"Hello"},
},
{
name: "read + seek back",
initialData: "Hello, World!",
readLengths: []int{5, 8, 8},
seekOffsets: []int64{-1, -1, 2},
seekWhence: []int{io.SeekStart, io.SeekStart, io.SeekStart},
expectedReads: []string{"Hello", ", World!", "llo, Wor"},
},
{
name: "seek past read data",
initialData: "Hello, World!",
readLengths: []int{5},
seekOffsets: []int64{20},
seekWhence: []int{io.SeekStart},
expectedReads: []string{""},
seekError: require.Error,
},
{
name: "seek to end",
initialData: "Hello, World!",
readLengths: []int{-1},
seekOffsets: []int64{20},
seekWhence: []int{io.SeekEnd},
expectedReads: []string{""},
seekError: require.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.seekError == nil {
tt.seekError = require.NoError
}
if tt.readError == nil {
tt.readError = require.NoError
}
bs := NewBufferedSeeker(io.NopCloser(bytes.NewBufferString(tt.initialData)))
for i, length := range tt.readLengths {
if len(tt.seekOffsets) > i && tt.seekOffsets[i] >= 0 {
_, err := bs.Seek(tt.seekOffsets[i], tt.seekWhence[i])
tt.seekError(t, err)
if err != nil {
continue
}
}
if length >= 0 {
buf := make([]byte, length)
n, err := bs.Read(buf)
tt.readError(t, err)
if err != nil {
continue
}
assert.Equalf(t, tt.expectedReads[i], string(buf[:n]), "read index %d", i)
}
}
})
}
}
func TestBufferedSeeker_Close(t *testing.T) {
bs := NewBufferedSeeker(io.NopCloser(bytes.NewBufferString("Hello, World!")))
err := bs.Close()
assert.NoError(t, err)
n, err := bs.Read(make([]byte, 1))
assert.Equal(t, 0, n)
assert.Error(t, err)
}

View file

@ -1,7 +1,11 @@
.DEFAULT_GOAL := validate-schema .DEFAULT_GOAL := validate-schema
.PHONY: validate-schema .PHONY: validate-schema
validate-schema: validate-schema:
go run ../../cmd/syft/main.go ubuntu:latest -vv -o cyclonedx > bom.xml @echo "Generating CycloneDX SBOMs..."
go run ../../cmd/syft/main.go ubuntu:latest -v -o cyclonedx-xml=bom.xml -o cyclonedx-json=bom.json
@echo "\nValidating CycloneDX XML..."
xmllint --noout --schema ./cyclonedx.xsd bom.xml xmllint --noout --schema ./cyclonedx.xsd bom.xml
go run ../../cmd/syft/main.go ubuntu:latest -vv -o cyclonedx-json > bom.json
@echo "\nValidating CycloneDX JSON..."
../../.tool/yajsv -s cyclonedx.json bom.json ../../.tool/yajsv -s cyclonedx.json bom.json

View file

@ -5,3 +5,5 @@
however, this tool does not know how to deal with references from HTTP, only the local filesystem. however, this tool does not know how to deal with references from HTTP, only the local filesystem.
For this reason we've included a copy of all schemas needed to validate `syft` output, modified For this reason we've included a copy of all schemas needed to validate `syft` output, modified
to reference local copies of dependent schemas. to reference local copies of dependent schemas.
You can get the latest schemas from the [CycloneDX specifications repo](https://github.com/CycloneDX/specification/tree/master/schema).

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,18 +0,0 @@
package syft
import (
"io"
"github.com/anchore/syft/syft/formats"
"github.com/anchore/syft/syft/sbom"
)
// TODO: deprecated, moved to syft/formats/formats.go. will be removed in v1.0.0
func Encode(s sbom.SBOM, f sbom.Format) ([]byte, error) {
return formats.Encode(s, f)
}
// TODO: deprecated, moved to syft/formats/formats.go. will be removed in v1.0.0
func Decode(reader io.Reader) (*sbom.SBOM, sbom.Format, error) {
return formats.Decode(reader)
}

View file

@ -7,7 +7,7 @@ import (
"github.com/anchore/packageurl-go" "github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/formats/common" "github.com/anchore/syft/syft/format/common"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
) )

View file

@ -2,56 +2,18 @@ package cyclonedxhelpers
import ( import (
"fmt" "fmt"
"io"
"strings"
"github.com/CycloneDX/cyclonedx-go" "github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/packageurl-go" "github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/formats/common" "github.com/anchore/syft/syft/format/common"
"github.com/anchore/syft/syft/linux" "github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source"
) )
const cycloneDXXmlSchema = "http://cyclonedx.org/schema/bom"
func GetValidator(format cyclonedx.BOMFileFormat) sbom.Validator {
return func(reader io.Reader) error {
bom := &cyclonedx.BOM{}
err := cyclonedx.NewBOMDecoder(reader, format).Decode(bom)
if err != nil {
return err
}
xmlWithoutNS := format == cyclonedx.BOMFileFormatXML && !strings.Contains(bom.XMLNS, cycloneDXXmlSchema)
xmlWithoutComponents := format == cyclonedx.BOMFileFormatXML && bom.Components == nil
if (cyclonedx.BOM{} == *bom || xmlWithoutComponents || xmlWithoutNS) {
return fmt.Errorf("not a valid CycloneDX document")
}
return nil
}
}
func GetDecoder(format cyclonedx.BOMFileFormat) sbom.Decoder {
return func(reader io.Reader) (*sbom.SBOM, error) {
bom := &cyclonedx.BOM{
Components: &[]cyclonedx.Component{},
}
err := cyclonedx.NewBOMDecoder(reader, format).Decode(bom)
if err != nil {
return nil, err
}
s, err := ToSyftModel(bom)
if err != nil {
return nil, err
}
return s, nil
}
}
func ToSyftModel(bom *cyclonedx.BOM) (*sbom.SBOM, error) { func ToSyftModel(bom *cyclonedx.BOM) (*sbom.SBOM, error) {
if bom == nil { if bom == nil {
return nil, fmt.Errorf("no content defined in CycloneDX BOM") return nil, fmt.Errorf("no content defined in CycloneDX BOM")

View file

@ -1,8 +1,6 @@
package cyclonedxhelpers package cyclonedxhelpers
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"testing" "testing"
@ -327,18 +325,6 @@ func Test_missingDataDecode(t *testing.T) {
assert.Equal(t, pkg.Licenses.Empty(), true) assert.Equal(t, pkg.Licenses.Empty(), true)
} }
func Test_missingComponentsDecode(t *testing.T) {
bom := &cyclonedx.BOM{
SpecVersion: cyclonedx.SpecVersion1_4,
}
bomBytes, _ := json.Marshal(&bom)
decode := GetDecoder(cyclonedx.BOMFileFormatJSON)
_, err := decode(bytes.NewReader(bomBytes))
assert.NoError(t, err)
}
func Test_decodeDependencies(t *testing.T) { func Test_decodeDependencies(t *testing.T) {
c1 := cyclonedx.Component{ c1 := cyclonedx.Component{
Name: "c1", Name: "c1",

View file

@ -3,7 +3,7 @@ package cyclonedxhelpers
import ( import (
"github.com/CycloneDX/cyclonedx-go" "github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/syft/formats/common" "github.com/anchore/syft/syft/format/common"
) )
var ( var (

View file

@ -19,7 +19,7 @@ import (
"github.com/anchore/syft/internal/spdxlicense" "github.com/anchore/syft/internal/spdxlicense"
"github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/formats/common/util" "github.com/anchore/syft/syft/format/common/util"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source" "github.com/anchore/syft/syft/source"

View file

@ -18,7 +18,7 @@ import (
"github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/cpe"
"github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/formats/common/util" "github.com/anchore/syft/syft/format/common/util"
"github.com/anchore/syft/syft/license" "github.com/anchore/syft/syft/license"
"github.com/anchore/syft/syft/linux" "github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg"

View file

@ -0,0 +1,131 @@
package cyclonedxjson
import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/format/common/cyclonedxhelpers"
"github.com/anchore/syft/syft/format/internal/cyclonedxutil"
"github.com/anchore/syft/syft/sbom"
)
var _ sbom.FormatDecoder = (*decoder)(nil)
type decoder struct {
decoder cyclonedxutil.Decoder
}
func NewFormatDecoder() sbom.FormatDecoder {
return decoder{
decoder: cyclonedxutil.NewDecoder(cyclonedx.BOMFileFormatJSON),
}
}
func (d decoder) Decode(reader io.ReadSeeker) (*sbom.SBOM, sbom.FormatID, string, error) {
if reader == nil {
return nil, "", "", fmt.Errorf("no SBOM bytes provided")
}
id, version := d.Identify(reader)
if id != ID {
return nil, "", "", fmt.Errorf("not a cyclonedx json document")
}
if version == "" {
return nil, "", "", fmt.Errorf("unsupported cyclonedx json document version")
}
doc, err := d.decoder.Decode(reader)
if err != nil {
return nil, id, version, fmt.Errorf("unable to decode cyclonedx json document: %w", err)
}
s, err := cyclonedxhelpers.ToSyftModel(doc)
if err != nil {
return nil, id, version, err
}
return s, id, version, nil
}
func (d decoder) Identify(reader io.ReadSeeker) (sbom.FormatID, string) {
if reader == nil {
return "", ""
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
log.Debugf("unable to seek to start of CycloneDX JSON SBOM: %+v", err)
return "", ""
}
type Document struct {
JSONSchema string `json:"$schema"`
BOMFormat string `json:"bomFormat"`
SpecVersion string `json:"specVersion"`
}
dec := json.NewDecoder(reader)
var doc Document
err := dec.Decode(&doc)
if err != nil {
// maybe not json? maybe not valid? doesn't matter, we won't process it.
return "", ""
}
id, version := getFormatInfo(doc.JSONSchema, doc.BOMFormat, doc.SpecVersion)
if version == "" || id != ID {
// not a cyclonedx json document that we support
return "", ""
}
return id, version
}
func getFormatInfo(schemaURI, bomFormat string, specVersion any) (sbom.FormatID, string) {
if !strings.Contains(schemaURI, "cyclonedx.org/schema/bom") {
// not a cyclonedx json document
return "", ""
}
if bomFormat != "CycloneDX" {
// not a cyclonedx json document
return "", ""
}
// by this point this looks to be valid cyclonedx json, but we need to know the version
var (
version string
spec cyclonedx.SpecVersion
err error
)
switch s := specVersion.(type) {
case string:
version = s
spec, err = cyclonedxutil.SpecVersionFromString(version)
if err != nil {
// not a supported version, but is cyclonedx json
return ID, ""
}
case cyclonedx.SpecVersion:
spec = s
version = cyclonedxutil.VersionFromSpecVersion(spec)
if version == "" {
// not a supported version, but is cyclonedx json
return ID, ""
}
default:
// bad input provided for version info
return ID, ""
}
if spec < 0 {
// not a supported version, but is cyclonedx json
return ID, ""
}
return ID, version
}

View file

@ -0,0 +1,118 @@
package cyclonedxjson
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/sbom"
)
func TestDecoder_Decode(t *testing.T) {
tests := []struct {
name string
file string
err bool
distro string
packages []string
}{
{
name: "dir-scan",
file: "snapshot/TestCycloneDxDirectoryEncoder.golden",
distro: "debian:1.2.3",
packages: []string{"package-1:1.0.1", "package-2:2.0.1"},
},
{
name: "image-scan",
file: "snapshot/TestCycloneDxImageEncoder.golden",
distro: "debian:1.2.3",
packages: []string{"package-1:1.0.1", "package-2:2.0.1"},
},
{
name: "not-an-sbom",
file: "bad-sbom",
err: true,
},
}
for _, test := range tests {
t.Run(test.file, func(t *testing.T) {
reader, err := os.Open(filepath.Join("test-fixtures", test.file))
require.NoError(t, err)
dec := NewFormatDecoder()
formatID, formatVersion := dec.Identify(reader)
if test.err {
assert.Equal(t, sbom.FormatID(""), formatID)
assert.Equal(t, "", formatVersion)
_, decodeID, decodeVersion, err := dec.Decode(reader)
require.Error(t, err)
assert.Equal(t, sbom.FormatID(""), decodeID)
assert.Equal(t, "", decodeVersion)
return
}
assert.Equal(t, ID, formatID)
assert.NotEmpty(t, formatVersion)
bom, decodeID, decodeVersion, err := dec.Decode(reader)
require.NotNil(t, bom)
require.NoError(t, err)
assert.Equal(t, ID, decodeID)
assert.Equal(t, formatVersion, decodeVersion)
split := strings.SplitN(test.distro, ":", 2)
distroName := split[0]
distroVersion := split[1]
assert.Equal(t, bom.Artifacts.LinuxDistribution.ID, distroName)
assert.Equal(t, bom.Artifacts.LinuxDistribution.Version, distroVersion)
var pkgs []string
for p := range bom.Artifacts.Packages.Enumerate() {
pkgs = append(pkgs, fmt.Sprintf("%s:%s", p.Name, p.Version))
}
assert.ElementsMatch(t, test.packages, pkgs)
})
}
}
func TestDecoder_Identify(t *testing.T) {
type testCase struct {
name string
file string
id sbom.FormatID
version string
}
var cases []testCase
for _, version := range SupportedVersions() {
cases = append(cases, testCase{
name: fmt.Sprintf("v%s schema", version),
file: fmt.Sprintf("test-fixtures/identify/%s.json", version),
id: ID,
version: version,
})
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
reader, err := os.Open(test.file)
require.NoError(t, err)
dec := NewFormatDecoder()
formatID, formatVersion := dec.Identify(reader)
assert.Equal(t, test.id, formatID)
assert.Equal(t, test.version, formatVersion)
})
}
}

View file

@ -0,0 +1,52 @@
package cyclonedxjson
import (
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/syft/format/internal/cyclonedxutil"
"github.com/anchore/syft/syft/sbom"
)
const ID = cyclonedxutil.JSONFormatID
func SupportedVersions() []string {
return cyclonedxutil.SupportedVersions(ID)
}
type EncoderConfig struct {
Version string
}
type encoder struct {
cfg EncoderConfig
cyclonedxutil.Encoder
}
func NewFormatEncoderWithConfig(cfg EncoderConfig) (sbom.FormatEncoder, error) {
enc, err := cyclonedxutil.NewEncoder(cfg.Version, cyclonedx.BOMFileFormatJSON)
if err != nil {
return nil, err
}
return encoder{
cfg: cfg,
Encoder: enc,
}, nil
}
func DefaultEncoderConfig() EncoderConfig {
return EncoderConfig{
Version: cyclonedxutil.DefaultVersion,
}
}
func (e encoder) ID() sbom.FormatID {
return ID
}
func (e encoder) Aliases() []string {
return []string{}
}
func (e encoder) Version() string {
return e.cfg.Version
}

View file

@ -0,0 +1,132 @@
package cyclonedxjson
import (
"bytes"
"flag"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/format/internal/testutil"
"github.com/anchore/syft/syft/sbom"
)
var updateSnapshot = flag.Bool("update-cyclonedx-json", false, "update the *.golden files for cyclone-dx JSON encoders")
var updateImage = flag.Bool("update-image", false, "update the golden image used for image encoder testing")
func getEncoder(t testing.TB) sbom.FormatEncoder {
enc, err := NewFormatEncoderWithConfig(DefaultEncoderConfig())
require.NoError(t, err)
return enc
}
func TestCycloneDxDirectoryEncoder(t *testing.T) {
dir := t.TempDir()
testutil.AssertEncoderAgainstGoldenSnapshot(t,
testutil.EncoderSnapshotTestConfig{
Subject: testutil.DirectoryInput(t, dir),
Format: getEncoder(t),
UpdateSnapshot: *updateSnapshot,
PersistRedactionsInSnapshot: true,
IsJSON: true,
Redactor: redactor(dir),
},
)
}
func TestCycloneDxImageEncoder(t *testing.T) {
testImage := "image-simple"
testutil.AssertEncoderAgainstGoldenImageSnapshot(t,
testutil.ImageSnapshotTestConfig{
Image: testImage,
UpdateImageSnapshot: *updateImage,
},
testutil.EncoderSnapshotTestConfig{
Subject: testutil.ImageInput(t, testImage),
Format: getEncoder(t),
UpdateSnapshot: *updateSnapshot,
PersistRedactionsInSnapshot: true,
IsJSON: true,
Redactor: redactor(),
},
)
}
func redactor(values ...string) testutil.Redactor {
return testutil.NewRedactions().
WithValuesRedacted(values...).
WithPatternRedactors(
map[string]string{
// UUIDs
`urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`: `urn:uuid:redacted`,
// timestamps
`([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]))`: `timestamp:redacted`,
// image hashes
`sha256:[A-Fa-f0-9]{64}`: `sha256:redacted`,
// BOM refs
`"bom-ref":\s*"[^"]+"`: `"bom-ref":"redacted"`,
},
)
}
func TestSupportedVersions(t *testing.T) {
encs := defaultFormatEncoders()
require.NotEmpty(t, encs)
versions := SupportedVersions()
require.Equal(t, len(versions), len(encs))
subject := testutil.DirectoryInput(t, t.TempDir())
dec := NewFormatDecoder()
for _, enc := range encs {
t.Run(enc.Version(), func(t *testing.T) {
require.Contains(t, versions, enc.Version())
var buf bytes.Buffer
require.NoError(t, enc.Encode(&buf, subject))
id, version := dec.Identify(bytes.NewReader(buf.Bytes()))
require.Equal(t, enc.ID(), id)
require.Equal(t, enc.Version(), version)
var s *sbom.SBOM
var err error
s, id, version, err = dec.Decode(bytes.NewReader(buf.Bytes()))
require.NoError(t, err)
require.Equal(t, enc.ID(), id)
require.Equal(t, enc.Version(), version)
require.NotEmpty(t, s.Artifacts.Packages.PackageCount())
assert.Equal(t, len(subject.Relationships), len(s.Relationships), "mismatched relationship count")
if !assert.Equal(t, subject.Artifacts.Packages.PackageCount(), s.Artifacts.Packages.PackageCount(), "mismatched package count") {
t.Logf("expected: %d", subject.Artifacts.Packages.PackageCount())
for _, p := range subject.Artifacts.Packages.Sorted() {
t.Logf(" - %s", p.String())
}
t.Logf("actual: %d", s.Artifacts.Packages.PackageCount())
for _, p := range s.Artifacts.Packages.Sorted() {
t.Logf(" - %s", p.String())
}
}
})
}
}
func defaultFormatEncoders() []sbom.FormatEncoder {
var encs []sbom.FormatEncoder
for _, version := range SupportedVersions() {
enc, err := NewFormatEncoderWithConfig(EncoderConfig{Version: version})
if err != nil {
panic(err)
}
encs = append(encs, enc)
}
return encs
}

View file

@ -0,0 +1 @@
not an sbom!

View file

@ -0,0 +1,33 @@
{
"$schema": "http://cyclonedx.org/schema/bom-1.2.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.2",
"serialNumber": "urn:uuid:8cfb48be-7ed8-4cea-8c23-8992e98d12be",
"version": 1,
"metadata": {
"timestamp": "2023-09-29T12:02:02-04:00",
"tools": [
{
"vendor": "anchore",
"name": "syft",
"version": "[not provided]"
}
],
"component": {
"bom-ref": "a0ff99a6af10f11f",
"type": "file",
"name": "go.mod",
"version": "sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd"
}
},
"components": [
{
"bom-ref": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86",
"type": "library",
"name": "github.com/wagoodman/go-partybus",
"version": "v0.0.0-20230516145632-8ccac152c651",
"cpe": "cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*",
"purl": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651"
}
]
}

View file

@ -0,0 +1,59 @@
{
"$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.3",
"serialNumber": "urn:uuid:57ce2636-4ca6-46ba-b389-196af071d6fc",
"version": 1,
"metadata": {
"timestamp": "2023-09-29T12:02:02-04:00",
"tools": [
{
"vendor": "anchore",
"name": "syft",
"version": "[not provided]"
}
],
"component": {
"bom-ref": "a0ff99a6af10f11f",
"type": "file",
"name": "go.mod",
"version": "sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd"
}
},
"components": [
{
"bom-ref": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86",
"type": "library",
"name": "github.com/wagoodman/go-partybus",
"version": "v0.0.0-20230516145632-8ccac152c651",
"cpe": "cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*",
"purl": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651",
"properties": [
{
"name": "syft:package:foundBy",
"value": "go-mod-file-cataloger"
},
{
"name": "syft:package:language",
"value": "go"
},
{
"name": "syft:package:metadataType",
"value": "GolangModMetadata"
},
{
"name": "syft:package:type",
"value": "go-module"
},
{
"name": "syft:cpe23",
"value": "cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*"
},
{
"name": "syft:location:0:path",
"value": "/go.mod"
}
]
}
]
}

View file

@ -0,0 +1,59 @@
{
"$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": "urn:uuid:6be7491f-6a9b-40f0-a60e-f74e2c0e23c3",
"version": 1,
"metadata": {
"timestamp": "2023-09-29T12:02:02-04:00",
"tools": [
{
"vendor": "anchore",
"name": "syft",
"version": "[not provided]"
}
],
"component": {
"bom-ref": "a0ff99a6af10f11f",
"type": "file",
"name": "go.mod",
"version": "sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd"
}
},
"components": [
{
"bom-ref": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86",
"type": "library",
"name": "github.com/wagoodman/go-partybus",
"version": "v0.0.0-20230516145632-8ccac152c651",
"cpe": "cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*",
"purl": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651",
"properties": [
{
"name": "syft:package:foundBy",
"value": "go-mod-file-cataloger"
},
{
"name": "syft:package:language",
"value": "go"
},
{
"name": "syft:package:metadataType",
"value": "GolangModMetadata"
},
{
"name": "syft:package:type",
"value": "go-module"
},
{
"name": "syft:cpe23",
"value": "cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*"
},
{
"name": "syft:location:0:path",
"value": "/go.mod"
}
]
}
]
}

View file

@ -0,0 +1,59 @@
{
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"serialNumber": "urn:uuid:22c456c5-c2a0-4285-a8ef-01b4382822c3",
"version": 1,
"metadata": {
"timestamp": "2023-09-29T12:02:02-04:00",
"tools": [
{
"vendor": "anchore",
"name": "syft",
"version": "[not provided]"
}
],
"component": {
"bom-ref": "a0ff99a6af10f11f",
"type": "file",
"name": "go.mod",
"version": "sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd"
}
},
"components": [
{
"bom-ref": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86",
"type": "library",
"name": "github.com/wagoodman/go-partybus",
"version": "v0.0.0-20230516145632-8ccac152c651",
"cpe": "cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*",
"purl": "pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651",
"properties": [
{
"name": "syft:package:foundBy",
"value": "go-mod-file-cataloger"
},
{
"name": "syft:package:language",
"value": "go"
},
{
"name": "syft:package:metadataType",
"value": "GolangModMetadata"
},
{
"name": "syft:package:type",
"value": "go-module"
},
{
"name": "syft:cpe23",
"value": "cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*"
},
{
"name": "syft:location:0:path",
"value": "/go.mod"
}
]
}
]
}

View file

@ -1,7 +1,7 @@
{ {
"$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
"bomFormat": "CycloneDX", "bomFormat": "CycloneDX",
"specVersion": "1.4", "specVersion": "1.5",
"serialNumber": "urn:uuid:redacted", "serialNumber": "urn:uuid:redacted",
"version": 1, "version": 1,
"metadata": { "metadata": {

View file

@ -1,7 +1,7 @@
{ {
"$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
"bomFormat": "CycloneDX", "bomFormat": "CycloneDX",
"specVersion": "1.4", "specVersion": "1.5",
"serialNumber": "urn:uuid:redacted", "serialNumber": "urn:uuid:redacted",
"version": 1, "version": 1,
"metadata": { "metadata": {

View file

@ -0,0 +1,106 @@
package cyclonedxxml
import (
"encoding/xml"
"fmt"
"io"
"strings"
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/format/common/cyclonedxhelpers"
"github.com/anchore/syft/syft/format/internal/cyclonedxutil"
"github.com/anchore/syft/syft/sbom"
)
var _ sbom.FormatDecoder = (*decoder)(nil)
type decoder struct {
decoder cyclonedxutil.Decoder
}
func NewFormatDecoder() sbom.FormatDecoder {
return decoder{
decoder: cyclonedxutil.NewDecoder(cyclonedx.BOMFileFormatXML),
}
}
func (d decoder) Decode(reader io.ReadSeeker) (*sbom.SBOM, sbom.FormatID, string, error) {
if reader == nil {
return nil, "", "", fmt.Errorf("no SBOM bytes provided")
}
id, version := d.Identify(reader)
if id != ID {
return nil, "", "", fmt.Errorf("not a cyclonedx xml document")
}
if version == "" {
return nil, "", "", fmt.Errorf("unsupported cyclonedx xml document version")
}
doc, err := d.decoder.Decode(reader)
if err != nil {
return nil, id, version, fmt.Errorf("unable to decode cyclonedx xml document: %w", err)
}
s, err := cyclonedxhelpers.ToSyftModel(doc)
if err != nil {
return nil, id, version, err
}
return s, id, version, nil
}
func (d decoder) Identify(reader io.ReadSeeker) (sbom.FormatID, string) {
if reader == nil {
return "", ""
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
log.Debugf("unable to seek to start of CycloneDX XML SBOM: %+v", err)
return "", ""
}
type Document struct {
XMLNS string `xml:"xmlns,attr"`
}
dec := xml.NewDecoder(reader)
var doc Document
err := dec.Decode(&doc)
if err != nil {
// maybe not xml? maybe not valid? doesn't matter, we won't process it.
return "", ""
}
id, version := getFormatInfo(doc.XMLNS)
if version == "" || id != ID {
// not a cyclonedx xml document that we support
return "", ""
}
return id, version
}
func getFormatInfo(xmlns string) (sbom.FormatID, string) {
version := getVersionFromXMLNS(xmlns)
if !strings.Contains(xmlns, "cyclonedx.org/schema/bom") {
// not a cyclonedx xml document
return "", ""
}
spec, err := cyclonedxutil.SpecVersionFromString(version)
if spec < 0 || err != nil {
// not a supported version, but is cyclonedx xml
return ID, ""
}
return ID, version
}
func getVersionFromXMLNS(xmlns string) string {
fields := strings.Split(xmlns, "/")
return fields[len(fields)-1]
}

View file

@ -0,0 +1,118 @@
package cyclonedxxml
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/sbom"
)
func TestDecoder_Decode(t *testing.T) {
tests := []struct {
name string
file string
err bool
distro string
packages []string
}{
{
name: "dir-scan",
file: "snapshot/TestCycloneDxDirectoryEncoder.golden",
distro: "debian:1.2.3",
packages: []string{"package-1:1.0.1", "package-2:2.0.1"},
},
{
name: "image-scan",
file: "snapshot/TestCycloneDxImageEncoder.golden",
distro: "debian:1.2.3",
packages: []string{"package-1:1.0.1", "package-2:2.0.1"},
},
{
name: "not-an-sbom",
file: "bad-sbom",
err: true,
},
}
for _, test := range tests {
t.Run(test.file, func(t *testing.T) {
reader, err := os.Open(filepath.Join("test-fixtures", test.file))
require.NoError(t, err)
dec := NewFormatDecoder()
formatID, formatVersion := dec.Identify(reader)
if test.err {
assert.Equal(t, sbom.FormatID(""), formatID)
assert.Equal(t, "", formatVersion)
_, decodeID, decodeVersion, err := dec.Decode(reader)
require.Error(t, err)
assert.Equal(t, sbom.FormatID(""), decodeID)
assert.Equal(t, "", decodeVersion)
return
}
assert.Equal(t, ID, formatID)
assert.NotEmpty(t, formatVersion)
bom, decodeID, decodeVersion, err := dec.Decode(reader)
require.NotNil(t, bom)
require.NoError(t, err)
assert.Equal(t, ID, decodeID)
assert.Equal(t, formatVersion, decodeVersion)
split := strings.SplitN(test.distro, ":", 2)
distroName := split[0]
distroVersion := split[1]
assert.Equal(t, bom.Artifacts.LinuxDistribution.ID, distroName)
assert.Equal(t, bom.Artifacts.LinuxDistribution.Version, distroVersion)
var pkgs []string
for p := range bom.Artifacts.Packages.Enumerate() {
pkgs = append(pkgs, fmt.Sprintf("%s:%s", p.Name, p.Version))
}
assert.ElementsMatch(t, test.packages, pkgs)
})
}
}
func TestDecoder_Identify(t *testing.T) {
type testCase struct {
name string
file string
id sbom.FormatID
version string
}
var cases []testCase
for _, version := range SupportedVersions() {
cases = append(cases, testCase{
name: fmt.Sprintf("v%s schema", version),
file: fmt.Sprintf("test-fixtures/identify/%s.xml", version),
id: ID,
version: version,
})
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
reader, err := os.Open(test.file)
require.NoError(t, err)
dec := NewFormatDecoder()
formatID, formatVersion := dec.Identify(reader)
assert.Equal(t, test.id, formatID)
assert.Equal(t, test.version, formatVersion)
})
}
}

View file

@ -0,0 +1,58 @@
package cyclonedxxml
import (
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/syft/syft/format/internal/cyclonedxutil"
"github.com/anchore/syft/syft/sbom"
)
var _ sbom.FormatEncoder = (*encoder)(nil)
const ID = cyclonedxutil.XMLFormatID
func SupportedVersions() []string {
return cyclonedxutil.SupportedVersions(ID)
}
type EncoderConfig struct {
Version string
}
type encoder struct {
cfg EncoderConfig
cyclonedxutil.Encoder
}
func NewFormatEncoderWithConfig(cfg EncoderConfig) (sbom.FormatEncoder, error) {
enc, err := cyclonedxutil.NewEncoder(cfg.Version, cyclonedx.BOMFileFormatXML)
if err != nil {
return nil, err
}
return encoder{
cfg: cfg,
Encoder: enc,
}, nil
}
func DefaultEncoderConfig() EncoderConfig {
return EncoderConfig{
Version: cyclonedxutil.DefaultVersion,
}
}
func (e encoder) ID() sbom.FormatID {
return ID
}
func (e encoder) Aliases() []string {
return []string{
"cyclonedx",
"cyclone",
"cdx",
}
}
func (e encoder) Version() string {
return e.cfg.Version
}

View file

@ -0,0 +1,129 @@
package cyclonedxxml
import (
"bytes"
"flag"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/format/internal/testutil"
"github.com/anchore/syft/syft/sbom"
)
var updateSnapshot = flag.Bool("update-cyclonedx-xml", false, "update the *.golden files for cyclone-dx XML encoders")
var updateImage = flag.Bool("update-image", false, "update the golden image used for image encoder testing")
func getEncoder(t testing.TB) sbom.FormatEncoder {
enc, err := NewFormatEncoderWithConfig(DefaultEncoderConfig())
require.NoError(t, err)
return enc
}
func TestCycloneDxDirectoryEncoder(t *testing.T) {
dir := t.TempDir()
testutil.AssertEncoderAgainstGoldenSnapshot(t,
testutil.EncoderSnapshotTestConfig{
Subject: testutil.DirectoryInput(t, dir),
Format: getEncoder(t),
UpdateSnapshot: *updateSnapshot,
PersistRedactionsInSnapshot: true,
IsJSON: false,
Redactor: redactor(dir),
},
)
}
func TestCycloneDxImageEncoder(t *testing.T) {
testImage := "image-simple"
testutil.AssertEncoderAgainstGoldenImageSnapshot(t,
testutil.ImageSnapshotTestConfig{
Image: testImage,
UpdateImageSnapshot: *updateImage,
},
testutil.EncoderSnapshotTestConfig{
Subject: testutil.ImageInput(t, testImage),
Format: getEncoder(t),
UpdateSnapshot: *updateSnapshot,
PersistRedactionsInSnapshot: true,
IsJSON: false,
Redactor: redactor(),
},
)
}
func redactor(values ...string) testutil.Redactor {
return testutil.NewRedactions().
WithValuesRedacted(values...).
WithPatternRedactors(
map[string]string{
// dates
`([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]))`: `redacted`,
// image hashes and BOM refs
`sha256:[A-Za-z0-9]{64}`: `sha256:redacted`,
// serial numbers and BOM refs
`(serialNumber|bom-ref)="[^"]+"`: `$1="redacted"`,
},
)
}
func TestSupportedVersions(t *testing.T) {
encs := defaultFormatEncoders()
require.NotEmpty(t, encs)
versions := SupportedVersions()
require.Equal(t, len(versions), len(encs))
subject := testutil.DirectoryInput(t, t.TempDir())
dec := NewFormatDecoder()
for _, enc := range encs {
t.Run(enc.Version(), func(t *testing.T) {
require.Contains(t, versions, enc.Version())
var buf bytes.Buffer
require.NoError(t, enc.Encode(&buf, subject))
id, version := dec.Identify(bytes.NewReader(buf.Bytes()))
require.Equal(t, enc.ID(), id)
require.Equal(t, enc.Version(), version)
var s *sbom.SBOM
var err error
s, id, version, err = dec.Decode(bytes.NewReader(buf.Bytes()))
require.NoError(t, err)
require.Equal(t, enc.ID(), id)
require.Equal(t, enc.Version(), version)
require.NotEmpty(t, s.Artifacts.Packages.PackageCount())
assert.Equal(t, len(subject.Relationships), len(s.Relationships), "mismatched relationship count")
if !assert.Equal(t, subject.Artifacts.Packages.PackageCount(), s.Artifacts.Packages.PackageCount(), "mismatched package count") {
t.Logf("expected: %d", subject.Artifacts.Packages.PackageCount())
for _, p := range subject.Artifacts.Packages.Sorted() {
t.Logf(" - %s", p.String())
}
t.Logf("actual: %d", s.Artifacts.Packages.PackageCount())
for _, p := range s.Artifacts.Packages.Sorted() {
t.Logf(" - %s", p.String())
}
}
})
}
}
func defaultFormatEncoders() []sbom.FormatEncoder {
var encs []sbom.FormatEncoder
for _, version := range SupportedVersions() {
enc, err := NewFormatEncoderWithConfig(EncoderConfig{Version: version})
if err != nil {
panic(err)
}
encs = append(encs, enc)
}
return encs
}

View file

@ -0,0 +1 @@
not an sbom!

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.0" version="1">
<components>
<component type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
<modified>false</modified>
</component>
</components>
</bom>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.1" serialNumber="urn:uuid:32ad4aec-ddc3-4c26-90e7-796f7ca82a1d" version="1">
<components>
<component bom-ref="pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86" type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
</component>
</components>
</bom>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.2" serialNumber="urn:uuid:214033a9-6fe2-4f0b-88f2-8dbca251d0e0" version="1">
<metadata>
<timestamp>2023-09-29T11:48:10-04:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>syft</name>
<version>[not provided]</version>
</tool>
</tools>
<component bom-ref="a0ff99a6af10f11f" type="file">
<name>go.mod</name>
<version>sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd</version>
</component>
</metadata>
<components>
<component bom-ref="pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86" type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
</component>
</components>
</bom>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.3" serialNumber="urn:uuid:7ec2b549-3d31-4c7b-85be-fad838c8f312" version="1">
<metadata>
<timestamp>2023-09-29T11:48:10-04:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>syft</name>
<version>[not provided]</version>
</tool>
</tools>
<component bom-ref="a0ff99a6af10f11f" type="file">
<name>go.mod</name>
<version>sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd</version>
</component>
</metadata>
<components>
<component bom-ref="pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86" type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
<properties>
<property name="syft:package:foundBy">go-mod-file-cataloger</property>
<property name="syft:package:language">go</property>
<property name="syft:package:metadataType">GolangModMetadata</property>
<property name="syft:package:type">go-module</property>
<property name="syft:cpe23">cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</property>
<property name="syft:location:0:path">/go.mod</property>
</properties>
</component>
</components>
</bom>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.4" serialNumber="urn:uuid:3e5d8b2a-30cd-409f-9519-558c53e542c5" version="1">
<metadata>
<timestamp>2023-09-29T11:48:10-04:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>syft</name>
<version>[not provided]</version>
</tool>
</tools>
<component bom-ref="a0ff99a6af10f11f" type="file">
<name>go.mod</name>
<version>sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd</version>
</component>
</metadata>
<components>
<component bom-ref="pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86" type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
<properties>
<property name="syft:package:foundBy">go-mod-file-cataloger</property>
<property name="syft:package:language">go</property>
<property name="syft:package:metadataType">GolangModMetadata</property>
<property name="syft:package:type">go-module</property>
<property name="syft:cpe23">cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</property>
<property name="syft:location:0:path">/go.mod</property>
</properties>
</component>
</components>
</bom>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.5" serialNumber="urn:uuid:b2188bff-93b8-47e7-b86e-41c417e0d75e" version="1">
<metadata>
<timestamp>2023-09-29T11:48:10-04:00</timestamp>
<tools>
<tool>
<vendor>anchore</vendor>
<name>syft</name>
<version>[not provided]</version>
</tool>
</tools>
<component bom-ref="a0ff99a6af10f11f" type="file">
<name>go.mod</name>
<version>sha256:sha256:dc333f342905248a52e424d8dfd061251d01867d01a4f9d7397144a775ff9ebd</version>
</component>
</metadata>
<components>
<component bom-ref="pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651?package-id=2ff71a67fb024c86" type="library">
<name>github.com/wagoodman/go-partybus</name>
<version>v0.0.0-20230516145632-8ccac152c651</version>
<cpe>cpe:2.3:a:wagoodman:go-partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</cpe>
<purl>pkg:golang/github.com/wagoodman/go-partybus@v0.0.0-20230516145632-8ccac152c651</purl>
<properties>
<property name="syft:package:foundBy">go-mod-file-cataloger</property>
<property name="syft:package:language">go</property>
<property name="syft:package:metadataType">GolangModMetadata</property>
<property name="syft:package:type">go-module</property>
<property name="syft:cpe23">cpe:2.3:a:wagoodman:go_partybus:v0.0.0-20230516145632-8ccac152c651:*:*:*:*:*:*:*</property>
<property name="syft:location:0:path">/go.mod</property>
</properties>
</component>
</components>
</bom>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.4" serialNumber="redacted" version="1"> <bom xmlns="http://cyclonedx.org/schema/bom/1.5" serialNumber="redacted" version="1">
<metadata> <metadata>
<timestamp>redacted</timestamp> <timestamp>redacted</timestamp>
<tools> <tools>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.4" serialNumber="redacted" version="1"> <bom xmlns="http://cyclonedx.org/schema/bom/1.5" serialNumber="redacted" version="1">
<metadata> <metadata>
<timestamp>redacted</timestamp> <timestamp>redacted</timestamp>
<tools> <tools>

91
syft/format/decoders.go Normal file
View file

@ -0,0 +1,91 @@
package format
import (
"fmt"
"io"
"github.com/anchore/syft/syft/format/cyclonedxjson"
"github.com/anchore/syft/syft/format/cyclonedxxml"
"github.com/anchore/syft/syft/format/spdxjson"
"github.com/anchore/syft/syft/format/spdxtagvalue"
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/sbom"
)
var (
staticDecoders sbom.FormatDecoder
_ sbom.FormatDecoder = (*DecoderCollection)(nil)
)
func init() {
staticDecoders = NewDecoderCollection(Decoders()...)
}
func Decoders() []sbom.FormatDecoder {
return []sbom.FormatDecoder{
syftjson.NewFormatDecoder(),
cyclonedxxml.NewFormatDecoder(),
cyclonedxjson.NewFormatDecoder(),
spdxtagvalue.NewFormatDecoder(),
spdxjson.NewFormatDecoder(),
}
}
type DecoderCollection struct {
decoders []sbom.FormatDecoder
}
func NewDecoderCollection(decoders ...sbom.FormatDecoder) sbom.FormatDecoder {
return &DecoderCollection{
decoders: decoders,
}
}
// Decode takes a set of bytes and attempts to decode it into an SBOM relative to the decoders in the collection.
func (c *DecoderCollection) Decode(reader io.ReadSeeker) (*sbom.SBOM, sbom.FormatID, string, error) {
if reader == nil {
return nil, "", "", fmt.Errorf("no SBOM bytes provided")
}
var bestID sbom.FormatID
for _, d := range c.decoders {
id, version := d.Identify(reader)
if id == "" || version == "" {
if id != "" {
bestID = id
}
continue
}
return d.Decode(reader)
}
if bestID != "" {
return nil, bestID, "", fmt.Errorf("sbom format found to be %q but the version is not supported", bestID)
}
return nil, "", "", fmt.Errorf("sbom format not recognized")
}
// Identify takes a set of bytes and attempts to identify the format of the SBOM relative to the decoders in the collection.
func (c *DecoderCollection) Identify(reader io.ReadSeeker) (sbom.FormatID, string) {
if reader == nil {
return "", ""
}
for _, d := range c.decoders {
id, version := d.Identify(reader)
if id != "" && version != "" {
return id, version
}
}
return "", ""
}
// Identify takes a set of bytes and attempts to identify the format of the SBOM.
func Identify(reader io.ReadSeeker) (sbom.FormatID, string) {
return staticDecoders.Identify(reader)
}
// Decode takes a set of bytes and attempts to decode it into an SBOM.
func Decode(reader io.ReadSeeker) (*sbom.SBOM, sbom.FormatID, string, error) {
return staticDecoders.Decode(reader)
}

View file

@ -0,0 +1,62 @@
package format
import (
"fmt"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/anchore/syft/syft/format/syftjson"
"github.com/anchore/syft/syft/sbom"
)
func TestIdentify(t *testing.T) {
tests := []struct {
fixture string
id sbom.FormatID
version string
}{
{
fixture: "test-fixtures/alpine-syft.json",
id: syftjson.ID,
version: "1.1.0",
},
}
for _, test := range tests {
t.Run(test.fixture, func(t *testing.T) {
reader, err := os.Open(test.fixture)
assert.NoError(t, err)
id, version := Identify(reader)
assert.Equal(t, test.id, id)
assert.Equal(t, test.version, version)
})
}
}
func TestFormats_EmptyInput(t *testing.T) {
for _, format := range Decoders() {
name := strings.Split(fmt.Sprintf("%#v", format), "{")[0]
t.Run(name, func(t *testing.T) {
t.Run("Decode", func(t *testing.T) {
assert.NotPanics(t, func() {
decodedSBOM, _, _, err := format.Decode(nil)
assert.Error(t, err)
assert.Nil(t, decodedSBOM)
})
})
t.Run("Identify", func(t *testing.T) {
assert.NotPanics(t, func() {
id, version := format.Identify(nil)
assert.Empty(t, id)
assert.Empty(t, version)
})
})
})
}
}

143
syft/format/encoders.go Normal file
View file

@ -0,0 +1,143 @@
package format
import (
"bytes"
"fmt"
"regexp"
"sort"
"strings"
"github.com/scylladb/go-set/strset"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/sbom"
)
type EncoderCollection struct {
encoders []sbom.FormatEncoder
}
func NewEncoderCollection(encoders ...sbom.FormatEncoder) *EncoderCollection {
return &EncoderCollection{
encoders: encoders,
}
}
// IDs returns all format IDs represented in the collection.
func (e EncoderCollection) IDs() []sbom.FormatID {
idSet := strset.New()
for _, f := range e.encoders {
idSet.Add(string(f.ID()))
}
idList := idSet.List()
sort.Strings(idList)
var ids []sbom.FormatID
for _, id := range idList {
ids = append(ids, sbom.FormatID(id))
}
return ids
}
// NameVersions returns all formats that are supported by the collection as a list of "name@version" strings.
func (e EncoderCollection) NameVersions() []string {
set := strset.New()
for _, f := range e.encoders {
if f.Version() == sbom.AnyVersion {
set.Add(string(f.ID()))
} else {
set.Add(fmt.Sprintf("%s@%s", f.ID(), f.Version()))
}
}
list := set.List()
sort.Strings(list)
return list
}
// Aliases returns all format aliases represented in the collection (where an ID would be "spdx-tag-value" the alias would be "spdx").
func (e EncoderCollection) Aliases() []string {
aliases := strset.New()
for _, f := range e.encoders {
aliases.Add(f.Aliases()...)
}
lst := aliases.List()
sort.Strings(lst)
return lst
}
// Get returns the contained encoder for a given format name and version.
func (e EncoderCollection) Get(name string, version string) sbom.FormatEncoder {
log.WithFields("name", name, "version", version).Trace("looking for matching encoder")
name = cleanFormatName(name)
var mostRecentFormat sbom.FormatEncoder
for _, f := range e.encoders {
log.WithFields("name", f.ID(), "version", f.Version(), "aliases", f.Aliases()).Trace("considering format")
names := []string{string(f.ID())}
names = append(names, f.Aliases()...)
for _, n := range names {
if cleanFormatName(n) == name && versionMatches(f.Version(), version) {
if mostRecentFormat == nil || f.Version() > mostRecentFormat.Version() {
mostRecentFormat = f
}
}
}
}
if mostRecentFormat != nil {
log.WithFields("name", mostRecentFormat.ID(), "version", mostRecentFormat.Version()).Trace("found matching encoder")
} else {
log.WithFields("search-name", name, "search-version", version).Trace("no matching encoder found")
}
return mostRecentFormat
}
// GetByString accepts a name@version string, such as:
// - json
// - spdx-json@2.1
// - cdx@1.5
func (e EncoderCollection) GetByString(s string) sbom.FormatEncoder {
parts := strings.SplitN(s, "@", 2)
version := sbom.AnyVersion
if len(parts) > 1 {
version = parts[1]
}
return e.Get(parts[0], version)
}
func versionMatches(version string, match string) bool {
if version == sbom.AnyVersion || match == sbom.AnyVersion {
return true
}
match = strings.ReplaceAll(match, ".", "\\.")
match = strings.ReplaceAll(match, "*", ".*")
match = fmt.Sprintf("^%s(\\..*)*$", match)
matcher, err := regexp.Compile(match)
if err != nil {
return false
}
return matcher.MatchString(version)
}
func cleanFormatName(name string) string {
r := strings.NewReplacer("-", "", "_", "")
return strings.ToLower(r.Replace(name))
}
// Encode takes all SBOM elements and a format option and encodes an SBOM document.
func Encode(s sbom.SBOM, f sbom.FormatEncoder) ([]byte, error) {
buff := bytes.Buffer{}
if err := f.Encode(&buff, s); err != nil {
return nil, fmt.Errorf("unable to encode sbom: %w", err)
}
return buff.Bytes(), nil
}

View file

@ -0,0 +1,111 @@
package format
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/anchore/syft/syft/sbom"
)
func Test_versionMatches(t *testing.T) {
tests := []struct {
name string
version string
match string
matches bool
}{
{
name: "any version matches number",
version: string(sbom.AnyVersion),
match: "6",
matches: true,
},
{
name: "number matches any version",
version: "6",
match: string(sbom.AnyVersion),
matches: true,
},
{
name: "same number matches",
version: "3",
match: "3",
matches: true,
},
{
name: "same major number matches",
version: "3.1",
match: "3",
matches: true,
},
{
name: "same minor number matches",
version: "3.1",
match: "3.1",
matches: true,
},
{
name: "wildcard-version matches minor",
version: "7.1.3",
match: "7.*",
matches: true,
},
{
name: "wildcard-version matches patch",
version: "7.4.8",
match: "7.4.*",
matches: true,
},
{
name: "sub-version matches major",
version: "7.19.11",
match: "7",
matches: true,
},
{
name: "sub-version matches minor",
version: "7.55.2",
match: "7.55",
matches: true,
},
{
name: "sub-version matches patch",
version: "7.32.6",
match: "7.32.6",
matches: true,
},
// negative tests
{
name: "different number does not match",
version: "3",
match: "4",
matches: false,
},
{
name: "sub-version doesn't match major",
version: "7.2.5",
match: "8.2.5",
matches: false,
},
{
name: "sub-version doesn't match minor",
version: "7.2.9",
match: "7.1",
matches: false,
},
{
name: "sub-version doesn't match patch",
version: "7.32.6",
match: "7.32.5",
matches: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matches := versionMatches(test.version, test.match)
assert.Equal(t, test.matches, matches)
})
}
}

Some files were not shown because too many files have changed in this diff Show more