Repository: anchore/grype
Pull request #1437 (2023-08-15 20:36:02)

chore: update CLI to CLIO
This PR updates the CLI to use CLIO.

Fixes: #1427 


Patch:
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 93be965d..00000000
--- a/cmd/grype/cli/legacy/cmd.go
+++ /dev/null
@@ -1,137 +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 NewCli() *cobra.Command {
-	rootCmd.AddCommand(explainCmd)
-	return rootCmd
-}
-
-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/explain.go b/cmd/grype/cli/legacy/explain.go
deleted file mode 100644
index d248957e..00000000
--- a/cmd/grype/cli/legacy/explain.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package legacy
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-
-	"github.com/spf13/cobra"
-
-	"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"
-)
-
-var cveIDs []string
-
-var explainCmd = &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(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 ...'")
-	},
-}
-
-func init() {
-	setExplainFlags(explainCmd)
-}
-
-func setExplainFlags(cmd *cobra.Command) {
-	cmd.Flags().StringArrayVarP(&cveIDs, "id", "", nil, "CVE ID to explain")
-}
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 b648df4a..6db79329 100644
--- a/cmd/grype/main.go
+++ b/cmd/grype/main.go
@@ -1,17 +1,34 @@
 package main
 
 import (
-	"os"
+	_ "modernc.org/sqlite"
 
-	"github.com/anchore/grype/cmd/grype/cli/legacy"
-	"github.com/anchore/grype/internal/log"
+	"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() {
-	cli := legacy.NewCli()
-	err := cli.Execute()
-	if err != nil {
-		log.Error(err)
-		os.Exit(1)
-	}
+	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/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/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/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"),