diff --git a/cmd/osv-scanner/__snapshots__/main_test.snap b/cmd/osv-scanner/__snapshots__/main_test.snap index f81185b9d09..b76a5a768a9 100755 --- a/cmd/osv-scanner/__snapshots__/main_test.snap +++ b/cmd/osv-scanner/__snapshots__/main_test.snap @@ -77,7 +77,7 @@ Scanned /fixtures/locks-many/package-lock.json file and found 1 package --- [TestRun/#06 - 2] -unsupported output format "unknown" - must be one of: table, json, markdown, sarif, gh-annotations +unsupported output format "unknown" - must be one of: table, vertical, json, markdown, sarif, gh-annotations --- diff --git a/go.mod b/go.mod index 411cde85434..f5ed34044ee 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/charmbracelet/glamour v0.7.0 github.com/charmbracelet/lipgloss v0.10.0 github.com/dghubble/trie v0.1.0 + github.com/fatih/color v1.15.0 github.com/gkampitakis/go-snaps v0.5.2 github.com/go-git/go-billy/v5 v5.5.0 github.com/go-git/go-git/v5 v5.11.0 @@ -74,6 +75,7 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/maruel/natural v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect diff --git a/go.sum b/go.sum index 0696fe6ac84..403e97fcc93 100644 --- a/go.sum +++ b/go.sum @@ -79,6 +79,8 @@ github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcej github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/gkampitakis/ciinfo v0.3.0 h1:gWZlOC2+RYYttL0hBqcoQhM7h1qNkVqvRCV1fOvpAv8= github.com/gkampitakis/ciinfo v0.3.0/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -136,6 +138,9 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -284,6 +289,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/output/vertical.go b/internal/output/vertical.go new file mode 100644 index 00000000000..ea65095b731 --- /dev/null +++ b/internal/output/vertical.go @@ -0,0 +1,135 @@ +package output + +import ( + "fmt" + "io" + "strings" + "unicode" + + "github.com/fatih/color" + "github.com/google/osv-scanner/pkg/models" +) + +func PrintVerticalResults(vulnResult *models.VulnerabilityResults, outputWriter io.Writer) { + for _, result := range vulnResult.Results { + fmt.Fprintln(outputWriter, toString(result)) + } +} + +func countVulnerabilities(result models.PackageSource) int { + count := 0 + + for _, pkg := range result.Packages { + count += len(pkg.Vulnerabilities) + } + + return count +} + +// truncate ensures that the given string is shorter than the provided limit. +// +// If the string is longer than the limit, it's trimmed and suffixed with an ellipsis. +// Ideally the string will be trimmed at the space that's closest to the limit to +// preserve whole words; if a string has no spaces before the limit, it'll be forcefully truncated. +func truncate(str string, limit int) string { + count := 0 + truncateAt := -1 + + for i, c := range str { + if unicode.IsSpace(c) { + truncateAt = i + } + + count++ + + if count >= limit { + // ideally we want to keep words whole when truncating, + // but if we can't find a space just truncate at the limit + if truncateAt == -1 { + truncateAt = limit + } + + return str[:truncateAt] + "..." + } + } + + return str +} + +func describe(vulnerability models.Vulnerability) string { + description := vulnerability.Summary + + if description == "" { + description += truncate(vulnerability.Details, 80) + } + + if description == "" { + description += "(no details available)" + } + + description += " (" + OSVBaseVulnerabilityURL + vulnerability.ID + ")" + + return description +} + +func formatLineByLine(result models.PackageSource) string { + lines := make([]string, 0, len(result.Packages)) + + for _, pkg := range result.Packages { + if len(pkg.Vulnerabilities) == 0 { + continue + } + + lines = append(lines, fmt.Sprintf( + " %s %s", + color.YellowString("%s@%s", pkg.Package.Name, pkg.Package.Version), + color.RedString("is affected by the following vulnerabilities:"), + )) + + for _, vulnerability := range pkg.Vulnerabilities { + lines = append(lines, fmt.Sprintf( + " %s %s", + color.CyanString("%s:", vulnerability.ID), + describe(vulnerability), + )) + } + } + + return strings.Join(lines, "\n") +} + +func toString(result models.PackageSource) string { + count := countVulnerabilities(result) + word := "known" + + out := "" + out += fmt.Sprintf( + "%s: found %s %s\n", + color.MagentaString("%s", result.Source.Path), + color.YellowString("%d", len(result.Packages)), + Form(len(result.Packages), "package", "packages"), + ) + + if count == 0 { + return out + fmt.Sprintf( + " %s\n", + color.GreenString("no %s vulnerabilities found", word), + ) + } + + out += "\n" + out += formatLineByLine(result) + out += "\n" + + out += fmt.Sprintf("\n %s\n", + color.RedString( + "%d %s %s found in %s", + count, + word, + Form(count, "vulnerability", "vulnerabilities"), + result.Source.Path, + ), + ) + + return out +} diff --git a/pkg/reporter/format.go b/pkg/reporter/format.go index c97f953fc56..11fac7a2868 100644 --- a/pkg/reporter/format.go +++ b/pkg/reporter/format.go @@ -5,7 +5,7 @@ import ( "io" ) -var format = []string{"table", "json", "markdown", "sarif", "gh-annotations"} +var format = []string{"table", "vertical", "json", "markdown", "sarif", "gh-annotations"} func Format() []string { return format @@ -17,6 +17,8 @@ func New(format string, stdout, stderr io.Writer, level VerbosityLevel, terminal switch format { case "json": return NewJSONReporter(stdout, stderr, level), nil + case "vertical": + return NewVerticalReporter(stdout, stderr, level, false, terminalWidth), nil case "table": return NewTableReporter(stdout, stderr, level, false, terminalWidth), nil case "markdown": diff --git a/pkg/reporter/vertical_reporter.go b/pkg/reporter/vertical_reporter.go new file mode 100644 index 00000000000..e5d44c596cd --- /dev/null +++ b/pkg/reporter/vertical_reporter.go @@ -0,0 +1,68 @@ +package reporter + +import ( + "fmt" + "io" + + "github.com/google/osv-scanner/internal/output" + "github.com/google/osv-scanner/pkg/models" +) + +type VerticalReporter struct { + hasErrored bool + stdout io.Writer + stderr io.Writer + level VerbosityLevel + markdown bool + // 0 indicates not a terminal output + terminalWidth int +} + +func NewVerticalReporter(stdout io.Writer, stderr io.Writer, level VerbosityLevel, markdown bool, terminalWidth int) *VerticalReporter { + return &VerticalReporter{ + stdout: stdout, + stderr: stderr, + hasErrored: false, + level: level, + markdown: markdown, + terminalWidth: terminalWidth, + } +} + +func (r *VerticalReporter) Errorf(format string, a ...any) { + fmt.Fprintf(r.stderr, format, a...) + r.hasErrored = true +} + +func (r *VerticalReporter) HasErrored() bool { + return r.hasErrored +} + +func (r *VerticalReporter) Warnf(format string, a ...any) { + if WarnLevel <= r.level { + fmt.Fprintf(r.stdout, format, a...) + } +} + +func (r *VerticalReporter) Infof(format string, a ...any) { + if InfoLevel <= r.level { + fmt.Fprintf(r.stdout, format, a...) + } +} + +func (r *VerticalReporter) Verbosef(format string, a ...any) { + if VerboseLevel <= r.level { + fmt.Fprintf(r.stdout, format, a...) + } +} + +func (r *VerticalReporter) PrintResult(vulnResult *models.VulnerabilityResults) error { + if len(vulnResult.Results) == 0 && !r.hasErrored { + fmt.Fprintf(r.stdout, "No issues found\n") + return nil + } + + output.PrintVerticalResults(vulnResult, r.stdout) + + return nil +} diff --git a/pkg/reporter/vertical_reporter_test.go b/pkg/reporter/vertical_reporter_test.go new file mode 100644 index 00000000000..1187e63e0e3 --- /dev/null +++ b/pkg/reporter/vertical_reporter_test.go @@ -0,0 +1,98 @@ +package reporter_test + +import ( + "bytes" + "io" + "testing" + + "github.com/google/osv-scanner/pkg/reporter" +) + +func TestVerticalReporter_Errorf(t *testing.T) { + t.Parallel() + + writer := &bytes.Buffer{} + r := reporter.NewVerticalReporter(io.Discard, writer, reporter.ErrorLevel, false, 0) + text := "hello world!" + + r.Errorf(text) + + if writer.String() != text { + t.Error("Error level message should have been printed") + } + if !r.HasErrored() { + t.Error("HasErrored() should have returned true") + } +} + +func TestVerticalReporter_Warnf(t *testing.T) { + t.Parallel() + + text := "hello world!" + tests := []struct { + lvl reporter.VerbosityLevel + expectedPrintout string + }{ + {lvl: reporter.WarnLevel, expectedPrintout: text}, + {lvl: reporter.ErrorLevel, expectedPrintout: ""}, + } + + for _, test := range tests { + writer := &bytes.Buffer{} + r := reporter.NewVerticalReporter(writer, io.Discard, test.lvl, false, 0) + + r.Warnf(text) + + if writer.String() != test.expectedPrintout { + t.Errorf("expected \"%s\", got \"%s\"", test.expectedPrintout, writer.String()) + } + } +} + +func TestVerticalReporter_Infof(t *testing.T) { + t.Parallel() + + text := "hello world!" + tests := []struct { + lvl reporter.VerbosityLevel + expectedPrintout string + }{ + {lvl: reporter.InfoLevel, expectedPrintout: text}, + {lvl: reporter.WarnLevel, expectedPrintout: ""}, + } + + for _, test := range tests { + writer := &bytes.Buffer{} + r := reporter.NewVerticalReporter(writer, io.Discard, test.lvl, false, 0) + + r.Infof(text) + + if writer.String() != test.expectedPrintout { + t.Errorf("expected \"%s\", got \"%s\"", test.expectedPrintout, writer.String()) + } + } +} + +func TestVerticalReporter_Verbosef(t *testing.T) { + t.Parallel() + + text := "hello world!" + tests := []struct { + lvl reporter.VerbosityLevel + expectedPrintout string + }{ + {lvl: reporter.VerboseLevel, expectedPrintout: text}, + {lvl: reporter.InfoLevel, expectedPrintout: ""}, + } + + for _, test := range tests { + writer := &bytes.Buffer{} + r := reporter.NewVerticalReporter(writer, io.Discard, test.lvl, false, 0) + + r.Verbosef(text) + + if writer.String() != test.expectedPrintout { + t.Errorf("expected \"%s\", got \"%s\"", test.expectedPrintout, writer.String()) + } + } +}