mirror of
https://github.com/anchore/syft.git
synced 2026-11-05 00:46:38 +01:00
Internalize majority of cmd package (#2533)
* internalize majority of cmd package and migrate integration tests Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * add internal api encoder Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * create internal representation of all formats Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * export capability to get default encoders Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * restore test fixtures 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:
@@ -0,0 +1,17 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"github.com/anchore/clio"
|
||||
)
|
||||
|
||||
type Attest struct {
|
||||
// IMPORTANT: do not show the attestation key/password in any YAML/JSON output (sensitive information)
|
||||
Key secret `yaml:"key" json:"key" mapstructure:"key"`
|
||||
Password secret `yaml:"password" json:"password" mapstructure:"password"`
|
||||
}
|
||||
|
||||
var _ clio.FlagAdder = (*Attest)(nil)
|
||||
|
||||
func (o Attest) AddFlags(flags clio.FlagSet) {
|
||||
flags.StringVarP((*string)(&o.Key), "key", "k", "the key to use for the attestation")
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/iancoleman/strcase"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/fangs"
|
||||
intFile "github.com/anchore/syft/internal/file"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft"
|
||||
"github.com/anchore/syft/syft/cataloging"
|
||||
"github.com/anchore/syft/syft/cataloging/filecataloging"
|
||||
"github.com/anchore/syft/syft/cataloging/pkgcataloging"
|
||||
"github.com/anchore/syft/syft/file/cataloger/filecontent"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/binary"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/golang"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/java"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/javascript"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/kernel"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/python"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
type Catalog struct {
|
||||
// high-level cataloger configuration
|
||||
Catalogers []string `yaml:"-" json:"catalogers" mapstructure:"catalogers"` // deprecated and not shown in yaml output
|
||||
DefaultCatalogers []string `yaml:"default-catalogers" json:"default-catalogers" mapstructure:"default-catalogers"`
|
||||
SelectCatalogers []string `yaml:"select-catalogers" json:"select-catalogers" mapstructure:"select-catalogers"`
|
||||
Package packageConfig `yaml:"package" json:"package" mapstructure:"package"`
|
||||
File fileConfig `yaml:"file" json:"file" mapstructure:"file"`
|
||||
Scope string `yaml:"scope" json:"scope" mapstructure:"scope"`
|
||||
Parallelism int `yaml:"parallelism" json:"parallelism" mapstructure:"parallelism"` // the number of catalog workers to run in parallel
|
||||
Relationships relationshipsConfig `yaml:"relationships" json:"relationships" mapstructure:"relationships"`
|
||||
|
||||
// ecosystem-specific cataloger configuration
|
||||
Golang golangConfig `yaml:"golang" json:"golang" mapstructure:"golang"`
|
||||
Java javaConfig `yaml:"java" json:"java" mapstructure:"java"`
|
||||
JavaScript javaScriptConfig `yaml:"javascript" json:"javascript" mapstructure:"javascript"`
|
||||
LinuxKernel linuxKernelConfig `yaml:"linux-kernel" json:"linux-kernel" mapstructure:"linux-kernel"`
|
||||
Python pythonConfig `yaml:"python" json:"python" mapstructure:"python"`
|
||||
|
||||
// configuration for the source (the subject being analyzed)
|
||||
Registry registryConfig `yaml:"registry" json:"registry" mapstructure:"registry"`
|
||||
Platform string `yaml:"platform" json:"platform" mapstructure:"platform"`
|
||||
Source sourceConfig `yaml:"source" json:"source" mapstructure:"source"`
|
||||
Exclusions []string `yaml:"exclude" json:"exclude" mapstructure:"exclude"`
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
clio.FlagAdder
|
||||
clio.PostLoader
|
||||
fangs.FieldDescriber
|
||||
} = (*Catalog)(nil)
|
||||
|
||||
func DefaultCatalog() Catalog {
|
||||
return Catalog{
|
||||
Scope: source.SquashedScope.String(),
|
||||
Package: defaultPackageConfig(),
|
||||
LinuxKernel: defaultLinuxKernelConfig(),
|
||||
File: defaultFileConfig(),
|
||||
Relationships: defaultRelationshipsConfig(),
|
||||
Source: defaultSourceConfig(),
|
||||
Parallelism: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg Catalog) ToSBOMConfig(id clio.Identification) *syft.CreateSBOMConfig {
|
||||
return syft.DefaultCreateSBOMConfig().
|
||||
WithTool(id.Name, id.Version).
|
||||
WithParallelism(cfg.Parallelism).
|
||||
WithRelationshipsConfig(cfg.ToRelationshipsConfig()).
|
||||
WithSearchConfig(cfg.ToSearchConfig()).
|
||||
WithPackagesConfig(cfg.ToPackagesConfig()).
|
||||
WithFilesConfig(cfg.ToFilesConfig()).
|
||||
WithCatalogerSelection(
|
||||
pkgcataloging.NewSelectionRequest().
|
||||
WithDefaults(cfg.DefaultCatalogers...).
|
||||
WithExpression(cfg.SelectCatalogers...),
|
||||
)
|
||||
}
|
||||
|
||||
func (cfg Catalog) ToSearchConfig() cataloging.SearchConfig {
|
||||
return cataloging.SearchConfig{
|
||||
Scope: source.ParseScope(cfg.Scope),
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg Catalog) ToRelationshipsConfig() cataloging.RelationshipsConfig {
|
||||
return cataloging.RelationshipsConfig{
|
||||
PackageFileOwnership: cfg.Relationships.PackageFileOwnership,
|
||||
PackageFileOwnershipOverlap: cfg.Relationships.PackageFileOwnershipOverlap,
|
||||
// note: this option was surfaced in the syft application configuration before this relationships section was added
|
||||
ExcludeBinaryPackagesWithFileOwnershipOverlap: cfg.Package.ExcludeBinaryOverlapByOwnership,
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg Catalog) ToFilesConfig() filecataloging.Config {
|
||||
hashers, err := intFile.Hashers(cfg.File.Metadata.Digests...)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Warn("unable to configure file hashers")
|
||||
}
|
||||
|
||||
return filecataloging.Config{
|
||||
Selection: cfg.File.Metadata.Selection,
|
||||
Hashers: hashers,
|
||||
Content: filecontent.Config{
|
||||
Globs: cfg.File.Content.Globs,
|
||||
SkipFilesAboveSize: cfg.File.Content.SkipFilesAboveSize,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg Catalog) ToPackagesConfig() pkgcataloging.Config {
|
||||
archiveSearch := cataloging.ArchiveSearchConfig{
|
||||
IncludeIndexedArchives: cfg.Package.SearchIndexedArchives,
|
||||
IncludeUnindexedArchives: cfg.Package.SearchUnindexedArchives,
|
||||
}
|
||||
return pkgcataloging.Config{
|
||||
Binary: binary.DefaultCatalogerConfig(),
|
||||
Golang: golang.DefaultCatalogerConfig().
|
||||
WithSearchLocalModCacheLicenses(cfg.Golang.SearchLocalModCacheLicenses).
|
||||
WithLocalModCacheDir(cfg.Golang.LocalModCacheDir).
|
||||
WithSearchRemoteLicenses(cfg.Golang.SearchRemoteLicenses).
|
||||
WithProxy(cfg.Golang.Proxy).
|
||||
WithNoProxy(cfg.Golang.NoProxy),
|
||||
JavaScript: javascript.DefaultCatalogerConfig().
|
||||
WithSearchRemoteLicenses(cfg.JavaScript.SearchRemoteLicenses).
|
||||
WithNpmBaseURL(cfg.JavaScript.NpmBaseURL),
|
||||
LinuxKernel: kernel.LinuxKernelCatalogerConfig{
|
||||
CatalogModules: cfg.LinuxKernel.CatalogModules,
|
||||
},
|
||||
Python: python.CatalogerConfig{
|
||||
GuessUnpinnedRequirements: cfg.Python.GuessUnpinnedRequirements,
|
||||
},
|
||||
JavaArchive: java.DefaultArchiveCatalogerConfig().
|
||||
WithUseNetwork(cfg.Java.UseNetwork).
|
||||
WithMavenBaseURL(cfg.Java.MavenURL).
|
||||
WithArchiveTraversal(archiveSearch, cfg.Java.MaxParentRecursiveDepth),
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Catalog) AddFlags(flags clio.FlagSet) {
|
||||
var validScopeValues []string
|
||||
for _, scope := range source.AllScopes {
|
||||
validScopeValues = append(validScopeValues, strcase.ToDelimited(string(scope), '-'))
|
||||
}
|
||||
flags.StringVarP(&cfg.Scope, "scope", "s",
|
||||
fmt.Sprintf("selection of layers to catalog, options=%v", validScopeValues))
|
||||
|
||||
flags.StringVarP(&cfg.Platform, "platform", "",
|
||||
"an optional platform specifier for container image sources (e.g. 'linux/arm64', 'linux/arm64/v8', 'arm64', 'linux')")
|
||||
|
||||
flags.StringArrayVarP(&cfg.Exclusions, "exclude", "",
|
||||
"exclude paths from being scanned using a glob expression")
|
||||
|
||||
flags.StringArrayVarP(&cfg.Catalogers, "catalogers", "",
|
||||
"enable one or more package catalogers")
|
||||
|
||||
if pfp, ok := flags.(fangs.PFlagSetProvider); ok {
|
||||
if err := pfp.PFlagSet().MarkDeprecated("catalogers", "use: override-default-catalogers and select-catalogers"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
} else {
|
||||
panic("unable to mark flags as deprecated")
|
||||
}
|
||||
|
||||
flags.StringArrayVarP(&cfg.DefaultCatalogers, "override-default-catalogers", "",
|
||||
"set the base set of catalogers to use (defaults to 'image' or 'directory' depending on the scan source)")
|
||||
|
||||
flags.StringArrayVarP(&cfg.SelectCatalogers, "select-catalogers", "",
|
||||
"add, remove, and filter the catalogers to be used")
|
||||
|
||||
flags.StringVarP(&cfg.Source.Name, "source-name", "",
|
||||
"set the name of the target being analyzed")
|
||||
|
||||
flags.StringVarP(&cfg.Source.Version, "source-version", "",
|
||||
"set the version of the target being analyzed")
|
||||
|
||||
flags.StringVarP(&cfg.Source.BasePath, "base-path", "",
|
||||
"base directory for scanning, no links will be followed above this directory, and all paths will be reported relative to this directory")
|
||||
}
|
||||
|
||||
func (cfg *Catalog) DescribeFields(descriptions fangs.FieldDescriptionSet) {
|
||||
descriptions.Add(&cfg.Parallelism, "number of cataloger workers to run in parallel")
|
||||
}
|
||||
|
||||
func (cfg *Catalog) PostLoad() error {
|
||||
usingLegacyCatalogers := len(cfg.Catalogers) > 0
|
||||
usingNewCatalogers := len(cfg.DefaultCatalogers) > 0 || len(cfg.SelectCatalogers) > 0
|
||||
|
||||
if usingLegacyCatalogers && usingNewCatalogers {
|
||||
return fmt.Errorf("cannot use both 'catalogers' and 'select-catalogers'/'default-catalogers' flags")
|
||||
}
|
||||
|
||||
flatten := func(l []string) []string {
|
||||
var out []string
|
||||
for _, v := range l {
|
||||
for _, s := range strings.Split(v, ",") {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
cfg.Catalogers = flatten(cfg.Catalogers)
|
||||
cfg.DefaultCatalogers = flatten(cfg.DefaultCatalogers)
|
||||
cfg.SelectCatalogers = flatten(cfg.SelectCatalogers)
|
||||
|
||||
// for backwards compatibility
|
||||
cfg.DefaultCatalogers = append(cfg.DefaultCatalogers, cfg.Catalogers...)
|
||||
|
||||
s := source.ParseScope(cfg.Scope)
|
||||
if s == source.UnknownScope {
|
||||
return fmt.Errorf("bad scope value %q", cfg.Scope)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCatalog_PostLoad(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
options Catalog
|
||||
assert func(t *testing.T, options Catalog)
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "mutually exclusive cataloger flags (cat / def-cat)",
|
||||
options: Catalog{
|
||||
Catalogers: []string{"foo,bar", "42"},
|
||||
DefaultCatalogers: []string{"some,thing"},
|
||||
Scope: "squashed",
|
||||
},
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "mutually exclusive cataloger flags (cat / sel-cat)",
|
||||
options: Catalog{
|
||||
Catalogers: []string{"foo,bar", "42"},
|
||||
SelectCatalogers: []string{"some,thing"},
|
||||
Scope: "squashed",
|
||||
},
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "allow old cataloger flags",
|
||||
options: Catalog{
|
||||
Catalogers: []string{"foo,bar"},
|
||||
Scope: "squashed",
|
||||
},
|
||||
assert: func(t *testing.T, options Catalog) {
|
||||
assert.Equal(t, []string{"bar", "foo"}, options.DefaultCatalogers) // note: sorted order
|
||||
assert.Equal(t, []string{"bar", "foo"}, options.Catalogers) // note: sorted order
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "allow new cataloger flags",
|
||||
options: Catalog{
|
||||
SelectCatalogers: []string{"foo,bar", "42"},
|
||||
DefaultCatalogers: []string{"some,thing"},
|
||||
Scope: "squashed",
|
||||
},
|
||||
assert: func(t *testing.T, options Catalog) {
|
||||
assert.Equal(t, []string{"42", "bar", "foo"}, options.SelectCatalogers) // note: sorted order
|
||||
assert.Equal(t, []string{"some", "thing"}, options.DefaultCatalogers) // note: sorted order
|
||||
assert.Empty(t, options.Catalogers)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = assert.NoError
|
||||
}
|
||||
tt.wantErr(t, tt.options.PostLoad(), fmt.Sprintf("PostLoad()"))
|
||||
if tt.assert != nil {
|
||||
tt.assert(t, tt.options)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package options
|
||||
|
||||
import "github.com/anchore/fangs"
|
||||
|
||||
// Config holds a reference to the specific config file that was used to load application configuration
|
||||
type Config struct {
|
||||
ConfigFile string `yaml:"config" json:"config" mapstructure:"config"`
|
||||
}
|
||||
|
||||
func (cfg *Config) DescribeFields(descriptions fangs.FieldDescriptionSet) {
|
||||
descriptions.Add(&cfg.ConfigFile, "the configuration file that was used to load application configuration")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/scylladb/go-set/strset"
|
||||
|
||||
intFile "github.com/anchore/syft/internal/file"
|
||||
"github.com/anchore/syft/syft/file"
|
||||
)
|
||||
|
||||
type fileConfig struct {
|
||||
Metadata fileMetadata `yaml:"metadata" json:"metadata" mapstructure:"metadata"`
|
||||
Content fileContent `yaml:"content" json:"content" mapstructure:"content"`
|
||||
}
|
||||
|
||||
type fileMetadata struct {
|
||||
Selection file.Selection `yaml:"selection" json:"selection" mapstructure:"selection"`
|
||||
Digests []string `yaml:"digests" json:"digests" mapstructure:"digests"`
|
||||
}
|
||||
|
||||
type fileContent struct {
|
||||
SkipFilesAboveSize int64 `yaml:"skip-files-above-size" json:"skip-files-above-size" mapstructure:"skip-files-above-size"`
|
||||
Globs []string `yaml:"globs" json:"globs" mapstructure:"globs"`
|
||||
}
|
||||
|
||||
func defaultFileConfig() fileConfig {
|
||||
return fileConfig{
|
||||
Metadata: fileMetadata{
|
||||
Selection: file.FilesOwnedByPackageSelection,
|
||||
Digests: []string{"sha1", "sha256"},
|
||||
},
|
||||
Content: fileContent{
|
||||
SkipFilesAboveSize: 250 * intFile.KB,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fileConfig) PostLoad() error {
|
||||
digests := strset.New(c.Metadata.Digests...).List()
|
||||
sort.Strings(digests)
|
||||
c.Metadata.Digests = digests
|
||||
|
||||
switch c.Metadata.Selection {
|
||||
case file.NoFilesSelection, file.FilesOwnedByPackageSelection, file.AllFilesSelection:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid file metadata selection: %q", c.Metadata.Selection)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/anchore/syft/syft/file"
|
||||
)
|
||||
|
||||
func Test_fileConfig_PostLoad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg fileConfig
|
||||
assert func(t *testing.T, cfg fileConfig)
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "deduplicate digests",
|
||||
cfg: fileConfig{
|
||||
Metadata: fileMetadata{
|
||||
Selection: file.NoFilesSelection,
|
||||
Digests: []string{"sha1", "sha1"},
|
||||
},
|
||||
},
|
||||
assert: func(t *testing.T, cfg fileConfig) {
|
||||
assert.Equal(t, []string{"sha1"}, cfg.Metadata.Digests)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error on invalid selection",
|
||||
cfg: fileConfig{
|
||||
Metadata: fileMetadata{
|
||||
Selection: file.Selection("invalid"),
|
||||
},
|
||||
},
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "error on empty selection",
|
||||
cfg: fileConfig{},
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = assert.NoError
|
||||
}
|
||||
tt.wantErr(t, tt.cfg.PostLoad())
|
||||
if tt.assert != nil {
|
||||
tt.assert(t, tt.cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/syft/format"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
var _ clio.PostLoader = (*Format)(nil)
|
||||
|
||||
// Format contains all user configuration for output formatting.
|
||||
type Format struct {
|
||||
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
|
||||
Template FormatTemplate `yaml:"template" json:"template" mapstructure:"template"`
|
||||
SyftJSON FormatSyftJSON `yaml:"json" json:"json" mapstructure:"json"`
|
||||
SPDXJSON FormatSPDXJSON `yaml:"spdx-json" json:"spdx-json" mapstructure:"spdx-json"`
|
||||
CyclonedxJSON FormatCyclonedxJSON `yaml:"cyclonedx-json" json:"cyclonedx-json" mapstructure:"cyclonedx-json"`
|
||||
CyclonedxXML FormatCyclonedxXML `yaml:"cyclonedx-xml" json:"cyclonedx-xml" mapstructure:"cyclonedx-xml"`
|
||||
}
|
||||
|
||||
func (o *Format) PostLoad() error {
|
||||
o.SyftJSON.Pretty = multiLevelOption[bool](false, o.Pretty, o.SyftJSON.Pretty)
|
||||
o.SPDXJSON.Pretty = multiLevelOption[bool](false, o.Pretty, o.SPDXJSON.Pretty)
|
||||
o.CyclonedxJSON.Pretty = multiLevelOption[bool](false, o.Pretty, o.CyclonedxJSON.Pretty)
|
||||
o.CyclonedxXML.Pretty = multiLevelOption[bool](false, o.Pretty, o.CyclonedxXML.Pretty)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DefaultFormat() Format {
|
||||
return Format{
|
||||
Template: DefaultFormatTemplate(),
|
||||
SyftJSON: DefaultFormatJSON(),
|
||||
SPDXJSON: DefaultFormatSPDXJSON(),
|
||||
CyclonedxJSON: DefaultFormatCyclonedxJSON(),
|
||||
CyclonedxXML: DefaultFormatCyclonedxXML(),
|
||||
}
|
||||
}
|
||||
|
||||
func (o Format) Encoders() ([]sbom.FormatEncoder, error) {
|
||||
return format.EncodersConfig{
|
||||
Template: o.Template.config(),
|
||||
SyftJSON: o.SyftJSON.config(),
|
||||
SPDXJSON: o.SPDXJSON.config(format.AllVersions), // we support multiple versions, not just a single version
|
||||
CyclonedxJSON: o.CyclonedxJSON.config(format.AllVersions), // we support multiple versions, not just a single version
|
||||
CyclonedxXML: o.CyclonedxXML.config(format.AllVersions), // we support multiple versions, not just a single version
|
||||
}.Encoders()
|
||||
}
|
||||
|
||||
func multiLevelOption[T any](defaultValue T, option ...*T) *T {
|
||||
result := defaultValue
|
||||
for _, opt := range option {
|
||||
if opt != nil {
|
||||
result = *opt
|
||||
}
|
||||
}
|
||||
return &result
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/format/cyclonedxjson"
|
||||
)
|
||||
|
||||
type FormatCyclonedxJSON struct {
|
||||
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
|
||||
}
|
||||
|
||||
func DefaultFormatCyclonedxJSON() FormatCyclonedxJSON {
|
||||
return FormatCyclonedxJSON{}
|
||||
}
|
||||
|
||||
func (o FormatCyclonedxJSON) config(version string) cyclonedxjson.EncoderConfig {
|
||||
var pretty bool
|
||||
if o.Pretty != nil {
|
||||
pretty = *o.Pretty
|
||||
}
|
||||
return cyclonedxjson.EncoderConfig{
|
||||
Version: version,
|
||||
Pretty: pretty,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatCyclonedxJSON_buildConfig(t *testing.T) {
|
||||
// assert when building the config that we respond to all possible fields
|
||||
|
||||
ft := &FormatCyclonedxJSON{}
|
||||
ft = setAllToNonZero(t, ft).(*FormatCyclonedxJSON)
|
||||
|
||||
subject := ft.config("Version")
|
||||
assertExpectedValue(t, subject)
|
||||
}
|
||||
|
||||
func setAllToNonZero(t testing.TB, structPtr any) any {
|
||||
// set all fields on the struct to non-zero values
|
||||
rt := reflect.TypeOf(structPtr)
|
||||
if rt.Kind() != reflect.Ptr || rt.Elem().Kind() != reflect.Struct {
|
||||
t.Fatal("expected a pointer to a struct")
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(structPtr).Elem()
|
||||
for i := 0; i < rt.Elem().NumField(); i++ {
|
||||
val := getNonZeroExampleValue(t, rv.Field(i).Interface(), rt.Elem().Field(i).Name)
|
||||
rv.Field(i).Set(reflect.ValueOf(val))
|
||||
}
|
||||
return structPtr
|
||||
}
|
||||
|
||||
func getNonZeroExampleValue(t testing.TB, v any, name string) any {
|
||||
switch v.(type) {
|
||||
case bool:
|
||||
return true
|
||||
case *bool:
|
||||
val := true
|
||||
return &val
|
||||
case string:
|
||||
return name
|
||||
}
|
||||
t.Fatalf("unsupported type: %T", v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertExpectedValue(t *testing.T, structTy any) {
|
||||
rt := reflect.TypeOf(structTy)
|
||||
rv := reflect.ValueOf(structTy)
|
||||
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
f := rt.Field(i)
|
||||
fieldValue := rv.Field(i)
|
||||
|
||||
if fieldValue.Type().Kind() == reflect.String {
|
||||
// use the field name as the expected value
|
||||
assert.Equalf(t, f.Name, fieldValue.String(), "field %q value differs", f.Name)
|
||||
} else {
|
||||
// use the zero value for the type
|
||||
if reflect.DeepEqual(fieldValue.Interface(), reflect.Zero(fieldValue.Type()).Interface()) {
|
||||
t.Errorf("field '%s' is zero", f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/format/cyclonedxxml"
|
||||
)
|
||||
|
||||
type FormatCyclonedxXML struct {
|
||||
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
|
||||
}
|
||||
|
||||
func DefaultFormatCyclonedxXML() FormatCyclonedxXML {
|
||||
return FormatCyclonedxXML{}
|
||||
}
|
||||
|
||||
func (o FormatCyclonedxXML) config(version string) cyclonedxxml.EncoderConfig {
|
||||
var pretty bool
|
||||
if o.Pretty != nil {
|
||||
pretty = *o.Pretty
|
||||
}
|
||||
return cyclonedxxml.EncoderConfig{
|
||||
Version: version,
|
||||
Pretty: pretty,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatCyclonedxXML_buildConfig(t *testing.T) {
|
||||
// assert when building the config that we respond to all possible fields
|
||||
|
||||
ft := FormatCyclonedxXML{}
|
||||
ftp := setAllToNonZero(t, &ft).(*FormatCyclonedxXML)
|
||||
|
||||
subject := ftp.config("Version")
|
||||
assertExpectedValue(t, subject)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/format/spdxjson"
|
||||
)
|
||||
|
||||
type FormatSPDXJSON struct {
|
||||
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
|
||||
}
|
||||
|
||||
func DefaultFormatSPDXJSON() FormatSPDXJSON {
|
||||
return FormatSPDXJSON{}
|
||||
}
|
||||
|
||||
func (o FormatSPDXJSON) config(v string) spdxjson.EncoderConfig {
|
||||
var pretty bool
|
||||
if o.Pretty != nil {
|
||||
pretty = *o.Pretty
|
||||
}
|
||||
return spdxjson.EncoderConfig{
|
||||
Version: v,
|
||||
Pretty: pretty,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatSPDXJSON_buildConfig(t *testing.T) {
|
||||
// assert when building the config that we respond to all possible fields
|
||||
|
||||
ft := &FormatSPDXJSON{}
|
||||
ft = setAllToNonZero(t, ft).(*FormatSPDXJSON)
|
||||
|
||||
subject := ft.config("Version")
|
||||
assertExpectedValue(t, subject)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/format/syftjson"
|
||||
)
|
||||
|
||||
type FormatSyftJSON struct {
|
||||
Legacy bool `yaml:"legacy" json:"legacy" mapstructure:"legacy"`
|
||||
Pretty *bool `yaml:"pretty" json:"pretty" mapstructure:"pretty"`
|
||||
}
|
||||
|
||||
func DefaultFormatJSON() FormatSyftJSON {
|
||||
return FormatSyftJSON{
|
||||
Legacy: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (o FormatSyftJSON) config() syftjson.EncoderConfig {
|
||||
var pretty bool
|
||||
if o.Pretty != nil {
|
||||
pretty = *o.Pretty
|
||||
}
|
||||
return syftjson.EncoderConfig{
|
||||
Legacy: o.Legacy,
|
||||
Pretty: pretty,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatSyftJSON_buildConfig(t *testing.T) {
|
||||
// assert when building the config that we respond to all possible fields
|
||||
|
||||
ft := &FormatSyftJSON{}
|
||||
ft = setAllToNonZero(t, ft).(*FormatSyftJSON)
|
||||
|
||||
subject := ft.config()
|
||||
assertExpectedValue(t, subject)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/syft/format/template"
|
||||
)
|
||||
|
||||
var _ clio.FlagAdder = (*FormatTemplate)(nil)
|
||||
|
||||
type FormatTemplate struct {
|
||||
Enabled bool `yaml:"-" json:"-" mapstructure:"-"`
|
||||
Path string `yaml:"path" json:"path" mapstructure:"path"` // -t template file to use for output
|
||||
}
|
||||
|
||||
func DefaultFormatTemplate() FormatTemplate {
|
||||
return FormatTemplate{
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *FormatTemplate) AddFlags(flags clio.FlagSet) {
|
||||
if o.Enabled {
|
||||
flags.StringVarP(&o.Path, "template", "t",
|
||||
"specify the path to a Go template file")
|
||||
}
|
||||
}
|
||||
|
||||
func (o FormatTemplate) config() template.EncoderConfig {
|
||||
return template.EncoderConfig{
|
||||
TemplatePath: o.Path,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package options
|
||||
|
||||
type golangConfig struct {
|
||||
SearchLocalModCacheLicenses bool `json:"search-local-mod-cache-licenses" yaml:"search-local-mod-cache-licenses" mapstructure:"search-local-mod-cache-licenses"`
|
||||
LocalModCacheDir string `json:"local-mod-cache-dir" yaml:"local-mod-cache-dir" mapstructure:"local-mod-cache-dir"`
|
||||
SearchRemoteLicenses bool `json:"search-remote-licenses" yaml:"search-remote-licenses" mapstructure:"search-remote-licenses"`
|
||||
Proxy string `json:"proxy" yaml:"proxy" mapstructure:"proxy"`
|
||||
NoProxy string `json:"no-proxy" yaml:"no-proxy" mapstructure:"no-proxy"`
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package options
|
||||
|
||||
type javaConfig struct {
|
||||
UseNetwork bool `yaml:"use-network" json:"use-network" mapstructure:"use-network"`
|
||||
MavenURL string `yaml:"maven-url" json:"maven-url" mapstructure:"maven-url"`
|
||||
MaxParentRecursiveDepth int `yaml:"max-parent-recursive-depth" json:"max-parent-recursive-depth" mapstructure:"max-parent-recursive-depth"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package options
|
||||
|
||||
type javaScriptConfig struct {
|
||||
SearchRemoteLicenses bool `json:"search-remote-licenses" yaml:"search-remote-licenses" mapstructure:"search-remote-licenses"`
|
||||
NpmBaseURL string `json:"npm-base-url" yaml:"npm-base-url" mapstructure:"npm-base-url"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package options
|
||||
|
||||
type linuxKernelConfig struct {
|
||||
CatalogModules bool `json:"catalog-modules" yaml:"catalog-modules" mapstructure:"catalog-modules"`
|
||||
}
|
||||
|
||||
func defaultLinuxKernelConfig() linuxKernelConfig {
|
||||
return linuxKernelConfig{
|
||||
CatalogModules: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/scylladb/go-set/strset"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"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"
|
||||
)
|
||||
|
||||
var _ interface {
|
||||
clio.FlagAdder
|
||||
clio.PostLoader
|
||||
} = (*Output)(nil)
|
||||
|
||||
// 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:"-"`
|
||||
AllowToFile 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"`
|
||||
Format `yaml:"format" json:"format" mapstructure:"format"`
|
||||
}
|
||||
|
||||
func DefaultOutput() Output {
|
||||
return Output{
|
||||
AllowMultipleOutputs: true,
|
||||
AllowToFile: true,
|
||||
Outputs: []string{string(table.ID)},
|
||||
OutputFile: OutputFile{
|
||||
Enabled: true,
|
||||
},
|
||||
Format: DefaultFormat(),
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Output) PostLoad() error {
|
||||
var errs error
|
||||
for _, loader := range []clio.PostLoader{&o.OutputFile, &o.Format} {
|
||||
if err := loader.PostLoad(); err != nil {
|
||||
errs = multierror.Append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
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.Format.Template.Path == "" {
|
||||
return nil, fmt.Errorf(`must specify path to template file when using "template" output format`)
|
||||
}
|
||||
|
||||
encoders, err := o.Encoders()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !o.AllowToFile {
|
||||
for _, opt := range o.Outputs {
|
||||
if strings.Contains(opt, "=") {
|
||||
return nil, fmt.Errorf("file output is not allowed ('-o format=path' should be '-o format')")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return makeSBOMWriter(o.Outputs, o.LegacyFile, encoders)
|
||||
}
|
||||
|
||||
func (o Output) OutputNameSet() *strset.Set {
|
||||
names := strset.New()
|
||||
for _, output := range o.Outputs {
|
||||
fields := strings.Split(output, "=")
|
||||
names.Add(fields[0])
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mitchellh/go-homedir"
|
||||
|
||||
"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:"-"`
|
||||
LegacyFile string `yaml:"-" json:"-" mapstructure:"legacyFile"`
|
||||
}
|
||||
|
||||
func (o *OutputFile) AddFlags(flags clio.FlagSet) {
|
||||
if o.Enabled {
|
||||
flags.StringVarP(&o.LegacyFile, "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.LegacyFile != "" {
|
||||
file, err := expandFilePath(o.LegacyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.LegacyFile = 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.LegacyFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return writer, nil
|
||||
}
|
||||
|
||||
func expandFilePath(file string) (string, error) {
|
||||
if file != "" {
|
||||
expandedPath, err := homedir.Expand(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to expand file path=%q: %w", file, err)
|
||||
}
|
||||
file = expandedPath
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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()
|
||||
require.NoError(t, opts.Format.PostLoad())
|
||||
opts.Format.Template.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()
|
||||
require.NoError(t, opts.Format.PostLoad())
|
||||
opts.Format.Template.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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_OutputHonorsAllowFile(t *testing.T) {
|
||||
o := DefaultOutput()
|
||||
|
||||
t.Run("file is not allowed", func(t *testing.T) {
|
||||
o.AllowToFile = false
|
||||
o.Outputs = []string{"table=/tmp/somefile"}
|
||||
|
||||
w, err := o.SBOMWriter()
|
||||
assert.Nil(t, w)
|
||||
assert.ErrorContains(t, err, "file output is not allowed")
|
||||
})
|
||||
|
||||
t.Run("file is allowed", func(t *testing.T) {
|
||||
o.AllowToFile = true
|
||||
o.Outputs = []string{"table=/tmp/somefile"}
|
||||
|
||||
w, err := o.SBOMWriter()
|
||||
assert.NotNil(t, w)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package options
|
||||
|
||||
import "github.com/anchore/syft/syft/cataloging"
|
||||
|
||||
type packageConfig struct {
|
||||
SearchUnindexedArchives bool `yaml:"search-unindexed-archives" json:"search-unindexed-archives" mapstructure:"search-unindexed-archives"`
|
||||
SearchIndexedArchives bool `yaml:"search-indexed-archives" json:"search-indexed-archives" mapstructure:"search-indexed-archives"`
|
||||
ExcludeBinaryOverlapByOwnership bool `yaml:"exclude-binary-overlap-by-ownership" json:"exclude-binary-overlap-by-ownership" mapstructure:"exclude-binary-overlap-by-ownership"` // exclude synthetic binary packages owned by os package files
|
||||
}
|
||||
|
||||
func defaultPackageConfig() packageConfig {
|
||||
c := cataloging.DefaultArchiveSearchConfig()
|
||||
return packageConfig{
|
||||
SearchIndexedArchives: c.IncludeIndexedArchives,
|
||||
SearchUnindexedArchives: c.IncludeUnindexedArchives,
|
||||
ExcludeBinaryOverlapByOwnership: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package options
|
||||
|
||||
type pythonConfig struct {
|
||||
GuessUnpinnedRequirements bool `json:"guess-unpinned-requirements" yaml:"guess-unpinned-requirements" mapstructure:"guess-unpinned-requirements"`
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/stereoscope/pkg/image"
|
||||
)
|
||||
|
||||
type RegistryCredentials struct {
|
||||
Authority string `yaml:"authority" json:"authority" mapstructure:"authority"`
|
||||
// IMPORTANT: do not show any credential information, use secret type to automatically redact the values
|
||||
Username secret `yaml:"username" json:"username" mapstructure:"username"`
|
||||
Password secret `yaml:"password" json:"password" mapstructure:"password"`
|
||||
Token secret `yaml:"token" json:"token" mapstructure:"token"`
|
||||
|
||||
TLSCert string `yaml:"tls-cert,omitempty" json:"tls-cert,omitempty" mapstructure:"tls-cert"`
|
||||
TLSKey string `yaml:"tls-key,omitempty" json:"tls-key,omitempty" mapstructure:"tls-key"`
|
||||
}
|
||||
|
||||
type registryConfig struct {
|
||||
InsecureSkipTLSVerify bool `yaml:"insecure-skip-tls-verify" json:"insecure-skip-tls-verify" mapstructure:"insecure-skip-tls-verify"`
|
||||
InsecureUseHTTP bool `yaml:"insecure-use-http" json:"insecure-use-http" mapstructure:"insecure-use-http"`
|
||||
Auth []RegistryCredentials `yaml:"auth" json:"auth" mapstructure:"auth"`
|
||||
CACert string `yaml:"ca-cert" json:"ca-cert" mapstructure:"ca-cert"`
|
||||
}
|
||||
|
||||
var _ clio.PostLoader = (*registryConfig)(nil)
|
||||
|
||||
func (cfg *registryConfig) PostLoad() error {
|
||||
// there may be additional credentials provided by env var that should be appended to the set of credentials
|
||||
authority, username, password, token, tlsCert, tlsKey :=
|
||||
os.Getenv("SYFT_REGISTRY_AUTH_AUTHORITY"),
|
||||
os.Getenv("SYFT_REGISTRY_AUTH_USERNAME"),
|
||||
os.Getenv("SYFT_REGISTRY_AUTH_PASSWORD"),
|
||||
os.Getenv("SYFT_REGISTRY_AUTH_TOKEN"),
|
||||
os.Getenv("SYFT_REGISTRY_AUTH_TLS_CERT"),
|
||||
os.Getenv("SYFT_REGISTRY_AUTH_TLS_KEY")
|
||||
|
||||
if hasNonEmptyCredentials(username, password, token, tlsCert, tlsKey) {
|
||||
// note: we prepend the credentials such that the environment variables take precedence over on-disk configuration.
|
||||
// since this PostLoad is called before the PostLoad on the Auth credentials list,
|
||||
// all appropriate redactions will be added
|
||||
cfg.Auth = append([]RegistryCredentials{
|
||||
{
|
||||
Authority: authority,
|
||||
Username: secret(username),
|
||||
Password: secret(password),
|
||||
Token: secret(token),
|
||||
TLSCert: tlsCert,
|
||||
TLSKey: tlsKey,
|
||||
},
|
||||
}, cfg.Auth...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasNonEmptyCredentials(username, password, token, tlsCert, tlsKey string) bool {
|
||||
hasUserPass := username != "" && password != ""
|
||||
hasToken := token != ""
|
||||
hasTLSMaterial := tlsCert != "" && tlsKey != ""
|
||||
return hasUserPass || hasToken || hasTLSMaterial
|
||||
}
|
||||
|
||||
func (cfg *registryConfig) ToOptions() *image.RegistryOptions {
|
||||
var auth = make([]image.RegistryCredentials, len(cfg.Auth))
|
||||
for i, a := range cfg.Auth {
|
||||
auth[i] = image.RegistryCredentials{
|
||||
Authority: a.Authority,
|
||||
Username: a.Username.String(),
|
||||
Password: a.Password.String(),
|
||||
Token: a.Token.String(),
|
||||
ClientCert: a.TLSCert,
|
||||
ClientKey: a.TLSKey,
|
||||
}
|
||||
}
|
||||
|
||||
return &image.RegistryOptions{
|
||||
InsecureSkipTLSVerify: cfg.InsecureSkipTLSVerify,
|
||||
InsecureUseHTTP: cfg.InsecureUseHTTP,
|
||||
Credentials: auth,
|
||||
CAFileOrDir: cfg.CACert,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/anchore/stereoscope/pkg/image"
|
||||
)
|
||||
|
||||
func TestHasNonEmptyCredentials(t *testing.T) {
|
||||
tests := []struct {
|
||||
username, password, token, cert, key string
|
||||
expected bool
|
||||
}{
|
||||
|
||||
{
|
||||
"", "", "", "", "",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"user", "", "", "", "",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"", "pass", "", "", "",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"", "pass", "tok", "", "",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"user", "", "tok", "", "",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"", "", "tok", "", "",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"user", "pass", "tok", "", "",
|
||||
true,
|
||||
},
|
||||
|
||||
{
|
||||
"user", "pass", "", "", "",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"", "", "", "cert", "key",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"", "", "", "cert", "",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"", "", "", "", "key",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("%+v", test), func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, hasNonEmptyCredentials(test.username, test.password, test.token, test.cert, test.key))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_registry_ToOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input registryConfig
|
||||
expected image.RegistryOptions
|
||||
}{
|
||||
{
|
||||
name: "no registry options",
|
||||
input: registryConfig{},
|
||||
expected: image.RegistryOptions{
|
||||
Credentials: []image.RegistryCredentials{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set InsecureSkipTLSVerify",
|
||||
input: registryConfig{
|
||||
InsecureSkipTLSVerify: true,
|
||||
},
|
||||
expected: image.RegistryOptions{
|
||||
InsecureSkipTLSVerify: true,
|
||||
Credentials: []image.RegistryCredentials{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set InsecureUseHTTP",
|
||||
input: registryConfig{
|
||||
InsecureUseHTTP: true,
|
||||
},
|
||||
expected: image.RegistryOptions{
|
||||
InsecureUseHTTP: true,
|
||||
Credentials: []image.RegistryCredentials{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set all bool options",
|
||||
input: registryConfig{
|
||||
InsecureSkipTLSVerify: true,
|
||||
InsecureUseHTTP: true,
|
||||
},
|
||||
expected: image.RegistryOptions{
|
||||
InsecureSkipTLSVerify: true,
|
||||
InsecureUseHTTP: true,
|
||||
Credentials: []image.RegistryCredentials{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "provide all tls configuration",
|
||||
input: registryConfig{
|
||||
CACert: "ca.crt",
|
||||
InsecureSkipTLSVerify: true,
|
||||
Auth: []RegistryCredentials{
|
||||
{
|
||||
TLSCert: "client.crt",
|
||||
TLSKey: "client.key",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: image.RegistryOptions{
|
||||
CAFileOrDir: "ca.crt",
|
||||
InsecureSkipTLSVerify: true,
|
||||
Credentials: []image.RegistryCredentials{
|
||||
{
|
||||
ClientCert: "client.crt",
|
||||
ClientKey: "client.key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assert.Equal(t, &test.expected, test.input.ToOptions())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package options
|
||||
|
||||
import "github.com/anchore/fangs"
|
||||
|
||||
var _ fangs.FieldDescriber = (*relationshipsConfig)(nil)
|
||||
|
||||
type relationshipsConfig struct {
|
||||
PackageFileOwnership bool `mapstructure:"package-file-ownership" json:"package-file-ownership" yaml:"package-file-ownership"`
|
||||
PackageFileOwnershipOverlap bool `mapstructure:"package-file-ownership-overlap" json:"package-file-ownership-overlap" yaml:"package-file-ownership-overlap"`
|
||||
}
|
||||
|
||||
func defaultRelationshipsConfig() relationshipsConfig {
|
||||
return relationshipsConfig{
|
||||
PackageFileOwnership: true,
|
||||
PackageFileOwnershipOverlap: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *relationshipsConfig) DescribeFields(descriptions fangs.FieldDescriptionSet) {
|
||||
descriptions.Add(&r.PackageFileOwnership, "include package-to-file relationships that indicate which files are owned by which packages.")
|
||||
descriptions.Add(&r.PackageFileOwnershipOverlap, "include package-to-package relationships that indicate one package is owned by another due to files claimed to be owned by one package are also evidence of another package's existence.")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/internal/redact"
|
||||
)
|
||||
|
||||
type secret string
|
||||
|
||||
var _ interface {
|
||||
fmt.Stringer
|
||||
clio.PostLoader
|
||||
} = (*secret)(nil)
|
||||
|
||||
// PostLoad needs to use a pointer receiver, even if it's not modifying the value
|
||||
func (r *secret) PostLoad() error {
|
||||
redact.Add(string(*r))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r secret) String() string {
|
||||
return string(r)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/scylladb/go-set/strset"
|
||||
)
|
||||
|
||||
type sourceConfig struct {
|
||||
Name string `json:"name" yaml:"name" mapstructure:"name"`
|
||||
Version string `json:"version" yaml:"version" mapstructure:"version"`
|
||||
BasePath string `yaml:"base-path" json:"base-path" mapstructure:"base-path"` // specify base path for all file paths
|
||||
File fileSource `json:"file" yaml:"file" mapstructure:"file"`
|
||||
Image imageSource `json:"image" yaml:"image" mapstructure:"image"`
|
||||
}
|
||||
|
||||
type fileSource struct {
|
||||
Digests []string `json:"digests" yaml:"digests" mapstructure:"digests"`
|
||||
}
|
||||
|
||||
type imageSource struct {
|
||||
DefaultPullSource string `json:"default-pull-source" yaml:"default-pull-source" mapstructure:"default-pull-source"`
|
||||
}
|
||||
|
||||
func defaultSourceConfig() sourceConfig {
|
||||
return sourceConfig{
|
||||
File: fileSource{
|
||||
Digests: []string{"sha256"},
|
||||
},
|
||||
Image: imageSource{
|
||||
DefaultPullSource: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fileSource) PostLoad() error {
|
||||
digests := strset.New(c.Digests...).List()
|
||||
sort.Strings(digests)
|
||||
c.Digests = digests
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c imageSource) PostLoad() error {
|
||||
return checkDefaultSourceValues(c.DefaultPullSource)
|
||||
}
|
||||
|
||||
var validDefaultSourceValues = []string{"registry", "docker", "podman", ""}
|
||||
|
||||
func checkDefaultSourceValues(source string) error {
|
||||
validValues := strset.New(validDefaultSourceValues...)
|
||||
if !validValues.Has(source) {
|
||||
validValuesString := strings.Join(validDefaultSourceValues, ", ")
|
||||
return fmt.Errorf("%s is not a valid default source; please use one of the following: %s''", source, validValuesString)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_fileSource_PostLoad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg fileSource
|
||||
assert func(t *testing.T, cfg fileSource)
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "deduplicate digests",
|
||||
cfg: fileSource{
|
||||
Digests: []string{"sha1", "sha1"},
|
||||
},
|
||||
assert: func(t *testing.T, cfg fileSource) {
|
||||
assert.Equal(t, []string{"sha1"}, cfg.Digests)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = assert.NoError
|
||||
}
|
||||
tt.wantErr(t, tt.cfg.PostLoad())
|
||||
if tt.assert != nil {
|
||||
tt.assert(t, tt.cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package options
|
||||
|
||||
import "github.com/anchore/fangs"
|
||||
|
||||
type UpdateCheck struct {
|
||||
CheckForAppUpdate bool `yaml:"check-for-app-update" json:"check-for-app-update" mapstructure:"check-for-app-update"` // whether to check for an application update on start up or not
|
||||
}
|
||||
|
||||
func DefaultUpdateCheck() UpdateCheck {
|
||||
return UpdateCheck{
|
||||
CheckForAppUpdate: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *UpdateCheck) DescribeFields(descriptions fangs.FieldDescriptionSet) {
|
||||
descriptions.Add(&cfg.CheckForAppUpdate, "whether to check for an application update on start up or not")
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"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/format"
|
||||
"github.com/anchore/syft/syft/format/table"
|
||||
"github.com/anchore/syft/syft/sbom"
|
||||
)
|
||||
|
||||
var _ sbom.Writer = (*sbomMultiWriter)(nil)
|
||||
|
||||
var _ interface {
|
||||
io.Closer
|
||||
sbom.Writer
|
||||
} = (*sbomStreamWriter)(nil)
|
||||
|
||||
// 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 string, encoders []sbom.FormatEncoder) (sbom.Writer, error) {
|
||||
outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, encoders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
writer, err := newSBOMMultiWriter(outputOptions...)
|
||||
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 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())
|
||||
}
|
||||
|
||||
for _, name := range outputs {
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
// split to at most two parts for <format>=<file>
|
||||
parts := strings.SplitN(name, "=", 2)
|
||||
|
||||
// the format name is the first part
|
||||
name = parts[0]
|
||||
|
||||
// default to the --file or empty string if not specified
|
||||
file := defaultFile
|
||||
|
||||
// If a file is specified as part of the output formatName, use that
|
||||
if len(parts) > 1 {
|
||||
file = parts[1]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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.FormatEncoder
|
||||
Path string
|
||||
}
|
||||
|
||||
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)
|
||||
// ignore errors
|
||||
expandedPath = p
|
||||
}
|
||||
return sbomWriterDescription{
|
||||
Format: f,
|
||||
Path: expandedPath,
|
||||
}
|
||||
}
|
||||
|
||||
// sbomMultiWriter holds a list of child sbom.Writers to apply all Write and Close operations to
|
||||
type sbomMultiWriter struct {
|
||||
writers []sbom.Writer
|
||||
}
|
||||
|
||||
// newSBOMMultiWriter create all report writers from input options; if a file is not specified the given defaultWriter is used
|
||||
func newSBOMMultiWriter(options ...sbomWriterDescription) (_ *sbomMultiWriter, err error) {
|
||||
if len(options) == 0 {
|
||||
return nil, fmt.Errorf("no output options provided")
|
||||
}
|
||||
|
||||
out := &sbomMultiWriter{}
|
||||
|
||||
for _, option := range options {
|
||||
switch len(option.Path) {
|
||||
case 0:
|
||||
out.writers = append(out.writers, &sbomPublisher{
|
||||
format: option.Format,
|
||||
})
|
||||
default:
|
||||
// create any missing subdirectories
|
||||
dir := path.Dir(option.Path)
|
||||
if dir != "" {
|
||||
s, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
err = os.MkdirAll(dir, 0755) // maybe should be os.ModePerm ?
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if !s.IsDir() {
|
||||
return nil, fmt.Errorf("output path does not contain a valid directory: %s", option.Path)
|
||||
}
|
||||
}
|
||||
fileOut, err := os.OpenFile(option.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create report file: %w", err)
|
||||
}
|
||||
out.writers = append(out.writers, &sbomStreamWriter{
|
||||
format: option.Format,
|
||||
out: fileOut,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Write writes the SBOM to all writers
|
||||
func (m *sbomMultiWriter) Write(s sbom.SBOM) (errs error) {
|
||||
for _, w := range m.writers {
|
||||
err := w.Write(s)
|
||||
if err != nil {
|
||||
errs = multierror.Append(errs, fmt.Errorf("unable to write SBOM: %w", err))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// sbomStreamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup
|
||||
type sbomStreamWriter struct {
|
||||
format sbom.FormatEncoder
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
// Write the provided SBOM to the data stream
|
||||
func (w *sbomStreamWriter) Write(s sbom.SBOM) error {
|
||||
defer w.Close()
|
||||
return w.format.Encode(w.out, s)
|
||||
}
|
||||
|
||||
// Close any resources, such as open files
|
||||
func (w *sbomStreamWriter) Close() error {
|
||||
if closer, ok := w.out.(io.Closer); ok {
|
||||
return closer.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sbomPublisher implements sbom.Writer that publishes results to the event bus
|
||||
type sbomPublisher struct {
|
||||
format sbom.FormatEncoder
|
||||
}
|
||||
|
||||
// Write the provided SBOM to the data stream
|
||||
func (w *sbomPublisher) Write(s sbom.SBOM) error {
|
||||
buf := &bytes.Buffer{}
|
||||
if err := w.format.Encode(buf, s); err != nil {
|
||||
return fmt.Errorf("unable to encode SBOM: %w", err)
|
||||
}
|
||||
|
||||
bus.Report(buf.String())
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package options
|
||||
|
||||
import (
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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:`)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
opt := DefaultOutput()
|
||||
require.NoError(t, opt.Format.PostLoad())
|
||||
encoders, err := opt.Encoders()
|
||||
require.NoError(t, err)
|
||||
_, err = makeSBOMWriter(tt.outputs, "", encoders)
|
||||
tt.wantErr(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 (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) {
|
||||
type writerConfig struct {
|
||||
format string
|
||||
file string
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
|
||||
testName := func(options []sbomWriterDescription, err bool) string {
|
||||
var out []string
|
||||
for _, opt := range options {
|
||||
out = append(out, string(opt.Format.ID())+"="+opt.Path)
|
||||
}
|
||||
errs := ""
|
||||
if err {
|
||||
errs = "(err)"
|
||||
}
|
||||
return strings.Join(out, ", ") + errs
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
outputs []sbomWriterDescription
|
||||
err bool
|
||||
expected []writerConfig
|
||||
}{
|
||||
{
|
||||
outputs: []sbomWriterDescription{},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
outputs: []sbomWriterDescription{
|
||||
{
|
||||
Format: dummyFormat("table"),
|
||||
Path: "",
|
||||
},
|
||||
},
|
||||
expected: []writerConfig{
|
||||
{
|
||||
format: "table",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
outputs: []sbomWriterDescription{
|
||||
{
|
||||
Format: dummyFormat("json"),
|
||||
},
|
||||
},
|
||||
expected: []writerConfig{
|
||||
{
|
||||
format: "json",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
outputs: []sbomWriterDescription{
|
||||
{
|
||||
Format: dummyFormat("json"),
|
||||
Path: "test-2.json",
|
||||
},
|
||||
},
|
||||
expected: []writerConfig{
|
||||
{
|
||||
format: "json",
|
||||
file: "test-2.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
outputs: []sbomWriterDescription{
|
||||
{
|
||||
Format: dummyFormat("json"),
|
||||
Path: "test-3/1.json",
|
||||
},
|
||||
{
|
||||
Format: dummyFormat("spdx-json"),
|
||||
Path: "test-3/2.json",
|
||||
},
|
||||
},
|
||||
expected: []writerConfig{
|
||||
{
|
||||
format: "json",
|
||||
file: "test-3/1.json",
|
||||
},
|
||||
{
|
||||
format: "spdx-json",
|
||||
file: "test-3/2.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
outputs: []sbomWriterDescription{
|
||||
{
|
||||
Format: dummyFormat("text"),
|
||||
},
|
||||
{
|
||||
Format: dummyFormat("spdx-json"),
|
||||
Path: "test-4.json",
|
||||
},
|
||||
},
|
||||
expected: []writerConfig{
|
||||
{
|
||||
format: "text",
|
||||
},
|
||||
{
|
||||
format: "spdx-json",
|
||||
file: "test-4.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(testName(test.outputs, test.err), func(t *testing.T) {
|
||||
outputs := test.outputs
|
||||
for i := range outputs {
|
||||
if outputs[i].Path != "" {
|
||||
outputs[i].Path = tmp + outputs[i].Path
|
||||
}
|
||||
}
|
||||
|
||||
mw, err := newSBOMMultiWriter(outputs...)
|
||||
|
||||
if test.err {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Len(t, mw.writers, len(test.expected))
|
||||
|
||||
for i, e := range test.expected {
|
||||
switch w := mw.writers[i].(type) {
|
||||
case *sbomStreamWriter:
|
||||
assert.Equal(t, string(w.format.ID()), e.format)
|
||||
assert.NotNil(t, w.out)
|
||||
if e.file != "" {
|
||||
assert.FileExists(t, tmp+e.file)
|
||||
}
|
||||
case *sbomPublisher:
|
||||
assert.Equal(t, string(w.format.ID()), e.format)
|
||||
default:
|
||||
t.Fatalf("unknown writer type: %T", w)
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_newSBOMWriterDescription(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "expand home dir",
|
||||
path: "~/place.txt",
|
||||
expected: filepath.Join(homedir.Get(), "place.txt"),
|
||||
},
|
||||
{
|
||||
name: "passthrough other paths",
|
||||
path: "/other/place.txt",
|
||||
expected: "/other/place.txt",
|
||||
},
|
||||
{
|
||||
name: "no path",
|
||||
path: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
o := newSBOMWriterDescription(dummyFormat("table"), tt.path)
|
||||
assert.Equal(t, tt.expected, o.Path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user