Repository: google/osv-scanner
Pull request #8 (2022-11-24 02:48:11)

Make OSV-scanner usable as a library
Refactor osv-scanner to make it easier to be used as a library. 

The changes are:
- Move scanning logic into the `osvscanner` package. The `DoScan` function takes in a struct that contains what used to be parsed directly from CLI. 
- Update output when not finding any packages to be more helpful rather than just printing the help message
- Move the `DoScan` response into the `models` package.
- Refactor scan results so that it's usable rather than just returning raw OSV responses. This essentially moves the logic from output/machinejson.go into `osvscanner`. 
- Add a Flatten() function vulnerability results. 
- Add the ability to manually scan git commits without relying on scanning. 
- Fixes some of the config tests
- Removes (nearly all?) panics and log.Fatal in the code.

another-rex: @G-Rath Just a heads up about this PR, I moved a bunch of logic around to make the code more easily usable as a library. 

This will probably cause some merge conflicts if you are also doing big changes on the main branch. 
G-Rath: @another-rex swee thanks for the heads up - the only thing I've got inflight right now is adding a hidden flag for setting the API url to make it easy to test, which should be easy to rebase onto this restructure

Patch:
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e50ac48
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.history/
+.vscode/
\ No newline at end of file
diff --git a/cmd/osv-scanner/main.go b/cmd/osv-scanner/main.go
index d04f512..5f99652 100644
--- a/cmd/osv-scanner/main.go
+++ b/cmd/osv-scanner/main.go
@@ -1,247 +1,19 @@
 package main
 
 import (
-	"bufio"
-	"bytes"
 	"errors"
 	"fmt"
 	"io"
 	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
 
-	"github.com/google/osv-scanner/internal/osv"
 	"github.com/google/osv-scanner/internal/output"
-	"github.com/google/osv-scanner/internal/sbom"
-	"github.com/google/osv-scanner/pkg/lockfile"
+	"github.com/google/osv-scanner/pkg/osvscanner"
 
 	"github.com/urfave/cli/v2"
 )
 
