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:
Alex Goodman
2024-01-24 13:29:51 -05:00
committed by GitHub
parent bf3cd9ed3b
commit e0e1c4ba0a
232 changed files with 809 additions and 661 deletions
+17
View File
@@ -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")
}
+224
View File
@@ -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
}
+72
View File
@@ -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)
}
})
}
}
+12
View File
@@ -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")
}
+50
View File
@@ -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)
}
+56
View File
@@ -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)
}
})
}
}
+58
View File
@@ -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,
}
}
+9
View File
@@ -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"`
}
+7
View File
@@ -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"`
}
+6
View File
@@ -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"`
}
+11
View File
@@ -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,
}
}
+129
View File
@@ -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
}
+71
View File
@@ -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
}
+203
View File
@@ -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)
})
}
+18
View File
@@ -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,
}
}
+5
View File
@@ -0,0 +1,5 @@
package options
type pythonConfig struct {
GuessUnpinnedRequirements bool `json:"guess-unpinned-requirements" yaml:"guess-unpinned-requirements" mapstructure:"guess-unpinned-requirements"`
}
+84
View File
@@ -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,
}
}
+147
View File
@@ -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.")
}
+25
View File
@@ -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)
}
+59
View File
@@ -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
}
+37
View File
@@ -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)
}
})
}
}
+17
View File
@@ -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")
}
+247
View File
@@ -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
}
+294
View File
@@ -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))
})
}
}