Repository: anchore/grype (go)

Issue:
Add DB providers command
It should be trivial to see what data providers (from vunnel) populated the DB, along with timestamp information:
```
grype db providers

alpine       2024-09-16T01:34:04.087474Z      ...
amazon       2024-09-16T01:34:04.087474Z      ...
chainguard   2024-10-16T01:34:04.087474Z      ...
debian       2024-09-16T01:34:04.087474Z      ...
github       2024-09-28T01:34:04.087474Z      ...
mariner      2024-09-16T01:34:04.087474Z
nvd          2023-11-16T01:34:04.087474Z
oracle       2024-09-16T01:34:04.087474Z
rhel         2024-09-16T01:34:04.087474Z
sles         2024-09-16T01:34:04.087474Z
ubuntu       2024-09-16T01:34:04.087474Z
wolfi        2024-09-16T01:34:04.087474Z
```

This should be available as:
- table output
- json output

Example JSON output:
```json
{
  "providers": [
    {
      "name": "nvd",
      "dateCaptured": "2024-09-16T01:34:04.087474Z",
      ...
    },
    ...
  ]
}
```

Ideally we'd capture vunnel version information either in bulk or with each provider. For instance, see the prototype information capture here: https://github.com/anchore/grype/blob/db-v6-blob-store/grype/db/v6/models.go#L71-L95



Pull request description:
Add `grype db providers` command
# Issue

resolves #2131 

# Changes

- Provider data is read from `provider-metadata.json` file.
- Added flag `-o`/`--output` flags which accept `json` and `table`.
- Display logic is separate from the provider data collection. 
- Can be easily updated for db schema `v6`.
- Fails gracefully, with relevant error message, for absence of `provider-metadata.json` file.
- Updated `Readme.md` for the new command.

Patch:
diff --git a/.binny.yaml b/.binny.yaml
index 2dbc53b0..18ea492e 100644
--- a/.binny.yaml
+++ b/.binny.yaml
@@ -98,7 +98,7 @@ tools:
   # used for triggering a release
   - name: gh
     version:
-      want: v2.59.0
+      want: v2.60.1
     method: github-release
     with:
       repo: cli/cli
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index beb9d2ac..d0836156 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -43,7 +43,7 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
 
     - name: Utilize Go Module Cache
       uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2
@@ -56,14 +56,14 @@ jobs:
           ${{ runner.os }}-go-
 
     - name: Set correct version of Golang to use during CodeQL run
-      uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
+      uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
       with:
         go-version: '1.21'
         check-latest: true
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13
+      uses: github/codeql-action/init@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
       with:
         languages: ${{ matrix.language }}
         # If you wish to specify custom queries, you can do so here or in a config file.
@@ -85,4 +85,4 @@ jobs:
       run: make grype
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13
+      uses: github/codeql-action/analyze@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
diff --git a/.github/workflows/release-version-file.yaml b/.github/workflows/release-version-file.yaml
index 41a046b7..97879b30 100644
--- a/.github/workflows/release-version-file.yaml
+++ b/.github/workflows/release-version-file.yaml
@@ -23,7 +23,7 @@ jobs:
   release:
     runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
 
       - name: Update version file
         run: make ci-release-version-file
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index fe51e8ae..ee816417 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -14,7 +14,7 @@ jobs:
     environment: release
     runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
 
       - name: Check if running on main
         if: github.ref != 'refs/heads/main'
@@ -24,7 +24,7 @@ jobs:
         # at the time of when the OIDC token was issued on the Github Actions runner.
         run: echo "This can only be run on the main branch otherwise releases produced will not be verifiable with cosign" && exit 1
 
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
 
       - name: Check if pinned syft is a release version
         run: .github/scripts/check-syft-version-is-release.sh
@@ -112,7 +112,7 @@ jobs:
       packages: write
       id-token: write
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
         with:
           fetch-depth: 0
 
diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml
index 4f905525..010fda8f 100644
--- a/.github/workflows/scorecards.yml
+++ b/.github/workflows/scorecards.yml
@@ -20,7 +20,7 @@ jobs:
 
     steps:
       - name: "Checkout code"
-        uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
         with:
           persist-credentials: false
 
@@ -38,6 +38,6 @@ jobs:
 
       # Upload the results to GitHub's code scanning dashboard.
       - name: "Upload to code-scanning"
-        uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v1.0.26
+        uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v1.0.26
         with:
           sarif_file: results.sarif