-const osvScannerConfigName = "osv-scanner.toml"
-
-// scanDir walks through the given directory to try to find any relevant files
-// These include:
-//   - Any lockfiles with scanLockfile
-//   - Any SBOM files with scanSBOMFile
-//   - Any git repositories with scanGit
-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 {
-			r.PrintText(fmt.Sprintf("Failed to walk %s: %v\n", path, err))
-			return err
-		}
-		path, err = filepath.Abs(path)
-		if err != nil {
-			r.PrintError(fmt.Sprintf("Failed to walk path %s\n", err))
-			return err
-		}
-
-		if !skipGit && info.IsDir() && info.Name() == ".git" {
-			err := scanGit(r, query, filepath.Dir(path)+"/")
-			if err != nil {
-				r.PrintText(fmt.Sprintf("scan failed for %s: %v\n", path, err))
-				return err
-			}
-			return filepath.SkipDir
-		}
-
-		if !info.IsDir() {
-			if parser, _ := lockfile.FindParser(path, ""); parser != nil {
-				err := scanLockfile(r, query, path)
-				if err != nil {
-					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(r, query, path)
-		}
-
-		if !root && !recursive && info.IsDir() {
-			return filepath.SkipDir
-		}
-		root = false
-
-		return nil
-	})
-}
-
-// scanLockfile will load, identify, and parse the lockfile path passed in, and add the dependencies specified
-// within to `query`
-func scanLockfile(r *output.Reporter, query *osv.BatchedQuery, path string) error {
-	parsedLockfile, err := lockfile.Parse(path, "")
-	if err != nil {
-		return err
-	}
-	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)
-		pkgDetailQuery.Source = osv.Source{
-			Path: path,
-			Type: "lockfile",
-		}
-		query.Queries = append(query.Queries, pkgDetailQuery)
-	}
-	return nil
-}
-
-// scanSBOMFile will load, identify, and parse the SBOM path passed in, and add the dependencies specified
-// within to `query`
-func scanSBOMFile(r *output.Reporter, query *osv.BatchedQuery, path string) error {
-	file, err := os.Open(path)
-	if err != nil {
-		return err
-	}
-
-	for _, provider := range sbom.Providers {
-		if provider.Name() == "SPDX" &&
-			!strings.Contains(strings.ToLower(filepath.Base(path)), ".spdx") {
-			// All spdx files should have the .spdx in the filename, even if
-			// it's not the extension:  https://spdx.github.io/spdx-spec/v2.3/conformance/
-			// Skip if this isn't the case to avoid panics
-			continue
-		}
-		err := provider.GetPackages(file, func(id sbom.Identifier) error {
-			purlQuery := osv.MakePURLRequest(id.PURL)
-			purlQuery.Source = osv.Source{
-				Path: path,
-				Type: "sbom",
-			}
-			query.Queries = append(query.Queries, purlQuery)
-			return nil
-		})
-		if err == nil {
-			// Found the right format.
-			r.PrintText(fmt.Sprintf("Scanned %s SBOM\n", provider.Name()))
-			return nil
-		}
-
-		if errors.Is(err, sbom.InvalidFormat) {
-			continue
-		}
-
-		return err
-	}
-
-	return nil
-}
-
-func getCommitSHA(repoDir string) (string, error) {
-	cmd := exec.Command("git", "-C", repoDir, "rev-parse", "HEAD")
-	var out bytes.Buffer
-	cmd.Stdout = &out
-
-	err := cmd.Run()
-	if err != nil {
-		return "", err
-	}
-	return strings.TrimSpace(out.String()), nil
-}
-
-// Scan git repository. Expects repoDir to end with /
-func scanGit(r *output.Reporter, query *osv.BatchedQuery, repoDir string) error {
-	commit, err := getCommitSHA(repoDir)
-	if err != nil {
-		return err
-	}
-
-	r.PrintText(fmt.Sprintf("Scanning %s at commit %s\n", repoDir, commit))
-
-	gitQuery := osv.MakeCommitRequest(commit)
-	gitQuery.Source = osv.Source{
-		Path: repoDir,
-		Type: "git",
-	}
-	query.Queries = append(query.Queries, gitQuery)
-	return nil
-}
-
-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 {
-		r.PrintError(fmt.Sprintf("Failed to get stdout: %s\n", err))
-		return err
-	}
-	err = cmd.Start()
-	if err != nil {
-		r.PrintError(fmt.Sprintf("Failed to start docker image: %s\n", err))
-		return err
-	}
-	defer cmd.Wait()
-	if err != nil {
-		r.PrintError(fmt.Sprintf("Failed to run docker: %s\n", err))
-		return err
-	}
-	scanner := bufio.NewScanner(stdout)
-	packages := 0
-	for scanner.Scan() {
-		text := scanner.Text()
-		text = strings.TrimSpace(text)
-		if len(text) == 0 {
-			continue
-		}
-		splitText := strings.Split(text, "###")
-		if len(splitText) != 2 {
-			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],
-			Version: splitText[1],
-			// TODO(rexpan): Get and specify exact debian release version
-			Ecosystem: "Debian",
-		})
-		pkgDetailsQuery.Source = osv.Source{
-			Path: dockerImageName,
-			Type: "docker",
-		}
-		query.Queries = append(query.Queries, pkgDetailsQuery)
-		packages += 1
-	}
-	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(r *output.Reporter, query osv.BatchedQuery, resp *osv.BatchedResponse, configManager *ConfigManager) int {
-	hiddenVulns := map[string]IgnoreEntry{}
-
-	for i, result := range resp.Results {
-		var filteredVulns []osv.MinimalVulnerability
-		configToUse := configManager.Get(query.Queries[i].Source.Path)
-		for _, vuln := range result.Vulns {
-			ignore, ignoreLine := configToUse.ShouldIgnore(vuln.ID)
-			if ignore {
-				hiddenVulns[vuln.ID] = ignoreLine
-			} else {
-				filteredVulns = append(filteredVulns, vuln)
-			}
-		}
-		resp.Results[i].Vulns = filteredVulns
-	}
-
-	for id, ignoreLine := range hiddenVulns {
-		r.PrintText(fmt.Sprintf("%s has been filtered out because: %s\n", id, ignoreLine.Reason))
-	}
-
-	return len(hiddenVulns)
-}
-
 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
 
 	app := &cli.App{
 		Name:      "osv-scanner",
@@ -293,62 +65,22 @@ func run(args []string, stdout, stderr io.Writer) int {
 		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 {
-					r.PrintError(fmt.Sprintf("Failed to read config file: %s\n", err))
-					return err
-				}
-			}
-
-			containers := context.StringSlice("docker")
-			for _, container := range containers {
-				// TODO: Automatically figure out what docker base image
-				// and scan appropriately.
-				scanDebianDocker(r, &query, container)
-			}
-
-			lockfiles := context.StringSlice("lockfile")
-			for _, lockfileElem := range lockfiles {
-				lockfileElem, err := filepath.Abs(lockfileElem)
-				if err != nil {
-					r.PrintError(fmt.Sprintf("Failed to resolved path with error %s\n", err))
-					return err
-				}
-				err = scanLockfile(r, &query, lockfileElem)
-				if err != nil {
-					return err
-				}
-			}
+			vulnResult, err := osvscanner.DoScan(osvscanner.ScannerActions{
+				LockfilePaths:        context.StringSlice("lockfile"),
+				SBOMPaths:            context.StringSlice("sbom"),
+				DockerContainerNames: context.StringSlice("docker"),
+				Recursive:            context.Bool("recursive"),
+				SkipGit:              context.Bool("skip-git"),
+				ConfigOverridePath:   context.String("config"),
+				DirectoryPaths:       context.Args().Slice(),
+			}, r)
 
-			sboms := context.StringSlice("sbom")
-			for _, sbomElem := range sboms {
-				sbomElem, err := filepath.Abs(sbomElem)
-				if err != nil {
-					r.PrintError(fmt.Sprintf("Failed to resolved path with error %s\n", err))
-					return err
-				}
-				err = scanSBOMFile(r, &query, sbomElem)
-				if err != nil {
-					return err
-				}
-			}
-
-			skipGit := context.Bool("skip-git")
-			recursive := context.Bool("recursive")
-			genericDirs := context.Args().Slice()
-			for _, dir := range genericDirs {
-				r.PrintText(fmt.Sprintf("Scanning dir %s\n", dir))
-				err := scanDir(r, &query, dir, skipGit, recursive)
-				if err != nil {
-					return err
-				}
+			if err != nil {
+				return err
 			}
 
-			if len(query.Queries) == 0 {
-				_ = cli.ShowAppHelp(context)
-				return fmt.Errorf("")
+			if err := r.PrintResult(&vulnResult); err != nil {
+				return fmt.Errorf("Failed to write output: %v", err)
 			}
 
 			return nil
@@ -356,52 +88,18 @@ func run(args []string, stdout, stderr io.Writer) int {
 	}
 
 	if err := app.Run(args); err != nil {
-		r.PrintError(fmt.Sprintf("%v", err))
-		return 127
-	}
-
-	resp, err := osv.MakeRequest(query)
-	if err != nil {
-		r.PrintError(fmt.Sprintf("Scan failed: %v", err))
-		return 127
-	}
-
-	filtered := filterResponse(r, query, resp, &configManager)
-	if filtered > 0 {
-		r.PrintText(fmt.Sprintf("Filtered %d vulnerabilities from output\n", filtered))
-	}
-
-	hydratedResp, err := osv.Hydrate(resp)
-	if err != nil {
-		r.PrintError(fmt.Sprintf("Failed to hydrate OSV response: %v", err))
-		return 127
-	}
-
-	err = r.PrintResult(query, hydratedResp)
+		if errors.Is(err, osvscanner.NoPackagesFoundErr) {
+			r.PrintError(fmt.Sprintf("No package sources found, --help for usage information."))
+			return 128
+		}
 
-	if err != nil {
-		r.PrintError(fmt.Sprintf("Failed to write output: %s", err))
+		r.PrintError(fmt.Sprintf("%v\n", err))
 		return 127
 	}
 
-	if hasVulnerabilities(hydratedResp) {
-		return 1
-	}
-
 	return 0
 }
 
-func hasVulnerabilities(resp *osv.HydratedBatchedResponse) bool {
-	for _, result := range resp.Results {
-		if len(result.Vulns) == 1 {
-			return true
-		}
-	}
-
-	return false
-}
-
-// TODO(ochang): Machine readable output format.
 func main() {
 	os.Exit(run(os.Args, os.Stdout, os.Stderr))
 }
diff --git a/testdata/testdatainner/innerFolder/test.yaml b/fixtures/testdatainner/innerFolder/test.yaml
similarity index 100%
rename from testdata/testdatainner/innerFolder/test.yaml
rename to fixtures/testdatainner/innerFolder/test.yaml
diff --git a/testdata/testdatainner/osv-scanner.toml b/fixtures/testdatainner/osv-scanner.toml
similarity index 100%
rename from testdata/testdatainner/osv-scanner.toml
rename to fixtures/testdatainner/osv-scanner.toml
diff --git a/testdata/testdatainner/some-manifest.yaml b/fixtures/testdatainner/some-manifest.yaml
similarity index 100%
rename from testdata/testdatainner/some-manifest.yaml
rename to fixtures/testdatainner/some-manifest.yaml
diff --git a/go.mod b/go.mod
index 647b78b..80ac414 100644
--- a/go.mod
+++ b/go.mod
@@ -18,6 +18,7 @@ require (
 
 require (
 	github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
+	github.com/golang/mock v1.6.0 // indirect
 	github.com/mattn/go-runewidth v0.0.13 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
diff --git a/go.sum b/go.sum
index b7dda10..555c3f6 100644
--- a/go.sum
+++ b/go.sum
@@ -8,6 +8,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
+github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/jedib0t/go-pretty/v6 v6.4.0 h1:YlI/2zYDrweA4MThiYMKtGRfT+2qZOO65ulej8GTcVI=
@@ -36,15 +38,37 @@ github.com/urfave/cli/v2 v2.23.0 h1:pkly7gKIeYv3olPAeNajNpLjeJrmTPYCoZWaV+2VfvE=
 github.com/urfave/cli/v2 v2.23.0/go.mod h1:1CNUng3PtjQMtRzJO4FMXBQvkGtuYRxxiR9xMa7jMwI=
 github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
 github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/exp v0.0.0-20221031165847-c99f073a8326 h1:QfTh0HpN6hlw6D3vu8DAwC8pBIwikq0AI1evdm+FksE=
 golang.org/x/exp v0.0.0-20221031165847-c99f073a8326/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
diff --git a/internal/grouper/grouper.go b/internal/grouper/grouper.go
index ccde9a8..74d5651 100644
--- a/internal/grouper/grouper.go
+++ b/internal/grouper/grouper.go
@@ -6,7 +6,7 @@ import (
 	"golang.org/x/exp/maps"
 	"golang.org/x/exp/slices"
 
-	"github.com/google/osv-scanner/internal/osv"
+	"github.com/google/osv-scanner/pkg/models"
 )
 
 func min(a, b int) int {
@@ -16,9 +16,9 @@ func min(a, b int) int {
 	return b
 }
 
-type GroupedVulnerabilities []osv.Vulnerability
+type GroupedVulnerabilities []models.Vulnerability
 
-func hasAliasIntersection(v1, v2 osv.Vulnerability) bool {
+func hasAliasIntersection(v1, v2 models.Vulnerability) bool {
 	// Check if any aliases intersect.
 	for _, alias := range v1.Aliases {
 		if slices.Contains(v2.Aliases, alias) {
@@ -30,7 +30,7 @@ func hasAliasIntersection(v1, v2 osv.Vulnerability) bool {
 }
 
 // Group groups vulnerabilities by aliases.
-func Group(vulns []osv.Vulnerability) []GroupedVulnerabilities {
+func Group(vulns []models.Vulnerability) []GroupedVulnerabilities {
 	// Mapping of `vulns` index to a group ID. A group ID is just another index in the `vulns` slice.
 	groups := make([]int, len(vulns))
 
diff --git a/internal/osv/osv.go b/internal/osv/osv.go
index cc12e7a..7075f60 100644
--- a/internal/osv/osv.go
+++ b/internal/osv/osv.go
@@ -5,10 +5,10 @@ import (
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
-	"log"
 	"net/http"
 
 	"github.com/google/osv-scanner/pkg/lockfile"
+	"github.com/google/osv-scanner/pkg/models"
 )
 
 const (
@@ -32,19 +32,10 @@ type Package struct {
 
 // Query represents a query to OSV.
 type Query struct {
-	Commit  string  `json:"commit,omitempty"`
-	Package Package `json:"package,omitempty"`
-	Version string  `json:"version,omitempty"`
-	Source  Source  `json:"omit"`
-}
-
-type Source struct {
-	Path string `json:"path"`
-	Type string `json:"type"`
-}
-
-func (s Source) String() string {
-	return s.Type + ":" + s.Path
+	Commit  string            `json:"commit,omitempty"`
+	Package Package           `json:"package,omitempty"`
+	Version string            `json:"version,omitempty"`
+	Source  models.SourceInfo `json:"omit"`
 }
 
 // BatchedQuery represents a batched query to OSV.
@@ -57,16 +48,9 @@ type MinimalVulnerability struct {
 	ID string `json:"id"`
 }
 
-// Vulnerability represents a vulnerability entry from OSV.
-type Vulnerability struct {
-	ID      string   `json:"id"`
-	Aliases []string `json:"aliases"`
-	// TODO(ochang): Add other fields.
-}
-
 // Response represents a full response from OSV.
 type Response struct {
-	Vulns []Vulnerability `json:"vulns"`
+	Vulns []models.Vulnerability `json:"vulns"`
 }
 
 // MinimalResponse represents an unhydrated response from OSV.
@@ -114,7 +98,7 @@ func MakePkgRequest(pkgDetails lockfile.PackageDetails) *Query {
 
 // From: https://stackoverflow.com/a/72408490
 func chunkBy[T any](items []T, chunkSize int) [][]T {
-	var _chunks = make([][]T, 0, (len(items)/chunkSize)+1)
+	_chunks := make([][]T, 0, (len(items)/chunkSize)+1)
 	for chunkSize < len(items) {
 		items, _chunks = items[chunkSize:], append(_chunks, items[0:chunkSize:chunkSize])
 	}
@@ -129,7 +113,7 @@ func checkResponseError(resp *http.Response) error {
 
 	respBuf, err := ioutil.ReadAll(resp.Body)
 	if err != nil {
-		log.Fatalf("Failed to read error response from server.")
+		return fmt.Errorf("Failed to read error response from server: %w", err)
 	}
 
 	return fmt.Errorf("Server response error: %s", string(respBuf))
@@ -171,7 +155,7 @@ func MakeRequest(request BatchedQuery) (*BatchedResponse, error) {
 }
 
 // Get a Vulnerabiltiy for the given ID.
-func Get(id string) (*Vulnerability, error) {
+func Get(id string) (*models.Vulnerability, error) {
 	resp, err := http.Get(GetEndpoint + "/" + id)
 	if err != nil {
 		return nil, err
@@ -182,7 +166,7 @@ func Get(id string) (*Vulnerability, error) {
 		return nil, err
 	}
 
-	var vuln Vulnerability
+	var vuln models.Vulnerability
 	decoder := json.NewDecoder(resp.Body)
 	err = decoder.Decode(&vuln)
 	if err != nil {
diff --git a/internal/output/machinejson.go b/internal/output/machinejson.go
index 7d39b4b..c118fbe 100644
--- a/internal/output/machinejson.go
+++ b/internal/output/machinejson.go
@@ -3,71 +3,13 @@ package output
 import (
 	"encoding/json"
 	"io"
-	"log"
 
-	"github.com/google/osv-scanner/internal/osv"
+	"github.com/google/osv-scanner/pkg/models"
 )
 
-type Output struct {
-	Results []Result `json:"results"`
-}
-
-type Result struct {
-	PackageSource osv.Source `json:"packageSource"`
-	Packages      []Package  `json:"packages"`
-}
-
-type Package struct {
-	Name            string              `json:"name"`
-	Version         string              `json:"version"`
-	Ecosystem       string              `json:"ecosystem"`
-	Vulnerabilities []osv.Vulnerability `json:"vulnerabilities"`
-}
-
 // PrintJSONResults writes results to the provided writer in JSON format
-func PrintJSONResults(query osv.BatchedQuery, resp *osv.HydratedBatchedResponse, outputWriter io.Writer) error {
-	output := Output{
-		Results: []Result{},
-	}
-	groupedBySource := map[osv.Source][]Package{}
-
-	for i, query := range query.Queries {
-		response := resp.Results[i]
-		if len(response.Vulns) == 0 {
-			continue
-		}
-		var pkg Package
-		if query.Commit != "" {
-			pkg.Version = query.Commit
-		} else if query.Package.PURL != "" {
-			var err error
-			pkg, err = PURLToPackage(query.Package.PURL)
-			if err != nil {
-				log.Printf("Failed to parse purl: %s, with error: %s",
-					query.Package.PURL, err)
-				continue
-			}
-			pkg.Vulnerabilities = response.Vulns
-		} else {
-			pkg = Package{
-				Name:            query.Package.Name,
-				Version:         query.Version,
-				Ecosystem:       query.Package.Ecosystem,
-				Vulnerabilities: response.Vulns,
-			}
-		}
-
-		groupedBySource[query.Source] = append(groupedBySource[query.Source], pkg)
-	}
-
-	for source, packages := range groupedBySource {
-		output.Results = append(output.Results, Result{
-			PackageSource: source,
-			Packages:      packages,
-		})
-	}
-
+func PrintJSONResults(vulnResult *models.VulnerabilityResults, outputWriter io.Writer) error {
 	encoder := json.NewEncoder(outputWriter)
 	encoder.SetIndent("", "  ")
-	return encoder.Encode(output)
+	return encoder.Encode(vulnResult)
 }
diff --git a/internal/output/reporter.go b/internal/output/reporter.go
index 9585d5f..b9de4c0 100644
--- a/internal/output/reporter.go
+++ b/internal/output/reporter.go
@@ -2,8 +2,9 @@ package output
 
 import (
 	"fmt"
-	"github.com/google/osv-scanner/internal/osv"
 	"io"
+
+	"github.com/google/osv-scanner/pkg/models"
 )
 
 type Reporter struct {
@@ -41,12 +42,12 @@ func (r *Reporter) PrintText(msg string) {
 	fmt.Fprint(target, msg)
 }
 
-func (r *Reporter) PrintResult(query osv.BatchedQuery, resp *osv.HydratedBatchedResponse) error {
+func (r *Reporter) PrintResult(vulnResult *models.VulnerabilityResults) error {
 	if r.outputAsJSON {
-		return PrintJSONResults(query, resp, r.stdout)
+		return PrintJSONResults(vulnResult, r.stdout)
 	}
 
-	PrintTableResults(query, resp, r.stdout)
+	PrintTableResults(vulnResult, r.stdout)
 
 	return nil
 }
diff --git a/internal/output/table.go b/internal/output/table.go
index b58d4e8..73a729a 100644
--- a/internal/output/table.go
+++ b/internal/output/table.go
@@ -2,13 +2,13 @@ package output
 
 import (
 	"io"
-	"log"
 	"os"
 	"path/filepath"
 	"strings"
 
 	"github.com/google/osv-scanner/internal/grouper"
 	"github.com/google/osv-scanner/internal/osv"
+	"github.com/google/osv-scanner/pkg/models"
 
 	"github.com/jedib0t/go-pretty/v6/table"
 	"github.com/jedib0t/go-pretty/v6/text"
@@ -16,57 +16,47 @@ import (
 )
 
 // PrintTableResults prints the osv scan results into a human friendly table.
-func PrintTableResults(query osv.BatchedQuery, resp *osv.HydratedBatchedResponse, outputWriter io.Writer) {
+func PrintTableResults(vulnResult *models.VulnerabilityResults, outputWriter io.Writer) {
 	outputTable := table.NewWriter()
 	outputTable.SetOutputMirror(outputWriter)
-	outputTable.AppendHeader(table.Row{"Source", "Ecosystem", "Affected Package", "Installed Version", "Vulnerability ID", "OSV URL"})
-
-	for i, query := range query.Queries {
-		if len(resp.Results[i].Vulns) == 0 {
-			continue
-		}
-		workingDir, err := os.Getwd()
-		source := query.Source
-		if err == nil {
-			sourcePath, err := filepath.Rel(workingDir, query.Source.Path)
-			if err == nil { // Simplify the path if possible
-				source.Path = sourcePath
-			}
-		}
-		for _, group := range grouper.Group(resp.Results[i].Vulns) {
-			outputRow := table.Row{source}
-			shouldMerge := false
-			if query.Commit != "" {
-				outputRow = append(outputRow, "GIT", query.Commit, query.Commit)
-				shouldMerge = true
-			} else if query.Package.PURL != "" {
-				pkg, err := PURLToPackage(query.Package.PURL)
-				if err != nil {
-					log.Println("Failed to parse purl")
-					continue
+	outputTable.AppendHeader(table.Row{"Source", "Ecosystem", "Affected Package", "Version", "OSV URL (ID In Bold)"})
+	for _, sourceRes := range vulnResult.Results {
+		for _, pkg := range sourceRes.Packages {
+			workingDir, err := os.Getwd()
+			source := sourceRes.Source
+			if err == nil {
+				sourcePath, err := filepath.Rel(workingDir, source.Path)
+				if err == nil { // Simplify the path if possible
+					source.Path = sourcePath
 				}
-				outputRow = append(outputRow, pkg.Ecosystem, pkg.Name, pkg.Version)
-				shouldMerge = true
-			} else {
-				outputRow = append(outputRow, query.Package.Ecosystem, query.Package.Name, query.Version)
 			}
+			for _, group := range grouper.Group(pkg.Vulnerabilities) {
+				outputRow := table.Row{source.Path}
+				shouldMerge := false
+				if pkg.Package.Ecosystem == "GIT" {
+					outputRow = append(outputRow, "GIT", pkg.Package.Version, pkg.Package.Version)
+					shouldMerge = true
+				} else {
+					outputRow = append(outputRow, pkg.Package.Ecosystem, pkg.Package.Name, pkg.Package.Version)
+				}
 
-			var ids []string
-			var links []string
+				var ids []string
+				var links []string
 
-			for _, vuln := range group {
-				ids = append(ids, vuln.ID)
-				links = append(links, osv.BaseVulnerabilityURL+vuln.ID)
-			}
+				for _, vuln := range group {
+					ids = append(ids, vuln.ID)
+					links = append(links, osv.BaseVulnerabilityURL+text.Bold.EscapeSeq()+vuln.ID+text.Reset.EscapeSeq())
+				}
 
-			outputRow = append(outputRow, strings.Join(ids, "\n"), strings.Join(links, "\n"))
-			outputTable.AppendRow(outputRow, table.RowConfig{AutoMerge: shouldMerge})
+				outputRow = append(outputRow, strings.Join(links, "\n"))
+				outputTable.AppendRow(outputRow, table.RowConfig{AutoMerge: shouldMerge})
+			}
 		}
 	}
 
 	outputTable.SetStyle(table.StyleRounded)
-	outputTable.Style().Color.Row = text.Colors{text.Reset, text.BgBlack}
-	outputTable.Style().Color.RowAlternate = text.Colors{text.Reset, text.Reset}
+	outputTable.Style().Color.Row = text.Colors{text.Reset, text.BgHiBlack}
+	outputTable.Style().Color.RowAlternate = text.Colors{text.Reset, text.BgBlack}
 
 	width, _, err := term.GetSize(int(os.Stdout.Fd()))
 	if err == nil { // If output is a terminal, set max length to width
diff --git a/cmd/osv-scanner/config.go b/pkg/config/config.go
similarity index 70%
rename from cmd/osv-scanner/config.go
rename to pkg/config/config.go
index 4a9afbd..127c933 100644
--- a/cmd/osv-scanner/config.go
+++ b/pkg/config/config.go
@@ -1,23 +1,26 @@
-package main
+package config
 
 import (
 	"fmt"
-	"log"
 	"os"
 	"path/filepath"
 	"time"
 
+	"github.com/google/osv-scanner/internal/output"
+
 	"github.com/BurntSushi/toml"
 	"golang.org/x/exp/slices"
 )
 
+const osvScannerConfigName = "osv-scanner.toml"
+
 type ConfigManager struct {
 	// Override to replace all other configs
-	overrideConfig *Config
+	OverrideConfig *Config
 	// Config to use if no config file is found alongside manifests
-	defaultConfig Config
+	DefaultConfig Config
 	// Cache to store loaded configs
-	configMap map[string]Config
+	ConfigMap map[string]Config
 }
 
 type Config struct {
@@ -56,39 +59,44 @@ func (c *ConfigManager) UseOverride(configPath string) error {
 		return err
 	}
 	config.LoadPath = configPath
-	c.overrideConfig = &config
+	c.OverrideConfig = &config
 	return nil
 }
 
 // Attempts to get the config
-func (c *ConfigManager) Get(targetPath string) Config {
-	if c.overrideConfig != nil {
-		return *c.overrideConfig
+func (c *ConfigManager) Get(r *output.Reporter, targetPath string) Config {
+	if c.OverrideConfig != nil {
+		return *c.OverrideConfig
+	}
+
+	configPath, err := normalizeConfigLoadPath(targetPath)
+	if err != nil {
+		r.PrintError(fmt.Sprintf("Can't find config path: %s", err))
+		return Config{}
 	}
 
-	configPath := normalizeConfigLoadPath(targetPath)
-	config, alreadyExists := c.configMap[configPath]
+	config, alreadyExists := c.ConfigMap[configPath]
 	if alreadyExists {
 		return config
 	}
 
-	config, configErr := tryLoadConfig(configPath)
+	config, configErr := tryLoadConfig(r, configPath)
 	if configErr == nil {
-		log.Printf("Loaded filter from: %s", config.LoadPath)
+		r.PrintText(fmt.Sprintf("Loaded filter from: %s\n", config.LoadPath))
 	} else {
 		// If config doesn't exist, use the default config
-		config = c.defaultConfig
+		config = c.DefaultConfig
 	}
-	c.configMap[configPath] = config
+	c.ConfigMap[configPath] = config
 
 	return config
 }
 
 // Finds the containing folder of `target`, then appends osvScannerConfigName
-func normalizeConfigLoadPath(target string) string {
+func normalizeConfigLoadPath(target string) (string, error) {
 	stat, err := os.Stat(target)
 	if err != nil {
-		log.Fatalf("Failed to stat target: %s", err)
+		return "", fmt.Errorf("Failed to stat target: %w", err)
 	}
 
 	var containingFolder string
@@ -98,18 +106,18 @@ func normalizeConfigLoadPath(target string) string {
 		containingFolder = target
 	}
 	configPath := filepath.Join(containingFolder, osvScannerConfigName)
-	return configPath
+	return configPath, nil
 }
 
 // tryLoadConfig tries to load config in `target` (or it's containing directory)
 // `target` will be the key for the entry in configMap
-func tryLoadConfig(configPath string) (Config, error) {
+func tryLoadConfig(r *output.Reporter, configPath string) (Config, error) {
 	configFile, err := os.Open(configPath)
 	var config Config
 	if err == nil { // File exists, and we have permission to read
 		_, err := toml.NewDecoder(configFile).Decode(&config)
 		if err != nil {
-			log.Fatalf("Failed to read config file: %s\n", err)
+			return Config{}, fmt.Errorf("Failed to parse config file: %w\n", err)
 		}
 		config.LoadPath = configPath
 		return config, nil
diff --git a/pkg/models/results.go b/pkg/models/results.go
new file mode 100644
index 0000000..e42d4fd
--- /dev/null
+++ b/pkg/models/results.go
@@ -0,0 +1,65 @@
+package models
+
+// Combined vulnerabilities found for the scanned packages
+type VulnerabilityResults struct {
+	Results []PackageSource `json:"results"`
+}
+
+// Flatten the grouped/nested vulnerability results into one flat array.
+func (vulns *VulnerabilityResults) Flatten() []VulnerabilityFlattened {
+	results := []VulnerabilityFlattened{}
+	for _, res := range vulns.Results {
+		for _, pkg := range res.Packages {
+			for _, v := range pkg.Vulnerabilities {
+				results = append(results, VulnerabilityFlattened{
+					Source:        res.Source,
+					Package:       pkg.Package,
+					Vulnerability: v,
+				})
+			}
+		}
+	}
+	return results
+}
+
+// Flattened Vulnerability Information.
+type VulnerabilityFlattened struct {
+	Source        SourceInfo
+	Package       PackageInfo
+	Vulnerability Vulnerability
+}
+
+// Vulnerability represents a vulnerability entry from OSV.
+type Vulnerability struct {
+	ID      string   `json:"id"`
+	Aliases []string `json:"aliases"`
+	// TODO(ochang): Add other fields.
+}
+
+type SourceInfo struct {
+	Path string `json:"path"`
+	Type string `json:"type"`
+}
+
+func (s SourceInfo) String() string {
+	return s.Type + ":" + s.Path
+}
+
+// Vulnerabilities grouped by sources
+type PackageSource struct {
+	Source   SourceInfo     `json:"packageSource"`
+	Packages []PackageVulns `json:"packages"`
+}
+
+// Vulnerabilities grouped by package
+type PackageVulns struct {
+	Package         PackageInfo
+	Vulnerabilities []Vulnerability `json:"vulnerabilities"`
+}
+
+// Specific package information
+type PackageInfo struct {
+	Name      string `json:"name"`
+	Version   string `json:"version"`
+	Ecosystem string `json:"ecosystem"`
+}
diff --git a/pkg/osvscanner/osvscanner.go b/pkg/osvscanner/osvscanner.go
new file mode 100644
index 0000000..e8ec124
--- /dev/null
+++ b/pkg/osvscanner/osvscanner.go
@@ -0,0 +1,339 @@
+package osvscanner
+
+import (
+	"bufio"
+	"bytes"
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"github.com/google/osv-scanner/internal/osv"
+	"github.com/google/osv-scanner/internal/output"
+	"github.com/google/osv-scanner/internal/sbom"
+	"github.com/google/osv-scanner/pkg/config"
+	"github.com/google/osv-scanner/pkg/lockfile"
+	"github.com/google/osv-scanner/pkg/models"
+)
+
+type ScannerActions struct {
+	LockfilePaths        []string
+	SBOMPaths            []string
+	DirectoryPaths       []string
+	GitCommits           []string
+	Recursive            bool
+	SkipGit              bool
+	DockerContainerNames []string
+	ConfigOverridePath   string
+}
+
+// Error for when no packages is found during a scan.
+var NoPackagesFoundErr = errors.New("No packages found in scan.")
+
+// scanDir walks through the given directory to try to find any relevant files
+// These include:
+//   - Any lockfiles with scanLockfile
+//   - Any SBOM files with scanSBOMFile
+//   - Any git repositories with scanGit
+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 {
+			r.PrintText(fmt.Sprintf("Failed to walk %s: %v\n", path, err))
+			return err
+		}
+		path, err = filepath.Abs(path)
+		if err != nil {
+			r.PrintError(fmt.Sprintf("Failed to walk path %s\n", err))
+			return err
+		}
+
+		if !skipGit && info.IsDir() && info.Name() == ".git" {
+			err := scanGit(r, query, filepath.Dir(path)+"/")
+			if err != nil {
+				r.PrintText(fmt.Sprintf("scan failed for %s: %v\n", path, err))
+				return err
+			}
+			return filepath.SkipDir
+		}
+
+		if !info.IsDir() {
+			if parser, _ := lockfile.FindParser(path, ""); parser != nil {
+				err := scanLockfile(r, query, path)
+				if err != nil {
+					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(r, query, path)
+		}
+
+		if !root && !recursive && info.IsDir() {
+			return filepath.SkipDir
+		}
+		root = false
+
+		return nil
+	})
+}
+
+// scanLockfile will load, identify, and parse the lockfile path passed in, and add the dependencies specified
+// within to `query`
+func scanLockfile(r *output.Reporter, query *osv.BatchedQuery, path string) error {
+	parsedLockfile, err := lockfile.Parse(path, "")
+	if err != nil {
+		return err
+	}
+	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)
+		pkgDetailQuery.Source = models.SourceInfo{
+			Path: path,
+			Type: "lockfile",
+		}
+		query.Queries = append(query.Queries, pkgDetailQuery)
+	}
+	return nil
+}
+
+// scanSBOMFile will load, identify, and parse the SBOM path passed in, and add the dependencies specified
+// within to `query`
+func scanSBOMFile(r *output.Reporter, query *osv.BatchedQuery, path string) error {
+	file, err := os.Open(path)
+	if err != nil {
+		return err
+	}
+
+	for _, provider := range sbom.Providers {
+		if provider.Name() == "SPDX" &&
+			!strings.Contains(strings.ToLower(filepath.Base(path)), ".spdx") {
+			// All spdx files should have the .spdx in the filename, even if
+			// it's not the extension:  https://spdx.github.io/spdx-spec/v2.3/conformance/
+			// Skip if this isn't the case to avoid panics
+			continue
+		}
+		err := provider.GetPackages(file, func(id sbom.Identifier) error {
+			purlQuery := osv.MakePURLRequest(id.PURL)
+			purlQuery.Source = models.SourceInfo{
+				Path: path,
+				Type: "sbom",
+			}
+			query.Queries = append(query.Queries, purlQuery)
+			return nil
+		})
+		if err == nil {
+			// Found the right format.
+			r.PrintText(fmt.Sprintf("Scanned %s SBOM\n", provider.Name()))
+			return nil
+		}
+
+		if errors.Is(err, sbom.InvalidFormat) {
+			continue
+		}
+
+		return err
+	}
+
+	return nil
+}
+
+func getCommitSHA(repoDir string) (string, error) {
+	cmd := exec.Command("git", "-C", repoDir, "rev-parse", "HEAD")
+	var out bytes.Buffer
+	cmd.Stdout = &out
+
+	err := cmd.Run()
+	if err != nil {
+		return "", err
+	}
+	return strings.TrimSpace(out.String()), nil
+}
+
+// Scan git repository. Expects repoDir to end with /
+func scanGit(r *output.Reporter, query *osv.BatchedQuery, repoDir string) error {
+	commit, err := getCommitSHA(repoDir)
+	if err != nil {
+		return err
+	}
+	r.PrintText(fmt.Sprintf("Scanning %s at commit %s\n", repoDir, commit))
+
+	return scanGitCommit(r, query, commit, repoDir)
+}
+
+func scanGitCommit(r *output.Reporter, query *osv.BatchedQuery, commit string, source string) error {
+	gitQuery := osv.MakeCommitRequest(commit)
+	gitQuery.Source = models.SourceInfo{
+		Path: source,
+		Type: "git",
+	}
+	query.Queries = append(query.Queries, gitQuery)
+	return nil
+}
+
+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 {
+		r.PrintError(fmt.Sprintf("Failed to get stdout: %s\n", err))
+		return err
+	}
+	err = cmd.Start()
+	if err != nil {
+		r.PrintError(fmt.Sprintf("Failed to start docker image: %s\n", err))
+		return err
+	}
+	defer cmd.Wait()
+	if err != nil {
+		r.PrintError(fmt.Sprintf("Failed to run docker: %s\n", err))
+		return err
+	}
+	scanner := bufio.NewScanner(stdout)
+	packages := 0
+	for scanner.Scan() {
+		text := scanner.Text()
+		text = strings.TrimSpace(text)
+		if len(text) == 0 {
+			continue
+		}
+		splitText := strings.Split(text, "###")
+		if len(splitText) != 2 {
+			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],
+			Version: splitText[1],
+			// TODO(rexpan): Get and specify exact debian release version
+			Ecosystem: "Debian",
+		})
+		pkgDetailsQuery.Source = models.SourceInfo{
+			Path: dockerImageName,
+			Type: "docker",
+		}
+		query.Queries = append(query.Queries, pkgDetailsQuery)
+		packages += 1
+	}
+	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(r *output.Reporter, query osv.BatchedQuery, resp *osv.BatchedResponse, configManager *config.ConfigManager) int {
+	hiddenVulns := map[string]config.IgnoreEntry{}
+
+	for i, result := range resp.Results {
+		var filteredVulns []osv.MinimalVulnerability
+		configToUse := configManager.Get(r, query.Queries[i].Source.Path)
+		for _, vuln := range result.Vulns {
+			ignore, ignoreLine := configToUse.ShouldIgnore(vuln.ID)
+			if ignore {
+				hiddenVulns[vuln.ID] = ignoreLine
+			} else {
+				filteredVulns = append(filteredVulns, vuln)
+			}
+		}
+		resp.Results[i].Vulns = filteredVulns
+	}
+
+	for id, ignoreLine := range hiddenVulns {
+		r.PrintText(fmt.Sprintf("%s has been filtered out because: %s\n", id, ignoreLine.Reason))
+	}
+
+	return len(hiddenVulns)
+}
+
+// Perform osv scanner action, with optional reporter to output information
+func DoScan(actions ScannerActions, r *output.Reporter) (models.VulnerabilityResults, error) {
+	if r == nil {
+		stdout := new(strings.Builder)
+		stderr := new(strings.Builder)
+		r = output.NewReporter(stdout, stderr, false)
+	}
+
+	configManager := config.ConfigManager{
+		DefaultConfig: config.Config{},
+		ConfigMap:     make(map[string]config.Config),
+	}
+
+	var query osv.BatchedQuery
+
+	if actions.ConfigOverridePath != "" {
+		err := configManager.UseOverride(actions.ConfigOverridePath)
+		if err != nil {
+			r.PrintError(fmt.Sprintf("Failed to read config file: %s\n", err))
+			return models.VulnerabilityResults{}, err
+		}
+	}
+
+	for _, container := range actions.DockerContainerNames {
+		// TODO: Automatically figure out what docker base image
+		// and scan appropriately.
+		scanDebianDocker(r, &query, container)
+	}
+
+	for _, lockfileElem := range actions.LockfilePaths {
+		lockfileElem, err := filepath.Abs(lockfileElem)
+		if err != nil {
+			r.PrintError(fmt.Sprintf("Failed to resolved path with error %s\n", err))
+			return models.VulnerabilityResults{}, err
+		}
+		err = scanLockfile(r, &query, lockfileElem)
+		if err != nil {
+			return models.VulnerabilityResults{}, err
+		}
+	}
+
+	for _, sbomElem := range actions.SBOMPaths {
+		sbomElem, err := filepath.Abs(sbomElem)
+		if err != nil {
+			return models.VulnerabilityResults{}, fmt.Errorf("Failed to resolved path with error %s\n", err)
+		}
+		err = scanSBOMFile(r, &query, sbomElem)
+		if err != nil {
+			return models.VulnerabilityResults{}, err
+		}
+	}
+
+	for _, commit := range actions.GitCommits {
+		err := scanGitCommit(r, &query, commit, "HASH")
+		if err != nil {
+			return models.VulnerabilityResults{}, err
+		}
+	}
+
+	for _, dir := range actions.DirectoryPaths {
+		r.PrintText(fmt.Sprintf("Scanning dir %s\n", dir))
+		err := scanDir(r, &query, dir, actions.SkipGit, actions.Recursive)
+		if err != nil {
+			return models.VulnerabilityResults{}, err
+		}
+	}
+
+	if len(query.Queries) == 0 {
+		return models.VulnerabilityResults{}, NoPackagesFoundErr
+	}
+
+	resp, err := osv.MakeRequest(query)
+	if err != nil {
+		return models.VulnerabilityResults{}, fmt.Errorf("Scan failed %v", err)
+	}
+
+	filtered := filterResponse(r, query, resp, &configManager)
+	if filtered > 0 {
+		r.PrintText(fmt.Sprintf("Filtered %d vulnerabilities from output\n", filtered))
+	}
+
+	hydratedResp, err := osv.Hydrate(resp)
+	if err != nil {
+		return models.VulnerabilityResults{}, fmt.Errorf("Failed to hydrate OSV response: %v", err)
+	}
+
+	return groupResponse(r, query, hydratedResp, &configManager), nil
+}
diff --git a/internal/output/purl_to_package.go b/pkg/osvscanner/purl_to_package.go
similarity index 76%
rename from internal/output/purl_to_package.go
rename to pkg/osvscanner/purl_to_package.go
index a59dcfc..5ee813c 100644
--- a/internal/output/purl_to_package.go
+++ b/pkg/osvscanner/purl_to_package.go
@@ -1,6 +1,7 @@
-package output
+package osvscanner
 
 import (
+	"github.com/google/osv-scanner/pkg/models"
 	"github.com/package-url/packageurl-go"
 )
 
@@ -18,16 +19,16 @@ var purlEcosystems = map[string]string{
 	"gem":      "RubyGems",
 }
 
-func PURLToPackage(purl string) (Package, error) {
+func PURLToPackage(purl string) (models.PackageInfo, error) {
 	parsedPURL, err := packageurl.FromString(purl)
 	if err != nil {
-		return Package{}, err
+		return models.PackageInfo{}, err
 	}
 	ecosystem := purlEcosystems[parsedPURL.Type]
 	if ecosystem == "" {
 		ecosystem = parsedPURL.Type
 	}
-	return Package{
+	return models.PackageInfo{
 		Name:      parsedPURL.Name,
 		Ecosystem: ecosystem,
 		Version:   parsedPURL.Version,
diff --git a/pkg/osvscanner/vulnerability_result.go b/pkg/osvscanner/vulnerability_result.go
new file mode 100644
index 0000000..0789ca9
--- /dev/null
+++ b/pkg/osvscanner/vulnerability_result.go
@@ -0,0 +1,59 @@
+package osvscanner
+
+import (
+	"fmt"
+
+	"github.com/google/osv-scanner/internal/osv"
+	"github.com/google/osv-scanner/internal/output"
+	"github.com/google/osv-scanner/pkg/config"
+	"github.com/google/osv-scanner/pkg/models"
+)
+
+// Convert raw OSV API response into structured vulnerability information.
+func groupResponse(r *output.Reporter, query osv.BatchedQuery, resp *osv.HydratedBatchedResponse, configManager *config.ConfigManager) models.VulnerabilityResults {
+	output := models.VulnerabilityResults{
+		Results: []models.PackageSource{},
+	}
+	groupedBySource := map[models.SourceInfo][]models.PackageVulns{}
+
+	for i, query := range query.Queries {
+		response := resp.Results[i]
+		if len(response.Vulns) == 0 {
+			continue
+		}
+		var pkg models.PackageVulns
+		if query.Commit != "" {
+			pkg.Package.Version = query.Commit
+			pkg.Package.Ecosystem = "GIT"
+		} else if query.Package.PURL != "" {
+			var err error
+			pkg.Package, err = PURLToPackage(query.Package.PURL)
+			if err != nil {
+				r.PrintError(fmt.Sprintf("Failed to parse purl: %s, with error: %s",
+					query.Package.PURL, err))
+				continue
+			}
+			pkg.Vulnerabilities = response.Vulns
+		} else {
+			pkg = models.PackageVulns{
+				Package: models.PackageInfo{
+					Name:      query.Package.Name,
+					Version:   query.Version,
+					Ecosystem: query.Package.Ecosystem,
+				},
+				Vulnerabilities: response.Vulns,
+			}
+		}
+
+		groupedBySource[query.Source] = append(groupedBySource[query.Source], pkg)
+	}
+
+	for source, packages := range groupedBySource {
+		output.Results = append(output.Results, models.PackageSource{
+			Source:   source,
+			Packages: packages,
+		})
+	}
+
+	return output
+}


Test patch:
diff --git a/cmd/osv-scanner/main_test.go b/cmd/osv-scanner/main_test.go
index 7a888b1..e0ac75c 100644
--- a/cmd/osv-scanner/main_test.go
+++ b/cmd/osv-scanner/main_test.go
@@ -103,28 +103,11 @@ func TestRun(t *testing.T) {
 		{
 			name:         "",
 			args:         []string{""},
-			wantExitCode: 127,
-			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)
+			wantExitCode: 128,
+			wantStdout:   "",
+			wantStderr: `
+        No package sources found, --help for usage information.
 			`,
-			wantStderr: "",
 		},
 		// one specific supported lockfile
 		{
@@ -141,29 +124,13 @@ func TestRun(t *testing.T) {
 		{
 			name:         "",
 			args:         []string{"", "./fixtures/locks-many/not-a-lockfile.toml"},
-			wantExitCode: 127,
+			wantExitCode: 128,
 			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: "",
+			wantStderr: `
+				No package sources found, --help for usage information.
+			`,
 		},
 		// all supported lockfiles in the directory should be checked
 		{
diff --git a/internal/grouper/grouper_test.go b/internal/grouper/grouper_test.go
index 5296524..dded526 100644
--- a/internal/grouper/grouper_test.go
+++ b/internal/grouper/grouper_test.go
@@ -4,23 +4,22 @@ import (
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
-
-	"github.com/google/osv-scanner/internal/osv"
+	"github.com/google/osv-scanner/pkg/models"
 )
 
 func TestGroup(t *testing.T) {
 	// Should be grouped by IDs appearing in alias.
-	v1 := osv.Vulnerability{
+	v1 := models.Vulnerability{
 		ID: "CVE-1",
 		Aliases: []string{
 			"FOO-1",
 		},
 	}
-	v2 := osv.Vulnerability{
+	v2 := models.Vulnerability{
 		ID:      "FOO-1",
 		Aliases: []string{},
 	}
-	v3 := osv.Vulnerability{
+	v3 := models.Vulnerability{
 		ID: "FOO-2",
 		Aliases: []string{
 			"FOO-1",
@@ -28,21 +27,21 @@ func TestGroup(t *testing.T) {
 	}
 
 	// Should be grouped by aliases intersecting.
-	v4 := osv.Vulnerability{
+	v4 := models.Vulnerability{
 		ID: "BAR-1",
 		Aliases: []string{
 			"CVE-2",
 			"CVE-3",
 		},
 	}
-	v5 := osv.Vulnerability{
+	v5 := models.Vulnerability{
 		ID: "BAR-2",
 		Aliases: []string{
 			"CVE-3",
 			"CVE-4",
 		},
 	}
-	v6 := osv.Vulnerability{
+	v6 := models.Vulnerability{
 		ID: "BAR-3",
 		Aliases: []string{
 			"CVE-4",
@@ -50,13 +49,13 @@ func TestGroup(t *testing.T) {
 	}
 
 	// Unrelated.
-	v7 := osv.Vulnerability{
+	v7 := models.Vulnerability{
 		ID: "UNRELATED-1",
 		Aliases: []string{
 			"BAR-1337",
 		},
 	}
-	v8 := osv.Vulnerability{
+	v8 := models.Vulnerability{
 		ID: "UNRELATED-2",
 		Aliases: []string{
 			"BAR-1338",
@@ -64,43 +63,43 @@ func TestGroup(t *testing.T) {
 	}
 
 	for _, tc := range []struct {
-		vulns []osv.Vulnerability
+		vulns []models.Vulnerability
 		want  []GroupedVulnerabilities
 	}{
 		{
-			vulns: []osv.Vulnerability{
+			vulns: []models.Vulnerability{
 				v1, v2, v3, v4, v5, v6, v7, v8,
 			},
 			want: []GroupedVulnerabilities{
-				GroupedVulnerabilities{
+				{
 					v1, v2, v3,
 				},
-				GroupedVulnerabilities{
+				{
 					v4, v5, v6,
 				},
-				GroupedVulnerabilities{
+				{
 					v7,
 				},
-				GroupedVulnerabilities{
+				{
 					v8,
 				},
 			},
 		},
 		{
-			vulns: []osv.Vulnerability{
+			vulns: []models.Vulnerability{
 				v8, v2, v1, v5, v7, v4, v6, v3,
 			},
 			want: []GroupedVulnerabilities{
-				GroupedVulnerabilities{
+				{
 					v8,
 				},
-				GroupedVulnerabilities{
+				{
 					v2, v1, v3,
 				},
-				GroupedVulnerabilities{
+				{
 					v5, v4, v6,
 				},
-				GroupedVulnerabilities{
+				{
 					v7,
 				},
 			},
diff --git a/cmd/osv-scanner/config_test.go b/pkg/config/config_test.go
similarity index 50%
rename from cmd/osv-scanner/config_test.go
rename to pkg/config/config_test.go
index 622153e..3b43997 100644
--- a/cmd/osv-scanner/config_test.go
+++ b/pkg/config/config_test.go
@@ -1,10 +1,12 @@
-package main
+package config
 
 import (
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
+	"github.com/google/osv-scanner/internal/output"
 )
 
 type testStruct struct {
@@ -27,27 +29,27 @@ func TestTryLoadConfig(t *testing.T) {
 	}
 	testPaths := []testStruct{
 		{
-			targetPath:   "../../testdata/testdatainner/innerFolder/test.yaml",
-			config:       expectedConfig,
+			targetPath:   "../../fixtures/testdatainner/innerFolder/test.yaml",
+			config:       Config{},
 			configHasErr: true,
 		},
 		{
-			targetPath:   "../../testdata/testdatainner/innerFolder/",
+			targetPath:   "../../fixtures/testdatainner/innerFolder/",
 			config:       Config{},
 			configHasErr: true,
 		},
 		{ // Test no slash at the end
-			targetPath:   "../../testdata/testdatainner/innerFolder",
+			targetPath:   "../../fixtures/testdatainner/innerFolder",
 			config:       Config{},
 			configHasErr: true,
 		},
 		{
-			targetPath:   "../../testdata/testdatainner/",
+			targetPath:   "../../fixtures/testdatainner/",
 			config:       expectedConfig,
 			configHasErr: false,
 		},
 		{
-			targetPath:   "../../testdata/testdatainner/some-manifest.yaml",
+			targetPath:   "../../fixtures/testdatainner/some-manifest.yaml",
 			config:       expectedConfig,
 			configHasErr: false,
 		},
@@ -58,11 +60,18 @@ func TestTryLoadConfig(t *testing.T) {
 		if err != nil {
 			t.Errorf("%s", err)
 		}
-		configPath := normalizeConfigLoadPath(absPath)
-		config, configErr := tryLoadConfig(configPath)
-		cmp.Equal(config, testData.config)
+		configPath, err := normalizeConfigLoadPath(absPath)
+		if err != nil {
+			t.Errorf("%s", err)
+		}
+		config, configErr := tryLoadConfig(output.NewReporter(new(strings.Builder), new(strings.Builder), false), configPath)
+		if !cmp.Equal(config.IgnoredVulns, testData.config.IgnoredVulns) {
+			t.Errorf("Configs not equal: %+v != %+v", config, testData.config)
+		}
 		if testData.configHasErr {
-			cmp.Equal(configErr, nil)
+			if configErr == nil {
+				t.Error("Config error not returned")
+			}
 		}
 	}
 }