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/syft/event"
"github.com/anchore/syft/syft/event/monitor"
"github.com/anchore/syft/syft/formats"
"github.com/anchore/syft/syft/formats/github"
"github.com/anchore/syft/syft/formats/syftjson"
"github.com/anchore/syft/syft/formats/table"
"github.com/anchore/syft/syft/formats/template"
"github.com/anchore/syft/syft/formats/text"
"github.com/anchore/syft/syft/format/cyclonedxjson"
"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"
"github.com/anchore/syft/syft/source"
)
@ -37,30 +35,33 @@ const (
)
type attestOptions struct {
options.Config `yaml:",inline" mapstructure:",squash"`
options.SingleOutput `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
options.Catalog `yaml:",inline" mapstructure:",squash"`
options.Attest `yaml:",inline" mapstructure:",squash"`
options.Config `yaml:",inline" mapstructure:",squash"`
options.Output `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
options.Catalog `yaml:",inline" mapstructure:",squash"`
options.Attest `yaml:",inline" mapstructure:",squash"`
}
func Attest(app clio.Application) *cobra.Command {
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{
UpdateCheck: options.DefaultUpdateCheck(),
SingleOutput: options.SingleOutput{
AllowableOptions: allowableOutputs,
Output: syftjson.ID.String(),
Output: options.Output{
AllowMultipleOutputs: false,
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(),
}
@ -95,15 +96,13 @@ func runAttest(id clio.Identification, opts *attestOptions, userInput string) er
return fmt.Errorf("unable to build SBOM: %w", err)
}
o := opts.Output
f, err := os.CreateTemp("", o)
f, err := os.CreateTemp("", "syft-attest-")
if err != nil {
return fmt.Errorf("unable to create temp file: %w", err)
}
defer os.Remove(f.Name())
writer, err := opts.SBOMWriter(f.Name())
writer, err := opts.SBOMWriter()
if err != nil {
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")
}
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
// As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go
var predicateType string
switch strings.ToLower(o) {
switch strings.ToLower(outputName) {
case "cyclonedx-json":
predicateType = "cyclonedx"
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/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/formats"
"github.com/anchore/syft/syft/format"
)
const (
@ -23,7 +23,7 @@ const (
type ConvertOptions struct {
options.Config `yaml:",inline" mapstructure:",squash"`
options.MultiOutput `yaml:",inline" mapstructure:",squash"`
options.Output `yaml:",inline" mapstructure:",squash"`
options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
}
@ -33,6 +33,7 @@ func Convert(app clio.Application) *cobra.Command {
opts := &ConvertOptions{
UpdateCheck: options.DefaultUpdateCheck(),
Output: options.DefaultOutput(),
}
return app.SetupCommand(&cobra.Command{
@ -63,10 +64,13 @@ func RunConvert(opts *ConvertOptions, userInput string) error {
return err
}
var reader io.ReadCloser
var reader io.ReadSeekCloser
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 {
f, err := os.Open(userInput)
if err != nil {
@ -78,7 +82,7 @@ func RunConvert(opts *ConvertOptions, userInput string) error {
reader = f
}
s, _, err := formats.Decode(reader)
s, _, _, err := format.Decode(reader)
if err != nil {
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/log"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/formats/template"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
@ -55,14 +54,14 @@ const (
type packagesOptions struct {
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.Catalog `yaml:",inline" mapstructure:",squash"`
}
func defaultPackagesOptions() *packagesOptions {
return &packagesOptions{
MultiOutput: options.DefaultOutput(),
Output: options.DefaultOutput(),
UpdateCheck: options.DefaultUpdateCheck(),
Catalog: options.DefaultCatalog(),
}
@ -108,11 +107,6 @@ func validateArgs(cmd *cobra.Command, args []string, error string) error {
// nolint:funlen
func runPackages(id clio.Identification, opts *packagesOptions, userInput string) error {
err := validatePackageOutputOptions(&opts.MultiOutput)
if err != nil {
return err
}
writer, err := opts.SBOMWriter()
if err != nil {
return err
@ -235,19 +229,3 @@ func mergeRelationships(cs ...<-chan artifact.Relationship) (relationships []art
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/internal"
"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/source"
)
@ -42,6 +42,9 @@ func PowerUser(app clio.Application) *cobra.Command {
pkgs.FileClassification.Cataloger.Enabled = true
opts := &powerUserOptions{
Catalog: pkgs,
OutputFile: options.OutputFile{ // nolint:staticcheck
Enabled: true,
},
}
return app.SetupCommand(&cobra.Command{
@ -62,7 +65,7 @@ func PowerUser(app clio.Application) *cobra.Command {
//nolint:funlen
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 {
return err
}

View file

@ -2,99 +2,232 @@ package options
import (
"fmt"
"slices"
"sort"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/scylladb/go-set/strset"
"github.com/anchore/clio"
"github.com/anchore/fangs"
"github.com/anchore/syft/syft/formats"
"github.com/anchore/syft/syft/formats/table"
"github.com/anchore/syft/syft/formats/template"
"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"
)
// 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 {
clio.FlagAdder
clio.PostLoader
} = (*OutputFile)(nil)
} = (*Output)(nil)
func (o *OutputFile) AddFlags(flags clio.FlagSet) {
flags.StringVarP(&o.File, "file", "",
"file to write the default report output to (default is STDOUT)")
// Output has the standard output options syft accepts: multiple -o, --file, --template
type Output struct {
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 {
flagSet := pfp.PFlagSet()
flagSet.Lookup("file").Deprecated = "use: output"
func DefaultOutput() Output {
return Output{
AllowMultipleOutputs: true,
Outputs: []string{string(table.ID)},
OutputFile: OutputFile{
Enabled: true,
},
OutputTemplate: OutputTemplate{
Enabled: true,
},
}
}
func (o *OutputFile) PostLoad() error {
if o.File != "" {
file, err := expandFilePath(o.File)
func (o *Output) AddFlags(flags clio.FlagSet) {
var names []string
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 {
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) {
return makeSBOMWriterForFormat(f, o.File)
func (l *encoderList) add(name sbom.FormatID) func(...sbom.FormatEncoder) {
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"
"os"
"path"
"sort"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/mitchellh/go-homedir"
"github.com/scylladb/go-set/strset"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/formats"
"github.com/anchore/syft/syft/formats/table"
"github.com/anchore/syft/syft/formats/template"
"github.com/anchore/syft/syft/format"
"github.com/anchore/syft/syft/format/table"
"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
// 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) {
outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, templateFilePath)
func makeSBOMWriter(outputs []string, defaultFile string, encoders []sbom.FormatEncoder) (sbom.Writer, error) {
outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, encoders)
if err != nil {
return nil, err
}
@ -42,18 +43,10 @@ func makeSBOMWriter(outputs []string, defaultFile, templateFilePath string) (sbo
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
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
if len(outputs) == 0 {
outputs = append(outputs, table.ID.String())
@ -76,29 +69,77 @@ func parseSBOMOutputFlags(outputs []string, defaultFile, templateFilePath string
file = parts[1]
}
format := formats.ByName(name)
if format == nil {
errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, formats.AllIDs()))
enc := encoderCollection.GetByString(name)
if enc == nil {
errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, formatVersionOptions(encoderCollection.NameVersions())))
continue
}
if tmpl, ok := format.(template.OutputFormat); ok {
tmpl.SetTemplatePath(templateFilePath)
format = tmpl
}
out = append(out, newSBOMWriterDescription(format, file))
out = append(out, newSBOMWriterDescription(enc, file))
}
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
type sbomWriterDescription struct {
Format sbom.Format
Format sbom.FormatEncoder
Path string
}
func newSBOMWriterDescription(f sbom.Format, p string) sbomWriterDescription {
func newSBOMWriterDescription(f sbom.FormatEncoder, p string) sbomWriterDescription {
expandedPath, err := homedir.Expand(p)
if err != nil {
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
type sbomStreamWriter struct {
format sbom.Format
format sbom.FormatEncoder
out io.Writer
}
@ -191,7 +232,7 @@ func (w *sbomStreamWriter) Close() error {
// sbomPublisher implements sbom.Writer that publishes results to the event bus
type sbomPublisher struct {
format sbom.Format
format sbom.FormatEncoder
}
// Write the provided SBOM to the data stream

View file

@ -8,43 +8,71 @@ import (
"github.com/docker/docker/pkg/homedir"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/sbom"
)
func Test_MakeSBOMWriter(t *testing.T) {
tests := []struct {
name string
outputs []string
wantErr assert.ErrorAssertionFunc
}{
{
name: "go case",
outputs: []string{"json"},
wantErr: assert.NoError,
},
{
name: "multiple",
outputs: []string{"table", "json"},
wantErr: assert.NoError,
},
{
name: "unknown format",
outputs: []string{"unknown"},
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 {
_, err := makeSBOMWriter(tt.outputs, "", "")
tt.wantErr(t, err)
t.Run(tt.name, func(t *testing.T) {
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
}
func dummyFormat(name string) sbom.Format {
return sbom.NewFormat(sbom.AnyVersion, dummyEncoder, nil, nil, sbom.FormatID(name))
func (d dummyEncoder) Version() string {
return sbom.AnyVersion
}
func (d dummyEncoder) Encode(writer io.Writer, s sbom.SBOM) error {
return nil
}
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/dave/jennifer v1.7.0
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/dustin/go-humanize v1.0.1
github.com/facebookincubator/nvdtools v0.1.5
@ -55,6 +55,7 @@ require (
github.com/pelletier/go-toml v1.9.5
github.com/saferwall/pe v1.4.7
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
// 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
@ -71,16 +72,10 @@ require (
github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1
golang.org/x/mod v0.13.0
golang.org/x/net v0.17.0
golang.org/x/term v0.13.0 // indirect
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.26.0
)
require (
github.com/distribution/reference v0.5.0
github.com/sanity-io/litter v1.5.5
)
require (
dario.cat/mergo v1.0.0 // 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/davecgh/go-spew v1.1.1 // 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/go-connections v0.4.0 // 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/sync v0.3.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/tools v0.13.0 // 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
.PHONY: 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
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

View file

@ -5,3 +5,5 @@
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
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/syft/syft/file"
"github.com/anchore/syft/syft/formats/common"
"github.com/anchore/syft/syft/format/common"
"github.com/anchore/syft/syft/pkg"
)

View file

@ -2,56 +2,18 @@ package cyclonedxhelpers
import (
"fmt"
"io"
"strings"
"github.com/CycloneDX/cyclonedx-go"
"github.com/anchore/packageurl-go"
"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/pkg"
"github.com/anchore/syft/syft/sbom"
"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) {
if bom == nil {
return nil, fmt.Errorf("no content defined in CycloneDX BOM")

View file

@ -1,8 +1,6 @@
package cyclonedxhelpers
import (
"bytes"
"encoding/json"
"fmt"
"testing"
@ -327,18 +325,6 @@ func Test_missingDataDecode(t *testing.T) {
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) {
c1 := cyclonedx.Component{
Name: "c1",

View file

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

View file

@ -19,7 +19,7 @@ import (
"github.com/anchore/syft/internal/spdxlicense"
"github.com/anchore/syft/syft/artifact"
"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/sbom"
"github.com/anchore/syft/syft/source"

View file

@ -18,7 +18,7 @@ import (
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/cpe"
"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/linux"
"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",
"specVersion": "1.4",
"specVersion": "1.5",
"serialNumber": "urn:uuid:redacted",
"version": 1,
"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",
"specVersion": "1.4",
"specVersion": "1.5",
"serialNumber": "urn:uuid:redacted",
"version": 1,
"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"?>
<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>
<timestamp>redacted</timestamp>
<tools>

View file

@ -1,5 +1,5 @@
<?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>
<timestamp>redacted</timestamp>
<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