Skip to content

Commit

Permalink
feat(outputs): allow to set multiple outputs (anchore#648)
Browse files Browse the repository at this point in the history
Signed-off-by: Olivier Boudet <o.boudet@gmail.com>
Signed-off-by: Olivier Boudet <olivier.boudet@cooperl.com>
  • Loading branch information
olivierboudet committed Jul 3, 2023
1 parent ecf9e65 commit 645def2
Show file tree
Hide file tree
Showing 14 changed files with 284 additions and 115 deletions.
19 changes: 10 additions & 9 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,14 @@ func setRootFlags(flags *pflag.FlagSet) {
fmt.Sprintf("selection of layers to analyze, options=%v", source.AllScopes),
)

flags.StringP(
"output", "o", "",
flags.StringArrayP(
"output", "o", nil,
fmt.Sprintf("report output formatter, formats=%v, deprecated formats=%v", presenter.AvailableFormats, presenter.DeprecatedFormats),
)

flags.StringP(
"file", "", "",
"file to write the report output to (default is STDOUT)",
"file to write the default report output to (default is STDOUT)",
)

flags.StringP(
Expand Down Expand Up @@ -299,7 +299,7 @@ func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-cha
go func() {
defer close(errs)

presenterConfig, err := presenter.ValidatedConfig(appConfig.Output, appConfig.OutputTemplateFile, appConfig.ShowSuppressed)
presenterConfig, err := presenter.ValidatedConfig(appConfig.Outputs, appConfig.File, appConfig.OutputTemplateFile, appConfig.ShowSuppressed)
if err != nil {
errs <- err
return
Expand Down Expand Up @@ -389,11 +389,12 @@ func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-cha
AppConfig: appConfig,
DBStatus: status,
}

bus.Publish(partybus.Event{
Type: event.VulnerabilityScanningFinished,
Value: presenter.GetPresenter(presenterConfig, pb),
})
for _, presenter := range presenter.GetPresenters(presenterConfig, pb) {
bus.Publish(partybus.Event{
Type: event.VulnerabilityScanningFinished,
Value: presenter,
})
}
}()
return errs
}
Expand Down
69 changes: 69 additions & 0 deletions cmd/writer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package cmd

import (
"fmt"
"strings"

"github.com/hashicorp/go-multierror"

"github.com/anchore/syft/syft/formats"
"github.com/anchore/syft/syft/formats/table"
"github.com/anchore/syft/syft/formats/template"
"github.com/anchore/syft/syft/sbom"
)

// 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, templateFilePath string) (sbom.Writer, error) {
outputOptions, err := parseOutputs(outputs, defaultFile, templateFilePath)
if err != nil {
return nil, err
}

writer, err := sbom.NewWriter(outputOptions...)
if err != nil {
return nil, err
}

return writer, nil
}

// parseOptions utility to parse command-line option strings and retain the existing behavior of default format and file
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, 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]
}

format := formats.ByName(name)
if format == nil {
errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, formats.AllIDs()))
continue
}

if tmpl, ok := format.(template.OutputFormat); ok {
tmpl.SetTemplatePath(templateFilePath)
format = tmpl
}

out = append(out, sbom.NewWriterOption(format, file))
}
return out, errs
}
88 changes: 58 additions & 30 deletions grype/presenter/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,62 +4,90 @@ import (
"errors"
"fmt"
"os"
"strings"
"text/template"

presenterTemplate "github.com/anchore/grype/grype/presenter/template"
)

// Config is the presenter domain's configuration data structure.
type Config struct {
format format
formats []format
templateFilePath string
showSuppressed bool
}

