Repository: google/osv-scanner

Issue:
Improve error message when SBOM name is not in the correct format
Related #1745 

Discussion:

Solution patch:
diff --git a/.github/workflows/renovate-validator.yml b/.github/workflows/renovate-validator.yml
index 092bb568..ac76d2d4 100644
--- a/.github/workflows/renovate-validator.yml
+++ b/.github/workflows/renovate-validator.yml
@@ -10,9 +10,15 @@ on:
     paths:
       - "renovate.json"
 
+# Restrict jobs in this workflow to have no permissions by default; permissions
+# should be granted per job as needed using a dedicated `permissions` block
+permissions: {}
+
 jobs:
   validate:
     runs-on: ubuntu-latest
+    permissions:
+      contents: read # to fetch code (actions/checkout)
     steps:
       - name: Checkout code
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
diff --git a/.golangci.yaml b/.golangci.yaml
index 87437c33..a0b6705e 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -96,6 +96,10 @@ linters-settings:
         disabled: false
       - name: redefines-builtin-id
         disabled: false
+      # todo: enable once we've upgraded to v2, as the validation schema
+      #    used by the github v1 action does not have this rule yet
+      # - name: redundant-test-main-exit
+      #   disabled: false
       - name: superfluous-else
         disabled: false
       - name: time-naming
diff --git a/cmd/osv-scanner/scan/__snapshots__/command_test.snap b/cmd/osv-scanner/scan/__snapshots__/command_test.snap
index f80bb813..8e0d13ad 100755
--- a/cmd/osv-scanner/scan/__snapshots__/command_test.snap
+++ b/cmd/osv-scanner/scan/__snapshots__/command_test.snap
@@ -910,6 +910,17 @@ No issues found
 
 ---
 
+[TestCommand/one_file_that_does_not_match_the_supported_sbom_file_names - 1]
+Failed to parse SBOM "<rootdir>/osv-scanner/fixtures/locks-many/composer.lock" with error: could not determine extractor suitable to this file
+If you believe this is a valid SBOM, make sure the filename follows format per your SBOMs specification.
+
+---
+
+[TestCommand/one_file_that_does_not_match_the_supported_sbom_file_names - 2]
+could not determine extractor suitable to this file
+
+---
+
 [TestCommand/one_specific_supported_lockfile - 1]
 Scanning dir ../fixtures/locks-many/composer.lock
 Scanned <rootdir>/osv-scanner/fixtures/locks-many/composer.lock file and found 1 package
diff --git a/docs/configuration.md b/docs/configuration.md
index 295edc36..bc1be103 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -7,7 +7,21 @@ nav_order: 5
 
 # Configure OSV-Scanner
 
-To configure scanning, place an osv-scanner.toml file in the scanned file's directory. To override this osv-scanner.toml file, pass the `--config=/path/to/config.toml` flag with the path to the configuration you want to apply instead.
+To configure scanning, place an osv-scanner.toml file in the scanned file's directory. This does not propagate to child directories.
+
+**Example:**
+
+```
+/Cargo.lock
+/osv-scanner.toml (1)
+/child-dir/go.mod
+/child-dir/osv-scanner.toml (2)
+/child-dir/nested-dir/package-lock.json
+```
+
+`osv-scanner.toml (1)` will only apply to `Cargo.lock`, `osv-scanner.toml (2)` will only apply to `go.mod`, and no config will apply to `package-lock.json`.
+
+To override `osv-scanner.toml` files, pass the `--config=/path/to/config.toml` flag with the path to the configuration you want to apply instead, this will apply `config.toml` to all files parsed, and ignore `osv-scanner.toml` in all directories.
 
 ## Ignore vulnerabilities by ID
 
diff --git a/docs/supported_languages_and_lockfiles.md b/docs/supported_languages_and_lockfiles.md
index 072860af..ca934a97 100644
--- a/docs/supported_languages_and_lockfiles.md
+++ b/docs/supported_languages_and_lockfiles.md
@@ -30,15 +30,16 @@ We found that when performing different forms of scanning, you are generally int
 
 When scanning container images (`osv-scanner scan image ...`), OSV-Scanner automatically extracts and analyzes the following artifacts:
 
