diff --git a/cmd/db_diff.go b/cmd/db_diff.go index 6f81455e..a1a165c0 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 d697d68f..2dd6cc33 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 c860e433..4edb9fe3 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 c93dba75..b39a91fc 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 @@ -130,14 +130,14 @@ func setRootFlags(flags *pflag.FlagSet) { fmt.Sprintf("selection of layers to analyze, options=%v", source.AllScopes), ) - flags.StringP( - "output", "o", "", - fmt.Sprintf("report output formatter, formats=%v, deprecated formats=%v", presenter.AvailableFormats, presenter.DeprecatedFormats), + flags.StringArrayP( + "output", "o", nil, + fmt.Sprintf("report output formatter, formats=%v, deprecated formats=%v", format.AvailableFormats, format.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( @@ -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.Output, 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,9 @@ func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-cha SBOM: sbom, AppConfig: appConfig, DBStatus: status, + }); err != nil { + errs <- err } - - bus.Publish(partybus.Event{ - Type: event.VulnerabilityScanningFinished, - Value: presenter.GetPresenter(presenterConfig, pb), - }) }() return errs } @@ -447,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 7c061616..48708284 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 2564f086..4ae41953 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 acb20216..bb3ee22a 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 d6ffab48..02656bd9 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 073cbaf0..b0d7907f 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 386c89c7..ab43539a 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 5dce10e5..e9c1aaa5 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 b9741f57..c5933d78 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 018a19b7..fb039344 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 b9741f57..c5933d78 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 725fa54b..24264fdc 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 faa61825..c757119c 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 91cfcde1..f0d77783 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 9b1a3c14..eab840d7 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,32 +62,6 @@ 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 { - return nil, err - } - - pres, ok := e.Value.(presenter.Presenter) - if !ok { - return nil, newPayloadErr(e.Type, "Value", e.Value) - } - - return pres, nil -} - -func ParseNonRootCommandFinished(e partybus.Event) (*string, error) { - if err := checkEventType(e.Type, event.NonRootCommandFinished); err != nil { - return nil, err - } - - result, ok := e.Value.(string) - if !ok { - return nil, newPayloadErr(e.Type, "Value", e.Value) - } - - return &result, nil -} - func ParseDatabaseDiffingStarted(e partybus.Event) (*diffEvents.Monitor, error) { if err := checkEventType(e.Type, event.DatabaseDiffingStarted); err != nil { return nil, err @@ -114,3 +74,35 @@ func ParseDatabaseDiffingStarted(e partybus.Event) (*diffEvents.Monitor, error) return &monitor, nil } + +func ParseCLIAppUpdateAvailable(e partybus.Event) (string, error) { + if err := checkEventType(e.Type, event.CLIAppUpdateAvailable); err != nil { + return "", err + } + + newVersion, ok := e.Value.(string) + if !ok { + return "", newPayloadErr(e.Type, "Value", e.Value) + } + + return newVersion, nil +} + +func ParseCLIReport(e partybus.Event) (string, string, error) { + if err := checkEventType(e.Type, event.CLIReport); err != nil { + return "", "", err + } + + context, ok := e.Source.(string) + if !ok { + // this is optional + context = "" + } + + 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 05485564..b04a5c47 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 d80a8c00..b3dcdf64 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 f3f691a0..2c3c769a 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 d8b3675d..7ee1253c 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 44be14aa..00000000 --- a/grype/presenter/config.go +++ /dev/null @@ -1,65 +0,0 @@ -package presenter - -import ( - "errors" - "fmt" - "os" - "text/template" - - presenterTemplate "github.com/anchore/grype/grype/presenter/template" -) - -// Config is the presenter domain's configuration data structure. -type Config struct { - format 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) - - 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) - } - - 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) - } - - return Config{ - format: format, - templateFilePath: outputTemplateFile, - }, nil - } - - if outputTemplateFile != "" { - 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, - }, nil -} diff --git a/grype/presenter/config_test.go b/grype/presenter/config_test.go deleted file mode 100644 index 3b90686b..00000000 --- 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", - "template", - false, - "./template/test-fixtures/test.valid.template", - Config{ - format: "template", - templateFilePath: "./template/test-fixtures/test.valid.template", - }, - assert.NoError, - }, - { - "template file with non-template format", - "json", - false, - "./some/path/to/a/custom.template", - Config{}, - assert.Error, - }, - { - "unknown format", - "some-made-up-format", - false, - "", - Config{}, - assert.Error, - }, - - { - "table format", - "table", - true, - "", - Config{ - format: 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 d1aa0580..00000000 --- a/grype/presenter/format.go +++ /dev/null @@ -1,71 +0,0 @@ -package presenter - -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/presenter.go b/grype/presenter/presenter.go index 00256d35..72f7a808 100644 --- a/grype/presenter/presenter.go +++ b/grype/presenter/presenter.go @@ -1,52 +1,17 @@ package presenter import ( - "io" + "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" + "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 GetPresenter(c Config, pb models.PresenterConfig) Presenter { - switch c.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 - } +// 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/version/constraint_unit.go b/grype/version/constraint_unit.go index d02b4211..23aef540 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 00000000..e3a59db7 --- /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/config/application.go b/internal/config/application.go index b1d23571..db32b369 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/getter.go b/internal/file/getter.go index 3e312ec5..216a0965 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 00000000..f6c099b3 --- /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 74% rename from grype/presenter/format_test.go rename to internal/format/format_test.go index f26a5297..665b442b 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 }{ { "", - tableFormat, + TableFormat, }, { "table", - tableFormat, + TableFormat, }, { "jSOn", - jsonFormat, + JSONFormat, }, { "booboodepoopoo", - unknownFormat, + 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 00000000..e365eaee --- /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 00000000..feb8f4ec --- /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 00000000..54c3f2a7 --- /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 fa1757c3..373b98e2 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 300825c9..6b33c718 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 b2985052..1ff56e35 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 44fd05aa..b5171686 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 41518aaa..49a73daa 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 fc75400b..8d874f29 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 126a04fa..00000000 --- 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 498bebc2..fb0bd3e3 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 + 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) + + case event.Type == grypeEvent.CLIExit: h.closeScreen(false) - if err := handleVulnerabilityScanningFinished(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() - - case event.Type == grypeEvent.NonRootCommandFinished: - 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 09b2f665..efa87c54 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 f0eed4ad..c3ad9ee5 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 1516e60f..b10e3e3c 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)) }