diff --git a/README.md b/README.md index 228e2731154..d87e3d43b3d 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,40 @@ Where the `formats` available are: - `spdx-json`: A JSON report conforming to the [SPDX 2.2 JSON Schema](https://github.com/spdx/spdx-spec/blob/v2.2/schemas/spdx-schema.json). - `github`: A JSON report conforming to GitHub's dependency snapshot format. - `table`: A columnar summary (default). +- `template`: Lets the user specify the output format. See ["Using templates"](#using-templates) below. + +#### Using templates + +Syft lets you define custom output formats, using [Go templates](https://pkg.go.dev/text/template). Here's how it works: + +- Define your format as a Go template, and save this template as a file. + +- Set the output format to "template" (`-o template`). + +- Specify the path to the template file (`-t ./path/to/custom.template`). + +- Syft's template processing uses the same data models as the `json` output format — so if you're wondering what data is available as you author a template, you can use the output from `syft -o json` as a reference. + +**Example:** You could make Syft output data in CSV format by writing a Go template that renders CSV data and then running `syft -o template -t ~/path/to/csv.tmpl`. + +Here's what the `csv.tmpl` file might look like: +```gotemplate +"Package","Version Installed","Found by" +{{- range .Artifacts}} +"{{.Name}}","{{.Version}}","{{.FoundBy}}" +{{- end}} +``` + +Which would produce output like: +```text +"Package","Version Installed","Found by" +"alpine-baselayout","3.2.0-r20","apkdb-cataloger" +"alpine-baselayout-data","3.2.0-r20","apkdb-cataloger" +"alpine-keys","2.4-r1","apkdb-cataloger" +... +``` + +Syft also includes a vast array of utility templating functions from [sprig](http://masterminds.github.io/sprig/) apart from the default Golang [text/template](https://pkg.go.dev/text/template#hdr-Functions) to allow users to customize the output format. #### Multiple outputs diff --git a/cmd/syft/cli/convert/convert.go b/cmd/syft/cli/convert/convert.go index 87a8d497db7..d94fc38edbb 100644 --- a/cmd/syft/cli/convert/convert.go +++ b/cmd/syft/cli/convert/convert.go @@ -13,7 +13,7 @@ import ( func Run(ctx context.Context, app *config.Application, args []string) error { log.Warn("convert is an experimental feature, run `syft convert -h` for help") - writer, err := options.MakeWriter(app.Outputs, app.File) + writer, err := options.MakeWriter(app.Outputs, app.File, "") if err != nil { return err } diff --git a/cmd/syft/cli/options/packages.go b/cmd/syft/cli/options/packages.go index 8615a249eb6..45bfea00de9 100644 --- a/cmd/syft/cli/options/packages.go +++ b/cmd/syft/cli/options/packages.go @@ -15,6 +15,7 @@ import ( type PackagesOptions struct { Scope string Output []string + OutputTemplatePath string File string Platform string Host string @@ -35,6 +36,9 @@ func (o *PackagesOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { cmd.PersistentFlags().StringArrayVarP(&o.Output, "output", "o", FormatAliases(table.ID), fmt.Sprintf("report output format, options=%v", FormatAliases(syft.FormatIDs()...))) + cmd.PersistentFlags().StringVarP(&o.OutputTemplatePath, "template", "t", "", + "specify the path to a Go template file") + cmd.PersistentFlags().StringVarP(&o.File, "file", "", "", "file to write the default report output to (default is STDOUT)") @@ -84,6 +88,10 @@ func bindPackageConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { return err } + if err := v.BindPFlag("output-template-path", flags.Lookup("template")); err != nil { + return err + } + if err := v.BindPFlag("platform", flags.Lookup("platform")); err != nil { return err } diff --git a/cmd/syft/cli/options/writer.go b/cmd/syft/cli/options/writer.go index d69f3abfb9d..85a704fb356 100644 --- a/cmd/syft/cli/options/writer.go +++ b/cmd/syft/cli/options/writer.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/anchore/syft/internal/formats/table" + "github.com/anchore/syft/internal/formats/template" "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/sbom" "github.com/hashicorp/go-multierror" @@ -12,8 +13,8 @@ import ( // makeWriter 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 MakeWriter(outputs []string, defaultFile string) (sbom.Writer, error) { - outputOptions, err := parseOutputs(outputs, defaultFile) +func MakeWriter(outputs []string, defaultFile, templateFilePath string) (sbom.Writer, error) { + outputOptions, err := parseOutputs(outputs, defaultFile, templateFilePath) if err != nil { return nil, err } @@ -27,7 +28,7 @@ func MakeWriter(outputs []string, defaultFile string) (sbom.Writer, error) { } // parseOptions utility to parse command-line option strings and retain the existing behavior of default format and file -func parseOutputs(outputs []string, defaultFile string) (out []sbom.WriterOption, errs error) { +func parseOutputs(outputs []string, defaultFile, templateFilePath string) (out []sbom.WriterOption, errs error) { // always should have one option -- we generally get the default of "table", but just make sure if len(outputs) == 0 { outputs = append(outputs, string(table.ID)) @@ -56,6 +57,11 @@ func parseOutputs(outputs []string, defaultFile string) (out []sbom.WriterOption continue } + if tmpl, ok := format.(template.OutputFormat); ok { + tmpl.SetTemplatePath(templateFilePath) + format = tmpl + } + out = append(out, sbom.NewWriterOption(format, file)) } return out, errs diff --git a/cmd/syft/cli/options/writer_test.go b/cmd/syft/cli/options/writer_test.go index 9e5063e8567..00e096e8fed 100644 --- a/cmd/syft/cli/options/writer_test.go +++ b/cmd/syft/cli/options/writer_test.go @@ -28,7 +28,7 @@ func TestIsSupportedFormat(t *testing.T) { } for _, tt := range tests { - _, err := MakeWriter(tt.outputs, "") + _, err := MakeWriter(tt.outputs, "", "") tt.wantErr(t, err) } } diff --git a/cmd/syft/cli/packages.go b/cmd/syft/cli/packages.go index 1ad4e6de34e..d9ccecded2a 100644 --- a/cmd/syft/cli/packages.go +++ b/cmd/syft/cli/packages.go @@ -14,12 +14,13 @@ import ( const ( packagesExample = ` {{.appName}} {{.command}} alpine:latest a summary of discovered packages - {{.appName}} {{.command}} alpine:latest -o json show all possible cataloging details - {{.appName}} {{.command}} alpine:latest -o cyclonedx show a CycloneDX formatted SBOM - {{.appName}} {{.command}} alpine:latest -o cyclonedx-json show a CycloneDX JSON formatted SBOM - {{.appName}} {{.command}} alpine:latest -o spdx show a SPDX 2.2 Tag-Value formatted SBOM - {{.appName}} {{.command}} alpine:latest -o spdx-json show a SPDX 2.2 JSON formatted SBOM - {{.appName}} {{.command}} alpine:latest -vv show verbose debug information + {{.appName}} {{.command}} alpine:latest -o json show all possible cataloging details + {{.appName}} {{.command}} alpine:latest -o cyclonedx show a CycloneDX formatted SBOM + {{.appName}} {{.command}} alpine:latest -o cyclonedx-json show a CycloneDX JSON formatted SBOM + {{.appName}} {{.command}} alpine:latest -o spdx show a SPDX 2.2 Tag-Value formatted SBOM + {{.appName}} {{.command}} alpine:latest -o spdx-json show a SPDX 2.2 JSON formatted SBOM + {{.appName}} {{.command}} alpine:latest -vv show verbose debug information + {{.appName}} {{.command}} alpine:latest -o template -t my_format.tmpl show a SBOM formatted according to given template file Supports the following image sources: {{.appName}} {{.command}} yourrepo/yourimage:tag defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry. diff --git a/cmd/syft/cli/packages/packages.go b/cmd/syft/cli/packages/packages.go index 55b7978992c..0914b186c68 100644 --- a/cmd/syft/cli/packages/packages.go +++ b/cmd/syft/cli/packages/packages.go @@ -13,6 +13,7 @@ import ( "github.com/anchore/syft/internal/anchore" "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/config" + "github.com/anchore/syft/internal/formats/template" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/ui" "github.com/anchore/syft/internal/version" @@ -25,7 +26,12 @@ import ( ) func Run(ctx context.Context, app *config.Application, args []string) error { - writer, err := options.MakeWriter(app.Outputs, app.File) + err := validateOutputOptions(app) + if err != nil { + return err + } + + writer, err := options.MakeWriter(app.Outputs, app.File, app.OutputTemplatePath) if err != nil { return err } @@ -185,3 +191,19 @@ func runPackageSbomUpload(src *source.Source, s sbom.SBOM, app *config.Applicati return nil } + +func validateOutputOptions(app *config.Application) error { + var usesTemplateOutput bool + for _, o := range app.Outputs { + if o == template.ID.String() { + usesTemplateOutput = true + break + } + } + + if usesTemplateOutput && app.OutputTemplatePath == "" { + return fmt.Errorf(`must specify path to template file when using "template" output format`) + } + + return nil +} diff --git a/cmd/syft/cli/poweruser.go b/cmd/syft/cli/poweruser.go index 1ca95e99a56..65d4cc6bffc 100644 --- a/cmd/syft/cli/poweruser.go +++ b/cmd/syft/cli/poweruser.go @@ -13,7 +13,7 @@ import ( const powerUserExample = ` {{.appName}} {{.command}} DEPRECATED - THIS COMMAND WILL BE REMOVED in v1.0.0 - Only image sources are supported (e.g. docker: , podman: , docker-archive: , oci: , etc.), the directory source (dir:) is not supported. + Only image sources are supported (e.g. docker: , podman: , docker-archive: , oci: , etc.), the directory source (dir:) is not supported, template outputs are not supported. All behavior is controlled via application configuration and environment variables (see https://github.com/anchore/syft#configuration) ` diff --git a/go.mod b/go.mod index b46276cd324..4da1ddfcace 100644 --- a/go.mod +++ b/go.mod @@ -54,6 +54,7 @@ require ( ) require ( + github.com/Masterminds/sprig/v3 v3.2.2 github.com/docker/docker v20.10.12+incompatible github.com/google/go-containerregistry v0.8.1-0.20220209165246-a44adc326839 github.com/in-toto/in-toto-golang v0.3.4-0.20211211042327-af1f9fb822bf @@ -79,6 +80,8 @@ require ( github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.1.1 // indirect github.com/Microsoft/go-winio v0.5.2 // indirect github.com/PaesslerAG/gval v1.0.0 // indirect github.com/PaesslerAG/jsonpath v0.1.1 // indirect @@ -175,6 +178,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.1 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/huandu/xstrings v1.3.2 // indirect github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect @@ -197,6 +201,8 @@ require ( github.com/mattn/go-runewidth v0.0.13 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/miekg/pkcs11 v1.1.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/nwaples/rardecode v1.1.0 // indirect @@ -218,6 +224,7 @@ require ( github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect github.com/segmentio/ksuid v1.0.4 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/shopspring/decimal v1.2.0 // indirect github.com/sigstore/fulcio v0.1.2-0.20220114150912-86a2036f9bc7 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/soheilhy/cmux v0.1.5 // indirect diff --git a/go.sum b/go.sum index 0631ee68336..73b43c3efe4 100644 --- a/go.sum +++ b/go.sum @@ -186,14 +186,17 @@ github.com/GoogleCloudPlatform/cloudsql-proxy v0.0.0-20191009163259-e802c2cb94ae github.com/GoogleCloudPlatform/cloudsql-proxy v1.27.0/go.mod h1:bn9iHmAjogMoIPkqBGyJ9R1m9cXGCjBE/cuhBs3oEsQ= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.0.3/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.1.0/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/sprig v2.15.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= @@ -1302,6 +1305,7 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 h1:i462o439ZjprVSFSZLZxcsoAe592sZB1rci2Z8j4wdk= @@ -1856,6 +1860,7 @@ github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c/go.mod h1:/PevMnwAx github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/shirou/gopsutil/v3 v3.21.10/go.mod h1:t75NhzCZ/dYyPQjyQmrAYP6c8+LCdFANeBMdLPCNnew= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= diff --git a/internal/config/application.go b/internal/config/application.go index 80573c72d69..ecb869c47db 100644 --- a/internal/config/application.go +++ b/internal/config/application.go @@ -38,6 +38,7 @@ type Application struct { // -q, indicates to not show any status output to stderr (ETUI or logging UI) Quiet bool `yaml:"quiet" json:"quiet" mapstructure:"quiet"` Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output + OutputTemplatePath string `yaml:"output-template-path" json:"output-template-path" mapstructure:"output-template-path"` // -t template file to use for output File string `yaml:"file" json:"file" mapstructure:"file"` // --file, the file to write report output to 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 Anchore anchore `yaml:"anchore" json:"anchore" mapstructure:"anchore"` // options for interacting with Anchore Engine/Enterprise diff --git a/internal/formats/template/encoder.go b/internal/formats/template/encoder.go new file mode 100644 index 00000000000..8ef95b73a7f --- /dev/null +++ b/internal/formats/template/encoder.go @@ -0,0 +1,49 @@ +package template + +import ( + "errors" + "fmt" + "os" + "reflect" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/mitchellh/go-homedir" +) + +func makeTemplateExecutor(templateFilePath string) (*template.Template, error) { + if templateFilePath == "" { + return nil, errors.New("no template file: please provide a template path") + } + + expandedPathToTemplateFile, err := homedir.Expand(templateFilePath) + if err != nil { + return nil, fmt.Errorf("unable to expand path %s", templateFilePath) + } + + templateContents, err := os.ReadFile(expandedPathToTemplateFile) + if err != nil { + return nil, fmt.Errorf("unable to get template content: %w", err) + } + + templateName := expandedPathToTemplateFile + tmpl, err := template.New(templateName).Funcs(funcMap).Parse(string(templateContents)) + if err != nil { + return nil, fmt.Errorf("unable to parse template: %w", err) + } + + return tmpl, nil +} + +// These are custom functions available to template authors. +var funcMap = func() template.FuncMap { + f := sprig.HermeticTxtFuncMap() + f["getLastIndex"] = func(collection interface{}) int { + if v := reflect.ValueOf(collection); v.Kind() == reflect.Slice { + return v.Len() - 1 + } + + return 0 + } + return f +}() diff --git a/internal/formats/template/encoder_test.go b/internal/formats/template/encoder_test.go new file mode 100644 index 00000000000..950d4f91460 --- /dev/null +++ b/internal/formats/template/encoder_test.go @@ -0,0 +1,29 @@ +package template + +import ( + "flag" + "testing" + + "github.com/anchore/syft/internal/formats/common/testutils" + "github.com/stretchr/testify/assert" +) + +var updateTmpl = flag.Bool("update-tmpl", false, "update the *.golden files for json encoders") + +func TestFormatWithOption(t *testing.T) { + f := OutputFormat{} + f.SetTemplatePath("test-fixtures/csv.template") + + testutils.AssertEncoderAgainstGoldenSnapshot(t, + f, + testutils.DirectoryInput(t), + *updateTmpl, + ) + +} + +func TestFormatWithoutOptions(t *testing.T) { + f := Format() + err := f.Encode(nil, testutils.DirectoryInput(t)) + assert.ErrorContains(t, err, "no template file: please provide a template path") +} diff --git a/internal/formats/template/format.go b/internal/formats/template/format.go new file mode 100644 index 00000000000..4b8f28a5ed1 --- /dev/null +++ b/internal/formats/template/format.go @@ -0,0 +1,47 @@ +package template + +import ( + "io" + + "github.com/anchore/syft/internal/formats/syftjson" + "github.com/anchore/syft/syft/sbom" +) + +const ID sbom.FormatID = "template" + +func Format() sbom.Format { + return OutputFormat{} +} + +// implementation of sbom.Format interface +// to make use of format options +type OutputFormat struct { + templateFilePath string +} + +func (f OutputFormat) ID() sbom.FormatID { + return ID +} + +func (f OutputFormat) Decode(reader io.Reader) (*sbom.SBOM, error) { + return nil, sbom.ErrDecodingNotSupported +} + +func (f OutputFormat) Encode(output io.Writer, s sbom.SBOM) error { + tmpl, err := makeTemplateExecutor(f.templateFilePath) + if err != nil { + return err + } + + doc := syftjson.ToFormatModel(s) + return tmpl.Execute(output, doc) +} + +func (f OutputFormat) Validate(reader io.Reader) error { + return sbom.ErrValidationNotSupported +} + +// SetTemplatePath sets path for template file +func (f *OutputFormat) SetTemplatePath(filePath string) { + f.templateFilePath = filePath +} diff --git a/internal/formats/template/test-fixtures/csv.template b/internal/formats/template/test-fixtures/csv.template new file mode 100644 index 00000000000..8474271adb2 --- /dev/null +++ b/internal/formats/template/test-fixtures/csv.template @@ -0,0 +1,4 @@ +"Package","Version Installed", "Found by" +{{- range .Artifacts}} +"{{.Name}}","{{.Version}}","{{.FoundBy}}" +{{- end}} \ No newline at end of file diff --git a/internal/formats/template/test-fixtures/snapshot/TestFormatWithOption.golden b/internal/formats/template/test-fixtures/snapshot/TestFormatWithOption.golden new file mode 100644 index 00000000000..a7bc3b7d3ff --- /dev/null +++ b/internal/formats/template/test-fixtures/snapshot/TestFormatWithOption.golden @@ -0,0 +1,3 @@ +"Package","Version Installed", "Found by" +"package-1","1.0.1","the-cataloger-1" +"package-2","2.0.1","the-cataloger-2" \ No newline at end of file diff --git a/syft/formats.go b/syft/formats.go index 061f191f15a..f3433a48e01 100644 --- a/syft/formats.go +++ b/syft/formats.go @@ -11,6 +11,7 @@ import ( "github.com/anchore/syft/internal/formats/spdx22tagvalue" "github.com/anchore/syft/internal/formats/syftjson" "github.com/anchore/syft/internal/formats/table" + "github.com/anchore/syft/internal/formats/template" "github.com/anchore/syft/internal/formats/text" "github.com/anchore/syft/syft/sbom" ) @@ -25,6 +26,7 @@ const ( GitHubID = github.ID SPDXTagValueFormatID = spdx22tagvalue.ID SPDXJSONFormatID = spdx22json.ID + TemplateFormatID = template.ID ) var formats []sbom.Format @@ -39,6 +41,7 @@ func init() { spdx22json.Format(), table.Format(), text.Format(), + template.Format(), } } @@ -84,6 +87,8 @@ func FormatByName(name string) sbom.Format { return FormatByID(table.ID) case "text": return FormatByID(text.ID) + case "template": + FormatByID(template.ID) } return nil diff --git a/syft/formats_test.go b/syft/formats_test.go index 94e78bdb406..9596abd6ba5 100644 --- a/syft/formats_test.go +++ b/syft/formats_test.go @@ -13,6 +13,7 @@ import ( "github.com/anchore/syft/internal/formats/spdx22tagvalue" "github.com/anchore/syft/internal/formats/syftjson" "github.com/anchore/syft/internal/formats/table" + "github.com/anchore/syft/internal/formats/template" "github.com/anchore/syft/internal/formats/text" "github.com/anchore/syft/syft/sbom" "github.com/stretchr/testify/require" @@ -181,6 +182,11 @@ func TestFormatByName(t *testing.T) { name: "github-json", want: github.ID, }, + + { + name: "template", + want: template.ID, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/test/cli/all_formats_expressible_test.go b/test/cli/all_formats_expressible_test.go index b769aaac5ee..a48639d765f 100644 --- a/test/cli/all_formats_expressible_test.go +++ b/test/cli/all_formats_expressible_test.go @@ -2,10 +2,12 @@ package cli import ( "fmt" - "github.com/stretchr/testify/require" "strings" "testing" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/internal/formats/template" "github.com/anchore/syft/syft" ) @@ -23,7 +25,12 @@ func TestAllFormatsExpressible(t *testing.T) { require.NotEmpty(t, formats) for _, o := range formats { t.Run(fmt.Sprintf("format:%s", o), func(t *testing.T) { - cmd, stdout, stderr := runSyft(t, nil, "dir:./test-fixtures/image-pkg-coverage", "-o", string(o)) + args := []string{"dir:./test-fixtures/image-pkg-coverage", "-o", string(o)} + if o == template.ID { + args = append(args, "-t", "test-fixtures/csv.template") + } + + cmd, stdout, stderr := runSyft(t, nil, args...) for _, traitFn := range commonAssertions { traitFn(t, stdout, stderr, cmd.ProcessState.ExitCode()) } diff --git a/test/cli/test-fixtures/csv.template b/test/cli/test-fixtures/csv.template new file mode 100644 index 00000000000..8474271adb2 --- /dev/null +++ b/test/cli/test-fixtures/csv.template @@ -0,0 +1,4 @@ +"Package","Version Installed", "Found by" +{{- range .Artifacts}} +"{{.Name}}","{{.Version}}","{{.FoundBy}}" +{{- end}} \ No newline at end of file