-| Source                          | Example files                      |
-| ------------------------------- | ---------------------------------- |
-| Alpine APK packages             | `/lib/apk/db/installed`            |
-| Debian/Ubuntu dpkg/apt packages | `/var/lib/dpkg/status`             |
-|                                 |                                    |
-| Go Binaries                     | `main-go`                          |
-| Java Uber `jars`                | `my-java-app.jar`                  |
-| Node Modules                    | `node-app/node_modules/...`        |
-| Python wheels                   | `lib/python3.11/site-packages/...` |
+| Source                               | Example files                      |
+| ------------------------------------ | ---------------------------------- |
+| Alpine APK packages                  | `/lib/apk/db/installed`            |
+| Debian/Ubuntu dpkg/apt packages      | `/var/lib/dpkg/status`             |
+|                                      |                                    |
+| Go Binaries                          | `main-go`                          |
+| Rust Binaries (with cargo-auditable) | `main-rust-built-with-auditable`   |
+| Java Uber `jars`                     | `my-java-app.jar`                  |
+| Node Modules                         | `node-app/node_modules/...`        |
+| Python wheels                        | `lib/python3.11/site-packages/...` |
 
 ## Supported lockfiles/manifests
 
diff --git a/pkg/osvscanner/internal/scanners/extractorbuilder.go b/pkg/osvscanner/internal/scanners/extractorbuilder.go
index b161e118..d57777e3 100644
--- a/pkg/osvscanner/internal/scanners/extractorbuilder.go
+++ b/pkg/osvscanner/internal/scanners/extractorbuilder.go
@@ -32,6 +32,7 @@ import (
 	"github.com/google/osv-scalibr/extractor/filesystem/language/python/wheelegg"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/r/renvlock"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/ruby/gemfilelock"
+	"github.com/google/osv-scalibr/extractor/filesystem/language/rust/cargoauditable"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/rust/cargolock"
 	"github.com/google/osv-scalibr/extractor/filesystem/os/apk"
 	"github.com/google/osv-scalibr/extractor/filesystem/os/dpkg"
@@ -177,6 +178,8 @@ func BuildArtifactExtractors() []filesystem.Extractor {
 		gobinary.New(gobinary.DefaultConfig()),
 		// Javascript
 		nodemodules.Extractor{},
+		// Rust
+		cargoauditable.NewDefault(),
 
 		// --- OS packages ---
 		// Alpine
diff --git a/pkg/osvscanner/internal/scanners/lockfile.go b/pkg/osvscanner/internal/scanners/lockfile.go
index f7a606f1..04aec9ac 100644
--- a/pkg/osvscanner/internal/scanners/lockfile.go
+++ b/pkg/osvscanner/internal/scanners/lockfile.go
@@ -75,16 +75,8 @@ var lockfileExtractorMapping = map[string][]string{
 
 // ScanSingleFile is similar to ScanSingleFileWithMapping, just without supporting the <lockfileformat>:/path/to/lockfile prefix identifier
 func ScanSingleFile(path string, extractorsToUse []filesystem.Extractor) ([]*extractor.Inventory, error) {
-	// TODO: Update the logging output to stop referring to SBOMs
-	path, err := filepath.Abs(path)
-	if err != nil {
-		slog.Error(fmt.Sprintf("Failed to resolved path %q with error: %s", path, err))
-		return nil, err
-	}
-
 	invs, err := scalibrextract.ExtractWithExtractors(context.Background(), path, extractorsToUse)
 	if err != nil {
-		slog.Info(fmt.Sprintf("Failed to parse SBOM %q with error: %s", path, err))
 		return nil, err
 	}
 
diff --git a/pkg/osvscanner/scan.go b/pkg/osvscanner/scan.go
index 5a807f5f..414490d1 100644
--- a/pkg/osvscanner/scan.go
+++ b/pkg/osvscanner/scan.go
@@ -1,10 +1,14 @@
 package osvscanner
 
 import (
+	"errors"
+	"fmt"
 	"log/slog"
+	"path/filepath"
 
 	"github.com/google/osv-scalibr/extractor"
 	"github.com/google/osv-scanner/v2/internal/imodels"
+	"github.com/google/osv-scanner/v2/internal/scalibrextract"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/ecosystemmock"
 	"github.com/google/osv-scanner/v2/pkg/osvscanner/internal/scanners"
 )
@@ -28,8 +32,20 @@ func scan(accessors ExternalAccessors, actions ScannerActions) ([]imodels.Packag
 	// --- SBOMs ---
 	sbomExtractors := scanners.BuildSBOMExtractors()
 	for _, sbomPath := range actions.SBOMPaths {
-		invs, err := scanners.ScanSingleFile(sbomPath, sbomExtractors)
+		path, err := filepath.Abs(sbomPath)
 		if err != nil {
+			slog.Error(fmt.Sprintf("Failed to resolved path %q with error: %s", path, err))
+			return nil, err
+		}
+
+		invs, err := scanners.ScanSingleFile(path, sbomExtractors)
+		if err != nil {
+			slog.Info(fmt.Sprintf("Failed to parse SBOM %q with error: %s", path, err))
+
+			if errors.Is(err, scalibrextract.ErrExtractorNotFound) {
+				slog.Info("If you believe this is a valid SBOM, make sure the filename follows format per your SBOMs specification.")
+			}
+
 			return nil, err
 		}
 
diff --git a/scripts/build_test_images.sh b/scripts/build_test_images.sh
index dcbadb5c..6484b9c8 100755
--- a/scripts/build_test_images.sh
+++ b/scripts/build_test_images.sh
@@ -2,12 +2,16 @@
 
 set -e
 
+# this is inverted because docker build takes "--no-cache",
+# so "false" here means that the layer cache will be used
+no_layer_cache=false
+
 function build_docker_image_fixture {
   image_name="$1"
   output_tar="internal/image/fixtures/$image_name.tar"
 
   if [ ! -f "$output_tar" ]; then
-    docker build internal/image/fixtures/ -f "internal/image/fixtures/$image_name.Dockerfile" -t "osv-scanner/$image_name:latest"
+    docker build internal/image/fixtures/ -f "internal/image/fixtures/$image_name.Dockerfile" -t "osv-scanner/$image_name:latest" --no-cache="$no_layer_cache"
     docker image save "osv-scanner/$image_name:latest" -o "$output_tar"
 
     echo "finished building $output_tar (did not exist)"
@@ -16,8 +20,31 @@ function build_docker_image_fixture {
   fi
 }
 
+force=false
+while [[ $# -gt 0 ]]; do
+  case $1 in
+    --force)
+      force=true
+      shift
+      ;;
+    --no-cache)
+      no_layer_cache=true
+      shift
+      ;;
+    *)
+      echo "Unknown argument: $1"
+      exit 1
+      ;;
+  esac
+done
+
 for dockerfile in internal/image/fixtures/*.Dockerfile; do
   image_name=$(basename "$dockerfile" .Dockerfile)
 
+  if [ "$force" = true ]; then
+    echo "Removing existing tar file for $image_name..."
+    rm "internal/image/fixtures/$image_name.tar"
+  fi
+
   build_docker_image_fixture "$image_name"
 done



Test patch:
diff --git a/cmd/osv-scanner/fix/command_test.go b/cmd/osv-scanner/fix/command_test.go
index 47753efa..725cf702 100644
--- a/cmd/osv-scanner/fix/command_test.go
+++ b/cmd/osv-scanner/fix/command_test.go
@@ -87,10 +87,7 @@ func TestCommand(t *testing.T) {
 				tc.Args = append(tc.Args, "-M", manifest)
 			}
 
-			stdout, stderr := testcmd.Run(t, tc)
-
-			testutility.NewSnapshot().MatchText(t, stdout)
-			testutility.NewSnapshot().MatchText(t, stderr)
+			testcmd.RunAndMatchSnapshots(t, tc)
 
 			if lockfile != "" {
 				matchFile(t, lockfile)
@@ -104,7 +101,7 @@ func TestCommand(t *testing.T) {
 
 func parseFlags(t *testing.T, flags []string, arguments []string) (*cli.Context, error) {
 	// This is a bit hacky: make a mock App with only the flags we care about.
-	// Then use app.Run() to parse the flags into the cli.Context, which is returned.
+	// Then use app.RunAndMatchSnapshots() to parse the flags into the cli.Context, which is returned.
 	t.Helper()
 	appFlags := make([]cli.Flag, 0, len(flags))
 	for _, f := range fix.Command(nil, nil).Flags {
diff --git a/cmd/osv-scanner/fix/testmain_test.go b/cmd/osv-scanner/fix/testmain_test.go
index 85018760..6e362329 100644
--- a/cmd/osv-scanner/fix/testmain_test.go
+++ b/cmd/osv-scanner/fix/testmain_test.go
@@ -1,16 +1,13 @@
 package fix
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/cmd/osv-scanner/internal/cmd/testmain_test.go b/cmd/osv-scanner/internal/cmd/testmain_test.go
index d57be277..5a5c66ad 100644
--- a/cmd/osv-scanner/internal/cmd/testmain_test.go
+++ b/cmd/osv-scanner/internal/cmd/testmain_test.go
@@ -1,16 +1,13 @@
 package cmd
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/cmd/osv-scanner/internal/testcmd/case.go b/cmd/osv-scanner/internal/testcmd/case.go
index e6d508f4..1f88f309 100644
--- a/cmd/osv-scanner/internal/testcmd/case.go
+++ b/cmd/osv-scanner/internal/testcmd/case.go
@@ -1,12 +1,24 @@
 package testcmd
 
-import "github.com/google/osv-scanner/v2/internal/testutility"
-
 type Case struct {
 	Name string
 	Args []string
 	Exit int
 
 	// ReplaceRules are only used for JSON output
-	ReplaceRules []testutility.JSONReplaceRule
+	ReplaceRules []JSONReplaceRule
+}
+
+func (c Case) isOutputtingJSON() bool {
+	for i, arg := range c.Args {
+		if arg == "--format=json" {
+			return true
+		}
+
+		if arg == "--format" && i < len(c.Args) && c.Args[i+1] == "json" {
+			return true
+		}
+	}
+
+	return false
 }
diff --git a/internal/testutility/jsonreplace.go b/cmd/osv-scanner/internal/testcmd/jsonreplace.go
similarity index 88%
rename from internal/testutility/jsonreplace.go
rename to cmd/osv-scanner/internal/testcmd/jsonreplace.go
index 9be0a31d..9788f0ae 100644
--- a/internal/testutility/jsonreplace.go
+++ b/cmd/osv-scanner/internal/testcmd/jsonreplace.go
@@ -1,4 +1,4 @@
-package testutility
+package testcmd
 
 import (
 	"strconv"
@@ -16,12 +16,14 @@ type JSONReplaceRule struct {
 }
 
 var (
+	// OnlyIDVulnsRule simplifies vulnerabilities to only their ID
 	OnlyIDVulnsRule = JSONReplaceRule{
 		Path: "results.#.packages.#.vulnerabilities",
 		ReplaceFunc: func(toReplace gjson.Result) any {
 			return toReplace.Get("#.id").Value()
 		},
 	}
+	// GroupsAsArrayLen replaces the groups array with its length
 	GroupsAsArrayLen = JSONReplaceRule{
 		Path: "results.#.packages.#.groups",
 		ReplaceFunc: func(toReplace gjson.Result) any {
@@ -32,6 +34,7 @@ var (
 			return 0
 		},
 	}
+	// OnlyFirstBaseImage simplifies the array of base images to only the first one
 	OnlyFirstBaseImage = JSONReplaceRule{
 		Path: "image_metadata.base_images.#",
 		ReplaceFunc: func(toReplace gjson.Result) any {
@@ -42,6 +45,7 @@ var (
 			return struct{}{}
 		},
 	}
+	// AnyDiffID truncates diff ids in image layer metadata to just `sha256:...`
 	AnyDiffID = JSONReplaceRule{
 		Path: "image_metadata.layer_metadata.#.diff_id",
 		ReplaceFunc: func(toReplace gjson.Result) any {
@@ -52,6 +56,7 @@ var (
 			return ""
 		},
 	}
+	// ShortenHistoryCommandLength truncates COMMAND data to 28 characters
 	ShortenHistoryCommandLength = JSONReplaceRule{
 		Path: "image_metadata.layer_metadata.#.command",
 		ReplaceFunc: func(toReplace gjson.Result) any {
@@ -62,7 +67,8 @@ var (
 			return toReplace.String()
 		},
 	}
-	// Older and newer versions of docker has different COMMAND histories
+	// NormalizeHistoryCommand replaces COMMAND data to be consistent
+	// across different versions of docker
 	NormalizeHistoryCommand = JSONReplaceRule{
 		Path: "image_metadata.layer_metadata.#.command",
 		ReplaceFunc: func(toReplace gjson.Result) any {
diff --git a/internal/testutility/jsonreplace_test.go b/cmd/osv-scanner/internal/testcmd/jsonreplace_test.go
similarity index 99%
rename from internal/testutility/jsonreplace_test.go
rename to cmd/osv-scanner/internal/testcmd/jsonreplace_test.go
index dbfb0e5e..ac583fc4 100644
--- a/internal/testutility/jsonreplace_test.go
+++ b/cmd/osv-scanner/internal/testcmd/jsonreplace_test.go
@@ -1,4 +1,4 @@
-package testutility
+package testcmd
 
 import (
 	"bytes"
diff --git a/cmd/osv-scanner/internal/testcmd/run.go b/cmd/osv-scanner/internal/testcmd/run.go
index 48721dc7..4c48db68 100644
--- a/cmd/osv-scanner/internal/testcmd/run.go
+++ b/cmd/osv-scanner/internal/testcmd/run.go
@@ -1,12 +1,15 @@
 package testcmd
 
 import (
+	"bytes"
+	"encoding/json"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/cmd/osv-scanner/internal/cmd"
+	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
-func Run(t *testing.T, tc Case) (string, string) {
+func run(t *testing.T, tc Case) (string, string) {
 	t.Helper()
 
 	stdout := newMuffledWriter()
@@ -20,3 +23,36 @@ func Run(t *testing.T, tc Case) (string, string) {
 
 	return stdout.String(), stderr.String()
 }
+
+func RunAndMatchSnapshots(t *testing.T, tc Case) {
+	t.Helper()
+
+	stdout, stderr := run(t, tc)
+
+	if tc.isOutputtingJSON() {
+		stdout = normalizeJSON(t, stdout, tc.ReplaceRules...)
+	}
+
+	testutility.NewSnapshot().MatchText(t, stdout)
+	testutility.NewSnapshot().WithWindowsReplacements(map[string]string{
+		"CreateFile": "stat",
+	}).MatchText(t, stderr)
+}
+
+// normalizeJSON runs the given JSONReplaceRules on the given JSON input and returns the normalized JSON string
+func normalizeJSON(t *testing.T, jsonInput string, jsonReplaceRules ...JSONReplaceRule) string {
+	t.Helper()
+
+	for _, rule := range jsonReplaceRules {
+		jsonInput = replaceJSONInput(t, jsonInput, rule.Path, rule.ReplaceFunc)
+	}
+
+	jsonFormatted := bytes.Buffer{}
+	err := json.Indent(&jsonFormatted, []byte(jsonInput), "", "  ")
+
+	if err != nil {
+		t.Fatalf("Failed to marshal JSON: %s", err)
+	}
+
+	return jsonFormatted.String()
+}
diff --git a/cmd/osv-scanner/internal/testcmd/test.go b/cmd/osv-scanner/internal/testcmd/test.go
deleted file mode 100644
index 8db27ad7..00000000
--- a/cmd/osv-scanner/internal/testcmd/test.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package testcmd
-
-import (
-	"testing"
-
-	"github.com/google/osv-scanner/v2/internal/testutility"
-)
-
-func Test(t *testing.T, tc Case) {
-	t.Helper()
-
-	stdout, stderr := Run(t, tc)
-
-	testutility.NewSnapshot().MatchText(t, stdout)
-	testutility.NewSnapshot().MatchText(t, stderr)
-}
-
-func TestJSONWithCustomRules(t *testing.T, tc Case) {
-	t.Helper()
-
-	stdout, stderr := Run(t, tc)
-
-	testutility.NewSnapshot().MatchOSVScannerJSONOutput(t, stdout, tc.ReplaceRules...)
-	testutility.NewSnapshot().MatchText(t, stderr)
-}
diff --git a/cmd/osv-scanner/main_test.go b/cmd/osv-scanner/main_test.go
index f038019f..45ea5314 100644
--- a/cmd/osv-scanner/main_test.go
+++ b/cmd/osv-scanner/main_test.go
@@ -22,7 +22,7 @@ func Test_run(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.Name, func(t *testing.T) {
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -51,7 +51,7 @@ func Test_run_SubCommands(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.Name, func(t *testing.T) {
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
diff --git a/cmd/osv-scanner/scan/command_test.go b/cmd/osv-scanner/scan/command_test.go
index 4b0c52da..ebd14725 100644
--- a/cmd/osv-scanner/scan/command_test.go
+++ b/cmd/osv-scanner/scan/command_test.go
@@ -50,6 +50,12 @@ func TestCommand(t *testing.T) {
 			Args: []string{"", "--config=../fixtures/osv-scanner-empty-config.toml", "--sbom", "../fixtures/sbom-insecure/with-duplicates.cdx.xml"},
 			Exit: 1,
 		},
+		// one file that does not match the supported sbom file names
+		{
+			Name: "one file that does not match the supported sbom file names",
+			Args: []string{"", "--config=../fixtures/osv-scanner-empty-config.toml", "--sbom", "../fixtures/locks-many/composer.lock"},
+			Exit: 127,
+		},
 		// one specific unsupported lockfile
 		{
 			Name: "one specific unsupported lockfile",
@@ -235,7 +241,7 @@ func TestCommand(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.Name, func(t *testing.T) {
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -256,7 +262,7 @@ func TestCommand_CallAnalysis(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.Name, func(t *testing.T) {
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -374,12 +380,7 @@ func TestCommand_LockfileWithExplicitParseAs(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.Name, func(t *testing.T) {
-			stdout, stderr := testcmd.Run(t, tt)
-
-			testutility.NewSnapshot().MatchText(t, stdout)
-			testutility.NewSnapshot().WithWindowsReplacements(map[string]string{
-				"CreateFile": "stat",
-			}).MatchText(t, stderr)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -400,7 +401,7 @@ func TestCommand_GithubActions(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.Name, func(t *testing.T) {
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -480,8 +481,8 @@ func TestCommand_LocalDatabases(t *testing.T) {
 
 			// run each test twice since they should provide the same output,
 			// and the second run should be fast as the db is already available
-			testcmd.Test(t, tt)
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -504,8 +505,8 @@ func TestCommand_LocalDatabases_AlwaysOffline(t *testing.T) {
 
 			// run each test twice since they should provide the same output,
 			// and the second run should be fast as the db is already available
-			testcmd.Test(t, tt)
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -580,7 +581,7 @@ func TestCommand_Licenses(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.Name, func(t *testing.T) {
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -610,7 +611,7 @@ func TestCommand_SubCommands(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.Name, func(t *testing.T) {
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -658,7 +659,7 @@ func TestCommand_MavenTransitive(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.Name, func(t *testing.T) {
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -705,7 +706,7 @@ func TestCommand_MoreLockfiles(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.Name, func(t *testing.T) {
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
diff --git a/cmd/osv-scanner/scan/image/command_test.go b/cmd/osv-scanner/scan/image/command_test.go
index be85caf4..95e5052d 100644
--- a/cmd/osv-scanner/scan/image/command_test.go
+++ b/cmd/osv-scanner/scan/image/command_test.go
@@ -48,7 +48,7 @@ func TestCommand_Docker(t *testing.T) {
 				testutility.Skip(t, "Skipping Docker-based test as only Linux has Docker installed in CI")
 			}
 
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -134,7 +134,7 @@ func TestCommand_OCIImage(t *testing.T) {
 				}
 			}
 
-			testcmd.Test(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
@@ -152,49 +152,49 @@ func TestCommand_OCIImageAllPackagesJSON(t *testing.T) {
 			Name: "Scanning python image with some packages",
 			Args: []string{"", "scan", "image", "--archive", "--format=json", "../../../../internal/image/fixtures/test-python-full.tar"},
 			Exit: 1,
-			ReplaceRules: []testutility.JSONReplaceRule{
-				testutility.GroupsAsArrayLen,
-				testutility.OnlyIDVulnsRule,
-				testutility.OnlyFirstBaseImage,
-				testutility.AnyDiffID,
-				testutility.NormalizeHistoryCommand,
-				testutility.ShortenHistoryCommandLength,
+			ReplaceRules: []testcmd.JSONReplaceRule{
+				testcmd.GroupsAsArrayLen,
+				testcmd.OnlyIDVulnsRule,
+				testcmd.OnlyFirstBaseImage,
+				testcmd.AnyDiffID,
+				testcmd.NormalizeHistoryCommand,
+				testcmd.ShortenHistoryCommandLength,
 			},
 		},
 		{
 			Name: "scanning node_modules using npm with some packages",
 			Args: []string{"", "scan", "image", "--archive", "--format=json", "../../../../internal/image/fixtures/test-node_modules-npm-full.tar"},
 			Exit: 1,
-			ReplaceRules: []testutility.JSONReplaceRule{
-				testutility.GroupsAsArrayLen,
-				testutility.OnlyIDVulnsRule,
-				testutility.OnlyFirstBaseImage,
-				testutility.AnyDiffID,
-				testutility.NormalizeHistoryCommand,
-				testutility.ShortenHistoryCommandLength,
+			ReplaceRules: []testcmd.JSONReplaceRule{
+				testcmd.GroupsAsArrayLen,
+				testcmd.OnlyIDVulnsRule,
+				testcmd.OnlyFirstBaseImage,
+				testcmd.AnyDiffID,
+				testcmd.NormalizeHistoryCommand,
+				testcmd.ShortenHistoryCommandLength,
 			},
 		},
 		{
 			Name: "scanning image with go binary",
 			Args: []string{"", "scan", "image", "--archive", "--all-packages", "--format=json", "../../../../internal/image/fixtures/test-go-binary.tar"},
 			Exit: 1,
-			ReplaceRules: []testutility.JSONReplaceRule{
-				testutility.GroupsAsArrayLen,
-				testutility.OnlyIDVulnsRule,
-				testutility.OnlyFirstBaseImage,
-				testutility.AnyDiffID,
-				testutility.NormalizeHistoryCommand,
+			ReplaceRules: []testcmd.JSONReplaceRule{
+				testcmd.GroupsAsArrayLen,
+				testcmd.OnlyIDVulnsRule,
+				testcmd.OnlyFirstBaseImage,
+				testcmd.AnyDiffID,
+				testcmd.NormalizeHistoryCommand,
 			},
 		},
 		{
 			Name: "scanning ubuntu image in json format",
 			Args: []string{"", "scan", "image", "--archive", "--format=json", "../../../../internal/image/fixtures/test-ubuntu.tar"},
 			Exit: 1,
-			ReplaceRules: []testutility.JSONReplaceRule{
-				testutility.GroupsAsArrayLen,
-				testutility.OnlyIDVulnsRule,
-				testutility.OnlyFirstBaseImage,
-				testutility.AnyDiffID,
+			ReplaceRules: []testcmd.JSONReplaceRule{
+				testcmd.GroupsAsArrayLen,
+				testcmd.OnlyIDVulnsRule,
+				testcmd.OnlyFirstBaseImage,
+				testcmd.AnyDiffID,
 			},
 		},
 	}
@@ -209,7 +209,7 @@ func TestCommand_OCIImageAllPackagesJSON(t *testing.T) {
 				}
 			}
 
-			testcmd.TestJSONWithCustomRules(t, tt)
+			testcmd.RunAndMatchSnapshots(t, tt)
 		})
 	}
 }
diff --git a/cmd/osv-scanner/scan/image/testmain_test.go b/cmd/osv-scanner/scan/image/testmain_test.go
index c25a6df3..bd7db18d 100644
--- a/cmd/osv-scanner/scan/image/testmain_test.go
+++ b/cmd/osv-scanner/scan/image/testmain_test.go
@@ -1,16 +1,13 @@
 package image_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/cmd/osv-scanner/scan/testmain_test.go b/cmd/osv-scanner/scan/testmain_test.go
index c8872cc5..e5d01f6e 100644
--- a/cmd/osv-scanner/scan/testmain_test.go
+++ b/cmd/osv-scanner/scan/testmain_test.go
@@ -1,16 +1,13 @@
 package scan_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/cmd/osv-scanner/testmain_test.go b/cmd/osv-scanner/testmain_test.go
index f63d4083..ad2e5480 100644
--- a/cmd/osv-scanner/testmain_test.go
+++ b/cmd/osv-scanner/testmain_test.go
@@ -18,10 +18,9 @@ func TestMain(m *testing.M) {
 	if err != nil {
 		panic(err)
 	}
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
 
 	os.RemoveAll("./fixtures/.git")
-	os.Exit(code)
 }
diff --git a/cmd/osv-scanner/update/command_test.go b/cmd/osv-scanner/update/command_test.go
index 3e579e90..46bf80a6 100644
--- a/cmd/osv-scanner/update/command_test.go
+++ b/cmd/osv-scanner/update/command_test.go
@@ -42,10 +42,7 @@ func TestCommand(t *testing.T) {
 				tc.Args = append(tc.Args, "-M", manifest)
 			}
 
-			stdout, stderr := testcmd.Run(t, tc)
-
-			testutility.NewSnapshot().MatchText(t, stdout)
-			testutility.NewSnapshot().MatchText(t, stderr)
+			testcmd.RunAndMatchSnapshots(t, tc)
 
 			if manifest != "" {
 				b, err := os.ReadFile(manifest)
diff --git a/cmd/osv-scanner/update/testmain_test.go b/cmd/osv-scanner/update/testmain_test.go
index a4ce67da..997ed8b6 100644
--- a/cmd/osv-scanner/update/testmain_test.go
+++ b/cmd/osv-scanner/update/testmain_test.go
@@ -1,16 +1,13 @@
 package update_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/internal/ci/testmain_test.go b/internal/ci/testmain_test.go
index 65a33384..09680fe7 100644
--- a/internal/ci/testmain_test.go
+++ b/internal/ci/testmain_test.go
@@ -1,16 +1,13 @@
 package ci_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/internal/datasource/testmain_test.go b/internal/datasource/testmain_test.go
index f7ab6671..b773f09e 100644
--- a/internal/datasource/testmain_test.go
+++ b/internal/datasource/testmain_test.go
@@ -1,16 +1,13 @@
 package datasource_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/internal/output/testmain_test.go b/internal/output/testmain_test.go
index 182268df..02fbe36e 100644
--- a/internal/output/testmain_test.go
+++ b/internal/output/testmain_test.go
@@ -1,16 +1,13 @@
 package output_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/internal/remediation/testmain_test.go b/internal/remediation/testmain_test.go
index 93f48eed..0819f0f7 100644
--- a/internal/remediation/testmain_test.go
+++ b/internal/remediation/testmain_test.go
@@ -1,16 +1,13 @@
 package remediation_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/internal/resolution/lockfile/testmain_test.go b/internal/resolution/lockfile/testmain_test.go
index dba91cfa..4a50eeb0 100644
--- a/internal/resolution/lockfile/testmain_test.go
+++ b/internal/resolution/lockfile/testmain_test.go
@@ -1,16 +1,13 @@
 package lockfile_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/internal/resolution/manifest/testmain_test.go b/internal/resolution/manifest/testmain_test.go
index 1ea40752..4c1ff1a9 100644
--- a/internal/resolution/manifest/testmain_test.go
+++ b/internal/resolution/manifest/testmain_test.go
@@ -1,16 +1,13 @@
 package manifest_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/internal/resolution/testmain_test.go b/internal/resolution/testmain_test.go
index 7f55eb6b..5721f7aa 100644
--- a/internal/resolution/testmain_test.go
+++ b/internal/resolution/testmain_test.go
@@ -1,16 +1,13 @@
 package resolution_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/internal/sourceanalysis/testmain_test.go b/internal/sourceanalysis/testmain_test.go
index 0e1f7730..77778a9d 100644
--- a/internal/sourceanalysis/testmain_test.go
+++ b/internal/sourceanalysis/testmain_test.go
@@ -1,16 +1,13 @@
 package sourceanalysis_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/internal/testutility/snapshot.go b/internal/testutility/snapshot.go
index c1b49dd4..2522dbe7 100644
--- a/internal/testutility/snapshot.go
+++ b/internal/testutility/snapshot.go
@@ -1,7 +1,6 @@
 package testutility
 
 import (
-	"bytes"
 	"encoding/json"
 	"testing"
 
@@ -53,21 +52,3 @@ func (s Snapshot) MatchText(t *testing.T, got string) {
 
 	snaps.MatchSnapshot(t, normalizeSnapshot(t, applyWindowsReplacements(got, s.windowsReplacements)))
 }
-
-// MatchOSVScannerJSONOutput asserts the existing snapshot matches the osv-scanner json output, while using a list of jsonReplacementRules
-func (s Snapshot) MatchOSVScannerJSONOutput(t *testing.T, jsonInput string, jsonReplaceRules ...JSONReplaceRule) {
-	t.Helper()
-
-	for _, rule := range jsonReplaceRules {
-		jsonInput = replaceJSONInput(t, jsonInput, rule.Path, rule.ReplaceFunc)
-	}
-
-	jsonFormatted := bytes.Buffer{}
-	err := json.Indent(&jsonFormatted, []byte(jsonInput), "", "  ")
-
-	if err != nil {
-		t.Fatalf("Failed to marshal JSON: %s", err)
-	}
-
-	s.MatchText(t, jsonFormatted.String())
-}
diff --git a/pkg/models/testmain_test.go b/pkg/models/testmain_test.go
index 955fd005..2142eb00 100644
--- a/pkg/models/testmain_test.go
+++ b/pkg/models/testmain_test.go
@@ -1,16 +1,13 @@
 package models_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }
diff --git a/pkg/osvscanner/testmain_test.go b/pkg/osvscanner/testmain_test.go
index d615ca18..a1ffd0b9 100644
--- a/pkg/osvscanner/testmain_test.go
+++ b/pkg/osvscanner/testmain_test.go
@@ -1,16 +1,13 @@
 package osvscanner_test
 
 import (
-	"os"
 	"testing"
 
 	"github.com/google/osv-scanner/v2/internal/testutility"
 )
 
 func TestMain(m *testing.M) {
-	code := m.Run()
+	m.Run()
 
 	testutility.CleanSnapshots(m)
-
-	os.Exit(code)
 }