Repository: anchore/grype
Pull request #1485 (2023-09-11 15:20:29)

chore(deps): bump tibdex/github-app-token from 1.8.2 to 2.0.0
Bumps [tibdex/github-app-token](https://github.com/tibdex/github-app-token) from 1.8.2 to 2.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/tibdex/github-app-token/releases">tibdex/github-app-token's releases</a>.</em></p>
<blockquote>
<h2>v2.0.0</h2>
<ul>
<li><strong>BREAKING</strong>: replaces the <code>installation_id</code> and <code>repository</code> inputs with <code>installation_retrieval_mode</code> and <code>installation_retrieval_payload</code> to also support organization and user installation.</li>
<li>switches to <code>node20</code>.</li>
<li>adds a <code>repositories</code> input to scope the created token to a subset of repositories.</li>
<li>revokes the created token at the end of the job with a <a href="https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#runspost"><code>post</code> script</a>.</li>
</ul>
<h2>v1.9.0</h2>
<p>No release notes provided.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/tibdex/github-app-token/commit/0914d50df753bbc42180d982a6550f195390069f"><code>0914d50</code></a> release v2.0.0</li>
<li><a href="https://github.com/tibdex/github-app-token/commit/95717386c657bc772ba69db6fafa0e08baf5372e"><code>9571738</code></a> Add support for organization and user installation retrieval and repository s...</li>
<li><a href="https://github.com/tibdex/github-app-token/commit/3a5af6bad89e48d69a91cd3cfea22862f4f672e7"><code>3a5af6b</code></a> Update dependencies and simplify project (<a href="https://redirect.github.com/tibdex/github-app-token/issues/83">#83</a>)</li>
<li>See full diff in <a href="https://github.com/tibdex/github-app-token/compare/0d49dd721133f900ebd5e0dff2810704e8defbc6...0914d50df753bbc42180d982a6550f195390069f">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tibdex/github-app-token&package-manager=github_actions&previous-version=1.8.2&new-version=2.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.github/workflows/update-bootstrap-tools.yml b/.github/workflows/update-bootstrap-tools.yml
index cd70318a..acf78db6 100644
--- a/.github/workflows/update-bootstrap-tools.yml
+++ b/.github/workflows/update-bootstrap-tools.yml
@@ -55,7 +55,7 @@ jobs:
           echo "GLOW=GLOW_LATEST_VERSION" >> $GITHUB_OUTPUT
         id: latest-versions
 
-      - uses: tibdex/github-app-token@0d49dd721133f900ebd5e0dff2810704e8defbc6 # v1.8.2
+      - uses: tibdex/github-app-token@0914d50df753bbc42180d982a6550f195390069f # v2.0.0
         id: generate-token
         with:
           app_id: ${{ secrets.TOKEN_APP_ID }}
diff --git a/.github/workflows/update-syft-release.yml b/.github/workflows/update-syft-release.yml
index 6cff52c7..15fe3d7a 100644
--- a/.github/workflows/update-syft-release.yml
+++ b/.github/workflows/update-syft-release.yml
@@ -38,7 +38,7 @@ jobs:
           # export the version for use with create-pull-request
           echo "LATEST_VERSION=$LATEST_VERSION" >> $GITHUB_OUTPUT
 
-      - uses: tibdex/github-app-token@0d49dd721133f900ebd5e0dff2810704e8defbc6 # v1.8.2
+      - uses: tibdex/github-app-token@0914d50df753bbc42180d982a6550f195390069f # v2.0.0
         id: generate-token
         with:
           app_id: ${{ secrets.TOKEN_APP_ID }}
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 1d9552aa..f024ee0d 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -24,10 +24,10 @@ builds:
       -w
       -s
       -extldflags '-static'
-      -X github.com/anchore/grype/internal/version.version={{.Version}}
-      -X github.com/anchore/grype/internal/version.gitCommit={{.Commit}}
-      -X github.com/anchore/grype/internal/version.buildDate={{.Date}}
-      -X github.com/anchore/grype/internal/version.gitDescription={{.Summary}}
+      -X main.version={{.Version}}
+      -X main.gitCommit={{.Commit}}
+      -X main.buildDate={{.Date}}
+      -X main.gitDescription={{.Summary}}
 
   - id: darwin-build
     dir: ./cmd/grype
diff --git a/cmd/grype/cli/cli.go b/cmd/grype/cli/cli.go
new file mode 100644
index 00000000..8a6bf750
--- /dev/null
+++ b/cmd/grype/cli/cli.go
@@ -0,0 +1,99 @@
+package cli
+
+import (
+	"os"
+	"runtime/debug"
+
+	"github.com/spf13/cobra"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/cmd/grype/cli/commands"
+	handler "github.com/anchore/grype/cmd/grype/cli/ui"
+	"github.com/anchore/grype/cmd/grype/internal/ui"
+	"github.com/anchore/grype/internal/bus"
+	"github.com/anchore/grype/internal/log"
+	"github.com/anchore/grype/internal/redact"
+	"github.com/anchore/stereoscope"
+	"github.com/anchore/syft/syft"
+)
+
+func Application(id clio.Identification) clio.Application {
+	app, _ := create(id)
+	return app
+}
+
+func Command(id clio.Identification) *cobra.Command {
+	_, cmd := create(id)
+	return cmd
+}
+
+func create(id clio.Identification) (clio.Application, *cobra.Command) {
+	clioCfg := clio.NewSetupConfig(id).
+		WithGlobalConfigFlag().   // add persistent -c <path> for reading an application config from
+		WithGlobalLoggingFlags(). // add persistent -v and -q flags tied to the logging config
+		WithConfigInRootHelp().   // --help on the root command renders the full application config in the help text
+		WithUIConstructor(
+			// select a UI based on the logging configuration and state of stdin (if stdin is a tty)
+			func(cfg clio.Config) ([]clio.UI, error) {
+				noUI := ui.None(cfg.Log.Quiet)
+				if !cfg.Log.AllowUI(os.Stdin) || cfg.Log.Quiet {
+					return []clio.UI{noUI}, nil
+				}
+
+				h := handler.New(handler.DefaultHandlerConfig())
+
+				return []clio.UI{
+					ui.New(cfg.Log.Quiet, h),
+					noUI,
+				}, nil
+			},
+		).
+		WithInitializers(
+			func(state *clio.State) error {
+				// clio is setting up and providing the bus, redact store, and logger to the application. Once loaded,
+				// we can hoist them into the internal packages for global use.
+				stereoscope.SetBus(state.Bus)
+				syft.SetBus(state.Bus)
+				bus.Set(state.Bus)
+
+				redact.Set(state.RedactStore)
+
+				stereoscope.SetLogger(state.Logger)
+				syft.SetLogger(state.Logger)
+				log.Set(state.Logger)
+
+				return nil
+			},
+		)
+
+	app := clio.New(*clioCfg)
+
+	rootCmd := commands.Root(app)
+
+	// add sub-commands
+	rootCmd.AddCommand(
+		commands.DB(app),
+		commands.Completion(),
+		commands.Explain(app),
+		clio.VersionCommand(id, syftVersion),
+	)
+
+	return app, rootCmd
+}
+
+func syftVersion() (string, string) {
+	buildInfo, ok := debug.ReadBuildInfo()
+	if !ok {
+		log.Debug("unable to find the buildinfo section of the binary (syft version is unknown)")
+		return "", ""
+	}
+
+	for _, d := range buildInfo.Deps {
+		if d.Path == "github.com/anchore/syft" {
+			return "SyftVersion", d.Version
+		}
+	}
+
+	log.Debug("unable to find 'github.com/anchore/syft' from the buildinfo section of the binary")
+	return "", ""
+}
diff --git a/cmd/grype/cli/legacy/completion.go b/cmd/grype/cli/commands/completion.go
similarity index 75%
rename from cmd/grype/cli/legacy/completion.go
rename to cmd/grype/cli/commands/completion.go
index ae7dd3e0..9a689988 100644
--- a/cmd/grype/cli/legacy/completion.go
+++ b/cmd/grype/cli/commands/completion.go
@@ -1,4 +1,4 @@
-package legacy
+package commands
 
 import (
 	"context"
@@ -11,11 +11,12 @@ import (
 	"github.com/spf13/cobra"
 )
 
-// completionCmd represents the completion command
-var completionCmd = &cobra.Command{
-	Use:   "completion [bash|zsh|fish]",
-	Short: "Generate a shell completion for Grype (listing local docker images)",
-	Long: `To load completions (docker image list):
+// Completion returns a command to provide completion to various terminal shells
+func Completion() *cobra.Command {
+	return &cobra.Command{
+		Use:   "completion [bash|zsh|fish]",
+		Short: "Generate a shell completion for Grype (listing local docker images)",
+		Long: `To load completions (docker image list):
 
 Bash:
 
@@ -46,40 +47,22 @@ $ grype completion fish | source
 # To load completions for each session, execute once:
 $ grype completion fish > ~/.config/fish/completions/grype.fish
 `,
-	DisableFlagsInUseLine: true,
-	ValidArgs:             []string{"bash", "fish", "zsh"},
-	Args:                  cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
-	RunE: func(cmd *cobra.Command, args []string) error {
-		var err error
-		switch args[0] {
-		case "zsh":
-			err = cmd.Root().GenZshCompletion(os.Stdout)
-		case "bash":
-			err = cmd.Root().GenBashCompletion(os.Stdout)
-		case "fish":
-			err = cmd.Root().GenFishCompletion(os.Stdout, true)
-		}
-		return err
-	},
-}
-
-func init() {
-	rootCmd.AddCommand(completionCmd)
-}
-
-func dockerImageValidArgsFunction(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
-	// Since we use ValidArgsFunction, Cobra will call this AFTER having parsed all flags and arguments provided
-	dockerImageRepoTags, err := listLocalDockerImages(toComplete)
-	if err != nil {
-		// Indicates that an error occurred and completions should be ignored
-		return []string{"completion failed"}, cobra.ShellCompDirectiveError
-	}
-	if len(dockerImageRepoTags) == 0 {
-		return []string{"no docker images found"}, cobra.ShellCompDirectiveError
+		DisableFlagsInUseLine: true,
+		ValidArgs:             []string{"bash", "fish", "zsh"},
+		Args:                  cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			var err error
+			switch args[0] {
+			case "zsh":
+				err = cmd.Root().GenZshCompletion(os.Stdout)
+			case "bash":
+				err = cmd.Root().GenBashCompletion(os.Stdout)
+			case "fish":
+				err = cmd.Root().GenFishCompletion(os.Stdout, true)
+			}
+			return err
+		},
 	}
-	// ShellCompDirectiveDefault indicates that the shell will perform its default behavior after completions have
-	// been provided (without implying other possible directives)
-	return dockerImageRepoTags, cobra.ShellCompDirectiveDefault
 }
 
 func listLocalDockerImages(prefix string) ([]string, error) {
@@ -108,3 +91,18 @@ func listLocalDockerImages(prefix string) ([]string, error) {
 	}
 	return repoTags, nil
 }
+
+func dockerImageValidArgsFunction(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+	// Since we use ValidArgsFunction, Cobra will call this AFTER having parsed all flags and arguments provided
+	dockerImageRepoTags, err := listLocalDockerImages(toComplete)
+	if err != nil {
+		// Indicates that an error occurred and completions should be ignored
+		return []string{"completion failed"}, cobra.ShellCompDirectiveError
+	}
+	if len(dockerImageRepoTags) == 0 {
+		return []string{"no docker images found"}, cobra.ShellCompDirectiveError
+	}
+	// ShellCompDirectiveDefault indicates that the shell will perform its default behavior after completions have
+	// been provided (without implying other possible directives)
+	return dockerImageRepoTags, cobra.ShellCompDirectiveDefault
+}
diff --git a/cmd/grype/cli/commands/db.go b/cmd/grype/cli/commands/db.go
new file mode 100644
index 00000000..30e611c3
--- /dev/null
+++ b/cmd/grype/cli/commands/db.go
@@ -0,0 +1,37 @@
+package commands
+
+import (
+	"github.com/spf13/cobra"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/cmd/grype/cli/options"
+)
+
+type DBOptions struct {
+	DB options.Database `yaml:"db" json:"db" mapstructure:"db"`
+}
+
+func dbOptionsDefault(id clio.Identification) *DBOptions {
+	return &DBOptions{
+		DB: options.DefaultDatabase(id),
+	}
+}
+
+func DB(app clio.Application) *cobra.Command {
+	db := &cobra.Command{
+		Use:   "db",
+		Short: "vulnerability database operations",
+	}
+
+	db.AddCommand(
+		DBCheck(app),
+		DBDelete(app),
+		DBDiff(app),
+		DBImport(app),
+		DBList(app),
+		DBStatus(app),
+		DBUpdate(app),
+	)
+
+	return db
+}
diff --git a/cmd/grype/cli/legacy/db_check.go b/cmd/grype/cli/commands/db_check.go
similarity index 58%
rename from cmd/grype/cli/legacy/db_check.go
rename to cmd/grype/cli/commands/db_check.go
index 0c1eec98..f532ca8d 100644
--- a/cmd/grype/cli/legacy/db_check.go
+++ b/cmd/grype/cli/commands/db_check.go
@@ -1,26 +1,33 @@
-package legacy
+package commands
 
 import (
 	"fmt"
 
 	"github.com/spf13/cobra"
 
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/cmd/grype/cli/options"
 	"github.com/anchore/grype/grype/db"
+	"github.com/anchore/grype/internal/bus"
 )
 
-var dbCheckCmd = &cobra.Command{
-	Use:   "check",
-	Short: "check to see if there is a database update available",
-	Args:  cobra.ExactArgs(0),
-	RunE:  runDBCheckCmd,
+func DBCheck(app clio.Application) *cobra.Command {
+	opts := dbOptionsDefault(app.ID())
+
+	return app.SetupCommand(&cobra.Command{
+		Use:   "check",
+		Short: "check to see if there is a database update available",
+		Args:  cobra.ExactArgs(0),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runDBCheck(opts.DB)
+		},
+	}, opts)
 }
 
-func init() {
-	dbCmd.AddCommand(dbCheckCmd)
-}
+func runDBCheck(opts options.Database) error {
+	defer bus.Exit()
 
-func runDBCheckCmd(_ *cobra.Command, _ []string) error {
-	dbCurator, err := db.NewCurator(appConfig.DB.ToCuratorConfig())
+	dbCurator, err := db.NewCurator(opts.ToCuratorConfig())
 	if err != nil {
 		return err
 	}
diff --git a/cmd/grype/cli/commands/db_delete.go b/cmd/grype/cli/commands/db_delete.go
new file mode 100644
index 00000000..2832e017
--- /dev/null
+++ b/cmd/grype/cli/commands/db_delete.go
@@ -0,0 +1,40 @@
+package commands
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/cmd/grype/cli/options"
+	"github.com/anchore/grype/grype/db"
+	"github.com/anchore/grype/internal/bus"
+)
+
+func DBDelete(app clio.Application) *cobra.Command {
+	opts := dbOptionsDefault(app.ID())
+
+	return app.SetupCommand(&cobra.Command{
+		Use:   "delete",
+		Short: "delete the vulnerability database",
+		Args:  cobra.ExactArgs(0),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runDBDelete(opts.DB)
+		},
+	}, opts)
+}
+
+func runDBDelete(opts options.Database) error {
+	defer bus.Exit()
+
+	dbCurator, err := db.NewCurator(opts.ToCuratorConfig())
+	if err != nil {
+		return err
+	}
+
+	if err := dbCurator.Delete(); err != nil {
+		return fmt.Errorf("unable to delete vulnerability database: %+v", err)
+	}
+
+	return stderrPrintLnf("Vulnerability database deleted")
+}
diff --git a/cmd/grype/cli/commands/db_diff.go b/cmd/grype/cli/commands/db_diff.go
new file mode 100644
index 00000000..77324e17
--- /dev/null
+++ b/cmd/grype/cli/commands/db_diff.go
@@ -0,0 +1,129 @@
+package commands
+
+import (
+	"strings"
+
+	"github.com/hashicorp/go-multierror"
+	"github.com/spf13/cobra"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/cmd/grype/cli/options"
+	"github.com/anchore/grype/grype/db"
+	"github.com/anchore/grype/grype/differ"
+	"github.com/anchore/grype/internal/bus"
+	"github.com/anchore/grype/internal/log"
+)
+
+type dbDiffOptions struct {
+	Output    string `yaml:"output" json:"output" mapstructure:"output"`
+	Delete    bool   `yaml:"delete" json:"delete" mapstructure:"delete"`
+	DBOptions `yaml:",inline" mapstructure:",squash"`
+}
+
+var _ clio.FlagAdder = (*dbDiffOptions)(nil)
+
+func (d *dbDiffOptions) AddFlags(flags clio.FlagSet) {
+	flags.StringVarP(&d.Output, "output", "o", "format to display results (available=[table, json])")
+	flags.BoolVarP(&d.Delete, "delete", "d", "delete downloaded databases after diff occurs")
+}
+
+func DBDiff(app clio.Application) *cobra.Command {
+	opts := &dbDiffOptions{
+		Output:    "table",
+		DBOptions: *dbOptionsDefault(app.ID()),
+	}
+
+	return app.SetupCommand(&cobra.Command{
+		Use:   "diff [flags] base_db_url target_db_url",
+		Short: "diff two DBs and display the result",
+		Args:  cobra.MaximumNArgs(2),
+		RunE: func(cmd *cobra.Command, args []string) (err error) {
+			var base, target string
+
+			switch len(args) {
+			case 0:
+				log.Info("base_db_url and target_db_url not provided; fetching most recent")
+				base, target, err = getDefaultURLs(opts.DB)
+				if err != nil {
+					return err
+				}
+			case 1:
+				log.Info("target_db_url not provided; fetching most recent")
+				base = args[0]
+				_, target, err = getDefaultURLs(opts.DB)
+				if err != nil {
+					return err
+				}
+			default:
+				base = args[0]
+				target = args[1]
+			}
+
+			return runDBDiff(opts, base, target)
+		},
+	}, opts)
+}
+
+func runDBDiff(opts *dbDiffOptions, base string, target string) (errs error) {
+	defer bus.Exit()
+
+	d, err := differ.NewDiffer(opts.DB.ToCuratorConfig())
+	if err != nil {
+		return err
+	}
+
+	if err := d.SetBaseDB(base); err != nil {
+		return err
+	}
+
+	if err := d.SetTargetDB(target); err != nil {
+		return err
+	}
+
+	diff, err := d.DiffDatabases()
+	if err != nil {
+		return err
+	}
+
+	sb := &strings.Builder{}
+
+	if len(*diff) == 0 {
+		sb.WriteString("Databases are identical!\n")
+	} else {
+		err := d.Present(opts.Output, diff, sb)
+		if err != nil {
+			errs = multierror.Append(errs, err)
+		}
+	}
+
+	bus.Report(sb.String())
+
+	if opts.Delete {
+		errs = multierror.Append(errs, d.DeleteDatabases())
+	}
+
+	return errs
+}
+
+func getDefaultURLs(opts options.Database) (baseURL string, targetURL string, err error) {
+	dbCurator, err := db.NewCurator(opts.ToCuratorConfig())
+	if err != nil {
+		return "", "", err
+	}
+
+	listing, err := dbCurator.ListingFromURL()
+	if err != nil {
+		return "", "", err
+	}
+
+	supportedSchema := dbCurator.SupportedSchema()
+	available, exists := listing.Available[supportedSchema]
+	if len(available) < 2 || !exists {
+		return "", "", stderrPrintLnf("Not enough databases available for the current schema to diff (%d)", supportedSchema)
+	}
+
+	targetURL = available[0].URL.String()
+	baseURL = available[1].URL.String()
+
+	return baseURL, targetURL, nil
+}
diff --git a/cmd/grype/cli/commands/db_import.go b/cmd/grype/cli/commands/db_import.go
new file mode 100644
index 00000000..3dd9bd52
--- /dev/null
+++ b/cmd/grype/cli/commands/db_import.go
@@ -0,0 +1,42 @@
+package commands
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/cmd/grype/cli/options"
+	"github.com/anchore/grype/grype/db"
+	"github.com/anchore/grype/internal"
+	"github.com/anchore/grype/internal/bus"
+)
+
+func DBImport(app clio.Application) *cobra.Command {
+	opts := dbOptionsDefault(app.ID())
+
+	return app.SetupCommand(&cobra.Command{
+		Use:   "import FILE",
+		Short: "import a vulnerability database archive",
+		Long:  fmt.Sprintf("import a vulnerability database archive from a local FILE.\nDB archives can be obtained from %q.", internal.DBUpdateURL),
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runDBImport(opts.DB, args[0])
+		},
+	}, opts)
+}
+
+func runDBImport(opts options.Database, dbArchivePath string) error {
+	defer bus.Exit()
+
+	dbCurator, err := db.NewCurator(opts.ToCuratorConfig())
+	if err != nil {
+		return err
+	}
+
+	if err := dbCurator.ImportFrom(dbArchivePath); err != nil {
+		return fmt.Errorf("unable to import vulnerability database: %+v", err)
+	}
+
+	return stderrPrintLnf("Vulnerability database imported")
+}
diff --git a/cmd/grype/cli/legacy/db_list.go b/cmd/grype/cli/commands/db_list.go
similarity index 56%
rename from cmd/grype/cli/legacy/db_list.go
rename to cmd/grype/cli/commands/db_list.go
index e07240b6..70ac44c6 100644
--- a/cmd/grype/cli/legacy/db_list.go
+++ b/cmd/grype/cli/commands/db_list.go
@@ -1,4 +1,4 @@
-package legacy
+package commands
 
 import (
 	"encoding/json"
@@ -7,26 +7,42 @@ import (
 
 	"github.com/spf13/cobra"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/grype/grype/db"
+	"github.com/anchore/grype/internal/bus"
 )
 
-var dbListOutputFormat string
+type dbListOptions struct {
+	Output    string `yaml:"output" json:"output" mapstructure:"output"`
+	DBOptions `yaml:",inline" mapstructure:",squash"`
+}
+
+var _ clio.FlagAdder = (*dbListOptions)(nil)
 
-var dbListCmd = &cobra.Command{
-	Use:   "list",
-	Short: "list all DBs available according to the listing URL",
-	Args:  cobra.ExactArgs(0),
-	RunE:  runDBListCmd,
+func (d *dbListOptions) AddFlags(flags clio.FlagSet) {
+	flags.StringVarP(&d.Output, "output", "o", "format to display results (available=[text, raw, json])")
 }
 
-func init() {
-	dbListCmd.Flags().StringVarP(&dbListOutputFormat, "output", "o", "text", "format to display results (available=[text, raw, json])")
+func DBList(app clio.Application) *cobra.Command {
+	opts := &dbListOptions{
+		Output:    "text",
+		DBOptions: *dbOptionsDefault(app.ID()),
+	}
 
-	dbCmd.AddCommand(dbListCmd)
+	return app.SetupCommand(&cobra.Command{
+		Use:   "list",
+		Short: "list all DBs available according to the listing URL",
+		Args:  cobra.ExactArgs(0),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runDBList(opts)
+		},
+	}, opts)
 }
 
-func runDBListCmd(_ *cobra.Command, _ []string) error {
-	dbCurator, err := db.NewCurator(appConfig.DB.ToCuratorConfig())
+func runDBList(opts *dbListOptions) error {
+	defer bus.Exit()
+
+	dbCurator, err := db.NewCurator(opts.DB.ToCuratorConfig())
 	if err != nil {
 		return err
 	}
@@ -43,7 +59,7 @@ func runDBListCmd(_ *cobra.Command, _ []string) error {
 		return stderrPrintLnf("No databases available for the current schema (%d)", supportedSchema)
 	}
 
-	switch dbListOutputFormat {
+	switch opts.Output {
 	case "text":
 		// summarize each listing entry for the current DB schema
 		for _, l := range available {
@@ -70,7 +86,7 @@ func runDBListCmd(_ *cobra.Command, _ []string) error {
 			return fmt.Errorf("failed to db listing information: %+v", err)
 		}
 	default:
-		return fmt.Errorf("unsupported output format: %s", dbListOutputFormat)
+		return fmt.Errorf("unsupported output format: %s", opts.Output)
 	}
 
 	return nil
diff --git a/cmd/grype/cli/commands/db_status.go b/cmd/grype/cli/commands/db_status.go
new file mode 100644
index 00000000..30c1d533
--- /dev/null
+++ b/cmd/grype/cli/commands/db_status.go
@@ -0,0 +1,49 @@
+package commands
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/cmd/grype/cli/options"
+	"github.com/anchore/grype/grype/db"
+	"github.com/anchore/grype/internal/bus"
+)
+
+func DBStatus(app clio.Application) *cobra.Command {
+	opts := dbOptionsDefault(app.ID())
+
+	return app.SetupCommand(&cobra.Command{
+		Use:   "status",
+		Short: "display database status",
+		Args:  cobra.ExactArgs(0),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runDBStatus(opts.DB)
+		},
+	}, opts)
+}
+
+func runDBStatus(opts options.Database) error {
+	defer bus.Exit()
+
+	dbCurator, err := db.NewCurator(opts.ToCuratorConfig())
+	if err != nil {
+		return err
+	}
+
+	status := dbCurator.Status()
+
+	statusStr := "valid"
+	if status.Err != nil {
+		statusStr = "invalid"
+	}
+
+	fmt.Println("Location: ", status.Location)
+	fmt.Println("Built:    ", status.Built.String())
+	fmt.Println("Schema:   ", status.SchemaVersion)
+	fmt.Println("Checksum: ", status.Checksum)
+	fmt.Println("Status:   ", statusStr)
+
+	return status.Err
+}
diff --git a/cmd/grype/cli/commands/db_update.go b/cmd/grype/cli/commands/db_update.go
new file mode 100644
index 00000000..13cfa624
--- /dev/null
+++ b/cmd/grype/cli/commands/db_update.go
@@ -0,0 +1,50 @@
+package commands
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/cmd/grype/cli/options"
+	"github.com/anchore/grype/grype/db"
+	"github.com/anchore/grype/internal/bus"
+	"github.com/anchore/grype/internal/log"
+)
+
+func DBUpdate(app clio.Application) *cobra.Command {
+	opts := dbOptionsDefault(app.ID())
+
+	return app.SetupCommand(&cobra.Command{
+		Use:   "update",
+		Short: "download the latest vulnerability database",
+		Args:  cobra.ExactArgs(0),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return runDBUpdate(opts.DB)
+		},
+	}, opts)
+}
+
+func runDBUpdate(opts options.Database) error {
+	defer bus.Exit()
+
+	dbCurator, err := db.NewCurator(opts.ToCuratorConfig())
+	if err != nil {
+		return err
+	}
+	updated, err := dbCurator.Update()
+	if err != nil {
+		return fmt.Errorf("unable to update vulnerability database: %+v", err)
+	}
+
+	result := "No vulnerability database update available\n"
+	if updated {
+		result = "Vulnerability database updated to latest version!\n"
+	}
+
+	log.Debugf("completed db update check with result: %s", result)
+
+	bus.Report(result)
+
+	return nil
+}
diff --git a/cmd/grype/cli/commands/explain.go b/cmd/grype/cli/commands/explain.go
new file mode 100644
index 00000000..8b0368b7
--- /dev/null
+++ b/cmd/grype/cli/commands/explain.go
@@ -0,0 +1,56 @@
+package commands
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/grype/presenter/explain"
+	"github.com/anchore/grype/grype/presenter/models"
+	"github.com/anchore/grype/internal"
+	"github.com/anchore/grype/internal/log"
+)
+
+type explainOptions struct {
+	CVEIDs []string `yaml:"cve-ids" json:"cve-ids" mapstructure:"cve-ids"`
+}
+
+var _ clio.FlagAdder = (*explainOptions)(nil)
+
+func (d *explainOptions) AddFlags(flags clio.FlagSet) {
+	flags.StringArrayVarP(&d.CVEIDs, "id", "", "CVE IDs to explain")
+}
+
+func Explain(app clio.Application) *cobra.Command {
+	opts := &explainOptions{}
+
+	return app.SetupCommand(&cobra.Command{
+		Use:   "explain --id [VULNERABILITY ID]",
+		Short: "Ask grype to explain a set of findings",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			log.Warn("grype explain is a prototype feature and is subject to change")
+			isStdinPipeOrRedirect, err := internal.IsStdinPipeOrRedirect()
+			if err != nil {
+				log.Warnf("unable to determine if there is piped input: %+v", err)
+				isStdinPipeOrRedirect = false
+			}
+			if isStdinPipeOrRedirect {
+				// TODO: eventually detect different types of input; for now assume grype json
+				var parseResult models.Document
+				decoder := json.NewDecoder(os.Stdin)
+				err := decoder.Decode(&parseResult)
+				if err != nil {
+					return fmt.Errorf("unable to parse piped input: %+v", err)
+				}
+				explainer := explain.NewVulnerabilityExplainer(os.Stdout, &parseResult)
+				return explainer.ExplainByID(opts.CVEIDs)
+			}
+			// perform a scan, then explain requested CVEs
+			// TODO: implement
+			return fmt.Errorf("requires grype json on stdin, please run 'grype -o json ... | grype explain ...'")
+		},
+	}, opts)
+}
diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go
new file mode 100644
index 00000000..5b6195c3
--- /dev/null
+++ b/cmd/grype/cli/commands/root.go
@@ -0,0 +1,344 @@
+package commands
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+	"sync"
+
+	"github.com/hashicorp/go-multierror"
+	"github.com/spf13/cobra"
+	"github.com/wagoodman/go-partybus"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/cmd/grype/cli/options"
+	"github.com/anchore/grype/grype"
+	"github.com/anchore/grype/grype/db"
+	grypeDb "github.com/anchore/grype/grype/db/v5"
+	"github.com/anchore/grype/grype/event"
+	"github.com/anchore/grype/grype/event/parsers"
+	"github.com/anchore/grype/grype/grypeerr"
+	"github.com/anchore/grype/grype/match"
+	"github.com/anchore/grype/grype/matcher"
+	"github.com/anchore/grype/grype/matcher/dotnet"
+	"github.com/anchore/grype/grype/matcher/golang"
+	"github.com/anchore/grype/grype/matcher/java"
+	"github.com/anchore/grype/grype/matcher/javascript"
+	"github.com/anchore/grype/grype/matcher/python"
+	"github.com/anchore/grype/grype/matcher/ruby"
+	"github.com/anchore/grype/grype/matcher/stock"
+	"github.com/anchore/grype/grype/pkg"
+	"github.com/anchore/grype/grype/presenter/models"
+	"github.com/anchore/grype/grype/store"
+	"github.com/anchore/grype/internal"
+	"github.com/anchore/grype/internal/bus"
+	"github.com/anchore/grype/internal/format"
+	"github.com/anchore/grype/internal/log"
+	"github.com/anchore/grype/internal/stringutil"
+	"github.com/anchore/syft/syft/linux"
+	syftPkg "github.com/anchore/syft/syft/pkg"
+	"github.com/anchore/syft/syft/sbom"
+)
+
+func Root(app clio.Application) *cobra.Command {
+	opts := options.DefaultGrype(app.ID())
+
+	return app.SetupRootCommand(&cobra.Command{
+		Use:   fmt.Sprintf("%s [IMAGE]", app.ID().Name),
+		Short: "A vulnerability scanner for container images, filesystems, and SBOMs",
+		Long: stringutil.Tprintf(`A vulnerability scanner for container images, filesystems, and SBOMs.
+
+Supports the following image sources:
+    {{.appName}} yourrepo/yourimage:tag             defaults to using images from a Docker daemon
+    {{.appName}} path/to/yourproject                a Docker tar, OCI tar, OCI directory, SIF container, or generic filesystem directory
+
+You can also explicitly specify the scheme to use:
+    {{.appName}} podman:yourrepo/yourimage:tag          explicitly use the Podman daemon
+    {{.appName}} docker:yourrepo/yourimage:tag          explicitly use the Docker daemon
+    {{.appName}} docker-archive:path/to/yourimage.tar   use a tarball from disk for archives created from "docker save"
+    {{.appName}} oci-archive:path/to/yourimage.tar      use a tarball from disk for OCI archives (from Podman or otherwise)
+    {{.appName}} oci-dir:path/to/yourimage              read directly from a path on disk for OCI layout directories (from Skopeo or otherwise)
+    {{.appName}} singularity:path/to/yourimage.sif      read directly from a Singularity Image Format (SIF) container on disk
+    {{.appName}} dir:path/to/yourproject                read directly from a path on disk (any directory)
+    {{.appName}} sbom:path/to/syft.json                 read Syft JSON from path on disk
+    {{.appName}} registry:yourrepo/yourimage:tag        pull image directly from a registry (no container runtime required)
+    {{.appName}} purl:path/to/purl/file                 read a newline separated file of purls from a path on disk
+
+You can also pipe in Syft JSON directly:
+	syft yourimage:tag -o json | {{.appName}}
+
+`, map[string]interface{}{
+			"appName": app.ID().Name,
+		}),
+		Args:          validateRootArgs,
+		SilenceUsage:  true,
+		SilenceErrors: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			userInput := ""
+			if len(args) > 0 {
+				userInput = args[0]
+			}
+			return runGrype(app, opts, userInput)
+		},
+		ValidArgsFunction: dockerImageValidArgsFunction,
+	}, opts)
+}
+
+var ignoreNonFixedMatches = []match.IgnoreRule{
+	{FixState: string(grypeDb.NotFixedState)},
+	{FixState: string(grypeDb.WontFixState)},
+	{FixState: string(grypeDb.UnknownFixState)},
+}
+
+var ignoreFixedMatches = []match.IgnoreRule{
+	{FixState: string(grypeDb.FixedState)},
+}
+
+//nolint:funlen
+func runGrype(app clio.Application, opts *options.Grype, userInput string) error {
+	errs := make(chan error)
+	go func() {
+		defer close(errs)
+		defer bus.Exit()
+
+		writer, err := format.MakeScanResultWriter(opts.Outputs, opts.File, format.PresentationConfig{
+			TemplateFilePath: opts.OutputTemplateFile,
+			ShowSuppressed:   opts.ShowSuppressed,
+		})
+		if err != nil {
+			errs <- err
+			return
+		}
+
+		checkForAppUpdate(app.ID(), opts)
+
+		var str *store.Store
+		var status *db.Status
+		var dbCloser *db.Closer
+		var packages []pkg.Package
+		var s *sbom.SBOM
+		var pkgContext pkg.Context
+		var wg = &sync.WaitGroup{}
+		var loadedDB, gatheredPackages bool
+
+		wg.Add(2)
+
+		go func() {
+			defer wg.Done()
+			log.Debug("loading DB")
+			str, status, dbCloser, err = grype.LoadVulnerabilityDB(opts.DB.ToCuratorConfig(), opts.DB.AutoUpdate)
+			if err = validateDBLoad(err, status); err != nil {
+				errs <- err
+				return
+			}
+			loadedDB = true
+		}()
+
+		go func() {
+			defer wg.Done()
+			log.Debugf("gathering packages")
+			// packages are grype.Package, not syft.Package
+			// the SBOM is returned for downstream formatting concerns
+			// grype uses the SBOM in combination with syft formatters to produce cycloneDX
+			// with vulnerability information appended
+			packages, pkgContext, s, err = pkg.Provide(userInput, getProviderConfig(opts))
+			if err != nil {
+				errs <- fmt.Errorf("failed to catalog: %w", err)
+				return
+			}
+			gatheredPackages = true
+		}()
+
+		wg.Wait()
+		if !loadedDB || !gatheredPackages {
+			return
+		}
+
+		if dbCloser != nil {
+			defer dbCloser.Close()
+		}
+
+		if opts.OnlyFixed {
+			opts.Ignore = append(opts.Ignore, ignoreNonFixedMatches...)
+		}
+
+		if opts.OnlyNotFixed {
+			opts.Ignore = append(opts.Ignore, ignoreFixedMatches...)
+		}
+
+		applyDistroHint(packages, &pkgContext, opts)
+
+		vulnMatcher := grype.VulnerabilityMatcher{
+			Store:          *str,
+			IgnoreRules:    opts.Ignore,
+			NormalizeByCVE: opts.ByCVE,
+			FailSeverity:   opts.FailOnServerity(),
+			Matchers:       getMatchers(opts),
+		}
+
+		remainingMatches, ignoredMatches, err := vulnMatcher.FindMatches(packages, pkgContext)
+		if err != nil {
+			errs <- err
+			if !errors.Is(err, grypeerr.ErrAboveSeverityThreshold) {
+				return
+			}
+		}
+
+		if err := writer.Write(models.PresenterConfig{
+			Matches:          *remainingMatches,
+			IgnoredMatches:   ignoredMatches,
+			Packages:         packages,
+			Context:          pkgContext,
+			MetadataProvider: str,
+			SBOM:             s,
+			AppConfig:        opts,
+			DBStatus:         status,
+		}); err != nil {
+			errs <- err
+		}
+	}()
+
+	return readAllErrors(errs)
+}
+
+func readAllErrors(errs <-chan error) (out error) {
+	for {
+		if errs == nil {
+			break
+		}
+		err, isOpen := <-errs
+		if !isOpen {
+			errs = nil
+			continue
+		}
+		if err != nil {
+			out = multierror.Append(out, err)
+		}
+	}
+	return out
+}
+
+func applyDistroHint(pkgs []pkg.Package, context *pkg.Context, opts *options.Grype) {
+	if opts.Distro != "" {
+		log.Infof("using distro: %s", opts.Distro)
+
+		split := strings.Split(opts.Distro, ":")
+		d := split[0]
+		v := ""
+		if len(split) > 1 {
+			v = split[1]
+		}
+		context.Distro = &linux.Release{
+			PrettyName: d,
+			Name:       d,
+			ID:         d,
+			IDLike: []string{
+				d,
+			},
+			Version:   v,
+			VersionID: v,
+		}
+	}
+
+	hasOSPackage := false
+	for _, p := range pkgs {
+		switch p.Type {
+		case syftPkg.AlpmPkg, syftPkg.DebPkg, syftPkg.RpmPkg, syftPkg.KbPkg:
+			hasOSPackage = true
+		}
+	}
+
+	if context.Distro == nil && hasOSPackage {
+		log.Warnf("Unable to determine the OS distribution. This may result in missing vulnerabilities. " +
+			"You may specify a distro using: --distro <distro>:<version>")
+	}
+}
+
+func checkForAppUpdate(id clio.Identification, opts *options.Grype) {
+	if !opts.CheckForAppUpdate {
+		return
+	}
+
+	version := id.Version
+	isAvailable, newVersion, err := isUpdateAvailable(version)
+	if err != nil {
+		log.Errorf(err.Error())
+	}
+	if isAvailable {
+		log.Infof("new version of %s is available: %s (currently running: %s)", id.Name, newVersion, version)
+
+		bus.Publish(partybus.Event{
+			Type: event.CLIAppUpdateAvailable,
+			Value: parsers.UpdateCheck{
+				New:     newVersion,
+				Current: id.Version,
+			},
+		})
+	} else {
+		log.Debugf("no new %s update available", id.Name)
+	}
+}
+
+func getMatchers(opts *options.Grype) []matcher.Matcher {
+	return matcher.NewDefaultMatchers(
+		matcher.Config{
+			Java: java.MatcherConfig{
+				ExternalSearchConfig: opts.ExternalSources.ToJavaMatcherConfig(),
+				UseCPEs:              opts.Match.Java.UseCPEs,
+			},
+			Ruby:       ruby.MatcherConfig(opts.Match.Ruby),
+			Python:     python.MatcherConfig(opts.Match.Python),
+			Dotnet:     dotnet.MatcherConfig(opts.Match.Dotnet),
+			Javascript: javascript.MatcherConfig(opts.Match.Javascript),
+			Golang:     golang.MatcherConfig(opts.Match.Golang),
+			Stock:      stock.MatcherConfig(opts.Match.Stock),
+		},
+	)
+}
+
+func getProviderConfig(opts *options.Grype) pkg.ProviderConfig {
+	return pkg.ProviderConfig{
+		SyftProviderConfig: pkg.SyftProviderConfig{
+			RegistryOptions:        opts.Registry.ToOptions(),
+			Exclusions:             opts.Exclusions,
+			CatalogingOptions:      opts.Search.ToConfig(),
+			Platform:               opts.Platform,
+			Name:                   opts.Name,
+			DefaultImagePullSource: opts.DefaultImagePullSource,
+		},
+		SynthesisConfig: pkg.SynthesisConfig{
+			GenerateMissingCPEs: opts.GenerateMissingCPEs,
+		},
+	}
+}
+
+func validateDBLoad(loadErr error, status *db.Status) error {
+	if loadErr != nil {
+		return fmt.Errorf("failed to load vulnerability db: %w", loadErr)
+	}
+	if status == nil {
+		return fmt.Errorf("unable to determine the status of the vulnerability db")
+	}
+	if status.Err != nil {
+		return fmt.Errorf("db could not be loaded: %w", status.Err)
+	}
+	return nil
+}
+
+func validateRootArgs(cmd *cobra.Command, args []string) error {
+	isStdinPipeOrRedirect, err := internal.IsStdinPipeOrRedirect()
+	if err != nil {
+		log.Warnf("unable to determine if there is piped input: %+v", err)
+		isStdinPipeOrRedirect = false
+	}
+
+	if len(args) == 0 && !isStdinPipeOrRedirect {
+		// in the case that no arguments are given and there is no piped input we want to show the help text and return with a non-0 return code.
+		if err := cmd.Help(); err != nil {
+			return fmt.Errorf("unable to display help: %w", err)
+		}
+		return fmt.Errorf("an image/directory argument is required")
+	}
+
+	return cobra.MaximumNArgs(1)(cmd, args)
+}
diff --git a/internal/version/update.go b/cmd/grype/cli/commands/update.go
similarity index 77%
rename from internal/version/update.go
rename to cmd/grype/cli/commands/update.go
index 03c003f2..14c8075e 100644
--- a/internal/version/update.go
+++ b/cmd/grype/cli/commands/update.go
@@ -1,4 +1,4 @@
-package version
+package commands
 
 import (
 	"fmt"
@@ -7,7 +7,7 @@ import (
 	"strings"
 
 	hashiVersion "github.com/anchore/go-version"
-	"github.com/anchore/grype/internal"
+	"github.com/anchore/grype/cmd/grype/internal"
 )
 
 var latestAppVersionURL = struct {
@@ -15,16 +15,22 @@ var latestAppVersionURL = struct {
 	path string
 }{
 	host: "https://toolbox-data.anchore.io",
-	path: fmt.Sprintf("/%s/releases/latest/VERSION", internal.ApplicationName),
+	path: fmt.Sprintf("/%s/releases/latest/VERSION", internal.Grype),
 }
 
-func IsUpdateAvailable() (bool, string, error) {
-	currentBuildInfo := FromBuild()
-	if !currentBuildInfo.isProductionBuild() {
+func isProductionBuild(version string) bool {
+	if strings.Contains(version, "SNAPSHOT") || strings.Contains(version, internal.NotProvided) {
+		return false
+	}
+	return true
+}
+
+func isUpdateAvailable(version string) (bool, string, error) {
+	if !isProductionBuild(version) {
 		// don't allow for non-production builds to check for a version.
 		return false, "", nil
 	}
-	currentVersion, err := hashiVersion.NewVersion(currentBuildInfo.Version)
+	currentVersion, err := hashiVersion.NewVersion(version)
 	if err != nil {
 		return false, "", fmt.Errorf("failed to parse current application version: %w", err)
 	}
diff --git a/cmd/grype/cli/legacy/util.go b/cmd/grype/cli/commands/util.go
similarity index 93%
rename from cmd/grype/cli/legacy/util.go
rename to cmd/grype/cli/commands/util.go
index da07f95a..500938ac 100644
--- a/cmd/grype/cli/legacy/util.go
+++ b/cmd/grype/cli/commands/util.go
@@ -1,4 +1,4 @@
-package legacy
+package commands
 
 import (
 	"fmt"
diff --git a/cmd/grype/cli/legacy/cmd.go b/cmd/grype/cli/legacy/cmd.go
deleted file mode 100644
index 9c5520c2..00000000
--- a/cmd/grype/cli/legacy/cmd.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package legacy
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"sort"
-
-	"github.com/gookit/color"
-	logrusUpstream "github.com/sirupsen/logrus"
-	"github.com/spf13/cobra"
-	"github.com/spf13/viper"
-	"github.com/wagoodman/go-partybus"
-
-	"github.com/anchore/go-logger/adapter/logrus"
-	"github.com/anchore/grype/grype"
-	"github.com/anchore/grype/internal/config"
-	"github.com/anchore/grype/internal/log"
-	"github.com/anchore/grype/internal/version"
-	"github.com/anchore/stereoscope"
-	"github.com/anchore/syft/syft"
-)
-
-var (
-	appConfig         *config.Application
-	eventBus          *partybus.Bus
-	eventSubscription *partybus.Subscription
-)
-
-func init() {
-	cobra.OnInitialize(
-		initRootCmdConfigOptions,
-		initAppConfig,
-		initLogging,
-		logAppConfig,
-		logAppVersion,
-		initEventBus,
-	)
-}
-
-func Execute() {
-	if err := rootCmd.Execute(); err != nil {
-		_ = stderrPrintLnf(err.Error())
-		os.Exit(1)
-	}
-}
-
-func initRootCmdConfigOptions() {
-	if err := bindRootConfigOptions(rootCmd.Flags()); err != nil {
-		panic(err)
-	}
-}
-
-func initAppConfig() {
-	cfg, err := config.LoadApplicationConfig(viper.GetViper(), persistentOpts)
-	if err != nil {
-		fmt.Printf("failed to load application config: \n\t%+v\n", err)
-		os.Exit(1)
-	}
-	appConfig = cfg
-}
-
-func initLogging() {
-	cfg := logrus.Config{
-		EnableConsole: (appConfig.Log.FileLocation == "" || appConfig.CliOptions.Verbosity > 0) && !appConfig.Quiet,
-		FileLocation:  appConfig.Log.FileLocation,
-		Level:         appConfig.Log.Level,
-	}
-
-	if appConfig.Log.Structured {
-		cfg.Formatter = &logrusUpstream.JSONFormatter{
-			TimestampFormat:   "2006-01-02T15:04:05.000Z",
-			DisableTimestamp:  false,
-			DisableHTMLEscape: false,
-			PrettyPrint:       false,
-		}
-	}
-
-	logWrapper, err := logrus.New(cfg)
-	if err != nil {
-		// this is kinda circular, but we can't return an error... ¯\_(ツ)_/¯
-		// I'm going to leave this here in case we one day have a different default logger other than the "discard" logger
-		log.Error("unable to initialize logger: %+v", err)
-		return
-	}
-	grype.SetLogger(logWrapper)
-	syft.SetLogger(logWrapper.Nested("from-lib", "syft"))
-	stereoscope.SetLogger(logWrapper.Nested("from-lib", "stereoscope"))
-}
-
-func logAppConfig() {
-	log.Debugf("application config:\n%+v", color.Magenta.Sprint(appConfig.String()))
-}
-
-func logAppVersion() {
-	versionInfo := version.FromBuild()
-	log.Infof("grype version: %s", versionInfo.Version)
-
-	var fields map[string]interface{}
-	bytes, err := json.Marshal(versionInfo)
-	if err != nil {
-		return
-	}
-	err = json.Unmarshal(bytes, &fields)
-	if err != nil {
-		return
-	}
-
-	keys := make([]string, 0, len(fields))
-	for k := range fields {
-		keys = append(keys, k)
-	}
-	sort.Strings(keys)
-
-	for idx, field := range keys {
-		value := fields[field]
-		branch := "├──"
-		if idx == len(fields)-1 {
-			branch = "└──"
-		}
-		log.Debugf("  %s %s: %s", branch, field, value)
-	}
-}
-
-func initEventBus() {
-	eventBus = partybus.NewBus()
-	eventSubscription = eventBus.Subscribe()
-
-	stereoscope.SetBus(eventBus)
-	syft.SetBus(eventBus)
-	grype.SetBus(eventBus)
-}
diff --git a/cmd/grype/cli/legacy/db.go b/cmd/grype/cli/legacy/db.go
deleted file mode 100644
index 247ed7c5..00000000
--- a/cmd/grype/cli/legacy/db.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package legacy
-
-import (
-	"github.com/spf13/cobra"
-)
-
-var dbCmd = &cobra.Command{
-	Use:   "db",
-	Short: "vulnerability database operations",
-}
-
-func init() {
-	rootCmd.AddCommand(dbCmd)
-}
diff --git a/cmd/grype/cli/legacy/db_delete.go b/cmd/grype/cli/legacy/db_delete.go
deleted file mode 100644
index 4ca36db3..00000000
--- a/cmd/grype/cli/legacy/db_delete.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package legacy
-
-import (
-	"fmt"
-
-	"github.com/spf13/cobra"
-
-	"github.com/anchore/grype/grype/db"
-)
-
-var dbDeleteCmd = &cobra.Command{
-	Use:   "delete",
-	Short: "delete the vulnerability database",
-	Args:  cobra.ExactArgs(0),
-	RunE:  runDBDeleteCmd,
-}
-
-func init() {
-	dbCmd.AddCommand(dbDeleteCmd)
-}
-
-func runDBDeleteCmd(_ *cobra.Command, _ []string) error {
-	dbCurator, err := db.NewCurator(appConfig.DB.ToCuratorConfig())
-	if err != nil {
-		return err
-	}
-
-	if err := dbCurator.Delete(); err != nil {
-		return fmt.Errorf("unable to delete vulnerability database: %+v", err)
-	}
-
-	return stderrPrintLnf("Vulnerability database deleted")
-}
diff --git a/cmd/grype/cli/legacy/db_diff.go b/cmd/grype/cli/legacy/db_diff.go
deleted file mode 100644
index 180ec99e..00000000
--- a/cmd/grype/cli/legacy/db_diff.go
+++ /dev/null
@@ -1,138 +0,0 @@
-package legacy
-
-import (
-	"strings"
-
-	"github.com/spf13/cobra"
-
-	"github.com/anchore/grype/cmd/grype/internal/ui"
-	"github.com/anchore/grype/grype/db"
-	"github.com/anchore/grype/grype/differ"
-	"github.com/anchore/grype/internal/bus"
-	"github.com/anchore/grype/internal/log"
-	"github.com/anchore/stereoscope"
-)
-
-var dbDiffOutputFormat string
-
-const deleteFlag string = "delete"
-
-var dbDiffCmd = &cobra.Command{
-	Use:   "diff [flags] base_db_url target_db_url",
-	Short: "diff two DBs and display the result",
-	Args:  cobra.MaximumNArgs(2),
-	RunE:  runDBDiffCmd,
-}
-
-func init() {
-	dbDiffCmd.Flags().StringVarP(&dbDiffOutputFormat, "output", "o", "table", "format to display results (available=[table, json])")
-	dbDiffCmd.Flags().BoolP(deleteFlag, "d", false, "delete downloaded databases after diff occurs")
-
-	dbCmd.AddCommand(dbDiffCmd)
-}
-
-func startDBDiffCmd(base string, target string, deleteDatabases bool) <-chan error {
-	errs := make(chan error)
-	go func() {
-		defer close(errs)
-		defer bus.Exit()
-		d, err := differ.NewDiffer(appConfig.DB.ToCuratorConfig())
-		if err != nil {
-			errs <- err
-			return
-		}
-
-		if err := d.SetBaseDB(base); err != nil {
-			errs <- err
-			return
-		}
-
-		if err := d.SetTargetDB(target); err != nil {
-			errs <- err
-			return
-		}
-
-		diff, err := d.DiffDatabases()
-		if err != nil {
-			errs <- err
-			return
-		}
-
-		sb := &strings.Builder{}
-
-		if len(*diff) == 0 {
-			sb.WriteString("Databases are identical!\n")
-		} else {
-			err := d.Present(dbDiffOutputFormat, diff, sb)
-			if err != nil {
-				errs <- err
-			}
-		}
-
-		bus.Report(sb.String())
-
-		if deleteDatabases {
-			errs <- d.DeleteDatabases()
-		}
-	}()
-	return errs
-}
-
-func runDBDiffCmd(cmd *cobra.Command, args []string) error {
-	deleteDatabases, err := cmd.Flags().GetBool(deleteFlag)
-	if err != nil {
-		return err
-	}
-
-	var base, target string
-
-	switch len(args) {
-	case 0:
-		log.Info("base_db_url and target_db_url not provided; fetching most recent")
-		base, target, err = getDefaultURLs()
-		if err != nil {
-			return err
-		}
-	case 1:
-		log.Info("target_db_url not provided; fetching most recent")
-		base = args[0]
-		_, target, err = getDefaultURLs()
-		if err != nil {
-			return err
-		}
-	default:
-		base = args[0]
-		target = args[1]
-	}
-
-	return eventLoop(
-		startDBDiffCmd(base, target, deleteDatabases),
-		setupSignals(),
-		eventSubscription,
-		stereoscope.Cleanup,
-		ui.Select(isVerbose(), appConfig.Quiet)...,
-	)
-}
-
-func getDefaultURLs() (baseURL string, targetURL string, err error) {
-	dbCurator, err := db.NewCurator(appConfig.DB.ToCuratorConfig())
-	if err != nil {
-		return "", "", err
-	}
-
-	listing, err := dbCurator.ListingFromURL()
-	if err != nil {
-		return "", "", err
-	}
-
-	supportedSchema := dbCurator.SupportedSchema()
-	available, exists := listing.Available[supportedSchema]
-	if len(available) < 2 || !exists {
-		return "", "", stderrPrintLnf("Not enough databases available for the current schema to diff (%d)", supportedSchema)
-	}
-
-	targetURL = available[0].URL.String()
-	baseURL = available[1].URL.String()
-
-	return baseURL, targetURL, nil
-}
diff --git a/cmd/grype/cli/legacy/db_import.go b/cmd/grype/cli/legacy/db_import.go
deleted file mode 100644
index 400d19d1..00000000
--- a/cmd/grype/cli/legacy/db_import.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package legacy
-
-import (
-	"fmt"
-
-	"github.com/spf13/cobra"
-
-	"github.com/anchore/grype/grype/db"
-	"github.com/anchore/grype/internal"
-)
-
-var dbImportCmd = &cobra.Command{
-	Use:   "import FILE",
-	Short: "import a vulnerability database archive",
-	Long:  fmt.Sprintf("import a vulnerability database archive from a local FILE.\nDB archives can be obtained from %q.", internal.DBUpdateURL),
-	Args:  cobra.ExactArgs(1),
-	RunE:  runDBImportCmd,
-}
-
-func init() {
-	dbCmd.AddCommand(dbImportCmd)
-}
-
-func runDBImportCmd(_ *cobra.Command, args []string) error {
-	dbCurator, err := db.NewCurator(appConfig.DB.ToCuratorConfig())
-	if err != nil {
-		return err
-	}
-
-	if err := dbCurator.ImportFrom(args[0]); err != nil {
-		return fmt.Errorf("unable to import vulnerability database: %+v", err)
-	}
-
-	return stderrPrintLnf("Vulnerability database imported")
-}
diff --git a/cmd/grype/cli/legacy/db_status.go b/cmd/grype/cli/legacy/db_status.go
deleted file mode 100644
index 4ed655f6..00000000
--- a/cmd/grype/cli/legacy/db_status.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package legacy
-
-import (
-	"fmt"
-
-	"github.com/spf13/cobra"
-
-	"github.com/anchore/grype/grype/db"
-)
-
-var statusCmd = &cobra.Command{
-	Use:   "status",
-	Short: "display database status",
-	Args:  cobra.ExactArgs(0),
-	RunE:  runDBStatusCmd,
-}
-
-func init() {
-	dbCmd.AddCommand(statusCmd)
-}
-
-func runDBStatusCmd(_ *cobra.Command, _ []string) error {
-	dbCurator, err := db.NewCurator(appConfig.DB.ToCuratorConfig())
-	if err != nil {
-		return err
-	}
-
-	status := dbCurator.Status()
-
-	statusStr := "valid"
-	if status.Err != nil {
-		statusStr = "invalid"
-	}
-
-	fmt.Println("Location: ", status.Location)
-	fmt.Println("Built:    ", status.Built.String())
-	fmt.Println("Schema:   ", status.SchemaVersion)
-	fmt.Println("Checksum: ", status.Checksum)
-	fmt.Println("Status:   ", statusStr)
-
-	return status.Err
-}
diff --git a/cmd/grype/cli/legacy/db_update.go b/cmd/grype/cli/legacy/db_update.go
deleted file mode 100644
index 1a72ce6d..00000000
--- a/cmd/grype/cli/legacy/db_update.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package legacy
-
-import (
-	"fmt"
-
-	"github.com/spf13/cobra"
-
-	"github.com/anchore/grype/cmd/grype/internal/ui"
-	"github.com/anchore/grype/grype/db"
-	"github.com/anchore/grype/internal/bus"
-	"github.com/anchore/stereoscope"
-)
-
-var dbUpdateCmd = &cobra.Command{
-	Use:   "update",
-	Short: "download the latest vulnerability database",
-	Args:  cobra.ExactArgs(0),
-	RunE:  runDBUpdateCmd,
-}
-
-func init() {
-	dbCmd.AddCommand(dbUpdateCmd)
-}
-
-func startDBUpdateCmd() <-chan error {
-	errs := make(chan error)
-	go func() {
-		defer close(errs)
-		defer bus.Exit()
-
-		dbCurator, err := db.NewCurator(appConfig.DB.ToCuratorConfig())
-		if err != nil {
-			errs <- err
-			return
-		}
-		updated, err := dbCurator.Update()
-		if err != nil {
-			errs <- fmt.Errorf("unable to update vulnerability database: %+v", err)
-		}
-
-		result := "No vulnerability database update available\n"
-		if updated {
-			result = "Vulnerability database updated to latest version!\n"
-		}
-
-		bus.Report(result)
-	}()
-	return errs
-}
-
-func runDBUpdateCmd(_ *cobra.Command, _ []string) error {
-	return eventLoop(
-		startDBUpdateCmd(),
-		setupSignals(),
-		eventSubscription,
-		stereoscope.Cleanup,
-		ui.Select(isVerbose(), appConfig.Quiet)...,
-	)
-}
diff --git a/cmd/grype/cli/legacy/event_loop.go b/cmd/grype/cli/legacy/event_loop.go
deleted file mode 100644
index ce24d251..00000000
--- a/cmd/grype/cli/legacy/event_loop.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package legacy
-
-import (
-	"errors"
-	"fmt"
-	"os"
-
-	"github.com/hashicorp/go-multierror"
-	"github.com/wagoodman/go-partybus"
-
-	"github.com/anchore/clio"
-	"github.com/anchore/grype/grype/grypeerr"
-	"github.com/anchore/grype/internal/log"
-)
-
-// eventLoop listens to worker errors (from execution path), worker events (from a partybus subscription), and
-// signal interrupts. Is responsible for handling each event relative to a given UI an to coordinate eventing until
-// an eventual graceful exit.
-func eventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *partybus.Subscription, cleanupFn func(), uxs ...clio.UI) error { //nolint:gocognit
-	defer cleanupFn()
-	events := subscription.Events()
-	var err error
-	var ux clio.UI
-
-	if ux, err = setupUI(subscription, uxs...); err != nil {
-		return err
-	}
-
-	var retErr error
-	var forceTeardown bool
-
-	for {
-		if workerErrs == nil && events == nil {
-			break
-		}
-		select {
-		case err, isOpen := <-workerErrs:
-			if !isOpen {
-				workerErrs = nil
-				continue
-			}
-			if err != nil {
-				// if the error is not a severity threshold error, then it is unexpected and we should start to tear down the UI
-				if !errors.Is(err, grypeerr.ErrAboveSeverityThreshold) {
-					// capture the error from the worker and unsubscribe to complete a graceful shutdown
-					_ = subscription.Unsubscribe()
-					// the worker has exited, we may have been mid-handling events for the UI which should now be
-					// ignored, in which case forcing a teardown of the UI irregardless of the state is required.
-					forceTeardown = true
-				}
-				retErr = multierror.Append(retErr, err)
-			}
-		case e, isOpen := <-events:
-			if !isOpen {
-				events = nil
-				continue
-			}
-
-			if err := ux.Handle(e); err != nil {
-				if errors.Is(err, partybus.ErrUnsubscribe) {
-					events = nil
-				} else {
-					retErr = multierror.Append(retErr, err)
-					// TODO: should we unsubscribe? should we try to halt execution? or continue?
-				}
-			}
-		case <-signals:
-			// ignore further results from any event source and exit ASAP, but ensure that all cache is cleaned up.
-			// we ignore further errors since cleaning up the tmp directories will affect running catalogers that are
-			// reading/writing from/to their nested temp dirs. This is acceptable since we are bailing without result.
-
-			// TODO: potential future improvement would be to pass context into workers with a cancel function that is
-			// to the event loop. In this way we can have a more controlled shutdown even at the most nested levels
-			// of processing.
-			events = nil
-			workerErrs = nil
-			forceTeardown = true
-		}
-	}
-
-	if err := ux.Teardown(forceTeardown); err != nil {
-		retErr = multierror.Append(retErr, err)
-	}
-
-	return retErr
-}
-
-// setupUI takes one or more UIs that responds to events and takes a event bus unsubscribe function for use
-// during teardown. With the given UIs, the first UI which the ui.Setup() function does not return an error
-// will be utilized in execution. Providing a set of UIs allows for the caller to provide graceful fallbacks
-// when there are environmental problem (e.g. unable to setup a TUI with the current TTY).
-func setupUI(subscription *partybus.Subscription, uis ...clio.UI) (clio.UI, error) {
-	for _, ux := range uis {
-		if err := ux.Setup(subscription); err != nil {
-			log.Warnf("unable to setup given UI, falling back to alternative UI: %+v", err)
-			continue
-		}
-
-		return ux, nil
-	}
-	return nil, fmt.Errorf("unable to setup any UI")
-}
diff --git a/cmd/grype/cli/legacy/root.go b/cmd/grype/cli/legacy/root.go
deleted file mode 100644
index 0fb0b6a1..00000000
--- a/cmd/grype/cli/legacy/root.go
+++ /dev/null
@@ -1,510 +0,0 @@
-package legacy
-
-import (
-	"errors"
-	"fmt"
-	"os"
-	"strings"
-	"sync"
-
-	"github.com/pkg/profile"
-	"github.com/spf13/cobra"
-	"github.com/spf13/pflag"
-	"github.com/spf13/viper"
-	"github.com/wagoodman/go-partybus"
-
-	"github.com/anchore/grype/cmd/grype/internal/ui"
-	"github.com/anchore/grype/grype"
-	"github.com/anchore/grype/grype/db"
-	grypeDb "github.com/anchore/grype/grype/db/v5"
-	"github.com/anchore/grype/grype/event"
-	"github.com/anchore/grype/grype/grypeerr"
-	"github.com/anchore/grype/grype/match"
-	"github.com/anchore/grype/grype/matcher"
-	"github.com/anchore/grype/grype/matcher/dotnet"
-	"github.com/anchore/grype/grype/matcher/golang"
-	"github.com/anchore/grype/grype/matcher/java"
-	"github.com/anchore/grype/grype/matcher/javascript"
-	"github.com/anchore/grype/grype/matcher/python"
-	"github.com/anchore/grype/grype/matcher/ruby"
-	"github.com/anchore/grype/grype/matcher/stock"
-	"github.com/anchore/grype/grype/pkg"
-	"github.com/anchore/grype/grype/presenter/models"
-	"github.com/anchore/grype/grype/store"
-	"github.com/anchore/grype/grype/vulnerability"
-	"github.com/anchore/grype/internal"
-	"github.com/anchore/grype/internal/bus"
-	"github.com/anchore/grype/internal/config"
-	"github.com/anchore/grype/internal/format"
-	"github.com/anchore/grype/internal/log"
-	"github.com/anchore/grype/internal/stringutil"
-	"github.com/anchore/grype/internal/version"
-	"github.com/anchore/stereoscope"
-	"github.com/anchore/syft/syft/linux"
-	syftPkg "github.com/anchore/syft/syft/pkg"
-	"github.com/anchore/syft/syft/sbom"
-	"github.com/anchore/syft/syft/source"
-)
-
-var persistentOpts = config.CliOnlyOptions{}
-
-var ignoreNonFixedMatches = []match.IgnoreRule{
-	{FixState: string(grypeDb.NotFixedState)},
-	{FixState: string(grypeDb.WontFixState)},
-	{FixState: string(grypeDb.UnknownFixState)},
-}
-
-var ignoreFixedMatches = []match.IgnoreRule{
-	{FixState: string(grypeDb.FixedState)},
-}
-
-var (
-	rootCmd = &cobra.Command{
-		Use:   fmt.Sprintf("%s [IMAGE]", internal.ApplicationName),
-		Short: "A vulnerability scanner for container images, filesystems, and SBOMs",
-		Long: stringutil.Tprintf(`A vulnerability scanner for container images, filesystems, and SBOMs.
-
-Supports the following image sources:
-    {{.appName}} yourrepo/yourimage:tag             defaults to using images from a Docker daemon
-    {{.appName}} path/to/yourproject                a Docker tar, OCI tar, OCI directory, SIF container, or generic filesystem directory
-
-You can also explicitly specify the scheme to use:
-    {{.appName}} podman:yourrepo/yourimage:tag          explicitly use the Podman daemon
-    {{.appName}} docker:yourrepo/yourimage:tag          explicitly use the Docker daemon
-    {{.appName}} docker-archive:path/to/yourimage.tar   use a tarball from disk for archives created from "docker save"
-    {{.appName}} oci-archive:path/to/yourimage.tar      use a tarball from disk for OCI archives (from Podman or otherwise)
-    {{.appName}} oci-dir:path/to/yourimage              read directly from a path on disk for OCI layout directories (from Skopeo or otherwise)
-    {{.appName}} singularity:path/to/yourimage.sif      read directly from a Singularity Image Format (SIF) container on disk
-    {{.appName}} dir:path/to/yourproject                read directly from a path on disk (any directory)
-    {{.appName}} sbom:path/to/syft.json                 read Syft JSON from path on disk
-    {{.appName}} registry:yourrepo/yourimage:tag        pull image directly from a registry (no container runtime required)
-    {{.appName}} purl:path/to/purl/file                 read a newline separated file of purls from a path on disk
-
-You can also pipe in Syft JSON directly:
-	syft yourimage:tag -o json | {{.appName}}
-
-`, map[string]interface{}{
-			"appName": internal.ApplicationName,
-		}),
-		Args:          validateRootArgs,
-		SilenceUsage:  true,
-		SilenceErrors: true,
-		RunE: func(cmd *cobra.Command, args []string) error {
-			if appConfig.Dev.ProfileCPU {
-				defer profile.Start(profile.CPUProfile).Stop()
-			} else if appConfig.Dev.ProfileMem {
-				defer profile.Start(profile.MemProfile).Stop()
-			}
-
-			return rootExec(cmd, args)
-		},
-		ValidArgsFunction: dockerImageValidArgsFunction,
-	}
-)
-
-func init() {
-	setGlobalCliOptions()
-	setRootFlags(rootCmd.Flags())
-}
-
-func setGlobalCliOptions() {
-	// setup global CLI options (available on all CLI commands)
-	rootCmd.PersistentFlags().StringVarP(&persistentOpts.ConfigPath, "config", "c", "", "application config file")
-
-	flag := "quiet"
-	rootCmd.PersistentFlags().BoolP(
-		flag, "q", false,
-		"suppress all logging output",
-	)
-	if err := viper.BindPFlag(flag, rootCmd.PersistentFlags().Lookup(flag)); err != nil {
-		fmt.Printf("unable to bind flag '%s': %+v", flag, err)
-		os.Exit(1)
-	}
-
-	rootCmd.PersistentFlags().CountVarP(&persistentOpts.Verbosity, "verbose", "v", "increase verbosity (-v = info, -vv = debug)")
-}
-
-func setRootFlags(flags *pflag.FlagSet) {
-	flags.StringP(
-		"scope", "s", source.SquashedScope.String(),
-		fmt.Sprintf("selection of layers to analyze, options=%v", source.AllScopes),
-	)
-
-	flags.StringArrayP(
-		"output", "o", nil,
-		fmt.Sprintf("report output formatter, formats=%v, deprecated formats=%v", format.AvailableFormats, format.DeprecatedFormats),
-	)
-
-	flags.StringP(
-		"file", "", "",
-		"file to write the default report output to (default is STDOUT)",
-	)
-
-	flags.StringP(
-		"name", "", "",
-		"set the name of the target being analyzed",
-	)
-
-	flags.StringP(
-		"distro", "", "",
-		"distro to match against in the format: <distro>:<version>",
-	)
-
-	flags.BoolP(
-		"add-cpes-if-none", "", false,
-		"generate CPEs for packages with no CPE data",
-	)
-
-	flags.StringP("template", "t", "", "specify the path to a Go template file ("+
-		"requires 'template' output to be selected)")
-
-	flags.StringP(
-		"fail-on", "f", "",
-		fmt.Sprintf("set the return code to 1 if a vulnerability is found with a severity >= the given severity, options=%v", vulnerability.AllSeverities()),
-	)
-
-	flags.BoolP(
-		"only-fixed", "", false,
-		"ignore matches for vulnerabilities that are not fixed",
-	)
-
-	flags.BoolP(
-		"only-notfixed", "", false,
-		"ignore matches for vulnerabilities that are fixed",
-	)
-
-	flags.BoolP(
-		"by-cve", "", false,
-		"orient results by CVE instead of the original vulnerability ID when possible",
-	)
-
-	flags.BoolP(
-		"show-suppressed", "", false,
-		"show suppressed/ignored vulnerabilities in the output (only supported with table output format)",
-	)
-
-	flags.StringArrayP(
-		"exclude", "", nil,
-		"exclude paths from being scanned using a glob expression",
-	)
-
-	flags.StringP(
-		"platform", "", "",
-		"an optional platform specifier for container image sources (e.g. 'linux/arm64', 'linux/arm64/v8', 'arm64', 'linux')",
-	)
-}
-
-//nolint:revive
-func bindRootConfigOptions(flags *pflag.FlagSet) error {
-	if err := viper.BindPFlag("search.scope", flags.Lookup("scope")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("output", flags.Lookup("output")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("file", flags.Lookup("file")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("distro", flags.Lookup("distro")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("add-cpes-if-none", flags.Lookup("add-cpes-if-none")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("output-template-file", flags.Lookup("template")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("fail-on-severity", flags.Lookup("fail-on")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("only-fixed", flags.Lookup("only-fixed")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("only-notfixed", flags.Lookup("only-notfixed")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("by-cve", flags.Lookup("by-cve")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("show-suppressed", flags.Lookup("show-suppressed")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("exclude", flags.Lookup("exclude")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("platform", flags.Lookup("platform")); err != nil {
-		return err
-	}
-
-	if err := viper.BindPFlag("name", flags.Lookup("name")); err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func rootExec(_ *cobra.Command, args []string) error {
-	// we may not be provided an image if the user is piping in SBOM input
-	var userInput string
-	if len(args) > 0 {
-		userInput = args[0]
-	}
-
-	return eventLoop(
-		startWorker(userInput, appConfig.FailOnSeverity),
-		setupSignals(),
-		eventSubscription,
-		stereoscope.Cleanup,
-		ui.Select(isVerbose(), appConfig.Quiet)...,
-	)
-}
-
-func isVerbose() (result bool) {
-	isStdinPipeOrRedirect, err := internal.IsStdinPipeOrRedirect()
-	if err != nil {
-		// since we can't tell if there was piped input we assume that there could be to disable the ETUI
-		log.Warnf("unable to determine if there is piped input: %+v", err)
-		return true
-	}
-	// verbosity should consider if there is piped input (in which case we should not show the ETUI)
-	return appConfig.CliOptions.Verbosity > 0 || isStdinPipeOrRedirect
-}
-
-//nolint:funlen
-func startWorker(userInput string, failOnSeverity *vulnerability.Severity) <-chan error {
-	errs := make(chan error)
-	go func() {
-		defer close(errs)
-		defer bus.Exit()
-
-		writer, err := format.MakeScanResultWriter(appConfig.Outputs, appConfig.File, format.PresentationConfig{
-			TemplateFilePath: appConfig.OutputTemplateFile,
-			ShowSuppressed:   appConfig.ShowSuppressed,
-		})
-		if err != nil {
-			errs <- err
-			return
-		}
-
-		checkForAppUpdate()
-
-		var str *store.Store
-		var status *db.Status
-		var dbCloser *db.Closer
-		var packages []pkg.Package
-		var sbom *sbom.SBOM
-		var pkgContext pkg.Context
-		var wg = &sync.WaitGroup{}
-		var loadedDB, gatheredPackages bool
-
-		wg.Add(2)
-
-		go func() {
-			defer wg.Done()
-			log.Debug("loading DB")
-			str, status, dbCloser, err = grype.LoadVulnerabilityDB(appConfig.DB.ToCuratorConfig(), appConfig.DB.AutoUpdate)
-			if err = validateDBLoad(err, status); err != nil {
-				errs <- err
-				return
-			}
-			loadedDB = true
-		}()
-
-		go func() {
-			defer wg.Done()
-			log.Debugf("gathering packages")
-			// packages are grype.Package, not syft.Package
-			// the SBOM is returned for downstream formatting concerns
-			// grype uses the SBOM in combination with syft formatters to produce cycloneDX
-			// with vulnerability information appended
-			packages, pkgContext, sbom, err = pkg.Provide(userInput, getProviderConfig())
-			if err != nil {
-				errs <- fmt.Errorf("failed to catalog: %w", err)
-				return
-			}
-			gatheredPackages = true
-		}()
-
-		wg.Wait()
-		if !loadedDB || !gatheredPackages {
-			return
-		}
-
-		if dbCloser != nil {
-			defer dbCloser.Close()
-		}
-
-		if appConfig.OnlyFixed {
-			appConfig.Ignore = append(appConfig.Ignore, ignoreNonFixedMatches...)
-		}
-
-		if appConfig.OnlyNotFixed {
-			appConfig.Ignore = append(appConfig.Ignore, ignoreFixedMatches...)
-		}
-
-		applyDistroHint(packages, &pkgContext, appConfig)
-
-		vulnMatcher := grype.VulnerabilityMatcher{
-			Store:          *str,
-			IgnoreRules:    appConfig.Ignore,
-			NormalizeByCVE: appConfig.ByCVE,
-			FailSeverity:   failOnSeverity,
-			Matchers:       getMatchers(),
-		}
-
-		remainingMatches, ignoredMatches, err := vulnMatcher.FindMatches(packages, pkgContext)
-		if err != nil {
-			errs <- err
-			if !errors.Is(err, grypeerr.ErrAboveSeverityThreshold) {
-				return
-			}
-		}
-
-		if err := writer.Write(models.PresenterConfig{
-			Matches:          *remainingMatches,
-			IgnoredMatches:   ignoredMatches,
-			Packages:         packages,
-			Context:          pkgContext,
-			MetadataProvider: str,
-			SBOM:             sbom,
-			AppConfig:        appConfig,
-			DBStatus:         status,
-		}); err != nil {
-			errs <- err
-		}
-	}()
-	return errs
-}
-
-func applyDistroHint(pkgs []pkg.Package, context *pkg.Context, appConfig *config.Application) {
-	if appConfig.Distro != "" {
-		log.Infof("using distro: %s", appConfig.Distro)
-
-		split := strings.Split(appConfig.Distro, ":")
-		d := split[0]
-		v := ""
-		if len(split) > 1 {
-			v = split[1]
-		}
-		context.Distro = &linux.Release{
-			PrettyName: d,
-			Name:       d,
-			ID:         d,
-			IDLike: []string{
-				d,
-			},
-			Version:   v,
-			VersionID: v,
-		}
-	}
-
-	hasOSPackage := false
-	for _, p := range pkgs {
-		switch p.Type {
-		case syftPkg.AlpmPkg, syftPkg.DebPkg, syftPkg.RpmPkg, syftPkg.KbPkg:
-			hasOSPackage = true
-		}
-	}
-
-	if context.Distro == nil && hasOSPackage {
-		log.Warnf("Unable to determine the OS distribution. This may result in missing vulnerabilities. " +
-			"You may specify a distro using: --distro <distro>:<version>")
-	}
-}
-
-func checkForAppUpdate() {
-	if !appConfig.CheckForAppUpdate {
-		return
-	}
-
-	isAvailable, newVersion, err := version.IsUpdateAvailable()
-	if err != nil {
-		log.Errorf(err.Error())
-	}
-	if isAvailable {
-		log.Infof("new version of %s is available: %s (currently running: %s)", internal.ApplicationName, newVersion, version.FromBuild().Version)
-
-		bus.Publish(partybus.Event{
-			Type:  event.CLIAppUpdateAvailable,
-			Value: newVersion,
-		})
-	} else {
-		log.Debugf("no new %s update available", internal.ApplicationName)
-	}
-}
-
-func getMatchers() []matcher.Matcher {
-	return matcher.NewDefaultMatchers(
-		matcher.Config{
-			Java: java.MatcherConfig{
-				ExternalSearchConfig: appConfig.ExternalSources.ToJavaMatcherConfig(),
-				UseCPEs:              appConfig.Match.Java.UseCPEs,
-			},
-			Ruby:       ruby.MatcherConfig(appConfig.Match.Ruby),
-			Python:     python.MatcherConfig(appConfig.Match.Python),
-			Dotnet:     dotnet.MatcherConfig(appConfig.Match.Dotnet),
-			Javascript: javascript.MatcherConfig(appConfig.Match.Javascript),
-			Golang:     golang.MatcherConfig(appConfig.Match.Golang),
-			Stock:      stock.MatcherConfig(appConfig.Match.Stock),
-		},
-	)
-}
-
-func getProviderConfig() pkg.ProviderConfig {
-	return pkg.ProviderConfig{
-		SyftProviderConfig: pkg.SyftProviderConfig{
-			RegistryOptions:        appConfig.Registry.ToOptions(),
-			Exclusions:             appConfig.Exclusions,
-			CatalogingOptions:      appConfig.Search.ToConfig(),
-			Platform:               appConfig.Platform,
-			Name:                   appConfig.Name,
-			DefaultImagePullSource: appConfig.DefaultImagePullSource,
-		},
-		SynthesisConfig: pkg.SynthesisConfig{
-			GenerateMissingCPEs: appConfig.GenerateMissingCPEs,
-		},
-	}
-}
-
-func validateDBLoad(loadErr error, status *db.Status) error {
-	if loadErr != nil {
-		return fmt.Errorf("failed to load vulnerability db: %w", loadErr)
-	}
-	if status == nil {
-		return fmt.Errorf("unable to determine the status of the vulnerability db")
-	}
-	if status.Err != nil {
-		return fmt.Errorf("db could not be loaded: %w", status.Err)
-	}
-	return nil
-}
-
-func validateRootArgs(cmd *cobra.Command, args []string) error {
-	isStdinPipeOrRedirect, err := internal.IsStdinPipeOrRedirect()
-	if err != nil {
-		log.Warnf("unable to determine if there is piped input: %+v", err)
-		isStdinPipeOrRedirect = false
-	}
-
-	if len(args) == 0 && !isStdinPipeOrRedirect {
-		// in the case that no arguments are given and there is no piped input we want to show the help text and return with a non-0 return code.
-		if err := cmd.Help(); err != nil {
-			return fmt.Errorf("unable to display help: %w", err)
-		}
-		return fmt.Errorf("an image/directory argument is required")
-	}
-
-	return cobra.MaximumNArgs(1)(cmd, args)
-}
diff --git a/cmd/grype/cli/legacy/signals.go b/cmd/grype/cli/legacy/signals.go
deleted file mode 100644
index e7013323..00000000
--- a/cmd/grype/cli/legacy/signals.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package legacy
-
-import (
-	"os"
-	"os/signal"
-	"syscall"
-)
-
-func setupSignals() <-chan os.Signal {
-	c := make(chan os.Signal, 1) // Note: A buffered channel is recommended for this; see https://golang.org/pkg/os/signal/#Notify
-
-	interruptions := []os.Signal{
-		syscall.SIGINT,
-		syscall.SIGTERM,
-	}
-
-	signal.Notify(c, interruptions...)
-
-	return c
-}
diff --git a/cmd/grype/cli/legacy/version.go b/cmd/grype/cli/legacy/version.go
deleted file mode 100644
index ce58d161..00000000
--- a/cmd/grype/cli/legacy/version.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package legacy
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-
-	"github.com/spf13/cobra"
-
-	"github.com/anchore/grype/grype/vulnerability"
-	"github.com/anchore/grype/internal"
-	"github.com/anchore/grype/internal/version"
-)
-
-var versionOutputFormat string
-
-var versionCmd = &cobra.Command{
-	Use:   "version",
-	Short: "show the version",
-	RunE:  printVersion,
-}
-
-func init() {
-	versionCmd.Flags().StringVarP(&versionOutputFormat, "output", "o", "text", "format to display results (available=[text, json])")
-
-	rootCmd.AddCommand(versionCmd)
-}
-
-func printVersion(_ *cobra.Command, _ []string) error {
-	versionInfo := version.FromBuild()
-	switch versionOutputFormat {
-	case "text":
-		fmt.Println("Application:         ", internal.ApplicationName)
-		fmt.Println("Version:             ", versionInfo.Version)
-		fmt.Println("Syft Version:        ", versionInfo.SyftVersion)
-		fmt.Println("BuildDate:           ", versionInfo.BuildDate)
-		fmt.Println("GitCommit:           ", versionInfo.GitCommit)
-		fmt.Println("GitDescription:      ", versionInfo.GitDescription)
-		fmt.Println("Platform:            ", versionInfo.Platform)
-		fmt.Println("GoVersion:           ", versionInfo.GoVersion)
-		fmt.Println("Compiler:            ", versionInfo.Compiler)
-		fmt.Println("Supported DB Schema: ", vulnerability.SchemaVersion)
-	case "json":
-
-		enc := json.NewEncoder(os.Stdout)
-		enc.SetEscapeHTML(false)
-		enc.SetIndent("", " ")
-		err := enc.Encode(&struct {
-			version.Version
-			Application   string `json:"application"`
-			SchemaVersion int    `json:"supportedDbSchema"`
-		}{
-			Version:       versionInfo,
-			Application:   internal.ApplicationName,
-			SchemaVersion: vulnerability.SchemaVersion,
-		})
-		if err != nil {
-			return fmt.Errorf("failed to show version information: %+v", err)
-		}
-	default:
-		return fmt.Errorf("unsupported output format: %s", versionOutputFormat)
-	}
-	return nil
-}
diff --git a/internal/config/database.go b/cmd/grype/cli/options/database.go
similarity index 66%
rename from internal/config/database.go
rename to cmd/grype/cli/options/database.go
index a3854297..83fd1770 100644
--- a/internal/config/database.go
+++ b/cmd/grype/cli/options/database.go
@@ -1,17 +1,17 @@
-package config
+package options
 
 import (
 	"path"
 	"time"
 
 	"github.com/adrg/xdg"
-	"github.com/spf13/viper"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/grype/grype/db"
 	"github.com/anchore/grype/internal"
 )
 
-type database struct {
+type Database struct {
 	Dir                   string        `yaml:"cache-dir" json:"cache-dir" mapstructure:"cache-dir"`
 	UpdateURL             string        `yaml:"update-url" json:"update-url" mapstructure:"update-url"`
 	CACert                string        `yaml:"ca-cert" json:"ca-cert" mapstructure:"ca-cert"`
@@ -21,18 +21,18 @@ type database struct {
 	MaxAllowedBuiltAge    time.Duration `yaml:"max-allowed-built-age" json:"max-allowed-built-age" mapstructure:"max-allowed-built-age"`
 }
 
-func (cfg database) loadDefaultValues(v *viper.Viper) {
-	v.SetDefault("db.cache-dir", path.Join(xdg.CacheHome, internal.ApplicationName, "db"))
-	v.SetDefault("db.update-url", internal.DBUpdateURL)
-	v.SetDefault("db.ca-cert", "")
-	v.SetDefault("db.auto-update", true)
-	v.SetDefault("db.validate-by-hash-on-start", false)
-	v.SetDefault("db.validate-age", true)
-	// After this period (5 days) the db data is considered stale
-	v.SetDefault("db.max-allowed-built-age", time.Hour*24*5)
+func DefaultDatabase(id clio.Identification) Database {
+	return Database{
+		Dir:         path.Join(xdg.CacheHome, id.Name, "db"),
+		UpdateURL:   internal.DBUpdateURL,
+		AutoUpdate:  true,
+		ValidateAge: true,
+		// After this period (5 days) the db data is considered stale
+		MaxAllowedBuiltAge: time.Hour * 24 * 5,
+	}
 }
 
-func (cfg database) ToCuratorConfig() db.Config {
+func (cfg Database) ToCuratorConfig() db.Config {
 	return db.Config{
 		DBRootDir:           cfg.Dir,
 		ListingURL:          cfg.UpdateURL,
diff --git a/internal/config/datasources.go b/cmd/grype/cli/options/datasources.go
similarity index 68%
rename from internal/config/datasources.go
rename to cmd/grype/cli/options/datasources.go
index df0dc3c1..99f1543b 100644
--- a/internal/config/datasources.go
+++ b/cmd/grype/cli/options/datasources.go
@@ -1,8 +1,6 @@
-package config
+package options
 
 import (
-	"github.com/spf13/viper"
-
 	"github.com/anchore/grype/grype/matcher/java"
 )
 
@@ -12,7 +10,7 @@ const (
 
 type externalSources struct {
 	Enable bool  `yaml:"enable" json:"enable" mapstructure:"enable"`
-	Maven  maven `yaml:"maven" json:"maven" mapsructure:"maven"`
+	Maven  maven `yaml:"maven" json:"maven" mapstructure:"maven"`
 }
 
 type maven struct {
@@ -20,10 +18,13 @@ type maven struct {
 	BaseURL              string `yaml:"base-url" json:"baseUrl" mapstructure:"base-url"`
 }
 
-func (cfg externalSources) loadDefaultValues(v *viper.Viper) {
-	v.SetDefault("external-sources.enable", false)
-	v.SetDefault("external-sources.maven.search-maven-upstream", true)
-	v.SetDefault("external-sources.maven.base-url", defaultMavenBaseURL)
+func defaultExternalSources() externalSources {
+	return externalSources{
+		Maven: maven{
+			SearchUpstreamBySha1: true,
+			BaseURL:              defaultMavenBaseURL,
+		},
+	}
 }
 
 func (cfg externalSources) ToJavaMatcherConfig() java.ExternalSearchConfig {
diff --git a/cmd/grype/cli/options/grype.go b/cmd/grype/cli/options/grype.go
new file mode 100644
index 00000000..165f296c
--- /dev/null
+++ b/cmd/grype/cli/options/grype.go
@@ -0,0 +1,136 @@
+package options
+
+import (
+	"fmt"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/grype/match"
+	"github.com/anchore/grype/grype/vulnerability"
+	"github.com/anchore/grype/internal/format"
+	"github.com/anchore/syft/syft/source"
+)
+
+type Grype struct {
+	Outputs                []string           `yaml:"output" json:"output" mapstructure:"output"`                                           // -o, <presenter>=<file> the Presenter hint string to use for report formatting and the output file
+	File                   string             `yaml:"file" json:"file" mapstructure:"file"`                                                 // --file, the file to write report output to
+	Distro                 string             `yaml:"distro" json:"distro" mapstructure:"distro"`                                           // --distro, specify a distro to explicitly use
+	GenerateMissingCPEs    bool               `yaml:"add-cpes-if-none" json:"add-cpes-if-none" mapstructure:"add-cpes-if-none"`             // --add-cpes-if-none, automatically generate CPEs if they are not present in import (e.g. from a 3rd party SPDX document)
+	OutputTemplateFile     string             `yaml:"output-template-file" json:"output-template-file" mapstructure:"output-template-file"` // -t, the template file to use for formatting the final report
+	CheckForAppUpdate      bool               `yaml:"check-for-app-update" json:"check-for-app-update" mapstructure:"check-for-app-update"` // whether to check for an application update on start up or not
+	OnlyFixed              bool               `yaml:"only-fixed" json:"only-fixed" mapstructure:"only-fixed"`                               // only fail if detected vulns have a fix
+	OnlyNotFixed           bool               `yaml:"only-notfixed" json:"only-notfixed" mapstructure:"only-notfixed"`                      // only fail if detected vulns don't have a fix
+	Platform               string             `yaml:"platform" json:"platform" mapstructure:"platform"`                                     // --platform, override the target platform for a container image
+	Search                 search             `yaml:"search" json:"search" mapstructure:"search"`
+	Ignore                 []match.IgnoreRule `yaml:"ignore" json:"ignore" mapstructure:"ignore"`
+	Exclusions             []string           `yaml:"exclude" json:"exclude" mapstructure:"exclude"`
+	DB                     Database           `yaml:"db" json:"db" mapstructure:"db"`
+	ExternalSources        externalSources    `yaml:"external-sources" json:"externalSources" mapstructure:"external-sources"`
+	Match                  matchConfig        `yaml:"match" json:"match" mapstructure:"match"`
+	FailOn                 string             `yaml:"fail-on-severity" json:"fail-on-severity" mapstructure:"fail-on-severity"`
+	Registry               registry           `yaml:"registry" json:"registry" mapstructure:"registry"`
+	ShowSuppressed         bool               `yaml:"show-suppressed" json:"show-suppressed" mapstructure:"show-suppressed"`
+	ByCVE                  bool               `yaml:"by-cve" json:"by-cve" mapstructure:"by-cve"` // --by-cve, indicates if the original match vulnerability IDs should be preserved or the CVE should be used instead
+	Name                   string             `yaml:"name" json:"name" mapstructure:"name"`
+	DefaultImagePullSource string             `yaml:"default-image-pull-source" json:"default-image-pull-source" mapstructure:"default-image-pull-source"`
+}
+
+var _ interface {
+	clio.FlagAdder
+	clio.PostLoader
+} = (*Grype)(nil)
+
+func DefaultGrype(id clio.Identification) *Grype {
+	return &Grype{
+		Search:            defaultSearch(source.SquashedScope),
+		DB:                DefaultDatabase(id),
+		Match:             defaultMatchConfig(),
+		ExternalSources:   defaultExternalSources(),
+		CheckForAppUpdate: true,
+	}
+}
+
+func (o *Grype) AddFlags(flags clio.FlagSet) {
+	flags.StringVarP(&o.Search.Scope,
+		"scope", "s",
+		fmt.Sprintf("selection of layers to analyze, options=%v", source.AllScopes),
+	)
+
+	flags.StringArrayVarP(&o.Outputs,
+		"output", "o",
+		fmt.Sprintf("report output formatter, formats=%v, deprecated formats=%v", format.AvailableFormats, format.DeprecatedFormats),
+	)
+
+	flags.StringVarP(&o.File,
+		"file", "",
+		"file to write the default report output to (default is STDOUT)",
+	)
+
+	flags.StringVarP(&o.Name,
+		"name", "",
+		"set the name of the target being analyzed",
+	)
+
+	flags.StringVarP(&o.Distro,
+		"distro", "",
+		"distro to match against in the format: <distro>:<version>",
+	)
+
+	flags.BoolVarP(&o.GenerateMissingCPEs,
+		"add-cpes-if-none", "",
+		"generate CPEs for packages with no CPE data",
+	)
+
+	flags.StringVarP(&o.OutputTemplateFile,
+		"template", "t",
+		"specify the path to a Go template file (requires 'template' output to be selected)")
+
+	flags.StringVarP(&o.FailOn,
+		"fail-on", "f",
+		fmt.Sprintf("set the return code to 1 if a vulnerability is found with a severity >= the given severity, options=%v", vulnerability.AllSeverities()),
+	)
+
+	flags.BoolVarP(&o.OnlyFixed,
+		"only-fixed", "",
+		"ignore matches for vulnerabilities that are not fixed",
+	)
+
+	flags.BoolVarP(&o.OnlyNotFixed,
+		"only-notfixed", "",
+		"ignore matches for vulnerabilities that are fixed",
+	)
+
+	flags.BoolVarP(&o.ByCVE,
+		"by-cve", "",
+		"orient results by CVE instead of the original vulnerability ID when possible",
+	)
+
+	flags.BoolVarP(&o.ShowSuppressed,
+		"show-suppressed", "",
+		"show suppressed/ignored vulnerabilities in the output (only supported with table output format)",
+	)
+
+	flags.StringArrayVarP(&o.Exclusions,
+		"exclude", "",
+		"exclude paths from being scanned using a glob expression",
+	)
+
+	flags.StringVarP(&o.Platform,
+		"platform", "",
+		"an optional platform specifier for container image sources (e.g. 'linux/arm64', 'linux/arm64/v8', 'arm64', 'linux')",
+	)
+}
+
+func (o *Grype) PostLoad() error {
+	if o.FailOn != "" {
+		failOnSeverity := *o.FailOnServerity()
+		if failOnSeverity == vulnerability.UnknownSeverity {
+			return fmt.Errorf("bad --fail-on severity value '%s'", o.FailOn)
+		}
+	}
+	return nil
+}
+
+func (o Grype) FailOnServerity() *vulnerability.Severity {
+	severity := vulnerability.ParseSeverity(o.FailOn)
+	return &severity
+}
diff --git a/internal/config/match.go b/cmd/grype/cli/options/match.go
similarity index 72%
rename from internal/config/match.go
rename to cmd/grype/cli/options/match.go
index e855b77a..f3eae474 100644
--- a/internal/config/match.go
+++ b/cmd/grype/cli/options/match.go
@@ -1,8 +1,4 @@
-package config
-
-import (
-	"github.com/spf13/viper"
-)
+package options
 
 // matchConfig contains all matching-related configuration options available to the user via the application config.
 type matchConfig struct {
@@ -19,12 +15,15 @@ type matcherConfig struct {
 	UseCPEs bool `yaml:"using-cpes" json:"using-cpes" mapstructure:"using-cpes"` // if CPEs should be used during matching
 }
 
-func (cfg matchConfig) loadDefaultValues(v *viper.Viper) {
-	v.SetDefault("match.java.using-cpes", true)
-	v.SetDefault("match.dotnet.using-cpes", true)
-	v.SetDefault("match.golang.using-cpes", true)
-	v.SetDefault("match.javascript.using-cpes", false)
-	v.SetDefault("match.python.using-cpes", true)
-	v.SetDefault("match.ruby.using-cpes", true)
-	v.SetDefault("match.stock.using-cpes", true)
+func defaultMatchConfig() matchConfig {
+	useCpe := matcherConfig{UseCPEs: true}
+	return matchConfig{
+		Java:       useCpe,
+		Dotnet:     useCpe,
+		Golang:     useCpe,
+		Javascript: useCpe,
+		Python:     useCpe,
+		Ruby:       useCpe,
+		Stock:      useCpe,
+	}
 }
diff --git a/internal/config/registry.go b/cmd/grype/cli/options/registry.go
similarity index 71%
rename from internal/config/registry.go
rename to cmd/grype/cli/options/registry.go
index c8453b91..4723fb8c 100644
--- a/internal/config/registry.go
+++ b/cmd/grype/cli/options/registry.go
@@ -1,21 +1,18 @@
-package config
+package options
 
 import (
 	"os"
 
-	"github.com/spf13/viper"
-
+	"github.com/anchore/clio"
 	"github.com/anchore/stereoscope/pkg/image"
 )
 
 type RegistryCredentials struct {
 	Authority string `yaml:"authority" json:"authority" mapstructure:"authority"`
-	// IMPORTANT: do not show the username in any YAML/JSON output (sensitive information)
-	Username string `yaml:"-" json:"-" mapstructure:"username"`
-	// IMPORTANT: do not show the password in any YAML/JSON output (sensitive information)
-	Password string `yaml:"-" json:"-" mapstructure:"password"`
-	// IMPORTANT: do not show the token in any YAML/JSON output (sensitive information)
-	Token string `yaml:"-" json:"-" mapstructure:"token"`
+	// IMPORTANT: do not show the username, password, or token in any output (sensitive information)
+	Username secret `yaml:"username" json:"username" mapstructure:"username"`
+	Password secret `yaml:"password" json:"password" mapstructure:"password"`
+	Token    secret `yaml:"token" json:"token" mapstructure:"token"`
 
 	TLSCert string `yaml:"tls-cert,omitempty" json:"tls-cert,omitempty" mapstructure:"tls-cert"`
 	TLSKey  string `yaml:"tls-key,omitempty" json:"tls-key,omitempty" mapstructure:"tls-key"`
@@ -28,15 +25,9 @@ type registry struct {
 	CACert                string                `yaml:"ca-cert" json:"ca-cert" mapstructure:"ca-cert"`
 }
 
-func (cfg registry) loadDefaultValues(v *viper.Viper) {
-	v.SetDefault("registry.insecure-skip-tls-verify", false)
-	v.SetDefault("registry.insecure-use-http", false)
-	v.SetDefault("registry.auth", []RegistryCredentials{})
-	v.SetDefault("registry.ca-cert", "")
-}
+var _ clio.PostLoader = (*registry)(nil)
 
-//nolint:unparam
-func (cfg *registry) parseConfigValues() error {
+func (cfg *registry) PostLoad() error {
 	// there may be additional credentials provided by env var that should be appended to the set of credentials
 	authority, username, password, token, tlsCert, tlsKey :=
 		os.Getenv("GRYPE_REGISTRY_AUTH_AUTHORITY"),
@@ -51,9 +42,9 @@ func (cfg *registry) parseConfigValues() error {
 		cfg.Auth = append([]RegistryCredentials{
 			{
 				Authority: authority,
-				Username:  username,
-				Password:  password,
-				Token:     token,
+				Username:  secret(username),
+				Password:  secret(password),
+				Token:     secret(token),
 				TLSCert:   tlsCert,
 				TLSKey:    tlsKey,
 			},
@@ -74,9 +65,9 @@ func (cfg *registry) ToOptions() *image.RegistryOptions {
 	for i, a := range cfg.Auth {
 		auth[i] = image.RegistryCredentials{
 			Authority:  a.Authority,
-			Username:   a.Username,
-			Password:   a.Password,
-			Token:      a.Token,
+			Username:   a.Username.String(),
+			Password:   a.Password.String(),
+			Token:      a.Token.String(),
 			ClientCert: a.TLSCert,
 			ClientKey:  a.TLSKey,
 		}
diff --git a/cmd/grype/cli/options/search.go b/cmd/grype/cli/options/search.go
new file mode 100644
index 00000000..c79190af
--- /dev/null
+++ b/cmd/grype/cli/options/search.go
@@ -0,0 +1,49 @@
+package options
+
+import (
+	"fmt"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/syft/syft/pkg/cataloger"
+	"github.com/anchore/syft/syft/source"
+)
+
+type search struct {
+	Scope                    string `yaml:"scope" json:"scope" mapstructure:"scope"`
+	IncludeUnindexedArchives bool   `yaml:"unindexed-archives" json:"unindexed-archives" mapstructure:"unindexed-archives"`
+	IncludeIndexedArchives   bool   `yaml:"indexed-archives" json:"indexed-archives" mapstructure:"indexed-archives"`
+}
+
+var _ clio.PostLoader = (*search)(nil)
+
+func defaultSearch(scope source.Scope) search {
+	c := cataloger.DefaultSearchConfig()
+	return search{
+		Scope:                    scope.String(),
+		IncludeUnindexedArchives: c.IncludeUnindexedArchives,
+		IncludeIndexedArchives:   c.IncludeIndexedArchives,
+	}
+}
+
+func (cfg *search) PostLoad() error {
+	scopeOption := cfg.GetScope()
+	if scopeOption == source.UnknownScope {
+		return fmt.Errorf("bad scope value %q", cfg.Scope)
+	}
+	return nil
+}
+
+func (cfg search) GetScope() source.Scope {
+	return source.ParseScope(cfg.Scope)
+}
+
+func (cfg search) ToConfig() cataloger.Config {
+	return cataloger.Config{
+		Search: cataloger.SearchConfig{
+			IncludeIndexedArchives:   cfg.IncludeIndexedArchives,
+			IncludeUnindexedArchives: cfg.IncludeUnindexedArchives,
+			Scope:                    cfg.GetScope(),
+		},
+		ExcludeBinaryOverlapByOwnership: true,
+	}
+}
diff --git a/cmd/grype/cli/options/secret.go b/cmd/grype/cli/options/secret.go
new file mode 100644
index 00000000..dcdf2b90
--- /dev/null
+++ b/cmd/grype/cli/options/secret.go
@@ -0,0 +1,25 @@
+package options
+
+import (
+	"fmt"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/internal/redact"
+)
+
+type secret string
+
+var _ interface {
+	fmt.Stringer
+	clio.PostLoader
+} = (*secret)(nil)
+
+// PostLoad needs to use a pointer receiver, even if it's not modifying the value
+func (r *secret) PostLoad() error {
+	redact.Add(string(*r))
+	return nil
+}
+
+func (r secret) String() string {
+	return string(r)
+}
diff --git a/cmd/grype/internal/constants.go b/cmd/grype/internal/constants.go
new file mode 100644
index 00000000..54521d7e
--- /dev/null
+++ b/cmd/grype/internal/constants.go
@@ -0,0 +1,6 @@
+package internal
+
+const (
+	Grype       = "grype"
+	NotProvided = "[not provided]"
+)
diff --git a/cmd/grype/internal/ui/post_ui_event_writer.go b/cmd/grype/internal/ui/post_ui_event_writer.go
index b464ff28..f6949daa 100644
--- a/cmd/grype/internal/ui/post_ui_event_writer.go
+++ b/cmd/grype/internal/ui/post_ui_event_writer.go
@@ -12,7 +12,6 @@ import (
 	"github.com/anchore/grype/grype/event"
 	"github.com/anchore/grype/grype/event/parsers"
 	"github.com/anchore/grype/internal/log"
-	"github.com/anchore/grype/internal/version"
 )
 
 type postUIEventWriter struct {
@@ -119,17 +118,17 @@ func writeAppUpdate(writer io.Writer, events ...partybus.Event) error {
 	style := lipgloss.NewStyle().Foreground(lipgloss.Color("13")).Italic(true)
 
 	for _, e := range events {
-		newVersion, err := parsers.ParseCLIAppUpdateAvailable(e)
+		version, err := parsers.ParseCLIAppUpdateAvailable(e)
 		if err != nil {
 			log.WithFields("error", err).Warn("failed to parse app update notification")
 			continue
 		}
 
-		if newVersion == "" {
+		if version.New == "" {
 			continue
 		}
 
-		notice := fmt.Sprintf("A newer version of grype is available for download: %s (installed version is %s)", newVersion, version.FromBuild().Version)
+		notice := fmt.Sprintf("A newer version of grype is available for download: %s (installed version is %s)", version.New, version.Current)
 
 		if _, err := fmt.Fprintln(writer, style.Render(notice)); err != nil {
 			// don't let this be fatal
diff --git a/cmd/grype/internal/ui/select.go b/cmd/grype/internal/ui/select.go
deleted file mode 100644
index 5628907e..00000000
--- a/cmd/grype/internal/ui/select.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package ui
-
-import (
-	"os"
-	"runtime"
-
-	"golang.org/x/term"
-
-	"github.com/anchore/clio"
-	grypeHandler "github.com/anchore/grype/cmd/grype/cli/ui"
-	syftHandler "github.com/anchore/syft/cmd/syft/cli/ui"
-)
-
-// Select is responsible for determining the specific UI function given select user option, the current platform
-// config values, and environment status (such as a TTY being present). The first UI in the returned slice of UIs
-// is intended to be used and the UIs that follow are meant to be attempted only in a fallback posture when there
-// are environmental problems (e.g. cannot write to the terminal). A writer is provided to capture the output of
-// the final SBOM report.
-func Select(verbose, quiet bool) (uis []clio.UI) {
-	isStdoutATty := term.IsTerminal(int(os.Stdout.Fd()))
-	isStderrATty := term.IsTerminal(int(os.Stderr.Fd()))
-	notATerminal := !isStderrATty && !isStdoutATty
-
-	switch {
-	case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty:
-		uis = append(uis, None(quiet))
-	default:
-		// TODO: it may make sense in the future to pass handler options into select
-
-		uis = append(uis,
-			New(
-				verbose, quiet,
-				grypeHandler.New(grypeHandler.DefaultHandlerConfig()),
-				syftHandler.New(syftHandler.DefaultHandlerConfig()),
-			),
-		)
-	}
-
-	return uis
-}
diff --git a/cmd/grype/internal/ui/ui.go b/cmd/grype/internal/ui/ui.go
index 0e2056a2..e0563f41 100644
--- a/cmd/grype/internal/ui/ui.go
+++ b/cmd/grype/internal/ui/ui.go
@@ -33,7 +33,7 @@ type UI struct {
 	frame   tea.Model
 }
 
-func New(_, quiet bool, hs ...bubbly.EventHandler) *UI {
+func New(quiet bool, hs ...bubbly.EventHandler) *UI {
 	return &UI{
 		handler: bubbly.NewHandlerCollection(hs...),
 		frame:   frame.New(),
diff --git a/cmd/grype/main.go b/cmd/grype/main.go
index e4cb2d5f..6db79329 100644
--- a/cmd/grype/main.go
+++ b/cmd/grype/main.go
@@ -1,9 +1,34 @@
 package main
 
 import (
-	"github.com/anchore/grype/cmd/grype/cli/legacy"
+	_ "modernc.org/sqlite"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/cmd/grype/cli"
+	"github.com/anchore/grype/cmd/grype/internal"
+)
+
+// applicationName is the non-capitalized name of the application (do not change this)
+const applicationName = internal.Grype
+
+// all variables here are provided as build-time arguments, with clear default values
+var (
+	version        = internal.NotProvided
+	buildDate      = internal.NotProvided
+	gitCommit      = internal.NotProvided
+	gitDescription = internal.NotProvided
 )
 
 func main() {
-	legacy.Execute()
+	app := cli.Application(
+		clio.Identification{
+			Name:           applicationName,
+			Version:        version,
+			BuildDate:      buildDate,
+			GitCommit:      gitCommit,
+			GitDescription: gitDescription,
+		},
+	)
+
+	app.Run()
 }
diff --git a/go.mod b/go.mod
index 3d72c07f..7f2b967f 100644
--- a/go.mod
+++ b/go.mod
@@ -41,25 +41,26 @@ require (
 	github.com/mitchellh/mapstructure v1.5.0
 	github.com/olekukonko/tablewriter v0.0.5
 	github.com/owenrumney/go-sarif v1.1.1
-	github.com/pkg/profile v1.7.0
+	github.com/pkg/profile v1.7.0 // indirect
 	// pinned to pull in 386 arch fix: https://github.com/scylladb/go-set/commit/cc7b2070d91ebf40d233207b633e28f5bd8f03a5
 	github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e
 	github.com/sergi/go-diff v1.3.1
 	github.com/sirupsen/logrus v1.9.3
 	github.com/spf13/afero v1.9.5
 	github.com/spf13/cobra v1.7.0
-	github.com/spf13/pflag v1.0.5
-	github.com/spf13/viper v1.16.0
+	github.com/spf13/pflag v1.0.5 // indirect
+	github.com/spf13/viper v1.16.0 // indirect
 	github.com/stretchr/testify v1.8.4
 	github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651
 	github.com/wagoodman/go-presenter v0.0.0-20211015174752-f9c01afc824b
 	github.com/wagoodman/go-progress v0.0.0-20230301185719-21920a456ad5
 	github.com/x-cray/logrus-prefixed-formatter v0.5.2
-	golang.org/x/term v0.12.0
-	gopkg.in/yaml.v2 v2.4.0
+	golang.org/x/term v0.12.0 // indirect
 	gorm.io/gorm v1.23.10
 )
 
+require modernc.org/sqlite v1.25.0
+
 require (
 	cloud.google.com/go v0.110.0 // indirect
 	cloud.google.com/go/compute v1.19.3 // indirect
@@ -180,7 +181,6 @@ require (
 	github.com/spdx/tools-golang v0.5.3 // indirect
 	github.com/spf13/cast v1.5.1 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
-	github.com/stretchr/objx v0.5.0 // indirect
 	github.com/subosito/gotenv v1.4.2 // indirect
 	github.com/sylabs/sif/v2 v2.11.5 // indirect
 	github.com/sylabs/squashfs v0.6.1 // indirect
@@ -197,7 +197,6 @@ require (
 	github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
 	github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
 	github.com/zclconf/go-cty v1.10.0 // indirect
-	github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 // indirect
 	go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect
 	go.opencensus.io v0.24.0 // indirect
 	go.uber.org/goleak v1.2.0 // indirect
@@ -227,7 +226,6 @@ require (
 	modernc.org/mathutil v1.5.0 // indirect
 	modernc.org/memory v1.6.0 // indirect
 	modernc.org/opt v0.1.3 // indirect
-	modernc.org/sqlite v1.25.0 // indirect
 	modernc.org/strutil v1.1.3 // indirect
 	modernc.org/token v1.1.0 // indirect
 )
diff --git a/go.sum b/go.sum
index b82404de..cee59921 100644
--- a/go.sum
+++ b/go.sum
@@ -949,8 +949,6 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/zclconf/go-cty v1.10.0 h1:mp9ZXQeIcN8kAwuqorjH+Q+njbJKjLrvB2yIh4q7U+0=
 github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
-github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 h1:V+UsotZpAVvfj3X/LMoEytoLzSiP6Lg0F7wdVyu9gGg=
-github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1/go.mod h1:ly2RBz4mnz1yeuVbQA/VFwGjK3mnHGRj1JuoG336Bis=
 go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
 go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
 go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs=
diff --git a/grype/event/event.go b/grype/event/event.go
index a95f68b2..9db979ab 100644
--- a/grype/event/event.go
+++ b/grype/event/event.go
@@ -2,12 +2,10 @@ package event
 
 import (
 	"github.com/wagoodman/go-partybus"
-
-	"github.com/anchore/grype/internal"
 )
 
 const (
-	typePrefix    = internal.ApplicationName
+	typePrefix    = "grype"
 	cliTypePrefix = typePrefix + "-cli"
 
 	// Events from the grype library
diff --git a/grype/event/parsers/parsers.go b/grype/event/parsers/parsers.go
index a12b4187..9606ef1a 100644
--- a/grype/event/parsers/parsers.go
+++ b/grype/event/parsers/parsers.go
@@ -74,17 +74,22 @@ func ParseDatabaseDiffingStarted(e partybus.Event) (*monitor.DBDiff, error) {
 	return &mon, nil
 }
 
-func ParseCLIAppUpdateAvailable(e partybus.Event) (string, error) {
+type UpdateCheck struct {
+	New     string
+	Current string
+}
+
+func ParseCLIAppUpdateAvailable(e partybus.Event) (*UpdateCheck, error) {
 	if err := checkEventType(e.Type, event.CLIAppUpdateAvailable); err != nil {
-		return "", err
+		return nil, err
 	}
 
-	newVersion, ok := e.Value.(string)
+	updateCheck, ok := e.Value.(UpdateCheck)
 	if !ok {
-		return "", newPayloadErr(e.Type, "Value", e.Value)
+		return nil, newPayloadErr(e.Type, "Value", e.Value)
 	}
 
-	return newVersion, nil
+	return &updateCheck, nil
 }
 
 func ParseCLIReport(e partybus.Event) (string, string, error) {
diff --git a/grype/lib.go b/grype/lib.go
index c1c0a0d3..53a499a2 100644
--- a/grype/lib.go
+++ b/grype/lib.go
@@ -13,5 +13,5 @@ func SetLogger(l logger.Logger) {
 }
 
 func SetBus(b *partybus.Bus) {
-	bus.SetPublisher(b)
+	bus.Set(b)
 }
diff --git a/grype/presenter/cyclonedx/presenter.go b/grype/presenter/cyclonedx/presenter.go
index 54b1a957..ab276d80 100644
--- a/grype/presenter/cyclonedx/presenter.go
+++ b/grype/presenter/cyclonedx/presenter.go
@@ -5,12 +5,11 @@ import (
 
 	"github.com/CycloneDX/cyclonedx-go"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/presenter/models"
 	"github.com/anchore/grype/grype/vulnerability"
-	"github.com/anchore/grype/internal"
-	"github.com/anchore/grype/internal/version"
 	"github.com/anchore/syft/syft/formats/common/cyclonedxhelpers"
 	"github.com/anchore/syft/syft/sbom"
 	"github.com/anchore/syft/syft/source"
@@ -18,6 +17,7 @@ import (
 
 // Presenter writes a CycloneDX report from the given Matches and Scope contents
 type Presenter struct {
+	id               clio.Identification
 	results          match.Matches
 	packages         []pkg.Package
 	src              *source.Description
@@ -29,6 +29,7 @@ type Presenter struct {
 // NewPresenter is a *Presenter constructor
 func NewJSONPresenter(pb models.PresenterConfig) *Presenter {
 	return &Presenter{
+		id:               pb.ID,
 		results:          pb.Matches,
 		packages:         pb.Packages,
 		metadataProvider: pb.MetadataProvider,
@@ -41,6 +42,7 @@ func NewJSONPresenter(pb models.PresenterConfig) *Presenter {
 // NewPresenter is a *Presenter constructor
 func NewXMLPresenter(pb models.PresenterConfig) *Presenter {
 	return &Presenter{
+		id:               pb.ID,
 		results:          pb.Matches,
 		packages:         pb.Packages,
 		metadataProvider: pb.MetadataProvider,
@@ -57,12 +59,11 @@ func (pres *Presenter) Present(output io.Writer) error {
 	cyclonedxBOM := cyclonedxhelpers.ToFormatModel(*pres.sbom)
 
 	// empty the tool metadata and add grype metadata
-	versionInfo := version.FromBuild()
 	cyclonedxBOM.Metadata.Tools = &[]cyclonedx.Tool{
 		{
 			Vendor:  "anchore",
-			Name:    internal.ApplicationName,
-			Version: versionInfo.Version,
+			Name:    pres.id.Name,
+			Version: pres.id.Version,
 		},
 	}
 
diff --git a/grype/presenter/explain/explain.go b/grype/presenter/explain/explain.go
new file mode 100644
index 00000000..92038be7
--- /dev/null
+++ b/grype/presenter/explain/explain.go
@@ -0,0 +1,463 @@
+package explain
+
+import (
+	_ "embed"
+	"fmt"
+	"io"
+	"sort"
+	"strings"
+	"text/template"
+
+	"github.com/anchore/grype/grype/match"
+	"github.com/anchore/grype/grype/pkg"
+	"github.com/anchore/grype/grype/presenter/models"
+	"github.com/anchore/syft/syft/file"
+)
+
+//go:embed explain_cve.tmpl
+var explainTemplate string
+
+type VulnerabilityExplainer interface {
+	ExplainByID(IDs []string) error
+	ExplainBySeverity(severity string) error
+	ExplainAll() error
+}
+
+type ViewModel struct {
+	PrimaryVulnerability   models.VulnerabilityMetadata
+	RelatedVulnerabilities []models.VulnerabilityMetadata
+	MatchedPackages        []*explainedPackage // I think this needs a map of artifacts to explained evidence
+	URLs                   []string
+}
+
+type viewModelBuilder struct {
+	PrimaryMatch   models.Match // The match that seems to be the one we're trying to explain
+	RelatedMatches []models.Match
+	requestedIDs   []string // the vulnerability IDs the user requested explanations of
+}
+
+type Findings map[string]ViewModel
+
+type explainedPackage struct {
+	PURL                string
+	Name                string
+	Version             string
+	MatchedOnID         string
+	MatchedOnNamespace  string
+	IndirectExplanation string
+	DirectExplanation   string
+	CPEExplanation      string
+	Locations           []explainedEvidence
+	displayPriority     int // shows how early it should be displayed; direct matches first
+}
+
+type explainedEvidence struct {
+	Location     string
+	ArtifactID   string
+	ViaVulnID    string
+	ViaNamespace string
+}
+
+type vulnerabilityExplainer struct {
+	w   io.Writer
+	doc *models.Document
+}
+
+func NewVulnerabilityExplainer(w io.Writer, doc *models.Document) VulnerabilityExplainer {
+	return &vulnerabilityExplainer{
+		w:   w,
+		doc: doc,
+	}
+}
+
+var funcs = template.FuncMap{
+	"trim": strings.TrimSpace,
+}
+
+func (e *vulnerabilityExplainer) ExplainByID(ids []string) error {
+	findings, err := Doc(e.doc, ids)
+	if err != nil {
+		return err
+	}
+	t := template.Must(template.New("explanation").Funcs(funcs).Parse(explainTemplate))
+	for _, id := range ids {
+		finding, ok := findings[id]
+		if !ok {
+			continue
+		}
+		if err := t.Execute(e.w, finding); err != nil {
+			return fmt.Errorf("unable to execute template: %w", err)
+		}
+	}
+	return nil
+}
+
+func (e *vulnerabilityExplainer) ExplainBySeverity(_ string) error {
+	return fmt.Errorf("not implemented")
+}
+
+func (e *vulnerabilityExplainer) ExplainAll() error {
+	findings, err := Doc(e.doc, nil)
+	if err != nil {
+		return err
+	}
+	t := template.Must(template.New("explanation").Funcs(funcs).Parse(explainTemplate))
+
+	return t.Execute(e.w, findings)
+}
+
+func Doc(doc *models.Document, requestedIDs []string) (Findings, error) {
+	result := make(Findings)
+	builders := make(map[string]*viewModelBuilder)
+	for _, m := range doc.Matches {
+		key := m.Vulnerability.ID
+		existing, ok := builders[key]
+		if !ok {
+			existing = newBuilder(requestedIDs)
+			builders[m.Vulnerability.ID] = existing
+		}
+		existing.WithMatch(m, requestedIDs)
+	}
+	for _, m := range doc.Matches {
+		for _, related := range m.RelatedVulnerabilities {
+			key := related.ID
+			existing, ok := builders[key]
+			if !ok {
+				existing = newBuilder(requestedIDs)
+				builders[key] = existing
+			}
+			existing.WithMatch(m, requestedIDs)
+		}
+	}
+	for k, v := range builders {
+		result[k] = v.Build()
+	}
+	return result, nil
+}
+
+func newBuilder(requestedIDs []string) *viewModelBuilder {
+	return &viewModelBuilder{
+		requestedIDs: requestedIDs,
+	}
+}
+
+// WithMatch adds a match to the builder
+// accepting enough information to determine whether the match is a primary match or a related match
+func (b *viewModelBuilder) WithMatch(m models.Match, userRequestedIDs []string) {
+	if b.isPrimaryAdd(m, userRequestedIDs) {
+		// Demote the current primary match to related match
+		// if it exists
+		if b.PrimaryMatch.Vulnerability.ID != "" {
+			b.WithRelatedMatch(b.PrimaryMatch)
+		}
+		b.WithPrimaryMatch(m)
+	} else {
+		b.WithRelatedMatch(m)
+	}
+}
+
+func (b *viewModelBuilder) isPrimaryAdd(candidate models.Match, userRequestedIDs []string) bool {
+	if b.PrimaryMatch.Vulnerability.ID == "" {
+		return true
+	}
+
+	idWasRequested := false
+	for _, id := range userRequestedIDs {
+		if candidate.Vulnerability.ID == id {
+			idWasRequested = true
+			break
+		}
+	}
+	// the user didn't ask about this ID, so it's not the primary one
+	if !idWasRequested && len(userRequestedIDs) > 0 {
+		return false
+	}
+	// NVD CPEs are somewhat canonical IDs for vulnerabilities, so if the user asked about CVE-YYYY-ID
+	// type number, and we have a record from NVD, consider that the primary record.
+	if candidate.Vulnerability.Namespace == "nvd:cpe" {
+		return true
+	}
+	// Either the user didn't ask for specific IDs, or the candidate has an ID the user asked for.
+	for _, related := range b.PrimaryMatch.RelatedVulnerabilities {
+		if related.ID == candidate.Vulnerability.ID {
+			return true
+		}
+	}
+	return false
+}
+
+func (b *viewModelBuilder) WithPrimaryMatch(m models.Match) *viewModelBuilder {
+	b.PrimaryMatch = m
+	return b
+}
+
+func (b *viewModelBuilder) WithRelatedMatch(m models.Match) *viewModelBuilder {
+	b.RelatedMatches = append(b.RelatedMatches, m)
+	return b
+}
+
+func (b *viewModelBuilder) Build() ViewModel {
+	explainedPackages := groupAndSortEvidence(append(b.RelatedMatches, b.PrimaryMatch))
+
+	var relatedVulnerabilities []models.VulnerabilityMetadata
+	dedupeRelatedVulnerabilities := make(map[string]models.VulnerabilityMetadata)
+	var sortDedupedRelatedVulnerabilities []string
+	for _, m := range append(b.RelatedMatches, b.PrimaryMatch) {
+		key := fmt.Sprintf("%s:%s", m.Vulnerability.Namespace, m.Vulnerability.ID)
+		dedupeRelatedVulnerabilities[key] = m.Vulnerability.VulnerabilityMetadata
+		for _, r := range m.RelatedVulnerabilities {
+			key := fmt.Sprintf("%s:%s", r.Namespace, r.ID)
+			dedupeRelatedVulnerabilities[key] = r
+		}
+	}
+
+	// delete the primary vulnerability from the related vulnerabilities so it isn't listed twice
+	primary := b.primaryVulnerability()
+	delete(dedupeRelatedVulnerabilities, fmt.Sprintf("%s:%s", primary.Namespace, primary.ID))
+	for k := range dedupeRelatedVulnerabilities {
+		sortDedupedRelatedVulnerabilities = append(sortDedupedRelatedVulnerabilities, k)
+	}
+	sort.Strings(sortDedupedRelatedVulnerabilities)
+	for _, k := range sortDedupedRelatedVulnerabilities {
+		relatedVulnerabilities = append(relatedVulnerabilities, dedupeRelatedVulnerabilities[k])
+	}
+
+	return ViewModel{
+		PrimaryVulnerability:   primary,
+		RelatedVulnerabilities: relatedVulnerabilities,
+		MatchedPackages:        explainedPackages,
+		URLs:                   b.dedupeAndSortURLs(primary),
+	}
+}
+
+func (b *viewModelBuilder) primaryVulnerability() models.VulnerabilityMetadata {
+	var primaryVulnerability models.VulnerabilityMetadata
+	for _, m := range append(b.RelatedMatches, b.PrimaryMatch) {
+		for _, r := range append(m.RelatedVulnerabilities, m.Vulnerability.VulnerabilityMetadata) {
+			if r.ID == b.PrimaryMatch.Vulnerability.ID && r.Namespace == "nvd:cpe" {
+				primaryVulnerability = r
+			}
+		}
+	}
+	if primaryVulnerability.ID == "" {
+		primaryVulnerability = b.PrimaryMatch.Vulnerability.VulnerabilityMetadata
+	}
+	return primaryVulnerability
+}
+
+// nolint:funlen
+func groupAndSortEvidence(matches []models.Match) []*explainedPackage {
+	idsToMatchDetails := make(map[string]*explainedPackage)
+	for _, m := range matches {
+		key := m.Artifact.ID
+		var newLocations []explainedEvidence
+		for _, l := range m.Artifact.Locations {
+			newLocations = append(newLocations, explainLocation(m, l))
+		}
+		var directExplanation string
+		var indirectExplanation string
+		var cpeExplanation string
+		var matchTypePriority int
+		for i, md := range m.MatchDetails {
+			explanation := explainMatchDetail(m, i)
+			if explanation != "" {
+				switch md.Type {
+				case string(match.CPEMatch):
+					cpeExplanation = fmt.Sprintf("%s:%s %s", m.Vulnerability.Namespace, m.Vulnerability.ID, explanation)
+					matchTypePriority = 1 // cpes are a type of direct match
+				case string(match.ExactIndirectMatch):
+					indirectExplanation = fmt.Sprintf("%s:%s %s", m.Vulnerability.Namespace, m.Vulnerability.ID, explanation)
+					matchTypePriority = 0 // display indirect matches after direct matches
+				case string(match.ExactDirectMatch):
+					directExplanation = fmt.Sprintf("%s:%s %s", m.Vulnerability.Namespace, m.Vulnerability.ID, explanation)
+					matchTypePriority = 2 // exact-direct-matches are high confidence, direct matches; display them first.
+				}
+			}
+		}
+		e, ok := idsToMatchDetails[key]
+		if !ok {
+			e = &explainedPackage{
+				PURL:                m.Artifact.PURL,
+				Name:                m.Artifact.Name,
+				Version:             m.Artifact.Version,
+				MatchedOnID:         m.Vulnerability.ID,
+				MatchedOnNamespace:  m.Vulnerability.Namespace,
+				DirectExplanation:   directExplanation,
+				IndirectExplanation: indirectExplanation,
+				CPEExplanation:      cpeExplanation,
+				Locations:           newLocations,
+				displayPriority:     matchTypePriority,
+			}
+			idsToMatchDetails[key] = e
+		} else {
+			e.Locations = append(e.Locations, newLocations...)
+			if e.CPEExplanation == "" {
+				e.CPEExplanation = cpeExplanation
+			}
+			if e.IndirectExplanation == "" {
+				e.IndirectExplanation = indirectExplanation
+			}
+			e.displayPriority += matchTypePriority
+		}
+	}
+	var sortIDs []string
+	for k, v := range idsToMatchDetails {
+		sortIDs = append(sortIDs, k)
+		dedupeLocations := make(map[string]explainedEvidence)
+		for _, l := range v.Locations {
+			dedupeLocations[l.Location] = l
+		}
+		var uniqueLocations []explainedEvidence
+		for _, l := range dedupeLocations {
+			uniqueLocations = append(uniqueLocations, l)
+		}
+		sort.Slice(uniqueLocations, func(i, j int) bool {
+			if uniqueLocations[i].ViaNamespace == uniqueLocations[j].ViaNamespace {
+				return uniqueLocations[i].Location < uniqueLocations[j].Location
+			}
+			return uniqueLocations[i].ViaNamespace < uniqueLocations[j].ViaNamespace
+		})
+		v.Locations = uniqueLocations
+	}
+
+	sort.Slice(sortIDs, func(i, j int) bool {
+		return explainedPackageIsLess(idsToMatchDetails[sortIDs[i]], idsToMatchDetails[sortIDs[j]])
+	})
+	var explainedPackages []*explainedPackage
+	for _, k := range sortIDs {
+		explainedPackages = append(explainedPackages, idsToMatchDetails[k])
+	}
+	return explainedPackages
+}
+
+func explainedPackageIsLess(i, j *explainedPackage) bool {
+	if i.displayPriority != j.displayPriority {
+		return i.displayPriority > j.displayPriority
+	}
+	return i.Name < j.Name
+}
+
+func explainMatchDetail(m models.Match, index int) string {
+	if len(m.MatchDetails) <= index {
+		return ""
+	}
+	md := m.MatchDetails[index]
+	explanation := ""
+	switch md.Type {
+	case string(match.CPEMatch):
+		explanation = formatCPEExplanation(m)
+	case string(match.ExactIndirectMatch):
+		sourceName, sourceVersion := sourcePackageNameAndVersion(md)
+		explanation = fmt.Sprintf("Indirect match; this CVE is reported against %s (version %s), the %s of this %s package.", sourceName, sourceVersion, nameForUpstream(string(m.Artifact.Type)), m.Artifact.Type)
+	case string(match.ExactDirectMatch):
+		explanation = fmt.Sprintf("Direct match (package name, version, and ecosystem) against %s (version %s).", m.Artifact.Name, m.Artifact.Version)
+	}
+	return explanation
+}
+
+// dedupeAndSortURLs returns a slice of the DataSource fields, deduplicated and sorted
+// the NVD and GHSA URL are given special treatment; they return first and second if present
+// and the rest are sorted by string sort.
+func (b *viewModelBuilder) dedupeAndSortURLs(primaryVulnerability models.VulnerabilityMetadata) []string {
+	showFirst := primaryVulnerability.DataSource
+	var URLs []string
+	URLs = append(URLs, b.PrimaryMatch.Vulnerability.DataSource)
+	for _, v := range b.PrimaryMatch.RelatedVulnerabilities {
+		URLs = append(URLs, v.DataSource)
+	}
+	for _, m := range b.RelatedMatches {
+		URLs = append(URLs, m.Vulnerability.DataSource)
+		for _, v := range m.RelatedVulnerabilities {
+			URLs = append(URLs, v.DataSource)
+		}
+	}
+	var result []string
+	deduplicate := make(map[string]bool)
+	result = append(result, showFirst)
+	deduplicate[showFirst] = true
+	nvdURL := ""
+	ghsaURL := ""
+	for _, u := range URLs {
+		if strings.HasPrefix(u, "https://nvd.nist.gov/vuln/detail") {
+			nvdURL = u
+		}
+		if strings.HasPrefix(u, "https://github.com/advisories") {
+			ghsaURL = u
+		}
+	}
+	if nvdURL != "" && nvdURL != showFirst {
+		result = append(result, nvdURL)
+		deduplicate[nvdURL] = true
+	}
+	if ghsaURL != "" && ghsaURL != showFirst {
+		result = append(result, ghsaURL)
+		deduplicate[ghsaURL] = true
+	}
+
+	for _, u := range URLs {
+		if _, ok := deduplicate[u]; !ok {
+			result = append(result, u)
+			deduplicate[u] = true
+		}
+	}
+	return result
+}
+
+func explainLocation(match models.Match, location file.Coordinates) explainedEvidence {
+	path := location.RealPath
+	if match.Artifact.MetadataType == pkg.JavaMetadataType {
+		if javaMeta, ok := match.Artifact.Metadata.(map[string]any); ok {
+			if virtPath, ok := javaMeta["virtualPath"].(string); ok {
+				path = virtPath
+			}
+		}
+	}
+	return explainedEvidence{
+		Location:     path,
+		ArtifactID:   match.Artifact.ID,
+		ViaVulnID:    match.Vulnerability.ID,
+		ViaNamespace: match.Vulnerability.Namespace,
+	}
+}
+
+func formatCPEExplanation(m models.Match) string {
+	searchedBy := m.MatchDetails[0].SearchedBy
+	if mapResult, ok := searchedBy.(map[string]interface{}); ok {
+		if cpes, ok := mapResult["cpes"]; ok {
+			if cpeSlice, ok := cpes.([]interface{}); ok {
+				if len(cpeSlice) > 0 {
+					return fmt.Sprintf("CPE match on `%s`.", cpeSlice[0])
+				}
+			}
+		}
+	}
+	return ""
+}
+
+func sourcePackageNameAndVersion(md models.MatchDetails) (string, string) {
+	var name string
+	var version string
+	if mapResult, ok := md.SearchedBy.(map[string]interface{}); ok {
+		if sourcePackage, ok := mapResult["package"]; ok {
+			if sourceMap, ok := sourcePackage.(map[string]interface{}); ok {
+				if maybeName, ok := sourceMap["name"]; ok {
+					name, _ = maybeName.(string)
+				}
+				if maybeVersion, ok := sourceMap["version"]; ok {
+					version, _ = maybeVersion.(string)
+				}
+			}
+		}
+	}
+	return name, version
+}
+
+func nameForUpstream(typ string) string {
+	switch typ {
+	case "deb":
+		return "origin"
+	case "rpm":
+		return "source RPM"
+	}
+	return "upstream"
+}
diff --git a/grype/presenter/explain/explain_cve.tmpl b/grype/presenter/explain/explain_cve.tmpl
new file mode 100644
index 00000000..952b3153
--- /dev/null
+++ b/grype/presenter/explain/explain_cve.tmpl
@@ -0,0 +1,15 @@
+{{ .PrimaryVulnerability.ID }} from {{ .PrimaryVulnerability.Namespace }} ({{ .PrimaryVulnerability.Severity }})
+{{ trim .PrimaryVulnerability.Description }}{{ if .RelatedVulnerabilities }}
+Related vulnerabilities:{{ range .RelatedVulnerabilities }}
+    - {{.Namespace}} {{ .ID }} ({{ .Severity }}){{end}}{{end}}
+Matched packages:{{ range .MatchedPackages }}
+    - Package: {{ .Name }}, version: {{ .Version }}{{ if .PURL }}
+      PURL: {{ .PURL }}{{ end }}
+      Match explanation(s):{{ if .DirectExplanation }}
+          - {{ .DirectExplanation }}{{ end }}{{ if .CPEExplanation }}
+          - {{ .CPEExplanation }}{{ end }}{{ if .IndirectExplanation }}
+          - {{ .IndirectExplanation }}{{ end }}
+      Locations:{{ range .Locations }}
+          - {{ .Location }}{{ end }}{{ end }}
+URLs:{{ range .URLs }}
+    - {{ . }}{{ end }}
diff --git a/grype/presenter/json/presenter.go b/grype/presenter/json/presenter.go
index 8b26a35f..4ad1b262 100644
--- a/grype/presenter/json/presenter.go
+++ b/grype/presenter/json/presenter.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"io"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/presenter/models"
@@ -12,6 +13,7 @@ import (
 
 // Presenter is a generic struct for holding fields needed for reporting
 type Presenter struct {
+	id               clio.Identification
 	matches          match.Matches
 	ignoredMatches   []match.IgnoredMatch
 	packages         []pkg.Package
@@ -24,6 +26,7 @@ type Presenter struct {
 // NewPresenter creates a new JSON presenter
 func NewPresenter(pb models.PresenterConfig) *Presenter {
 	return &Presenter{
+		id:               pb.ID,
 		matches:          pb.Matches,
 		ignoredMatches:   pb.IgnoredMatches,
 		packages:         pb.Packages,
@@ -36,7 +39,7 @@ func NewPresenter(pb models.PresenterConfig) *Presenter {
 
 // Present creates a JSON-based reporting
 func (pres *Presenter) Present(output io.Writer) error {
-	doc, err := models.NewDocument(pres.packages, pres.context, pres.matches, pres.ignoredMatches, pres.metadataProvider,
+	doc, err := models.NewDocument(pres.id, pres.packages, pres.context, pres.matches, pres.ignoredMatches, pres.metadataProvider,
 		pres.appConfig, pres.dbStatus)
 	if err != nil {
 		return err
diff --git a/grype/presenter/models/document.go b/grype/presenter/models/document.go
index 32cc9156..f800f372 100644
--- a/grype/presenter/models/document.go
+++ b/grype/presenter/models/document.go
@@ -5,11 +5,10 @@ import (
 	"sort"
 	"time"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/vulnerability"
-	"github.com/anchore/grype/internal"
-	"github.com/anchore/grype/internal/version"
 )
 
 // Document represents the JSON document to be presented
@@ -22,7 +21,7 @@ type Document struct {
 }
 
 // NewDocument creates and populates a new Document struct, representing the populated JSON document.
-func NewDocument(packages []pkg.Package, context pkg.Context, matches match.Matches, ignoredMatches []match.IgnoredMatch, metadataProvider vulnerability.MetadataProvider, appConfig interface{}, dbStatus interface{}) (Document, error) {
+func NewDocument(id clio.Identification, packages []pkg.Package, context pkg.Context, matches match.Matches, ignoredMatches []match.IgnoredMatch, metadataProvider vulnerability.MetadataProvider, appConfig interface{}, dbStatus interface{}) (Document, error) {
 	timestamp, timestampErr := time.Now().Local().MarshalText()
 	if timestampErr != nil {
 		return Document{}, timestampErr
@@ -80,8 +79,8 @@ func NewDocument(packages []pkg.Package, context pkg.Context, matches match.Matc
 		Source:         src,
 		Distro:         newDistribution(context.Distro),
 		Descriptor: descriptor{
-			Name:                  internal.ApplicationName,
-			Version:               version.FromBuild().Version,
+			Name:                  id.Name,
+			Version:               id.Version,
 			Configuration:         appConfig,
 			VulnerabilityDBStatus: dbStatus,
 			Timestamp:             string(timestamp),
diff --git a/grype/presenter/models/match.go b/grype/presenter/models/match.go
index 6b60aaa1..333c8047 100644
--- a/grype/presenter/models/match.go
+++ b/grype/presenter/models/match.go
@@ -21,8 +21,8 @@ type Match struct {
 type MatchDetails struct {
 	Type       string      `json:"type"`
 	Matcher    string      `json:"matcher"`
-	SearchedBy interface{} `json:"searchedBy"`
-	Found      interface{} `json:"found"`
+	SearchedBy interface{} `json:"searchedBy"` // The specific attributes that were used to search (other than package name and version) --this indicates "how" the match was made.
+	Found      interface{} `json:"found"`      // The specific attributes on the vulnerability object that were matched with --this indicates "what" was matched on / within.
 }
 
 func newMatch(m match.Match, p pkg.Package, metadataProvider vulnerability.MetadataProvider) (*Match, error) {
diff --git a/grype/presenter/models/presenter_bundle.go b/grype/presenter/models/presenter_bundle.go
index dc2c384c..d9e9dfd2 100644
--- a/grype/presenter/models/presenter_bundle.go
+++ b/grype/presenter/models/presenter_bundle.go
@@ -1,6 +1,7 @@
 package models
 
 import (
+	"github.com/anchore/clio"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/vulnerability"
@@ -8,6 +9,7 @@ import (
 )
 
 type PresenterConfig struct {
+	ID               clio.Identification
 	Matches          match.Matches
 	IgnoredMatches   []match.IgnoredMatch
 	Packages         []pkg.Package
diff --git a/grype/presenter/sarif/presenter.go b/grype/presenter/sarif/presenter.go
index 95eb7c2f..f54f0514 100644
--- a/grype/presenter/sarif/presenter.go
+++ b/grype/presenter/sarif/presenter.go
@@ -8,18 +8,19 @@ import (
 
 	"github.com/owenrumney/go-sarif/sarif"
 
+	"github.com/anchore/clio"
 	v5 "github.com/anchore/grype/grype/db/v5"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/presenter/models"
 	"github.com/anchore/grype/grype/vulnerability"
-	"github.com/anchore/grype/internal/version"
 	"github.com/anchore/syft/syft/file"
 	"github.com/anchore/syft/syft/source"
 )
 
 // Presenter holds the data for generating a report and implements the presenter.Presenter interface
 type Presenter struct {
+	id               clio.Identification
 	results          match.Matches
 	packages         []pkg.Package
 	src              *source.Description
@@ -29,6 +30,7 @@ type Presenter struct {
 // NewPresenter is a *Presenter constructor
 func NewPresenter(pb models.PresenterConfig) *Presenter {
 	return &Presenter{
+		id:               pb.ID,
 		results:          pb.Matches,
 		packages:         pb.Packages,
 		metadataProvider: pb.MetadataProvider,
@@ -53,8 +55,8 @@ func (pres *Presenter) toSarifReport() (*sarif.Report, error) {
 		return nil, err
 	}
 
-	v := version.FromBuild().Version
-	if v == "[not provided]" {
+	v := pres.id.Version
+	if v == "[not provided]" || v == "" {
 		// Need a semver to pass the MS SARIF validator
 		v = "0.0.0-dev"
 	}
@@ -62,7 +64,7 @@ func (pres *Presenter) toSarifReport() (*sarif.Report, error) {
 	doc.AddRun(&sarif.Run{
 		Tool: sarif.Tool{
 			Driver: &sarif.ToolComponent{
-				Name:           "Grype",
+				Name:           pres.id.Name,
 				Version:        sp(v),
 				InformationURI: sp("https://github.com/anchore/grype"),
 				Rules:          pres.sarifRules(),
diff --git a/grype/presenter/template/presenter.go b/grype/presenter/template/presenter.go
index 30f01837..d6fe7a31 100644
--- a/grype/presenter/template/presenter.go
+++ b/grype/presenter/template/presenter.go
@@ -11,6 +11,7 @@ import (
 	"github.com/Masterminds/sprig/v3"
 	"github.com/mitchellh/go-homedir"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/presenter/models"
@@ -19,6 +20,7 @@ import (
 
 // Presenter is an implementation of presenter.Presenter that formats output according to a user-provided Go text template.
 type Presenter struct {
+	id                 clio.Identification
 	matches            match.Matches
 	ignoredMatches     []match.IgnoredMatch
 	packages           []pkg.Package
@@ -32,6 +34,7 @@ type Presenter struct {
 // NewPresenter returns a new template.Presenter.
 func NewPresenter(pb models.PresenterConfig, templateFile string) *Presenter {
 	return &Presenter{
+		id:                 pb.ID,
 		matches:            pb.Matches,
 		ignoredMatches:     pb.IgnoredMatches,
 		packages:           pb.Packages,
@@ -61,7 +64,7 @@ func (pres *Presenter) Present(output io.Writer) error {
 		return fmt.Errorf("unable to parse template: %w", err)
 	}
 
-	document, err := models.NewDocument(pres.packages, pres.context, pres.matches, pres.ignoredMatches, pres.metadataProvider,
+	document, err := models.NewDocument(pres.id, pres.packages, pres.context, pres.matches, pres.ignoredMatches, pres.metadataProvider,
 		pres.appConfig, pres.dbStatus)
 	if err != nil {
 		return err
diff --git a/internal/bus/bus.go b/internal/bus/bus.go
index d8a3d615..2ba893dc 100644
--- a/internal/bus/bus.go
+++ b/internal/bus/bus.go
@@ -4,7 +4,7 @@ import "github.com/wagoodman/go-partybus"
 
 var publisher partybus.Publisher
 
-func SetPublisher(p partybus.Publisher) {
+func Set(p partybus.Publisher) {
 	publisher = p
 }
 
diff --git a/internal/bus/helpers.go b/internal/bus/helpers.go
index 53464acc..e1b19935 100644
--- a/internal/bus/helpers.go
+++ b/internal/bus/helpers.go
@@ -4,6 +4,7 @@ import (
 	"github.com/wagoodman/go-partybus"
 
 	"github.com/anchore/grype/grype/event"
+	"github.com/anchore/grype/internal/redact"
 )
 
 func Exit() {
@@ -15,7 +16,7 @@ func Exit() {
 func Report(report string) {
 	Publish(partybus.Event{
 		Type:  event.CLIReport,
-		Value: report,
+		Value: redact.Apply(report),
 	})
 }
 
diff --git a/internal/config/application.go b/internal/config/application.go
deleted file mode 100644
index db32b369..00000000
--- a/internal/config/application.go
+++ /dev/null
@@ -1,250 +0,0 @@
-package config
-
-import (
-	"errors"
-	"fmt"
-	"path"
-	"reflect"
-	"strings"
-
-	"github.com/adrg/xdg"
-	"github.com/mitchellh/go-homedir"
-	"github.com/spf13/viper"
-	"gopkg.in/yaml.v2"
-
-	"github.com/anchore/go-logger"
-	"github.com/anchore/grype/grype/match"
-	"github.com/anchore/grype/grype/vulnerability"
-	"github.com/anchore/grype/internal"
-)
-
-var ErrApplicationConfigNotFound = fmt.Errorf("application config not found")
-
-type defaultValueLoader interface {
-	loadDefaultValues(*viper.Viper)
-}
-
-type parser interface {
-	parseConfigValues() error
-}
-
-type Application struct {
-	ConfigPath             string                  `yaml:",omitempty" json:"configPath"` // the location where the application config was read from (either from -c or discovered while loading)
-	Verbosity              uint                    `yaml:"verbosity,omitempty" json:"verbosity" mapstructure:"verbosity"`
-	Outputs                []string                `yaml:"output" json:"output" mapstructure:"output"`                                           // -o, <presenter>=<file> the Presenter hint string to use for report formatting and the output file
-	File                   string                  `yaml:"file" json:"file" mapstructure:"file"`                                                 // --file, the file to write report output to
-	Distro                 string                  `yaml:"distro" json:"distro" mapstructure:"distro"`                                           // --distro, specify a distro to explicitly use
-	GenerateMissingCPEs    bool                    `yaml:"add-cpes-if-none" json:"add-cpes-if-none" mapstructure:"add-cpes-if-none"`             // --add-cpes-if-none, automatically generate CPEs if they are not present in import (e.g. from a 3rd party SPDX document)
-	OutputTemplateFile     string                  `yaml:"output-template-file" json:"output-template-file" mapstructure:"output-template-file"` // -t, the template file to use for formatting the final report
-	Quiet                  bool                    `yaml:"quiet" json:"quiet" mapstructure:"quiet"`                                              // -q, indicates to not show any status output to stderr (ETUI or logging UI)
-	CheckForAppUpdate      bool                    `yaml:"check-for-app-update" json:"check-for-app-update" mapstructure:"check-for-app-update"` // whether to check for an application update on start up or not
-	OnlyFixed              bool                    `yaml:"only-fixed" json:"only-fixed" mapstructure:"only-fixed"`                               // only fail if detected vulns have a fix
-	OnlyNotFixed           bool                    `yaml:"only-notfixed" json:"only-notfixed" mapstructure:"only-notfixed"`                      // only fail if detected vulns don't have a fix
-	Platform               string                  `yaml:"platform" json:"platform" mapstructure:"platform"`                                     // --platform, override the target platform for a container image
-	CliOptions             CliOnlyOptions          `yaml:"-" json:"-"`
-	Search                 search                  `yaml:"search" json:"search" mapstructure:"search"`
-	Ignore                 []match.IgnoreRule      `yaml:"ignore" json:"ignore" mapstructure:"ignore"`
-	Exclusions             []string                `yaml:"exclude" json:"exclude" mapstructure:"exclude"`
-	DB                     database                `yaml:"db" json:"db" mapstructure:"db"`
-	ExternalSources        externalSources         `yaml:"external-sources" json:"externalSources" mapstructure:"external-sources"`
-	Match                  matchConfig             `yaml:"match" json:"match" mapstructure:"match"`
-	Dev                    development             `yaml:"dev" json:"dev" mapstructure:"dev"`
-	FailOn                 string                  `yaml:"fail-on-severity" json:"fail-on-severity" mapstructure:"fail-on-severity"`
-	FailOnSeverity         *vulnerability.Severity `yaml:"-" json:"-"`
-	Registry               registry                `yaml:"registry" json:"registry" mapstructure:"registry"`
-	Log                    logging                 `yaml:"log" json:"log" mapstructure:"log"`
-	ShowSuppressed         bool                    `yaml:"show-suppressed" json:"show-suppressed" mapstructure:"show-suppressed"`
-	ByCVE                  bool                    `yaml:"by-cve" json:"by-cve" mapstructure:"by-cve"` // --by-cve, indicates if the original match vulnerability IDs should be preserved or the CVE should be used instead
-	Name                   string                  `yaml:"name" json:"name" mapstructure:"name"`
-	DefaultImagePullSource string                  `yaml:"default-image-pull-source" json:"default-image-pull-source" mapstructure:"default-image-pull-source"`
-}
-
-func newApplicationConfig(v *viper.Viper, cliOpts CliOnlyOptions) *Application {
-	config := &Application{
-		CliOptions: cliOpts,
-	}
-	config.loadDefaultValues(v)
-
-	return config
-}
-
-func LoadApplicationConfig(v *viper.Viper, cliOpts CliOnlyOptions) (*Application, error) {
-	// the user may not have a config, and this is OK, we can use the default config + default cobra cli values instead
-	config := newApplicationConfig(v, cliOpts)
-
-	if err := readConfig(v, cliOpts.ConfigPath); err != nil && !errors.Is(err, ErrApplicationConfigNotFound) {
-		return nil, err
-	}
-
-	if err := v.Unmarshal(config); err != nil {
-		return nil, fmt.Errorf("unable to parse config: %w", err)
-	}
-	config.ConfigPath = v.ConfigFileUsed()
-
-	if err := config.parseConfigValues(); err != nil {
-		return nil, fmt.Errorf("invalid application config: %w", err)
-	}
-
-	return config, nil
-}
-
-// init loads the default configuration values into the viper instance (before the config values are read and parsed).
-func (cfg Application) loadDefaultValues(v *viper.Viper) {
-	// set the default values for primitive fields in this struct
-	v.SetDefault("check-for-app-update", true)
-	v.SetDefault("default-image-pull-source", "")
-
-	// for each field in the configuration struct, see if the field implements the defaultValueLoader interface and invoke it if it does
-	value := reflect.ValueOf(cfg)
-	for i := 0; i < value.NumField(); i++ {
-		// note: the defaultValueLoader method receiver is NOT a pointer receiver.
-		if loadable, ok := value.Field(i).Interface().(defaultValueLoader); ok {
-			// the field implements defaultValueLoader, call it
-			loadable.loadDefaultValues(v)
-		}
-	}
-}
-
-func (cfg *Application) parseConfigValues() error {
-	// parse application config options
-	for _, optionFn := range []func() error{
-		cfg.parseLogLevelOption,
-		cfg.parseFailOnOption,
-	} {
-		if err := optionFn(); err != nil {
-			return err
-		}
-	}
-
-	// parse nested config options
-	// for each field in the configuration struct, see if the field implements the parser interface
-	// note: the app config is a pointer, so we need to grab the elements explicitly (to traverse the address)
-	value := reflect.ValueOf(cfg).Elem()
-	for i := 0; i < value.NumField(); i++ {
-		// note: since the interface method of parser is a pointer receiver we need to get the value of the field as a pointer.
-		if parsable, ok := value.Field(i).Addr().Interface().(parser); ok {
-			// the field implements parser, call it
-			if err := parsable.parseConfigValues(); err != nil {
-				return err
-			}
-		}
-	}
-	return nil
-}
-
-func (cfg *Application) parseLogLevelOption() error {
-	switch {
-	case cfg.Quiet:
-		// TODO: this is bad: quiet option trumps all other logging options (such as to a file on disk)
-		// we should be able to quiet the console logging and leave file logging alone...
-		// ... this will be an enhancement for later
-		cfg.Log.Level = logger.DisabledLevel
-
-	case cfg.CliOptions.Verbosity > 0:
-		verb := cfg.CliOptions.Verbosity
-		cfg.Log.Level = logger.LevelFromVerbosity(verb, logger.WarnLevel, logger.InfoLevel, logger.DebugLevel, logger.TraceLevel)
-
-	case cfg.Log.Level != "":
-		var err error
-		cfg.Log.Level, err = logger.LevelFromString(string(cfg.Log.Level))
-		if err != nil {
-			return err
-		}
-
-		if logger.IsVerbose(cfg.Log.Level) {
-			cfg.Verbosity = 1
-		}
-	default:
-		cfg.Log.Level = logger.WarnLevel
-	}
-
-	return nil
-}
-
-func (cfg *Application) parseFailOnOption() error {
-	if cfg.FailOn != "" {
-		failOnSeverity := vulnerability.ParseSeverity(cfg.FailOn)
-		if failOnSeverity == vulnerability.UnknownSeverity {
-			return fmt.Errorf("bad --fail-on severity value '%s'", cfg.FailOn)
-		}
-		cfg.FailOnSeverity = &failOnSeverity
-	}
-	return nil
-}
-
-func (cfg Application) String() string {
-	// yaml is pretty human friendly (at least when compared to json)
-	appCfgStr, err := yaml.Marshal(&cfg)
-
-	if err != nil {
-		return err.Error()
-	}
-
-	return string(appCfgStr)
-}
-
-// readConfig attempts to read the given config path from disk or discover an alternate store location
-func readConfig(v *viper.Viper, configPath string) error {
-	var err error
-	v.AutomaticEnv()
-	v.SetEnvPrefix(internal.ApplicationName)
-	// allow for nested options to be specified via environment variables
-	// e.g. pod.context = APPNAME_POD_CONTEXT
-	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
-
-	// use explicitly the given user config
-	if configPath != "" {
-		v.SetConfigFile(configPath)
-		if err := v.ReadInConfig(); err != nil {
-			return fmt.Errorf("unable to read application config=%q : %w", configPath, err)
-		}
-		// don't fall through to other options if the config path was explicitly provided
-		return nil
-	}
-
-	// start searching for valid configs in order...
-
-	// 1. look for .<appname>.yaml (in the current directory)
-	v.AddConfigPath(".")
-	v.SetConfigName("." + internal.ApplicationName)
-	if err = v.ReadInConfig(); err == nil {
-		return nil
-	} else if !errors.As(err, &viper.ConfigFileNotFoundError{}) {
-		return fmt.Errorf("unable to parse config=%q: %w", v.ConfigFileUsed(), err)
-	}
-
-	// 2. look for .<appname>/config.yaml (in the current directory)
-	v.AddConfigPath("." + internal.ApplicationName)
-	v.SetConfigName("config")
-	if err = v.ReadInConfig(); err == nil {
-		return nil
-	} else if !errors.As(err, &viper.ConfigFileNotFoundError{}) {
-		return fmt.Errorf("unable to parse config=%q: %w", v.ConfigFileUsed(), err)
-	}
-
-	// 3. look for ~/.<appname>.yaml
-	home, err := homedir.Dir()
-	if err == nil {
-		v.AddConfigPath(home)
-		v.SetConfigName("." + internal.ApplicationName)
-		if err = v.ReadInConfig(); err == nil {
-			return nil
-		} else if !errors.As(err, &viper.ConfigFileNotFoundError{}) {
-			return fmt.Errorf("unable to parse config=%q: %w", v.ConfigFileUsed(), err)
-		}
-	}
-
-	// 4. look for <appname>/config.yaml in xdg locations (starting with xdg home config dir, then moving upwards)
-	v.AddConfigPath(path.Join(xdg.ConfigHome, internal.ApplicationName))
-	for _, dir := range xdg.ConfigDirs {
-		v.AddConfigPath(path.Join(dir, internal.ApplicationName))
-	}
-	v.SetConfigName("config")
-	if err = v.ReadInConfig(); err == nil {
-		return nil
-	} else if !errors.As(err, &viper.ConfigFileNotFoundError{}) {
-		return fmt.Errorf("unable to parse config=%q: %w", v.ConfigFileUsed(), err)
-	}
-
-	return ErrApplicationConfigNotFound
-}
diff --git a/internal/config/cli_only_options.go b/internal/config/cli_only_options.go
deleted file mode 100644
index 5bb0e496..00000000
--- a/internal/config/cli_only_options.go
+++ /dev/null
@@ -1,6 +0,0 @@
-package config
-
-type CliOnlyOptions struct {
-	ConfigPath string
-	Verbosity  int
-}
diff --git a/internal/config/development.go b/internal/config/development.go
deleted file mode 100644
index 4e1e8b01..00000000
--- a/internal/config/development.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package config
-
-import "github.com/spf13/viper"
-
-type development struct {
-	ProfileCPU bool `yaml:"profile-cpu" json:"profile-cpu" mapstructure:"profile-cpu"`
-	ProfileMem bool `yaml:"profile-mem" json:"profile-mem" mapstructure:"profile-mem"`
-}
-
-func (cfg development) loadDefaultValues(v *viper.Viper) {
-	v.SetDefault("dev.profile-cpu", false)
-	v.SetDefault("dev.profile-mem", false)
-}
diff --git a/internal/config/logging.go b/internal/config/logging.go
deleted file mode 100644
index bf9ffe37..00000000
--- a/internal/config/logging.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package config
-
-import (
-	"github.com/spf13/viper"
-
-	"github.com/anchore/go-logger"
-)
-
-// logging contains all logging-related configuration options available to the user via the application config.
-type logging struct {
-	Structured   bool         `yaml:"structured" json:"structured" mapstructure:"structured"` // show all log entries as JSON formatted strings
-	Level        logger.Level `yaml:"level" json:"level" mapstructure:"level"`                // the log level string hint
-	FileLocation string       `yaml:"file" json:"file" mapstructure:"file"`                   // the file path to write logs to
-}
-
-func (cfg logging) loadDefaultValues(v *viper.Viper) {
-	v.SetDefault("log.structured", false)
-	v.SetDefault("log.file", "")
-	v.SetDefault("log.level", string(logger.WarnLevel))
-}
diff --git a/internal/config/search.go b/internal/config/search.go
deleted file mode 100644
index 8894e9d3..00000000
--- a/internal/config/search.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package config
-
-import (
-	"fmt"
-
-	"github.com/spf13/viper"
-
-	"github.com/anchore/syft/syft/pkg/cataloger"
-	"github.com/anchore/syft/syft/source"
-)
-
-type search struct {
-	ScopeOpt                 source.Scope `yaml:"-" json:"-"`
-	Scope                    string       `yaml:"scope" json:"scope" mapstructure:"scope"`
-	IncludeUnindexedArchives bool         `yaml:"unindexed-archives" json:"unindexed-archives" mapstructure:"unindexed-archives"`
-	IncludeIndexedArchives   bool         `yaml:"indexed-archives" json:"indexed-archives" mapstructure:"indexed-archives"`
-}
-
-func (cfg *search) parseConfigValues() error {
-	scopeOption := source.ParseScope(cfg.Scope)
-	if scopeOption == source.UnknownScope {
-		return fmt.Errorf("bad scope value %q", cfg.Scope)
-	}
-	cfg.ScopeOpt = scopeOption
-
-	return nil
-}
-
-func (cfg search) loadDefaultValues(v *viper.Viper) {
-	c := cataloger.DefaultSearchConfig()
-	v.SetDefault("search.unindexed-archives", c.IncludeUnindexedArchives)
-	v.SetDefault("search.indexed-archives", c.IncludeIndexedArchives)
-}
-
-func (cfg search) ToConfig() cataloger.Config {
-	return cataloger.Config{
-		Search: cataloger.SearchConfig{
-			IncludeIndexedArchives:   cfg.IncludeIndexedArchives,
-			IncludeUnindexedArchives: cfg.IncludeUnindexedArchives,
-			Scope:                    cfg.ScopeOpt,
-		},
-		ExcludeBinaryOverlapByOwnership: true,
-	}
-}
diff --git a/internal/constants.go b/internal/constants.go
index ea1f6b7b..4097fcd8 100644
--- a/internal/constants.go
+++ b/internal/constants.go
@@ -1,5 +1,4 @@
 package internal
 
 // note: do not change this
-const ApplicationName = "grype"
 const DBUpdateURL = "https://toolbox-data.anchore.io/grype/databases/listing.json"
diff --git a/internal/log/log.go b/internal/log/log.go
index 62b8cda9..ff3bbb68 100644
--- a/internal/log/log.go
+++ b/internal/log/log.go
@@ -1,14 +1,27 @@
+/*
+Package log contains the singleton object and helper functions for facilitating logging within the syft library.
+*/
 package log
 
 import (
 	"github.com/anchore/go-logger"
 	"github.com/anchore/go-logger/adapter/discard"
+	"github.com/anchore/go-logger/adapter/redact"
+	red "github.com/anchore/grype/internal/redact"
 )
 
-// log is the singleton used to facilitate logging internally within syft
-var log logger.Logger = discard.New()
+// log is the singleton used to facilitate logging internally within
+var log = discard.New()
 
 func Set(l logger.Logger) {
+	// though the application will automatically have a redaction logger, library consumers may not be doing this.
+	// for this reason we additionally ensure there is a redaction logger configured for any logger passed. The
+	// source of truth for redaction values is still in the internal redact package. If the passed logger is already
+	// redacted, then this is a no-op.
+	store := red.Get()
+	if store != nil {
+		l = redact.New(l, store)
+	}
 	log = l
 }
 
diff --git a/internal/redact/redact.go b/internal/redact/redact.go
new file mode 100644
index 00000000..3bb76e26
--- /dev/null
+++ b/internal/redact/redact.go
@@ -0,0 +1,36 @@
+package redact
+
+import "github.com/anchore/go-logger/adapter/redact"
+
+var store redact.Store
+
+func Set(s redact.Store) {
+	if store != nil {
+		// if someone is trying to set a redaction store and we already have one then something is wrong. The store
+		// that we're replacing might already have values in it, so we should never replace it.
+		panic("replace existing redaction store (probably unintentional)")
+	}
+	store = s
+}
+
+func Get() redact.Store {
+	return store
+}
+
+func Add(vs ...string) {
+	if store == nil {
+		// if someone is trying to add values that should never be output and we don't have a store then something is wrong.
+		// we should never accidentally output values that should be redacted, thus we panic here.
+		panic("cannot add redactions without a store")
+	}
+	store.Add(vs...)
+}
+
+func Apply(value string) string {
+	if store == nil {
+		// if someone is trying to add values that should never be output and we don't have a store then something is wrong.
+		// we should never accidentally output values that should be redacted, thus we panic here.
+		panic("cannot apply redactions without a store")
+	}
+	return store.RedactString(value)
+}
diff --git a/internal/version/build.go b/internal/version/build.go
deleted file mode 100644
index c8f20caa..00000000
--- a/internal/version/build.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package version
-
-import (
-	"fmt"
-	"runtime"
-	"runtime/debug"
-	"strings"
-
-	"github.com/anchore/grype/internal/log"
-)
-
-const valueNotProvided = "[not provided]"
-
-// all variables here are provided as build-time arguments, with clear default values
-var version = valueNotProvided
-var gitCommit = valueNotProvided
-var gitDescription = valueNotProvided
-var buildDate = valueNotProvided
-var platform = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
-
-// Version defines the application version details (generally from build information)
-type Version struct {
-	Version        string `json:"version"`        // application semantic version
-	SyftVersion    string `json:"syftVersion"`    // the version of syft being used by grype
-	GitCommit      string `json:"gitCommit"`      // git SHA at build-time
-	GitDescription string `json:"gitDescription"` // output of 'git describe --dirty --always --tags'
-	BuildDate      string `json:"buildDate"`      // date of the build
-	GoVersion      string `json:"goVersion"`      // go runtime version at build-time
-	Compiler       string `json:"compiler"`       // compiler used at build-time
-	Platform       string `json:"platform"`       // GOOS and GOARCH at build-time
-}
-
-func (v Version) isProductionBuild() bool {
-	if strings.Contains(v.Version, "SNAPSHOT") || strings.Contains(v.Version, valueNotProvided) {
-		return false
-	}
-	return true
-}
-
-// FromBuild provides all version details
-func FromBuild() Version {
-	actualSyftVersion, err := extractSyftVersion()
-	if err != nil {
-		// TODO: parameterize error
-		log.Trace("unable to find syft version")
-		actualSyftVersion = valueNotProvided
-	}
-	return Version{
-		Version:        version,
-		SyftVersion:    actualSyftVersion,
-		GitCommit:      gitCommit,
-		GitDescription: gitDescription,
-		BuildDate:      buildDate,
-		GoVersion:      runtime.Version(),
-		Compiler:       runtime.Compiler,
-		Platform:       platform,
-	}
-}
-
-func extractSyftVersion() (string, error) {
-	buildInfo, ok := debug.ReadBuildInfo()
-	if !ok {
-		return "", fmt.Errorf("unable to find the buildinfo section of the binary (syft version is unknown)")
-	}
-
-	for _, d := range buildInfo.Deps {
-		if d.Path == "github.com/anchore/syft" {
-			return d.Version, nil
-		}
-	}
-
-	return "", fmt.Errorf("unable to find 'github.com/anchore/syft' from the buildinfo section of the binary")
-}


Test patch:
diff --git a/cmd/grype/cli/cli_test.go b/cmd/grype/cli/cli_test.go
new file mode 100644
index 00000000..8e39d4bb
--- /dev/null
+++ b/cmd/grype/cli/cli_test.go
@@ -0,0 +1,19 @@
+package cli
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/anchore/clio"
+)
+
+func Test_Command(t *testing.T) {
+	root := Command(clio.Identification{
+		Name:    "test-name",
+		Version: "test-version",
+	})
+
+	require.Equal(t, root.Name(), "test-name")
+	require.NotEmpty(t, root.Commands())
+}
diff --git a/cmd/grype/cli/legacy/root_test.go b/cmd/grype/cli/commands/root_test.go
similarity index 91%
rename from cmd/grype/cli/legacy/root_test.go
rename to cmd/grype/cli/commands/root_test.go
index 75b0433f..58a2bd3a 100644
--- a/cmd/grype/cli/legacy/root_test.go
+++ b/cmd/grype/cli/commands/root_test.go
@@ -1,17 +1,17 @@
-package legacy
+package commands
 
 import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 
+	"github.com/anchore/grype/cmd/grype/cli/options"
 	"github.com/anchore/grype/grype/pkg"
-	"github.com/anchore/grype/internal/config"
 )
 
 func Test_applyDistroHint(t *testing.T) {
 	ctx := pkg.Context{}
-	cfg := config.Application{}
+	cfg := options.Grype{}
 
 	applyDistroHint([]pkg.Package{}, &ctx, &cfg)
 	assert.Nil(t, ctx.Distro)
diff --git a/internal/version/update_test.go b/cmd/grype/cli/commands/update_test.go
similarity index 95%
rename from internal/version/update_test.go
rename to cmd/grype/cli/commands/update_test.go
index 10c34b73..eb2dedb9 100644
--- a/internal/version/update_test.go
+++ b/cmd/grype/cli/commands/update_test.go
@@ -1,4 +1,4 @@
-package version
+package commands
 
 import (
 	"net/http"
@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	hashiVersion "github.com/anchore/go-version"
+	"github.com/anchore/grype/cmd/grype/internal"
 )
 
 func TestIsUpdateAvailable(t *testing.T) {
@@ -74,7 +75,7 @@ func TestIsUpdateAvailable(t *testing.T) {
 		},
 		{
 			name:          "NoBuildVersion",
-			buildVersion:  valueNotProvided,
+			buildVersion:  internal.NotProvided,
 			latestVersion: "1.0.0",
 			code:          200,
 			isAvailable:   false,
@@ -96,7 +97,7 @@ func TestIsUpdateAvailable(t *testing.T) {
 		t.Run(test.name, func(t *testing.T) {
 			// setup mocks
 			// local...
-			version = test.buildVersion
+			version := test.buildVersion
 			// remote...
 			handler := http.NewServeMux()
 			handler.HandleFunc(latestAppVersionURL.path, func(w http.ResponseWriter, r *http.Request) {
@@ -107,7 +108,7 @@ func TestIsUpdateAvailable(t *testing.T) {
 			latestAppVersionURL.host = mockSrv.URL
 			defer mockSrv.Close()
 
-			isAvailable, newVersion, err := IsUpdateAvailable()
+			isAvailable, newVersion, err := isUpdateAvailable(version)
 			if err != nil && !test.err {
 				t.Fatalf("got error but expected none: %+v", err)
 			} else if err == nil && test.err {
diff --git a/cmd/grype/cli/legacy/event_loop_test.go b/cmd/grype/cli/legacy/event_loop_test.go
deleted file mode 100644
index 489065d3..00000000
--- a/cmd/grype/cli/legacy/event_loop_test.go
+++ /dev/null
@@ -1,459 +0,0 @@
-package legacy
-
-import (
-	"fmt"
-	"os"
-	"syscall"
-	"testing"
-	"time"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/mock"
-	"github.com/wagoodman/go-partybus"
-
-	"github.com/anchore/clio"
-	"github.com/anchore/grype/grype/event"
-)
-
-var _ clio.UI = (*uiMock)(nil)
-
-type uiMock struct {
-	t            *testing.T
-	finalEvent   partybus.Event
-	subscription partybus.Unsubscribable
-	mock.Mock
-}
-
-func (u *uiMock) Setup(unsubscribe partybus.Unsubscribable) error {
-	u.t.Helper()
-	u.t.Logf("UI Setup called")
-	u.subscription = unsubscribe
-	return u.Called(unsubscribe.Unsubscribe).Error(0)
-}
-
-func (u *uiMock) Handle(event partybus.Event) error {
-	u.t.Helper()
-	u.t.Logf("UI Handle called: %+v", event.Type)
-	if event == u.finalEvent {
-		assert.NoError(u.t, u.subscription.Unsubscribe())
-	}
-	return u.Called(event).Error(0)
-}
-
-func (u *uiMock) Teardown(_ bool) error {
-	u.t.Helper()
-	u.t.Logf("UI Teardown called")
-	return u.Called().Error(0)
-}
-
-func Test_EventLoop_gracefulExit(t *testing.T) {
-	test := func(t *testing.T) {
-
-		testBus := partybus.NewBus()
-		subscription := testBus.Subscribe()
-		t.Cleanup(testBus.Close)
-
-		finalEvent := partybus.Event{
-			Type: event.CLIExit,
-		}
-
-		worker := func() <-chan error {
-			ret := make(chan error)
-			go func() {
-				t.Log("worker running")
-				// send an empty item (which is ignored) ensuring we've entered the select statement,
-				// then close (a partial shutdown).
-				ret <- nil
-				t.Log("worker sent nothing")
-				close(ret)
-				t.Log("worker closed")
-				// do the other half of the shutdown
-				testBus.Publish(finalEvent)
-				t.Log("worker published final event")
-			}()
-			return ret
-		}
-
-		signaler := func() <-chan os.Signal {
-			return nil
-		}
-
-		ux := &uiMock{
-			t:          t,
-			finalEvent: finalEvent,
-		}
-
-		// ensure the mock sees at least the final event
-		ux.On("Handle", finalEvent).Return(nil)
-		// ensure the mock sees basic setup/teardown events
-		ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
-		ux.On("Teardown").Return(nil)
-
-		var cleanupCalled bool
-		cleanupFn := func() {
-			t.Log("cleanup called")
-			cleanupCalled = true
-		}
-
-		assert.NoError(t,
-			eventLoop(
-				worker(),
-				signaler(),
-				subscription,
-				cleanupFn,
-				ux,
-			),
-		)
-
-		assert.True(t, cleanupCalled, "cleanup function not called")
-		ux.AssertExpectations(t)
-	}
-
-	// if there is a bug, then there is a risk of the event loop never returning
-	testWithTimeout(t, 5*time.Second, test)
-}
-
-func Test_EventLoop_workerError(t *testing.T) {
-	test := func(t *testing.T) {
-
-		testBus := partybus.NewBus()
-		subscription := testBus.Subscribe()
-		t.Cleanup(testBus.Close)
-
-		workerErr := fmt.Errorf("worker error")
-
-		worker := func() <-chan error {
-			ret := make(chan error)
-			go func() {
-				t.Log("worker running")
-				// send an empty item (which is ignored) ensuring we've entered the select statement,
-				// then close (a partial shutdown).
-				ret <- nil
-				t.Log("worker sent nothing")
-				ret <- workerErr
-				t.Log("worker sent error")
-				close(ret)
-				t.Log("worker closed")
-				// note: NO final event is fired
-			}()
-			return ret
-		}
-
-		signaler := func() <-chan os.Signal {
-			return nil
-		}
-
-		ux := &uiMock{
-			t: t,
-		}
-
-		// ensure the mock sees basic setup/teardown events
-		ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
-		ux.On("Teardown").Return(nil)
-
-		var cleanupCalled bool
-		cleanupFn := func() {
-			t.Log("cleanup called")
-			cleanupCalled = true
-		}
-
-		// ensure we see an error returned
-		assert.ErrorIs(t,
-			eventLoop(
-				worker(),
-				signaler(),
-				subscription,
-				cleanupFn,
-				ux,
-			),
-			workerErr,
-			"should have seen a worker error, but did not",
-		)
-
-		assert.True(t, cleanupCalled, "cleanup function not called")
-		ux.AssertExpectations(t)
-	}
-
-	// if there is a bug, then there is a risk of the event loop never returning
-	testWithTimeout(t, 5*time.Second, test)
-}
-
-func Test_EventLoop_unsubscribeError(t *testing.T) {
-	test := func(t *testing.T) {
-
-		testBus := partybus.NewBus()
-		subscription := testBus.Subscribe()
-		t.Cleanup(testBus.Close)
-
-		finalEvent := partybus.Event{
-			Type: event.CLIExit,
-		}
-
-		worker := func() <-chan error {
-			ret := make(chan error)
-			go func() {
-				t.Log("worker running")
-				// send an empty item (which is ignored) ensuring we've entered the select statement,
-				// then close (a partial shutdown).
-				ret <- nil
-				t.Log("worker sent nothing")
-				close(ret)
-				t.Log("worker closed")
-				// do the other half of the shutdown
-				testBus.Publish(finalEvent)
-				t.Log("worker published final event")
-			}()
-			return ret
-		}
-
-		signaler := func() <-chan os.Signal {
-			return nil
-		}
-
-		ux := &uiMock{
-			t:          t,
-			finalEvent: finalEvent,
-		}
-
-		// ensure the mock sees at least the final event... note the unsubscribe error here
-		ux.On("Handle", finalEvent).Return(partybus.ErrUnsubscribe)
-		// ensure the mock sees basic setup/teardown events
-		ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
-		ux.On("Teardown").Return(nil)
-
-		var cleanupCalled bool
-		cleanupFn := func() {
-			t.Log("cleanup called")
-			cleanupCalled = true
-		}
-
-		// unsubscribe errors should be handled and ignored, not propagated. We are additionally asserting that
-		// this case is handled as a controlled shutdown (this test should not timeout)
-		assert.NoError(t,
-			eventLoop(
-				worker(),
-				signaler(),
-				subscription,
-				cleanupFn,
-				ux,
-			),
-		)
-
-		assert.True(t, cleanupCalled, "cleanup function not called")
-		ux.AssertExpectations(t)
-	}
-
-	// if there is a bug, then there is a risk of the event loop never returning
-	testWithTimeout(t, 5*time.Second, test)
-}
-
-func Test_EventLoop_handlerError(t *testing.T) {
-	test := func(t *testing.T) {
-
-		testBus := partybus.NewBus()
-		subscription := testBus.Subscribe()
-		t.Cleanup(testBus.Close)
-
-		finalEvent := partybus.Event{
-			Type:  event.CLIExit,
-			Error: fmt.Errorf("an exit error occured"),
-		}
-
-		worker := func() <-chan error {
-			ret := make(chan error)
-			go func() {
-				t.Log("worker running")
-				// send an empty item (which is ignored) ensuring we've entered the select statement,
-				// then close (a partial shutdown).
-				ret <- nil
-				t.Log("worker sent nothing")
-				close(ret)
-				t.Log("worker closed")
-				// do the other half of the shutdown
-				testBus.Publish(finalEvent)
-				t.Log("worker published final event")
-			}()
-			return ret
-		}
-
-		signaler := func() <-chan os.Signal {
-			return nil
-		}
-
-		ux := &uiMock{
-			t:          t,
-			finalEvent: finalEvent,
-		}
-
-		// ensure the mock sees at least the final event... note the event error is propagated
-		ux.On("Handle", finalEvent).Return(finalEvent.Error)
-		// ensure the mock sees basic setup/teardown events
-		ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
-		ux.On("Teardown").Return(nil)
-
-		var cleanupCalled bool
-		cleanupFn := func() {
-			t.Log("cleanup called")
-			cleanupCalled = true
-		}
-
-		// handle errors SHOULD propagate the event loop. We are additionally asserting that this case is
-		// handled as a controlled shutdown (this test should not timeout)
-		assert.ErrorIs(t,
-			eventLoop(
-				worker(),
-				signaler(),
-				subscription,
-				cleanupFn,
-				ux,
-			),
-			finalEvent.Error,
-			"should have seen a event error, but did not",
-		)
-
-		assert.True(t, cleanupCalled, "cleanup function not called")
-		ux.AssertExpectations(t)
-	}
-
-	// if there is a bug, then there is a risk of the event loop never returning
-	testWithTimeout(t, 5*time.Second, test)
-}
-
-func Test_EventLoop_signalsStopExecution(t *testing.T) {
-	test := func(t *testing.T) {
-
-		testBus := partybus.NewBus()
-		subscription := testBus.Subscribe()
-		t.Cleanup(testBus.Close)
-
-		worker := func() <-chan error {
-			// the worker will never return work and the event loop will always be waiting...
-			return make(chan error)
-		}
-
-		signaler := func() <-chan os.Signal {
-			ret := make(chan os.Signal)
-			go func() {
-				ret <- syscall.SIGINT
-				// note: we do NOT close the channel to ensure the event loop does not depend on that behavior to exit
-			}()
-			return ret
-		}
-
-		ux := &uiMock{
-			t: t,
-		}
-
-		// ensure the mock sees basic setup/teardown events
-		ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
-		ux.On("Teardown").Return(nil)
-
-		var cleanupCalled bool
-		cleanupFn := func() {
-			t.Log("cleanup called")
-			cleanupCalled = true
-		}
-
-		assert.NoError(t,
-			eventLoop(
-				worker(),
-				signaler(),
-				subscription,
-				cleanupFn,
-				ux,
-			),
-		)
-
-		assert.True(t, cleanupCalled, "cleanup function not called")
-		ux.AssertExpectations(t)
-	}
-
-	// if there is a bug, then there is a risk of the event loop never returning
-	testWithTimeout(t, 5*time.Second, test)
-}
-
-func Test_EventLoop_uiTeardownError(t *testing.T) {
-	test := func(t *testing.T) {
-
-		testBus := partybus.NewBus()
-		subscription := testBus.Subscribe()
-		t.Cleanup(testBus.Close)
-
-		finalEvent := partybus.Event{
-			Type: event.CLIExit,
-		}
-
-		worker := func() <-chan error {
-			ret := make(chan error)
-			go func() {
-				t.Log("worker running")
-				// send an empty item (which is ignored) ensuring we've entered the select statement,
-				// then close (a partial shutdown).
-				ret <- nil
-				t.Log("worker sent nothing")
-				close(ret)
-				t.Log("worker closed")
-				// do the other half of the shutdown
-				testBus.Publish(finalEvent)
-				t.Log("worker published final event")
-			}()
-			return ret
-		}
-
-		signaler := func() <-chan os.Signal {
-			return nil
-		}
-
-		ux := &uiMock{
-			t:          t,
-			finalEvent: finalEvent,
-		}
-
-		teardownError := fmt.Errorf("sorry, dave, the UI doesn't want to be torn down")
-
-		// ensure the mock sees at least the final event... note the event error is propagated
-		ux.On("Handle", finalEvent).Return(nil)
-		// ensure the mock sees basic setup/teardown events
-		ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil)
-		ux.On("Teardown").Return(teardownError)
-
-		var cleanupCalled bool
-		cleanupFn := func() {
-			t.Log("cleanup called")
-			cleanupCalled = true
-		}
-
-		// ensure we see an error returned
-		assert.ErrorIs(t,
-			eventLoop(
-				worker(),
-				signaler(),
-				subscription,
-				cleanupFn,
-				ux,
-			),
-			teardownError,
-			"should have seen a UI teardown error, but did not",
-		)
-
-		assert.True(t, cleanupCalled, "cleanup function not called")
-		ux.AssertExpectations(t)
-	}
-
-	// if there is a bug, then there is a risk of the event loop never returning
-	testWithTimeout(t, 5*time.Second, test)
-}
-
-func testWithTimeout(t *testing.T, timeout time.Duration, test func(*testing.T)) {
-	done := make(chan bool)
-	go func() {
-		test(t)
-		done <- true
-	}()
-
-	select {
-	case <-time.After(timeout):
-		t.Fatal("test timed out")
-	case <-done:
-	}
-}
diff --git a/internal/config/registry_test.go b/cmd/grype/cli/options/registry_test.go
similarity index 99%
rename from internal/config/registry_test.go
rename to cmd/grype/cli/options/registry_test.go
index 034384e6..4979fcbc 100644
--- a/internal/config/registry_test.go
+++ b/cmd/grype/cli/options/registry_test.go
@@ -1,4 +1,4 @@
-package config
+package options
 
 import (
 	"fmt"
diff --git a/cmd/grype/internal/ui/post_ui_event_writer_test.go b/cmd/grype/internal/ui/post_ui_event_writer_test.go
index 03e2b19d..329ce2b7 100644
--- a/cmd/grype/internal/ui/post_ui_event_writer_test.go
+++ b/cmd/grype/internal/ui/post_ui_event_writer_test.go
@@ -9,6 +9,7 @@ import (
 	"github.com/wagoodman/go-partybus"
 
 	"github.com/anchore/grype/grype/event"
+	"github.com/anchore/grype/grype/event/parsers"
 )
 
 func Test_postUIEventWriter_write(t *testing.T) {
@@ -34,8 +35,11 @@ func Test_postUIEventWriter_write(t *testing.T) {
 					Value: "<notification 2>",
 				},
 				{
-					Type:  event.CLIAppUpdateAvailable,
-					Value: "v0.33.0",
+					Type: event.CLIAppUpdateAvailable,
+					Value: parsers.UpdateCheck{
+						New:     "v0.33.0",
+						Current: "[not provided]",
+					},
 				},
 				{
 					Type:  event.CLINotification,
@@ -61,8 +65,11 @@ func Test_postUIEventWriter_write(t *testing.T) {
 					Value: "<notification 1>",
 				},
 				{
-					Type:  event.CLIAppUpdateAvailable,
-					Value: "<app update>",
+					Type: event.CLIAppUpdateAvailable,
+					Value: parsers.UpdateCheck{
+						New:     "<new version>",
+						Current: "<current version>",
+					},
 				},
 				{
 					Type:  event.CLIReport,
diff --git a/grype/presenter/cyclonedx/presenter_test.go b/grype/presenter/cyclonedx/presenter_test.go
index c0e4debb..5184c8b1 100644
--- a/grype/presenter/cyclonedx/presenter_test.go
+++ b/grype/presenter/cyclonedx/presenter_test.go
@@ -7,6 +7,7 @@ import (
 
 	"github.com/stretchr/testify/require"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/go-testutils"
 	"github.com/anchore/grype/grype/presenter/internal"
 	"github.com/anchore/grype/grype/presenter/models"
@@ -20,6 +21,10 @@ func TestCycloneDxPresenterImage(t *testing.T) {
 	matches, packages, context, metadataProvider, _, _ := internal.GenerateAnalysis(t, internal.ImageSource)
 	sbom := internal.SBOMFromPackages(t, packages)
 	pb := models.PresenterConfig{
+		ID: clio.Identification{
+			Name:    "grype",
+			Version: "[not provided]",
+		},
 		Matches:          matches,
 		Packages:         packages,
 		Context:          context,
@@ -53,6 +58,10 @@ func TestCycloneDxPresenterDir(t *testing.T) {
 	matches, packages, ctx, metadataProvider, _, _ := internal.GenerateAnalysis(t, internal.DirectorySource)
 	sbom := internal.SBOMFromPackages(t, packages)
 	pb := models.PresenterConfig{
+		ID: clio.Identification{
+			Name:    "grype",
+			Version: "[not provided]",
+		},
 		Matches:          matches,
 		Packages:         packages,
 		Context:          ctx,
diff --git a/grype/presenter/explain/__snapshots__/explain_snapshot_test.snap b/grype/presenter/explain/__snapshots__/explain_snapshot_test.snap
new file mode 100755
index 00000000..0d151ca8
--- /dev/null
+++ b/grype/presenter/explain/__snapshots__/explain_snapshot_test.snap
@@ -0,0 +1,108 @@
+
+[TestExplainSnapshot/keycloak-CVE-2020-12413 - 1]
+CVE-2020-12413 from nvd:cpe (Medium)
+The Raccoon attack is a timing attack on DHE ciphersuites inherit in the TLS specification. To mitigate this vulnerability, Firefox disabled support for DHE ciphersuites.
+Related vulnerabilities:
+    - redhat:distro:redhat:9 CVE-2020-12413 (Low)
+Matched packages:
+    - Package: nss, version: 3.79.0-17.el9_1
+      PURL: pkg:rpm/rhel/nss@3.79.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1
+      Match explanation(s):
+          - redhat:distro:redhat:9:CVE-2020-12413 Direct match (package name, version, and ecosystem) against nss (version 3.79.0-17.el9_1).
+      Locations:
+          - /var/lib/rpm/rpmdb.sqlite
+    - Package: nspr, version: 4.34.0-17.el9_1
+      PURL: pkg:rpm/rhel/nspr@4.34.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1
+      Match explanation(s):
+          - redhat:distro:redhat:9:CVE-2020-12413 Indirect match; this CVE is reported against nss (version 3.79.0-17.el9_1), the source RPM of this rpm package.
+      Locations:
+          - /var/lib/rpm/rpmdb.sqlite
+    - Package: nss-softokn, version: 3.79.0-17.el9_1
+      PURL: pkg:rpm/rhel/nss-softokn@3.79.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1
+      Match explanation(s):
+          - redhat:distro:redhat:9:CVE-2020-12413 Indirect match; this CVE is reported against nss (version 3.79.0-17.el9_1), the source RPM of this rpm package.
+      Locations:
+          - /var/lib/rpm/rpmdb.sqlite
+    - Package: nss-softokn-freebl, version: 3.79.0-17.el9_1
+      PURL: pkg:rpm/rhel/nss-softokn-freebl@3.79.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1
+      Match explanation(s):
+          - redhat:distro:redhat:9:CVE-2020-12413 Indirect match; this CVE is reported against nss (version 3.79.0-17.el9_1), the source RPM of this rpm package.
+      Locations:
+          - /var/lib/rpm/rpmdb.sqlite
+    - Package: nss-sysinit, version: 3.79.0-17.el9_1
+      PURL: pkg:rpm/rhel/nss-sysinit@3.79.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1
+      Match explanation(s):
+          - redhat:distro:redhat:9:CVE-2020-12413 Indirect match; this CVE is reported against nss (version 3.79.0-17.el9_1), the source RPM of this rpm package.
+      Locations:
+          - /var/lib/rpm/rpmdb.sqlite
+    - Package: nss-util, version: 3.79.0-17.el9_1
+      PURL: pkg:rpm/rhel/nss-util@3.79.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1
+      Match explanation(s):
+          - redhat:distro:redhat:9:CVE-2020-12413 Indirect match; this CVE is reported against nss (version 3.79.0-17.el9_1), the source RPM of this rpm package.
+      Locations:
+          - /var/lib/rpm/rpmdb.sqlite
+URLs:
+    - https://nvd.nist.gov/vuln/detail/CVE-2020-12413
+    - https://access.redhat.com/security/cve/CVE-2020-12413
+
+---
+
+[TestExplainSnapshot/chainguard-ruby-CVE-2023-28755 - 1]
+CVE-2023-28755 from nvd:cpe (High)
+A ReDoS issue was discovered in the URI component through 0.12.0 in Ruby through 3.2.1. The URI parser mishandles invalid URLs that have specific characters. It causes an increase in execution time for parsing strings to URI objects. The fixed versions are 0.12.1, 0.11.1, 0.10.2 and 0.10.0.1.
+Related vulnerabilities:
+    - github:language:ruby GHSA-hv5j-3h9f-99c2 (High)
+    - wolfi:distro:wolfi:rolling CVE-2023-28755 (High)
+Matched packages:
+    - Package: ruby-3.0, version: 3.0.4-r1
+      PURL: pkg:apk/wolfi/ruby-3.0@3.0.4-r1?arch=aarch64&distro=wolfi-20221118
+      Match explanation(s):
+          - wolfi:distro:wolfi:rolling:CVE-2023-28755 Direct match (package name, version, and ecosystem) against ruby-3.0 (version 3.0.4-r1).
+          - nvd:cpe:CVE-2023-28755 CPE match on `cpe:2.3:a:ruby-lang:uri:0.10.1:*:*:*:*:*:*:*`.
+          - wolfi:distro:wolfi:rolling:CVE-2023-28755 Indirect match; this CVE is reported against ruby-3.0 (version 3.0.4-r1), the upstream of this apk package.
+      Locations:
+          - /usr/lib/ruby/gems/3.0.0/specifications/default/uri-0.10.1.gemspec
+          - /lib/apk/db/installed
+URLs:
+    - https://nvd.nist.gov/vuln/detail/CVE-2023-28755
+    - https://github.com/advisories/GHSA-hv5j-3h9f-99c2
+    - http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-28755
+
+---
+
+[TestExplainSnapshot/test_a_GHSA - 1]
+GHSA-cfh5-3ghh-wfjx from github:language:java (Medium)
+Moderate severity vulnerability that affects org.apache.httpcomponents:httpclient
+Related vulnerabilities:
+    - nvd:cpe CVE-2014-3577 (Medium)
+Matched packages:
+    - Package: httpclient, version: 4.1.1
+      PURL: pkg:maven/org.apache.httpcomponents/httpclient@4.1.1
+      Match explanation(s):
+          - github:language:java:GHSA-cfh5-3ghh-wfjx Direct match (package name, version, and ecosystem) against httpclient (version 4.1.1).
+      Locations:
+          - /TwilioNotifier.hpi:WEB-INF/lib/sdk-3.0.jar:httpclient
+URLs:
+    - https://github.com/advisories/GHSA-cfh5-3ghh-wfjx
+    - https://nvd.nist.gov/vuln/detail/CVE-2014-3577
+
+---
+
+[TestExplainSnapshot/test_a_CVE_alias_of_a_GHSA - 1]
+CVE-2014-3577 from nvd:cpe (Medium)
+org.apache.http.conn.ssl.AbstractVerifier in Apache HttpComponents HttpClient before 4.3.5 and HttpAsyncClient before 4.0.2 does not properly verify that the server hostname matches a domain name in the subject's Common Name (CN) or subjectAltName field of the X.509 certificate, which allows man-in-the-middle attackers to spoof SSL servers via a "CN=" string in a field in the distinguished name (DN) of a certificate, as demonstrated by the "foo,CN=www.apache.org" string in the O field.
+Related vulnerabilities:
+    - github:language:java GHSA-cfh5-3ghh-wfjx (Medium)
+Matched packages:
+    - Package: httpclient, version: 4.1.1
+      PURL: pkg:maven/org.apache.httpcomponents/httpclient@4.1.1
+      Match explanation(s):
+          - github:language:java:GHSA-cfh5-3ghh-wfjx Direct match (package name, version, and ecosystem) against httpclient (version 4.1.1).
+          - nvd:cpe:CVE-2014-3577 CPE match on `cpe:2.3:a:apache:httpclient:4.1.1:*:*:*:*:*:*:*`.
+      Locations:
+          - /TwilioNotifier.hpi:WEB-INF/lib/sdk-3.0.jar:httpclient
+URLs:
+    - https://nvd.nist.gov/vuln/detail/CVE-2014-3577
+    - https://github.com/advisories/GHSA-cfh5-3ghh-wfjx
+
+---
diff --git a/grype/presenter/explain/explain_snapshot_test.go b/grype/presenter/explain/explain_snapshot_test.go
new file mode 100644
index 00000000..4bbdd4cb
--- /dev/null
+++ b/grype/presenter/explain/explain_snapshot_test.go
@@ -0,0 +1,71 @@
+package explain_test
+
+import (
+	"bytes"
+	"encoding/json"
+	"os"
+	"testing"
+
+	"github.com/gkampitakis/go-snaps/snaps"
+	"github.com/stretchr/testify/require"
+
+	"github.com/anchore/grype/grype/presenter/explain"
+	"github.com/anchore/grype/grype/presenter/models"
+)
+
+func TestExplainSnapshot(t *testing.T) {
+	// load sample json
+	testCases := []struct {
+		name             string
+		fixture          string
+		vulnerabilityIDs []string
+	}{
+		{
+			name:             "keycloak-CVE-2020-12413",
+			fixture:          "./test-fixtures/keycloak-test.json",
+			vulnerabilityIDs: []string{"CVE-2020-12413"},
+		},
+		{
+			name:             "chainguard-ruby-CVE-2023-28755",
+			fixture:          "test-fixtures/chainguard-ruby-test.json",
+			vulnerabilityIDs: []string{"CVE-2023-28755"},
+		},
+		{
+			name: "test a GHSA",
+			/*
+				fixture created by:
+				Saving output of
+				grype anchore/test_images@sha256:10008791acbc5866de04108746a02a0c4029ce3a4400a9b3dad45d7f2245f9da -o json
+				Then filtering matches to relevant ones:
+				jq -c '.matches[]' | rg -e GHSA-cfh5-3ghh-wfjx -e CVE-2014-3577 | jq -s .
+			*/
+			fixture:          "test-fixtures/ghsa-test.json",
+			vulnerabilityIDs: []string{"GHSA-cfh5-3ghh-wfjx"},
+		},
+		{
+			name:             "test a CVE alias of a GHSA",
+			fixture:          "test-fixtures/ghsa-test.json",
+			vulnerabilityIDs: []string{"CVE-2014-3577"},
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			r, err := os.Open(tc.fixture)
+			require.NoError(t, err)
+
+			// parse to models.Document
+			doc := models.Document{}
+			decoder := json.NewDecoder(r)
+			err = decoder.Decode(&doc)
+			require.NoError(t, err)
+			// create explain.VulnerabilityExplainer
+			w := bytes.NewBufferString("")
+			explainer := explain.NewVulnerabilityExplainer(w, &doc)
+			// call ExplainByID
+			err = explainer.ExplainByID(tc.vulnerabilityIDs)
+			require.NoError(t, err)
+			// assert output
+			snaps.MatchSnapshot(t, w.String())
+		})
+	}
+}
diff --git a/grype/presenter/explain/test-fixtures/chainguard-ruby-test.json b/grype/presenter/explain/test-fixtures/chainguard-ruby-test.json
new file mode 100644
index 00000000..22151b2e
--- /dev/null
+++ b/grype/presenter/explain/test-fixtures/chainguard-ruby-test.json
@@ -0,0 +1,429 @@
+{
+ "matches": [
+    {
+      "vulnerability": {
+        "id": "CVE-2023-28755",
+        "dataSource": "http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-28755",
+        "namespace": "wolfi:distro:wolfi:rolling",
+        "severity": "High",
+        "urls": [
+          "http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-28755"
+        ],
+        "cvss": [],
+        "fix": {
+          "versions": [
+            "3.0.6-r0"
+          ],
+          "state": "fixed"
+        },
+        "advisories": []
+      },
+      "relatedVulnerabilities": [
+        {
+          "id": "CVE-2023-28755",
+          "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2023-28755",
+          "namespace": "nvd:cpe",
+          "severity": "High",
+          "urls": [
+            "https://github.com/ruby/uri/releases/",
+            "https://lists.debian.org/debian-lts-announce/2023/04/msg00033.html",
+            "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/FFZANOQA4RYX7XCB42OO3P24DQKWHEKA/",
+            "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/G76GZG3RAGYF4P75YY7J7TGYAU7Z5E2T/",
+            "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/WMIOPLBAAM3FEQNAXA2L7BDKOGSVUT5Z/",
+            "https://www.ruby-lang.org/en/downloads/releases/",
+            "https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/",
+            "https://www.ruby-lang.org/en/news/2023/03/28/redos-in-uri-cve-2023-28755/"
+          ],
+          "description": "A ReDoS issue was discovered in the URI component through 0.12.0 in Ruby through 3.2.1. The URI parser mishandles invalid URLs that have specific characters. It causes an increase in execution time for parsing strings to URI objects. The fixed versions are 0.12.1, 0.11.1, 0.10.2 and 0.10.0.1.",
+          "cvss": [
+            {
+              "version": "3.1",
+              "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
+              "metrics": {
+                "baseScore": 7.5,
+                "exploitabilityScore": 3.9,
+                "impactScore": 3.6
+              },
+              "vendorMetadata": {}
+            }
+          ]
+        }
+      ],
+      "matchDetails": [
+        {
+          "type": "exact-indirect-match",
+          "matcher": "apk-matcher",
+          "searchedBy": {
+            "distro": {
+              "type": "wolfi",
+              "version": "20221118"
+            },
+            "namespace": "wolfi:distro:wolfi:rolling",
+            "package": {
+              "name": "ruby-3.0",
+              "version": "3.0.4-r1"
+            }
+          },
+          "found": {
+            "versionConstraint": "< 3.0.6-r0 (apk)",
+            "vulnerabilityID": "CVE-2023-28755"
+          }
+        },
+        {
+          "type": "exact-direct-match",
+          "matcher": "apk-matcher",
+          "searchedBy": {
+            "distro": {
+              "type": "wolfi",
+              "version": "20221118"
+            },
+            "namespace": "wolfi:distro:wolfi:rolling",
+            "package": {
+              "name": "ruby-3.0",
+              "version": "3.0.4-r1"
+            }
+          },
+          "found": {
+            "versionConstraint": "< 3.0.6-r0 (apk)",
+            "vulnerabilityID": "CVE-2023-28755"
+          }
+        }
+      ],
+      "artifact": {
+        "name": "ruby-3.0",
+        "version": "3.0.4-r1",
+        "type": "apk",
+        "locations": [
+          {
+            "path": "/lib/apk/db/installed",
+            "layerID": "sha256:ed905fc06ed3176315bd1e33075ca5b09cd768ad78142fb45439350469556880"
+          }
+        ],
+        "language": "",
+        "licenses": [
+          "PSF-2.0"
+        ],
+        "cpes": [
+          "cpe:2.3:a:ruby-lang:ruby-3.0:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby-lang:ruby_3.0:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby_lang:ruby-3.0:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby_lang:ruby_3.0:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby-3.0:ruby-3.0:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby-3.0:ruby_3.0:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby_3.0:ruby-3.0:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby_3.0:ruby_3.0:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby-lang:ruby:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby_lang:ruby:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby-3.0:ruby:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby:ruby-3.0:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby:ruby_3.0:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby_3.0:ruby:3.0.4-r1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby:ruby:3.0.4-r1:*:*:*:*:*:*:*"
+        ],
+        "purl": "pkg:apk/wolfi/ruby-3.0@3.0.4-r1?arch=aarch64&distro=wolfi-20221118",
+        "upstreams": [
+          {
+            "name": "ruby-3.0"
+          }
+        ]
+      }
+    },
+    {
+      "vulnerability": {
+        "id": "CVE-2023-28755",
+        "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2023-28755",
+        "namespace": "nvd:cpe",
+        "severity": "High",
+        "urls": [
+          "https://github.com/ruby/uri/releases/",
+          "https://lists.debian.org/debian-lts-announce/2023/04/msg00033.html",
+          "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/FFZANOQA4RYX7XCB42OO3P24DQKWHEKA/",
+          "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/G76GZG3RAGYF4P75YY7J7TGYAU7Z5E2T/",
+          "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/WMIOPLBAAM3FEQNAXA2L7BDKOGSVUT5Z/",
+          "https://www.ruby-lang.org/en/downloads/releases/",
+          "https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/",
+          "https://www.ruby-lang.org/en/news/2023/03/28/redos-in-uri-cve-2023-28755/"
+        ],
+        "description": "A ReDoS issue was discovered in the URI component through 0.12.0 in Ruby through 3.2.1. The URI parser mishandles invalid URLs that have specific characters. It causes an increase in execution time for parsing strings to URI objects. The fixed versions are 0.12.1, 0.11.1, 0.10.2 and 0.10.0.1.",
+        "cvss": [
+          {
+            "version": "3.1",
+            "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
+            "metrics": {
+              "baseScore": 7.5,
+              "exploitabilityScore": 3.9,
+              "impactScore": 3.6
+            },
+            "vendorMetadata": {}
+          }
+        ],
+        "fix": {
+          "versions": [],
+          "state": "unknown"
+        },
+        "advisories": []
+      },
+      "relatedVulnerabilities": [],
+      "matchDetails": [
+        {
+          "type": "cpe-match",
+          "matcher": "ruby-gem-matcher",
+          "searchedBy": {
+            "namespace": "nvd:cpe",
+            "cpes": [
+              "cpe:2.3:a:ruby-lang:uri:0.10.1:*:*:*:*:*:*:*"
+            ]
+          },
+          "found": {
+            "vulnerabilityID": "CVE-2023-28755",
+            "versionConstraint": "<= 0.10.0 || = 0.10.1 || = 0.11.0 || = 0.12.0 (unknown)",
+            "cpes": [
+              "cpe:2.3:a:ruby-lang:uri:*:*:*:*:*:ruby:*:*",
+              "cpe:2.3:a:ruby-lang:uri:0.10.1:*:*:*:*:ruby:*:*"
+            ]
+          }
+        }
+      ],
+      "artifact": {
+        "name": "uri",
+        "version": "0.10.1",
+        "type": "gem",
+        "locations": [
+          {
+            "path": "/usr/lib/ruby/gems/3.0.0/specifications/default/uri-0.10.1.gemspec",
+            "layerID": "sha256:ed905fc06ed3176315bd1e33075ca5b09cd768ad78142fb45439350469556880"
+          }
+        ],
+        "language": "ruby",
+        "licenses": [
+          "Ruby",
+          "BSD-2-Clause"
+        ],
+        "cpes": [
+          "cpe:2.3:a:akira-yamada:uri:0.10.1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:akira_yamada:uri:0.10.1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby-lang:uri:0.10.1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby_lang:uri:0.10.1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby:uri:0.10.1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:uri:uri:0.10.1:*:*:*:*:*:*:*"
+        ],
+        "purl": "pkg:gem/uri@0.10.1",
+        "upstreams": []
+      }
+    },
+    {
+      "vulnerability": {
+        "id": "GHSA-hv5j-3h9f-99c2",
+        "dataSource": "https://github.com/advisories/GHSA-hv5j-3h9f-99c2",
+        "namespace": "github:language:ruby",
+        "severity": "High",
+        "urls": [
+          "https://github.com/advisories/GHSA-hv5j-3h9f-99c2"
+        ],
+        "description": "Ruby URI component ReDoS issue",
+        "cvss": [],
+        "fix": {
+          "versions": [
+            "0.10.2"
+          ],
+          "state": "fixed"
+        },
+        "advisories": []
+      },
+      "relatedVulnerabilities": [
+        {
+          "id": "CVE-2023-28755",
+          "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2023-28755",
+          "namespace": "nvd:cpe",
+          "severity": "High",
+          "urls": [
+            "https://github.com/ruby/uri/releases/",
+            "https://lists.debian.org/debian-lts-announce/2023/04/msg00033.html",
+            "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/FFZANOQA4RYX7XCB42OO3P24DQKWHEKA/",
+            "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/G76GZG3RAGYF4P75YY7J7TGYAU7Z5E2T/",
+            "https://lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/WMIOPLBAAM3FEQNAXA2L7BDKOGSVUT5Z/",
+            "https://www.ruby-lang.org/en/downloads/releases/",
+            "https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/",
+            "https://www.ruby-lang.org/en/news/2023/03/28/redos-in-uri-cve-2023-28755/"
+          ],
+          "description": "A ReDoS issue was discovered in the URI component through 0.12.0 in Ruby through 3.2.1. The URI parser mishandles invalid URLs that have specific characters. It causes an increase in execution time for parsing strings to URI objects. The fixed versions are 0.12.1, 0.11.1, 0.10.2 and 0.10.0.1.",
+          "cvss": [
+            {
+              "version": "3.1",
+              "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
+              "metrics": {
+                "baseScore": 7.5,
+                "exploitabilityScore": 3.9,
+                "impactScore": 3.6
+              },
+              "vendorMetadata": {}
+            }
+          ]
+        }
+      ],
+      "matchDetails": [
+        {
+          "type": "exact-direct-match",
+          "matcher": "ruby-gem-matcher",
+          "searchedBy": {
+            "language": "ruby",
+            "namespace": "github:language:ruby"
+          },
+          "found": {
+            "versionConstraint": "=0.10.1 (unknown)",
+            "vulnerabilityID": "GHSA-hv5j-3h9f-99c2"
+          }
+        }
+      ],
+      "artifact": {
+        "name": "uri",
+        "version": "0.10.1",
+        "type": "gem",
+        "locations": [
+          {
+            "path": "/usr/lib/ruby/gems/3.0.0/specifications/default/uri-0.10.1.gemspec",
+            "layerID": "sha256:ed905fc06ed3176315bd1e33075ca5b09cd768ad78142fb45439350469556880"
+          }
+        ],
+        "language": "ruby",
+        "licenses": [
+          "Ruby",
+          "BSD-2-Clause"
+        ],
+        "cpes": [
+          "cpe:2.3:a:akira-yamada:uri:0.10.1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:akira_yamada:uri:0.10.1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby-lang:uri:0.10.1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby_lang:uri:0.10.1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:ruby:uri:0.10.1:*:*:*:*:*:*:*",
+          "cpe:2.3:a:uri:uri:0.10.1:*:*:*:*:*:*:*"
+        ],
+        "purl": "pkg:gem/uri@0.10.1",
+        "upstreams": []
+      }
+    }
+  ],
+ "source": {
+  "type": "image",
+  "target": {
+   "userInput": "cgr.dev/chainguard/ruby:latest-3.0",
+   "imageID": "sha256:2f88265cfbc43ca35cd327347a9f59375b9f29ef998b8a54a882e31111266640",
+   "manifestDigest": "sha256:86abe662dfa3746038eea6b0db91092b0767d78b8b8938a343d614cd1579adc2",
+   "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+   "tags": [
+    "cgr.dev/chainguard/ruby:latest-3.0"
+   ],
+   "imageSize": 38865264,
+   "layers": [
+    {
+     "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+     "digest": "sha256:ed905fc06ed3176315bd1e33075ca5b09cd768ad78142fb45439350469556880",
+     "size": 38865264
+    }
+   ],
+   "manifest": "eyJzY2hlbWFWZXJzaW9uIjoyLCJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuZG9ja2VyLmRpc3RyaWJ1dGlvbi5tYW5pZmVzdC52Mitqc29uIiwiY29uZmlnIjp7Im1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5kb2NrZXIuY29udGFpbmVyLmltYWdlLnYxK2pzb24iLCJzaXplIjo1NTMsImRpZ2VzdCI6InNoYTI1NjoyZjg4MjY1Y2ZiYzQzY2EzNWNkMzI3MzQ3YTlmNTkzNzViOWYyOWVmOTk4YjhhNTRhODgyZTMxMTExMjY2NjQwIn0sImxheWVycyI6W3sibWVkaWFUeXBlIjoiYXBwbGljYXRpb24vdm5kLmRvY2tlci5pbWFnZS5yb290ZnMuZGlmZi50YXIuZ3ppcCIsInNpemUiOjQxMTYxNzI4LCJkaWdlc3QiOiJzaGEyNTY6ZWQ5MDVmYzA2ZWQzMTc2MzE1YmQxZTMzMDc1Y2E1YjA5Y2Q3NjhhZDc4MTQyZmI0NTQzOTM1MDQ2OTU1Njg4MCJ9XX0=",
+   "config": "eyJhcmNoaXRlY3R1cmUiOiJhcm02NCIsImF1dGhvciI6ImdpdGh1Yi5jb20vY2hhaW5ndWFyZC1kZXYvYXBrbyIsImNyZWF0ZWQiOiIyMDIzLTAxLTEzVDAwOjExOjI2WiIsImhpc3RvcnkiOlt7ImF1dGhvciI6ImFwa28iLCJjcmVhdGVkIjoiMjAyMy0wMS0xM1QwMDoxMToyNloiLCJjcmVhdGVkX2J5IjoiYXBrbyIsImNvbW1lbnQiOiJUaGlzIGlzIGFuIGFwa28gc2luZ2xlLWxheWVyIGltYWdlIn1dLCJvcyI6ImxpbnV4Iiwicm9vdGZzIjp7InR5cGUiOiJsYXllcnMiLCJkaWZmX2lkcyI6WyJzaGEyNTY6ZWQ5MDVmYzA2ZWQzMTc2MzE1YmQxZTMzMDc1Y2E1YjA5Y2Q3NjhhZDc4MTQyZmI0NTQzOTM1MDQ2OTU1Njg4MCJdfSwiY29uZmlnIjp7IkNtZCI6WyIvdXNyL2Jpbi9pcmIiXSwiRW52IjpbIlBBVEg9L3Vzci9sb2NhbC9zYmluOi91c3IvbG9jYWwvYmluOi91c3Ivc2JpbjovdXNyL2Jpbjovc2JpbjovYmluIiwiU1NMX0NFUlRfRklMRT0vZXRjL3NzbC9jZXJ0cy9jYS1jZXJ0aWZpY2F0ZXMuY3J0Il0sIlVzZXIiOiI2NTUzMiIsIldvcmtpbmdEaXIiOiIvd29yayJ9fQ==",
+   "repoDigests": [
+    "cgr.dev/chainguard/ruby@sha256:3c9afb4f188827ea1062ec3b8acea32893236a0d7df31e0498df93486cff0978"
+   ],
+   "architecture": "arm64",
+   "os": "linux"
+  }
+ },
+ "distro": {
+  "name": "wolfi",
+  "version": "20221118",
+  "idLike": []
+ },
+ "descriptor": {
+  "name": "grype",
+  "version": "0.61.1",
+  "configuration": {
+   "configPath": "",
+   "verbosity": 0,
+   "output": "json",
+   "file": "",
+   "distro": "",
+   "add-cpes-if-none": false,
+   "output-template-file": "",
+   "quiet": false,
+   "check-for-app-update": true,
+   "only-fixed": false,
+   "only-notfixed": false,
+   "platform": "",
+   "search": {
+    "scope": "Squashed",
+    "unindexed-archives": false,
+    "indexed-archives": true
+   },
+   "ignore": null,
+   "exclude": [],
+   "db": {
+    "cache-dir": "/Users/willmurphy/Library/Caches/grype/db",
+    "update-url": "https://toolbox-data.anchore.io/grype/databases/listing.json",
+    "ca-cert": "",
+    "auto-update": true,
+    "validate-by-hash-on-start": false,
+    "validate-age": true,
+    "max-allowed-built-age": 432000000000000
+   },
+   "externalSources": {
+    "enable": false,
+    "maven": {
+     "searchUpstreamBySha1": true,
+     "baseUrl": "https://search.maven.org/solrsearch/select"
+    }
+   },
+   "match": {
+    "java": {
+     "using-cpes": true
+    },
+    "dotnet": {
+     "using-cpes": true
+    },
+    "golang": {
+     "using-cpes": true
+    },
+    "javascript": {
+     "using-cpes": false
+    },
+    "python": {
+     "using-cpes": true
+    },
+    "ruby": {
+     "using-cpes": true
+    },
+    "stock": {
+     "using-cpes": true
+    }
+   },
+   "dev": {
+    "profile-cpu": false,
+    "profile-mem": false
+   },
+   "fail-on-severity": "",
+   "registry": {
+    "insecure-skip-tls-verify": false,
+    "insecure-use-http": false,
+    "auth": []
+   },
+   "log": {
+    "structured": false,
+    "level": "warn",
+    "file": ""
+   },
+   "show-suppressed": false,
+   "by-cve": false,
+   "name": "",
+   "default-image-pull-source": ""
+  },
+  "db": {
+   "built": "2023-05-17T01:32:43Z",
+   "schemaVersion": 5,
+   "location": "/Users/willmurphy/Library/Caches/grype/db/5",
+   "checksum": "sha256:84ebb8325f426565e7a0cd00b2ea265a0ee0ec69db158a65541a42fddd1e15b0",
+   "error": null
+  },
+  "timestamp": "2023-05-17T21:00:56.783213-04:00"
+ }
+}
diff --git a/grype/presenter/explain/test-fixtures/ghsa-test.json b/grype/presenter/explain/test-fixtures/ghsa-test.json
new file mode 100644
index 00000000..661b78b0
--- /dev/null
+++ b/grype/presenter/explain/test-fixtures/ghsa-test.json
@@ -0,0 +1,396 @@
+{
+ "matches": [
+    {
+      "vulnerability": {
+        "id": "GHSA-cfh5-3ghh-wfjx",
+        "dataSource": "https://github.com/advisories/GHSA-cfh5-3ghh-wfjx",
+        "namespace": "github:language:java",
+        "severity": "Medium",
+        "urls": [
+          "https://github.com/advisories/GHSA-cfh5-3ghh-wfjx"
+        ],
+        "description": "Moderate severity vulnerability that affects org.apache.httpcomponents:httpclient",
+        "cvss": [],
+        "fix": {
+          "versions": [
+            "4.3.5"
+          ],
+          "state": "fixed"
+        },
+        "advisories": []
+      },
+      "relatedVulnerabilities": [
+        {
+          "id": "CVE-2014-3577",
+          "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2014-3577",
+          "namespace": "nvd:cpe",
+          "severity": "Medium",
+          "urls": [
+            "http://lists.opensuse.org/opensuse-security-announce/2020-11/msg00032.html",
+            "http://lists.opensuse.org/opensuse-security-announce/2020-11/msg00033.html",
+            "http://packetstormsecurity.com/files/127913/Apache-HttpComponents-Man-In-The-Middle.html",
+            "http://rhn.redhat.com/errata/RHSA-2014-1146.html",
+            "http://rhn.redhat.com/errata/RHSA-2014-1166.html",
+            "http://rhn.redhat.com/errata/RHSA-2014-1833.html",
+            "http://rhn.redhat.com/errata/RHSA-2014-1834.html",
+            "http://rhn.redhat.com/errata/RHSA-2014-1835.html",
+            "http://rhn.redhat.com/errata/RHSA-2014-1836.html",
+            "http://rhn.redhat.com/errata/RHSA-2014-1891.html",
+            "http://rhn.redhat.com/errata/RHSA-2014-1892.html",
+            "http://rhn.redhat.com/errata/RHSA-2015-0125.html",
+            "http://rhn.redhat.com/errata/RHSA-2015-0158.html",
+            "http://rhn.redhat.com/errata/RHSA-2015-0675.html",
+            "http://rhn.redhat.com/errata/RHSA-2015-0720.html",
+            "http://rhn.redhat.com/errata/RHSA-2015-0765.html",
+            "http://rhn.redhat.com/errata/RHSA-2015-0850.html",
+            "http://rhn.redhat.com/errata/RHSA-2015-0851.html",
+            "http://rhn.redhat.com/errata/RHSA-2015-1176.html",
+            "http://rhn.redhat.com/errata/RHSA-2015-1177.html",
+            "http://rhn.redhat.com/errata/RHSA-2015-1888.html",
+            "http://rhn.redhat.com/errata/RHSA-2016-1773.html",
+            "http://rhn.redhat.com/errata/RHSA-2016-1931.html",
+            "http://seclists.org/fulldisclosure/2014/Aug/48",
+            "http://secunia.com/advisories/60466",
+            "http://www.openwall.com/lists/oss-security/2021/10/06/1",
+            "http://www.oracle.com/technetwork/security-advisory/cpujul2018-4258247.html",
+            "http://www.osvdb.org/110143",
+            "http://www.securityfocus.com/bid/69258",
+            "http://www.securitytracker.com/id/1030812",
+            "http://www.ubuntu.com/usn/USN-2769-1",
+            "https://access.redhat.com/solutions/1165533",
+            "https://exchange.xforce.ibmcloud.com/vulnerabilities/95327",
+            "https://h20566.www2.hpe.com/portal/site/hpsc/public/kb/docDisplay?docId=emr_na-c05103564",
+            "https://h20566.www2.hpe.com/portal/site/hpsc/public/kb/docDisplay?docId=emr_na-c05363782",
+            "https://lists.apache.org/thread.html/519eb0fd45642dcecd9ff74cb3e71c20a4753f7d82e2f07864b5108f@%3Cdev.drill.apache.org%3E",
+            "https://lists.apache.org/thread.html/b0656d359c7d40ec9f39c8cc61bca66802ef9a2a12ee199f5b0c1442@%3Cdev.drill.apache.org%3E",
+            "https://lists.apache.org/thread.html/f9bc3e55f4e28d1dcd1a69aae6d53e609a758e34d2869b4d798e13cc@%3Cissues.drill.apache.org%3E",
+            "https://lists.apache.org/thread.html/r36e44ffc1a9b365327df62cdfaabe85b9a5637de102cea07d79b2dbf@%3Ccommits.cxf.apache.org%3E",
+            "https://lists.apache.org/thread.html/rc774278135816e7afc943dc9fc78eb0764f2c84a2b96470a0187315c@%3Ccommits.cxf.apache.org%3E",
+            "https://lists.apache.org/thread.html/rd49aabd984ed540c8ff7916d4d79405f3fa311d2fdbcf9ed307839a6@%3Ccommits.cxf.apache.org%3E",
+            "https://lists.apache.org/thread.html/rec7160382badd3ef4ad017a22f64a266c7188b9ba71394f0d321e2d4@%3Ccommits.cxf.apache.org%3E",
+            "https://lists.apache.org/thread.html/rfb87e0bf3995e7d560afeed750fac9329ff5f1ad49da365129b7f89e@%3Ccommits.cxf.apache.org%3E",
+            "https://lists.apache.org/thread.html/rff42cfa5e7d75b7c1af0e37589140a8f1999e578a75738740b244bd4@%3Ccommits.cxf.apache.org%3E"
+          ],
+          "description": "org.apache.http.conn.ssl.AbstractVerifier in Apache HttpComponents HttpClient before 4.3.5 and HttpAsyncClient before 4.0.2 does not properly verify that the server hostname matches a domain name in the subject's Common Name (CN) or subjectAltName field of the X.509 certificate, which allows man-in-the-middle attackers to spoof SSL servers via a \"CN=\" string in a field in the distinguished name (DN) of a certificate, as demonstrated by the \"foo,CN=www.apache.org\" string in the O field.",
+          "cvss": [
+            {
+              "source": "nvd@nist.gov",
+              "type": "Primary",
+              "version": "2.0",
+              "vector": "AV:N/AC:M/Au:N/C:P/I:P/A:N",
+              "metrics": {
+                "baseScore": 5.8,
+                "exploitabilityScore": 8.6,
+                "impactScore": 4.9
+              },
+              "vendorMetadata": {}
+            }
+          ]
+        }
+      ],
+      "matchDetails": [
+        {
+          "type": "exact-direct-match",
+          "matcher": "java-matcher",
+          "searchedBy": {
+            "language": "java",
+            "namespace": "github:language:java",
+            "package": {
+              "name": "httpclient",
+              "version": "4.1.1"
+            }
+          },
+          "found": {
+            "versionConstraint": "<4.3.5 (unknown)",
+            "vulnerabilityID": "GHSA-cfh5-3ghh-wfjx"
+          }
+        }
+      ],
+      "artifact": {
+        "id": "f09cdae46b001bc5",
+        "name": "httpclient",
+        "version": "4.1.1",
+        "type": "java-archive",
+        "locations": [
+          {
+            "path": "/TwilioNotifier.hpi",
+            "layerID": "sha256:6cc6db176440e3dc3218d2e325716c1922ea9d900b61d7ad6f388fd0ed2b4ef9"
+          }
+        ],
+        "language": "java",
+        "licenses": [],
+        "cpes": [
+          "cpe:2.3:a:apache:httpclient:4.1.1:*:*:*:*:*:*:*"
+        ],
+        "purl": "pkg:maven/org.apache.httpcomponents/httpclient@4.1.1",
+        "upstreams": [],
+        "metadataType": "JavaMetadata",
+        "metadata": {
+          "virtualPath": "/TwilioNotifier.hpi:WEB-INF/lib/sdk-3.0.jar:httpclient",
+          "pomArtifactID": "httpclient",
+          "pomGroupID": "org.apache.httpcomponents",
+          "manifestName": "",
+          "archiveDigests": null
+        }
+      }
+    },
+    {
+      "vulnerability": {
+        "id": "CVE-2014-3577",
+        "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2014-3577",
+        "namespace": "nvd:cpe",
+        "severity": "Medium",
+        "urls": [
+          "http://lists.opensuse.org/opensuse-security-announce/2020-11/msg00032.html",
+          "http://lists.opensuse.org/opensuse-security-announce/2020-11/msg00033.html",
+          "http://packetstormsecurity.com/files/127913/Apache-HttpComponents-Man-In-The-Middle.html",
+          "http://rhn.redhat.com/errata/RHSA-2014-1146.html",
+          "http://rhn.redhat.com/errata/RHSA-2014-1166.html",
+          "http://rhn.redhat.com/errata/RHSA-2014-1833.html",
+          "http://rhn.redhat.com/errata/RHSA-2014-1834.html",
+          "http://rhn.redhat.com/errata/RHSA-2014-1835.html",
+          "http://rhn.redhat.com/errata/RHSA-2014-1836.html",
+          "http://rhn.redhat.com/errata/RHSA-2014-1891.html",
+          "http://rhn.redhat.com/errata/RHSA-2014-1892.html",
+          "http://rhn.redhat.com/errata/RHSA-2015-0125.html",
+          "http://rhn.redhat.com/errata/RHSA-2015-0158.html",
+          "http://rhn.redhat.com/errata/RHSA-2015-0675.html",
+          "http://rhn.redhat.com/errata/RHSA-2015-0720.html",
+          "http://rhn.redhat.com/errata/RHSA-2015-0765.html",
+          "http://rhn.redhat.com/errata/RHSA-2015-0850.html",
+          "http://rhn.redhat.com/errata/RHSA-2015-0851.html",
+          "http://rhn.redhat.com/errata/RHSA-2015-1176.html",
+          "http://rhn.redhat.com/errata/RHSA-2015-1177.html",
+          "http://rhn.redhat.com/errata/RHSA-2015-1888.html",
+          "http://rhn.redhat.com/errata/RHSA-2016-1773.html",
+          "http://rhn.redhat.com/errata/RHSA-2016-1931.html",
+          "http://seclists.org/fulldisclosure/2014/Aug/48",
+          "http://secunia.com/advisories/60466",
+          "http://www.openwall.com/lists/oss-security/2021/10/06/1",
+          "http://www.oracle.com/technetwork/security-advisory/cpujul2018-4258247.html",
+          "http://www.osvdb.org/110143",
+          "http://www.securityfocus.com/bid/69258",
+          "http://www.securitytracker.com/id/1030812",
+          "http://www.ubuntu.com/usn/USN-2769-1",
+          "https://access.redhat.com/solutions/1165533",
+          "https://exchange.xforce.ibmcloud.com/vulnerabilities/95327",
+          "https://h20566.www2.hpe.com/portal/site/hpsc/public/kb/docDisplay?docId=emr_na-c05103564",
+          "https://h20566.www2.hpe.com/portal/site/hpsc/public/kb/docDisplay?docId=emr_na-c05363782",
+          "https://lists.apache.org/thread.html/519eb0fd45642dcecd9ff74cb3e71c20a4753f7d82e2f07864b5108f@%3Cdev.drill.apache.org%3E",
+          "https://lists.apache.org/thread.html/b0656d359c7d40ec9f39c8cc61bca66802ef9a2a12ee199f5b0c1442@%3Cdev.drill.apache.org%3E",
+          "https://lists.apache.org/thread.html/f9bc3e55f4e28d1dcd1a69aae6d53e609a758e34d2869b4d798e13cc@%3Cissues.drill.apache.org%3E",
+          "https://lists.apache.org/thread.html/r36e44ffc1a9b365327df62cdfaabe85b9a5637de102cea07d79b2dbf@%3Ccommits.cxf.apache.org%3E",
+          "https://lists.apache.org/thread.html/rc774278135816e7afc943dc9fc78eb0764f2c84a2b96470a0187315c@%3Ccommits.cxf.apache.org%3E",
+          "https://lists.apache.org/thread.html/rd49aabd984ed540c8ff7916d4d79405f3fa311d2fdbcf9ed307839a6@%3Ccommits.cxf.apache.org%3E",
+          "https://lists.apache.org/thread.html/rec7160382badd3ef4ad017a22f64a266c7188b9ba71394f0d321e2d4@%3Ccommits.cxf.apache.org%3E",
+          "https://lists.apache.org/thread.html/rfb87e0bf3995e7d560afeed750fac9329ff5f1ad49da365129b7f89e@%3Ccommits.cxf.apache.org%3E",
+          "https://lists.apache.org/thread.html/rff42cfa5e7d75b7c1af0e37589140a8f1999e578a75738740b244bd4@%3Ccommits.cxf.apache.org%3E"
+        ],
+        "description": "org.apache.http.conn.ssl.AbstractVerifier in Apache HttpComponents HttpClient before 4.3.5 and HttpAsyncClient before 4.0.2 does not properly verify that the server hostname matches a domain name in the subject's Common Name (CN) or subjectAltName field of the X.509 certificate, which allows man-in-the-middle attackers to spoof SSL servers via a \"CN=\" string in a field in the distinguished name (DN) of a certificate, as demonstrated by the \"foo,CN=www.apache.org\" string in the O field.",
+        "cvss": [
+          {
+            "source": "nvd@nist.gov",
+            "type": "Primary",
+            "version": "2.0",
+            "vector": "AV:N/AC:M/Au:N/C:P/I:P/A:N",
+            "metrics": {
+              "baseScore": 5.8,
+              "exploitabilityScore": 8.6,
+              "impactScore": 4.9
+            },
+            "vendorMetadata": {}
+          }
+        ],
+        "fix": {
+          "versions": [],
+          "state": "unknown"
+        },
+        "advisories": []
+      },
+      "relatedVulnerabilities": [],
+      "matchDetails": [
+        {
+          "type": "cpe-match",
+          "matcher": "java-matcher",
+          "searchedBy": {
+            "namespace": "nvd:cpe",
+            "cpes": [
+              "cpe:2.3:a:apache:httpclient:4.1.1:*:*:*:*:*:*:*"
+            ],
+            "Package": {
+              "name": "httpclient",
+              "version": "4.1.1"
+            }
+          },
+          "found": {
+            "vulnerabilityID": "CVE-2014-3577",
+            "versionConstraint": ">= 4.0, <= 4.3.4 (unknown)",
+            "cpes": [
+              "cpe:2.3:a:apache:httpclient:*:*:*:*:*:*:*:*"
+            ]
+          }
+        }
+      ],
+      "artifact": {
+        "id": "f09cdae46b001bc5",
+        "name": "httpclient",
+        "version": "4.1.1",
+        "type": "java-archive",
+        "locations": [
+          {
+            "path": "/TwilioNotifier.hpi",
+            "layerID": "sha256:6cc6db176440e3dc3218d2e325716c1922ea9d900b61d7ad6f388fd0ed2b4ef9"
+          }
+        ],
+        "language": "java",
+        "licenses": [],
+        "cpes": [
+          "cpe:2.3:a:apache:httpclient:4.1.1:*:*:*:*:*:*:*"
+        ],
+        "purl": "pkg:maven/org.apache.httpcomponents/httpclient@4.1.1",
+        "upstreams": [],
+        "metadataType": "JavaMetadata",
+        "metadata": {
+          "virtualPath": "/TwilioNotifier.hpi:WEB-INF/lib/sdk-3.0.jar:httpclient",
+          "pomArtifactID": "httpclient",
+          "pomGroupID": "org.apache.httpcomponents",
+          "manifestName": "",
+          "archiveDigests": null
+        }
+      }
+    }
+  ],
+ "source": {
+  "type": "image",
+  "target": {
+   "userInput": "anchore/test_images@sha256:10008791acbc5866de04108746a02a0c4029ce3a4400a9b3dad45d7f2245f9da",
+   "imageID": "sha256:e1a0913e5e6eb346f15791e9627842ae80b14564f9c7a4f2e0910a9433673d8b",
+   "manifestDigest": "sha256:1212e7636ec0b1a7b90eb354e761e67163c2256de127036f086876e190631b43",
+   "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+   "tags": [],
+   "imageSize": 42104079,
+   "layers": [
+    {
+     "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+     "digest": "sha256:e2eb06d8af8218cfec8210147357a68b7e13f7c485b991c288c2d01dc228bb68",
+     "size": 5590942
+    },
+    {
+     "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+     "digest": "sha256:6cc6db176440e3dc3218d2e325716c1922ea9d900b61d7ad6f388fd0ed2b4ef9",
+     "size": 36511427
+    },
+    {
+     "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+     "digest": "sha256:5d5007f009bb615228db4046d5cae910563859d1e3a37cadb2d691ea783ad8a7",
+     "size": 1710
+    }
+   ],
+   "manifest": "eyJzY2hlbWFWZXJzaW9uIjoyLCJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuZG9ja2VyLmRpc3RyaWJ1dGlvbi5tYW5pZmVzdC52Mitqc29uIiwiY29uZmlnIjp7Im1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5kb2NrZXIuY29udGFpbmVyLmltYWdlLnYxK2pzb24iLCJzaXplIjoyMTU5LCJkaWdlc3QiOiJzaGEyNTY6ZTFhMDkxM2U1ZTZlYjM0NmYxNTc5MWU5NjI3ODQyYWU4MGIxNDU2NGY5YzdhNGYyZTA5MTBhOTQzMzY3M2Q4YiJ9LCJsYXllcnMiOlt7Im1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5kb2NrZXIuaW1hZ2Uucm9vdGZzLmRpZmYudGFyLmd6aXAiLCJzaXplIjo1ODY1NDcyLCJkaWdlc3QiOiJzaGEyNTY6ZTJlYjA2ZDhhZjgyMThjZmVjODIxMDE0NzM1N2E2OGI3ZTEzZjdjNDg1Yjk5MWMyODhjMmQwMWRjMjI4YmI2OCJ9LHsibWVkaWFUeXBlIjoiYXBwbGljYXRpb24vdm5kLmRvY2tlci5pbWFnZS5yb290ZnMuZGlmZi50YXIuZ3ppcCIsInNpemUiOjM2NTE1MzI4LCJkaWdlc3QiOiJzaGEyNTY6NmNjNmRiMTc2NDQwZTNkYzMyMThkMmUzMjU3MTZjMTkyMmVhOWQ5MDBiNjFkN2FkNmYzODhmZDBlZDJiNGVmOSJ9LHsibWVkaWFUeXBlIjoiYXBwbGljYXRpb24vdm5kLmRvY2tlci5pbWFnZS5yb290ZnMuZGlmZi50YXIuZ3ppcCIsInNpemUiOjM1ODQsImRpZ2VzdCI6InNoYTI1Njo1ZDUwMDdmMDA5YmI2MTUyMjhkYjQwNDZkNWNhZTkxMDU2Mzg1OWQxZTNhMzdjYWRiMmQ2OTFlYTc4M2FkOGE3In1dfQ==",
+   "config": "eyJhcmNoaXRlY3R1cmUiOiJhbWQ2NCIsImNvbmZpZyI6eyJIb3N0bmFtZSI6IiIsIkRvbWFpbm5hbWUiOiIiLCJVc2VyIjoiIiwiQXR0YWNoU3RkaW4iOmZhbHNlLCJBdHRhY2hTdGRvdXQiOmZhbHNlLCJBdHRhY2hTdGRlcnIiOmZhbHNlLCJUdHkiOmZhbHNlLCJPcGVuU3RkaW4iOmZhbHNlLCJTdGRpbk9uY2UiOmZhbHNlLCJFbnYiOlsiUEFUSD0vdXNyL2xvY2FsL3NiaW46L3Vzci9sb2NhbC9iaW46L3Vzci9zYmluOi91c3IvYmluOi9zYmluOi9iaW4iXSwiQ21kIjpbIi9iaW4vc2giXSwiSW1hZ2UiOiJzaGEyNTY6YTUyNzg0NzAxODkzMmE0ZWZlMWYxM2U4MzY3NTE4YzQ0MmI2MzE1OTA3YTE2MDRiZWJhYTJhZjg1NjgwMTc1MSIsIlZvbHVtZXMiOm51bGwsIldvcmtpbmdEaXIiOiIiLCJFbnRyeXBvaW50IjpudWxsLCJPbkJ1aWxkIjpudWxsLCJMYWJlbHMiOm51bGx9LCJjb250YWluZXJfY29uZmlnIjp7Ikhvc3RuYW1lIjoiIiwiRG9tYWlubmFtZSI6IiIsIlVzZXIiOiIiLCJBdHRhY2hTdGRpbiI6ZmFsc2UsIkF0dGFjaFN0ZG91dCI6ZmFsc2UsIkF0dGFjaFN0ZGVyciI6ZmFsc2UsIlR0eSI6ZmFsc2UsIk9wZW5TdGRpbiI6ZmFsc2UsIlN0ZGluT25jZSI6ZmFsc2UsIkVudiI6WyJQQVRIPS91c3IvbG9jYWwvc2JpbjovdXNyL2xvY2FsL2JpbjovdXNyL3NiaW46L3Vzci9iaW46L3NiaW46L2JpbiJdLCJDbWQiOlsiL2Jpbi9zaCIsIi1jIiwiIyhub3ApIENPUFkgZmlsZTo4ZTY2YzA3MmFjYjU4ZTVjN2ViZWU5MGI0ZGVhNjc1YjdjM2VmOTA5MTQ0Yjk3MzA4MzYwMGU3N2NkNDIyNzY5IGluIC8gIl0sIkltYWdlIjoic2hhMjU2OmE1Mjc4NDcwMTg5MzJhNGVmZTFmMTNlODM2NzUxOGM0NDJiNjMxNTkwN2ExNjA0YmViYWEyYWY4NTY4MDE3NTEiLCJWb2x1bWVzIjpudWxsLCJXb3JraW5nRGlyIjoiIiwiRW50cnlwb2ludCI6bnVsbCwiT25CdWlsZCI6bnVsbCwiTGFiZWxzIjpudWxsfSwiY3JlYXRlZCI6IjIwMjEtMTAtMjJUMTc6MDY6MzIuOTIxOTkxNjI5WiIsImRvY2tlcl92ZXJzaW9uIjoiMjAuMTAuNyIsImhpc3RvcnkiOlt7ImNyZWF0ZWQiOiIyMDIxLTA4LTI3VDE3OjE5OjQ1LjU1MzA5MjM2M1oiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgQUREIGZpbGU6YWFkNDI5MGQyNzU4MGNjMWEwOTRmZmFmOThjM2NhMmZjNWQ2OTlmZTY5NWRmYjhlNmU5ZmFjMjBmMTEyOTQ1MCBpbiAvICJ9LHsiY3JlYXRlZCI6IjIwMjEtMDgtMjdUMTc6MTk6NDUuNzU4NjExNTIzWiIsImNyZWF0ZWRfYnkiOiIvYmluL3NoIC1jICMobm9wKSAgQ01EIFtcIi9iaW4vc2hcIl0iLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkIjoiMjAyMS0xMC0yMlQxNzowNjozMi43MTI3NTA5MjRaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgd2dldCAtbnYgaHR0cHM6Ly9yZXBvMS5tYXZlbi5vcmcvbWF2ZW4yL2p1bml0L2p1bml0LzQuMTMuMS9qdW5pdC00LjEzLjEuamFyIFx1MDAyNlx1MDAyNiAgICAgd2dldCAtbnYgaHR0cHM6Ly9nZXQuamVua2lucy5pby9wbHVnaW5zL1R3aWxpb05vdGlmaWVyLzAuMi4xL1R3aWxpb05vdGlmaWVyLmhwaSBcdTAwMjZcdTAwMjYgICAgIHdnZXQgLW52IGh0dHBzOi8vdXBkYXRlcy5qZW5raW5zLWNpLm9yZy9kb3dubG9hZC93YXIvMS4zOTAvaHVkc29uLndhciBcdTAwMjZcdTAwMjYgICAgIHdnZXQgLW52IGh0dHBzOi8vZ2V0LmplbmtpbnMuaW8vcGx1Z2lucy9ub21hZC8wLjcuNC9ub21hZC5ocGkifSx7ImNyZWF0ZWQiOiIyMDIxLTEwLTIyVDE3OjA2OjMyLjkyMTk5MTYyOVoiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgQ09QWSBmaWxlOjhlNjZjMDcyYWNiNThlNWM3ZWJlZTkwYjRkZWE2NzViN2MzZWY5MDkxNDRiOTczMDgzNjAwZTc3Y2Q0MjI3NjkgaW4gLyAifV0sIm9zIjoibGludXgiLCJyb290ZnMiOnsidHlwZSI6ImxheWVycyIsImRpZmZfaWRzIjpbInNoYTI1NjplMmViMDZkOGFmODIxOGNmZWM4MjEwMTQ3MzU3YTY4YjdlMTNmN2M0ODViOTkxYzI4OGMyZDAxZGMyMjhiYjY4Iiwic2hhMjU2OjZjYzZkYjE3NjQ0MGUzZGMzMjE4ZDJlMzI1NzE2YzE5MjJlYTlkOTAwYjYxZDdhZDZmMzg4ZmQwZWQyYjRlZjkiLCJzaGEyNTY6NWQ1MDA3ZjAwOWJiNjE1MjI4ZGI0MDQ2ZDVjYWU5MTA1NjM4NTlkMWUzYTM3Y2FkYjJkNjkxZWE3ODNhZDhhNyJdfX0=",
+   "repoDigests": [
+    "anchore/test_images@sha256:10008791acbc5866de04108746a02a0c4029ce3a4400a9b3dad45d7f2245f9da"
+   ],
+   "architecture": "amd64",
+   "os": "linux"
+  }
+ },
+ "distro": {
+  "name": "alpine",
+  "version": "3.14.2",
+  "idLike": []
+ },
+ "descriptor": {
+  "name": "grype",
+  "version": "0.65.1",
+  "configuration": {
+   "configPath": "",
+   "verbosity": 0,
+   "output": [
+    "json"
+   ],
+   "file": "",
+   "distro": "",
+   "add-cpes-if-none": false,
+   "output-template-file": "",
+   "quiet": true,
+   "check-for-app-update": true,
+   "only-fixed": false,
+   "only-notfixed": false,
+   "platform": "",
+   "search": {
+    "scope": "Squashed",
+    "unindexed-archives": false,
+    "indexed-archives": true
+   },
+   "ignore": null,
+   "exclude": [],
+   "db": {
+    "cache-dir": "/Users/willmurphy/Library/Caches/grype/db",
+    "update-url": "https://toolbox-data.anchore.io/grype/databases/listing.json",
+    "ca-cert": "",
+    "auto-update": true,
+    "validate-by-hash-on-start": false,
+    "validate-age": true,
+    "max-allowed-built-age": 432000000000000
+   },
+   "externalSources": {
+    "enable": false,
+    "maven": {
+     "searchUpstreamBySha1": true,
+     "baseUrl": "https://search.maven.org/solrsearch/select"
+    }
+   },
+   "match": {
+    "java": {
+     "using-cpes": true
+    },
+    "dotnet": {
+     "using-cpes": true
+    },
+    "golang": {
+     "using-cpes": true
+    },
+    "javascript": {
+     "using-cpes": false
+    },
+    "python": {
+     "using-cpes": true
+    },
+    "ruby": {
+     "using-cpes": true
+    },
+    "stock": {
+     "using-cpes": true
+    }
+   },
+   "dev": {
+    "profile-cpu": false,
+    "profile-mem": false
+   },
+   "fail-on-severity": "",
+   "registry": {
+    "insecure-skip-tls-verify": false,
+    "insecure-use-http": false,
+    "auth": []
+   },
+   "log": {
+    "structured": false,
+    "level": "",
+    "file": ""
+   },
+   "show-suppressed": false,
+   "by-cve": false,
+   "name": "",
+   "default-image-pull-source": ""
+  },
+  "db": {
+   "built": "2023-08-31T01:24:19Z",
+   "schemaVersion": 5,
+   "location": "/Users/willmurphy/Library/Caches/grype/db/5",
+   "checksum": "sha256:911c05ea7c2a5f993758e5428c614914384c2a8265d7e2b0edb843799d62626c",
+   "error": null
+  },
+  "timestamp": "2023-08-31T15:13:32.377177-04:00"
+ }
+}
diff --git a/grype/presenter/explain/test-fixtures/keycloak-test.json b/grype/presenter/explain/test-fixtures/keycloak-test.json
new file mode 100644
index 00000000..c14db8cb
--- /dev/null
+++ b/grype/presenter/explain/test-fixtures/keycloak-test.json
@@ -0,0 +1,839 @@
+{
+ "matches": [{
+    "vulnerability": {
+      "id": "CVE-2020-12413",
+      "dataSource": "https://access.redhat.com/security/cve/CVE-2020-12413",
+      "namespace": "redhat:distro:redhat:9",
+      "severity": "Low",
+      "urls": [
+        "https://access.redhat.com/security/cve/CVE-2020-12413"
+      ],
+      "description": "A flaw was found in Mozilla nss. A raccoon attack exploits a flaw in the TLS specification which can lead to an attacker being able to compute the pre-master secret in connections which have used a Diffie-Hellman(DH) based ciphersuite. In such a case this would result in the attacker being able to eavesdrop on all encrypted communications sent over that TLS connection. The highest threat from this vulnerability is to data confidentiality.",
+      "cvss": [
+        {
+          "version": "3.1",
+          "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+          "metrics": {
+            "baseScore": 5.9,
+            "exploitabilityScore": 2.2,
+            "impactScore": 3.6
+          },
+          "vendorMetadata": {
+            "base_severity": "Medium",
+            "status": "draft"
+          }
+        }
+      ],
+      "fix": {
+        "versions": [],
+        "state": "wont-fix"
+      },
+      "advisories": []
+    },
+    "relatedVulnerabilities": [
+      {
+        "id": "CVE-2020-12413",
+        "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2020-12413",
+        "namespace": "nvd:cpe",
+        "severity": "Medium",
+        "urls": [
+          "https://bugzilla.mozilla.org/show_bug.cgi?id=CVE-2020-12413",
+          "https://raccoon-attack.com/"
+        ],
+        "description": "The Raccoon attack is a timing attack on DHE ciphersuites inherit in the TLS specification. To mitigate this vulnerability, Firefox disabled support for DHE ciphersuites.",
+        "cvss": [
+          {
+            "source": "nvd@nist.gov",
+            "type": "Primary",
+            "version": "3.1",
+            "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+            "metrics": {
+              "baseScore": 5.9,
+              "exploitabilityScore": 2.2,
+              "impactScore": 3.6
+            },
+            "vendorMetadata": {}
+          }
+        ]
+      }
+    ],
+    "matchDetails": [
+      {
+        "type": "exact-indirect-match",
+        "matcher": "rpm-matcher",
+        "searchedBy": {
+          "distro": {
+            "type": "redhat",
+            "version": "9.1"
+          },
+          "namespace": "redhat:distro:redhat:9",
+          "package": {
+            "name": "nss",
+            "version": "3.79.0-17.el9_1"
+          }
+        },
+        "found": {
+          "versionConstraint": "none (rpm)",
+          "vulnerabilityID": "CVE-2020-12413"
+        }
+      }
+    ],
+    "artifact": {
+      "id": "ff2aefb138ebd4bf",
+      "name": "nspr",
+      "version": "4.34.0-17.el9_1",
+      "type": "rpm",
+      "locations": [
+        {
+          "path": "/var/lib/rpm/rpmdb.sqlite",
+          "layerID": "sha256:798d91f89858e63627a98d3a196c9ee4d0899259c0f64b68b1e0260a67c9cd2b"
+        }
+      ],
+      "language": "",
+      "licenses": [
+        "MPLv2.0"
+      ],
+      "cpes": [
+        "cpe:2.3:a:redhat:nspr:4.34.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nspr:nspr:4.34.0-17.el9_1:*:*:*:*:*:*:*"
+      ],
+      "purl": "pkg:rpm/rhel/nspr@4.34.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1",
+      "upstreams": [
+        {
+          "name": "nss",
+          "version": "3.79.0-17.el9_1"
+        }
+      ],
+      "metadataType": "RpmMetadata",
+      "metadata": {
+        "epoch": null,
+        "modularityLabel": ""
+      }
+    }
+  },
+  {
+    "vulnerability": {
+      "id": "CVE-2020-12413",
+      "dataSource": "https://access.redhat.com/security/cve/CVE-2020-12413",
+      "namespace": "redhat:distro:redhat:9",
+      "severity": "Low",
+      "urls": [
+        "https://access.redhat.com/security/cve/CVE-2020-12413"
+      ],
+      "description": "A flaw was found in Mozilla nss. A raccoon attack exploits a flaw in the TLS specification which can lead to an attacker being able to compute the pre-master secret in connections which have used a Diffie-Hellman(DH) based ciphersuite. In such a case this would result in the attacker being able to eavesdrop on all encrypted communications sent over that TLS connection. The highest threat from this vulnerability is to data confidentiality.",
+      "cvss": [
+        {
+          "version": "3.1",
+          "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+          "metrics": {
+            "baseScore": 5.9,
+            "exploitabilityScore": 2.2,
+            "impactScore": 3.6
+          },
+          "vendorMetadata": {
+            "base_severity": "Medium",
+            "status": "draft"
+          }
+        }
+      ],
+      "fix": {
+        "versions": [],
+        "state": "wont-fix"
+      },
+      "advisories": []
+    },
+    "relatedVulnerabilities": [
+      {
+        "id": "CVE-2020-12413",
+        "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2020-12413",
+        "namespace": "nvd:cpe",
+        "severity": "Medium",
+        "urls": [
+          "https://bugzilla.mozilla.org/show_bug.cgi?id=CVE-2020-12413",
+          "https://raccoon-attack.com/"
+        ],
+        "description": "The Raccoon attack is a timing attack on DHE ciphersuites inherit in the TLS specification. To mitigate this vulnerability, Firefox disabled support for DHE ciphersuites.",
+        "cvss": [
+          {
+            "source": "nvd@nist.gov",
+            "type": "Primary",
+            "version": "3.1",
+            "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+            "metrics": {
+              "baseScore": 5.9,
+              "exploitabilityScore": 2.2,
+              "impactScore": 3.6
+            },
+            "vendorMetadata": {}
+          }
+        ]
+      }
+    ],
+    "matchDetails": [
+      {
+        "type": "exact-direct-match",
+        "matcher": "rpm-matcher",
+        "searchedBy": {
+          "distro": {
+            "type": "redhat",
+            "version": "9.1"
+          },
+          "namespace": "redhat:distro:redhat:9",
+          "package": {
+            "name": "nss",
+            "version": "0:3.79.0-17.el9_1"
+          }
+        },
+        "found": {
+          "versionConstraint": "none (rpm)",
+          "vulnerabilityID": "CVE-2020-12413"
+        }
+      }
+    ],
+    "artifact": {
+      "id": "840f8a931c86688f",
+      "name": "nss",
+      "version": "3.79.0-17.el9_1",
+      "type": "rpm",
+      "locations": [
+        {
+          "path": "/var/lib/rpm/rpmdb.sqlite",
+          "layerID": "sha256:798d91f89858e63627a98d3a196c9ee4d0899259c0f64b68b1e0260a67c9cd2b"
+        }
+      ],
+      "language": "",
+      "licenses": [
+        "MPLv2.0"
+      ],
+      "cpes": [
+        "cpe:2.3:a:redhat:nss:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss:nss:3.79.0-17.el9_1:*:*:*:*:*:*:*"
+      ],
+      "purl": "pkg:rpm/rhel/nss@3.79.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1",
+      "upstreams": [],
+      "metadataType": "RpmMetadata",
+      "metadata": {
+        "epoch": null,
+        "modularityLabel": ""
+      }
+    }
+  },
+  {
+    "vulnerability": {
+      "id": "CVE-2020-12413",
+      "dataSource": "https://access.redhat.com/security/cve/CVE-2020-12413",
+      "namespace": "redhat:distro:redhat:9",
+      "severity": "Low",
+      "urls": [
+        "https://access.redhat.com/security/cve/CVE-2020-12413"
+      ],
+      "description": "A flaw was found in Mozilla nss. A raccoon attack exploits a flaw in the TLS specification which can lead to an attacker being able to compute the pre-master secret in connections which have used a Diffie-Hellman(DH) based ciphersuite. In such a case this would result in the attacker being able to eavesdrop on all encrypted communications sent over that TLS connection. The highest threat from this vulnerability is to data confidentiality.",
+      "cvss": [
+        {
+          "version": "3.1",
+          "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+          "metrics": {
+            "baseScore": 5.9,
+            "exploitabilityScore": 2.2,
+            "impactScore": 3.6
+          },
+          "vendorMetadata": {
+            "base_severity": "Medium",
+            "status": "draft"
+          }
+        }
+      ],
+      "fix": {
+        "versions": [],
+        "state": "wont-fix"
+      },
+      "advisories": []
+    },
+    "relatedVulnerabilities": [
+      {
+        "id": "CVE-2020-12413",
+        "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2020-12413",
+        "namespace": "nvd:cpe",
+        "severity": "Medium",
+        "urls": [
+          "https://bugzilla.mozilla.org/show_bug.cgi?id=CVE-2020-12413",
+          "https://raccoon-attack.com/"
+        ],
+        "description": "The Raccoon attack is a timing attack on DHE ciphersuites inherit in the TLS specification. To mitigate this vulnerability, Firefox disabled support for DHE ciphersuites.",
+        "cvss": [
+          {
+            "source": "nvd@nist.gov",
+            "type": "Primary",
+            "version": "3.1",
+            "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+            "metrics": {
+              "baseScore": 5.9,
+              "exploitabilityScore": 2.2,
+              "impactScore": 3.6
+            },
+            "vendorMetadata": {}
+          }
+        ]
+      }
+    ],
+    "matchDetails": [
+      {
+        "type": "exact-indirect-match",
+        "matcher": "rpm-matcher",
+        "searchedBy": {
+          "distro": {
+            "type": "redhat",
+            "version": "9.1"
+          },
+          "namespace": "redhat:distro:redhat:9",
+          "package": {
+            "name": "nss",
+            "version": "3.79.0-17.el9_1"
+          }
+        },
+        "found": {
+          "versionConstraint": "none (rpm)",
+          "vulnerabilityID": "CVE-2020-12413"
+        }
+      }
+    ],
+    "artifact": {
+      "id": "7d1c659d9eb00024",
+      "name": "nss-softokn",
+      "version": "3.79.0-17.el9_1",
+      "type": "rpm",
+      "locations": [
+        {
+          "path": "/var/lib/rpm/rpmdb.sqlite",
+          "layerID": "sha256:798d91f89858e63627a98d3a196c9ee4d0899259c0f64b68b1e0260a67c9cd2b"
+        }
+      ],
+      "language": "",
+      "licenses": [
+        "MPLv2.0"
+      ],
+      "cpes": [
+        "cpe:2.3:a:nss-softokn:nss-softokn:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss-softokn:nss_softokn:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss_softokn:nss-softokn:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss_softokn:nss_softokn:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:redhat:nss-softokn:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:redhat:nss_softokn:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss:nss-softokn:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss:nss_softokn:3.79.0-17.el9_1:*:*:*:*:*:*:*"
+      ],
+      "purl": "pkg:rpm/rhel/nss-softokn@3.79.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1",
+      "upstreams": [
+        {
+          "name": "nss",
+          "version": "3.79.0-17.el9_1"
+        }
+      ],
+      "metadataType": "RpmMetadata",
+      "metadata": {
+        "epoch": null,
+        "modularityLabel": ""
+      }
+    }
+  },
+  {
+    "vulnerability": {
+      "id": "CVE-2020-12413",
+      "dataSource": "https://access.redhat.com/security/cve/CVE-2020-12413",
+      "namespace": "redhat:distro:redhat:9",
+      "severity": "Low",
+      "urls": [
+        "https://access.redhat.com/security/cve/CVE-2020-12413"
+      ],
+      "description": "A flaw was found in Mozilla nss. A raccoon attack exploits a flaw in the TLS specification which can lead to an attacker being able to compute the pre-master secret in connections which have used a Diffie-Hellman(DH) based ciphersuite. In such a case this would result in the attacker being able to eavesdrop on all encrypted communications sent over that TLS connection. The highest threat from this vulnerability is to data confidentiality.",
+      "cvss": [
+        {
+          "version": "3.1",
+          "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+          "metrics": {
+            "baseScore": 5.9,
+            "exploitabilityScore": 2.2,
+            "impactScore": 3.6
+          },
+          "vendorMetadata": {
+            "base_severity": "Medium",
+            "status": "draft"
+          }
+        }
+      ],
+      "fix": {
+        "versions": [],
+        "state": "wont-fix"
+      },
+      "advisories": []
+    },
+    "relatedVulnerabilities": [
+      {
+        "id": "CVE-2020-12413",
+        "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2020-12413",
+        "namespace": "nvd:cpe",
+        "severity": "Medium",
+        "urls": [
+          "https://bugzilla.mozilla.org/show_bug.cgi?id=CVE-2020-12413",
+          "https://raccoon-attack.com/"
+        ],
+        "description": "The Raccoon attack is a timing attack on DHE ciphersuites inherit in the TLS specification. To mitigate this vulnerability, Firefox disabled support for DHE ciphersuites.",
+        "cvss": [
+          {
+            "source": "nvd@nist.gov",
+            "type": "Primary",
+            "version": "3.1",
+            "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+            "metrics": {
+              "baseScore": 5.9,
+              "exploitabilityScore": 2.2,
+              "impactScore": 3.6
+            },
+            "vendorMetadata": {}
+          }
+        ]
+      }
+    ],
+    "matchDetails": [
+      {
+        "type": "exact-indirect-match",
+        "matcher": "rpm-matcher",
+        "searchedBy": {
+          "distro": {
+            "type": "redhat",
+            "version": "9.1"
+          },
+          "namespace": "redhat:distro:redhat:9",
+          "package": {
+            "name": "nss",
+            "version": "3.79.0-17.el9_1"
+          }
+        },
+        "found": {
+          "versionConstraint": "none (rpm)",
+          "vulnerabilityID": "CVE-2020-12413"
+        }
+      }
+    ],
+    "artifact": {
+      "id": "cb1f96627e29924e",
+      "name": "nss-softokn-freebl",
+      "version": "3.79.0-17.el9_1",
+      "type": "rpm",
+      "locations": [
+        {
+          "path": "/var/lib/rpm/rpmdb.sqlite",
+          "layerID": "sha256:798d91f89858e63627a98d3a196c9ee4d0899259c0f64b68b1e0260a67c9cd2b"
+        }
+      ],
+      "language": "",
+      "licenses": [
+        "MPLv2.0"
+      ],
+      "cpes": [
+        "cpe:2.3:a:nss-softokn-freebl:nss-softokn-freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss-softokn-freebl:nss_softokn_freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss_softokn_freebl:nss-softokn-freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss_softokn_freebl:nss_softokn_freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss-softokn:nss-softokn-freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss-softokn:nss_softokn_freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss_softokn:nss-softokn-freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss_softokn:nss_softokn_freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:redhat:nss-softokn-freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:redhat:nss_softokn_freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss:nss-softokn-freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss:nss_softokn_freebl:3.79.0-17.el9_1:*:*:*:*:*:*:*"
+      ],
+      "purl": "pkg:rpm/rhel/nss-softokn-freebl@3.79.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1",
+      "upstreams": [
+        {
+          "name": "nss",
+          "version": "3.79.0-17.el9_1"
+        }
+      ],
+      "metadataType": "RpmMetadata",
+      "metadata": {
+        "epoch": null,
+        "modularityLabel": ""
+      }
+    }
+  },
+  {
+    "vulnerability": {
+      "id": "CVE-2020-12413",
+      "dataSource": "https://access.redhat.com/security/cve/CVE-2020-12413",
+      "namespace": "redhat:distro:redhat:9",
+      "severity": "Low",
+      "urls": [
+        "https://access.redhat.com/security/cve/CVE-2020-12413"
+      ],
+      "description": "A flaw was found in Mozilla nss. A raccoon attack exploits a flaw in the TLS specification which can lead to an attacker being able to compute the pre-master secret in connections which have used a Diffie-Hellman(DH) based ciphersuite. In such a case this would result in the attacker being able to eavesdrop on all encrypted communications sent over that TLS connection. The highest threat from this vulnerability is to data confidentiality.",
+      "cvss": [
+        {
+          "version": "3.1",
+          "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+          "metrics": {
+            "baseScore": 5.9,
+            "exploitabilityScore": 2.2,
+            "impactScore": 3.6
+          },
+          "vendorMetadata": {
+            "base_severity": "Medium",
+            "status": "draft"
+          }
+        }
+      ],
+      "fix": {
+        "versions": [],
+        "state": "wont-fix"
+      },
+      "advisories": []
+    },
+    "relatedVulnerabilities": [
+      {
+        "id": "CVE-2020-12413",
+        "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2020-12413",
+        "namespace": "nvd:cpe",
+        "severity": "Medium",
+        "urls": [
+          "https://bugzilla.mozilla.org/show_bug.cgi?id=CVE-2020-12413",
+          "https://raccoon-attack.com/"
+        ],
+        "description": "The Raccoon attack is a timing attack on DHE ciphersuites inherit in the TLS specification. To mitigate this vulnerability, Firefox disabled support for DHE ciphersuites.",
+        "cvss": [
+          {
+            "source": "nvd@nist.gov",
+            "type": "Primary",
+            "version": "3.1",
+            "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+            "metrics": {
+              "baseScore": 5.9,
+              "exploitabilityScore": 2.2,
+              "impactScore": 3.6
+            },
+            "vendorMetadata": {}
+          }
+        ]
+      }
+    ],
+    "matchDetails": [
+      {
+        "type": "exact-indirect-match",
+        "matcher": "rpm-matcher",
+        "searchedBy": {
+          "distro": {
+            "type": "redhat",
+            "version": "9.1"
+          },
+          "namespace": "redhat:distro:redhat:9",
+          "package": {
+            "name": "nss",
+            "version": "3.79.0-17.el9_1"
+          }
+        },
+        "found": {
+          "versionConstraint": "none (rpm)",
+          "vulnerabilityID": "CVE-2020-12413"
+        }
+      }
+    ],
+    "artifact": {
+      "id": "d096d490e4fccf36",
+      "name": "nss-sysinit",
+      "version": "3.79.0-17.el9_1",
+      "type": "rpm",
+      "locations": [
+        {
+          "path": "/var/lib/rpm/rpmdb.sqlite",
+          "layerID": "sha256:798d91f89858e63627a98d3a196c9ee4d0899259c0f64b68b1e0260a67c9cd2b"
+        }
+      ],
+      "language": "",
+      "licenses": [
+        "MPLv2.0"
+      ],
+      "cpes": [
+        "cpe:2.3:a:nss-sysinit:nss-sysinit:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss-sysinit:nss_sysinit:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss_sysinit:nss-sysinit:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss_sysinit:nss_sysinit:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:redhat:nss-sysinit:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:redhat:nss_sysinit:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss:nss-sysinit:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss:nss_sysinit:3.79.0-17.el9_1:*:*:*:*:*:*:*"
+      ],
+      "purl": "pkg:rpm/rhel/nss-sysinit@3.79.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1",
+      "upstreams": [
+        {
+          "name": "nss",
+          "version": "3.79.0-17.el9_1"
+        }
+      ],
+      "metadataType": "RpmMetadata",
+      "metadata": {
+        "epoch": null,
+        "modularityLabel": ""
+      }
+    }
+  },
+  {
+    "vulnerability": {
+      "id": "CVE-2020-12413",
+      "dataSource": "https://access.redhat.com/security/cve/CVE-2020-12413",
+      "namespace": "redhat:distro:redhat:9",
+      "severity": "Low",
+      "urls": [
+        "https://access.redhat.com/security/cve/CVE-2020-12413"
+      ],
+      "description": "A flaw was found in Mozilla nss. A raccoon attack exploits a flaw in the TLS specification which can lead to an attacker being able to compute the pre-master secret in connections which have used a Diffie-Hellman(DH) based ciphersuite. In such a case this would result in the attacker being able to eavesdrop on all encrypted communications sent over that TLS connection. The highest threat from this vulnerability is to data confidentiality.",
+      "cvss": [
+        {
+          "version": "3.1",
+          "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+          "metrics": {
+            "baseScore": 5.9,
+            "exploitabilityScore": 2.2,
+            "impactScore": 3.6
+          },
+          "vendorMetadata": {
+            "base_severity": "Medium",
+            "status": "draft"
+          }
+        }
+      ],
+      "fix": {
+        "versions": [],
+        "state": "wont-fix"
+      },
+      "advisories": []
+    },
+    "relatedVulnerabilities": [
+      {
+        "id": "CVE-2020-12413",
+        "dataSource": "https://nvd.nist.gov/vuln/detail/CVE-2020-12413",
+        "namespace": "nvd:cpe",
+        "severity": "Medium",
+        "urls": [
+          "https://bugzilla.mozilla.org/show_bug.cgi?id=CVE-2020-12413",
+          "https://raccoon-attack.com/"
+        ],
+        "description": "The Raccoon attack is a timing attack on DHE ciphersuites inherit in the TLS specification. To mitigate this vulnerability, Firefox disabled support for DHE ciphersuites.",
+        "cvss": [
+          {
+            "source": "nvd@nist.gov",
+            "type": "Primary",
+            "version": "3.1",
+            "vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
+            "metrics": {
+              "baseScore": 5.9,
+              "exploitabilityScore": 2.2,
+              "impactScore": 3.6
+            },
+            "vendorMetadata": {}
+          }
+        ]
+      }
+    ],
+    "matchDetails": [
+      {
+        "type": "exact-indirect-match",
+        "matcher": "rpm-matcher",
+        "searchedBy": {
+          "distro": {
+            "type": "redhat",
+            "version": "9.1"
+          },
+          "namespace": "redhat:distro:redhat:9",
+          "package": {
+            "name": "nss",
+            "version": "3.79.0-17.el9_1"
+          }
+        },
+        "found": {
+          "versionConstraint": "none (rpm)",
+          "vulnerabilityID": "CVE-2020-12413"
+        }
+      }
+    ],
+    "artifact": {
+      "id": "641950c22b3f5035",
+      "name": "nss-util",
+      "version": "3.79.0-17.el9_1",
+      "type": "rpm",
+      "locations": [
+        {
+          "path": "/var/lib/rpm/rpmdb.sqlite",
+          "layerID": "sha256:798d91f89858e63627a98d3a196c9ee4d0899259c0f64b68b1e0260a67c9cd2b"
+        }
+      ],
+      "language": "",
+      "licenses": [
+        "MPLv2.0"
+      ],
+      "cpes": [
+        "cpe:2.3:a:nss-util:nss-util:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss-util:nss_util:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss_util:nss-util:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss_util:nss_util:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:redhat:nss-util:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:redhat:nss_util:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss:nss-util:3.79.0-17.el9_1:*:*:*:*:*:*:*",
+        "cpe:2.3:a:nss:nss_util:3.79.0-17.el9_1:*:*:*:*:*:*:*"
+      ],
+      "purl": "pkg:rpm/rhel/nss-util@3.79.0-17.el9_1?arch=x86_64&upstream=nss-3.79.0-17.el9_1.src.rpm&distro=rhel-9.1",
+      "upstreams": [
+        {
+          "name": "nss",
+          "version": "3.79.0-17.el9_1"
+        }
+      ],
+      "metadataType": "RpmMetadata",
+      "metadata": {
+        "epoch": null,
+        "modularityLabel": ""
+      }
+    }
+  }
+ ],
+ "source": {
+  "type": "image",
+  "target": {
+   "userInput": "docker.io/keycloak/keycloak:21.0.2@sha256:347a0d748d05a050dc64b92de2246d2240db6eb38afbc17c3c08d0acb0db1b50",
+   "imageID": "sha256:8cf8fd2be2ded92962d52adff75ad06a4c30f69c66facbdf223364c6c9e33b8c",
+   "manifestDigest": "sha256:d1630d3eb8285a978301bcefc5b223e564ae300750af2fc9ea3f413c5376a47e",
+   "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+   "tags": [],
+   "imageSize": 433836339,
+   "layers": [
+    {
+     "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+     "digest": "sha256:4e37aeaccb4c8016e381d6e2b2a0f22ea59985a7b9b8eca674726e8c60f2f51d",
+     "size": 24302817
+    },
+    {
+     "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+     "digest": "sha256:798d91f89858e63627a98d3a196c9ee4d0899259c0f64b68b1e0260a67c9cd2b",
+     "size": 222622091
+    },
+    {
+     "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+     "digest": "sha256:8329e422b4fd63ffd06518346e5f1f7b33e8190a79e5c321f9c50aba8651d30c",
+     "size": 186910556
+    },
+    {
+     "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
+     "digest": "sha256:987fd030ab2cd62944cb487df846c312b64dbb2a6a3131a81253c15a9da2a26c",
+     "size": 875
+    }
+   ],
+   "manifest": "eyJzY2hlbWFWZXJzaW9uIjoyLCJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuZG9ja2VyLmRpc3RyaWJ1dGlvbi5tYW5pZmVzdC52Mitqc29uIiwiY29uZmlnIjp7Im1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5kb2NrZXIuY29udGFpbmVyLmltYWdlLnYxK2pzb24iLCJzaXplIjo2NTY5LCJkaWdlc3QiOiJzaGEyNTY6OGNmOGZkMmJlMmRlZDkyOTYyZDUyYWRmZjc1YWQwNmE0YzMwZjY5YzY2ZmFjYmRmMjIzMzY0YzZjOWUzM2I4YyJ9LCJsYXllcnMiOlt7Im1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5kb2NrZXIuaW1hZ2Uucm9vdGZzLmRpZmYudGFyLmd6aXAiLCJzaXplIjoyNjEyNzg3MiwiZGlnZXN0Ijoic2hhMjU2OjRlMzdhZWFjY2I0YzgwMTZlMzgxZDZlMmIyYTBmMjJlYTU5OTg1YTdiOWI4ZWNhNjc0NzI2ZThjNjBmMmY1MWQifSx7Im1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5kb2NrZXIuaW1hZ2Uucm9vdGZzLmRpZmYudGFyLmd6aXAiLCJzaXplIjoyMjUxMTI1NzYsImRpZ2VzdCI6InNoYTI1Njo3OThkOTFmODk4NThlNjM2MjdhOThkM2ExOTZjOWVlNGQwODk5MjU5YzBmNjRiNjhiMWUwMjYwYTY3YzljZDJiIn0seyJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuZG9ja2VyLmltYWdlLnJvb3Rmcy5kaWZmLnRhci5nemlwIiwic2l6ZSI6MTg3MzE1MjAwLCJkaWdlc3QiOiJzaGEyNTY6ODMyOWU0MjJiNGZkNjNmZmQwNjUxODM0NmU1ZjFmN2IzM2U4MTkwYTc5ZTVjMzIxZjljNTBhYmE4NjUxZDMwYyJ9LHsibWVkaWFUeXBlIjoiYXBwbGljYXRpb24vdm5kLmRvY2tlci5pbWFnZS5yb290ZnMuZGlmZi50YXIuZ3ppcCIsInNpemUiOjQwOTYsImRpZ2VzdCI6InNoYTI1Njo5ODdmZDAzMGFiMmNkNjI5NDRjYjQ4N2RmODQ2YzMxMmI2NGRiYjJhNmEzMTMxYTgxMjUzYzE1YTlkYTJhMjZjIn1dfQ==",
+   "config": "eyJhcmNoaXRlY3R1cmUiOiJhbWQ2NCIsImNvbmZpZyI6eyJVc2VyIjoiMTAwMCIsIkV4cG9zZWRQb3J0cyI6eyI4MDgwL3RjcCI6e30sIjg0NDMvdGNwIjp7fX0sIkVudiI6WyJQQVRIPS91c3IvbG9jYWwvc2JpbjovdXNyL2xvY2FsL2JpbjovdXNyL3NiaW46L3Vzci9iaW46L3NiaW46L2JpbiIsIkxBTkc9ZW5fVVMuVVRGLTgiXSwiRW50cnlwb2ludCI6WyIvb3B0L2tleWNsb2FrL2Jpbi9rYy5zaCJdLCJMYWJlbHMiOnsiYXJjaGl0ZWN0dXJlIjoieDg2XzY0IiwiYnVpbGQtZGF0ZSI6IjIwMjMtMDItMjJUMTM6NTQ6MjUiLCJjb20ucmVkaGF0LmNvbXBvbmVudCI6InViaTktbWljcm8tY29udGFpbmVyIiwiY29tLnJlZGhhdC5saWNlbnNlX3Rlcm1zIjoiaHR0cHM6Ly93d3cucmVkaGF0LmNvbS9lbi9hYm91dC9yZWQtaGF0LWVuZC11c2VyLWxpY2Vuc2UtYWdyZWVtZW50cyNVQkkiLCJkZXNjcmlwdGlvbiI6IlZlcnkgc21hbGwgaW1hZ2Ugd2hpY2ggZG9lc24ndCBpbnN0YWxsIHRoZSBwYWNrYWdlIG1hbmFnZXIuIiwiZGlzdHJpYnV0aW9uLXNjb3BlIjoicHVibGljIiwiaW8uYnVpbGRhaC52ZXJzaW9uIjoiMS4yNy4zIiwiaW8uazhzLmRlc2NyaXB0aW9uIjoiVmVyeSBzbWFsbCBpbWFnZSB3aGljaCBkb2Vzbid0IGluc3RhbGwgdGhlIHBhY2thZ2UgbWFuYWdlci4iLCJpby5rOHMuZGlzcGxheS1uYW1lIjoiVWJpOS1taWNybyIsImlvLm9wZW5zaGlmdC5leHBvc2Utc2VydmljZXMiOiIiLCJtYWludGFpbmVyIjoiUmVkIEhhdCwgSW5jLiIsIm5hbWUiOiJ1Ymk5L3ViaS1taWNybyIsIm9yZy5vcGVuY29udGFpbmVycy5pbWFnZS5jcmVhdGVkIjoiMjAyMy0wMy0zMFQxMToxMjoxOC45ODVaIiwib3JnLm9wZW5jb250YWluZXJzLmltYWdlLmRlc2NyaXB0aW9uIjoiIiwib3JnLm9wZW5jb250YWluZXJzLmltYWdlLmxpY2Vuc2VzIjoiQXBhY2hlLTIuMCIsIm9yZy5vcGVuY29udGFpbmVycy5pbWFnZS5yZXZpc2lvbiI6ImIzNTJhMWY2ZThiYTkyYTA0NWI1OWNjOGRlZDE4NWUzYjFkMjYxNTUiLCJvcmcub3BlbmNvbnRhaW5lcnMuaW1hZ2Uuc291cmNlIjoiaHR0cHM6Ly9naXRodWIuY29tL2tleWNsb2FrLXJlbC9rZXljbG9hay1yZWwiLCJvcmcub3BlbmNvbnRhaW5lcnMuaW1hZ2UudGl0bGUiOiJrZXljbG9hay1yZWwiLCJvcmcub3BlbmNvbnRhaW5lcnMuaW1hZ2UudXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL2tleWNsb2FrLXJlbC9rZXljbG9hay1yZWwiLCJvcmcub3BlbmNvbnRhaW5lcnMuaW1hZ2UudmVyc2lvbiI6IjIxLjAuMiIsInJlbGVhc2UiOiIxNSIsInN1bW1hcnkiOiJ1Ymk5IG1pY3JvIGltYWdlIiwidXJsIjoiaHR0cHM6Ly9hY2Nlc3MucmVkaGF0LmNvbS9jb250YWluZXJzLyMvcmVnaXN0cnkuYWNjZXNzLnJlZGhhdC5jb20vdWJpOS91YmktbWljcm8vaW1hZ2VzLzkuMS4wLTE1IiwidmNzLXJlZiI6ImM1NjNlMDkxZTBjN2JkNWE2OWIyYTQ2OTkwZGRhNGY1OTU5NWFhMzciLCJ2Y3MtdHlwZSI6ImdpdCIsInZlbmRvciI6IlJlZCBIYXQsIEluYy4iLCJ2ZXJzaW9uIjoiOS4xLjAifSwiT25CdWlsZCI6bnVsbH0sImNyZWF0ZWQiOiIyMDIzLTAzLTMwVDExOjEzOjE0LjYyOTk2NjA5NFoiLCJoaXN0b3J5IjpbeyJjcmVhdGVkIjoiMjAyMy0wMi0yMlQxMzo1NjowMy45Mzc2NTUxODNaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIExBQkVMIG1haW50YWluZXI9XCJSZWQgSGF0LCBJbmMuXCIiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkIjoiMjAyMy0wMi0yMlQxMzo1NjowMy45Mzc3ODIxNDlaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIExBQkVMIGNvbS5yZWRoYXQuY29tcG9uZW50PVwidWJpOS1taWNyby1jb250YWluZXJcIiIsImVtcHR5X2xheWVyIjp0cnVlfSx7ImNyZWF0ZWQiOiIyMDIzLTAyLTIyVDEzOjU2OjAzLjkzNzgwODc4M1oiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgTEFCRUwgbmFtZT1cInViaTkvdWJpLW1pY3JvXCIiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkIjoiMjAyMy0wMi0yMlQxMzo1NjowMy45Mzc4NTM2MzVaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIExBQkVMIHZlcnNpb249XCI5LjEuMFwiIiwiZW1wdHlfbGF5ZXIiOnRydWV9LHsiY3JlYXRlZCI6IjIwMjMtMDItMjJUMTM6NTY6MDMuOTM3OTMwMjE1WiIsImNyZWF0ZWRfYnkiOiIvYmluL3NoIC1jICMobm9wKSBMQUJFTCBjb20ucmVkaGF0LmxpY2Vuc2VfdGVybXM9XCJodHRwczovL3d3dy5yZWRoYXQuY29tL2VuL2Fib3V0L3JlZC1oYXQtZW5kLXVzZXItbGljZW5zZS1hZ3JlZW1lbnRzI1VCSVwiIiwiZW1wdHlfbGF5ZXIiOnRydWV9LHsiY3JlYXRlZCI6IjIwMjMtMDItMjJUMTM6NTY6MDMuOTM3OTY5MTY5WiIsImNyZWF0ZWRfYnkiOiIvYmluL3NoIC1jICMobm9wKSBMQUJFTCBzdW1tYXJ5PVwidWJpOSBtaWNybyBpbWFnZVwiIiwiZW1wdHlfbGF5ZXIiOnRydWV9LHsiY3JlYXRlZCI6IjIwMjMtMDItMjJUMTM6NTY6MDMuOTM4MDAzNDAyWiIsImNyZWF0ZWRfYnkiOiIvYmluL3NoIC1jICMobm9wKSBMQUJFTCBkZXNjcmlwdGlvbj1cIlZlcnkgc21hbGwgaW1hZ2Ugd2hpY2ggZG9lc24ndCBpbnN0YWxsIHRoZSBwYWNrYWdlIG1hbmFnZXIuXCIiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkIjoiMjAyMy0wMi0yMlQxMzo1NjowMy45MzgyMjU4ODRaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIExBQkVMIGlvLms4cy5kaXNwbGF5LW5hbWU9XCJVYmk5LW1pY3JvXCIiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkIjoiMjAyMy0wMi0yMlQxMzo1NjowMy45MzgzMjAxOTlaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIExBQkVMIGlvLm9wZW5zaGlmdC5leHBvc2Utc2VydmljZXM9XCJcIiIsImVtcHR5X2xheWVyIjp0cnVlfSx7ImNyZWF0ZWQiOiIyMDIzLTAyLTIyVDEzOjU2OjA0LjkxOTA1OTE2OFoiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgQ09QWSBkaXI6MWMzNjc0ODA2NTg5ZWRhYmNiNTg1NGIxOThhMjlkZGU3ODZhYmI3MmU4YTU5NTA4OGRmNTI3Mjg1MzVjMTk5ZiBpbiAvICIsImVtcHR5X2xheWVyIjp0cnVlfSx7ImNyZWF0ZWQiOiIyMDIzLTAyLTIyVDEzOjU2OjA1LjE4OTA4MTIyM1oiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgQ09QWSBmaWxlOmVlYzczZjg1OWM2ZTdmNmM4YTk0MjdlY2M1MjQ5NTA0ZmU4OWFlNTRkYzNhMTUyMWI0NDI2NzRhOTA0OTdkMzIgaW4gL2V0Yy95dW0ucmVwb3MuZC91YmkucmVwbyAiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkIjoiMjAyMy0wMi0yMlQxMzo1NjowNS4xODkxMTM0MDRaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIENNRCAvYmluL3NoIiwiZW1wdHlfbGF5ZXIiOnRydWV9LHsiY3JlYXRlZCI6IjIwMjMtMDItMjJUMTM6NTY6MDUuMTg5MTkyNDZaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIExBQkVMIHJlbGVhc2U9MTUiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkIjoiMjAyMy0wMi0yMlQxMzo1NjowNS40NTY0OTQ0MjRaIiwiY3JlYXRlZF9ieSI6Ii9iaW4vc2ggLWMgIyhub3ApIEFERCBmaWxlOjI0ODgwMDQ4ZTQ5YzMwZDJlNWQ2ZGQ3M2VmNzg5NGU2OTIyMTczN2M5MmM0YWNjNDgxYWMxNmY1NDZjZWE3OTEgaW4gL3Jvb3QvYnVpbGRpbmZvL2NvbnRlbnRfbWFuaWZlc3RzL3ViaTktbWljcm8tY29udGFpbmVyLTkuMS4wLTE1Lmpzb24gIiwiZW1wdHlfbGF5ZXIiOnRydWV9LHsiY3JlYXRlZCI6IjIwMjMtMDItMjJUMTM6NTY6MDUuNzM0NTA1MjUyWiIsImNyZWF0ZWRfYnkiOiIvYmluL3NoIC1jICMobm9wKSBBREQgZmlsZTpjYTYxZjYyNzUwMTQ4MWFlZjYyNzcwZjM4ZmY0NjQyNzY2MzcwZDBiYjE2ZjM0MzBlZjI4NzkyNzcxMDAwYjE0IGluIC9yb290L2J1aWxkaW5mby9Eb2NrZXJmaWxlLXViaTktdWJpLW1pY3JvLTkuMS4wLTE1ICIsImVtcHR5X2xheWVyIjp0cnVlfSx7ImNyZWF0ZWQiOiIyMDIzLTAyLTIyVDEzOjU2OjA1Ljk3NjcyMjE4OFoiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgTEFCRUwgXCJkaXN0cmlidXRpb24tc2NvcGVcIj1cInB1YmxpY1wiIFwidmVuZG9yXCI9XCJSZWQgSGF0LCBJbmMuXCIgXCJidWlsZC1kYXRlXCI9XCIyMDIzLTAyLTIyVDEzOjU0OjI1XCIgXCJhcmNoaXRlY3R1cmVcIj1cIng4Nl82NFwiIFwidmNzLXR5cGVcIj1cImdpdFwiIFwidmNzLXJlZlwiPVwiYzU2M2UwOTFlMGM3YmQ1YTY5YjJhNDY5OTBkZGE0ZjU5NTk1YWEzN1wiIFwiaW8uazhzLmRlc2NyaXB0aW9uXCI9XCJWZXJ5IHNtYWxsIGltYWdlIHdoaWNoIGRvZXNuJ3QgaW5zdGFsbCB0aGUgcGFja2FnZSBtYW5hZ2VyLlwiIFwidXJsXCI9XCJodHRwczovL2FjY2Vzcy5yZWRoYXQuY29tL2NvbnRhaW5lcnMvIy9yZWdpc3RyeS5hY2Nlc3MucmVkaGF0LmNvbS91Ymk5L3ViaS1taWNyby9pbWFnZXMvOS4xLjAtMTVcIiJ9LHsiY3JlYXRlZCI6IjIwMjMtMDMtMzBUMTE6MTM6MTMuNjYwNjQ3MjY5WiIsImNyZWF0ZWRfYnkiOiJFTlYgTEFORz1lbl9VUy5VVEYtOCIsImNvbW1lbnQiOiJidWlsZGtpdC5kb2NrZXJmaWxlLnYwIiwiZW1wdHlfbGF5ZXIiOnRydWV9LHsiY3JlYXRlZCI6IjIwMjMtMDMtMzBUMTE6MTM6MTMuNjYwNjQ3MjY5WiIsImNyZWF0ZWRfYnkiOiJDT1BZIC90bXAvbnVsbC9yb290ZnMvIC8gIyBidWlsZGtpdCIsImNvbW1lbnQiOiJidWlsZGtpdC5kb2NrZXJmaWxlLnYwIn0seyJjcmVhdGVkIjoiMjAyMy0wMy0zMFQxMToxMzoxNC41NDg0MDQ5NzZaIiwiY3JlYXRlZF9ieSI6IkNPUFkgL29wdC9rZXljbG9hayAvb3B0L2tleWNsb2FrICMgYnVpbGRraXQiLCJjb21tZW50IjoiYnVpbGRraXQuZG9ja2VyZmlsZS52MCJ9LHsiY3JlYXRlZCI6IjIwMjMtMDMtMzBUMTE6MTM6MTQuNjI5OTY2MDk0WiIsImNyZWF0ZWRfYnkiOiJSVU4gL2Jpbi9zaCAtYyBlY2hvIFwia2V5Y2xvYWs6eDowOnJvb3RcIiBcdTAwM2VcdTAwM2UgL2V0Yy9ncm91cCBcdTAwMjZcdTAwMjYgICAgIGVjaG8gXCJrZXljbG9hazp4OjEwMDA6MDprZXljbG9hayB1c2VyOi9vcHQva2V5Y2xvYWs6L3NiaW4vbm9sb2dpblwiIFx1MDAzZVx1MDAzZSAvZXRjL3Bhc3N3ZCAjIGJ1aWxka2l0IiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAifSx7ImNyZWF0ZWQiOiIyMDIzLTAzLTMwVDExOjEzOjE0LjYyOTk2NjA5NFoiLCJjcmVhdGVkX2J5IjoiVVNFUiAxMDAwIiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkIjoiMjAyMy0wMy0zMFQxMToxMzoxNC42Mjk5NjYwOTRaIiwiY3JlYXRlZF9ieSI6IkVYUE9TRSBtYXBbODA4MC90Y3A6e31dIiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkIjoiMjAyMy0wMy0zMFQxMToxMzoxNC42Mjk5NjYwOTRaIiwiY3JlYXRlZF9ieSI6IkVYUE9TRSBtYXBbODQ0My90Y3A6e31dIiwiY29tbWVudCI6ImJ1aWxka2l0LmRvY2tlcmZpbGUudjAiLCJlbXB0eV9sYXllciI6dHJ1ZX0seyJjcmVhdGVkIjoiMjAyMy0wMy0zMFQxMToxMzoxNC42Mjk5NjYwOTRaIiwiY3JlYXRlZF9ieSI6IkVOVFJZUE9JTlQgW1wiL29wdC9rZXljbG9hay9iaW4va2Muc2hcIl0iLCJjb21tZW50IjoiYnVpbGRraXQuZG9ja2VyZmlsZS52MCIsImVtcHR5X2xheWVyIjp0cnVlfV0sIm1vYnkuYnVpbGRraXQuYnVpbGRpbmZvLnYxIjoiZXlKbWNtOXVkR1Z1WkNJNkltUnZZMnRsY21acGJHVXVkakFpTENKemIzVnlZMlZ6SWpwYmV5SjBlWEJsSWpvaVpHOWphMlZ5TFdsdFlXZGxJaXdpY21WbUlqb2ljbVZuYVhOMGNua3VZV05qWlhOekxuSmxaR2hoZEM1amIyMHZkV0pwT1MxdGFXTnlienBzWVhSbGMzUWlMQ0p3YVc0aU9pSnphR0V5TlRZNk5UTTJOemszTVRRNVlqSmxOakUwWXpFMll6RTRaRE00WVdVM1pqVXdPRGc1TWprM1pUa3lZVGRqWXpSaE5qQXlORGt4TkRjM1pHUmpNMkppTURZeFppSjlMSHNpZEhsd1pTSTZJbVJ2WTJ0bGNpMXBiV0ZuWlNJc0luSmxaaUk2SW5KbFoybHpkSEo1TG1GalkyVnpjeTV5WldSb1lYUXVZMjl0TDNWaWFUazZiR0YwWlhOMElpd2ljR2x1SWpvaWMyaGhNalUyT21OaU16QXpOREEwWlRVM05tWm1OVFV5T0dRMFpqQTRZakV5WVdRNE5XWmhZamhtTmpGbVlUbGxOV1JpWVRZM1lqTTNZakV4T1dSaU1qUTROalZrWmpNaWZWMTkiLCJvcyI6ImxpbnV4Iiwicm9vdGZzIjp7InR5cGUiOiJsYXllcnMiLCJkaWZmX2lkcyI6WyJzaGEyNTY6NGUzN2FlYWNjYjRjODAxNmUzODFkNmUyYjJhMGYyMmVhNTk5ODVhN2I5YjhlY2E2NzQ3MjZlOGM2MGYyZjUxZCIsInNoYTI1Njo3OThkOTFmODk4NThlNjM2MjdhOThkM2ExOTZjOWVlNGQwODk5MjU5YzBmNjRiNjhiMWUwMjYwYTY3YzljZDJiIiwic2hhMjU2OjgzMjllNDIyYjRmZDYzZmZkMDY1MTgzNDZlNWYxZjdiMzNlODE5MGE3OWU1YzMyMWY5YzUwYWJhODY1MWQzMGMiLCJzaGEyNTY6OTg3ZmQwMzBhYjJjZDYyOTQ0Y2I0ODdkZjg0NmMzMTJiNjRkYmIyYTZhMzEzMWE4MTI1M2MxNWE5ZGEyYTI2YyJdfX0=",
+   "repoDigests": [
+    "keycloak/keycloak@sha256:347a0d748d05a050dc64b92de2246d2240db6eb38afbc17c3c08d0acb0db1b50"
+   ],
+   "architecture": "amd64",
+   "os": "linux"
+  }
+ },
+ "distro": {
+  "name": "redhat",
+  "version": "9.1",
+  "idLike": [
+   "fedora"
+  ]
+ },
+ "descriptor": {
+  "name": "grype",
+  "version": "0.66.0",
+  "configuration": {
+   "configPath": "",
+   "verbosity": 0,
+   "output": [
+    "json"
+   ],
+   "file": "",
+   "distro": "",
+   "add-cpes-if-none": false,
+   "output-template-file": "",
+   "quiet": true,
+   "check-for-app-update": true,
+   "only-fixed": false,
+   "only-notfixed": false,
+   "platform": "",
+   "search": {
+    "scope": "Squashed",
+    "unindexed-archives": false,
+    "indexed-archives": true
+   },
+   "ignore": null,
+   "exclude": [],
+   "db": {
+    "cache-dir": "/Users/willmurphy/Library/Caches/grype/db",
+    "update-url": "https://toolbox-data.anchore.io/grype/databases/listing.json",
+    "ca-cert": "",
+    "auto-update": true,
+    "validate-by-hash-on-start": false,
+    "validate-age": true,
+    "max-allowed-built-age": 432000000000000
+   },
+   "externalSources": {
+    "enable": false,
+    "maven": {
+     "searchUpstreamBySha1": true,
+     "baseUrl": "https://search.maven.org/solrsearch/select"
+    }
+   },
+   "match": {
+    "java": {
+     "using-cpes": true
+    },
+    "dotnet": {
+     "using-cpes": true
+    },
+    "golang": {
+     "using-cpes": true
+    },
+    "javascript": {
+     "using-cpes": false
+    },
+    "python": {
+     "using-cpes": true
+    },
+    "ruby": {
+     "using-cpes": true
+    },
+    "stock": {
+     "using-cpes": true
+    }
+   },
+   "dev": {
+    "profile-cpu": false,
+    "profile-mem": false
+   },
+   "fail-on-severity": "",
+   "registry": {
+    "insecure-skip-tls-verify": false,
+    "insecure-use-http": false,
+    "auth": [],
+    "ca-cert": ""
+   },
+   "log": {
+    "structured": false,
+    "level": "",
+    "file": ""
+   },
+   "show-suppressed": false,
+   "by-cve": false,
+   "name": "",
+   "default-image-pull-source": ""
+  },
+  "db": {
+   "built": "2023-09-01T01:26:55Z",
+   "schemaVersion": 5,
+   "location": "/Users/willmurphy/Library/Caches/grype/db/5",
+   "checksum": "sha256:5db8bddae95f375db7186527c7554311e9ddc41e815ef2dbc28dc5d206ef2c7b",
+   "error": null
+  },
+  "timestamp": "2023-09-01T08:13:42.20194-04:00"
+ }
+}
diff --git a/grype/presenter/json/presenter_test.go b/grype/presenter/json/presenter_test.go
index 3f09c4f7..9b2add41 100644
--- a/grype/presenter/json/presenter_test.go
+++ b/grype/presenter/json/presenter_test.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/go-testutils"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
@@ -26,6 +27,10 @@ func TestJsonImgsPresenter(t *testing.T) {
 	matches, packages, context, metadataProvider, _, _ := internal.GenerateAnalysis(t, internal.ImageSource)
 
 	pb := models.PresenterConfig{
+		ID: clio.Identification{
+			Name:    "grype",
+			Version: "[not provided]",
+		},
 		Matches:          matches,
 		Packages:         packages,
 		Context:          context,
@@ -59,6 +64,10 @@ func TestJsonDirsPresenter(t *testing.T) {
 	matches, packages, context, metadataProvider, _, _ := internal.GenerateAnalysis(t, internal.DirectorySource)
 
 	pb := models.PresenterConfig{
+		ID: clio.Identification{
+			Name:    "grype",
+			Version: "[not provided]",
+		},
 		Matches:          matches,
 		Packages:         packages,
 		Context:          context,
@@ -102,6 +111,10 @@ func TestEmptyJsonPresenter(t *testing.T) {
 	}
 
 	pb := models.PresenterConfig{
+		ID: clio.Identification{
+			Name:    "grype",
+			Version: "[not provided]",
+		},
 		Matches:          matches,
 		Packages:         nil,
 		Context:          ctx,
@@ -129,7 +142,7 @@ func TestEmptyJsonPresenter(t *testing.T) {
 
 func TestPresenter_Present_NewDocumentSorted(t *testing.T) {
 	matches, packages, context, metadataProvider, appConfig, dbStatus := internal.GenerateAnalysis(t, internal.ImageSource)
-	doc, err := models.NewDocument(packages, context, matches, nil, metadataProvider, appConfig, dbStatus)
+	doc, err := models.NewDocument(clio.Identification{}, packages, context, matches, nil, metadataProvider, appConfig, dbStatus)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/grype/presenter/models/document_test.go b/grype/presenter/models/document_test.go
index e52aa1b7..9c983617 100644
--- a/grype/presenter/models/document_test.go
+++ b/grype/presenter/models/document_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/grype/grype/match"
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/vulnerability"
@@ -81,7 +82,7 @@ func TestPackagesAreSorted(t *testing.T) {
 			Version: "8.0",
 		},
 	}
-	doc, err := NewDocument(packages, ctx, matches, nil, NewMetadataMock(), nil, nil)
+	doc, err := NewDocument(clio.Identification{}, packages, ctx, matches, nil, NewMetadataMock(), nil, nil)
 	if err != nil {
 		t.Fatalf("unable to get document: %+v", err)
 	}
@@ -103,7 +104,7 @@ func TestTimestampValidFormat(t *testing.T) {
 		Distro: nil,
 	}
 
-	doc, err := NewDocument(nil, ctx, matches, nil, nil, nil, nil)
+	doc, err := NewDocument(clio.Identification{}, nil, ctx, matches, nil, nil, nil, nil)
 	if err != nil {
 		t.Fatalf("unable to get document: %+v", err)
 	}
diff --git a/grype/presenter/sarif/presenter_test.go b/grype/presenter/sarif/presenter_test.go
index f770f569..ffb2c9b1 100644
--- a/grype/presenter/sarif/presenter_test.go
+++ b/grype/presenter/sarif/presenter_test.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/go-testutils"
 	"github.com/anchore/grype/grype/pkg"
 	"github.com/anchore/grype/grype/presenter/internal"
@@ -17,7 +18,7 @@ import (
 	"github.com/anchore/syft/syft/source"
 )
 
-var update = flag.Bool("update", false, "update .golden files for sarif presenters")
+var updateSnapshot = flag.Bool("update-sarif", false, "update .golden files for sarif presenters")
 
 func TestSarifPresenter(t *testing.T) {
 	tests := []struct {
@@ -41,6 +42,9 @@ func TestSarifPresenter(t *testing.T) {
 			matches, packages, context, metadataProvider, _, _ := internal.GenerateAnalysis(t, tc.scheme)
 
 			pb := models.PresenterConfig{
+				ID: clio.Identification{
+					Name: "grype",
+				},
 				Matches:          matches,
 				Packages:         packages,
 				Context:          context,
@@ -54,7 +58,7 @@ func TestSarifPresenter(t *testing.T) {
 			}
 
 			actual := buffer.Bytes()
-			if *update {
+			if *updateSnapshot {
 				testutils.UpdateGoldenFileContents(t, actual)
 			}
 
diff --git a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden
index c8bb7eca..03b93514 100644
--- a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden
+++ b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_directory.golden
@@ -5,7 +5,7 @@
     {
       "tool": {
         "driver": {
-          "name": "Grype",
+          "name": "grype",
           "version": "0.0.0-dev",
           "informationUri": "https://github.com/anchore/grype",
           "rules": [
diff --git a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden
index 488d7a9a..239843b3 100644
--- a/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden
+++ b/grype/presenter/sarif/test-fixtures/snapshot/TestSarifPresenter_image.golden
@@ -5,7 +5,7 @@
     {
       "tool": {
         "driver": {
-          "name": "Grype",
+          "name": "grype",
           "version": "0.0.0-dev",
           "informationUri": "https://github.com/anchore/grype",
           "rules": [
diff --git a/test/cli/cmd_test.go b/test/cli/cmd_test.go
index 88f73dd4..3eecde0c 100644
--- a/test/cli/cmd_test.go
+++ b/test/cli/cmd_test.go
@@ -39,7 +39,7 @@ func TestCmd(t *testing.T) {
 		},
 		{
 			name: "responds-to-search-options",
-			args: []string{"-vv"},
+			args: []string{"--help"},
 			env: map[string]string{
 				"GRYPE_SEARCH_UNINDEXED_ARCHIVES": "true",
 				"GRYPE_SEARCH_INDEXED_ARCHIVES":   "false",
@@ -51,7 +51,7 @@ func TestCmd(t *testing.T) {
 				// package-cataloger-level options.
 				assertInOutput("unindexed-archives: true"),
 				assertInOutput("indexed-archives: false"),
-				assertInOutput("scope: all-layers"),
+				assertInOutput("scope: 'all-layers'"),
 			},
 		},
 		{
diff --git a/test/cli/registry_auth_test.go b/test/cli/registry_auth_test.go
index 0d6b0c88..f1e1e66a 100644
--- a/test/cli/registry_auth_test.go
+++ b/test/cli/registry_auth_test.go
@@ -40,7 +40,7 @@ func TestRegistryAuth(t *testing.T) {
 			args: []string{"-vv", "registry:localhost:5000/something:latest"},
 			env: map[string]string{
 				"GRYPE_REGISTRY_AUTH_AUTHORITY": "localhost:5000",
-				"GRYPE_REGISTRY_AUTH_TOKEN":     "token",
+				"GRYPE_REGISTRY_AUTH_TOKEN":     "my-token",
 			},
 			assertions: []traitAssertion{
 				assertInOutput("source=OciRegistry"),