From 7d02d254e0226c27100f2466ca4f5f2a9dd53024 Mon Sep 17 00:00:00 2001 From: Olivier Boudet Date: Thu, 15 Jun 2023 22:50:51 +0200 Subject: [PATCH 1/3] feat(outputs): allow to set multiple outputs (#648) Signed-off-by: Olivier Boudet Signed-off-by: Olivier Boudet Signed-off-by: Alex Goodman --- 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 +++++++ 13 files changed, 283 insertions(+), 114 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 c93dba75d60..0ba6e641b7e 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 00000000000..c2829f5f4d7 --- /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 44be14aa96c..d28244e6f70 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 3b90686be7b..b2ce7afe3c2 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 d1aa05803a8..d69b3a0e0e2 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 f26a529747d..d773e53a42b 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 8b26a35f895..df34edf01b8 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 7806ba60ef1..649a71db884 100644 --- a/grype/presenter/json/presenter_test.go +++ b/grype/presenter/json/presenter_test.go @@ -31,7 +31,7 @@ func TestJsonImgsPresenter(t *testing.T) { MetadataProvider: metadataProvider, } - pres := NewPresenter(pb) + pres := NewPresenter(pb, "") // run presenter if err := pres.Present(&buffer); err != nil { @@ -64,7 +64,7 @@ func TestJsonDirsPresenter(t *testing.T) { MetadataProvider: metadataProvider, } - pres := NewPresenter(pb) + pres := NewPresenter(pb, "") // run presenter if err := pres.Present(&buffer); err != nil { @@ -107,7 +107,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 00256d350fd..7aa6961a7d0 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 710e2851add..9c8016e452c 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 502f8ca3f75..b7a12d94247 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 b1d2357108d..db32b369c47 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 00000000000..59faefc225b --- /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 + } +} From ba08acf5e6d55221982ff72edd791fc2c2d943d0 Mon Sep 17 00:00:00 2001 From: Olivier Boudet Date: Mon, 3 Jul 2023 09:06:59 +0200 Subject: [PATCH 2/3] feat(outputs): allow to set multiple outputs (#648) review Signed-off-by: Olivier Boudet Signed-off-by: Alex Goodman --- cmd/writer.go | 69 -------- grype/presenter/format.go | 2 + grype/presenter/json/presenter.go | 8 +- grype/presenter/json/presenter_test.go | 48 +++++- .../TestJsonPresentWithOutputFile.golden | 153 ++++++++++++++++++ grype/presenter/presenter.go | 7 +- grype/presenter/template/presenter.go | 7 +- grype/presenter/template/presenter_test.go | 50 +++++- ...TestPresenter_PresentWithOutputFile.golden | 12 ++ internal/file/get_writer.go | 6 +- 10 files changed, 280 insertions(+), 82 deletions(-) delete mode 100644 cmd/writer.go create mode 100644 grype/presenter/json/test-fixtures/snapshot/TestJsonPresentWithOutputFile.golden create mode 100644 grype/presenter/template/test-fixtures/snapshot/TestPresenter_PresentWithOutputFile.golden diff --git a/cmd/writer.go b/cmd/writer.go deleted file mode 100644 index c2829f5f4d7..00000000000 --- a/cmd/writer.go +++ /dev/null @@ -1,69 +0,0 @@ -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/format.go b/grype/presenter/format.go index d69b3a0e0e2..94e915f1906 100644 --- a/grype/presenter/format.go +++ b/grype/presenter/format.go @@ -69,6 +69,8 @@ var AvailableFormats = []id{ templateFormat, } +var DefaultFormat = tableFormat + // DeprecatedFormats TODO: remove in v1.0 var DeprecatedFormats = []id{ embeddedVEXJSON, diff --git a/grype/presenter/json/presenter.go b/grype/presenter/json/presenter.go index df34edf01b8..39fbcfc1119 100644 --- a/grype/presenter/json/presenter.go +++ b/grype/presenter/json/presenter.go @@ -4,6 +4,8 @@ import ( "encoding/json" "io" + "github.com/spf13/afero" + "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/presenter/models" @@ -22,10 +24,11 @@ type Presenter struct { appConfig interface{} dbStatus interface{} outputFilePath string + fs afero.Fs } // NewPresenter creates a new JSON presenter -func NewPresenter(pb models.PresenterConfig, outputFilePath string) *Presenter { +func NewPresenter(fs afero.Fs, pb models.PresenterConfig, outputFilePath string) *Presenter { return &Presenter{ matches: pb.Matches, ignoredMatches: pb.IgnoredMatches, @@ -35,12 +38,13 @@ func NewPresenter(pb models.PresenterConfig, outputFilePath string) *Presenter { appConfig: pb.AppConfig, dbStatus: pb.DBStatus, outputFilePath: outputFilePath, + fs: fs, } } // Present creates a JSON-based reporting func (pres *Presenter) Present(defaultOutput io.Writer) error { - output, closer, err := file.GetWriter(defaultOutput, pres.outputFilePath) + output, closer, err := file.GetWriter(pres.fs, defaultOutput, pres.outputFilePath) defer func() { if closer != nil { err := closer() diff --git a/grype/presenter/json/presenter_test.go b/grype/presenter/json/presenter_test.go index 649a71db884..56356c1712b 100644 --- a/grype/presenter/json/presenter_test.go +++ b/grype/presenter/json/presenter_test.go @@ -6,6 +6,7 @@ import ( "regexp" "testing" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/anchore/go-testutils" @@ -31,7 +32,7 @@ func TestJsonImgsPresenter(t *testing.T) { MetadataProvider: metadataProvider, } - pres := NewPresenter(pb, "") + pres := NewPresenter(afero.NewMemMapFs(), pb, "") // run presenter if err := pres.Present(&buffer); err != nil { @@ -64,7 +65,7 @@ func TestJsonDirsPresenter(t *testing.T) { MetadataProvider: metadataProvider, } - pres := NewPresenter(pb, "") + pres := NewPresenter(afero.NewMemMapFs(), pb, "") // run presenter if err := pres.Present(&buffer); err != nil { @@ -107,7 +108,7 @@ func TestEmptyJsonPresenter(t *testing.T) { MetadataProvider: nil, } - pres := NewPresenter(pb, "") + pres := NewPresenter(afero.NewMemMapFs(), pb, "") // run presenter if err := pres.Present(&buffer); err != nil { @@ -129,3 +130,44 @@ func TestEmptyJsonPresenter(t *testing.T) { func redact(content []byte) []byte { return timestampRegexp.ReplaceAll(content, []byte(`"timestamp":""`)) } + +func TestJsonPresentWithOutputFile(t *testing.T) { + var buffer bytes.Buffer + + matches, packages, context, metadataProvider, _, _ := models.GenerateAnalysis(t, source.DirectoryScheme) + + pb := models.PresenterConfig{ + Matches: matches, + Packages: packages, + Context: context, + MetadataProvider: metadataProvider, + } + + outputFilePath := "/tmp/report.test.txt" + fs := afero.NewMemMapFs() + pres := NewPresenter(fs, pb, outputFilePath) + + // run presenter + if err := pres.Present(&buffer); err != nil { + t.Fatal(err) + } + + f, err := fs.Open(outputFilePath) + if err != nil { + t.Fatalf("no output file: %+v", err) + } + + outputContent, err := afero.ReadAll(f) + if err != nil { + t.Fatalf("could not file: %+v", err) + } + outputContent = redact(outputContent) + + if *update { + testutils.UpdateGoldenFileContents(t, outputContent) + } + + var expected = testutils.GetGoldenFileContents(t) + + assert.Equal(t, string(expected), string(outputContent)) +} diff --git a/grype/presenter/json/test-fixtures/snapshot/TestJsonPresentWithOutputFile.golden b/grype/presenter/json/test-fixtures/snapshot/TestJsonPresentWithOutputFile.golden new file mode 100644 index 00000000000..840e58c3e60 --- /dev/null +++ b/grype/presenter/json/test-fixtures/snapshot/TestJsonPresentWithOutputFile.golden @@ -0,0 +1,153 @@ +{ + "matches": [ + { + "vulnerability": { + "id": "CVE-1999-0001", + "dataSource": "", + "severity": "Low", + "urls": [], + "description": "1999-01 description", + "cvss": [ + { + "version": "3.0", + "vector": "another vector", + "metrics": { + "baseScore": 4 + }, + "vendorMetadata": {} + } + ], + "fix": { + "versions": [ + "the-next-version" + ], + "state": "fixed" + }, + "advisories": [] + }, + "relatedVulnerabilities": [], + "matchDetails": [ + { + "type": "exact-direct-match", + "matcher": "dpkg-matcher", + "searchedBy": { + "distro": { + "type": "ubuntu", + "version": "20.04" + } + }, + "found": { + "constraint": ">= 20" + } + } + ], + "artifact": { + "id": "96699b00fe3004b4", + "name": "package-1", + "version": "1.1.1", + "type": "rpm", + "locations": [ + { + "path": "/foo/bar/somefile-1.txt" + } + ], + "language": "", + "licenses": [], + "cpes": [ + "cpe:2.3:a:anchore:engine:0.9.2:*:*:python:*:*:*:*" + ], + "purl": "", + "upstreams": [ + { + "name": "nothing", + "version": "3.2" + } + ], + "metadataType": "RpmMetadata", + "metadata": { + "epoch": 2, + "modularityLabel": "" + } + } + }, + { + "vulnerability": { + "id": "CVE-1999-0002", + "dataSource": "", + "severity": "Critical", + "urls": [], + "description": "1999-02 description", + "cvss": [ + { + "version": "2.0", + "vector": "vector", + "metrics": { + "baseScore": 1, + "exploitabilityScore": 2, + "impactScore": 3 + }, + "vendorMetadata": { + "BaseSeverity": "Low", + "Status": "verified" + } + } + ], + "fix": { + "versions": [], + "state": "" + }, + "advisories": [] + }, + "relatedVulnerabilities": [], + "matchDetails": [ + { + "type": "exact-indirect-match", + "matcher": "dpkg-matcher", + "searchedBy": { + "cpe": "somecpe" + }, + "found": { + "constraint": "somecpe" + } + } + ], + "artifact": { + "id": "b4013a965511376c", + "name": "package-2", + "version": "2.2.2", + "type": "deb", + "locations": [ + { + "path": "/foo/bar/somefile-2.txt" + } + ], + "language": "", + "licenses": [ + "MIT", + "Apache-2.0" + ], + "cpes": [ + "cpe:2.3:a:anchore:engine:2.2.2:*:*:python:*:*:*:*" + ], + "purl": "", + "upstreams": [] + } + } + ], + "source": { + "type": "directory", + "target": "/some/path" + }, + "distro": { + "name": "centos", + "version": "8.0", + "idLike": [ + "centos" + ] + }, + "descriptor": { + "name": "grype", + "version": "[not provided]", + "timestamp":"" + } +} diff --git a/grype/presenter/presenter.go b/grype/presenter/presenter.go index 7aa6961a7d0..2a0fe9e7b3f 100644 --- a/grype/presenter/presenter.go +++ b/grype/presenter/presenter.go @@ -3,6 +3,8 @@ package presenter import ( "io" + "github.com/spf13/afero" + "github.com/anchore/grype/grype/presenter/cyclonedx" "github.com/anchore/grype/grype/presenter/json" "github.com/anchore/grype/grype/presenter/models" @@ -20,10 +22,11 @@ type Presenter interface { // GetPresenter retrieves a Presenter that matches a CLI option // TODO dependency cycle with presenter package to sub formats func GetPresenters(c Config, pb models.PresenterConfig) (presenters []Presenter) { + fs := afero.NewOsFs() for _, f := range c.formats { switch f.id { case jsonFormat: - presenters = append(presenters, json.NewPresenter(pb, f.outputFilePath)) + presenters = append(presenters, json.NewPresenter(fs, pb, f.outputFilePath)) case tableFormat: presenters = append(presenters, table.NewPresenter(pb, c.showSuppressed)) @@ -39,7 +42,7 @@ func GetPresenters(c Config, pb models.PresenterConfig) (presenters []Presenter) case sarifFormat: presenters = append(presenters, sarif.NewPresenter(pb)) case templateFormat: - presenters = append(presenters, template.NewPresenter(pb, f.outputFilePath, c.templateFilePath)) + presenters = append(presenters, template.NewPresenter(fs, 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") diff --git a/grype/presenter/template/presenter.go b/grype/presenter/template/presenter.go index 9c8016e452c..fa2085f2e6f 100644 --- a/grype/presenter/template/presenter.go +++ b/grype/presenter/template/presenter.go @@ -10,6 +10,7 @@ import ( "github.com/Masterminds/sprig/v3" "github.com/mitchellh/go-homedir" + "github.com/spf13/afero" "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" @@ -30,10 +31,11 @@ type Presenter struct { dbStatus interface{} outputFilePath string pathToTemplateFile string + fs afero.Fs } // NewPresenter returns a new template.Presenter. -func NewPresenter(pb models.PresenterConfig, outputFilePath string, templateFile string) *Presenter { +func NewPresenter(fs afero.Fs, pb models.PresenterConfig, outputFilePath string, templateFile string) *Presenter { return &Presenter{ matches: pb.Matches, ignoredMatches: pb.IgnoredMatches, @@ -44,12 +46,13 @@ func NewPresenter(pb models.PresenterConfig, outputFilePath string, templateFile dbStatus: pb.DBStatus, outputFilePath: outputFilePath, pathToTemplateFile: templateFile, + fs: fs, } } // Present creates output using a user-supplied Go template. func (pres *Presenter) Present(defaultOutput io.Writer) error { - output, closer, err := file.GetWriter(defaultOutput, pres.outputFilePath) + output, closer, err := file.GetWriter(pres.fs, defaultOutput, pres.outputFilePath) defer func() { if closer != nil { err := closer() diff --git a/grype/presenter/template/presenter_test.go b/grype/presenter/template/presenter_test.go index b7a12d94247..503436a3f9f 100644 --- a/grype/presenter/template/presenter_test.go +++ b/grype/presenter/template/presenter_test.go @@ -7,6 +7,7 @@ import ( "path" "testing" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -35,7 +36,7 @@ func TestPresenter_Present(t *testing.T) { DBStatus: dbStatus, } - templatePresenter := NewPresenter(pb, "", templateFilePath) + templatePresenter := NewPresenter(afero.NewMemMapFs(), pb, "", templateFilePath) var buffer bytes.Buffer if err := templatePresenter.Present(&buffer); err != nil { @@ -52,6 +53,51 @@ func TestPresenter_Present(t *testing.T) { assert.Equal(t, string(expected), string(actual)) } +func TestPresenter_PresentWithOutputFile(t *testing.T) { + matches, packages, context, metadataProvider, appConfig, dbStatus := models.GenerateAnalysis(t, source.ImageScheme) + + workingDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + templateFilePath := path.Join(workingDirectory, "./test-fixtures/test.template") + + pb := models.PresenterConfig{ + Matches: matches, + Packages: packages, + Context: context, + MetadataProvider: metadataProvider, + AppConfig: appConfig, + DBStatus: dbStatus, + } + + outputFilePath := "/tmp/report.test.txt" + fs := afero.NewMemMapFs() + templatePresenter := NewPresenter(fs, pb, outputFilePath, templateFilePath) + + var buffer bytes.Buffer + if err := templatePresenter.Present(&buffer); err != nil { + t.Fatal(err) + } + + f, err := fs.Open(outputFilePath) + if err != nil { + t.Fatalf("no output file: %+v", err) + } + + outputContent, err := afero.ReadAll(f) + if err != nil { + t.Fatalf("could not read file: %+v", err) + } + + if *update { + testutils.UpdateGoldenFileContents(t, outputContent) + } + expected := testutils.GetGoldenFileContents(t) + + assert.Equal(t, string(expected), string(outputContent)) +} + func TestPresenter_SprigDate_Fails(t *testing.T) { matches, packages, context, metadataProvider, appConfig, dbStatus := internal.GenerateAnalysis(t, internal.ImageSource) workingDirectory, err := os.Getwd() @@ -69,7 +115,7 @@ func TestPresenter_SprigDate_Fails(t *testing.T) { DBStatus: dbStatus, } - templatePresenter := NewPresenter(pb, "", templateFilePath) + templatePresenter := NewPresenter(afero.NewMemMapFs(), pb, "", templateFilePath) var buffer bytes.Buffer err = templatePresenter.Present(&buffer) diff --git a/grype/presenter/template/test-fixtures/snapshot/TestPresenter_PresentWithOutputFile.golden b/grype/presenter/template/test-fixtures/snapshot/TestPresenter_PresentWithOutputFile.golden new file mode 100644 index 00000000000..0ac37fa30dc --- /dev/null +++ b/grype/presenter/template/test-fixtures/snapshot/TestPresenter_PresentWithOutputFile.golden @@ -0,0 +1,12 @@ +Identified distro as centos version 8.0. + Vulnerability: CVE-1999-0001 + Severity: Low + Package: package-1 version 1.1.1 (rpm) + CPEs: ["cpe:2.3:a:anchore:engine:0.9.2:*:*:python:*:*:*:*"] + Matched by: dpkg-matcher + Vulnerability: CVE-1999-0002 + Severity: Critical + Package: package-2 version 2.2.2 (deb) + CPEs: ["cpe:2.3:a:anchore:engine:2.2.2:*:*:python:*:*:*:*"] + Matched by: dpkg-matcher + diff --git a/internal/file/get_writer.go b/internal/file/get_writer.go index 59faefc225b..90c709e0efa 100644 --- a/internal/file/get_writer.go +++ b/internal/file/get_writer.go @@ -5,9 +5,11 @@ import ( "io" "os" "strings" + + "github.com/spf13/afero" ) -func GetWriter(defaultWriter io.Writer, outputFile string) (io.Writer, func() error, error) { +func GetWriter(fs afero.Fs, defaultWriter io.Writer, outputFile string) (io.Writer, func() error, error) { nop := func() error { return nil } path := strings.TrimSpace(outputFile) @@ -16,7 +18,7 @@ func GetWriter(defaultWriter io.Writer, outputFile string) (io.Writer, func() er return defaultWriter, nop, nil default: - outputFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + outputFile, err := fs.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) From 269f7f07cb01d135ceb2c354005effe73606cdd4 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Tue, 11 Jul 2023 13:14:42 -0400 Subject: [PATCH 3/3] use syft format writter pattern and de-emphasize presenter package Signed-off-by: Alex Goodman --- cmd/db_diff.go | 8 +- cmd/db_update.go | 9 +- cmd/event_loop_test.go | 8 +- cmd/root.go | 27 ++- go.mod | 1 + go.sum | 2 + grype/db/curator_test.go | 6 +- grype/db/v1/store/store.go | 4 +- grype/db/v2/store/store.go | 4 +- grype/db/v3/namespace.go | 4 +- grype/db/v3/store/store.go | 4 +- grype/db/v4/pkg/resolver/java/resolver.go | 4 +- grype/db/v4/store/store.go | 4 +- grype/db/v5/pkg/resolver/java/resolver.go | 4 +- grype/db/v5/store/store.go | 4 +- grype/distro/distro_test.go | 6 +- grype/event/event.go | 29 ++- grype/event/parsers/parsers.go | 52 ++-- grype/matcher/dpkg/matcher_test.go | 4 +- grype/matcher/java/matcher_test.go | 4 +- grype/matcher/portage/matcher_test.go | 4 +- grype/pkg/package.go | 4 +- grype/presenter/config.go | 93 -------- grype/presenter/config_test.go | 67 ------ grype/presenter/format.go | 78 ------ grype/presenter/json/presenter.go | 24 +- grype/presenter/json/presenter_test.go | 48 +--- .../TestJsonPresentWithOutputFile.golden | 153 ------------ grype/presenter/presenter.go | 61 +---- grype/presenter/template/presenter.go | 23 +- grype/presenter/template/presenter_test.go | 50 +--- ...TestPresenter_PresentWithOutputFile.golden | 12 - grype/version/constraint_unit.go | 4 +- internal/bus/helpers.go | 20 ++ internal/file/get_writer.go | 31 --- internal/file/getter.go | 6 +- internal/format/format.go | 71 ++++++ .../format}/format_test.go | 14 +- internal/format/presenter.go | 51 ++++ internal/format/writer.go | 219 +++++++++++++++++ internal/format/writer_test.go | 222 ++++++++++++++++++ internal/{format => stringutil}/color.go | 2 +- internal/{ => stringutil}/parse.go | 2 +- internal/{ => stringutil}/string_helpers.go | 2 +- .../{ => stringutil}/string_helpers_test.go | 2 +- internal/{ => stringutil}/stringset.go | 2 +- internal/{format => stringutil}/tprint.go | 2 +- internal/ui/common_event_handlers.go | 36 --- internal/ui/ephemeral_terminal_ui.go | 31 ++- internal/ui/etui_event_handlers.go | 4 +- internal/ui/logger_ui.go | 33 +-- test/integration/match_by_image_test.go | 6 +- 52 files changed, 757 insertions(+), 808 deletions(-) delete mode 100644 grype/presenter/config.go delete mode 100644 grype/presenter/config_test.go delete mode 100644 grype/presenter/format.go delete mode 100644 grype/presenter/json/test-fixtures/snapshot/TestJsonPresentWithOutputFile.golden delete mode 100644 grype/presenter/template/test-fixtures/snapshot/TestPresenter_PresentWithOutputFile.golden create mode 100644 internal/bus/helpers.go delete mode 100644 internal/file/get_writer.go create mode 100644 internal/format/format.go rename {grype/presenter => internal/format}/format_test.go (70%) create mode 100644 internal/format/presenter.go create mode 100644 internal/format/writer.go create mode 100644 internal/format/writer_test.go rename internal/{format => stringutil}/color.go (93%) rename internal/{ => stringutil}/parse.go (95%) rename internal/{ => stringutil}/string_helpers.go (96%) rename internal/{ => stringutil}/string_helpers_test.go (99%) rename internal/{ => stringutil}/stringset.go (96%) rename internal/{format => stringutil}/tprint.go (94%) delete mode 100644 internal/ui/common_event_handlers.go diff --git a/cmd/db_diff.go b/cmd/db_diff.go index 6f81455e95c..a1a165c084a 100644 --- a/cmd/db_diff.go +++ b/cmd/db_diff.go @@ -5,11 +5,9 @@ import ( "os" "github.com/spf13/cobra" - "github.com/wagoodman/go-partybus" "github.com/anchore/grype/grype/db" "github.com/anchore/grype/grype/differ" - "github.com/anchore/grype/grype/event" "github.com/anchore/grype/internal/bus" "github.com/anchore/grype/internal/log" "github.com/anchore/grype/internal/ui" @@ -38,6 +36,7 @@ func startDBDiffCmd(base string, target string, deleteDatabases bool) <-chan err errs := make(chan error) go func() { defer close(errs) + defer bus.Exit() d, err := differ.NewDiffer(appConfig.DB.ToCuratorConfig()) if err != nil { errs <- err @@ -72,11 +71,6 @@ func startDBDiffCmd(base string, target string, deleteDatabases bool) <-chan err if deleteDatabases { errs <- d.DeleteDatabases() } - - bus.Publish(partybus.Event{ - Type: event.NonRootCommandFinished, - Value: "", - }) }() return errs } diff --git a/cmd/db_update.go b/cmd/db_update.go index d697d68f6e5..2dd6cc335df 100644 --- a/cmd/db_update.go +++ b/cmd/db_update.go @@ -4,10 +4,8 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/wagoodman/go-partybus" "github.com/anchore/grype/grype/db" - "github.com/anchore/grype/grype/event" "github.com/anchore/grype/internal/bus" "github.com/anchore/grype/internal/log" "github.com/anchore/grype/internal/ui" @@ -29,6 +27,8 @@ func startDBUpdateCmd() <-chan error { errs := make(chan error) go func() { defer close(errs) + defer bus.Exit() + dbCurator, err := db.NewCurator(appConfig.DB.ToCuratorConfig()) if err != nil { errs <- err @@ -44,10 +44,7 @@ func startDBUpdateCmd() <-chan error { result = "Vulnerability database updated to latest version!\n" } - bus.Publish(partybus.Event{ - Type: event.NonRootCommandFinished, - Value: result, - }) + bus.Report(result) }() return errs } diff --git a/cmd/event_loop_test.go b/cmd/event_loop_test.go index c860e433ae0..4edb9fe3304 100644 --- a/cmd/event_loop_test.go +++ b/cmd/event_loop_test.go @@ -51,7 +51,7 @@ func Test_eventLoop_gracefulExit(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.VulnerabilityScanningFinished, + Type: event.CLIExit, } worker := func() <-chan error { @@ -183,7 +183,7 @@ func Test_eventLoop_unsubscribeError(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.VulnerabilityScanningFinished, + Type: event.CLIExit, } worker := func() <-chan error { @@ -252,7 +252,7 @@ func Test_eventLoop_handlerError(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.VulnerabilityScanningFinished, + Type: event.CLIExit, Error: fmt.Errorf("unable to create presenter"), } @@ -377,7 +377,7 @@ func Test_eventLoop_uiTeardownError(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.VulnerabilityScanningFinished, + Type: event.CLIExit, } worker := func() <-chan error { diff --git a/cmd/root.go b/cmd/root.go index 0ba6e641b7e..b39a91fc881 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,7 +28,6 @@ import ( "github.com/anchore/grype/grype/matcher/ruby" "github.com/anchore/grype/grype/matcher/stock" "github.com/anchore/grype/grype/pkg" - "github.com/anchore/grype/grype/presenter" "github.com/anchore/grype/grype/presenter/models" "github.com/anchore/grype/grype/store" "github.com/anchore/grype/grype/vulnerability" @@ -37,6 +36,7 @@ import ( "github.com/anchore/grype/internal/config" "github.com/anchore/grype/internal/format" "github.com/anchore/grype/internal/log" + "github.com/anchore/grype/internal/stringutil" "github.com/anchore/grype/internal/ui" "github.com/anchore/grype/internal/version" "github.com/anchore/stereoscope" @@ -62,7 +62,7 @@ var ( rootCmd = &cobra.Command{ Use: fmt.Sprintf("%s [IMAGE]", internal.ApplicationName), Short: "A vulnerability scanner for container images, filesystems, and SBOMs", - Long: format.Tprintf(`A vulnerability scanner for container images, filesystems, and SBOMs. + Long: stringutil.Tprintf(`A vulnerability scanner for container images, filesystems, and SBOMs. Supports the following image sources: {{.appName}} yourrepo/yourimage:tag defaults to using images from a Docker daemon @@ -132,7 +132,7 @@ func setRootFlags(flags *pflag.FlagSet) { flags.StringArrayP( "output", "o", nil, - fmt.Sprintf("report output formatter, formats=%v, deprecated formats=%v", presenter.AvailableFormats, presenter.DeprecatedFormats), + fmt.Sprintf("report output formatter, formats=%v, deprecated formats=%v", format.AvailableFormats, format.DeprecatedFormats), ) flags.StringP( @@ -298,8 +298,13 @@ func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-cha errs := make(chan error) go func() { defer close(errs) + defer bus.Exit() - presenterConfig, err := presenter.ValidatedConfig(appConfig.Outputs, appConfig.File, appConfig.OutputTemplateFile, appConfig.ShowSuppressed) + // TODO: appConfig.File + writer, err := format.MakeScanResultWriter(appConfig.Outputs, appConfig.OutputTemplateFile, format.PresentationConfig{ + TemplateFilePath: appConfig.OutputTemplateFile, + ShowSuppressed: appConfig.ShowSuppressed, + }) if err != nil { errs <- err return @@ -332,7 +337,7 @@ func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-cha go func() { defer wg.Done() log.Debugf("gathering packages") - // packages are grype.Pacakge, not syft.Package + // packages are grype.Package, not syft.Package // the SBOM is returned for downstream formatting concerns // grype uses the SBOM in combination with syft formatters to produce cycloneDX // with vulnerability information appended @@ -379,7 +384,7 @@ func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-cha } } - pb := models.PresenterConfig{ + if err := writer.Write(models.PresenterConfig{ Matches: *remainingMatches, IgnoredMatches: ignoredMatches, Packages: packages, @@ -388,12 +393,8 @@ func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-cha SBOM: sbom, AppConfig: appConfig, DBStatus: status, - } - for _, presenter := range presenter.GetPresenters(presenterConfig, pb) { - bus.Publish(partybus.Event{ - Type: event.VulnerabilityScanningFinished, - Value: presenter, - }) + }); err != nil { + errs <- err } }() return errs @@ -448,7 +449,7 @@ func checkForAppUpdate() { log.Infof("new version of %s is available: %s (currently running: %s)", internal.ApplicationName, newVersion, version.FromBuild().Version) bus.Publish(partybus.Event{ - Type: event.AppUpdateAvailable, + Type: event.CLIAppUpdateAvailable, Value: newVersion, }) } else { diff --git a/go.mod b/go.mod index 32aa7d09fa3..aa49fd6d0d0 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( github.com/anchore/syft v0.84.2-0.20230705174713-cfbb9f703bd7 github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b github.com/mitchellh/mapstructure v1.5.0 + github.com/wagoodman/go-presenter v0.0.0-20211015174752-f9c01afc824b ) require ( diff --git a/go.sum b/go.sum index 09fadb6961a..a74ae6cc10c 100644 --- a/go.sum +++ b/go.sum @@ -842,6 +842,8 @@ github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+ github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= github.com/wagoodman/go-partybus v0.0.0-20210627031916-db1f5573bbc5 h1:phTLPgMRDYTizrBSKsNSOa2zthoC2KsJsaY/8sg3rD8= github.com/wagoodman/go-partybus v0.0.0-20210627031916-db1f5573bbc5/go.mod h1:JPirS5jde/CF5qIjcK4WX+eQmKXdPc6vcZkJ/P0hfPw= +github.com/wagoodman/go-presenter v0.0.0-20211015174752-f9c01afc824b h1:uWNQ0khA6RdFzODOMwKo9XXu7fuewnnkHykUtuKru8s= +github.com/wagoodman/go-presenter v0.0.0-20211015174752-f9c01afc824b/go.mod h1:ewlIKbKV8l+jCj8rkdXIs361ocR5x3qGyoCSca47Gx8= github.com/wagoodman/go-progress v0.0.0-20230301185719-21920a456ad5 h1:lwgTsTy18nYqASnH58qyfRW/ldj7Gt2zzBvgYPzdA4s= github.com/wagoodman/go-progress v0.0.0-20230301185719-21920a456ad5/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= github.com/wagoodman/jotframe v0.0.0-20211129225309-56b0d0a4aebb h1:Yz6VVOcLuWLAHYlJzTw7JKnWxdV/WXpug2X0quEzRnY= diff --git a/grype/db/curator_test.go b/grype/db/curator_test.go index acb20216bf3..bb3ee22ac09 100644 --- a/grype/db/curator_test.go +++ b/grype/db/curator_test.go @@ -21,14 +21,14 @@ import ( "github.com/stretchr/testify/require" "github.com/wagoodman/go-progress" - "github.com/anchore/grype/internal" "github.com/anchore/grype/internal/file" + "github.com/anchore/grype/internal/stringutil" ) type testGetter struct { file map[string]string dir map[string]string - calls internal.StringSet + calls stringutil.StringSet fs afero.Fs } @@ -36,7 +36,7 @@ func newTestGetter(fs afero.Fs, f, d map[string]string) *testGetter { return &testGetter{ file: f, dir: d, - calls: internal.NewStringSet(), + calls: stringutil.NewStringSet(), fs: fs, } } diff --git a/grype/db/v1/store/store.go b/grype/db/v1/store/store.go index d6ffab4816d..02656bd9317 100644 --- a/grype/db/v1/store/store.go +++ b/grype/db/v1/store/store.go @@ -10,7 +10,7 @@ import ( "github.com/anchore/grype/grype/db/internal/gormadapter" v1 "github.com/anchore/grype/grype/db/v1" "github.com/anchore/grype/grype/db/v1/store/model" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" _ "github.com/anchore/sqlite" // provide the sqlite dialect to gorm via import ) @@ -172,7 +172,7 @@ func (s *store) AddVulnerabilityMetadata(metadata ...v1.VulnerabilityMetadata) e existing.CvssV3 = m.CvssV3 } - links := internal.NewStringSetFromSlice(existing.Links) + links := stringutil.NewStringSetFromSlice(existing.Links) for _, l := range m.Links { links.Add(l) } diff --git a/grype/db/v2/store/store.go b/grype/db/v2/store/store.go index 073cbaf01ff..b0d7907f636 100644 --- a/grype/db/v2/store/store.go +++ b/grype/db/v2/store/store.go @@ -10,7 +10,7 @@ import ( "github.com/anchore/grype/grype/db/internal/gormadapter" v2 "github.com/anchore/grype/grype/db/v2" "github.com/anchore/grype/grype/db/v2/store/model" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" _ "github.com/anchore/sqlite" // provide the sqlite dialect to gorm via import ) @@ -171,7 +171,7 @@ func (s *store) AddVulnerabilityMetadata(metadata ...v2.VulnerabilityMetadata) e existing.CvssV3 = m.CvssV3 } - links := internal.NewStringSetFromSlice(existing.Links) + links := stringutil.NewStringSetFromSlice(existing.Links) for _, l := range m.Links { links.Add(l) } diff --git a/grype/db/v3/namespace.go b/grype/db/v3/namespace.go index 386c89c793c..ab43539ac64 100644 --- a/grype/db/v3/namespace.go +++ b/grype/db/v3/namespace.go @@ -6,8 +6,8 @@ import ( "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/pkg" - "github.com/anchore/grype/internal" "github.com/anchore/grype/internal/log" + "github.com/anchore/grype/internal/stringutil" packageurl "github.com/anchore/packageurl-go" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -110,7 +110,7 @@ func defaultPackageNamer(p pkg.Package) []string { } func githubJavaPackageNamer(p pkg.Package) []string { - names := internal.NewStringSet() + names := stringutil.NewStringSet() // all github advisories are stored by ":" if metadata, ok := p.Metadata.(pkg.JavaMetadata); ok { diff --git a/grype/db/v3/store/store.go b/grype/db/v3/store/store.go index 5dce10e5647..e9c1aaa5aa9 100644 --- a/grype/db/v3/store/store.go +++ b/grype/db/v3/store/store.go @@ -10,7 +10,7 @@ import ( "github.com/anchore/grype/grype/db/internal/gormadapter" v3 "github.com/anchore/grype/grype/db/v3" "github.com/anchore/grype/grype/db/v3/store/model" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" _ "github.com/anchore/sqlite" // provide the sqlite dialect to gorm via import ) @@ -179,7 +179,7 @@ func (s *store) AddVulnerabilityMetadata(metadata ...v3.VulnerabilityMetadata) e existing.Cvss = append(existing.Cvss, incomingCvss) } - links := internal.NewStringSetFromSlice(existing.URLs) + links := stringutil.NewStringSetFromSlice(existing.URLs) for _, l := range m.URLs { links.Add(l) } diff --git a/grype/db/v4/pkg/resolver/java/resolver.go b/grype/db/v4/pkg/resolver/java/resolver.go index b9741f57150..c5933d78ef6 100644 --- a/grype/db/v4/pkg/resolver/java/resolver.go +++ b/grype/db/v4/pkg/resolver/java/resolver.go @@ -5,8 +5,8 @@ import ( "strings" grypePkg "github.com/anchore/grype/grype/pkg" - "github.com/anchore/grype/internal" "github.com/anchore/grype/internal/log" + "github.com/anchore/grype/internal/stringutil" "github.com/anchore/packageurl-go" ) @@ -18,7 +18,7 @@ func (r *Resolver) Normalize(name string) string { } func (r *Resolver) Resolve(p grypePkg.Package) []string { - names := internal.NewStringSet() + names := stringutil.NewStringSet() // The current default for the Java ecosystem is to use a Maven-like identifier of the form // ":" diff --git a/grype/db/v4/store/store.go b/grype/db/v4/store/store.go index 018a19b7909..fb039344548 100644 --- a/grype/db/v4/store/store.go +++ b/grype/db/v4/store/store.go @@ -10,7 +10,7 @@ import ( "github.com/anchore/grype/grype/db/internal/gormadapter" v4 "github.com/anchore/grype/grype/db/v4" "github.com/anchore/grype/grype/db/v4/store/model" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" _ "github.com/anchore/sqlite" // provide the sqlite dialect to gorm via import ) @@ -189,7 +189,7 @@ func (s *store) AddVulnerabilityMetadata(metadata ...v4.VulnerabilityMetadata) e existing.Cvss = append(existing.Cvss, incomingCvss) } - links := internal.NewStringSetFromSlice(existing.URLs) + links := stringutil.NewStringSetFromSlice(existing.URLs) for _, l := range m.URLs { links.Add(l) } diff --git a/grype/db/v5/pkg/resolver/java/resolver.go b/grype/db/v5/pkg/resolver/java/resolver.go index b9741f57150..c5933d78ef6 100644 --- a/grype/db/v5/pkg/resolver/java/resolver.go +++ b/grype/db/v5/pkg/resolver/java/resolver.go @@ -5,8 +5,8 @@ import ( "strings" grypePkg "github.com/anchore/grype/grype/pkg" - "github.com/anchore/grype/internal" "github.com/anchore/grype/internal/log" + "github.com/anchore/grype/internal/stringutil" "github.com/anchore/packageurl-go" ) @@ -18,7 +18,7 @@ func (r *Resolver) Normalize(name string) string { } func (r *Resolver) Resolve(p grypePkg.Package) []string { - names := internal.NewStringSet() + names := stringutil.NewStringSet() // The current default for the Java ecosystem is to use a Maven-like identifier of the form // ":" diff --git a/grype/db/v5/store/store.go b/grype/db/v5/store/store.go index 725fa54ba26..24264fdcfe9 100644 --- a/grype/db/v5/store/store.go +++ b/grype/db/v5/store/store.go @@ -10,7 +10,7 @@ import ( "github.com/anchore/grype/grype/db/internal/gormadapter" v5 "github.com/anchore/grype/grype/db/v5" "github.com/anchore/grype/grype/db/v5/store/model" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" _ "github.com/anchore/sqlite" // provide the sqlite dialect to gorm via import ) @@ -207,7 +207,7 @@ func (s *store) AddVulnerabilityMetadata(metadata ...v5.VulnerabilityMetadata) e existing.Cvss = append(existing.Cvss, incomingCvss) } - links := internal.NewStringSetFromSlice(existing.URLs) + links := stringutil.NewStringSetFromSlice(existing.URLs) for _, l := range m.URLs { links.Add(l) } diff --git a/grype/distro/distro_test.go b/grype/distro/distro_test.go index faa61825f3c..c757119c53f 100644 --- a/grype/distro/distro_test.go +++ b/grype/distro/distro_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" "github.com/anchore/syft/syft/linux" "github.com/anchore/syft/syft/source" ) @@ -214,8 +214,8 @@ func Test_NewDistroFromRelease_Coverage(t *testing.T) { }, } - observedDistros := internal.NewStringSet() - definedDistros := internal.NewStringSet() + observedDistros := stringutil.NewStringSet() + definedDistros := stringutil.NewStringSet() for _, distroType := range All { definedDistros.Add(string(distroType)) diff --git a/grype/event/event.go b/grype/event/event.go index 91cfcde1a01..f0d777832cd 100644 --- a/grype/event/event.go +++ b/grype/event/event.go @@ -1,12 +1,27 @@ package event -import "github.com/wagoodman/go-partybus" +import ( + "github.com/wagoodman/go-partybus" + + "github.com/anchore/grype/internal" +) const ( - AppUpdateAvailable partybus.EventType = "grype-app-update-available" - UpdateVulnerabilityDatabase partybus.EventType = "grype-update-vulnerability-database" - VulnerabilityScanningStarted partybus.EventType = "grype-vulnerability-scanning-started" - VulnerabilityScanningFinished partybus.EventType = "grype-vulnerability-scanning-finished" - NonRootCommandFinished partybus.EventType = "grype-non-root-command-finished" - DatabaseDiffingStarted partybus.EventType = "grype-database-diffing-started" + typePrefix = internal.ApplicationName + cliTypePrefix = typePrefix + "-cli" + + UpdateVulnerabilityDatabase partybus.EventType = "grype-update-vulnerability-database" + VulnerabilityScanningStarted partybus.EventType = "grype-vulnerability-scanning-started" + DatabaseDiffingStarted partybus.EventType = "grype-database-diffing-started" + + // Events exclusively for the CLI + + // CLIAppUpdateAvailable is a partybus event that occurs when an application update is available + CLIAppUpdateAvailable partybus.EventType = cliTypePrefix + "-app-update-available" + + // CLIReport is a partybus event that occurs when an analysis result is ready for final presentation to stdout + CLIReport partybus.EventType = cliTypePrefix + "-report" + + // CLIExit is a partybus event that occurs when an analysis result is ready for final presentation + CLIExit partybus.EventType = cliTypePrefix + "-exit-event" ) diff --git a/grype/event/parsers/parsers.go b/grype/event/parsers/parsers.go index 9b1a3c14155..eab840d702e 100644 --- a/grype/event/parsers/parsers.go +++ b/grype/event/parsers/parsers.go @@ -9,7 +9,6 @@ import ( diffEvents "github.com/anchore/grype/grype/differ/events" "github.com/anchore/grype/grype/event" "github.com/anchore/grype/grype/matcher" - "github.com/anchore/grype/grype/presenter" ) type ErrBadPayload struct { @@ -37,19 +36,6 @@ func checkEventType(actual, expected partybus.EventType) error { return nil } -func ParseAppUpdateAvailable(e partybus.Event) (string, error) { - if err := checkEventType(e.Type, event.AppUpdateAvailable); err != nil { - return "", err - } - - newVersion, ok := e.Value.(string) - if !ok { - return "", newPayloadErr(e.Type, "Value", e.Value) - } - - return newVersion, nil -} - func ParseUpdateVulnerabilityDatabase(e partybus.Event) (progress.StagedProgressable, error) { if err := checkEventType(e.Type, event.UpdateVulnerabilityDatabase); err != nil { return nil, err @@ -76,41 +62,47 @@ func ParseVulnerabilityScanningStarted(e partybus.Event) (*matcher.Monitor, erro return &monitor, nil } -func ParseVulnerabilityScanningFinished(e partybus.Event) (presenter.Presenter, error) { - if err := checkEventType(e.Type, event.VulnerabilityScanningFinished); err != nil { +func ParseDatabaseDiffingStarted(e partybus.Event) (*diffEvents.Monitor, error) { + if err := checkEventType(e.Type, event.DatabaseDiffingStarted); err != nil { return nil, err } - pres, ok := e.Value.(presenter.Presenter) + monitor, ok := e.Value.(diffEvents.Monitor) if !ok { return nil, newPayloadErr(e.Type, "Value", e.Value) } - return pres, nil + return &monitor, nil } -func ParseNonRootCommandFinished(e partybus.Event) (*string, error) { - if err := checkEventType(e.Type, event.NonRootCommandFinished); err != nil { - return nil, err +func ParseCLIAppUpdateAvailable(e partybus.Event) (string, error) { + if err := checkEventType(e.Type, event.CLIAppUpdateAvailable); err != nil { + return "", err } - result, ok := e.Value.(string) + newVersion, ok := e.Value.(string) if !ok { - return nil, newPayloadErr(e.Type, "Value", e.Value) + return "", newPayloadErr(e.Type, "Value", e.Value) } - return &result, nil + return newVersion, nil } -func ParseDatabaseDiffingStarted(e partybus.Event) (*diffEvents.Monitor, error) { - if err := checkEventType(e.Type, event.DatabaseDiffingStarted); err != nil { - return nil, err +func ParseCLIReport(e partybus.Event) (string, string, error) { + if err := checkEventType(e.Type, event.CLIReport); err != nil { + return "", "", err } - monitor, ok := e.Value.(diffEvents.Monitor) + context, ok := e.Source.(string) if !ok { - return nil, newPayloadErr(e.Type, "Value", e.Value) + // this is optional + context = "" } - return &monitor, nil + report, ok := e.Value.(string) + if !ok { + return "", "", newPayloadErr(e.Type, "Value", e.Value) + } + + return context, report, nil } diff --git a/grype/matcher/dpkg/matcher_test.go b/grype/matcher/dpkg/matcher_test.go index 054855641fe..b04a5c477de 100644 --- a/grype/matcher/dpkg/matcher_test.go +++ b/grype/matcher/dpkg/matcher_test.go @@ -10,7 +10,7 @@ import ( "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -39,7 +39,7 @@ func TestMatcherDpkg_matchBySourceIndirection(t *testing.T) { assert.Len(t, actual, 2, "unexpected indirect matches count") - foundCVEs := internal.NewStringSet() + foundCVEs := stringutil.NewStringSet() for _, a := range actual { foundCVEs.Add(a.Vulnerability.ID) diff --git a/grype/matcher/java/matcher_test.go b/grype/matcher/java/matcher_test.go index d80a8c0073c..b3dcdf64371 100644 --- a/grype/matcher/java/matcher_test.go +++ b/grype/matcher/java/matcher_test.go @@ -9,7 +9,7 @@ import ( "github.com/anchore/grype/grype/match" "github.com/anchore/grype/grype/pkg" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -44,7 +44,7 @@ func TestMatcherJava_matchUpstreamMavenPackage(t *testing.T) { assert.Len(t, actual, 2, "unexpected matches count") - foundCVEs := internal.NewStringSet() + foundCVEs := stringutil.NewStringSet() for _, v := range actual { foundCVEs.Add(v.Vulnerability.ID) diff --git a/grype/matcher/portage/matcher_test.go b/grype/matcher/portage/matcher_test.go index f3f691a0cc2..2c3c769a59f 100644 --- a/grype/matcher/portage/matcher_test.go +++ b/grype/matcher/portage/matcher_test.go @@ -9,7 +9,7 @@ import ( "github.com/anchore/grype/grype/distro" "github.com/anchore/grype/grype/pkg" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" syftPkg "github.com/anchore/syft/syft/pkg" ) @@ -33,7 +33,7 @@ func TestMatcherPortage_Match(t *testing.T) { assert.Len(t, actual, 1, "unexpected indirect matches count") - foundCVEs := internal.NewStringSet() + foundCVEs := stringutil.NewStringSet() for _, a := range actual { foundCVEs.Add(a.Vulnerability.ID) diff --git a/grype/pkg/package.go b/grype/pkg/package.go index d8b3675d370..7ee1253c7b9 100644 --- a/grype/pkg/package.go +++ b/grype/pkg/package.go @@ -5,8 +5,8 @@ import ( "regexp" "strings" - "github.com/anchore/grype/internal" "github.com/anchore/grype/internal/log" + "github.com/anchore/grype/internal/stringutil" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" @@ -231,7 +231,7 @@ func rpmDataFromPkg(p pkg.Package) (metadata *RpmMetadata, upstreams []UpstreamP } func getNameAndELVersion(sourceRpm string) (string, string) { - groupMatches := internal.MatchCaptureGroups(rpmPackageNamePattern, sourceRpm) + groupMatches := stringutil.MatchCaptureGroups(rpmPackageNamePattern, sourceRpm) version := groupMatches["version"] + "-" + groupMatches["release"] return groupMatches["name"], version } diff --git a/grype/presenter/config.go b/grype/presenter/config.go deleted file mode 100644 index d28244e6f70..00000000000 --- a/grype/presenter/config.go +++ /dev/null @@ -1,93 +0,0 @@ -package presenter - -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 { - 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(outputs []string, defaultFile string, outputTemplateFile string, showSuppressed bool) (Config, error) { - formats := parseOutputs(outputs, defaultFile) - hasTemplateFormat := false - - for _, format := range formats { - if format.id == unknownFormat { - return Config{}, fmt.Errorf("unsupported output format %q, supported formats are: %+v", format.id, - AvailableFormats) - } - - if format.id == templateFormat { - hasTemplateFormat = true - - if outputTemplateFile == "" { - return Config{}, fmt.Errorf("must specify path to template file when using %q output format", - templateFormat) - } - - 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) - } - - if _, err := template.New("").Funcs(presenterTemplate.FuncMap).ParseFiles(outputTemplateFile); err != nil { - return Config{}, fmt.Errorf("unable to parse template: %w", err) - } - } - } - - 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{ - 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 deleted file mode 100644 index b2ce7afe3c2..00000000000 --- a/grype/presenter/config_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package presenter - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestValidatedConfig(t *testing.T) { - cases := []struct { - name string - outputValue []string - includeSuppressed bool - outputTemplateFileValue string - expectedConfig Config - assertErrExpectation func(assert.TestingT, error, ...interface{}) bool - }{ - { - "valid template config", - []string{"template"}, - false, - "./template/test-fixtures/test.valid.template", - Config{ - formats: []format{{id: templateFormat}}, - templateFilePath: "./template/test-fixtures/test.valid.template", - }, - assert.NoError, - }, - { - "template file with non-template format", - []string{"json"}, - false, - "./some/path/to/a/custom.template", - Config{}, - assert.Error, - }, - { - "unknown format", - []string{"some-made-up-format"}, - false, - "", - Config{}, - assert.Error, - }, - - { - "table format", - []string{"table"}, - true, - "", - Config{ - formats: []format{{id: tableFormat}}, - showSuppressed: true, - }, - assert.NoError, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - 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 deleted file mode 100644 index 94e915f1906..00000000000 --- a/grype/presenter/format.go +++ /dev/null @@ -1,78 +0,0 @@ -package presenter - -import ( - "strings" -) - -const ( - 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 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 id 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 format{id: tableFormat} - case strings.ToLower(jsonFormat.String()): - return format{id: jsonFormat} - case strings.ToLower(tableFormat.String()): - return format{id: tableFormat} - case strings.ToLower(sarifFormat.String()): - return format{id: sarifFormat} - case strings.ToLower(templateFormat.String()): - return format{id: templateFormat} - case strings.ToLower(cycloneDXFormat.String()): - return format{id: cycloneDXFormat} - case strings.ToLower(cycloneDXJSON.String()): - return format{id: cycloneDXJSON} - case strings.ToLower(cycloneDXXML.String()): - return format{id: cycloneDXXML} - case strings.ToLower(embeddedVEXJSON.String()): - return format{id: cycloneDXJSON} - case strings.ToLower(embeddedVEXXML.String()): - return format{id: cycloneDXFormat} - default: - return format{id: unknownFormat} - } -} - -// AvailableFormats is a list of presenter format options available to users. -var AvailableFormats = []id{ - jsonFormat, - tableFormat, - cycloneDXFormat, - cycloneDXJSON, - sarifFormat, - templateFormat, -} - -var DefaultFormat = tableFormat - -// DeprecatedFormats TODO: remove in v1.0 -var DeprecatedFormats = []id{ - embeddedVEXJSON, - embeddedVEXXML, -} diff --git a/grype/presenter/json/presenter.go b/grype/presenter/json/presenter.go index 39fbcfc1119..8b26a35f895 100644 --- a/grype/presenter/json/presenter.go +++ b/grype/presenter/json/presenter.go @@ -4,14 +4,10 @@ import ( "encoding/json" "io" - "github.com/spf13/afero" - "github.com/anchore/grype/grype/match" "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 @@ -23,12 +19,10 @@ type Presenter struct { metadataProvider vulnerability.MetadataProvider appConfig interface{} dbStatus interface{} - outputFilePath string - fs afero.Fs } // NewPresenter creates a new JSON presenter -func NewPresenter(fs afero.Fs, pb models.PresenterConfig, outputFilePath string) *Presenter { +func NewPresenter(pb models.PresenterConfig) *Presenter { return &Presenter{ matches: pb.Matches, ignoredMatches: pb.IgnoredMatches, @@ -37,25 +31,11 @@ func NewPresenter(fs afero.Fs, pb models.PresenterConfig, outputFilePath string) context: pb.Context, appConfig: pb.AppConfig, dbStatus: pb.DBStatus, - outputFilePath: outputFilePath, - fs: fs, } } // Present creates a JSON-based reporting -func (pres *Presenter) Present(defaultOutput io.Writer) error { - output, closer, err := file.GetWriter(pres.fs, 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 - } +func (pres *Presenter) Present(output io.Writer) error { 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 56356c1712b..7806ba60ef1 100644 --- a/grype/presenter/json/presenter_test.go +++ b/grype/presenter/json/presenter_test.go @@ -6,7 +6,6 @@ import ( "regexp" "testing" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/anchore/go-testutils" @@ -32,7 +31,7 @@ func TestJsonImgsPresenter(t *testing.T) { MetadataProvider: metadataProvider, } - pres := NewPresenter(afero.NewMemMapFs(), pb, "") + pres := NewPresenter(pb) // run presenter if err := pres.Present(&buffer); err != nil { @@ -65,7 +64,7 @@ func TestJsonDirsPresenter(t *testing.T) { MetadataProvider: metadataProvider, } - pres := NewPresenter(afero.NewMemMapFs(), pb, "") + pres := NewPresenter(pb) // run presenter if err := pres.Present(&buffer); err != nil { @@ -108,7 +107,7 @@ func TestEmptyJsonPresenter(t *testing.T) { MetadataProvider: nil, } - pres := NewPresenter(afero.NewMemMapFs(), pb, "") + pres := NewPresenter(pb) // run presenter if err := pres.Present(&buffer); err != nil { @@ -130,44 +129,3 @@ func TestEmptyJsonPresenter(t *testing.T) { func redact(content []byte) []byte { return timestampRegexp.ReplaceAll(content, []byte(`"timestamp":""`)) } - -func TestJsonPresentWithOutputFile(t *testing.T) { - var buffer bytes.Buffer - - matches, packages, context, metadataProvider, _, _ := models.GenerateAnalysis(t, source.DirectoryScheme) - - pb := models.PresenterConfig{ - Matches: matches, - Packages: packages, - Context: context, - MetadataProvider: metadataProvider, - } - - outputFilePath := "/tmp/report.test.txt" - fs := afero.NewMemMapFs() - pres := NewPresenter(fs, pb, outputFilePath) - - // run presenter - if err := pres.Present(&buffer); err != nil { - t.Fatal(err) - } - - f, err := fs.Open(outputFilePath) - if err != nil { - t.Fatalf("no output file: %+v", err) - } - - outputContent, err := afero.ReadAll(f) - if err != nil { - t.Fatalf("could not file: %+v", err) - } - outputContent = redact(outputContent) - - if *update { - testutils.UpdateGoldenFileContents(t, outputContent) - } - - var expected = testutils.GetGoldenFileContents(t) - - assert.Equal(t, string(expected), string(outputContent)) -} diff --git a/grype/presenter/json/test-fixtures/snapshot/TestJsonPresentWithOutputFile.golden b/grype/presenter/json/test-fixtures/snapshot/TestJsonPresentWithOutputFile.golden deleted file mode 100644 index 840e58c3e60..00000000000 --- a/grype/presenter/json/test-fixtures/snapshot/TestJsonPresentWithOutputFile.golden +++ /dev/null @@ -1,153 +0,0 @@ -{ - "matches": [ - { - "vulnerability": { - "id": "CVE-1999-0001", - "dataSource": "", - "severity": "Low", - "urls": [], - "description": "1999-01 description", - "cvss": [ - { - "version": "3.0", - "vector": "another vector", - "metrics": { - "baseScore": 4 - }, - "vendorMetadata": {} - } - ], - "fix": { - "versions": [ - "the-next-version" - ], - "state": "fixed" - }, - "advisories": [] - }, - "relatedVulnerabilities": [], - "matchDetails": [ - { - "type": "exact-direct-match", - "matcher": "dpkg-matcher", - "searchedBy": { - "distro": { - "type": "ubuntu", - "version": "20.04" - } - }, - "found": { - "constraint": ">= 20" - } - } - ], - "artifact": { - "id": "96699b00fe3004b4", - "name": "package-1", - "version": "1.1.1", - "type": "rpm", - "locations": [ - { - "path": "/foo/bar/somefile-1.txt" - } - ], - "language": "", - "licenses": [], - "cpes": [ - "cpe:2.3:a:anchore:engine:0.9.2:*:*:python:*:*:*:*" - ], - "purl": "", - "upstreams": [ - { - "name": "nothing", - "version": "3.2" - } - ], - "metadataType": "RpmMetadata", - "metadata": { - "epoch": 2, - "modularityLabel": "" - } - } - }, - { - "vulnerability": { - "id": "CVE-1999-0002", - "dataSource": "", - "severity": "Critical", - "urls": [], - "description": "1999-02 description", - "cvss": [ - { - "version": "2.0", - "vector": "vector", - "metrics": { - "baseScore": 1, - "exploitabilityScore": 2, - "impactScore": 3 - }, - "vendorMetadata": { - "BaseSeverity": "Low", - "Status": "verified" - } - } - ], - "fix": { - "versions": [], - "state": "" - }, - "advisories": [] - }, - "relatedVulnerabilities": [], - "matchDetails": [ - { - "type": "exact-indirect-match", - "matcher": "dpkg-matcher", - "searchedBy": { - "cpe": "somecpe" - }, - "found": { - "constraint": "somecpe" - } - } - ], - "artifact": { - "id": "b4013a965511376c", - "name": "package-2", - "version": "2.2.2", - "type": "deb", - "locations": [ - { - "path": "/foo/bar/somefile-2.txt" - } - ], - "language": "", - "licenses": [ - "MIT", - "Apache-2.0" - ], - "cpes": [ - "cpe:2.3:a:anchore:engine:2.2.2:*:*:python:*:*:*:*" - ], - "purl": "", - "upstreams": [] - } - } - ], - "source": { - "type": "directory", - "target": "/some/path" - }, - "distro": { - "name": "centos", - "version": "8.0", - "idLike": [ - "centos" - ] - }, - "descriptor": { - "name": "grype", - "version": "[not provided]", - "timestamp":"" - } -} diff --git a/grype/presenter/presenter.go b/grype/presenter/presenter.go index 2a0fe9e7b3f..72f7a80899c 100644 --- a/grype/presenter/presenter.go +++ b/grype/presenter/presenter.go @@ -1,60 +1,17 @@ package presenter import ( - "io" + "github.com/wagoodman/go-presenter" - "github.com/spf13/afero" - - "github.com/anchore/grype/grype/presenter/cyclonedx" - "github.com/anchore/grype/grype/presenter/json" "github.com/anchore/grype/grype/presenter/models" - "github.com/anchore/grype/grype/presenter/sarif" - "github.com/anchore/grype/grype/presenter/table" - "github.com/anchore/grype/grype/presenter/template" - "github.com/anchore/grype/internal/log" + "github.com/anchore/grype/internal/format" ) -// Presenter is the main interface other Presenters need to implement -type Presenter interface { - Present(io.Writer) error -} - -// GetPresenter retrieves a Presenter that matches a CLI option -// TODO dependency cycle with presenter package to sub formats -func GetPresenters(c Config, pb models.PresenterConfig) (presenters []Presenter) { - fs := afero.NewOsFs() - for _, f := range c.formats { - switch f.id { - case jsonFormat: - presenters = append(presenters, json.NewPresenter(fs, 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: - 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(fs, 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 +// GetPresenter retrieves a Presenter that matches a CLI option. +// Deprecated: this will be removed in v1.0 +func GetPresenter(f string, templatePath string, showSuppressed bool, pb models.PresenterConfig) presenter.Presenter { + return format.GetPresenter(format.Parse(f), format.PresentationConfig{ + TemplateFilePath: templatePath, + ShowSuppressed: showSuppressed, + }, pb) } diff --git a/grype/presenter/template/presenter.go b/grype/presenter/template/presenter.go index fa2085f2e6f..710e2851add 100644 --- a/grype/presenter/template/presenter.go +++ b/grype/presenter/template/presenter.go @@ -10,14 +10,11 @@ import ( "github.com/Masterminds/sprig/v3" "github.com/mitchellh/go-homedir" - "github.com/spf13/afero" "github.com/anchore/grype/grype/match" "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. @@ -29,13 +26,11 @@ type Presenter struct { metadataProvider vulnerability.MetadataProvider appConfig interface{} dbStatus interface{} - outputFilePath string pathToTemplateFile string - fs afero.Fs } // NewPresenter returns a new template.Presenter. -func NewPresenter(fs afero.Fs, pb models.PresenterConfig, outputFilePath string, templateFile string) *Presenter { +func NewPresenter(pb models.PresenterConfig, templateFile string) *Presenter { return &Presenter{ matches: pb.Matches, ignoredMatches: pb.IgnoredMatches, @@ -44,26 +39,12 @@ func NewPresenter(fs afero.Fs, pb models.PresenterConfig, outputFilePath string, context: pb.Context, appConfig: pb.AppConfig, dbStatus: pb.DBStatus, - outputFilePath: outputFilePath, pathToTemplateFile: templateFile, - fs: fs, } } // Present creates output using a user-supplied Go template. -func (pres *Presenter) Present(defaultOutput io.Writer) error { - output, closer, err := file.GetWriter(pres.fs, 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 - } +func (pres *Presenter) Present(output io.Writer) error { 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 503436a3f9f..502f8ca3f75 100644 --- a/grype/presenter/template/presenter_test.go +++ b/grype/presenter/template/presenter_test.go @@ -7,7 +7,6 @@ import ( "path" "testing" - "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -36,7 +35,7 @@ func TestPresenter_Present(t *testing.T) { DBStatus: dbStatus, } - templatePresenter := NewPresenter(afero.NewMemMapFs(), pb, "", templateFilePath) + templatePresenter := NewPresenter(pb, templateFilePath) var buffer bytes.Buffer if err := templatePresenter.Present(&buffer); err != nil { @@ -53,51 +52,6 @@ func TestPresenter_Present(t *testing.T) { assert.Equal(t, string(expected), string(actual)) } -func TestPresenter_PresentWithOutputFile(t *testing.T) { - matches, packages, context, metadataProvider, appConfig, dbStatus := models.GenerateAnalysis(t, source.ImageScheme) - - workingDirectory, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - templateFilePath := path.Join(workingDirectory, "./test-fixtures/test.template") - - pb := models.PresenterConfig{ - Matches: matches, - Packages: packages, - Context: context, - MetadataProvider: metadataProvider, - AppConfig: appConfig, - DBStatus: dbStatus, - } - - outputFilePath := "/tmp/report.test.txt" - fs := afero.NewMemMapFs() - templatePresenter := NewPresenter(fs, pb, outputFilePath, templateFilePath) - - var buffer bytes.Buffer - if err := templatePresenter.Present(&buffer); err != nil { - t.Fatal(err) - } - - f, err := fs.Open(outputFilePath) - if err != nil { - t.Fatalf("no output file: %+v", err) - } - - outputContent, err := afero.ReadAll(f) - if err != nil { - t.Fatalf("could not read file: %+v", err) - } - - if *update { - testutils.UpdateGoldenFileContents(t, outputContent) - } - expected := testutils.GetGoldenFileContents(t) - - assert.Equal(t, string(expected), string(outputContent)) -} - func TestPresenter_SprigDate_Fails(t *testing.T) { matches, packages, context, metadataProvider, appConfig, dbStatus := internal.GenerateAnalysis(t, internal.ImageSource) workingDirectory, err := os.Getwd() @@ -115,7 +69,7 @@ func TestPresenter_SprigDate_Fails(t *testing.T) { DBStatus: dbStatus, } - templatePresenter := NewPresenter(afero.NewMemMapFs(), pb, "", templateFilePath) + templatePresenter := NewPresenter(pb, templateFilePath) var buffer bytes.Buffer err = templatePresenter.Present(&buffer) diff --git a/grype/presenter/template/test-fixtures/snapshot/TestPresenter_PresentWithOutputFile.golden b/grype/presenter/template/test-fixtures/snapshot/TestPresenter_PresentWithOutputFile.golden deleted file mode 100644 index 0ac37fa30dc..00000000000 --- a/grype/presenter/template/test-fixtures/snapshot/TestPresenter_PresentWithOutputFile.golden +++ /dev/null @@ -1,12 +0,0 @@ -Identified distro as centos version 8.0. - Vulnerability: CVE-1999-0001 - Severity: Low - Package: package-1 version 1.1.1 (rpm) - CPEs: ["cpe:2.3:a:anchore:engine:0.9.2:*:*:python:*:*:*:*"] - Matched by: dpkg-matcher - Vulnerability: CVE-1999-0002 - Severity: Critical - Package: package-2 version 2.2.2 (deb) - CPEs: ["cpe:2.3:a:anchore:engine:2.2.2:*:*:python:*:*:*:*"] - Matched by: dpkg-matcher - diff --git a/grype/version/constraint_unit.go b/grype/version/constraint_unit.go index d02b4211492..23aef540ea5 100644 --- a/grype/version/constraint_unit.go +++ b/grype/version/constraint_unit.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" ) // operator group only matches on range operators (GT, LT, GTE, LTE, E) @@ -19,7 +19,7 @@ type constraintUnit struct { } func parseUnit(phrase string) (*constraintUnit, error) { - match := internal.MatchCaptureGroups(constraintPartPattern, phrase) + match := stringutil.MatchCaptureGroups(constraintPartPattern, phrase) version, exists := match["version"] if !exists { return nil, nil diff --git a/internal/bus/helpers.go b/internal/bus/helpers.go new file mode 100644 index 00000000000..e3a59db78f0 --- /dev/null +++ b/internal/bus/helpers.go @@ -0,0 +1,20 @@ +package bus + +import ( + "github.com/wagoodman/go-partybus" + + "github.com/anchore/grype/grype/event" +) + +func Exit() { + Publish(partybus.Event{ + Type: event.CLIExit, + }) +} + +func Report(report string) { + Publish(partybus.Event{ + Type: event.CLIReport, + Value: report, + }) +} diff --git a/internal/file/get_writer.go b/internal/file/get_writer.go deleted file mode 100644 index 90c709e0efa..00000000000 --- a/internal/file/get_writer.go +++ /dev/null @@ -1,31 +0,0 @@ -package file - -import ( - "fmt" - "io" - "os" - "strings" - - "github.com/spf13/afero" -) - -func GetWriter(fs afero.Fs, 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 := fs.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/internal/file/getter.go b/internal/file/getter.go index 3e312ec50e1..216a0965a70 100644 --- a/internal/file/getter.go +++ b/internal/file/getter.go @@ -9,7 +9,7 @@ import ( "github.com/hashicorp/go-getter/helper/url" "github.com/wagoodman/go-progress" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" ) var ( @@ -62,7 +62,7 @@ func (g HashiGoGetter) GetToDir(dst, src string, monitors ...*progress.Manual) e func validateHTTPSource(src string) error { // we are ignoring any sources that are not destined to use the http getter object - if !internal.HasAnyOfPrefixes(src, "http://", "https://") { + if !stringutil.HasAnyOfPrefixes(src, "http://", "https://") { return nil } @@ -71,7 +71,7 @@ func validateHTTPSource(src string) error { return fmt.Errorf("bad URL provided %q: %w", src, err) } // only allow for sources with archive extensions - if !internal.HasAnyOfSuffixes(u.Path, archiveExtensions...) { + if !stringutil.HasAnyOfSuffixes(u.Path, archiveExtensions...) { return ErrNonArchiveSource } return nil diff --git a/internal/format/format.go b/internal/format/format.go new file mode 100644 index 00000000000..f6c099b346b --- /dev/null +++ b/internal/format/format.go @@ -0,0 +1,71 @@ +package format + +import ( + "strings" +) + +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" + + // DEPRECATED <-- TODO: remove in v1.0 + EmbeddedVEXJSON Format = "embedded-cyclonedx-vex-json" + EmbeddedVEXXML Format = "embedded-cyclonedx-vex-xml" +) + +// Format is a dedicated type to represent a specific kind of presenter output format. +type Format string + +func (f Format) 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 + case strings.ToLower(JSONFormat.String()): + return JSONFormat + case strings.ToLower(TableFormat.String()): + return TableFormat + case strings.ToLower(SarifFormat.String()): + return SarifFormat + case strings.ToLower(TemplateFormat.String()): + return TemplateFormat + case strings.ToLower(CycloneDXFormat.String()): + return CycloneDXFormat + case strings.ToLower(CycloneDXJSON.String()): + return CycloneDXJSON + case strings.ToLower(CycloneDXXML.String()): + return CycloneDXXML + case strings.ToLower(EmbeddedVEXJSON.String()): + return CycloneDXJSON + case strings.ToLower(EmbeddedVEXXML.String()): + return CycloneDXFormat + default: + return UnknownFormat + } +} + +// AvailableFormats is a list of presenter format options available to users. +var AvailableFormats = []Format{ + JSONFormat, + TableFormat, + CycloneDXFormat, + CycloneDXJSON, + SarifFormat, + TemplateFormat, +} + +// DeprecatedFormats TODO: remove in v1.0 +var DeprecatedFormats = []Format{ + EmbeddedVEXJSON, + EmbeddedVEXXML, +} diff --git a/grype/presenter/format_test.go b/internal/format/format_test.go similarity index 70% rename from grype/presenter/format_test.go rename to internal/format/format_test.go index d773e53a42b..665b442b749 100644 --- a/grype/presenter/format_test.go +++ b/internal/format/format_test.go @@ -1,4 +1,4 @@ -package presenter +package format import ( "testing" @@ -9,29 +9,29 @@ import ( func TestParse(t *testing.T) { cases := []struct { input string - expected format + expected Format }{ { "", - format{id: "table"}, + TableFormat, }, { "table", - format{id: "table"}, + TableFormat, }, { "jSOn", - format{id: "json"}, + JSONFormat, }, { "booboodepoopoo", - format{id: "unknown"}, + UnknownFormat, }, } for _, tc := range cases { t.Run(tc.input, func(t *testing.T) { - actual := parse(tc.input) + actual := Parse(tc.input) assert.Equal(t, tc.expected, actual, "unexpected result for input %q", tc.input) }) } diff --git a/internal/format/presenter.go b/internal/format/presenter.go new file mode 100644 index 00000000000..e365eaee587 --- /dev/null +++ b/internal/format/presenter.go @@ -0,0 +1,51 @@ +package format + +import ( + "github.com/wagoodman/go-presenter" + + "github.com/anchore/grype/grype/presenter/cyclonedx" + "github.com/anchore/grype/grype/presenter/json" + "github.com/anchore/grype/grype/presenter/models" + "github.com/anchore/grype/grype/presenter/sarif" + "github.com/anchore/grype/grype/presenter/table" + "github.com/anchore/grype/grype/presenter/template" + "github.com/anchore/grype/internal/log" +) + +type PresentationConfig struct { + TemplateFilePath string + ShowSuppressed bool +} + +// GetPresenter retrieves a Presenter that matches a CLI option +func GetPresenter(format Format, c PresentationConfig, pb models.PresenterConfig) presenter.Presenter { + switch format { + case JSONFormat: + return json.NewPresenter(pb) + case TableFormat: + return 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 + } +} diff --git a/internal/format/writer.go b/internal/format/writer.go new file mode 100644 index 00000000000..feb8f4ecdca --- /dev/null +++ b/internal/format/writer.go @@ -0,0 +1,219 @@ +package format + +import ( + "bytes" + "fmt" + "io" + "os" + "path" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/mitchellh/go-homedir" + + "github.com/anchore/grype/grype/presenter/models" + "github.com/anchore/grype/internal/bus" + "github.com/anchore/grype/internal/log" +) + +type ScanResultWriter interface { + Write(result models.PresenterConfig) error +} + +var _ ScanResultWriter = (*scanResultMultiWriter)(nil) + +var _ interface { + io.Closer + ScanResultWriter +} = (*scanResultStreamWriter)(nil) + +// MakeScanResultWriter creates a ScanResultWriter 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, ScanResultWriter.Close() should be called +func MakeScanResultWriter(outputs []string, defaultFile string, cfg PresentationConfig) (ScanResultWriter, error) { + outputOptions, err := parseOutputFlags(outputs, defaultFile, cfg) + if err != nil { + return nil, err + } + + writer, err := newMultiWriter(outputOptions...) + if err != nil { + return nil, err + } + + return writer, nil +} + +// MakeScanResultWriterForFormat creates a ScanResultWriter for the given format or returns an error. +func MakeScanResultWriterForFormat(f string, path string, cfg PresentationConfig) (ScanResultWriter, error) { + format := Parse(f) + + if format == UnknownFormat { + return nil, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, f, AvailableFormats) + } + + writer, err := newMultiWriter(newWriterDescription(format, path, cfg)) + if err != nil { + return nil, err + } + + return writer, nil +} + +// parseOutputFlags utility to parse command-line option strings and retain the existing behavior of default format and file +func parseOutputFlags(outputs []string, defaultFile string, cfg PresentationConfig) (out []scanResultWriterDescription, 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, TableFormat.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 := Parse(name) + + if format == UnknownFormat { + errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, AvailableFormats)) + continue + } + + out = append(out, newWriterDescription(format, file, cfg)) + } + return out, errs +} + +// scanResultWriterDescription Format and path strings used to create ScanResultWriter +type scanResultWriterDescription struct { + Format Format + Path string + Cfg PresentationConfig +} + +func newWriterDescription(f Format, p string, cfg PresentationConfig) scanResultWriterDescription { + 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 scanResultWriterDescription{ + Format: f, + Path: expandedPath, + Cfg: cfg, + } +} + +// scanResultMultiWriter holds a list of child ScanResultWriters to apply all Write and Close operations to +type scanResultMultiWriter struct { + writers []ScanResultWriter +} + +// newMultiWriter create all report writers from input options; if a file is not specified the given defaultWriter is used +func newMultiWriter(options ...scanResultWriterDescription) (_ *scanResultMultiWriter, err error) { + if len(options) == 0 { + return nil, fmt.Errorf("no output options provided") + } + + out := &scanResultMultiWriter{} + + for _, option := range options { + switch len(option.Path) { + case 0: + out.writers = append(out.writers, &scanResultPublisher{ + format: option.Format, + cfg: option.Cfg, + }) + 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, &scanResultStreamWriter{ + format: option.Format, + out: fileOut, + cfg: option.Cfg, + }) + } + } + + return out, nil +} + +// Write writes the result to all writers +func (m *scanResultMultiWriter) Write(s models.PresenterConfig) (errs error) { + for _, w := range m.writers { + err := w.Write(s) + if err != nil { + errs = multierror.Append(errs, fmt.Errorf("unable to write result: %w", err)) + } + } + return errs +} + +// scanResultStreamWriter implements ScanResultWriter for a given format and io.Writer, also providing a close function for cleanup +type scanResultStreamWriter struct { + format Format + cfg PresentationConfig + out io.Writer +} + +// Write the provided result to the data stream +func (w *scanResultStreamWriter) Write(s models.PresenterConfig) error { + pres := GetPresenter(w.format, w.cfg, s) + if err := pres.Present(w.out); err != nil { + return fmt.Errorf("unable to encode result: %w", err) + } + return nil +} + +// Close any resources, such as open files +func (w *scanResultStreamWriter) Close() error { + if closer, ok := w.out.(io.Closer); ok { + return closer.Close() + } + return nil +} + +// scanResultPublisher implements ScanResultWriter that publishes results to the event bus +type scanResultPublisher struct { + format Format + cfg PresentationConfig +} + +// Write the provided result to the data stream +func (w *scanResultPublisher) Write(s models.PresenterConfig) error { + pres := GetPresenter(w.format, w.cfg, s) + buf := &bytes.Buffer{} + if err := pres.Present(buf); err != nil { + return fmt.Errorf("unable to encode result: %w", err) + } + + bus.Report(buf.String()) + return nil +} diff --git a/internal/format/writer_test.go b/internal/format/writer_test.go new file mode 100644 index 00000000000..54c3f2a76de --- /dev/null +++ b/internal/format/writer_test.go @@ -0,0 +1,222 @@ +package format + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/docker/docker/pkg/homedir" + "github.com/stretchr/testify/assert" +) + +func Test_MakeScanResultWriter(t *testing.T) { + tests := []struct { + outputs []string + wantErr assert.ErrorAssertionFunc + }{ + { + outputs: []string{"json"}, + wantErr: assert.NoError, + }, + { + outputs: []string{"table", "json"}, + wantErr: assert.NoError, + }, + { + 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 { + _, err := MakeScanResultWriter(tt.outputs, "", PresentationConfig{}) + tt.wantErr(t, err) + } +} + +func Test_newSBOMMultiWriter(t *testing.T) { + type writerConfig struct { + format string + file string + } + + tmp := t.TempDir() + + testName := func(options []scanResultWriterDescription, err bool) string { + var out []string + for _, opt := range options { + out = append(out, string(opt.Format)+"="+opt.Path) + } + errs := "" + if err { + errs = "(err)" + } + return strings.Join(out, ", ") + errs + } + + tests := []struct { + outputs []scanResultWriterDescription + err bool + expected []writerConfig + }{ + { + outputs: []scanResultWriterDescription{}, + err: true, + }, + { + outputs: []scanResultWriterDescription{ + { + Format: "table", + Path: "", + }, + }, + expected: []writerConfig{ + { + format: "table", + }, + }, + }, + { + outputs: []scanResultWriterDescription{ + { + Format: "json", + }, + }, + expected: []writerConfig{ + { + format: "json", + }, + }, + }, + { + outputs: []scanResultWriterDescription{ + { + Format: "json", + Path: "test-2.json", + }, + }, + expected: []writerConfig{ + { + format: "json", + file: "test-2.json", + }, + }, + }, + { + outputs: []scanResultWriterDescription{ + { + Format: "json", + Path: "test-3/1.json", + }, + { + Format: "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: []scanResultWriterDescription{ + { + Format: "text", + }, + { + Format: "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 := newMultiWriter(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 *scanResultStreamWriter: + assert.Equal(t, string(w.format), e.format) + if e.file != "" { + assert.NotNil(t, w.out) + } else { + assert.NotNil(t, w.out) + } + if e.file != "" { + assert.FileExists(t, tmp+e.file) + } + case *scanResultPublisher: + assert.Equal(t, string(w.format), 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 := newWriterDescription("table", tt.path, PresentationConfig{}) + assert.Equal(t, tt.expected, o.Path) + }) + } +} diff --git a/internal/format/color.go b/internal/stringutil/color.go similarity index 93% rename from internal/format/color.go rename to internal/stringutil/color.go index fa1757c3415..373b98e20ea 100644 --- a/internal/format/color.go +++ b/internal/stringutil/color.go @@ -1,4 +1,4 @@ -package format +package stringutil import "fmt" diff --git a/internal/parse.go b/internal/stringutil/parse.go similarity index 95% rename from internal/parse.go rename to internal/stringutil/parse.go index 300825c986e..6b33c718d0f 100644 --- a/internal/parse.go +++ b/internal/stringutil/parse.go @@ -1,4 +1,4 @@ -package internal +package stringutil import "regexp" diff --git a/internal/string_helpers.go b/internal/stringutil/string_helpers.go similarity index 96% rename from internal/string_helpers.go rename to internal/stringutil/string_helpers.go index b29850522c9..1ff56e35c54 100644 --- a/internal/string_helpers.go +++ b/internal/stringutil/string_helpers.go @@ -1,4 +1,4 @@ -package internal +package stringutil import "strings" diff --git a/internal/string_helpers_test.go b/internal/stringutil/string_helpers_test.go similarity index 99% rename from internal/string_helpers_test.go rename to internal/stringutil/string_helpers_test.go index 44fd05aadf2..b5171686801 100644 --- a/internal/string_helpers_test.go +++ b/internal/stringutil/string_helpers_test.go @@ -1,4 +1,4 @@ -package internal +package stringutil import ( "testing" diff --git a/internal/stringset.go b/internal/stringutil/stringset.go similarity index 96% rename from internal/stringset.go rename to internal/stringutil/stringset.go index 41518aaade0..49a73daab22 100644 --- a/internal/stringset.go +++ b/internal/stringutil/stringset.go @@ -1,4 +1,4 @@ -package internal +package stringutil type StringSet map[string]struct{} diff --git a/internal/format/tprint.go b/internal/stringutil/tprint.go similarity index 94% rename from internal/format/tprint.go rename to internal/stringutil/tprint.go index fc75400bc89..8d874f298bf 100644 --- a/internal/format/tprint.go +++ b/internal/stringutil/tprint.go @@ -1,4 +1,4 @@ -package format +package stringutil import ( "bytes" diff --git a/internal/ui/common_event_handlers.go b/internal/ui/common_event_handlers.go deleted file mode 100644 index 126a04fa42d..00000000000 --- a/internal/ui/common_event_handlers.go +++ /dev/null @@ -1,36 +0,0 @@ -package ui - -import ( - "fmt" - "io" - - "github.com/wagoodman/go-partybus" - - grypeEventParsers "github.com/anchore/grype/grype/event/parsers" -) - -func handleVulnerabilityScanningFinished(event partybus.Event, reportOutput io.Writer) error { - // show the report to stdout - pres, err := grypeEventParsers.ParseVulnerabilityScanningFinished(event) - if err != nil { - return fmt.Errorf("bad CatalogerFinished event: %w", err) - } - - if err := pres.Present(reportOutput); err != nil { - return fmt.Errorf("unable to show vulnerability report: %w", err) - } - return nil -} - -func handleNonRootCommandFinished(event partybus.Event, reportOutput io.Writer) error { - // show the report to stdout - result, err := grypeEventParsers.ParseNonRootCommandFinished(event) - if err != nil { - return fmt.Errorf("bad NonRootCommandFinished event: %w", err) - } - - if _, err := reportOutput.Write([]byte(*result)); err != nil { - return fmt.Errorf("unable to show vulnerability report: %w", err) - } - return nil -} diff --git a/internal/ui/ephemeral_terminal_ui.go b/internal/ui/ephemeral_terminal_ui.go index 498bebc22e9..fb0bd3e3ef3 100644 --- a/internal/ui/ephemeral_terminal_ui.go +++ b/internal/ui/ephemeral_terminal_ui.go @@ -16,6 +16,7 @@ import ( "github.com/anchore/go-logger" grypeEvent "github.com/anchore/grype/grype/event" + "github.com/anchore/grype/grype/event/parsers" "github.com/anchore/grype/internal/log" "github.com/anchore/grype/ui" ) @@ -44,6 +45,7 @@ type ephemeralTerminalUI struct { logBuffer *bytes.Buffer uiOutput *os.File reportOutput io.Writer + reports []string } // NewEphemeralTerminalUI writes all events to a TUI and writes the final report to the given writer. @@ -78,30 +80,22 @@ func (h *ephemeralTerminalUI) Handle(event partybus.Event) error { log.Errorf("unable to show %s event: %+v", event.Type, err) } - case event.Type == grypeEvent.AppUpdateAvailable: - if err := handleAppUpdateAvailable(ctx, h.frame, event, h.waitGroup); err != nil { + case event.Type == grypeEvent.CLIAppUpdateAvailable: + if err := handleCLIAppUpdateAvailable(ctx, h.frame, event, h.waitGroup); err != nil { log.Errorf("unable to show %s event: %+v", event.Type, err) } - case event.Type == grypeEvent.VulnerabilityScanningFinished: - // we need to close the screen now since signaling the the presenter is ready means that we - // are about to write bytes to stdout, so we should reset the terminal state first - h.closeScreen(false) - - if err := handleVulnerabilityScanningFinished(event, h.reportOutput); err != nil { + case event.Type == grypeEvent.CLIReport: + _, report, err := parsers.ParseCLIReport(event) + if err != nil { log.Errorf("unable to show %s event: %+v", event.Type, err) + break } + h.reports = append(h.reports, report) - // this is the last expected event, stop listening to events - return h.unsubscribe() - - case event.Type == grypeEvent.NonRootCommandFinished: + case event.Type == grypeEvent.CLIExit: h.closeScreen(false) - if err := handleNonRootCommandFinished(event, h.reportOutput); err != nil { - log.Errorf("unable to show %s event: %+v", event.Type, err) - } - // this is the last expected event, stop listening to events return h.unsubscribe() } @@ -154,6 +148,11 @@ func (h *ephemeralTerminalUI) flushLog() { func (h *ephemeralTerminalUI) Teardown(force bool) error { h.closeScreen(force) showCursor(h.uiOutput) + for _, report := range h.reports { + if _, err := fmt.Fprintln(h.reportOutput, report); err != nil { + return fmt.Errorf("failed to write report: %w", err) + } + } return nil } diff --git a/internal/ui/etui_event_handlers.go b/internal/ui/etui_event_handlers.go index 09b2f66559b..efa87c54d20 100644 --- a/internal/ui/etui_event_handlers.go +++ b/internal/ui/etui_event_handlers.go @@ -18,8 +18,8 @@ import ( "github.com/anchore/grype/internal/version" ) -func handleAppUpdateAvailable(_ context.Context, fr *frame.Frame, event partybus.Event, _ *sync.WaitGroup) error { - newVersion, err := grypeEventParsers.ParseAppUpdateAvailable(event) +func handleCLIAppUpdateAvailable(_ context.Context, fr *frame.Frame, event partybus.Event, _ *sync.WaitGroup) error { + newVersion, err := grypeEventParsers.ParseCLIAppUpdateAvailable(event) if err != nil { return fmt.Errorf("bad %s event: %w", event.Type, err) } diff --git a/internal/ui/logger_ui.go b/internal/ui/logger_ui.go index f0eed4ada81..c3ad9ee5ac2 100644 --- a/internal/ui/logger_ui.go +++ b/internal/ui/logger_ui.go @@ -6,12 +6,14 @@ import ( "github.com/wagoodman/go-partybus" grypeEvent "github.com/anchore/grype/grype/event" + "github.com/anchore/grype/grype/event/parsers" "github.com/anchore/grype/internal/log" ) type loggerUI struct { unsubscribe func() error reportOutput io.Writer + reports []string } // NewLoggerUI writes all events to the common application logger and writes the final report to the given writer. @@ -26,25 +28,28 @@ func (l *loggerUI) Setup(unsubscribe func() error) error { return nil } -func (l loggerUI) Handle(event partybus.Event) error { +func (l *loggerUI) Handle(event partybus.Event) error { switch event.Type { - case grypeEvent.VulnerabilityScanningFinished: - if err := handleVulnerabilityScanningFinished(event, l.reportOutput); err != nil { - log.Warnf("unable to show catalog image finished event: %+v", err) + case grypeEvent.CLIReport: + _, report, err := parsers.ParseCLIReport(event) + if err != nil { + log.Errorf("unable to show %s event: %+v", event.Type, err) + break } - case grypeEvent.NonRootCommandFinished: - if err := handleNonRootCommandFinished(event, l.reportOutput); err != nil { - log.Warnf("unable to show command finished event: %+v", err) - } - // ignore all events except for the final events - default: - return nil + l.reports = append(l.reports, report) + case grypeEvent.CLIExit: + // this is the last expected event, stop listening to events + return l.unsubscribe() } - - // this is the last expected event, stop listening to events - return l.unsubscribe() + return nil } func (l loggerUI) Teardown(_ bool) error { + for _, report := range l.reports { + _, err := l.reportOutput.Write([]byte(report)) + if err != nil { + return err + } + } return nil } diff --git a/test/integration/match_by_image_test.go b/test/integration/match_by_image_test.go index 1516e60f723..b10e3e3c233 100644 --- a/test/integration/match_by_image_test.go +++ b/test/integration/match_by_image_test.go @@ -16,7 +16,7 @@ import ( "github.com/anchore/grype/grype/pkg" "github.com/anchore/grype/grype/store" "github.com/anchore/grype/grype/vulnerability" - "github.com/anchore/grype/internal" + "github.com/anchore/grype/internal/stringutil" "github.com/anchore/stereoscope/pkg/imagetest" "github.com/anchore/syft/syft" syftPkg "github.com/anchore/syft/syft/pkg" @@ -538,8 +538,8 @@ func addHaskellMatches(t *testing.T, theSource source.Source, catalog *syftPkg.C } func TestMatchByImage(t *testing.T) { - observedMatchers := internal.NewStringSet() - definedMatchers := internal.NewStringSet() + observedMatchers := stringutil.NewStringSet() + definedMatchers := stringutil.NewStringSet() for _, l := range match.AllMatcherTypes { definedMatchers.Add(string(l)) }