Repository: google/osv-scanner
Pull request #158 (2023-01-21 22:39:26)

feat: create `--format` flag
Helps with #156 and [related to](https://github.com/google/osv-scanner/issues/85#issuecomment-1361890538) #85.

I've gone with `--format` instead of `--output` because it seems more common (looking at CLI tools like ESLint, Rubocop, `bundler-audit`, rspec, Terminus) and also because `--output` tends to match the flag used by some CLI tools for "output to file" such as ESLint and cURL which the scanner might decide to support one day.

I've also kept it simple for now since there's only a couple of formats supported - in future it might make sense to employ enum / const types and look at some fancy map similar to what `lockfile` has for its parsers, but I don't think that's needed here yet.

(this'll need rebasing when #161 is landed)


Patch:
diff --git a/README.md b/README.md
index 7c9e9c9..157df22 100644
--- a/README.md
+++ b/README.md
@@ -212,12 +212,30 @@ id = "GO-2022-1059"
 reason = "No external http servers are written in Go lang."
 ```
 
-## JSON output
-By default osv-scanner outputs a human readable table. To have osv-scanner output JSON instead, pass the `--json` flag when calling osv-scanner.
+## Output formats
 
-When using the --json flag, only the JSON output will be printed to stdout, with all other outputs being directed to stderr. So to save only the json output to file, you can redirect the output with `osv-scanner --json ... > /path/to/file.json`
+You can control the format used by the scanner to output results with the `--format` flag. The different formats supported by the scanner are:
 
-### Output Format
+### `table` format
+
+The default format, which outputs the results as a human-readable table.
+
+Sample output:
+
+```
+╭─────────────────────────────────────┬───────────┬──────────────────────────┬─────────┬────────────────────╮
+│ OSV URL (ID IN BOLD)                │ ECOSYSTEM │ PACKAGE                  │ VERSION │ SOURCE             │
+├─────────────────────────────────────┼───────────┼──────────────────────────┼─────────┼────────────────────┤
+│ https://osv.dev/GHSA-c3h9-896r-86jm │ Go        │ github.com/gogo/protobuf │ 1.3.1   │ path/to/go.mod     │
+│ https://osv.dev/GHSA-m5pq-gvj9-9vr8 │ crates.io │ regex                    │ 1.3.1   │ path/to/Cargo.lock │
+╰─────────────────────────────────────┴───────────┴──────────────────────────┴─────────┴────────────────────╯
+```
+
+### `json` format
+
+Outputs the results as a JSON object to stdout, with all other output being directed to stderr - this makes it safe to redirect the output to a file with `osv-scanner --format json ... > /path/to/file.json`.
+
+Sample output:
 
 ```json5
 {
diff --git a/cmd/osv-scanner/main.go b/cmd/osv-scanner/main.go
index 82c7c94..53cd51e 100644
--- a/cmd/osv-scanner/main.go
+++ b/cmd/osv-scanner/main.go
@@ -22,7 +22,7 @@ func run(args []string, stdout, stderr io.Writer) int {
 	var r *output.Reporter
 
 	cli.VersionPrinter = func(ctx *cli.Context) {
-		r = output.NewReporter(stdout, stderr, false)
+		r = output.NewReporter(stdout, stderr, "")
 		r.PrintText(fmt.Sprintf("osv-scanner version: %s\ncommit: %s\nbuilt at: %s\n", ctx.App.Version, commit, date))
 	}
 
@@ -57,9 +57,22 @@ func run(args []string, stdout, stderr io.Writer) int {
 				Usage:     "set/override config file",
 				TakesFile: true,
 			},
+			&cli.StringFlag{
+				Name:    "format",
+				Aliases: []string{"f"},
+				Usage:   "sets the output format",
+				Value:   "table",
+				Action: func(context *cli.Context, s string) error {
+					if s != "table" && s != "json" {
+						return fmt.Errorf("unsupported output format \"%s\" - must be either \"table\" or \"json\"", s)
+					}
+
+					return nil
+				},
+			},
 			&cli.BoolFlag{
 				Name:  "json",
-				Usage: "sets output to json (WIP)",
+				Usage: "sets output to json (deprecated, use --format json instead)",
 			},
 			&cli.BoolFlag{
 				Name:  "skip-git",
@@ -75,7 +88,13 @@ func run(args []string, stdout, stderr io.Writer) int {
 		},
 		ArgsUsage: "[directory1 directory2...]",
 		Action: func(context *cli.Context) error {
-			r = output.NewReporter(stdout, stderr, context.Bool("json"))
+			format := context.String("format")
+
+			if context.Bool("json") {
+				format = "json"
+			}
+
+			r = output.NewReporter(stdout, stderr, format)
 
 			vulnResult, err := osvscanner.DoScan(osvscanner.ScannerActions{
 				LockfilePaths:        context.StringSlice("lockfile"),
@@ -97,7 +116,7 @@ func run(args []string, stdout, stderr io.Writer) int {
 
 	if err := app.Run(args); err != nil {
 		if r == nil {
-			r = output.NewReporter(stdout, stderr, false)
+			r = output.NewReporter(stdout, stderr, "")
 		}
 		if errors.Is(err, osvscanner.VulnerabilitiesFoundErr) {
 			return 1
diff --git a/internal/output/reporter.go b/internal/output/reporter.go
index 10a9918..50679a8 100644
--- a/internal/output/reporter.go
+++ b/internal/output/reporter.go
@@ -9,16 +9,16 @@ import (
 )
 
 type Reporter struct {
-	stdout       io.Writer
-	stderr       io.Writer
-	outputAsJSON bool
+	stdout io.Writer
+	stderr io.Writer
+	format string
 }
 
-func NewReporter(stdout io.Writer, stderr io.Writer, outputAsJSON bool) *Reporter {
+func NewReporter(stdout io.Writer, stderr io.Writer, format string) *Reporter {
 	return &Reporter{
-		stdout:       stdout,
-		stderr:       stderr,
-		outputAsJSON: outputAsJSON,
+		stdout: stdout,
+		stderr: stderr,
+		format: format,
 	}
 }
 
@@ -27,7 +27,7 @@ func NewVoidReporter() *Reporter {
 	stdout := new(strings.Builder)
 	stderr := new(strings.Builder)
 
-	return NewReporter(stdout, stderr, false)
+	return NewReporter(stdout, stderr, "")
 }
 
 // PrintError writes the given message to stderr, regardless of if the reporter
@@ -44,7 +44,7 @@ func (r *Reporter) PrintError(msg string) {
 func (r *Reporter) PrintText(msg string) {
 	target := r.stdout
 
-	if r.outputAsJSON {
+	if r.format == "json" {
 		target = r.stderr
 	}
 
@@ -52,11 +52,12 @@ func (r *Reporter) PrintText(msg string) {
 }
 
 func (r *Reporter) PrintResult(vulnResult *models.VulnerabilityResults) error {
-	if r.outputAsJSON {
+	switch r.format {
+	case "json":
 		return PrintJSONResults(vulnResult, r.stdout)
+	case "table":
+		PrintTableResults(vulnResult, r.stdout)
 	}
 
-	PrintTableResults(vulnResult, r.stdout)
-
 	return nil
 }


Test patch:
diff --git a/cmd/osv-scanner/main_test.go b/cmd/osv-scanner/main_test.go
index de837b4..26e4ff3 100644
--- a/cmd/osv-scanner/main_test.go
+++ b/cmd/osv-scanner/main_test.go
@@ -208,6 +208,20 @@ func TestRun(t *testing.T) {
 				Scanned %%/fixtures/locks-many/composer.lock file and found 1 packages
 			`,
 		},
+		{
+			name:         "",
+			args:         []string{"", "--format", "json", "./fixtures/locks-many/composer.lock"},
+			wantExitCode: 0,
+			wantStdout: `
+				{
+					"results": []
+				}
+			`,
+			wantStderr: `
+				Scanning dir ./fixtures/locks-many/composer.lock
+				Scanned %%/fixtures/locks-many/composer.lock file and found 1 packages
+			`,
+		},
 	}
 	for _, tt := range tests {
 		tt := tt