From 645def2d60284533c0981721f55c39eff05993ad Mon Sep 17 00:00:00 2001 From: Olivier Boudet Date: Thu, 15 Jun 2023 22:50:51 +0200 Subject: [PATCH] feat(outputs): allow to set multiple outputs (#648) Signed-off-by: Olivier Boudet Signed-off-by: Olivier Boudet --- cmd/root.go | 19 ++--- cmd/writer.go | 69 +++++++++++++++++ grype/presenter/config.go | 88 ++++++++++++++-------- grype/presenter/config_test.go | 16 ++-- grype/presenter/format.go | 55 ++++++++------ grype/presenter/format_test.go | 8 +- grype/presenter/json/presenter.go | 20 ++++- grype/presenter/json/presenter_test.go | 6 +- grype/presenter/presenter.go | 61 ++++++++------- grype/presenter/template/presenter.go | 20 ++++- grype/presenter/template/presenter_test.go | 4 +- internal/config/application.go | 2 +- internal/file/get_writer.go | 29 +++++++ test/quality/vulnerability-match-labels | 2 +- 14 files changed, 284 insertions(+), 115 deletions(-) create mode 100644 cmd/writer.go create mode 100644 internal/file/get_writer.go diff --git a/cmd/root.go b/cmd/root.go index c93dba75d607..0ba6e641b7e8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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( @@ -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 @@ -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 } diff --git a/cmd/writer.go b/cmd/writer.go new file mode 100644 index 000000000000..c2829f5f4d71 --- /dev/null +++ b/cmd/writer.go @@ -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 = + 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 +} diff --git a/grype/presenter/config.go b/grype/presenter/config.go index 44be14aa96c9..d28244e6f704 100644 --- a/grype/presenter/config.go +++ b/grype/presenter/config.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "strings" "text/template" presenterTemplate "github.com/anchore/grype/grype/presenter/template" @@ -11,55 +12,82 @@ import ( // 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 = + 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 +} diff --git a/grype/presenter/config_test.go b/grype/presenter/config_test.go index 3b90686be7b8..b2ce7afe3c2f 100644 --- a/grype/presenter/config_test.go +++ b/grype/presenter/config_test.go @@ -9,7 +9,7 @@ import ( func TestValidatedConfig(t *testing.T) { cases := []struct { name string - outputValue string + outputValue []string includeSuppressed bool outputTemplateFileValue string expectedConfig Config @@ -17,18 +17,18 @@ func TestValidatedConfig(t *testing.T) { }{ { "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{}, @@ -36,7 +36,7 @@ func TestValidatedConfig(t *testing.T) { }, { "unknown format", - "some-made-up-format", + []string{"some-made-up-format"}, false, "", Config{}, @@ -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, @@ -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) diff --git a/grype/presenter/format.go b/grype/presenter/format.go index d1aa05803a82..d69b3a0e0e2c 100644 --- a/grype/presenter/format.go +++ b/grype/presenter/format.go @@ -5,24 +5,29 @@ 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) } @@ -30,32 +35,32 @@ func (f format) String() string { 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, @@ -65,7 +70,7 @@ var AvailableFormats = []format{ } // DeprecatedFormats TODO: remove in v1.0 -var DeprecatedFormats = []format{ +var DeprecatedFormats = []id{ embeddedVEXJSON, embeddedVEXXML, } diff --git a/grype/presenter/format_test.go b/grype/presenter/format_test.go index f26a529747df..d773e53a42bb 100644 --- a/grype/presenter/format_test.go +++ b/grype/presenter/format_test.go @@ -13,19 +13,19 @@ func TestParse(t *testing.T) { }{ { "", - tableFormat, + format{id: "table"}, }, { "table", - tableFormat, + format{id: "table"}, }, { "jSOn", - jsonFormat, + format{id: "json"}, }, { "booboodepoopoo", - unknownFormat, + format{id: "unknown"}, }, } diff --git a/grype/presenter/json/presenter.go b/grype/presenter/json/presenter.go index 8b26a35f895c..df34edf01b84 100644 --- a/grype/presenter/json/presenter.go +++ b/grype/presenter/json/presenter.go @@ -8,6 +8,8 @@ import ( "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/presenter/models" "github.com/anchore/grype/grype/vulnerability" + "github.com/anchore/grype/internal/file" + "github.com/anchore/grype/internal/log" ) // Presenter is a generic struct for holding fields needed for reporting @@ -19,10 +21,11 @@ type Presenter struct { metadataProvider vulnerability.MetadataProvider appConfig interface{} dbStatus interface{} + outputFilePath string } // NewPresenter creates a new JSON presenter -func NewPresenter(pb models.PresenterConfig) *Presenter { +func NewPresenter(pb models.PresenterConfig, outputFilePath string) *Presenter { return &Presenter{ matches: pb.Matches, ignoredMatches: pb.IgnoredMatches, @@ -31,11 +34,24 @@ func NewPresenter(pb models.PresenterConfig) *Presenter { context: pb.Context, appConfig: pb.AppConfig, dbStatus: pb.DBStatus, + outputFilePath: outputFilePath, } } // Present creates a JSON-based reporting -func (pres *Presenter) Present(output io.Writer) error { +func (pres *Presenter) Present(defaultOutput io.Writer) error { + output, closer, err := file.GetWriter(defaultOutput, pres.outputFilePath) + defer func() { + if closer != nil { + err := closer() + if err != nil { + log.Warnf("unable to write to report destination: %+v", err) + } + } + }() + if err != nil { + return err + } doc, err := models.NewDocument(pres.packages, pres.context, pres.matches, pres.ignoredMatches, pres.metadataProvider, pres.appConfig, pres.dbStatus) if err != nil { diff --git a/grype/presenter/json/presenter_test.go b/grype/presenter/json/presenter_test.go index 12a471c1fc18..99045de9e8da 100644 --- a/grype/presenter/json/presenter_test.go +++ b/grype/presenter/json/presenter_test.go @@ -30,7 +30,7 @@ func TestJsonImgsPresenter(t *testing.T) { MetadataProvider: metadataProvider, } - pres := NewPresenter(pb) + pres := NewPresenter(pb, "") // run presenter if err := pres.Present(&buffer); err != nil { @@ -63,7 +63,7 @@ func TestJsonDirsPresenter(t *testing.T) { MetadataProvider: metadataProvider, } - pres := NewPresenter(pb) + pres := NewPresenter(pb, "") // run presenter if err := pres.Present(&buffer); err != nil { @@ -106,7 +106,7 @@ func TestEmptyJsonPresenter(t *testing.T) { MetadataProvider: nil, } - pres := NewPresenter(pb) + pres := NewPresenter(pb, "") // run presenter if err := pres.Present(&buffer); err != nil { diff --git a/grype/presenter/presenter.go b/grype/presenter/presenter.go index 00256d350fda..7aa6961a7d0a 100644 --- a/grype/presenter/presenter.go +++ b/grype/presenter/presenter.go @@ -19,34 +19,39 @@ type Presenter interface { // GetPresenter retrieves a Presenter that matches a CLI option // TODO dependency cycle with presenter package to sub formats -func GetPresenter(c Config, pb models.PresenterConfig) Presenter { - switch c.format { - case jsonFormat: - return json.NewPresenter(pb) - case tableFormat: - return table.NewPresenter(pb, c.showSuppressed) +func GetPresenters(c Config, pb models.PresenterConfig) (presenters []Presenter) { + for _, f := range c.formats { + switch f.id { + case jsonFormat: + presenters = append(presenters, json.NewPresenter(pb, f.outputFilePath)) + case tableFormat: + presenters = append(presenters, table.NewPresenter(pb, c.showSuppressed)) - // NOTE: cyclonedx is identical to embeddedVEXJSON - // The cyclonedx library only provides two BOM formats: JSON and XML - // These embedded formats will be removed in v1.0 - case cycloneDXFormat: - return cyclonedx.NewXMLPresenter(pb) - case cycloneDXJSON: - return cyclonedx.NewJSONPresenter(pb) - case cycloneDXXML: - return cyclonedx.NewXMLPresenter(pb) - case sarifFormat: - return sarif.NewPresenter(pb) - case templateFormat: - return template.NewPresenter(pb, c.templateFilePath) - // DEPRECATED TODO: remove in v1.0 - case embeddedVEXJSON: - log.Warn("embedded-cyclonedx-vex-json format is deprecated and will be removed in v1.0") - return cyclonedx.NewJSONPresenter(pb) - case embeddedVEXXML: - log.Warn("embedded-cyclonedx-vex-xml format is deprecated and will be removed in v1.0") - return cyclonedx.NewXMLPresenter(pb) - default: - return nil + // NOTE: cyclonedx is identical to embeddedVEXJSON + // The cyclonedx library only provides two BOM formats: JSON and XML + // These embedded formats will be removed in v1.0 + case cycloneDXFormat: + presenters = append(presenters, cyclonedx.NewXMLPresenter(pb)) + case cycloneDXJSON: + presenters = append(presenters, cyclonedx.NewJSONPresenter(pb)) + case cycloneDXXML: + presenters = append(presenters, cyclonedx.NewXMLPresenter(pb)) + case sarifFormat: + presenters = append(presenters, sarif.NewPresenter(pb)) + case templateFormat: + presenters = append(presenters, template.NewPresenter(pb, f.outputFilePath, c.templateFilePath)) + // DEPRECATED TODO: remove in v1.0 + case embeddedVEXJSON: + log.Warn("embedded-cyclonedx-vex-json format is deprecated and will be removed in v1.0") + presenters = append(presenters, cyclonedx.NewJSONPresenter(pb)) + case embeddedVEXXML: + log.Warn("embedded-cyclonedx-vex-xml format is deprecated and will be removed in v1.0") + presenters = append(presenters, cyclonedx.NewXMLPresenter(pb)) + } } + if len(presenters) == 0 { + presenters = append(presenters, table.NewPresenter(pb, c.showSuppressed)) + } + log.Info(presenters) + return presenters } diff --git a/grype/presenter/template/presenter.go b/grype/presenter/template/presenter.go index 710e2851addc..9c8016e452ca 100644 --- a/grype/presenter/template/presenter.go +++ b/grype/presenter/template/presenter.go @@ -15,6 +15,8 @@ import ( "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/presenter/models" "github.com/anchore/grype/grype/vulnerability" + "github.com/anchore/grype/internal/file" + "github.com/anchore/grype/internal/log" ) // Presenter is an implementation of presenter.Presenter that formats output according to a user-provided Go text template. @@ -26,11 +28,12 @@ type Presenter struct { metadataProvider vulnerability.MetadataProvider appConfig interface{} dbStatus interface{} + outputFilePath string pathToTemplateFile string } // NewPresenter returns a new template.Presenter. -func NewPresenter(pb models.PresenterConfig, templateFile string) *Presenter { +func NewPresenter(pb models.PresenterConfig, outputFilePath string, templateFile string) *Presenter { return &Presenter{ matches: pb.Matches, ignoredMatches: pb.IgnoredMatches, @@ -39,12 +42,25 @@ func NewPresenter(pb models.PresenterConfig, templateFile string) *Presenter { context: pb.Context, appConfig: pb.AppConfig, dbStatus: pb.DBStatus, + outputFilePath: outputFilePath, pathToTemplateFile: templateFile, } } // Present creates output using a user-supplied Go template. -func (pres *Presenter) Present(output io.Writer) error { +func (pres *Presenter) Present(defaultOutput io.Writer) error { + output, closer, err := file.GetWriter(defaultOutput, pres.outputFilePath) + defer func() { + if closer != nil { + err := closer() + if err != nil { + log.Warnf("unable to write to report destination: %+v", err) + } + } + }() + if err != nil { + return err + } expandedPathToTemplateFile, err := homedir.Expand(pres.pathToTemplateFile) if err != nil { return fmt.Errorf("unable to expand path %q", pres.pathToTemplateFile) diff --git a/grype/presenter/template/presenter_test.go b/grype/presenter/template/presenter_test.go index 9acaf6cd59e9..8038ae693c21 100644 --- a/grype/presenter/template/presenter_test.go +++ b/grype/presenter/template/presenter_test.go @@ -35,7 +35,7 @@ func TestPresenter_Present(t *testing.T) { DBStatus: dbStatus, } - templatePresenter := NewPresenter(pb, templateFilePath) + templatePresenter := NewPresenter(pb, "", templateFilePath) var buffer bytes.Buffer if err := templatePresenter.Present(&buffer); err != nil { @@ -69,7 +69,7 @@ func TestPresenter_SprigDate_Fails(t *testing.T) { DBStatus: dbStatus, } - templatePresenter := NewPresenter(pb, templateFilePath) + templatePresenter := NewPresenter(pb, "", templateFilePath) var buffer bytes.Buffer err = templatePresenter.Present(&buffer) diff --git a/internal/config/application.go b/internal/config/application.go index b1d2357108de..db32b369c47a 100644 --- a/internal/config/application.go +++ b/internal/config/application.go @@ -31,7 +31,7 @@ type parser interface { type Application struct { ConfigPath string `yaml:",omitempty" json:"configPath"` // the location where the application config was read from (either from -c or discovered while loading) Verbosity uint `yaml:"verbosity,omitempty" json:"verbosity" mapstructure:"verbosity"` - Output string `yaml:"output" json:"output" mapstructure:"output"` // -o, the Presenter hint string to use for report formatting + Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, = the Presenter hint string to use for report formatting and the output file File string `yaml:"file" json:"file" mapstructure:"file"` // --file, the file to write report output to Distro string `yaml:"distro" json:"distro" mapstructure:"distro"` // --distro, specify a distro to explicitly use GenerateMissingCPEs bool `yaml:"add-cpes-if-none" json:"add-cpes-if-none" mapstructure:"add-cpes-if-none"` // --add-cpes-if-none, automatically generate CPEs if they are not present in import (e.g. from a 3rd party SPDX document) diff --git a/internal/file/get_writer.go b/internal/file/get_writer.go new file mode 100644 index 000000000000..59faefc225b1 --- /dev/null +++ b/internal/file/get_writer.go @@ -0,0 +1,29 @@ +package file + +import ( + "fmt" + "io" + "os" + "strings" +) + +func GetWriter(defaultWriter io.Writer, outputFile string) (io.Writer, func() error, error) { + nop := func() error { return nil } + path := strings.TrimSpace(outputFile) + + switch len(path) { + case 0: + return defaultWriter, nop, nil + + default: + outputFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + + if err != nil { + return nil, nop, fmt.Errorf("unable to create report file: %w", err) + } + + return outputFile, func() error { + return outputFile.Close() + }, nil + } +} diff --git a/test/quality/vulnerability-match-labels b/test/quality/vulnerability-match-labels index 89eb6bb52d97..5f5bfa96fbe9 160000 --- a/test/quality/vulnerability-match-labels +++ b/test/quality/vulnerability-match-labels @@ -1 +1 @@ -Subproject commit 89eb6bb52d979bdd7790de8fd702eab6c5b742c2 +Subproject commit 5f5bfa96fbe993649fa2c5eba81c788d1cc263e2