mirror of
https://github.com/anchore/syft
synced 2024-11-10 06:14:16 +00:00
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:
parent
7315f83f9d
commit
7392d607b6
254 changed files with 10450 additions and 2714 deletions
|
@ -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"
|
||||
)
|
||||
|
@ -38,7 +36,7 @@ const (
|
|||
|
||||
type attestOptions struct {
|
||||
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.Catalog `yaml:",inline" mapstructure:",squash"`
|
||||
options.Attest `yaml:",inline" mapstructure:",squash"`
|
||||
|
@ -47,20 +45,23 @@ type attestOptions struct {
|
|||
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":
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 err
|
||||
}
|
||||
o.File = file
|
||||
}
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (o *OutputFile) SBOMWriter(f sbom.Format) (sbom.Writer, error) {
|
||||
return makeSBOMWriterForFormat(f, o.File)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
56
cmd/syft/cli/options/output_file.go
Normal file
56
cmd/syft/cli/options/output_file.go
Normal 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
|
||||
}
|
31
cmd/syft/cli/options/output_template.go
Normal file
31
cmd/syft/cli/options/output_template.go
Normal 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
|
||||
}
|
179
cmd/syft/cli/options/output_test.go
Normal file
179
cmd/syft/cli/options/output_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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, "", "")
|
||||
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
11
go.mod
|
@ -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
|
||||
|
|
84
internal/buffered_seek_reader.go
Normal file
84
internal/buffered_seek_reader.go
Normal 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()
|
||||
}
|
141
internal/buffered_seek_reader_test.go
Normal file
141
internal/buffered_seek_reader_test.go
Normal 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)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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)
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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")
|
|
@ -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",
|
|
@ -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 (
|
|
@ -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"
|
|
@ -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"
|
131
syft/format/cyclonedxjson/decoder.go
Normal file
131
syft/format/cyclonedxjson/decoder.go
Normal 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
|
||||
}
|
118
syft/format/cyclonedxjson/decoder_test.go
Normal file
118
syft/format/cyclonedxjson/decoder_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
52
syft/format/cyclonedxjson/encoder.go
Normal file
52
syft/format/cyclonedxjson/encoder.go
Normal 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
|
||||
}
|
132
syft/format/cyclonedxjson/encoder_test.go
Normal file
132
syft/format/cyclonedxjson/encoder_test.go
Normal 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
|
||||
}
|
1
syft/format/cyclonedxjson/test-fixtures/bad-sbom
Normal file
1
syft/format/cyclonedxjson/test-fixtures/bad-sbom
Normal file
|
@ -0,0 +1 @@
|
|||
not an sbom!
|
33
syft/format/cyclonedxjson/test-fixtures/identify/1.2.json
Normal file
33
syft/format/cyclonedxjson/test-fixtures/identify/1.2.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
59
syft/format/cyclonedxjson/test-fixtures/identify/1.3.json
Normal file
59
syft/format/cyclonedxjson/test-fixtures/identify/1.3.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
59
syft/format/cyclonedxjson/test-fixtures/identify/1.4.json
Normal file
59
syft/format/cyclonedxjson/test-fixtures/identify/1.4.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
59
syft/format/cyclonedxjson/test-fixtures/identify/1.5.json
Normal file
59
syft/format/cyclonedxjson/test-fixtures/identify/1.5.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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": {
|
|
@ -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": {
|
106
syft/format/cyclonedxxml/decoder.go
Normal file
106
syft/format/cyclonedxxml/decoder.go
Normal 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]
|
||||
}
|
118
syft/format/cyclonedxxml/decoder_test.go
Normal file
118
syft/format/cyclonedxxml/decoder_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
58
syft/format/cyclonedxxml/encoder.go
Normal file
58
syft/format/cyclonedxxml/encoder.go
Normal 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
|
||||
}
|
129
syft/format/cyclonedxxml/encoder_test.go
Normal file
129
syft/format/cyclonedxxml/encoder_test.go
Normal 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
|
||||
}
|
1
syft/format/cyclonedxxml/test-fixtures/bad-sbom
Normal file
1
syft/format/cyclonedxxml/test-fixtures/bad-sbom
Normal file
|
@ -0,0 +1 @@
|
|||
not an sbom!
|
12
syft/format/cyclonedxxml/test-fixtures/identify/1.0.xml
Normal file
12
syft/format/cyclonedxxml/test-fixtures/identify/1.0.xml
Normal 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>
|
11
syft/format/cyclonedxxml/test-fixtures/identify/1.1.xml
Normal file
11
syft/format/cyclonedxxml/test-fixtures/identify/1.1.xml
Normal 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>
|
25
syft/format/cyclonedxxml/test-fixtures/identify/1.2.xml
Normal file
25
syft/format/cyclonedxxml/test-fixtures/identify/1.2.xml
Normal 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>
|
33
syft/format/cyclonedxxml/test-fixtures/identify/1.3.xml
Normal file
33
syft/format/cyclonedxxml/test-fixtures/identify/1.3.xml
Normal 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>
|
33
syft/format/cyclonedxxml/test-fixtures/identify/1.4.xml
Normal file
33
syft/format/cyclonedxxml/test-fixtures/identify/1.4.xml
Normal 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>
|
33
syft/format/cyclonedxxml/test-fixtures/identify/1.5.xml
Normal file
33
syft/format/cyclonedxxml/test-fixtures/identify/1.5.xml
Normal 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>
|
|
@ -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>
|
|
@ -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
91
syft/format/decoders.go
Normal 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)
|
||||
}
|
62
syft/format/decoders_test.go
Normal file
62
syft/format/decoders_test.go
Normal 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
143
syft/format/encoders.go
Normal 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
|
||||
}
|
111
syft/format/encoders_test.go
Normal file
111
syft/format/encoders_test.go
Normal 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
Loading…
Reference in a new issue