Repository: google/osv-scanner
Pull request #3 (2022-11-20 00:01:03)

feat: restructure output implementation to support testing and further improvements
(pull over of https://github.com/google/osv.dev/pull/817)

This switches the CLI to use an internal `Reporter` for handling output, which has a couple of wins:
  * enables testing by making it easy to reliable set the streams used for `stdout` and `stderr`
    * I've added some basic tests so that the structure is there, but not written cases for every single situation; a followup task should be to chip away at adding new tests for existing features
    * currently `run` is private, but making it public should address [this request](https://github.com/ossf/scorecard/issues/2162#issuecomment-1317586767)
  * ensure that the output is valid for the flags provided, while also not being surprising in regards to piping
    * i.e. now doing `osv-scanner . > myfile` will capture the human-output results, and `osv-scanner --json . > myfile` will give you valid JSON since the `Reporter` sends text to `stderr` when `--json` is passed
  * enables future improvements to output, such as adding a `--verbose` or `--quiet` flags which could easily be handled with `PrintDebug`, `PrintExtra`, etc functions

I've tried to keep this minimal because this sort of thing is typically the backbone of a CLI, so it ends up shaping the implementation of everything else; that includes only focusing on `main` as that's where the main logging is and ideally these should be some tests to accompany the changes that I think would be good to implement together.

This is based on the implementation in `osv-detector`, which has been working very well.

another-rex: /gcbrun
another-rex: /gcbrun

Patch:
diff --git a/cmd/osv-scanner/fixtures/locks-many-with-invalid/Gemfile.lock b/cmd/osv-scanner/fixtures/locks-many-with-invalid/Gemfile.lock
new file mode 100644
index 0000000..19a42f0
--- /dev/null
+++ b/cmd/osv-scanner/fixtures/locks-many-with-invalid/Gemfile.lock
@@ -0,0 +1,16 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    ast (2.4.2)
+
+PLATFORMS
+  x86_64-linux
+
+DEPENDENCIES
+  ast
+
+RUBY VERSION
+   ruby 3.0.2p107
+
+BUNDLED WITH
+   2.2.28
diff --git a/cmd/osv-scanner/fixtures/locks-many-with-invalid/composer.lock b/cmd/osv-scanner/fixtures/locks-many-with-invalid/composer.lock
new file mode 100644
index 0000000..053207d
--- /dev/null
+++ b/cmd/osv-scanner/fixtures/locks-many-with-invalid/composer.lock
@@ -0,0 +1,51 @@
+{
+  "_readme": [
+    "This file locks the dependencies of your project to a known state",
+    "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
+    "This file is @generated automatically"
+  ],
+  "content-hash": "439b16dd5df2e0730bd1cc4352654d09",,
+  "packages": [
+    {
+      "name": "sentry/sdk",
+      "version": "2.0.4",
+      "source": {
+        "type": "git",
+        "url": "https://github.com/getsentry/sentry-php-sdk.git",
+        "reference": "4c115873c86ad5bd0ac6d962db70ca53bf8fb874"
+      },
+      "dist": {
+        "type": "zip",
+        "url": "https://api.github.com/repos/getsentry/sentry-php-sdk/zipball/4c115873c86ad5bd0ac6d962db70ca53bf8fb874",
+        "reference": "4c115873c86ad5bd0ac6d962db70ca53bf8fb874",
+        "shasum": ""
+      },
+      "require": {
+        "http-interop/http-factory-guzzle": "^1.0",
+        "php-http/curl-client": "^1.0|^2.0",
+        "sentry/sentry": "^2.1.3"
+      },
+      "type": "metapackage",
+      "notification-url": "https://packagist.org/downloads/",
+      "license": ["MIT"],
+      "authors": [
+        {
+          "name": "Sentry",
+          "email": "accounts@sentry.io"
+        }
+      ],
+      "description": "This is a metapackage shipping sentry/sentry with a recommended http client.",
+      "time": "2019-09-09T19:54:44+00:00"
+    }
+  ],
+  "packages-dev": [],
+  "aliases": [],
+  "minimum-stability": "dev",
+  "stability-flags": [],
+  "prefer-stable": true,
+  "prefer-lowest": false,
+  "platform": {
+    "php": "^7.1.3"
+  },
+  "platform-dev": []
+}
diff --git a/cmd/osv-scanner/fixtures/locks-many-with-invalid/yarn.lock b/cmd/osv-scanner/fixtures/locks-many-with-invalid/yarn.lock
new file mode 100644
index 0000000..65a4fc7
--- /dev/null
+++ b/cmd/osv-scanner/fixtures/locks-many-with-invalid/yarn.lock
@@ -0,0 +1,7 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+balanced-match@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
diff --git a/cmd/osv-scanner/fixtures/locks-many/Gemfile.lock b/cmd/osv-scanner/fixtures/locks-many/Gemfile.lock
new file mode 100644
index 0000000..19a42f0
--- /dev/null
+++ b/cmd/osv-scanner/fixtures/locks-many/Gemfile.lock
@@ -0,0 +1,16 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    ast (2.4.2)
+
+PLATFORMS
+  x86_64-linux
+
+DEPENDENCIES
+  ast
+
+RUBY VERSION
+   ruby 3.0.2p107
+
+BUNDLED WITH
+   2.2.28
diff --git a/cmd/osv-scanner/fixtures/locks-many/composer.lock b/cmd/osv-scanner/fixtures/locks-many/composer.lock
new file mode 100644
index 0000000..3cfadf7
--- /dev/null
+++ b/cmd/osv-scanner/fixtures/locks-many/composer.lock
@@ -0,0 +1,51 @@
+{
+  "_readme": [
+    "This file locks the dependencies of your project to a known state",
+    "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
+    "This file is @generated automatically"
+  ],
+  "content-hash": "439b16dd5df2e0730bd1cc4352654d09",
+  "packages": [
+    {
+      "name": "sentry/sdk",
+      "version": "2.0.4",
+      "source": {
+        "type": "git",
+        "url": "https://github.com/getsentry/sentry-php-sdk.git",
+        "reference": "4c115873c86ad5bd0ac6d962db70ca53bf8fb874"
+      },
+      "dist": {
+        "type": "zip",
+        "url": "https://api.github.com/repos/getsentry/sentry-php-sdk/zipball/4c115873c86ad5bd0ac6d962db70ca53bf8fb874",
+        "reference": "4c115873c86ad5bd0ac6d962db70ca53bf8fb874",
+        "shasum": ""
+      },
+      "require": {
+        "http-interop/http-factory-guzzle": "^1.0",
+        "php-http/curl-client": "^1.0|^2.0",
+        "sentry/sentry": "^2.1.3"
+      },
+      "type": "metapackage",
+      "notification-url": "https://packagist.org/downloads/",
+      "license": ["MIT"],
+      "authors": [
+        {
+          "name": "Sentry",
+          "email": "accounts@sentry.io"
+        }
+      ],
+      "description": "This is a metapackage shipping sentry/sentry with a recommended http client.",
+      "time": "2019-09-09T19:54:44+00:00"
+    }
+  ],
+  "packages-dev": [],
+  "aliases": [],
+  "minimum-stability": "dev",
+  "stability-flags": [],
+  "prefer-stable": true,
+  "prefer-lowest": false,
+  "platform": {
+    "php": "^7.1.3"
+  },
+  "platform-dev": []
+}
diff --git a/cmd/osv-scanner/fixtures/locks-many/not-a-lockfile.toml b/cmd/osv-scanner/fixtures/locks-many/not-a-lockfile.toml
new file mode 100644
index 0000000..3e4db4a
--- /dev/null
+++ b/cmd/osv-scanner/fixtures/locks-many/not-a-lockfile.toml
@@ -0,0 +1,1 @@
+_="whatever this is, it's not a lockfile!"
diff --git a/cmd/osv-scanner/fixtures/locks-many/yarn.lock b/cmd/osv-scanner/fixtures/locks-many/yarn.lock
new file mode 100644
index 0000000..65a4fc7
--- /dev/null
+++ b/cmd/osv-scanner/fixtures/locks-many/yarn.lock
@@ -0,0 +1,7 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+balanced-match@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
diff --git a/cmd/osv-scanner/fixtures/locks-one-with-nested/nested/composer.lock b/cmd/osv-scanner/fixtures/locks-one-with-nested/nested/composer.lock
new file mode 100644
index 0000000..3cfadf7
--- /dev/null
+++ b/cmd/osv-scanner/fixtures/locks-one-with-nested/nested/composer.lock
@@ -0,0 +1,51 @@
+{
+  "_readme": [
+    "This file locks the dependencies of your project to a known state",
+    "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
+    "This file is @generated automatically"
+  ],
+  "content-hash": "439b16dd5df2e0730bd1cc4352654d09",
+  "packages": [
+    {
+      "name": "sentry/sdk",
+      "version": "2.0.4",
+      "source": {
+        "type": "git",
+        "url": "https://github.com/getsentry/sentry-php-sdk.git",
+        "reference": "4c115873c86ad5bd0ac6d962db70ca53bf8fb874"
+      },
+      "dist": {
+        "type": "zip",
+        "url": "https://api.github.com/repos/getsentry/sentry-php-sdk/zipball/4c115873c86ad5bd0ac6d962db70ca53bf8fb874",
+        "reference": "4c115873c86ad5bd0ac6d962db70ca53bf8fb874",
+        "shasum": ""
+      },
+      "require": {
+        "http-interop/http-factory-guzzle": "^1.0",
+        "php-http/curl-client": "^1.0|^2.0",
+        "sentry/sentry": "^2.1.3"
+      },
+      "type": "metapackage",
+      "notification-url": "https://packagist.org/downloads/",
+      "license": ["MIT"],
+      "authors": [
+        {
+          "name": "Sentry",
+          "email": "accounts@sentry.io"
+        }
+      ],
+      "description": "This is a metapackage shipping sentry/sentry with a recommended http client.",
+      "time": "2019-09-09T19:54:44+00:00"
+    }
+  ],
+  "packages-dev": [],
+  "aliases": [],
+  "minimum-stability": "dev",
+  "stability-flags": [],
+  "prefer-stable": true,
+  "prefer-lowest": false,
+  "platform": {
+    "php": "^7.1.3"
+  },
+  "platform-dev": []
+}
diff --git a/cmd/osv-scanner/fixtures/locks-one-with-nested/yarn.lock b/cmd/osv-scanner/fixtures/locks-one-with-nested/yarn.lock
new file mode 100644
index 0000000..65a4fc7
--- /dev/null
+++ b/cmd/osv-scanner/fixtures/locks-one-with-nested/yarn.lock
@@ -0,0 +1,7 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+balanced-match@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
diff --git a/cmd/osv-scanner/main.go b/cmd/osv-scanner/main.go
index 1012ed4..dbea4d2 100644
--- a/cmd/osv-scanner/main.go
+++ b/cmd/osv-scanner/main.go
@@ -4,7 +4,8 @@ import (
 	"bufio"
 	"bytes"
 	"errors"
-	"log"
+	"fmt"
+	"io"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -25,22 +26,23 @@ const osvScannerConfigName = "osv-scanner.toml"
 //   - Any lockfiles with scanLockfile
 //   - Any SBOM files with scanSBOMFile
 //   - Any git repositories with scanGit
-func scanDir(query *osv.BatchedQuery, dir string, skipGit bool, recursive bool) error {
+func scanDir(r *output.Reporter, query *osv.BatchedQuery, dir string, skipGit bool, recursive bool) error {
 	root := true
 	return filepath.WalkDir(dir, func(path string, info os.DirEntry, err error) error {
 		if err != nil {
-			log.Printf("Failed to walk %s: %v", path, err)
+			r.PrintText(fmt.Sprintf("Failed to walk %s: %v\n", path, err))
 			return err
 		}
 		path, err = filepath.Abs(path)
 		if err != nil {
-			log.Fatalf("Failed to walk path %s", err)
+			r.PrintError(fmt.Sprintf("Failed to walk path %s\n", err))
+			return err
 		}
 
 		if !skipGit && info.IsDir() && info.Name() == ".git" {
-			err := scanGit(query, filepath.Dir(path)+"/")
+			err := scanGit(r, query, filepath.Dir(path)+"/")
 			if err != nil {
-				log.Printf("scan failed for %s: %v\n", path, err)
+				r.PrintText(fmt.Sprintf("scan failed for %s: %v\n", path, err))
 				return err
 			}
 			return filepath.SkipDir
@@ -48,15 +50,15 @@ func scanDir(query *osv.BatchedQuery, dir string, skipGit bool, recursive bool)
 
 		if !info.IsDir() {
 			if parser, _ := lockfile.FindParser(path, ""); parser != nil {
-				err := scanLockfile(query, path)
+				err := scanLockfile(r, query, path)
 				if err != nil {
-					log.Println("Attempted to scan lockfile but failed: " + path)
+					r.PrintError(fmt.Sprintf("Attempted to scan lockfile but failed: %s\n", path))
 				}
 			}
 			// No need to check for error
 			// If scan fails, it means it isn't a valid SBOM file,
 			// so just move onto the next file
-			_ = scanSBOMFile(query, path)
+			_ = scanSBOMFile(r, query, path)
 		}
 
 		if !root && !recursive && info.IsDir() {
@@ -70,12 +72,12 @@ func scanDir(query *osv.BatchedQuery, dir string, skipGit bool, recursive bool)
 
 // scanLockfile will load, identify, and parse the lockfile path passed in, and add the dependencies specified
 // within to `query`
-func scanLockfile(query *osv.BatchedQuery, path string) error {
+func scanLockfile(r *output.Reporter, query *osv.BatchedQuery, path string) error {
 	parsedLockfile, err := lockfile.Parse(path, "")
 	if err != nil {
 		return err
 	}
-	log.Printf("Scanned %s file and found %d packages", path, len(parsedLockfile.Packages))
+	r.PrintText(fmt.Sprintf("Scanned %s file and found %d packages\n", path, len(parsedLockfile.Packages)))
 
 	for _, pkgDetail := range parsedLockfile.Packages {
 		pkgDetailQuery := osv.MakePkgRequest(pkgDetail)
@@ -90,7 +92,7 @@ func scanLockfile(query *osv.BatchedQuery, path string) error {
 
 // scanSBOMFile will load, identify, and parse the SBOM path passed in, and add the dependencies specified
 // within to `query`
-func scanSBOMFile(query *osv.BatchedQuery, path string) error {
+func scanSBOMFile(r *output.Reporter, query *osv.BatchedQuery, path string) error {
 	file, err := os.Open(path)
 	if err != nil {
 		return err
@@ -115,7 +117,7 @@ func scanSBOMFile(query *osv.BatchedQuery, path string) error {
 		})
 		if err == nil {
 			// Found the right format.
-			log.Printf("Scanned %s SBOM", provider.Name())
+			r.PrintText(fmt.Sprintf("Scanned %s SBOM\n", provider.Name()))
 			return nil
 		}
 
@@ -142,13 +144,13 @@ func getCommitSHA(repoDir string) (string, error) {
 }
 
 // Scan git repository. Expects repoDir to end with /
-func scanGit(query *osv.BatchedQuery, repoDir string) error {
+func scanGit(r *output.Reporter, query *osv.BatchedQuery, repoDir string) error {
 	commit, err := getCommitSHA(repoDir)
 	if err != nil {
 		return err
 	}
 
-	log.Printf("Scanning %s at commit %s", repoDir, commit)
+	r.PrintText(fmt.Sprintf("Scanning %s at commit %s\n", repoDir, commit))
 
 	gitQuery := osv.MakeCommitRequest(commit)
 	gitQuery.Source = osv.Source{
@@ -159,20 +161,23 @@ func scanGit(query *osv.BatchedQuery, repoDir string) error {
 	return nil
 }
 
-func scanDebianDocker(query *osv.BatchedQuery, dockerImageName string) {
+func scanDebianDocker(r *output.Reporter, query *osv.BatchedQuery, dockerImageName string) error {
 	cmd := exec.Command("docker", "run", "--rm", "--entrypoint", "/usr/bin/dpkg-query", dockerImageName, "-f", "${Package}###${Version}\\n", "-W")
 	stdout, err := cmd.StdoutPipe()
 
 	if err != nil {
-		log.Fatalf("Failed to get stdout: %s", err)
+		r.PrintError(fmt.Sprintf("Failed to get stdout: %s\n", err))
+		return err
 	}
 	err = cmd.Start()
 	if err != nil {
-		log.Fatalf("Failed to start docker image: %s", err)
+		r.PrintError(fmt.Sprintf("Failed to start docker image: %s\n", err))
+		return err
 	}
 	defer cmd.Wait()
 	if err != nil {
-		log.Fatalf("Failed to run docker: %s", err)
+		r.PrintError(fmt.Sprintf("Failed to run docker: %s\n", err))
+		return err
 	}
 	scanner := bufio.NewScanner(stdout)
 	packages := 0
@@ -184,7 +189,8 @@ func scanDebianDocker(query *osv.BatchedQuery, dockerImageName string) {
 		}
 		splitText := strings.Split(text, "###")
 		if len(splitText) != 2 {
-			log.Fatalf("Unexpected output from Debian container: \n\n%s", text)
+			r.PrintError(fmt.Sprintf("Unexpected output from Debian container: \n\n%s\n", text))
+			return fmt.Errorf("Unexpected output from Debian container: \n\n%s", text)
 		}
 		pkgDetailsQuery := osv.MakePkgRequest(lockfile.PackageDetails{
 			Name:    splitText[0],
@@ -199,11 +205,13 @@ func scanDebianDocker(query *osv.BatchedQuery, dockerImageName string) {
 		query.Queries = append(query.Queries, pkgDetailsQuery)
 		packages += 1
 	}
-	log.Printf("Scanned docker image with %d packages", packages)
+	r.PrintText(fmt.Sprintf("Scanned docker image with %d packages\n", packages))
+
+	return nil
 }
 
 // Filters response according to config, returns number of responses removed
-func filterResponse(query osv.BatchedQuery, resp *osv.BatchedResponse, configManager *ConfigManager) int {
+func filterResponse(r *output.Reporter, query osv.BatchedQuery, resp *osv.BatchedResponse, configManager *ConfigManager) int {
 	hiddenVulns := map[string]IgnoreEntry{}
 
 	for i, result := range resp.Results {
@@ -221,24 +229,26 @@ func filterResponse(query osv.BatchedQuery, resp *osv.BatchedResponse, configMan
 	}
 
 	for id, ignoreLine := range hiddenVulns {
-		log.Printf("%s has been filtered out because: %s", id, ignoreLine.Reason)
+		r.PrintText(fmt.Sprintf("%s has been filtered out because: %s\n", id, ignoreLine.Reason))
 	}
 
 	return len(hiddenVulns)
 }
 
-func main() {
+func run(args []string, stdout, stderr io.Writer) int {
+	var r *output.Reporter
 	configManager := ConfigManager{
 		defaultConfig: Config{},
 		configMap:     make(map[string]Config),
 	}
 	var query osv.BatchedQuery
-	var outputJson bool
 
 	app := &cli.App{
-		Name:    "osv-scanner",
-		Usage:   "scans various mediums for dependencies and matches it against the OSV database",
-		Suggest: true,
+		Name:      "osv-scanner",
+		Usage:     "scans various mediums for dependencies and matches it against the OSV database",
+		Suggest:   true,
+		Writer:    stdout,
+		ErrWriter: stderr,
 		Flags: []cli.Flag{
 			&cli.StringSliceFlag{
 				Name:      "docker",
@@ -281,12 +291,14 @@ func main() {
 		},
 		ArgsUsage: "[directory1 directory2...]",
 		Action: func(context *cli.Context) error {
+			r = output.NewReporter(stdout, stderr, context.Bool("json"))
 
 			configPath := context.String("config")
 			if configPath != "" {
 				err := configManager.UseOverride(configPath)
 				if err != nil {
-					log.Fatalf("Failed to read config file: %s\n", err)
+					r.PrintError(fmt.Sprintf("Failed to read config file: %s\n", err))
+					return err
 				}
 			}
 
@@ -294,16 +306,17 @@ func main() {
 			for _, container := range containers {
 				// TODO: Automatically figure out what docker base image
 				// and scan appropriately.
-				scanDebianDocker(&query, container)
+				scanDebianDocker(r, &query, container)
 			}
 
 			lockfiles := context.StringSlice("lockfile")
 			for _, lockfileElem := range lockfiles {
 				lockfileElem, err := filepath.Abs(lockfileElem)
 				if err != nil {
-					log.Fatalf("Failed to resolved path with error %s", err)
+					r.PrintError(fmt.Sprintf("Failed to resolved path with error %s\n", err))
+					return err
 				}
-				err = scanLockfile(&query, lockfileElem)
+				err = scanLockfile(r, &query, lockfileElem)
 				if err != nil {
 					return err
 				}
@@ -313,9 +326,10 @@ func main() {
 			for _, sbomElem := range sboms {
 				sbomElem, err := filepath.Abs(sbomElem)
 				if err != nil {
-					log.Fatalf("Failed to resolved path with error %s", err)
+					r.PrintError(fmt.Sprintf("Failed to resolved path with error %s\n", err))
+					return err
 				}
-				err = scanSBOMFile(&query, sbomElem)
+				err = scanSBOMFile(r, &query, sbomElem)
 				if err != nil {
 					return err
 				}
@@ -325,49 +339,55 @@ func main() {
 			recursive := context.Bool("recursive")
 			genericDirs := context.Args().Slice()
 			for _, dir := range genericDirs {
-				log.Printf("Scanning dir %s\n", dir)
-				err := scanDir(&query, dir, skipGit, recursive)
+				r.PrintText(fmt.Sprintf("Scanning dir %s\n", dir))
+				err := scanDir(r, &query, dir, skipGit, recursive)
 				if err != nil {
 					return err
 				}
 			}
 
 			if len(query.Queries) == 0 {
-				cli.ShowAppHelpAndExit(context, 1)
+				_ = cli.ShowAppHelp(context)
+				return fmt.Errorf("")
 			}
 
-			outputJson = context.Bool("json")
-
 			return nil
 		},
 	}
 
-	if err := app.Run(os.Args); err != nil {
-		log.Fatal(err)
+	if err := app.Run(args); err != nil {
+		r.PrintError(fmt.Sprintf("%v", err))
+		return 1
 	}
 
 	resp, err := osv.MakeRequest(query)
 	if err != nil {
-		log.Fatalf("Scan failed: %v", err)
+		r.PrintError(fmt.Sprintf("Scan failed: %v", err))
+		return 1
 	}
 
-	filtered := filterResponse(query, resp, &configManager)
+	filtered := filterResponse(r, query, resp, &configManager)
 	if filtered > 0 {
-		log.Printf("Filtered %d vulnerabilities from output", filtered)
+		r.PrintText(fmt.Sprintf("Filtered %d vulnerabilities from output\n", filtered))
 	}
 
 	hydratedResp, err := osv.Hydrate(resp)
 	if err != nil {
-		log.Fatalf("Failed to hydrate OSV response: %v", err)
+		r.PrintError(fmt.Sprintf("Failed to hydrate OSV response: %v", err))
+		return 1
 	}
 
-	if outputJson {
-		err = output.PrintJSONResults(query, hydratedResp, os.Stdout)
-	} else {
-		output.PrintTableResults(query, hydratedResp, os.Stdout)
-	}
+	err = r.PrintResult(query, hydratedResp)
 
 	if err != nil {
-		log.Fatalf("Failed to write output: %s", err)
+		r.PrintError(fmt.Sprintf("Failed to write output: %s", err))
+		return 1
 	}
+
+	return 0
+}
+
+// TODO(ochang): Machine readable output format.
+func main() {
+	os.Exit(run(os.Args, os.Stdout, os.Stderr))
 }
diff --git a/internal/output/reporter.go b/internal/output/reporter.go
new file mode 100644
index 0000000..9585d5f
--- /dev/null
+++ b/internal/output/reporter.go
@@ -0,0 +1,52 @@
+package output
+
+import (
+	"fmt"
+	"github.com/google/osv-scanner/internal/osv"
+	"io"
+)
+
+type Reporter struct {
+	stdout       io.Writer
+	stderr       io.Writer
+	outputAsJSON bool
+}
+
+func NewReporter(stdout io.Writer, stderr io.Writer, outputAsJSON bool) *Reporter {
+	return &Reporter{
+		stdout:       stdout,
+		stderr:       stderr,
+		outputAsJSON: outputAsJSON,
+	}
+}
+
+// PrintError writes the given message to stderr, regardless of if the reporter
+// is outputting as JSON or not
+func (r *Reporter) PrintError(msg string) {
+	fmt.Fprint(r.stderr, msg)
+}
+
+// PrintText writes the given message to stdout, _unless_ the reporter is set
+// to output as JSON, in which case it writes the message to stderr.
+//
+// This should be used for content that should always be outputted, but that
+// should not be captured when piping if outputting JSON.
+func (r *Reporter) PrintText(msg string) {
+	target := r.stdout
+
+	if r.outputAsJSON {
+		target = r.stderr
+	}
+
+	fmt.Fprint(target, msg)
+}
+
+func (r *Reporter) PrintResult(query osv.BatchedQuery, resp *osv.HydratedBatchedResponse) error {
+	if r.outputAsJSON {
+		return PrintJSONResults(query, resp, r.stdout)
+	}
+
+	PrintTableResults(query, resp, r.stdout)
+
+	return nil
+}


Test patch:
diff --git a/cmd/osv-scanner/main_test.go b/cmd/osv-scanner/main_test.go
new file mode 100644
index 0000000..86e11d3
--- /dev/null
+++ b/cmd/osv-scanner/main_test.go
@@ -0,0 +1,242 @@
+//nolint:testpackage // main cannot be accessed directly, so cannot use main_test
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"regexp"
+	"strings"
+	"testing"
+)
+
+func dedent(t *testing.T, str string) string {
+	t.Helper()
+
+	// 0. replace all tabs with spaces
+	str = strings.ReplaceAll(str, "\t", "  ")
+
+	// 1. remove trailing whitespace
+	re := regexp.MustCompile(`\r?\n([\t ]*)$`)
+	str = re.ReplaceAllString(str, "")
+
+	// 2. if any of the lines are not indented, return as we're already dedent-ed
+	re = regexp.MustCompile(`(^|\r?\n)[^\t \n]`)
+	if re.MatchString(str) {
+		return str
+	}
+
+	// 3. find all line breaks to determine the highest common indentation level
+	re = regexp.MustCompile(`\n[\t ]+`)
+	matches := re.FindAllString(str, -1)
+
+	// 4. remove the common indentation from all strings
+	if matches != nil {
+		size := len(matches[0]) - 1
+
+		for _, match := range matches {
+			if len(match)-1 < size {
+				size = len(match) - 1
+			}
+		}
+
+		re := regexp.MustCompile(`\n[\t ]{` + fmt.Sprint(size) + `}`)
+		str = re.ReplaceAllString(str, "\n")
+	}
+
+	// 5. Remove leading whitespace.
+	re = regexp.MustCompile(`^\r?\n`)
+	str = re.ReplaceAllString(str, "")
+
+	return str
+}
+
+// checks if two strings are equal, treating any occurrences of `%%` in the
+// expected string to mean "any text"
+func areEqual(t *testing.T, actual, expect string) bool {
+	t.Helper()
+
+	expect = regexp.QuoteMeta(expect)
+	expect = strings.ReplaceAll(expect, "%%", ".+")
+
+	re := regexp.MustCompile(`^` + expect + `$`)
+
+	return re.MatchString(actual)
+}
+
+type cliTestCase struct {
+	name         string
+	args         []string
+	wantExitCode int
+	wantStdout   string
+	wantStderr   string
+}
+
+func testCli(t *testing.T, tc cliTestCase) {
+	t.Helper()
+
+	stdoutBuffer := &bytes.Buffer{}
+	stderrBuffer := &bytes.Buffer{}
+
+	ec := run(tc.args, stdoutBuffer, stderrBuffer)
+	// ec := run(tc.args, os.Stdout, os.Stderr)
+
+	stdout := stdoutBuffer.String()
+	stderr := stderrBuffer.String()
+
+	if ec != tc.wantExitCode {
+		t.Errorf("cli exited with code %d, not %d", ec, tc.wantExitCode)
+	}
+
+	if !areEqual(t, dedent(t, stdout), dedent(t, tc.wantStdout)) {
+		t.Errorf("stdout\n got:\n%s\n\n want:\n%s", dedent(t, stdout), dedent(t, tc.wantStdout))
+	}
+
+	if !areEqual(t, dedent(t, stderr), dedent(t, tc.wantStderr)) {
+		t.Errorf("stderr\n got:\n%s\n\n want:\n%s", dedent(t, stderr), dedent(t, tc.wantStderr))
+	}
+}
+
+func TestRun(t *testing.T) {
+	t.Parallel()
+
+	tests := []cliTestCase{
+		{
+			name:         "",
+			args:         []string{""},
+			wantExitCode: 1,
+			wantStdout: `
+				NAME:
+					 osv-scanner - scans various mediums for dependencies and matches it against the OSV database
+
+				USAGE:
+					 osv-scanner [global options] command [command options] [directory1 directory2...]
+
+				COMMANDS:
+					 help, h  Shows a list of commands or help for one command
+
+				GLOBAL OPTIONS:
+					 --docker value, -D value [ --docker value, -D value ]      scan docker image with this name
+					 --lockfile value, -L value [ --lockfile value, -L value ]  scan package lockfile on this path
+					 --sbom value, -S value [ --sbom value, -S value ]          scan sbom file on this path
+					 --config value                                             set/override config file
+					 --json                                                     sets output to json (WIP) (default: false)
+					 --skip-git                                                 skip scanning git repositories (default: false)
+					 --recursive, -r                                            check subdirectories (default: false)
+					 --help, -h                                                 show help (default: false)
+			`,
+			wantStderr: "",
+		},
+		// one specific supported lockfile
+		{
+			name:         "",
+			args:         []string{"", "./fixtures/locks-many/composer.lock"},
+			wantExitCode: 0,
+			wantStdout: `
+				Scanning dir ./fixtures/locks-many/composer.lock
+        Scanned %%/fixtures/locks-many/composer.lock file and found 1 packages
+			`,
+			wantStderr: "",
+		},
+		// one specific unsupported lockfile
+		{
+			name:         "",
+			args:         []string{"", "./fixtures/locks-many/not-a-lockfile.toml"},
+			wantExitCode: 1,
+			wantStdout: `
+				Scanning dir ./fixtures/locks-many/not-a-lockfile.toml
+				NAME:
+					 osv-scanner - scans various mediums for dependencies and matches it against the OSV database
+
+				USAGE:
+					 osv-scanner [global options] command [command options] [directory1 directory2...]
+
+				COMMANDS:
+					 help, h  Shows a list of commands or help for one command
+
+				GLOBAL OPTIONS:
+					 --docker value, -D value [ --docker value, -D value ]      scan docker image with this name
+					 --lockfile value, -L value [ --lockfile value, -L value ]  scan package lockfile on this path
+					 --sbom value, -S value [ --sbom value, -S value ]          scan sbom file on this path
+					 --config value                                             set/override config file
+					 --json                                                     sets output to json (WIP) (default: false)
+					 --skip-git                                                 skip scanning git repositories (default: false)
+					 --recursive, -r                                            check subdirectories (default: false)
+					 --help, -h                                                 show help (default: false)
+			`,
+			wantStderr: "",
+		},
+		// all supported lockfiles in the directory should be checked
+		{
+			name:         "",
+			args:         []string{"", "./fixtures/locks-many"},
+			wantExitCode: 0,
+			wantStdout: `
+				Scanning dir ./fixtures/locks-many
+				Scanned %%/fixtures/locks-many/Gemfile.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many/composer.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many/yarn.lock file and found 1 packages
+			`,
+			wantStderr: "",
+		},
+		// all supported lockfiles in the directory should be checked
+		{
+			name:         "",
+			args:         []string{"", "./fixtures/locks-many-with-invalid"},
+			wantExitCode: 0,
+			wantStdout: `
+				Scanning dir ./fixtures/locks-many-with-invalid
+				Scanned %%/fixtures/locks-many-with-invalid/Gemfile.lock file and found 1 packages
+				Scanned %%/fixtures/locks-many-with-invalid/yarn.lock file and found 1 packages
+			`,
+			wantStderr: `
+				Attempted to scan lockfile but failed: %%/fixtures/locks-many-with-invalid/composer.lock
+			`,
+		},
+		// only the files in the given directories are checked by default (no recursion)
+		{
+			name:         "",
+			args:         []string{"", "./fixtures/locks-one-with-nested"},
+			wantExitCode: 0,
+			wantStdout: `
+				Scanning dir ./fixtures/locks-one-with-nested
+				Scanned %%/fixtures/locks-one-with-nested/yarn.lock file and found 1 packages
+			`,
+			wantStderr: "",
+		},
+		// nested directories are checked when `--recursive` is passed
+		{
+			name:         "",
+			args:         []string{"", "--recursive", "./fixtures/locks-one-with-nested"},
+			wantExitCode: 0,
+			wantStdout: `
+				Scanning dir ./fixtures/locks-one-with-nested
+				Scanned %%/fixtures/locks-one-with-nested/nested/composer.lock file and found 1 packages
+				Scanned %%/fixtures/locks-one-with-nested/yarn.lock file and found 1 packages
+			`,
+			wantStderr: "",
+		},
+		// output with json
+		{
+			name:         "",
+			args:         []string{"", "--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
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			testCli(t, tt)
+		})
+	}
+}