// ValidatedConfig returns a new, validated presenter.Config. If a valid Config cannot be created using the given input,
// an error is returned.
func ValidatedConfig(output, outputTemplateFile string, showSuppressed bool) (Config, error) {
format := parse(output)
func ValidatedConfig(outputs []string, defaultFile string, outputTemplateFile string, showSuppressed bool) (Config, error) {
formats := parseOutputs(outputs, defaultFile)
hasTemplateFormat := false

if format == unknownFormat {
return Config{}, fmt.Errorf("unsupported output format %q, supported formats are: %+v", output,
AvailableFormats)
}

if format == templateFormat {
if outputTemplateFile == "" {
return Config{}, fmt.Errorf("must specify path to template file when using %q output format",
templateFormat)
for _, format := range formats {
if format.id == unknownFormat {
return Config{}, fmt.Errorf("unsupported output format %q, supported formats are: %+v", format.id,
AvailableFormats)
}

if _, err := os.Stat(outputTemplateFile); errors.Is(err, os.ErrNotExist) {
// file does not exist
return Config{}, fmt.Errorf("template file %q does not exist",
outputTemplateFile)
}
if format.id == templateFormat {
hasTemplateFormat = true

if _, err := os.ReadFile(outputTemplateFile); err != nil {
return Config{}, fmt.Errorf("unable to read template file: %w", err)
}
if outputTemplateFile == "" {
return Config{}, fmt.Errorf("must specify path to template file when using %q output format",
templateFormat)
}

if _, err := template.New("").Funcs(presenterTemplate.FuncMap).ParseFiles(outputTemplateFile); err != nil {
return Config{}, fmt.Errorf("unable to parse template: %w", err)
}
if _, err := os.Stat(outputTemplateFile); errors.Is(err, os.ErrNotExist) {
// file does not exist
return Config{}, fmt.Errorf("template file %q does not exist",
outputTemplateFile)
}

if _, err := os.ReadFile(outputTemplateFile); err != nil {
return Config{}, fmt.Errorf("unable to read template file: %w", err)
}

return Config{
format: format,
templateFilePath: outputTemplateFile,
}, nil
if _, err := template.New("").Funcs(presenterTemplate.FuncMap).ParseFiles(outputTemplateFile); err != nil {
return Config{}, fmt.Errorf("unable to parse template: %w", err)
}
}
}

if outputTemplateFile != "" {
if outputTemplateFile != "" && !hasTemplateFormat {
return Config{}, fmt.Errorf("specified template file %q, but "+
"%q output format must be selected in order to use a template file",
outputTemplateFile, templateFormat)
}

return Config{
format: format,
showSuppressed: showSuppressed,
formats: formats,
showSuppressed: showSuppressed,
templateFilePath: outputTemplateFile,
}, nil
}

// 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 []format) {
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]
}

format := parse(name)
format.outputFilePath = file
out = append(out, format)
}
return out
}
16 changes: 8 additions & 8 deletions grype/presenter/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,34 @@ import (
func TestValidatedConfig(t *testing.T) {
cases := []struct {
name string
outputValue string
outputValue []string
includeSuppressed bool
outputTemplateFileValue string
expectedConfig Config
assertErrExpectation func(assert.TestingT, error, ...interface{}) bool
}{
{
"valid template config",
"template",
[]string{"template"},
false,
"./template/test-fixtures/test.valid.template",
Config{
format: "template",
formats: []format{{id: templateFormat}},
templateFilePath: "./template/test-fixtures/test.valid.template",
},
assert.NoError,
},
{
"template file with non-template format",
"json",
[]string{"json"},
false,
"./some/path/to/a/custom.template",
Config{},
assert.Error,
},
{
"unknown format",
"some-made-up-format",
[]string{"some-made-up-format"},
false,
"",
Config{},
Expand All @@ -45,11 +45,11 @@ func TestValidatedConfig(t *testing.T) {

{
"table format",
"table",
[]string{"table"},
true,
"",
Config{
format: tableFormat,
formats: []format{{id: tableFormat}},
showSuppressed: true,
},
assert.NoError,
Expand All @@ -58,7 +58,7 @@ func TestValidatedConfig(t *testing.T) {

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
actualConfig, actualErr := ValidatedConfig(tc.outputValue, tc.outputTemplateFileValue, tc.includeSuppressed)
actualConfig, actualErr := ValidatedConfig(tc.outputValue, "", tc.outputTemplateFileValue, tc.includeSuppressed)

assert.Equal(t, tc.expectedConfig, actualConfig)
tc.assertErrExpectation(t, actualErr)
Expand Down
55 changes: 30 additions & 25 deletions grype/presenter/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,57 +5,62 @@ import (
)

const (
unknownFormat format = "unknown"
jsonFormat format = "json"
tableFormat format = "table"
cycloneDXFormat format = "cyclonedx"
cycloneDXJSON format = "cyclonedx-json"
cycloneDXXML format = "cyclonedx-xml"
sarifFormat format = "sarif"
templateFormat format = "template"
unknownFormat id = "unknown"
jsonFormat id = "json"
tableFormat id = "table"
cycloneDXFormat id = "cyclonedx"
cycloneDXJSON id = "cyclonedx-json"
cycloneDXXML id = "cyclonedx-xml"
sarifFormat id = "sarif"
templateFormat id = "template"

// DEPRECATED <-- TODO: remove in v1.0
embeddedVEXJSON format = "embedded-cyclonedx-vex-json"
embeddedVEXXML format = "embedded-cyclonedx-vex-xml"
embeddedVEXJSON id = "embedded-cyclonedx-vex-json"
embeddedVEXXML id = "embedded-cyclonedx-vex-xml"
)

// format is a dedicated type to represent a specific kind of presenter output format.
type format string
type id string

func (f format) String() string {
type format struct {
id id
outputFilePath string
}

func (f id) String() string {
return string(f)
}

// parse returns the presenter.format specified by the given user input.
func parse(userInput string) format {
switch strings.ToLower(userInput) {
case "":
return tableFormat
return format{id: tableFormat}
case strings.ToLower(jsonFormat.String()):
return jsonFormat
return format{id: jsonFormat}
case strings.ToLower(tableFormat.String()):
return tableFormat
return format{id: tableFormat}
case strings.ToLower(sarifFormat.String()):
return sarifFormat
return format{id: sarifFormat}
case strings.ToLower(templateFormat.String()):
return templateFormat
return format{id: templateFormat}
case strings.ToLower(cycloneDXFormat.String()):
return cycloneDXFormat
return format{id: cycloneDXFormat}
case strings.ToLower(cycloneDXJSON.String()):
return cycloneDXJSON
return format{id: cycloneDXJSON}
case strings.ToLower(cycloneDXXML.String()):
return cycloneDXXML
return format{id: cycloneDXXML}
case strings.ToLower(embeddedVEXJSON.String()):
return cycloneDXJSON
return format{id: cycloneDXJSON}
case strings.ToLower(embeddedVEXXML.String()):
return cycloneDXFormat
return format{id: cycloneDXFormat}
default:
return unknownFormat
return format{id: unknownFormat}
}
}

// AvailableFormats is a list of presenter format options available to users.
var AvailableFormats = []format{
var AvailableFormats = []id{
jsonFormat,
tableFormat,
cycloneDXFormat,
Expand All @@ -65,7 +70,7 @@ var AvailableFormats = []format{
}

// DeprecatedFormats TODO: remove in v1.0
var DeprecatedFormats = []format{
var DeprecatedFormats = []id{
embeddedVEXJSON,
embeddedVEXXML,
}
Loading

0 comments on commit 645def2

Please sign in to comment.