diff --git a/cmd/writer.go b/cmd/writer.go deleted file mode 100644 index c2829f5f4d71..000000000000 --- 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 d69b3a0e0e2c..94e915f19065 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 df34edf01b84..ac29a0f5a82d 100644 --- a/grype/presenter/json/presenter.go +++ b/grype/presenter/json/presenter.go @@ -10,6 +10,7 @@ import ( "github.com/anchore/grype/grype/vulnerability" "github.com/anchore/grype/internal/file" "github.com/anchore/grype/internal/log" + "github.com/spf13/afero" ) // Presenter is a generic struct for holding fields needed for reporting @@ -22,10 +23,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 +37,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 99045de9e8da..ed5f377ec30a 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" @@ -30,7 +31,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 { @@ -63,7 +64,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 { @@ -106,7 +107,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 { @@ -128,3 +129,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 000000000000..840e58c3e600 --- /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 7aa6961a7d0a..119cfb3cd0e7 100644 --- a/grype/presenter/presenter.go +++ b/grype/presenter/presenter.go @@ -10,6 +10,7 @@ import ( "github.com/anchore/grype/grype/presenter/table" "github.com/anchore/grype/grype/presenter/template" "github.com/anchore/grype/internal/log" + "github.com/spf13/afero" ) // Presenter is the main interface other Presenters need to implement @@ -20,10 +21,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 +41,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 9c8016e452ca..fa2085f2e6fd 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 8038ae693c21..651e0103e9f2 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 := models.GenerateAnalysis(t, source.ImageScheme) 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 000000000000..0ac37fa30dce --- /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 59faefc225b1..90c709e0efaf 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)