diff --git a/.github/workflows/update-bootstrap-tools.yml b/.github/workflows/update-bootstrap-tools.yml
index e8225a4f..b53ad031 100644
--- a/.github/workflows/update-bootstrap-tools.yml
+++ b/.github/workflows/update-bootstrap-tools.yml
@@ -13,7 +13,7 @@ jobs:
     runs-on: ubuntu-latest
     if: github.repository == 'anchore/grype' # only run for main repo
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
 
       - name: Bootstrap environment
         uses: ./.github/actions/bootstrap
diff --git a/.github/workflows/update-quality-gate-db.yml b/.github/workflows/update-quality-gate-db.yml
index 2e6fa439..47a99062 100644
--- a/.github/workflows/update-quality-gate-db.yml
+++ b/.github/workflows/update-quality-gate-db.yml
@@ -13,7 +13,7 @@ jobs:
     runs-on: ubuntu-latest
     if: github.repository == 'anchore/grype' # only run for main repo
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
 
       - name: "Update quality DB"
         run: |
diff --git a/.github/workflows/update-syft-release.yml b/.github/workflows/update-syft-release.yml
index 5724ed7d..6d2d2b41 100644
--- a/.github/workflows/update-syft-release.yml
+++ b/.github/workflows/update-syft-release.yml
@@ -17,9 +17,9 @@ jobs:
     runs-on: ubuntu-latest
     if: github.repository == 'anchore/grype' # only run for main repo
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
 
-      - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
+      - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
         with:
           go-version: ${{ env.GO_VERSION }}
           stable: ${{ env.GO_STABLE_VERSION }}
diff --git a/.github/workflows/validations.yaml b/.github/workflows/validations.yaml
index 24677068..b8baed61 100644
--- a/.github/workflows/validations.yaml
+++ b/.github/workflows/validations.yaml
@@ -16,7 +16,7 @@ jobs:
     name: "Static analysis"
     runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
 
       - name: Bootstrap environment
         uses: ./.github/actions/bootstrap
@@ -29,7 +29,7 @@ jobs:
     name: "Unit tests"
     runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
 
       - name: Bootstrap environment
         uses: ./.github/actions/bootstrap
@@ -42,7 +42,7 @@ jobs:
     name: "Quality tests"
     runs-on: ubuntu-22.04-4core-16gb
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
         with:
           submodules: true
 
@@ -97,7 +97,7 @@ jobs:
     name: "Integration tests"
     runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
 
       - name: Bootstrap environment
         uses: ./.github/actions/bootstrap
@@ -115,7 +115,7 @@ jobs:
     name: "Build snapshot artifacts"
     runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
 
       - name: Bootstrap environment
         uses: ./.github/actions/bootstrap
@@ -145,7 +145,7 @@ jobs:
     needs: [Build-Snapshot-Artifacts]
     runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
 
       - name: Download snapshot build
         uses: actions/cache/restore@6849a6489940f00c2f30c0fb92c6274307ccb58a #v4.1.2
@@ -180,7 +180,7 @@ jobs:
       - name: Install Cosign
         uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da #v3.7.0
 
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
 
       - name: Download snapshot build
         uses: actions/cache/restore@6849a6489940f00c2f30c0fb92c6274307ccb58a #v4.1.2
@@ -205,7 +205,7 @@ jobs:
     needs: [Build-Snapshot-Artifacts]
     runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 #v4.2.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
 
       - name: Bootstrap environment
         uses: ./.github/actions/bootstrap
diff --git a/README.md b/README.md
index bf7a69ce..3752941a 100644
--- a/README.md
+++ b/README.md
@@ -567,6 +567,8 @@ Grype provides database-specific CLI commands for users that want to control the
 
 `grype db import` — provide grype with a database archive to explicitly use (useful for offline DB updates)
 
+`grype db providers` - provides a detailed list of database providers
+
 Find complete information on Grype's database commands by running `grype db --help`.
 
 ## Shell completion
