Repository: anchore/grype
Pull request #2062 (2024-08-16 16:58:01)

chore: add grype version to db network operations
This PR adds the User-Agent containing the grype version to the database network operations.


Patch:
diff --git a/.binny.yaml b/.binny.yaml
index c67bb71d..a9835221 100644
--- a/.binny.yaml
+++ b/.binny.yaml
@@ -26,7 +26,7 @@ tools:
   # used for linting
   - name: golangci-lint
     version:
-      want: v1.59.1
+      want: v1.60.1
     method: github-release
     with:
       repo: golangci/golangci-lint
@@ -58,7 +58,7 @@ tools:
   # used to release all artifacts
   - name: goreleaser
     version:
-      want: v2.1.0
+      want: v2.2.0
     method: github-release
     with:
       repo: goreleaser/goreleaser
diff --git a/cmd/grype/cli/commands/root.go b/cmd/grype/cli/commands/root.go
index 69a6cb45..f23382db 100644
--- a/cmd/grype/cli/commands/root.go
+++ b/cmd/grype/cli/commands/root.go
@@ -347,6 +347,14 @@ func validateRootArgs(cmd *cobra.Command, args []string) error {
 		return fmt.Errorf("an image/directory argument is required")
 	}
 
+	// in the case that a single empty string argument ("") is given and there is no piped input we want to show the help text and return with a non-0 return code.
+	if len(args) != 0 && args[0] == "" && !isStdinPipeOrRedirect {
+		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/options/database.go b/cmd/grype/cli/options/database.go
index 23e47997..986cfab2 100644
--- a/cmd/grype/cli/options/database.go
+++ b/cmd/grype/cli/options/database.go
@@ -12,16 +12,17 @@ import (
 )
 
 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"`
-	AutoUpdate             bool          `yaml:"auto-update" json:"auto-update" mapstructure:"auto-update"`
-	ValidateByHashOnStart  bool          `yaml:"validate-by-hash-on-start" json:"validate-by-hash-on-start" mapstructure:"validate-by-hash-on-start"`
-	ValidateAge            bool          `yaml:"validate-age" json:"validate-age" mapstructure:"validate-age"`
-	MaxAllowedBuiltAge     time.Duration `yaml:"max-allowed-built-age" json:"max-allowed-built-age" mapstructure:"max-allowed-built-age"`
-	RequireUpdateCheck     bool          `yaml:"require-update-check" json:"require-update-check" mapstructure:"require-update-check"`
-	UpdateAvailableTimeout time.Duration `yaml:"update-available-timeout" json:"update-available-timeout" mapstructure:"update-available-timeout"`
-	UpdateDownloadTimeout  time.Duration `yaml:"update-download-timeout" json:"update-download-timeout" mapstructure:"update-download-timeout"`
+	ID                     clio.Identification `yaml:"-" json:"-" mapstructure:"-"`
+	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"`
+	AutoUpdate             bool                `yaml:"auto-update" json:"auto-update" mapstructure:"auto-update"`
+	ValidateByHashOnStart  bool                `yaml:"validate-by-hash-on-start" json:"validate-by-hash-on-start" mapstructure:"validate-by-hash-on-start"`
+	ValidateAge            bool                `yaml:"validate-age" json:"validate-age" mapstructure:"validate-age"`
+	MaxAllowedBuiltAge     time.Duration       `yaml:"max-allowed-built-age" json:"max-allowed-built-age" mapstructure:"max-allowed-built-age"`
+	RequireUpdateCheck     bool                `yaml:"require-update-check" json:"require-update-check" mapstructure:"require-update-check"`
+	UpdateAvailableTimeout time.Duration       `yaml:"update-available-timeout" json:"update-available-timeout" mapstructure:"update-available-timeout"`
+	UpdateDownloadTimeout  time.Duration       `yaml:"update-download-timeout" json:"update-download-timeout" mapstructure:"update-download-timeout"`
 }
 
 var _ interface {
@@ -36,6 +37,7 @@ const (
 
 func DefaultDatabase(id clio.Identification) Database {
 	return Database{
+		ID:          id,
 		Dir:         path.Join(xdg.CacheHome, id.Name, "db"),
 		UpdateURL:   internal.DBUpdateURL,
 		AutoUpdate:  true,
@@ -50,6 +52,7 @@ func DefaultDatabase(id clio.Identification) Database {
 
 func (cfg Database) ToCuratorConfig() db.Config {
 	return db.Config{
+		ID:                  cfg.ID,
 		DBRootDir:           cfg.Dir,
 		ListingURL:          cfg.UpdateURL,
 		CACert:              cfg.CACert,
diff --git a/go.mod b/go.mod
index 9cd11836..6949e4a7 100644
--- a/go.mod
+++ b/go.mod
@@ -17,10 +17,10 @@ require (
 	github.com/anchore/syft v1.11.0
 	github.com/aquasecurity/go-pep440-version v0.0.0-20210121094942-22b2f8951d46
 	github.com/bmatcuk/doublestar/v2 v2.0.4
-	github.com/charmbracelet/bubbletea v0.26.6
+	github.com/charmbracelet/bubbletea v0.27.0
 	github.com/charmbracelet/lipgloss v0.12.1
 	github.com/dave/jennifer v1.7.0
-	github.com/docker/docker v27.1.1+incompatible
+	github.com/docker/docker v27.1.2+incompatible
 	github.com/dustin/go-humanize v1.0.1
 	github.com/facebookincubator/nvdtools v0.1.5
 	github.com/gabriel-vasile/mimetype v1.4.5
@@ -246,7 +246,7 @@ require (
 	golang.org/x/net v0.28.0 // indirect
 	golang.org/x/oauth2 v0.19.0 // indirect
 	golang.org/x/sync v0.8.0 // indirect
-	golang.org/x/sys v0.23.0 // indirect
+	golang.org/x/sys v0.24.0 // indirect
 	golang.org/x/term v0.23.0 // indirect
 	golang.org/x/text v0.17.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
diff --git a/go.sum b/go.sum
index c45465cd..95089a31 100644
--- a/go.sum
+++ b/go.sum
@@ -310,8 +310,8 @@ 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.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
 github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
-github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=
-github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=
+github.com/charmbracelet/bubbletea v0.27.0 h1:Mznj+vvYuYagD9Pn2mY7fuelGvP0HAXtZYGgRBCbHvU=
+github.com/charmbracelet/bubbletea v0.27.0/go.mod h1:5MdP9XH6MbQkgGhnlxUqCNmBXf9I74KRQ8HIidRxV1Y=
 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.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs=
@@ -384,8 +384,8 @@ github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2
 github.com/docker/cli v27.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
-github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v27.1.2+incompatible h1:AhGzR1xaQIy53qCkxARaFluI00WPGtXn0AJuoQsVYTY=
+github.com/docker/docker v27.1.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A=
 github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0=
 github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
@@ -1323,8 +1323,8 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
-golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
+golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
diff --git a/grype/db/curator.go b/grype/db/curator.go
index 0c5c1dd0..7af7d341 100644
--- a/grype/db/curator.go
+++ b/grype/db/curator.go
@@ -17,6 +17,7 @@ import (
 	partybus "github.com/wagoodman/go-partybus"
 	progress "github.com/wagoodman/go-progress"
 
+	"github.com/anchore/clio"
 	grypeDB "github.com/anchore/grype/grype/db/v5"
 	"github.com/anchore/grype/grype/db/v5/store"
 	"github.com/anchore/grype/grype/event"
@@ -31,6 +32,7 @@ const (
 )
 
 type Config struct {
+	ID                  clio.Identification
 	DBRootDir           string
 	ListingURL          string
 	CACert              string
@@ -75,8 +77,8 @@ func NewCurator(cfg Config) (Curator, error) {
 	return Curator{
 		fs:                  fs,
 		targetSchema:        vulnerability.SchemaVersion,
-		listingDownloader:   file.NewGetter(listingClient),
-		updateDownloader:    file.NewGetter(dbClient),
+		listingDownloader:   file.NewGetter(cfg.ID, listingClient),
+		updateDownloader:    file.NewGetter(cfg.ID, dbClient),
 		dbDir:               dbDir,
 		dbPath:              path.Join(dbDir, FileName),
 		listingURL:          cfg.ListingURL,
diff --git a/grype/db/v4/store/store.go b/grype/db/v4/store/store.go
index e51281a4..bcdb1aca 100644
--- a/grype/db/v4/store/store.go
+++ b/grype/db/v4/store/store.go
@@ -265,8 +265,8 @@ func (s *store) AddVulnerabilityMatchExclusion(exclusions ...v4.VulnerabilityMat
 func (s *store) Close() {
 	s.db.Exec("VACUUM;")
 
-	sqlDB, err := s.db.DB()
-	if err != nil {
+	sqlDB, _ := s.db.DB()
+	if sqlDB != nil {
 		_ = sqlDB.Close()
 	}
 }
diff --git a/grype/db/v5/store/store.go b/grype/db/v5/store/store.go
index 9a2c882d..3c7696ad 100644
--- a/grype/db/v5/store/store.go
+++ b/grype/db/v5/store/store.go
@@ -289,8 +289,8 @@ func (s *store) AddVulnerabilityMatchExclusion(exclusions ...v5.VulnerabilityMat
 func (s *store) Close() {
 	s.db.Exec("VACUUM;")
 
-	sqlDB, err := s.db.DB()
-	if err != nil {
+	sqlDB, _ := s.db.DB()
+	if sqlDB != nil {
 		_ = sqlDB.Close()
 	}
 }
diff --git a/grype/distro/type.go b/grype/distro/type.go
index f079b6ea..587bc316 100644
--- a/grype/distro/type.go
+++ b/grype/distro/type.go
@@ -64,6 +64,7 @@ var IDMapping = map[string]Type{
 	"centos":        CentOS,
 	"fedora":        Fedora,
 	"alpine":        Alpine,
+	"Alpine Linux":  Alpine,
 	"busybox":       Busybox,
 	"amzn":          AmazonLinux,
 	"ol":            OracleLinux,
diff --git a/grype/internal/packagemetadata/discover_type_names.go b/grype/internal/packagemetadata/discover_type_names.go
index 9e42401a..8e609d8f 100644
--- a/grype/internal/packagemetadata/discover_type_names.go
+++ b/grype/internal/packagemetadata/discover_type_names.go
@@ -70,7 +70,7 @@ func findMetadataDefinitionNames(paths ...string) ([]string, error) {
 	// note: 3 is a point-in-time gut check. This number could be updated if new metadata definitions are added, but is not required.
 	// it is really intended to catch any major issues with the generation process that would generate, say, 0 definitions.
 	if len(strNames) < 3 {
-		return nil, fmt.Errorf("not enough metadata definitions found (discovered: " + fmt.Sprintf("%d", len(strNames)) + ")")
+		return nil, fmt.Errorf("not enough metadata definitions found: discovered %d ", len(strNames))
 	}
 
 	return strNames, nil
diff --git a/grype/matcher/apk/matcher.go b/grype/matcher/apk/matcher.go
index b74aa1bb..f3bf4b40 100644
--- a/grype/matcher/apk/matcher.go
+++ b/grype/matcher/apk/matcher.go
@@ -146,8 +146,9 @@ func (m *Matcher) findApkPackage(store vulnerability.Provider, d *distro.Distro,
 		return nil, err
 	}
 
+	// TODO: are there other errors that we should handle here that causes this to short circuit
 	cpeMatches, err := m.cpeMatchesWithoutSecDBFixes(store, d, p)
-	if err != nil {
+	if err != nil && !errors.Is(err, search.ErrEmptyCPEMatch) {
 		return nil, err
 	}
 
diff --git a/grype/pkg/syft_sbom_provider.go b/grype/pkg/syft_sbom_provider.go
index dbb86229..1f7056f9 100644
--- a/grype/pkg/syft_sbom_provider.go
+++ b/grype/pkg/syft_sbom_provider.go
@@ -2,6 +2,7 @@ package pkg
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"io"
 	"os"
@@ -83,7 +84,11 @@ func extractReaderAndInfo(userInput string) (io.ReadSeeker, *inputInfo, error) {
 	case userInput == "":
 		// we only want to attempt reading in from stdin if the user has not specified other
 		// options from the CLI, otherwise we should not assume there is any valid input from stdin.
-		return decodeStdin(stdinReader())
+		r, err := stdinReader()
+		if err != nil {
+			return nil, nil, err
+		}
+		return decodeStdin(r)
 
 	case explicitlySpecifyingSBOM(userInput):
 		filepath := strings.TrimPrefix(userInput, "sbom:")
@@ -140,18 +145,17 @@ func fileHasContent(f *os.File) bool {
 	return false
 }
 
-func stdinReader() io.Reader {
+func stdinReader() (io.Reader, error) {
 	isStdinPipeOrRedirect, err := internal.IsStdinPipeOrRedirect()
 	if err != nil {
-		log.Warnf("unable to determine if there is piped input: %+v", err)
-		return nil
+		return nil, fmt.Errorf("unable to determine if there is piped input: %w", err)
 	}
 
 	if !isStdinPipeOrRedirect {
-		return nil
+		return nil, errors.New("no input was provided via stdin")
 	}
 
-	return os.Stdin
+	return os.Stdin, nil
 }
 
 func closeFile(f *os.File) {
diff --git a/internal/file/getter.go b/internal/file/getter.go
index 216a0965..12d765f5 100644
--- a/internal/file/getter.go
+++ b/internal/file/getter.go
@@ -9,6 +9,7 @@ import (
 	"github.com/hashicorp/go-getter/helper/url"
 	"github.com/wagoodman/go-progress"
 
+	"github.com/anchore/clio"
 	"github.com/anchore/grype/internal/stringutil"
 )
 
@@ -32,10 +33,13 @@ type HashiGoGetter struct {
 
 // NewGetter creates and returns a new Getter. Providing an http.Client is optional. If one is provided,
 // it will be used for all HTTP(S) getting; otherwise, go-getter's default getters will be used.
-func NewGetter(httpClient *http.Client) *HashiGoGetter {
+func NewGetter(id clio.Identification, httpClient *http.Client) *HashiGoGetter {
 	return &HashiGoGetter{
 		httpGetter: getter.HttpGetter{
 			Client: httpClient,
+			Header: http.Header{
+				"User-Agent": []string{fmt.Sprintf("%v %v", id.Name, id.Version)},
+			},
 		},
 	}
 }


Test patch:
diff --git a/cmd/grype/cli/commands/listing_test.go b/cmd/grype/cli/commands/listing_test.go
new file mode 100644
index 00000000..fa879f3d
--- /dev/null
+++ b/cmd/grype/cli/commands/listing_test.go
@@ -0,0 +1,41 @@
+package commands
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/anchore/clio"
+)
+
+func Test_ListingUserAgent(t *testing.T) {
+	listingFile := "/listing.json"
+
+	got := ""
+
+	// setup mock
+	handler := http.NewServeMux()
+	handler.HandleFunc(listingFile, func(w http.ResponseWriter, r *http.Request) {
+		got = r.Header.Get("User-Agent")
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte("1.0.0"))
+	})
+	mockSrv := httptest.NewServer(handler)
+	defer mockSrv.Close()
+
+	dbOptions := *dbOptionsDefault(clio.Identification{
+		Name:    "the-app",
+		Version: "v3.2.1",
+	})
+	dbOptions.DB.RequireUpdateCheck = true
+	dbOptions.DB.UpdateURL = mockSrv.URL + listingFile
+
+	_ = runDBList(&dbListOptions{
+		Output:    "",
+		DBOptions: dbOptions,
+	})
+
+	if got != "the-app v3.2.1" {
+		t.Errorf("expected User-Agent header to match, got: %v", got)
+	}
+}
diff --git a/grype/matcher/apk/matcher_test.go b/grype/matcher/apk/matcher_test.go
index 20767eb6..6b046024 100644
--- a/grype/matcher/apk/matcher_test.go
+++ b/grype/matcher/apk/matcher_test.go
@@ -635,6 +635,86 @@ func TestDistroMatchBySourceIndirection(t *testing.T) {
 	assertMatches(t, expected, actual)
 }
 
+func TestSecDBMatchesStillCountedWithCpeErrors(t *testing.T) {
+	// this should match the test package
+	// the test package will have no CPE causing an error,
+	// but the error should not cause the secDB matches to fail
+	secDbVuln := grypeDB.Vulnerability{
+		ID:                "CVE-2020-2",
+		VersionConstraint: "<= 1.3.3-r0",
+		VersionFormat:     "apk",
+		Namespace:         "secdb:distro:alpine:3.12",
+	}
+
+	store := mockStore{
+		backend: map[string]map[string][]grypeDB.Vulnerability{
+			"secdb:distro:alpine:3.12": {
+				"musl": []grypeDB.Vulnerability{secDbVuln},
+			},
+		},
+	}
+
+	provider, err := db.NewVulnerabilityProvider(&store)
+	require.NoError(t, err)
+
+	m := Matcher{}
+	d, err := distro.New(distro.Alpine, "3.12.0", "")
+	if err != nil {
+		t.Fatalf("failed to create a new distro: %+v", err)
+	}
+
+	p := pkg.Package{
+		ID:      pkg.ID(uuid.NewString()),
+		Name:    "musl-utils",
+		Version: "1.3.2-r0",
+		Type:    syftPkg.ApkPkg,
+		Upstreams: []pkg.UpstreamPackage{
+			{
+				Name: "musl",
+			},
+		},
+		CPEs: []cpe.CPE{},
+	}
+
+	vulnFound, err := vulnerability.NewVulnerability(secDbVuln)
+	assert.NoError(t, err)
+
+	expected := []match.Match{
+		{
+
+			Vulnerability: *vulnFound,
+			Package:       p,
+			Details: []match.Detail{
+				{
+					Type:       match.ExactIndirectMatch,
+					Confidence: 1.0,
+					SearchedBy: map[string]interface{}{
+						"distro": map[string]string{
+							"type":    d.Type.String(),
+							"version": d.RawVersion,
+						},
+						"package": map[string]string{
+							"name":    "musl",
+							"version": p.Version,
+						},
+						"namespace": "secdb:distro:alpine:3.12",
+					},
+					Found: map[string]interface{}{
+						"versionConstraint": vulnFound.Constraint.String(),
+						"vulnerabilityID":   "CVE-2020-2",
+					},
+					Matcher: match.ApkMatcher,
+				},
+			},
+		},
+	}
+
+	actual, err := m.Match(provider, d, p)
+	assert.NoError(t, err)
+
+	assertMatches(t, expected, actual)
+}
+
 func TestNVDMatchBySourceIndirection(t *testing.T) {
 	nvdVuln := grypeDB.Vulnerability{
 		ID:                "CVE-2020-1",
diff --git a/internal/file/getter_test.go b/internal/file/getter_test.go
index f0bdaf90..a8f47ce8 100644
--- a/internal/file/getter_test.go
+++ b/internal/file/getter_test.go
@@ -14,6 +14,8 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+
+	"github.com/anchore/clio"
 )
 
 func TestGetter_GetFile(t *testing.T) {
@@ -45,7 +47,7 @@ func TestGetter_GetFile(t *testing.T) {
 				tc.prepareClient(httpClient)
 			}
 
-			getter := NewGetter(httpClient)
+			getter := NewGetter(testID, httpClient)
 			requestURL := createRequestURL(t, server, requestPath)
 
 			tempDir := t.TempDir()
@@ -72,7 +74,7 @@ func TestGetter_GetToDir_FilterNonArchivesWired(t *testing.T) {
 
 	for _, test := range testCases {
 		t.Run(test.name, func(t *testing.T) {
-			test.assert(t, NewGetter(nil).GetToDir(t.TempDir(), test.source))
+			test.assert(t, NewGetter(testID, nil).GetToDir(t.TempDir(), test.source))
 		})
 	}
 }
@@ -138,7 +140,7 @@ func TestGetter_GetToDir_CertConcerns(t *testing.T) {
 				tc.prepareClient(httpClient)
 			}
 
-			getter := NewGetter(httpClient)
+			getter := NewGetter(testID, httpClient)
 			requestURL := createRequestURL(t, server, requestPath)
 
 			tempDir := t.TempDir()
@@ -193,6 +195,11 @@ func withResponseForPath(t *testing.T, path string, response []byte) muxOption {
 	}
 }
 
+var testID = clio.Identification{
+	Name:    "test-app",
+	Version: "v0.5.3",
+}
+
 func newTestServer(t *testing.T, muxOptions ...muxOption) *httptest.Server {
 	t.Helper()
 
diff --git a/test/cli/cmd_test.go b/test/cli/cmd_test.go
index 36cabe5d..145fb8bd 100644
--- a/test/cli/cmd_test.go
+++ b/test/cli/cmd_test.go
@@ -25,6 +25,15 @@ func TestCmd(t *testing.T) {
 				assertFailingReturnCode,
 			},
 		},
+		{
+			name: "empty-string-arg-shows-help",
+			args: []string{""},
+			assertions: []traitAssertion{
+				assertInOutput("an image/directory argument is required"),                              // specific error that should be shown
+				assertInOutput("A vulnerability scanner for container images, filesystems, and SBOMs"), // excerpt from help description
+				assertFailingReturnCode,
+			},
+		},
 		{
 			name: "ensure valid descriptor",
 			args: []string{getFixtureImage(t, "image-bare"), "-o", "json"},