@@ -661,14 +663,17 @@ They will also not be dependent on a docker daemon, (or some other runtime softw
 
 ## Configuration
 
-Default configuration search paths:
+Default configuration search paths (see all with `grype config locations`):
 
 - `.grype.yaml`
 - `.grype/config.yaml`
 - `~/.grype.yaml`
 - `<XDG_CONFIG_HOME>/grype/config.yaml`
 
-You can also use the `--config` / `-c` flag to provide your own configuration file/path:
+Use `grype config` to print a sample config file to stdout.
+Use `grype config --load` to print the current config after loading all values to stdout.
+
+You can specify files directly using the `--config` / `-c` flags to provide your own configuration files/paths:
 
 ```
 grype <image> -c /path/to/config.yaml
@@ -740,9 +745,9 @@ db:
   # same as GRYPE_DB_AUTO_UPDATE env var
   auto-update: true
 
-  # location to write the vulnerability database cache
+  # location to write the vulnerability database cache; defaults to $XDG_CACHE_HOME/grype/db
   # same as GRYPE_DB_CACHE_DIR env var
-  cache-dir: "$XDG_CACHE_HOME/grype/db"
+  cache-dir: ""
 
   # URL of the vulnerability database
   # same as GRYPE_DB_UPDATE_URL env var
diff --git a/cmd/grype/cli/cli.go b/cmd/grype/cli/cli.go
index b3cf2b6b..4b8560d4 100644
--- a/cmd/grype/cli/cli.go
+++ b/cmd/grype/cli/cli.go
@@ -36,19 +36,19 @@ func create(id clio.Identification) (clio.Application, *cobra.Command) {
 		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) {
+			func(cfg clio.Config) (*clio.UICollection, error) {
 				noUI := ui.None(cfg.Log.Quiet)
 				if !cfg.Log.AllowUI(os.Stdin) || cfg.Log.Quiet {
-					return []clio.UI{noUI}, nil
+					return clio.NewUICollection(noUI), nil
 				}
 
-				return []clio.UI{
+				return clio.NewUICollection(
 					ui.New(cfg.Log.Quiet,
 						grypeHandler.New(grypeHandler.DefaultHandlerConfig()),
 						syftHandler.New(syftHandler.DefaultHandlerConfig()),
 					),
 					noUI,
-				}, nil
+				), nil
 			},
 		).
 		WithInitializers(
diff --git a/cmd/grype/cli/commands/db.go b/cmd/grype/cli/commands/db.go
index ce1ed09b..41a81b2c 100644
--- a/cmd/grype/cli/commands/db.go
+++ b/cmd/grype/cli/commands/db.go
@@ -35,6 +35,7 @@ func DB(app clio.Application) *cobra.Command {
 		DBStatus(app),
 		DBUpdate(app),
 		DBSearch(app),
+		DBProviders(app),
 	)
 
 	return db
diff --git a/cmd/grype/cli/commands/db_providers.go b/cmd/grype/cli/commands/db_providers.go
new file mode 100644
index 00000000..d25de084
--- /dev/null
+++ b/cmd/grype/cli/commands/db_providers.go
@@ -0,0 +1,155 @@
+package commands
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path"
+	"strings"
+
+	"github.com/olekukonko/tablewriter"
+	"github.com/spf13/cobra"
+
+	"github.com/anchore/clio"
+	"github.com/anchore/grype/grype/db/legacy/distribution"
+	"github.com/anchore/grype/internal/bus"
+)
+
+const metadataFileName = "provider-metadata.json"
+const jsonOutputFormat = "json"
+const tableOutputFormat = "table"
+
+type dbProviderMetadata struct {
+	Name              string `json:"name"`
+	LastSuccessfulRun string `json:"lastSuccessfulRun"`
+}
+
+type dbProviders struct {
+	Providers []dbProviderMetadata `json:"providers"`
+}
+
+type dbProvidersOptions struct {
+	Output string `yaml:"output" json:"output"`
+}
+
+var _ clio.FlagAdder = (*dbProvidersOptions)(nil)
+
+func (d *dbProvidersOptions) AddFlags(flags clio.FlagSet) {
+	flags.StringVarP(&d.Output, "output", "o", "format to display results (available=[table, json])")
+}
+
+func DBProviders(app clio.Application) *cobra.Command {
+	opts := &dbProvidersOptions{
+		Output: tableOutputFormat,
+	}
+
+	return app.SetupCommand(&cobra.Command{
+		Use:   "providers",
+		Short: "list vulnerability database providers",
+		Args:  cobra.ExactArgs(0),
+		RunE: func(_ *cobra.Command, _ []string) error {
+			return runDBProviders(opts, app)
+		},
+	}, opts)
+}
+
+func runDBProviders(opts *dbProvidersOptions, app clio.Application) error {
+	metadataFileLocation, err := getMetadataFileLocation(app)
+	if err != nil {
+		return nil
+	}
+	providers, err := getDBProviders(*metadataFileLocation)
+	if err != nil {
+		return err
+	}
+
+	sb := &strings.Builder{}
+
+	switch opts.Output {
+	case tableOutputFormat:
+		displayDBProvidersTable(providers.Providers, sb)
+	case jsonOutputFormat:
+		err = displayDBProvidersJSON(providers, sb)
+		if err != nil {
+			return err
+		}
+	default:
+		return fmt.Errorf("unsupported output format: %s", opts.Output)
+	}
+	bus.Report(sb.String())
+
+	return nil
+}
+
+func getMetadataFileLocation(app clio.Application) (*string, error) {
+	dbCurator, err := distribution.NewCurator(dbOptionsDefault(app.ID()).DB.ToCuratorConfig())
+	if err != nil {
+		return nil, err
+	}
+
+	location := dbCurator.Status().Location
+
+	return &location, nil
+}
+
+func getDBProviders(metadataFileLocation string) (*dbProviders, error) {
+	metadataFile := path.Join(metadataFileLocation, metadataFileName)
+
+	file, err := os.Open(metadataFile)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, fmt.Errorf("file not found: %w", err)
+		}
+		return nil, fmt.Errorf("error opening file: %w", err)
+	}
+	defer file.Close()
+
+	var providers dbProviders
+	fileBytes, err := io.ReadAll(file)
+	if err != nil {
+		return nil, fmt.Errorf("error reading file: %w", err)
+	}
+	err = json.Unmarshal(fileBytes, &providers)
+	if err != nil {
+		return nil, fmt.Errorf("cannot unmarshal providers: %w", err)
+	}
+
+	return &providers, nil
+}
+
+func displayDBProvidersTable(providers []dbProviderMetadata, output io.Writer) {
+	rows := [][]string{}
+	for _, provider := range providers {
+		rows = append(rows, []string{provider.Name, provider.LastSuccessfulRun})
+	}
+
+	table := tablewriter.NewWriter(output)
+	table.SetHeader([]string{"Name", "Last Successful Run"})
+
+	table.SetHeaderLine(false)
+	table.SetBorder(false)
+	table.SetAutoWrapText(false)
+	table.SetAutoFormatHeaders(true)
+	table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
+	table.SetAlignment(tablewriter.ALIGN_LEFT)
+	table.SetCenterSeparator("")
+	table.SetColumnSeparator("")
+	table.SetRowSeparator("")
+	table.SetTablePadding("  ")
+	table.SetNoWhiteSpace(true)
+
+	table.AppendBulk(rows)
+	table.Render()
+}
+
+func displayDBProvidersJSON(providers *dbProviders, output io.Writer) error {
+	encoder := json.NewEncoder(output)
+	encoder.SetEscapeHTML(false)
+	encoder.SetIndent("", " ")
+	err := encoder.Encode(providers)
+	if err != nil {
+		return fmt.Errorf("cannot display json: %w", err)
+	}
+	return nil
+}
diff --git a/cmd/grype/cli/commands/util.go b/cmd/grype/cli/commands/util.go
index fb69e2c1..5ac90ca7 100644
--- a/cmd/grype/cli/commands/util.go
+++ b/cmd/grype/cli/commands/util.go
@@ -21,7 +21,7 @@ func disableUI(app clio.Application) func(*cobra.Command, []string) error {
 		}
 
 		state := app.(Stater).State()
-		state.UIs = []clio.UI{ui.None(state.Config.Log.Quiet)}
+		state.UI = clio.NewUICollection(ui.None(state.Config.Log.Quiet))
 
 		return nil
 	}
diff --git a/go.mod b/go.mod
index 0b7e62c7..a95b0884 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
 	github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
 	github.com/adrg/xdg v0.5.1
 	github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9
-	github.com/anchore/clio v0.0.0-20240522144804-d81e109008aa
+	github.com/anchore/clio v0.0.0-20241015191535-f538a9016e10
 	github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537
 	github.com/anchore/go-logger v0.0.0-20230725134548-c21dafa1ec5a
 	github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04
@@ -18,8 +18,8 @@ require (
 	github.com/anchore/syft v1.14.2
 	github.com/aquasecurity/go-pep440-version v0.0.0-20210121094942-22b2f8951d46
 	github.com/bmatcuk/doublestar/v2 v2.0.4
-	github.com/charmbracelet/bubbletea v1.1.1
-	github.com/charmbracelet/lipgloss v0.13.0
+	github.com/charmbracelet/bubbletea v1.1.2
+	github.com/charmbracelet/lipgloss v0.13.1
 	github.com/dave/jennifer v1.7.1
 	github.com/docker/docker v27.3.1+incompatible
 	github.com/dustin/go-humanize v1.0.1
@@ -62,6 +62,8 @@ require (
 	gorm.io/gorm v1.25.12
 )
 
+require gopkg.in/yaml.v3 v3.0.1
+
 require (
 	cloud.google.com/go v0.112.1 // indirect
 	cloud.google.com/go/compute v1.24.0 // indirect
@@ -80,7 +82,7 @@ require (
 	github.com/Microsoft/hcsshim v0.11.7 // indirect
 	github.com/ProtonMail/go-crypto v1.0.0 // indirect
 	github.com/acobaugh/osrelease v0.1.0 // indirect
-	github.com/anchore/fangs v0.0.0-20240903175602-e716ef12c23d // indirect
+	github.com/anchore/fangs v0.0.0-20241014225144-4e1713cafd77 // indirect
 	github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb // indirect
 	github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 // indirect
 	github.com/andybalholm/brotli v1.0.4 // indirect
@@ -93,7 +95,7 @@ require (
 	github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
 	github.com/charmbracelet/bubbles v0.20.0 // indirect
 	github.com/charmbracelet/harmonica v0.2.0 // indirect
-	github.com/charmbracelet/x/ansi v0.2.3 // indirect
+	github.com/charmbracelet/x/ansi v0.4.0 // indirect
 	github.com/charmbracelet/x/term v0.2.0 // indirect
 	github.com/cloudflare/circl v1.3.8 // indirect
 	github.com/containerd/cgroups v1.1.0 // indirect
@@ -260,7 +262,6 @@ require (
 	google.golang.org/protobuf v1.34.2 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
 	modernc.org/libc v1.55.3 // indirect
 	modernc.org/mathutil v1.6.0 // indirect
 	modernc.org/memory v1.8.0 // indirect
diff --git a/go.sum b/go.sum
index c251a545..cc9d72f7 100644
--- a/go.sum
+++ b/go.sum
@@ -238,10 +238,10 @@ github.com/anchore/archiver/v3 v3.5.2 h1:Bjemm2NzuRhmHy3m0lRe5tNoClB9A4zYyDV58Pa
 github.com/anchore/archiver/v3 v3.5.2/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
 github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9 h1:p0ZIe0htYOX284Y4axJaGBvXHU0VCCzLN5Wf5XbKStU=
 github.com/anchore/bubbly v0.0.0-20231115134915-def0aba654a9/go.mod h1:3ZsFB9tzW3vl4gEiUeuSOMDnwroWxIxJelOOHUp8dSw=
-github.com/anchore/clio v0.0.0-20240522144804-d81e109008aa h1:pwlAn4O9SBUnlgfa69YcqIynbUyobLVFYu8HxSoCffA=
-github.com/anchore/clio v0.0.0-20240522144804-d81e109008aa/go.mod h1:nD3H5uIvjxlfmakOBgtyFQbk5Zjp3l538kxfpHPslzI=
-github.com/anchore/fangs v0.0.0-20240903175602-e716ef12c23d h1:ZD4wdCBgJJzJybjTUIEiiupLF7B9H3WLuBTjspBO2Mc=
-github.com/anchore/fangs v0.0.0-20240903175602-e716ef12c23d/go.mod h1:Xh4ObY3fmoMzOEVXwDtS1uK44JC7+nRD0n29/1KYFYg=
+github.com/anchore/clio v0.0.0-20241015191535-f538a9016e10 h1:3xmanFdoQEH0REvPA+gLm3Km0/981F4z2a/7ADTlv8k=
+github.com/anchore/clio v0.0.0-20241015191535-f538a9016e10/go.mod h1:h6Ly2hlKjQoPtI3rA8oB5afSmB/XimhcY55xbuW4Dwo=
+github.com/anchore/fangs v0.0.0-20241014225144-4e1713cafd77 h1:h7+GCqazHVS5GDJYYS6wjjglYi8xFnVWMdSUukoImTM=
+github.com/anchore/fangs v0.0.0-20241014225144-4e1713cafd77/go.mod h1:qbev5czQeyDO74fPNThiEKYkgt0mx1axb+5wQcxDPFY=
 github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537 h1:GjNGuwK5jWjJMyVppBjYS54eOiiSNv4Ba869k4wh72Q=
 github.com/anchore/go-collections v0.0.0-20240216171411-9321230ce537/go.mod h1:1aiktV46ATCkuVg0O573ZrH56BUawTECPETbZyBcqT8=
 github.com/anchore/go-logger v0.0.0-20230725134548-c21dafa1ec5a h1:nJ2G8zWKASyVClGVgG7sfM5mwoZlZ2zYpIzN2OhjWkw=
@@ -311,14 +311,14 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
 github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
 github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
-github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY=
-github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
+github.com/charmbracelet/bubbletea v1.1.2 h1:naQXF2laRxyLyil/i7fxdpiz1/k06IKquhm4vBfHsIc=
+github.com/charmbracelet/bubbletea v1.1.2/go.mod h1:9HIU/hBV24qKjlehyj8z1r/tR9TYTQEag+cWZnuXo8E=
 github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
 github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
-github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
-github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
-github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
-github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
+github.com/charmbracelet/lipgloss v0.13.1 h1:Oik/oqDTMVA01GetT4JdEC033dNzWoQHdWnHnQmXE2A=
+github.com/charmbracelet/lipgloss v0.13.1/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U=
+github.com/charmbracelet/x/ansi v0.4.0 h1:NqwHA4B23VwsDn4H3VcNX1W1tOmgnvY1NDx5tOXdnOU=
+github.com/charmbracelet/x/ansi v0.4.0/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
 github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
 github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
 github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s=


Test patch:
diff --git a/cmd/grype/cli/commands/db_providers_test.go b/cmd/grype/cli/commands/db_providers_test.go
new file mode 100644
index 00000000..734ff127
--- /dev/null
+++ b/cmd/grype/cli/commands/db_providers_test.go
@@ -0,0 +1,151 @@
+package commands
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"os"
+	"reflect"
+	"testing"
+)
+
+func TestGetDBProviders(t *testing.T) {
+
+	tests := []struct {
+		name              string
+		fileLocation      string
+		expectedProviders dbProviders
+		expectedError     error
+	}{
+		{
+			name:         "test provider metadata file",
+			fileLocation: "./test-fixtures",
+			expectedProviders: dbProviders{
+				Providers: []dbProviderMetadata{
+					{
+						Name:              "provider1",
+						LastSuccessfulRun: "2024-10-16T01:33:16.844201Z",
+					},
+					{
+						Name:              "provider2",
+						LastSuccessfulRun: "2024-10-16T01:32:43.516596Z",
+					},
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			name:              "no metadata file found",
+			fileLocation:      "./",
+			expectedProviders: dbProviders{},
+			expectedError:     os.ErrNotExist,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			providers, err := getDBProviders(test.fileLocation)
+			if err != nil {
+				if errors.Is(err, test.expectedError) {
+					return
+				}
+				t.Errorf("getDBProviders() expected list of providers, got error:  %v", err)
+				return
+			}
+			if !reflect.DeepEqual(*providers, test.expectedProviders) {
+				t.Error("getDBProviders() providers comparison failed, got error")
+			}
+		})
+	}
+
+}
+
+func TestDisplayDBProvidersTable(t *testing.T) {
+	tests := []struct {
+		name           string
+		providers      dbProviders
+		expectedOutput string
+	}{
+		{
+			name: "display providers table",
+			providers: dbProviders{
+				Providers: []dbProviderMetadata{
+					{
+						Name:              "provider1",
+						LastSuccessfulRun: "2024-10-16T01:33:16.844201Z",
+					},
+					{
+						Name:              "provider2",
+						LastSuccessfulRun: "2024-10-16T01:32:43.516596Z",
+					},
+				},
+			},
+			expectedOutput: "NAME       LAST SUCCESSFUL RUN         \nprovider1  2024-10-16T01:33:16.844201Z  \nprovider2  2024-10-16T01:32:43.516596Z  \n",
+		},
+		{
+			name: "empty list of providers",
+			providers: dbProviders{
+				Providers: []dbProviderMetadata{},
+			},
+			expectedOutput: "NAME  LAST SUCCESSFUL RUN \n",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+
+			var out bytes.Buffer
+			displayDBProvidersTable(test.providers.Providers, &out)
+			outputString := out.String()
+			if outputString != test.expectedOutput {
+				t.Errorf("displayDBProvidersTable() = %v, want %v", out.String(), test.expectedOutput)
+			}
+		})
+	}
+}
+
+func TestDisplayDBProvidersJSON(t *testing.T) {
+	tests := []struct {
+		name      string
+		providers dbProviders
+	}{
+
+		{
+			name: "display providers table",
+			providers: dbProviders{
+				Providers: []dbProviderMetadata{
+					{
+						Name:              "provider1",
+						LastSuccessfulRun: "2024-10-16T01:33:16.844201Z",
+					},
+					{
+						Name:              "provider2",
+						LastSuccessfulRun: "2024-10-16T01:32:43.516596Z",
+					},
+				},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+
+			var out bytes.Buffer
+			err := displayDBProvidersJSON(&test.providers, &out)
+			if err != nil {
+				t.Error(err)
+			}
+			var providers dbProviders
+
+			err = json.Unmarshal(out.Bytes(), &providers)
+			if err != nil {
+				t.Error(err)
+			}
+
+			if !reflect.DeepEqual(providers, test.providers) {
+				t.Error("DBProvidersJSON() providers comparison failed, got error")
+			}
+
+		})
+	}
+}
diff --git a/cmd/grype/cli/commands/test-fixtures/provider-metadata.json b/cmd/grype/cli/commands/test-fixtures/provider-metadata.json
new file mode 100644
index 00000000..015f6914
--- /dev/null
+++ b/cmd/grype/cli/commands/test-fixtures/provider-metadata.json
@@ -0,0 +1,12 @@
+{
+  "providers": [
+    {
+      "name": "provider1",
+      "lastSuccessfulRun": "2024-10-16T01:33:16.844201Z"
+    },
+    {
+      "name": "provider2",
+      "lastSuccessfulRun": "2024-10-16T01:32:43.516596Z"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/test/cli/config_test.go b/test/cli/config_test.go
new file mode 100644
index 00000000..0f0eaca5
--- /dev/null
+++ b/test/cli/config_test.go
@@ -0,0 +1,171 @@
+package cli
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v3"
+)
+
+func Test_configLoading(t *testing.T) {
+	cwd, err := os.Getwd()
+	require.NoError(t, err)
+	defer func() { require.NoError(t, os.Chdir(cwd)) }()
+
+	configsDir := filepath.Join(cwd, "test-fixtures", "configs")
+	path := func(path string) string {
+		return filepath.Join(configsDir, filepath.Join(strings.Split(path, "/")...))
+	}
+
+	type ignore struct {
+		Vuln string `yaml:"vulnerability"`
+	}
+
+	type config struct {
+		Ignores []ignore `yaml:"ignore"`
+	}
+
+	tests := []struct {
+		name     string
+		home     string
+		cwd      string
+		args     []string
+		expected []ignore
+		err      string
+	}{
+		{
+			name: "single explicit config",
+			home: configsDir,
+			cwd:  cwd,
+			args: []string{
+				"-c",
+				path("dir1/.grype.yaml"),
+			},
+			expected: []ignore{
+				{
+					Vuln: "dir1-vuln1",
+				},
+				{
+					Vuln: "dir1-vuln2",
+				},
+			},
+		},
+		{
+			name: "multiple explicit config",
+			home: configsDir,
+			cwd:  cwd,
+			args: []string{
+				"-c",
+				path("dir1/.grype.yaml"),
+				"-c",
+				path("dir2/.grype.yaml"),
+			},
+			expected: []ignore{
+				{
+					Vuln: "dir1-vuln1",
+				},
+				{
+					Vuln: "dir1-vuln2",
+				},
+				{
+					Vuln: "dir2-vuln1",
+				},
+				{
+					Vuln: "dir2-vuln2",
+				},
+			},
+		},
+		{
+			name: "empty profile override",
+			home: configsDir,
+			cwd:  cwd,
+			args: []string{
+				"-c",
+				path("dir1/.grype.yaml"),
+				"-c",
+				path("dir2/.grype.yaml"),
+				"--profile",
+				"no-ignore",
+			},
+			expected: []ignore{},
+		},
+		{
+			name: "no profiles defined",
+			home: configsDir,
+			cwd:  configsDir,
+			args: []string{
+				"-c",
+				path("dir3/.grype.yaml"),
+				"--profile",
+				"invalid",
+			},
+			err: "not found in any configuration files",
+		},
+		{
+			name: "invalid profile name",
+			home: configsDir,
+			cwd:  cwd,
+			args: []string{
+				"-c",
+				path("dir1/.grype.yaml"),
+				"-c",
+				path("dir2/.grype.yaml"),
+				"--profile",
+				"alt",
+			},
+			err: "profile not found",
+		},
+		{
+			name: "explicit with profile override",
+			home: configsDir,
+			cwd:  cwd,
+			args: []string{
+				"-c",
+				path("dir1/.grype.yaml"),
+				"-c",
+				path("dir2/.grype.yaml"),
+				"--profile",
+				"alt-ignore",
+			},
+			expected: []ignore{
+				{
+					Vuln: "dir1-alt-vuln1", // dir1 is still first
+				},
+				{
+					Vuln: "dir1-alt-vuln2", // dir1 is still first
+				},
+				{
+					Vuln: "dir2-alt-vuln1",
+				},
+				{
+					Vuln: "dir2-alt-vuln2",
+				},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			require.NoError(t, os.Chdir(test.cwd))
+			defer func() { require.NoError(t, os.Chdir(cwd)) }()
+			env := map[string]string{
+				"HOME":            test.home,
+				"XDG_CONFIG_HOME": test.home,
+			}
+			_, stdout, stderr := runGrype(t, env, append([]string{"config", "--load"}, test.args...)...)
+			if test.err != "" {
+				require.Contains(t, stderr, test.err)
+				return
+			} else {
+				require.Empty(t, stderr)
+			}
+			got := config{}
+			err = yaml.NewDecoder(strings.NewReader(stdout)).Decode(&got)
+			require.NoError(t, err)
+			require.Equal(t, test.expected, got.Ignores)
+		})
+	}
+}
diff --git a/test/cli/db_providers_test.go b/test/cli/db_providers_test.go
new file mode 100644
index 00000000..3437aa88
--- /dev/null
+++ b/test/cli/db_providers_test.go
@@ -0,0 +1,65 @@
+package cli
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestDBProviders(t *testing.T) {
+	tests := []struct {
+		name       string
+		args       []string
+		env        map[string]string
+		assertions []traitAssertion
+	}{
+		{
+			name: "db providers command",
+			args: []string{"db", "providers"},
+			assertions: []traitAssertion{
+				assertInOutput("LAST SUCCESSFUL RUN"),
+				assertNoStderr,
+				assertTableReport,
+			},
+		},
+		{
+			name: "db providers command help",
+			args: []string{"db", "providers", "-h"},
+			assertions: []traitAssertion{
+				assertInOutput("list vulnerability database providers"),
+				assertNoStderr,
+			},
+		},
+		{
+			name: "db providers command with table output flag",
+			args: []string{"db", "providers", "-o", "table"},
+			assertions: []traitAssertion{
+				assertInOutput("LAST SUCCESSFUL RUN"),
+				assertNoStderr,
+				assertTableReport,
+			},
+		},
+		{
+			name: "db providers command with json output flag",
+			args: []string{"db", "providers", "-o", "json"},
+			assertions: []traitAssertion{
+				assertInOutput("providers"),
+				assertNoStderr,
+				assertJsonReport,
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			cmd, stdout, stderr := runGrype(t, test.env, test.args...)
+			for _, traitAssertionFn := range test.assertions {
+				traitAssertionFn(t, stdout, stderr, cmd.ProcessState.ExitCode())
+			}
+			if t.Failed() {
+				t.Log("STDOUT:\n", stdout)
+				t.Log("STDERR:\n", stderr)
+				t.Log("COMMAND:", strings.Join(cmd.Args, " "))
+			}
+		})
+	}
+}
diff --git a/test/cli/test-fixtures/configs/dir1/.grype.yaml b/test/cli/test-fixtures/configs/dir1/.grype.yaml
new file mode 100644
index 00000000..6caefc21
--- /dev/null
+++ b/test/cli/test-fixtures/configs/dir1/.grype.yaml
@@ -0,0 +1,12 @@
+ignore:
+  - vulnerability: 'dir1-vuln1'
+  - vulnerability: 'dir1-vuln2'
+
+profiles:
+  no-ignore:
+    ignore: []
+
+  alt-ignore:
+    ignore:
+      - vulnerability: 'dir1-alt-vuln1'
+      - vulnerability: 'dir1-alt-vuln2'
diff --git a/test/cli/test-fixtures/configs/dir2/.grype.yaml b/test/cli/test-fixtures/configs/dir2/.grype.yaml
new file mode 100644
index 00000000..8e1ae8ba
--- /dev/null
+++ b/test/cli/test-fixtures/configs/dir2/.grype.yaml
@@ -0,0 +1,9 @@
+ignore:
+  - vulnerability: 'dir2-vuln1'
+  - vulnerability: 'dir2-vuln2'
+
+profiles:
+  alt-ignore:
+    ignore:
+      - vulnerability: 'dir2-alt-vuln1'
+      - vulnerability: 'dir2-alt-vuln2'
diff --git a/test/cli/test-fixtures/configs/dir3/.grype.yaml b/test/cli/test-fixtures/configs/dir3/.grype.yaml
new file mode 100644
index 00000000..e69de29b
diff --git a/test/cli/trait_assertions_test.go b/test/cli/trait_assertions_test.go
index 8aa14b09..11e30f18 100644
--- a/test/cli/trait_assertions_test.go
+++ b/test/cli/trait_assertions_test.go
@@ -1,6 +1,7 @@
 package cli
 
 import (
+	"encoding/json"
 	"strings"
 	"testing"
 
@@ -70,3 +71,19 @@ func assertNotInOutput(notWanted string) traitAssertion {
 		}
 	}
 }
+
+func assertJsonReport(tb testing.TB, stdout, _ string, _ int) {
+	tb.Helper()
+	var data interface{}
+
+	if err := json.Unmarshal([]byte(stdout), &data); err != nil {
+		tb.Errorf("expected to find a JSON report, but was unmarshalable: %+v", err)
+	}
+}
+
+func assertTableReport(tb testing.TB, stdout, _ string, _ int) {
+	tb.Helper()
+	if !strings.Contains(stdout, "NAME") || !strings.Contains(stdout, "LAST SUCCESSFUL RUN") {
+		tb.Errorf("expected to find a table report, but did not")
+	}
+}