Repository: anchore/grype
Pull request #2464 (2025-02-19 22:38:11)

Add KEV information to v6 DB
This PR adds the CISA KEV data shape to the DB as a new table and plumbs through `VulnerabilityMetadata` calls to attach matching KEV entries for a given vulnerability.

This adds a new `vulnerability.knownExploited[]` array to the JSON output:
```
$ grype 'cpe:2.3:a:zohocorp:manageengine_desktop_central:1' -o json | jq '.matches[] | select(.vulnerability.id == "CVE-2020-10189").vulnerability.knownExploited'
 ✔ Scanned for vulnerabilities     [0 vulnerability matches]  
   ├── by severity: 10 critical, 10 high, 5 medium, 0 low, 0 negligible
   └── by status:   19 fixed, 6 not-fixed, 0 ignored 
```
```json
[
  {
    "cve": "CVE-2020-10189",
    "vendorProject": "Zoho",
    "product": "ManageEngine",
    "dateAdded": "2021-11-03",
    "requiredAction": "Apply updates per vendor instructions.",
    "dueDate": "2022-05-03",
    "knownRansomwareCampaignUse": "unknown",
    "urls": [
      "https://nvd.nist.gov/vuln/detail/CVE-2020-10189"
    ],
    "cwes": [
      "CWE-502"
    ]
  }
]

```

I've removed the KEV `vulnerabilityName` and `shortDescription` from the DB and grype output as it is redundant to the NVD data; here's the removed values for the above record as an example:
```json
{
  "vulnerabilityName": "Zoho ManageEngine Desktop Central File Upload Vulnerability",
  "shortDescription": "Zoho ManageEngine Desktop Central contains a file upload vulnerability that allows for unauthenticated remote code execution."
}
```

Partially addresses https://github.com/anchore/grype/issues/1511



Patch:
diff --git a/grype/db/v6/affected_package_store.go b/grype/db/v6/affected_package_store.go
index b5a8982a..f49d2841 100644
--- a/grype/db/v6/affected_package_store.go
+++ b/grype/db/v6/affected_package_store.go
@@ -16,13 +16,8 @@ import (
 )
 
 const (
-	// batchSize affects how many records are fetched at a time from the DB. Note: when using preload, row entries
-	// for related records may convey as parameters in a "WHERE x in (...)" which can lead to a large number of
-	// parameters in the query -- if above 999 then this will result in an error for sqlite. For this reason we
-	// try to keep this value well below 999.
-	batchSize = 300
-	anyPkg    = "any"
-	anyOS     = "any"
+	anyPkg = "any"
+	anyOS  = "any"
 )
 
 var NoOSSpecified = &OSSpecifier{}
@@ -405,7 +400,7 @@ func (s *affectedPackageStore) GetAffectedPackages(pkg *PackageSpecifier, config
 				blobs = append(blobs, r)
 			}
 			if err := s.blobStore.attachBlobValue(blobs...); err != nil {
-				return fmt.Errorf("unable to attach blobs: %w", err)
+				return fmt.Errorf("unable to attach affected package blobs: %w", err)
 			}
 		}
 
diff --git a/grype/db/v6/blobs.go b/grype/db/v6/blobs.go
index 80147828..92f865f0 100644
--- a/grype/db/v6/blobs.go
+++ b/grype/db/v6/blobs.go
@@ -200,3 +200,16 @@ type AffectedVersion struct {
 	// Constraint defines the version range constraint for affected versions.
 	Constraint string `json:"constraint,omitempty"`
 }
+
+type KnownExploitedVulnerabilityBlob struct {
+	Cve                        string     `json:"cve"`
+	VendorProject              string     `json:"vendor_project,omitempty"`
+	Product                    string     `json:"product,omitempty"`
+	DateAdded                  *time.Time `json:"date_added,omitempty"`
+	RequiredAction             string     `json:"required_action,omitempty"`
+	DueDate                    *time.Time `json:"due_date,omitempty"`
+	KnownRansomwareCampaignUse string     `json:"known_ransomware_campaign_use,omitempty"`
+	Notes                      string     `json:"notes,omitempty"`
+	URLs                       []string   `json:"urls,omitempty"`
+	CWEs                       []string   `json:"cwes,omitempty"`
+}
diff --git a/grype/db/v6/db.go b/grype/db/v6/db.go
index 705317d4..de2cc5af 100644
--- a/grype/db/v6/db.go
+++ b/grype/db/v6/db.go
@@ -12,8 +12,6 @@ import (
 )
 
 const (
-	VulnerabilityDBFileName = "vulnerability.db"
-
 	// We follow SchemaVer semantics (see https://snowplow.io/blog/introducing-schemaver-for-semantic-versioning-of-schemas)
 
 	// ModelVersion indicates how many breaking schema changes there have been (which will prevent interaction with any historical data)
@@ -24,9 +22,25 @@ const (
 	Revision = 0
 
 	// Addition indicates how many changes have been introduced that are compatible with all historical data
-	Addition = 0
+	Addition = 1
+
+	// v6 model changelog:
+	// 6.0.0: Initial version 🎉
+	// 6.0.1: Add CISA KEV store
 )
 
+const (
+	VulnerabilityDBFileName = "vulnerability.db"
+
+	// batchSize affects how many records are fetched at a time from the DB. Note: when using preload, row entries
+	// for related records may convey as parameters in a "WHERE x in (...)" which can lead to a large number of
+	// parameters in the query -- if above 999 then this will result in an error for sqlite. For this reason we
+	// try to keep this value well below 999.
+	batchSize = 300
+)
+
+var ErrDBCapabilityNotSupported = fmt.Errorf("capability not supported by DB")
+
 type ReadWriter interface {
 	Reader
 	Writer
@@ -36,6 +50,7 @@ type Reader interface {
 	DBMetadataStoreReader
 	ProviderStoreReader
 	VulnerabilityStoreReader
+	VulnerabilityDecoratorStoreReader
 	AffectedPackageStoreReader
 	AffectedCPEStoreReader
 	io.Closer
@@ -45,7 +60,9 @@ type Reader interface {
 
 type Writer interface {
 	DBMetadataStoreWriter
+	ProviderStoreWriter
 	VulnerabilityStoreWriter
+	VulnerabilityDecoratorStoreWriter
 	AffectedPackageStoreWriter
 	AffectedCPEStoreWriter
 	io.Closer
diff --git a/grype/db/v6/distribution/client.go b/grype/db/v6/distribution/client.go
index afac7b61..464e4172 100644
--- a/grype/db/v6/distribution/client.go
+++ b/grype/db/v6/distribution/client.go
@@ -230,21 +230,18 @@ func isSupersededBy(current *v6.Description, candidate v6.Description) bool {
 		return true
 	}
 
-	otherModelPart, otherOk := candidate.SchemaVersion.ModelPart()
-	currentModelPart, currentOk := current.SchemaVersion.ModelPart()
-
-	if !currentOk {
+	if !current.SchemaVersion.Valid() {
 		log.Error("existing database has no schema version, doing nothing...")
 		return false
 	}
 
-	if !otherOk {
+	if !candidate.SchemaVersion.Valid() {
 		log.Error("update has no schema version, doing nothing...")
 		return false
 	}
 
-	if otherModelPart != currentModelPart {
-		log.WithFields("want", currentModelPart, "received", otherModelPart).Warn("update is for a different DB schema, skipping...")
+	if candidate.SchemaVersion.Model != current.SchemaVersion.Model {
+		log.WithFields("want", current.SchemaVersion.Model, "received", candidate.SchemaVersion.Model).Warn("update is for a different DB schema, skipping...")
 		return false
 	}
 
diff --git a/grype/db/v6/installation/curator.go b/grype/db/v6/installation/curator.go
index 49dbe50c..32077553 100644
--- a/grype/db/v6/installation/curator.go
+++ b/grype/db/v6/installation/curator.go
@@ -95,9 +95,10 @@ func (c curator) Reader() (db.Reader, error) {
 		return nil, fmt.Errorf("unable to get vulnerability store metadata: %w", err)
 	}
 
-	var currentDBSchemaVersion schemaver.SchemaVer
+	var currentDBSchemaVersion *schemaver.SchemaVer
 	if m != nil {
-		currentDBSchemaVersion = schemaver.New(m.Model, m.Revision, m.Addition)
+		v := schemaver.New(m.Model, m.Revision, m.Addition)
+		currentDBSchemaVersion = &v
 	}
 
 	doRehydrate, err := isRehydrationNeeded(c.fs, c.config.DBDirectoryPath(), currentDBSchemaVersion, schemaver.New(db.ModelVersion, db.Revision, db.Addition))
@@ -287,8 +288,8 @@ func (c curator) update(current *db.Description) (*distribution.Archive, error)
 	return update, nil
 }
 
-func isRehydrationNeeded(fs afero.Fs, dirPath string, currentDBVersion schemaver.SchemaVer, currentClientVersion schemaver.SchemaVer) (bool, error) {
-	if currentDBVersion == "" {
+func isRehydrationNeeded(fs afero.Fs, dirPath string, currentDBVersion *schemaver.SchemaVer, currentClientVersion schemaver.SchemaVer) (bool, error) {
+	if currentDBVersion == nil {
 		// there is no DB to rehydrate
 		return false, nil
 	}
@@ -306,7 +307,7 @@ func isRehydrationNeeded(fs afero.Fs, dirPath string, currentDBVersion schemaver
 		return false, fmt.Errorf("unable to parse client version from import metadata: %w", err)
 	}
 
-	hydratedWithOldClient := clientHydrationVersion.LessThan(currentDBVersion)
+	hydratedWithOldClient := clientHydrationVersion.LessThan(*currentDBVersion)
 	haveNewerClient := clientHydrationVersion.LessThan(currentClientVersion)
 	doRehydrate := hydratedWithOldClient && haveNewerClient
 
@@ -486,9 +487,8 @@ func (c curator) validateIntegrity(description *db.Description, dbFilePath strin
 		return nil, "", fmt.Errorf("database not found: %s", dbFilePath)
 	}
 
-	gotModel, ok := description.SchemaVersion.ModelPart()
-	if !ok || gotModel != db.ModelVersion {
-		return nil, "", fmt.Errorf("unsupported database version: have=%d want=%d", gotModel, db.ModelVersion)
+	if description.SchemaVersion.Model != db.ModelVersion {
+		return nil, "", fmt.Errorf("unsupported database version: have=%d want=%d", description.SchemaVersion.Model, db.ModelVersion)
 	}
 
 	if _, err := c.fs.Stat(dbFilePath); err != nil {
diff --git a/grype/db/v6/models.go b/grype/db/v6/models.go
index 3054163d..cb59c7ff 100644
--- a/grype/db/v6/models.go
+++ b/grype/db/v6/models.go
@@ -11,6 +11,7 @@ import (
 	"gorm.io/gorm"
 
 	"github.com/anchore/grype/internal/log"
+	"github.com/anchore/grype/internal/schemaver"
 )
 
 func Models() []any {
@@ -38,6 +39,9 @@ func Models() []any {
 		// CPE related search tables
 		&AffectedCPEHandle{}, // join on CPE
 		&Cpe{},
+
+		// decorations to vulnerability records
+		&KnownExploitedVulnerabilityHandle{},
 	}
 }
 
@@ -68,6 +72,10 @@ type DBMetadata struct {
 	Addition       int        `gorm:"column:addition;not null"`
 }
 
+func newSchemaVerFromDBMetadata(m DBMetadata) schemaver.SchemaVer {
+	return schemaver.New(m.Model, m.Revision, m.Addition)
+}
+
 // data source info //////////////////////////////////////////////////////
 
 // Provider is the upstream data processor (usually Vunnel) that is responsible for vulnerability records. Each provider
@@ -731,3 +739,37 @@ func (c *Cpe) AfterCreate(tx *gorm.DB) (err error) {
 	}
 	return nil
 }
+
+type KnownExploitedVulnerabilityHandle struct {
+	ID int64 `gorm:"primaryKey"`
+
+	Cve string `gorm:"column:cve;not null;index,collate:NOCASE"`
+
+	BlobID    ID                               `gorm:"column:blob_id"`
+	BlobValue *KnownExploitedVulnerabilityBlob `gorm:"-"`
+}
+
+func (v KnownExploitedVulnerabilityHandle) getBlobValue() any {
+	if v.BlobValue == nil {
+		return nil // must return untyped nil or getBlobValue() == nil will always be false
+	}
+	return v.BlobValue
+}
+
+func (v *KnownExploitedVulnerabilityHandle) setBlobID(id ID) {
+	v.BlobID = id
+}
+
+func (v KnownExploitedVulnerabilityHandle) getBlobID() ID {
+	return v.BlobID
+}
+
+func (v *KnownExploitedVulnerabilityHandle) setBlob(rawBlobValue []byte) error {
+	var blobValue KnownExploitedVulnerabilityBlob
+	if err := json.Unmarshal(rawBlobValue, &blobValue); err != nil {
+		return fmt.Errorf("unable to unmarshal KEV blob value: %w", err)
+	}
+
+	v.BlobValue = &blobValue
+	return nil
+}
diff --git a/grype/db/v6/provider_store.go b/grype/db/v6/provider_store.go
index 5df66a23..0cfcfbf1 100644
--- a/grype/db/v6/provider_store.go
+++ b/grype/db/v6/provider_store.go
@@ -15,6 +15,10 @@ type ProviderStoreReader interface {
 	fillProviders(handles []ref[string, Provider]) error
 }
 
+type ProviderStoreWriter interface {
+	AddProvider(p Provider) error
+}
+
 type providerStore struct {
 	db *gorm.DB
 }
@@ -25,6 +29,15 @@ func newProviderStore(db *gorm.DB) *providerStore {
 	}
 }
 
+func (s *providerStore) AddProvider(p Provider) error {
+	result := s.db.FirstOrCreate(&p)
+	if result.Error != nil {
+		return fmt.Errorf("failed to create provider record: %w", result.Error)
+	}
+
+	return nil
+}
+
 func (s *providerStore) GetProvider(name string) (*Provider, error) {
 	log.WithFields("name", name).Trace("fetching provider record")
 
diff --git a/grype/db/v6/store.go b/grype/db/v6/store.go
index 9105aa2e..ccde29d6 100644
--- a/grype/db/v6/store.go
+++ b/grype/db/v6/store.go
@@ -15,6 +15,7 @@ type store struct {
 	*vulnerabilityStore
 	*affectedPackageStore
 	*affectedCPEStore
+	*vulnerabilityDecoratorStore
 	blobStore *blobStore
 	db        *gorm.DB
 	config    Config
@@ -55,18 +56,38 @@ func newStore(cfg Config, empty, writable bool) (*store, error) {
 		return nil, fmt.Errorf("failed to open db: %w", err)
 	}
 
+	metadataStore := newDBMetadataStore(db)
+
+	if empty {
+		if err := metadataStore.SetDBMetadata(); err != nil {
+			return nil, fmt.Errorf("failed to set db metadata: %w", err)
+		}
+	}
+
+	meta, err := metadataStore.GetDBMetadata()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get db metadata: %w", err)
+	}
+
+	if meta == nil {
+		return nil, fmt.Errorf("no DB metadata found")
+	}
+
+	dbVersion := newSchemaVerFromDBMetadata(*meta)
+
 	bs := newBlobStore(db)
 	return &store{
-		dbMetadataStore:      newDBMetadataStore(db),
-		providerStore:        newProviderStore(db),
-		vulnerabilityStore:   newVulnerabilityStore(db, bs),
-		affectedPackageStore: newAffectedPackageStore(db, bs),
-		affectedCPEStore:     newAffectedCPEStore(db, bs),
-		blobStore:            bs,
-		db:                   db,
-		config:               cfg,
-		empty:                empty,
-		writable:             writable,
+		dbMetadataStore:             metadataStore,
+		providerStore:               newProviderStore(db),
+		vulnerabilityStore:          newVulnerabilityStore(db, bs),
+		affectedPackageStore:        newAffectedPackageStore(db, bs),
+		affectedCPEStore:            newAffectedCPEStore(db, bs),
+		vulnerabilityDecoratorStore: newVulnerabilityDecoratorStore(db, bs, dbVersion),
+		blobStore:                   bs,
+		db:                          db,
+		config:                      cfg,
+		empty:                       empty,
+		writable:                    writable,
 	}, nil
 }
 
diff --git a/grype/db/v6/vulnerability_decorator_store.go b/grype/db/v6/vulnerability_decorator_store.go
new file mode 100644
index 00000000..86e71004
--- /dev/null
+++ b/grype/db/v6/vulnerability_decorator_store.go
@@ -0,0 +1,98 @@
+package v6
+
+import (
+	"fmt"
+	"time"
+
+	"gorm.io/gorm"
+
+	"github.com/anchore/go-logger"
+	"github.com/anchore/grype/internal/log"
+	"github.com/anchore/grype/internal/schemaver"
+)
+
+type VulnerabilityDecoratorStoreWriter interface {
+	AddKnownExploitedVulnerabilities(kevs ...*KnownExploitedVulnerabilityHandle) error
+}
+
+type VulnerabilityDecoratorStoreReader interface {
+	GetKnownExploitedVulnerabilities(cve string) ([]KnownExploitedVulnerabilityHandle, error)
+}
+
+type vulnerabilityDecoratorStore struct {
+	db         *gorm.DB
+	blobStore  *blobStore
+	kevEnabled bool
+}
+
+func newVulnerabilityDecoratorStore(db *gorm.DB, bs *blobStore, dbVersion schemaver.SchemaVer) *vulnerabilityDecoratorStore {
+	minSupportedKEVClientVersion := schemaver.New(6, 0, 1)
+	return &vulnerabilityDecoratorStore{
+		db:         db,
+		blobStore:  bs,
+		kevEnabled: dbVersion.GreaterOrEqualTo(minSupportedKEVClientVersion),
+	}
+}
+
+func (s *vulnerabilityDecoratorStore) AddKnownExploitedVulnerabilities(kevs ...*KnownExploitedVulnerabilityHandle) error {
+	if !s.kevEnabled {
+		// when populating a new DB we should never run into capability issues
+		return ErrDBCapabilityNotSupported
+	}
+
+	for i := range kevs {
+		k := kevs[i]
+		// this adds the blob value to the DB and sets the ID on the kev handle
+		if err := s.blobStore.addBlobable(k); err != nil {
+			return fmt.Errorf("unable to add KEV blob: %w", err)
+		}
+
+		if err := s.db.Create(k).Error; err != nil {
+			return fmt.Errorf("unable to create known exploited vulnerability: %w", err)
+		}
+	}
+	return nil
+}
+
+func (s *vulnerabilityDecoratorStore) GetKnownExploitedVulnerabilities(cve string) ([]KnownExploitedVulnerabilityHandle, error) {
+	if !s.kevEnabled {
+		// capability incompatibilities should gracefully degrade, returning no data or errors
+		return nil, nil
+	}
+
+	fields := logger.Fields{
+		"cve": cve,
+	}
+	start := time.Now()
+	var count int
+	defer func() {
+		fields["duration"] = time.Since(start)
+		fields["records"] = count
+		log.WithFields(fields).Trace("fetched KEV records")
+	}()
+
+	var models []KnownExploitedVulnerabilityHandle
+	var results []*KnownExploitedVulnerabilityHandle
+
+	if err := s.db.Where("cve = ?", cve).FindInBatches(&results, batchSize, func(_ *gorm.DB, _ int) error {
+		var blobs []blobable
+		for _, r := range results {
+			blobs = append(blobs, r)
+		}
+		if err := s.blobStore.attachBlobValue(blobs...); err != nil {
+			return fmt.Errorf("unable to attach KEV blobs: %w", err)
+		}
+
+		for _, r := range results {
+			models = append(models, *r)
+		}
+
+		count += len(results)
+
+		return nil
+	}).Error; err != nil {
+		return models, fmt.Errorf("unable to fetch KEV records: %w", err)
+	}
+
+	return models, nil
+}
diff --git a/grype/db/v6/vulnerability_provider.go b/grype/db/v6/vulnerability_provider.go
index 4aad4a67..a1f5090d 100644
--- a/grype/db/v6/vulnerability_provider.go
+++ b/grype/db/v6/vulnerability_provider.go
@@ -6,7 +6,9 @@ import (
 	"io"
 	"strings"
 
+	"github.com/hashicorp/go-multierror"
 	"github.com/iancoleman/strcase"
+	"github.com/scylladb/go-set/strset"
 
 	"github.com/anchore/go-logger"
 	"github.com/anchore/grype/grype/db/v6/name"
@@ -64,14 +66,20 @@ func (s vulnerabilityProvider) VulnerabilityMetadata(ref vulnerability.Reference
 		}, nil
 	}
 
+	kevs, err := s.fetchKnownExploited(getCVEs(vuln))
+	if err != nil {
+		log.WithFields("id", ref.ID, "vulnerability", vuln.String(), "error", err).Debug("unable to extract known exploited from vulnerability")
+	}
+
 	return &vulnerability.Metadata{
-		ID:          vuln.Name,
-		DataSource:  vuln.Provider.ID,
-		Namespace:   ref.Namespace,
-		Severity:    toSeverityString(sev),
-		URLs:        toURLs(vuln),
-		Description: vuln.BlobValue.Description,
-		Cvss:        cvss,
+		ID:             vuln.Name,
+		DataSource:     vuln.Provider.ID,
+		Namespace:      ref.Namespace,
+		Severity:       toSeverityString(sev),
+		URLs:           toURLs(vuln),
+		Description:    vuln.BlobValue.Description,
+		Cvss:           cvss,
+		KnownExploited: kevs,
 	}, nil
 }
 
@@ -87,6 +95,33 @@ func (s vulnerabilityProvider) fetchVulnerability(ref vulnerability.Reference) (
 	return nil, nil
 }
 
+func (s vulnerabilityProvider) fetchKnownExploited(cves []string) ([]vulnerability.KnownExploited, error) {
+	var out []vulnerability.KnownExploited
+	var errs error
+	for _, cve := range cves {
+		kevs, err := s.reader.GetKnownExploitedVulnerabilities(cve)
+		if err != nil {
+			errs = multierror.Append(errs, err)
+			continue
+		}
+		for _, kev := range kevs {
+			out = append(out, vulnerability.KnownExploited{
+				CVE:                        kev.Cve,
+				VendorProject:              kev.BlobValue.VendorProject,
+				Product:                    kev.BlobValue.Product,
+				DateAdded:                  kev.BlobValue.DateAdded,
+				RequiredAction:             kev.BlobValue.RequiredAction,
+				DueDate:                    kev.BlobValue.DueDate,
+				KnownRansomwareCampaignUse: kev.BlobValue.KnownRansomwareCampaignUse,
+				Notes:                      kev.BlobValue.Notes,
+				URLs:                       kev.BlobValue.URLs,
+				CWEs:                       kev.BlobValue.CWEs,
+			})
+		}
+	}
+	return out, errs
+}
+
 func (s vulnerabilityProvider) PackageSearchNames(p pkg.Package) []string {
 	return name.PackageNames(p)
 }
@@ -412,3 +447,36 @@ func toURLs(vuln *VulnerabilityHandle) []string {
 	}
 	return out
 }
+
+func getCVEs(vuln *VulnerabilityHandle) []string {
+	var cves []string
+	set := strset.New()
+
+	addCVE := func(id string) {
+		lower := strings.ToLower(id)
+		if strings.HasPrefix(lower, "cve-") {
+			if !set.Has(lower) {
+				cves = append(cves, id)
+				set.Add(lower)
+			}
+		}
+	}
+
+	if vuln == nil {
+		return cves
+	}
+
+	addCVE(vuln.Name)
+
+	if vuln.BlobValue == nil {
+		return cves
+	}
+
+	addCVE(vuln.BlobValue.ID)
+
+	for _, alias := range vuln.BlobValue.Aliases {
+		addCVE(alias)
+	}
+
+	return cves
+}
diff --git a/grype/db/v6/vulnerability_store.go b/grype/db/v6/vulnerability_store.go
index 76d180c3..3cab8bfb 100644
--- a/grype/db/v6/vulnerability_store.go
+++ b/grype/db/v6/vulnerability_store.go
@@ -306,7 +306,7 @@ func (s *vulnerabilityStore) GetVulnerabilities(vuln *VulnerabilitySpecifier, co
 				blobs = append(blobs, r)
 			}
 			if err := s.blobStore.attachBlobValue(blobs...); err != nil {
-				return fmt.Errorf("unable to attach blobs: %w", err)
+				return fmt.Errorf("unable to attach vulnerability blobs: %w", err)
 			}
 		}
 
diff --git a/grype/internal/packagemetadata/discover_type_names.go b/grype/internal/packagemetadata/discover_type_names.go
index a1a3fbcb..617e18d8 100644
--- a/grype/internal/packagemetadata/discover_type_names.go
+++ b/grype/internal/packagemetadata/discover_type_names.go
@@ -18,6 +18,7 @@ var metadataExceptions = strset.New(
 	"FileMetadata",
 	"PURLFileMetadata",
 	"PURLLiteralMetadata",
+	"CPELiteralMetadata",
 )
 
 func DiscoverTypeNames() ([]string, error) {
diff --git a/grype/pkg/cpe_provider.go b/grype/pkg/cpe_provider.go
new file mode 100644
index 00000000..b9226ab9
--- /dev/null
+++ b/grype/pkg/cpe_provider.go
@@ -0,0 +1,100 @@
+package pkg
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/anchore/syft/syft/cpe"
+	"github.com/anchore/syft/syft/pkg"
+	"github.com/anchore/syft/syft/sbom"
+	"github.com/anchore/syft/syft/source"
+)
+
+const cpeInputPrefix = "cpe:"
+
+type CPELiteralMetadata struct {
+	CPE string
+}
+
+func cpeProvider(userInput string) ([]Package, Context, *sbom.SBOM, error) {
+	reader, ctx, err := getCPEReader(userInput)
+	if err != nil {
+		return nil, Context{}, nil, err
+	}
+
+	return decodeCPEsFromReader(reader, ctx)
+}
+
+func getCPEReader(userInput string) (r io.Reader, ctx Context, err error) {
+	if strings.HasPrefix(userInput, cpeInputPrefix) {
+		ctx.Source = &source.Description{
+			Metadata: CPELiteralMetadata{
+				CPE: userInput,
+			},
+		}
+		return strings.NewReader(userInput), ctx, nil
+	}
+	return nil, ctx, errDoesNotProvide
+}
+
+func decodeCPEsFromReader(reader io.Reader, ctx Context) ([]Package, Context, *sbom.SBOM, error) {
+	scanner := bufio.NewScanner(reader)
+	var packages []Package
+	var syftPkgs []pkg.Package
+
+	for scanner.Scan() {
+		rawLine := scanner.Text()
+		p, syftPkg, err := cpeToPackage(rawLine)
+		if err != nil {
+			return nil, Context{}, nil, err
+		}
+
+		if p != nil {
+			packages = append(packages, *p)
+		}
+		if syftPkg != nil {
+			syftPkgs = append(syftPkgs, *syftPkg)
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		return nil, Context{}, nil, err
+	}
+
+	s := &sbom.SBOM{
+		Artifacts: sbom.Artifacts{
+			Packages: pkg.NewCollection(syftPkgs...),
+		},
+	}
+
+	return packages, ctx, s, nil
+}
+
+func cpeToPackage(rawLine string) (*Package, *pkg.Package, error) {
+	c, err := cpe.New(rawLine, "")
+	if err != nil {
+		return nil, nil, fmt.Errorf("unable to decode cpe %q: %w", rawLine, err)
+	}
+
+	syftPkg := pkg.Package{
+		Name:    c.Attributes.Product,
+		Version: c.Attributes.Version,
+		CPEs:    []cpe.CPE{c},
+		// TODO infer from cpe target sw, this is not as important since the only matcher for CPEs is the stock matcher
+		//Type:     pkgType,
+		// Language:...
+	}
+
+	syftPkg.SetID()
+
+	return &Package{
+		ID:       ID(c.Attributes.BindToFmtString()),
+		CPEs:     syftPkg.CPEs,
+		Name:     syftPkg.Name,
+		Version:  syftPkg.Version,
+		Type:     syftPkg.Type,
+		Language: syftPkg.Language,
+	}, &syftPkg, nil
+}
diff --git a/grype/pkg/provider.go b/grype/pkg/provider.go
index 9258edd2..985e3a2a 100644
--- a/grype/pkg/provider.go
+++ b/grype/pkg/provider.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/bmatcuk/doublestar/v2"
 
+	"github.com/anchore/grype/internal/log"
 	"github.com/anchore/syft/syft/file"
 	"github.com/anchore/syft/syft/sbom"
 )
@@ -23,14 +24,23 @@ func Provide(userInput string, config ProviderConfig) ([]Package, Context, *sbom
 				return nil, ctx, s, exclusionsErr
 			}
 		}
+		log.WithFields("input", userInput).Trace("interpreting input from the given SBOM")
 		return packages, ctx, s, err
 	}
 
 	packages, ctx, s, err = purlProvider(userInput)
 	if !errors.Is(err, errDoesNotProvide) {
+		log.WithFields("input", userInput).Trace("interpreting input from the given PURL(s)")
 		return packages, ctx, s, err
 	}
 
+	packages, ctx, s, err = cpeProvider(userInput)
+	if !errors.Is(err, errDoesNotProvide) {
+		log.WithFields("input", userInput).Trace("interpreting input from the given CPE")
+		return packages, ctx, s, err
+	}
+
+	log.WithFields("input", userInput).Trace("passing input to syft for interpretation")
 	return syftProvider(userInput, config)
 }
 
diff --git a/grype/pkg/purl_provider.go b/grype/pkg/purl_provider.go
index c885de2e..692a3b10 100644
--- a/grype/pkg/purl_provider.go
+++ b/grype/pkg/purl_provider.go
@@ -38,10 +38,49 @@ func purlProvider(userInput string) ([]Package, Context, *sbom.SBOM, error) {
 		return nil, Context{}, nil, err
 	}
 
-	return decodePurlFile(reader, ctx)
+	return decodePurlsFromReader(reader, ctx)
 }
 
-func decodePurlFile(reader io.Reader, ctx Context) ([]Package, Context, *sbom.SBOM, error) {
+func getPurlReader(userInput string) (r io.Reader, ctx Context, err error) {
+	switch {
+	case strings.HasPrefix(userInput, purlInputPrefix):
+		path := strings.TrimPrefix(userInput, purlInputPrefix)
+		ctx.Source = &source.Description{
+			Metadata: PURLFileMetadata{
+				Path: path,
+			},
+		}
+		file, err := openPurlFile(path)
+		if err != nil {
+			return nil, ctx, err
+		}
+		return file, ctx, nil
+	case strings.HasPrefix(userInput, singlePurlInputPrefix):
+		ctx.Source = &source.Description{
+			Metadata: PURLLiteralMetadata{
+				PURL: userInput,
+			},
+		}
+		return strings.NewReader(userInput), ctx, nil
+	}
+	return nil, ctx, errDoesNotProvide
+}
+
+func openPurlFile(path string) (*os.File, error) {
+	expandedPath, err := homedir.Expand(path)
+	if err != nil {
+		return nil, fmt.Errorf("unable to open purls: %w", err)
+	}
+
+	f, err := os.Open(expandedPath)
+	if err != nil {
+		return nil, fmt.Errorf("unable to open file %s: %w", expandedPath, err)
+	}
+
+	return f, nil
+}
+
+func decodePurlsFromReader(reader io.Reader, ctx Context) ([]Package, Context, *sbom.SBOM, error) {
 	scanner := bufio.NewScanner(reader)
 	var packages []Package
 	var syftPkgs []pkg.Package
@@ -219,42 +258,3 @@ func handleDefaultUpstream(pkgName string, value string) []UpstreamPackage {
 	}
 	return nil
 }
-
-func getPurlReader(userInput string) (r io.Reader, ctx Context, err error) {
-	switch {
-	case strings.HasPrefix(userInput, purlInputPrefix):
-		path := strings.TrimPrefix(userInput, purlInputPrefix)
-		ctx.Source = &source.Description{
-			Metadata: PURLFileMetadata{
-				Path: path,
-			},
-		}
-		file, err := openPurlFile(path)
-		if err != nil {
-			return nil, ctx, err
-		}
-		return file, ctx, nil
-	case strings.HasPrefix(userInput, singlePurlInputPrefix):
-		ctx.Source = &source.Description{
-			Metadata: PURLLiteralMetadata{
-				PURL: userInput,
-			},
-		}
-		return strings.NewReader(userInput), ctx, nil
-	}
-	return nil, ctx, errDoesNotProvide
-}
-
-func openPurlFile(path string) (*os.File, error) {
-	expandedPath, err := homedir.Expand(path)
-	if err != nil {
-		return nil, fmt.Errorf("unable to open purls: %w", err)
-	}
-
-	f, err := os.Open(expandedPath)
-	if err != nil {
-		return nil, fmt.Errorf("unable to open file %s: %w", expandedPath, err)
-	}
-
-	return f, nil
-}
diff --git a/grype/presenter/models/source.go b/grype/presenter/models/source.go
index f97871e0..6bbb1fe6 100644
--- a/grype/presenter/models/source.go
+++ b/grype/presenter/models/source.go
@@ -3,6 +3,7 @@ package models
 import (
 	"fmt"
 
+	"github.com/anchore/grype/grype/pkg"
 	syftSource "github.com/anchore/syft/syft/source"
 )
 
@@ -14,6 +15,21 @@ type source struct {
 // newSource creates a new source object to be represented into JSON.
 func newSource(src syftSource.Description) (source, error) {
 	switch m := src.Metadata.(type) {
+	case pkg.PURLFileMetadata:
+		return source{
+			Type:   "purl-file",
+			Target: m.Path,
+		}, nil
+	case pkg.PURLLiteralMetadata:
+		return source{
+			Type:   "purl",
+			Target: m.PURL,
+		}, nil
+	case pkg.CPELiteralMetadata:
+		return source{
+			Type:   "cpe",
+			Target: m.CPE,
+		}, nil
 	case syftSource.ImageMetadata:
 		// ensure that empty collections are not shown as null
 		if m.RepoDigests == nil {
diff --git a/grype/presenter/models/vulnerability_metadata.go b/grype/presenter/models/vulnerability_metadata.go
index ced69aea..02795d2a 100644
--- a/grype/presenter/models/vulnerability_metadata.go
+++ b/grype/presenter/models/vulnerability_metadata.go
@@ -1,15 +1,33 @@
 package models
 
-import "github.com/anchore/grype/grype/vulnerability"
+import (
+	"time"
+
+	"github.com/anchore/grype/grype/vulnerability"
+)
 
 type VulnerabilityMetadata struct {
-	ID          string   `json:"id"`
-	DataSource  string   `json:"dataSource"`
-	Namespace   string   `json:"namespace,omitempty"`
-	Severity    string   `json:"severity,omitempty"`
-	URLs        []string `json:"urls"`
-	Description string   `json:"description,omitempty"`
-	Cvss        []Cvss   `json:"cvss"`
+	ID             string           `json:"id"`
+	DataSource     string           `json:"dataSource"`
+	Namespace      string           `json:"namespace,omitempty"`
+	Severity       string           `json:"severity,omitempty"`
+	URLs           []string         `json:"urls"`
+	Description    string           `json:"description,omitempty"`
+	Cvss           []Cvss           `json:"cvss"`
+	KnownExploited []KnownExploited `json:"knownExploited,omitempty"`
+}
+
+type KnownExploited struct {
+	CVE                        string   `json:"cve"`
+	VendorProject              string   `json:"vendorProject,omitempty"`
+	Product                    string   `json:"product,omitempty"`
+	DateAdded                  string   `json:"dateAdded,omitempty"`
+	RequiredAction             string   `json:"requiredAction,omitempty"`
+	DueDate                    string   `json:"dueDate,omitempty"`
+	KnownRansomwareCampaignUse string   `json:"knownRansomwareCampaignUse"`
+	Notes                      string   `json:"notes,omitempty"`
+	URLs                       []string `json:"urls,omitempty"`
+	CWEs                       []string `json:"cwes,omitempty"`
 }
 
 func NewVulnerabilityMetadata(id, namespace string, metadata *vulnerability.Metadata) VulnerabilityMetadata {
@@ -26,14 +44,34 @@ func NewVulnerabilityMetadata(id, namespace string, metadata *vulnerability.Meta
 	}
 
 	return VulnerabilityMetadata{
-		ID:          id,
-		DataSource:  metadata.DataSource,
-		Namespace:   metadata.Namespace,
-		Severity:    metadata.Severity,
-		URLs:        urls,
-		Description: metadata.Description,
-		Cvss:        NewCVSS(metadata),
+		ID:             id,
+		DataSource:     metadata.DataSource,
+		Namespace:      metadata.Namespace,
+		Severity:       metadata.Severity,
+		URLs:           urls,
+		Description:    metadata.Description,
+		Cvss:           NewCVSS(metadata),
+		KnownExploited: toKnownExploited(metadata.KnownExploited),
+	}
+}
+
+func toKnownExploited(knownExploited []vulnerability.KnownExploited) []KnownExploited {
+	result := make([]KnownExploited, len(knownExploited))
+	for idx, ke := range knownExploited {
+		result[idx] = KnownExploited{
+			CVE:                        ke.CVE,
+			VendorProject:              ke.VendorProject,
+			Product:                    ke.Product,
+			DateAdded:                  ke.DateAdded.Format(time.DateOnly),
+			RequiredAction:             ke.RequiredAction,
+			DueDate:                    ke.DueDate.Format(time.DateOnly),
+			KnownRansomwareCampaignUse: ke.KnownRansomwareCampaignUse,
+			Notes:                      ke.Notes,
+			URLs:                       ke.URLs,
+			CWEs:                       ke.CWEs,
+		}
 	}
+	return result
 }
 
 // returns severtiy score for presenter sorting purposes
diff --git a/grype/vulnerability/metadata.go b/grype/vulnerability/metadata.go
index a3219f66..b3735931 100644
--- a/grype/vulnerability/metadata.go
+++ b/grype/vulnerability/metadata.go
@@ -1,13 +1,16 @@
 package vulnerability
 
+import "time"
+
 type Metadata struct {
-	ID          string
-	DataSource  string
-	Namespace   string
-	Severity    string
-	URLs        []string
-	Description string
-	Cvss        []Cvss
+	ID             string
+	DataSource     string
+	Namespace      string
+	Severity       string
+	URLs           []string
+	Description    string
+	Cvss           []Cvss
+	KnownExploited []KnownExploited
 }
 
 type Cvss struct {
@@ -32,3 +35,16 @@ func NewCvssMetrics(baseScore, exploitabilityScore, impactScore float64) CvssMet
 		ImpactScore:         &impactScore,
 	}
 }
+
+type KnownExploited struct {
+	CVE                        string
+	VendorProject              string
+	Product                    string
+	DateAdded                  *time.Time
+	RequiredAction             string
+	DueDate                    *time.Time
+	KnownRansomwareCampaignUse string
+	Notes                      string
+	URLs                       []string
+	CWEs                       []string
+}
diff --git a/internal/schemaver/schema_ver.go b/internal/schemaver/schema_ver.go
index c76f6cc8..83423f3e 100644
--- a/internal/schemaver/schema_ver.go
+++ b/internal/schemaver/schema_ver.go
@@ -1,93 +1,88 @@
 package schemaver
 
 import (
+	"encoding/json"
 	"fmt"
 	"strconv"
 	"strings"
 )
 
-type SchemaVer string
+type SchemaVer struct {
+	Model    int // breaking changes
+	Revision int // potentially-breaking changes
+	Addition int // additions only
+}
 
 func New(model, revision, addition int) SchemaVer {
-	return SchemaVer(fmt.Sprintf("%d.%d.%d", model, revision, addition))
+	return SchemaVer{
+		Model:    model,
+		Revision: revision,
+		Addition: addition,
+	}
 }
 
 func Parse(s string) (SchemaVer, error) {
 	// must provide model.revision.addition
 	parts := strings.Split(strings.TrimSpace(s), ".")
 	if len(parts) != 3 {
-		return "", fmt.Errorf("invalid schema version format: %s", s)
+		return SchemaVer{}, fmt.Errorf("invalid schema version format: %s", s)
 	}
 	// check that all parts are integers
-	for _, part := range parts {
-		if _, err := strconv.Atoi(part); err != nil {
-			return "", fmt.Errorf("invalid schema version format: %s", s)
+	var values [3]int
+	for i, part := range parts {
+		if i == 0 {
+			part = strings.TrimPrefix(part, "v")
+		}
+		v, err := strconv.Atoi(part)
+		if err != nil || v < 0 {
+			return SchemaVer{}, fmt.Errorf("invalid schema version format: %s", s)
 		}
+		values[i] = v
 	}
-	return SchemaVer(s), nil
-}
-
-func (s SchemaVer) String() string {
-	return string(s)
-}
-
-func (s SchemaVer) ModelPart() (int, bool) {
-	v, ok := parseVersionPart(s, 0)
-	if v == 0 {
-		ok = false
+	if values[0] < 1 {
+		return SchemaVer{}, fmt.Errorf("model value must be greater than 0: %s", s)
 	}
-	return v, ok
+	return New(values[0], values[1], values[2]), nil
 }
 
-func (s SchemaVer) RevisionPart() (int, bool) {
-	return parseVersionPart(s, 1)
+func (s SchemaVer) Valid() bool {
+	return s.Model > 0 && s.Revision >= 0 && s.Addition >= 0
 }
 
-func (s SchemaVer) AdditionPart() (int, bool) {
-	return parseVersionPart(s, 2)
+func (s SchemaVer) MarshalJSON() ([]byte, error) {
+	return []byte(fmt.Sprintf(`"%s"`, s.String())), nil
 }
 
-func parseVersionPart(s SchemaVer, index int) (int, bool) {
-	parts := strings.Split(string(s), ".")
-	if len(parts) <= index {
-		return 0, false
+func (s *SchemaVer) UnmarshalJSON(data []byte) error {
+	var str string
+	if err := json.Unmarshal(data, &str); err != nil {
+		return fmt.Errorf("failed to unmarshal schema version as string: %w", err)
 	}
-	value, err := strconv.Atoi(parts[index])
+
+	parsed, err := Parse(str)
 	if err != nil {
-		return 0, false
+		return fmt.Errorf("failed to parse schema version: %w", err)
 	}
-	return value, true
-}
-
-func (s SchemaVer) LessThan(other SchemaVer) bool {
-	return s.compare(other) < 0
+	*s = parsed
+	return nil
 }
 
-func (s SchemaVer) GreaterOrEqualTo(other SchemaVer) bool {
-	return s.compare(other) >= 0
+func (s SchemaVer) String() string {
+	return fmt.Sprintf("v%d.%d.%d", s.Model, s.Revision, s.Addition)
 }
 
-func (s SchemaVer) compare(other SchemaVer) int {
-	parts := strings.Split(string(s), ".")
-	otherParts := strings.Split(string(other), ".")
-
-	for i := 0; i < 3; i++ {
-		v1 := 0
-		if i < len(parts) {
-			v1, _ = strconv.Atoi(parts[i])
-		}
-
-		v2 := 0
-		if i < len(otherParts) {
-			v2, _ = strconv.Atoi(otherParts[i])
-		}
+func (s SchemaVer) LessThan(other SchemaVer) bool {
+	if s.Model != other.Model {
+		return s.Model < other.Model
+	}
 
-		if v1 < v2 {
-			return -1
-		} else if v1 > v2 {
-			return 1
-		}
+	if s.Revision != other.Revision {
+		return s.Revision < other.Revision
 	}
 
-	return 0
+	return s.Addition < other.Addition
+}
+
+func (s SchemaVer) GreaterOrEqualTo(other SchemaVer) bool {
+	return !s.LessThan(other)
 }


Test patch:
diff --git a/cmd/grype/cli/commands/db_check_test.go b/cmd/grype/cli/commands/db_check_test.go
index ff95a386..82c5026c 100644
--- a/cmd/grype/cli/commands/db_check_test.go
+++ b/cmd/grype/cli/commands/db_check_test.go
@@ -11,17 +11,18 @@ import (
 
 	db "github.com/anchore/grype/grype/db/v6"
 	"github.com/anchore/grype/grype/db/v6/distribution"
+	"github.com/anchore/grype/internal/schemaver"
 )
 
 func TestPresentNewDBCheck(t *testing.T) {
 	currentDB := &db.Description{
-		SchemaVersion: "v6.0.0",
+		SchemaVersion: schemaver.New(6, 0, 0),
 		Built:         db.Time{Time: time.Date(2023, 11, 25, 12, 0, 0, 0, time.UTC)},
 	}
 
 	candidateDB := &distribution.Archive{
 		Description: db.Description{
-			SchemaVersion: "v6.0.1",
+			SchemaVersion: schemaver.New(6, 0, 1),
 			Built:         db.Time{Time: time.Date(2023, 11, 26, 12, 0, 0, 0, time.UTC)},
 		},
 		Path:     "vulnerability-db_6.0.1_2023-11-26T12:00:00Z_6238463.tar.gz",
diff --git a/cmd/grype/cli/commands/db_list_test.go b/cmd/grype/cli/commands/db_list_test.go
index e5b9d9f9..25eec621 100644
--- a/cmd/grype/cli/commands/db_list_test.go
+++ b/cmd/grype/cli/commands/db_list_test.go
@@ -123,7 +123,7 @@ func TestPresentNewDBList(t *testing.T) {
 			format: textOutputFormat,
 			latest: latestDoc,
 			expectedText: `Status:   active
-Schema:   6.0.0
+Schema:   v6.0.0
 Built:    2024-11-27T14:43:17Z
 Listing:  http://localhost:8000/latest.json
 DB URL:   http://localhost:8000/vulnerability-db_v6.0.0_2024-11-25T01:31:56Z_1732718597.tar.zst
@@ -137,7 +137,7 @@ Checksum: sha256:16bcb6551c748056f752f299fcdb4fa50fe61589d086be3889e670261ff21ca
 			latest: latestDoc,
 			expectedText: `{
  "status": "active",
- "schemaVersion": "6.0.0",
+ "schemaVersion": "v6.0.0",
  "built": "2024-11-27T14:43:17Z",
  "path": "vulnerability-db_v6.0.0_2024-11-25T01:31:56Z_1732718597.tar.zst",
  "checksum": "sha256:16bcb6551c748056f752f299fcdb4fa50fe61589d086be3889e670261ff21ca4"
diff --git a/grype/db/v6/db_metadata_store_test.go b/grype/db/v6/db_metadata_store_test.go
index 30cc3eec..4812c87b 100644
--- a/grype/db/v6/db_metadata_store_test.go
+++ b/grype/db/v6/db_metadata_store_test.go
@@ -9,7 +9,9 @@ import (
 )
 
 func TestDbMetadataStore_empty(t *testing.T) {
-	s := newDBMetadataStore(setupTestStore(t).db)
+	db := setupTestStore(t).db
+	require.NoError(t, db.Unscoped().Where("1 = 1").Delete(&DBMetadata{}).Error) // delete all existing records
+	s := newDBMetadataStore(db)
 
 	// attempt to fetch a non-existent record
 	actualMetadata, err := s.GetDBMetadata()
@@ -59,6 +61,8 @@ func setupTestStore(t testing.TB, d ...string) *store {
 	}, true, true)
 	require.NoError(t, err)
 
+	require.NoError(t, s.SetDBMetadata())
+
 	return s
 }
 
diff --git a/grype/db/v6/distribution/client_test.go b/grype/db/v6/distribution/client_test.go
index b9ac8841..357f88ac 100644
--- a/grype/db/v6/distribution/client_test.go
+++ b/grype/db/v6/distribution/client_test.go
@@ -15,6 +15,7 @@ import (
 	"github.com/wagoodman/go-progress"
 
 	db "github.com/anchore/grype/grype/db/v6"
+	"github.com/anchore/grype/internal/schemaver"
 )
 
 func TestClient_LatestFromURL(t *testing.T) {
@@ -31,7 +32,7 @@ func TestClient_LatestFromURL(t *testing.T) {
 					Status: "active",
 					Archive: Archive{
 						Description: db.Description{
-							SchemaVersion: "1.0.0",
+							SchemaVersion: schemaver.New(1, 0, 0),
 							Built:         db.Time{Time: time.Date(2023, 9, 26, 12, 0, 0, 0, time.UTC)},
 						},
 						Path:     "path/to/archive",
@@ -51,7 +52,7 @@ func TestClient_LatestFromURL(t *testing.T) {
 				Status: "active",
 				Archive: Archive{
 					Description: db.Description{
-						SchemaVersion: "1.0.0",
+						SchemaVersion: schemaver.New(1, 0, 0),
 						Built:         db.Time{Time: time.Date(2023, 9, 26, 12, 0, 0, 0, time.UTC)},
 					},
 					Path:     "path/to/archive",
@@ -189,7 +190,7 @@ func TestClient_Download(t *testing.T) {
 
 func TestClient_IsUpdateAvailable(t *testing.T) {
 	current := &db.Description{
-		SchemaVersion: "1.0.0",
+		SchemaVersion: schemaver.New(1, 0, 0),
 		Built:         db.Time{Time: time.Date(2023, 9, 26, 12, 0, 0, 0, time.UTC)},
 	}
 
@@ -205,7 +206,7 @@ func TestClient_IsUpdateAvailable(t *testing.T) {
 				Status: StatusActive,
 				Archive: Archive{
 					Description: db.Description{
-						SchemaVersion: "1.0.0",
+						SchemaVersion: schemaver.New(1, 0, 0),
 						Built:         db.Time{Time: time.Date(2023, 9, 27, 12, 0, 0, 0, time.UTC)},
 					},
 					Path:     "path/to/archive.tar.gz",
@@ -214,7 +215,7 @@ func TestClient_IsUpdateAvailable(t *testing.T) {
 			},
 			archive: &Archive{
 				Description: db.Description{
-					SchemaVersion: "1.0.0",
+					SchemaVersion: schemaver.New(1, 0, 0),
 					Built:         db.Time{Time: time.Date(2023, 9, 27, 12, 0, 0, 0, time.UTC)},
 				},
 				Path:     "path/to/archive.tar.gz",
@@ -227,7 +228,7 @@ func TestClient_IsUpdateAvailable(t *testing.T) {
 				Status: "active",
 				Archive: Archive{
 					Description: db.Description{
-						SchemaVersion: "1.0.0",
+						SchemaVersion: schemaver.New(1, 0, 0),
 						Built:         db.Time{Time: time.Date(2023, 9, 26, 12, 0, 0, 0, time.UTC)},
 					},
 					Path:     "path/to/archive.tar.gz",
@@ -247,7 +248,7 @@ func TestClient_IsUpdateAvailable(t *testing.T) {
 				Status: StatusDeprecated,
 				Archive: Archive{
 					Description: db.Description{
-						SchemaVersion: "1.0.0",
+						SchemaVersion: schemaver.New(1, 0, 0),
 						Built:         db.Time{Time: time.Date(2023, 9, 27, 12, 0, 0, 0, time.UTC)},
 					},
 					Path:     "path/to/archive.tar.gz",
@@ -256,7 +257,7 @@ func TestClient_IsUpdateAvailable(t *testing.T) {
 			},
 			archive: &Archive{
 				Description: db.Description{
-					SchemaVersion: "1.0.0",
+					SchemaVersion: schemaver.New(1, 0, 0),
 					Built:         db.Time{Time: time.Date(2023, 9, 27, 12, 0, 0, 0, time.UTC)},
 				},
 				Path:     "path/to/archive.tar.gz",
@@ -270,7 +271,7 @@ func TestClient_IsUpdateAvailable(t *testing.T) {
 				Status: StatusEndOfLife,
 				Archive: Archive{
 					Description: db.Description{
-						SchemaVersion: "1.0.0",
+						SchemaVersion: schemaver.New(1, 0, 0),
 						Built:         db.Time{Time: time.Date(2023, 9, 27, 12, 0, 0, 0, time.UTC)},
 					},
 					Path:     "path/to/archive.tar.gz",
@@ -279,7 +280,7 @@ func TestClient_IsUpdateAvailable(t *testing.T) {
 			},
 			archive: &Archive{
 				Description: db.Description{
-					SchemaVersion: "1.0.0",
+					SchemaVersion: schemaver.New(1, 0, 0),
 					Built:         db.Time{Time: time.Date(2023, 9, 27, 12, 0, 0, 0, time.UTC)},
 				},
 				Path:     "path/to/archive.tar.gz",
@@ -308,22 +309,22 @@ func TestDatabaseDescription_IsSupersededBy(t *testing.T) {
 	t2 := time.Date(2023, 9, 27, 12, 0, 0, 0, time.UTC)
 
 	currentMetadata := db.Description{
-		SchemaVersion: "1.0.0",
+		SchemaVersion: schemaver.New(1, 0, 0),
 		Built:         db.Time{Time: t1},
 	}
 
 	newerMetadata := db.Description{
-		SchemaVersion: "1.0.0",
+		SchemaVersion: schemaver.New(1, 0, 0),
 		Built:         db.Time{Time: t2},
 	}
 
 	olderMetadata := db.Description{
-		SchemaVersion: "1.0.0",
+		SchemaVersion: schemaver.New(1, 0, 0),
 		Built:         db.Time{Time: t1},
 	}
 
 	differentModelMetadata := db.Description{
-		SchemaVersion: "2.0.0",
+		SchemaVersion: schemaver.New(2, 0, 0),
 		Built:         db.Time{Time: t2},
 	}
 
diff --git a/grype/db/v6/distribution/latest.go b/grype/db/v6/distribution/latest.go
index 38ee08d9..b0a12e77 100644
--- a/grype/db/v6/distribution/latest.go
+++ b/grype/db/v6/distribution/latest.go
@@ -41,7 +41,7 @@ type Archive struct {
 func NewLatestDocument(entries ...Archive) *LatestDocument {
 	var validEntries []Archive
 	for _, entry := range entries {
-		if modelPart, ok := entry.SchemaVersion.ModelPart(); ok && modelPart == db.ModelVersion {
+		if entry.SchemaVersion.Model == db.ModelVersion {
 			validEntries = append(validEntries, entry)
 		}
 	}
@@ -92,7 +92,7 @@ func NewArchive(path string, t time.Time, model, revision, addition int) (*Archi
 }
 
 func (l LatestDocument) Write(writer io.Writer) error {
-	if l.SchemaVersion == "" {
+	if l.SchemaVersion.Model == 0 {
 		return fmt.Errorf("missing schema version")
 	}
 
diff --git a/grype/db/v6/distribution/latest_test.go b/grype/db/v6/distribution/latest_test.go
index f841a3e3..9e09392b 100644
--- a/grype/db/v6/distribution/latest_test.go
+++ b/grype/db/v6/distribution/latest_test.go
@@ -34,9 +34,7 @@ func TestNewLatestDocument(t *testing.T) {
 		latestDoc := NewLatestDocument(archive1, archive2)
 		require.NotNil(t, latestDoc)
 		require.Equal(t, latestDoc.Archive, archive1) // most recent archive
-		actual, ok := latestDoc.SchemaVersion.ModelPart()
-		require.True(t, ok)
-		require.Equal(t, actual, db.ModelVersion)
+		require.Equal(t, latestDoc.SchemaVersion.Model, db.ModelVersion)
 	})
 
 	t.Run("filter entries", func(t *testing.T) {
@@ -56,9 +54,7 @@ func TestNewLatestDocument(t *testing.T) {
 		latestDoc := NewLatestDocument(archive1, archive2)
 		require.NotNil(t, latestDoc)
 		require.Equal(t, latestDoc.Archive, archive2) // most recent archive with valid version
-		actual, ok := latestDoc.SchemaVersion.ModelPart()
-		require.True(t, ok)
-		require.Equal(t, actual, db.ModelVersion)
+		require.Equal(t, latestDoc.SchemaVersion.Model, db.ModelVersion)
 	})
 
 	t.Run("no entries", func(t *testing.T) {
@@ -73,7 +69,8 @@ func TestNewLatestFromReader(t *testing.T) {
 		latestDoc := LatestDocument{
 			Archive: Archive{
 				Description: db.Description{
-					Built: db.Time{Time: time.Now().Truncate(time.Second).UTC()},
+					SchemaVersion: schemaver.New(db.ModelVersion, db.Revision, db.Addition),
+					Built:         db.Time{Time: time.Now().Truncate(time.Second).UTC()},
 				},
 			},
 			Status: "active",
diff --git a/grype/db/v6/installation/curator_test.go b/grype/db/v6/installation/curator_test.go
index d0733c8d..3e6ecdaa 100644
--- a/grype/db/v6/installation/curator_test.go
+++ b/grype/db/v6/installation/curator_test.go
@@ -118,21 +118,15 @@ func writeTestDescriptionToDB(t *testing.T, dir string, desc db.Description) str
 		t.Fatalf("failed to delete existing DB metadata record: %v", err)
 	}
 
-	mod, ok := desc.SchemaVersion.ModelPart()
-	require.True(t, ok)
-
-	revision, ok := desc.SchemaVersion.RevisionPart()
-	require.True(t, ok)
-
-	addition, ok := desc.SchemaVersion.AdditionPart()
-	require.True(t, ok)
+	require.NotEmpty(t, desc.SchemaVersion.Model)
+	require.NotEmpty(t, desc.SchemaVersion.String())
 
 	ts := time.Now().UTC()
 	instance := &db.DBMetadata{
 		BuildTimestamp: &ts,
-		Model:          mod,
-		Revision:       revision,
-		Addition:       addition,
+		Model:          desc.SchemaVersion.Model,
+		Revision:       desc.SchemaVersion.Revision,
+		Addition:       desc.SchemaVersion.Revision,
 	}
 
 	require.NoError(t, d.Create(instance).Error)
@@ -692,7 +686,7 @@ func Test_isRehydrationNeeded(t *testing.T) {
 	}{
 		{
 			name:             "no database exists",
-			currentDBVersion: "",
+			currentDBVersion: schemaver.SchemaVer{},
 			currentClientVer: schemaver.New(6, 2, 0),
 			expectedResult:   false,
 		},
@@ -706,7 +700,7 @@ func Test_isRehydrationNeeded(t *testing.T) {
 		{
 			name:               "invalid client version in metadata",
 			currentDBVersion:   schemaver.New(6, 0, 0),
-			hydrationClientVer: schemaver.SchemaVer("not.valid.version"),
+			hydrationClientVer: schemaver.SchemaVer{-19, 0, 0},
 			currentClientVer:   schemaver.New(6, 2, 0),
 			expectedResult:     false,
 			expectedErr:        "unable to parse client version from import metadata",
@@ -739,11 +733,16 @@ func Test_isRehydrationNeeded(t *testing.T) {
 			fs := afero.NewOsFs()
 			testDir := t.TempDir()
 
-			if tt.hydrationClientVer != "" {
+			if tt.hydrationClientVer.Model != 0 {
 				writeTestImportMetadataWithCustomVersion(t, fs, testDir, "xxh64:something", tt.hydrationClientVer.String())
 			}
 
-			result, err := isRehydrationNeeded(fs, testDir, tt.currentDBVersion, tt.currentClientVer)
+			var dbVersion *schemaver.SchemaVer
+			if tt.currentDBVersion.Model != 0 {
+				dbVersion = &tt.currentDBVersion
+			}
+
+			result, err := isRehydrationNeeded(fs, testDir, dbVersion, tt.currentClientVer)
 
 			if tt.expectedErr != "" {
 				require.Error(t, err)
diff --git a/grype/db/v6/vulnerability_decorator_store_test.go b/grype/db/v6/vulnerability_decorator_store_test.go
new file mode 100644
index 00000000..7f94d830
--- /dev/null
+++ b/grype/db/v6/vulnerability_decorator_store_test.go
@@ -0,0 +1,296 @@
+package v6
+
+import (
+	"slices"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/anchore/grype/internal/schemaver"
+)
+
+func TestVulnerabilityDecoratorStore(t *testing.T) {
+	tests := []struct {
+		name        string
+		kevEnabled  bool
+		setupStore  func(*vulnerabilityDecoratorStore) error
+		input       []*KnownExploitedVulnerabilityHandle
+		expectError require.ErrorAssertionFunc
+	}{
+		{
+			name:       "happy path - single KEV",
+			kevEnabled: true,
+			input: []*KnownExploitedVulnerabilityHandle{
+				{
+					Cve: "CVE-2023-1234",
+					BlobValue: &KnownExploitedVulnerabilityBlob{
+						Cve:           "CVE-2023-1234",
+						VendorProject: "Test Vendor",
+						Product:       "Test Product",
+						DateAdded:     timeRef(time.Now()),
+					},
+				},
+			},
+		},
+		{
+			name:       "happy path - multiple KEVs",
+			kevEnabled: true,
+			input: []*KnownExploitedVulnerabilityHandle{
+				{
+					Cve: "CVE-2023-1234",
+					BlobValue: &KnownExploitedVulnerabilityBlob{
+						Cve:           "CVE-2023-1234",
+						VendorProject: "Vendor 1",
+					},
+				},
+				{
+					Cve: "CVE-2023-5678",
+					BlobValue: &KnownExploitedVulnerabilityBlob{
+						Cve:           "CVE-2023-5678",
+						VendorProject: "Vendor 2",
+					},
+				},
+			},
+		},
+		{
+			name:        "error - KEV disabled",
+			kevEnabled:  false,
+			input:       []*KnownExploitedVulnerabilityHandle{{Cve: "CVE-2023-1234"}},
+			expectError: require.Error,
+		},
+		{
+			name:       "duplicate CVEs (unexpected but allowed)",
+			kevEnabled: true,
+			input: []*KnownExploitedVulnerabilityHandle{
+				{
+					Cve: "CVE-2023-1234",
+					BlobValue: &KnownExploitedVulnerabilityBlob{
+						Cve:            "CVE-2023-1234",
+						RequiredAction: "1",
+					},
+				},
+				{
+					Cve: "CVE-2023-1234",
+					BlobValue: &KnownExploitedVulnerabilityBlob{
+						Cve:            "CVE-2023-1234",
+						RequiredAction: "2",
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.expectError == nil {
+				tt.expectError = require.NoError
+			}
+
+			db := setupTestStore(t).db
+			bs := newBlobStore(db)
+
+			s := &vulnerabilityDecoratorStore{
+				db:         db,
+				blobStore:  bs,
+				kevEnabled: tt.kevEnabled,
+			}
+
+			if tt.setupStore != nil {
+				require.NoError(t, tt.setupStore(s))
+			}
+
+			err := s.AddKnownExploitedVulnerabilities(tt.input...)
+			tt.expectError(t, err)
+			if err != nil {
+				return
+			}
+
+			var cves []string
+			for _, kev := range tt.input {
+				if !slices.Contains(cves, kev.Cve) {
+					cves = append(cves, kev.Cve)
+				}
+			}
+
+			var actual []*KnownExploitedVulnerabilityHandle
+			for _, cve := range cves {
+				intermediate, err := s.GetKnownExploitedVulnerabilities(cve)
+				require.NoError(t, err)
+				for i := range intermediate {
+					actual = append(actual, &intermediate[i])
+				}
+			}
+
+			for _, a := range actual {
+				assert.NotZero(t, a.ID)
+				assert.NotZero(t, a.BlobID)
+			}
+
+			if d := cmp.Diff(tt.input, actual); d != "" {
+				t.Errorf("unexpected known exploited vulnerabilities (-expected, +actual): %s", d)
+			}
+		})
+	}
+}
+
+func TestVulnerabilityDecoratorStore_AddKnownExploitedVulnerabilities_VersionCompatibility(t *testing.T) {
+	tests := []struct {
+		name          string
+		dbVersion     schemaver.SchemaVer
+		input         []*KnownExploitedVulnerabilityHandle
+		expectEnabled bool
+		expectError   require.ErrorAssertionFunc
+		expectedCount int
+	}{
+		{
+			name:      "supported db version",
+			dbVersion: schemaver.New(6, 0, 1),
+			input: []*KnownExploitedVulnerabilityHandle{
+				{
+					Cve: "CVE-2023-1234",
+					BlobValue: &KnownExploitedVulnerabilityBlob{
+						Cve:           "CVE-2023-1234",
+						VendorProject: "Test Vendor",
+						DateAdded:     timeRef(time.Now()),
+					},
+				},
+			},
+			expectEnabled: true,
+			expectError:   require.NoError,
+			expectedCount: 1,
+		},
+		{
+			name:      "unsupported db version",
+			dbVersion: schemaver.New(6, 0, 0),
+			input: []*KnownExploitedVulnerabilityHandle{
+				{
+					Cve: "CVE-2023-1234",
+					BlobValue: &KnownExploitedVulnerabilityBlob{
+						Cve: "CVE-2023-1234",
+					},
+				},
+			},
+			expectEnabled: false,
+			expectError:   require.Error,
+			expectedCount: 0,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.expectError == nil {
+				tt.expectError = require.NoError
+			}
+
+			db := setupTestStore(t).db
+			bs := newBlobStore(db)
+
+			s := newVulnerabilityDecoratorStore(db, bs, tt.dbVersion)
+			assert.Equal(t, tt.expectEnabled, s.kevEnabled)
+
+			err := s.AddKnownExploitedVulnerabilities(tt.input...)
+			tt.expectError(t, err)
+			if err != nil {
+				return
+			}
+
+			results, err := s.GetKnownExploitedVulnerabilities(tt.input[0].Cve)
+			require.NoError(t, err)
+			assert.Len(t, results, tt.expectedCount)
+		})
+	}
+}
+
+func TestVulnerabilityDecoratorStore_GetKnownExploitedVulnerabilities_VersionCompatibility(t *testing.T) {
+	tests := []struct {
+		name          string
+		dbVersion     schemaver.SchemaVer
+		input         []*KnownExploitedVulnerabilityHandle
+		expectEnabled bool
+		expectError   require.ErrorAssertionFunc
+		expectedCount int
+	}{
+		{
+			name:      "supported db version",
+			dbVersion: schemaver.New(6, 0, 1),
+			input: []*KnownExploitedVulnerabilityHandle{
+				{
+					Cve: "CVE-2023-1234",
+					BlobValue: &KnownExploitedVulnerabilityBlob{
+						Cve: "CVE-2023-1234",
+					},
+				},
+			},
+			expectEnabled: true,
+			expectError:   require.NoError,
+			expectedCount: 1,
+		},
+		{
+			name:      "unsupported db version",
+			dbVersion: schemaver.New(6, 0, 0),
+			input: []*KnownExploitedVulnerabilityHandle{
+				{
+					Cve: "CVE-2023-1234",
+					BlobValue: &KnownExploitedVulnerabilityBlob{
+						Cve: "CVE-2023-1234",
+					},
+				},
+			},
+			expectEnabled: false,
+			expectError:   require.NoError,
+			expectedCount: 0,
+		},
+		{
+			name:      "future db version",
+			dbVersion: schemaver.New(6, 1, 0),
+			input: []*KnownExploitedVulnerabilityHandle{
+				{
+					Cve: "CVE-2023-1234",
+					BlobValue: &KnownExploitedVulnerabilityBlob{
+						Cve: "CVE-2023-1234",
+					},
+				},
+			},
+			expectEnabled: true,
+			expectError:   require.NoError,
+			expectedCount: 1,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.expectError == nil {
+				tt.expectError = require.NoError
+			}
+
+			db := setupTestStore(t).db
+			bs := newBlobStore(db)
+
+			s := newVulnerabilityDecoratorStore(db, bs, tt.dbVersion)
+			assert.Equal(t, tt.expectEnabled, s.kevEnabled)
+
+			// this is just to get around not being able to write entries...
+			supportedStore := newVulnerabilityDecoratorStore(db, bs, schemaver.New(6, 0, 1))
+			err := supportedStore.AddKnownExploitedVulnerabilities(tt.input...)
+			require.NoError(t, err)
+
+			results, err := s.GetKnownExploitedVulnerabilities(tt.input[0].Cve)
+			tt.expectError(t, err)
+			assert.Len(t, results, tt.expectedCount)
+
+			if tt.expectedCount > 0 {
+				for _, result := range results {
+					assert.NotNil(t, result.BlobValue)
+					assert.Equal(t, tt.input[0].Cve, result.BlobValue.Cve)
+				}
+			}
+		})
+	}
+}
+
+func timeRef(t time.Time) *time.Time {
+	return &t
+}
diff --git a/grype/pkg/cpe_provider_test.go b/grype/pkg/cpe_provider_test.go
new file mode 100644
index 00000000..bbc5b0bd
--- /dev/null
+++ b/grype/pkg/cpe_provider_test.go
@@ -0,0 +1,169 @@
+package pkg
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/stretchr/testify/require"
+
+	"github.com/anchore/syft/syft/cpe"
+	"github.com/anchore/syft/syft/file"
+	"github.com/anchore/syft/syft/pkg"
+	"github.com/anchore/syft/syft/sbom"
+	"github.com/anchore/syft/syft/source"
+)
+
+func Test_CPEProvider(t *testing.T) {
+	tests := []struct {
+		name      string
+		userInput string
+		context   Context
+		pkgs      []Package
+		sbom      *sbom.SBOM
+		wantErr   require.ErrorAssertionFunc
+	}{
+		{
+			name:      "takes a single cpe",
+			userInput: "cpe:/a:apache:log4j:2.14.1",
+			context: Context{
+				Source: &source.Description{
+					Metadata: CPELiteralMetadata{
+						CPE: "cpe:/a:apache:log4j:2.14.1",
+					},
+				},
+			},
+			pkgs: []Package{
+				{
+					Name:    "log4j",
+					Version: "2.14.1",
+					CPEs: []cpe.CPE{
+						cpe.Must("cpe:/a:apache:log4j:2.14.1", ""),
+					},
+				},
+			},
+			sbom: &sbom.SBOM{
+				Artifacts: sbom.Artifacts{
+					Packages: pkg.NewCollection(pkg.Package{
+						Name:    "log4j",
+						Version: "2.14.1",
+						CPEs: []cpe.CPE{
+							cpe.Must("cpe:/a:apache:log4j:2.14.1", ""),
+						},
+					}),
+				},
+			},
+		},
+		{
+			name:      "takes cpe with no version",
+			userInput: "cpe:/a:apache:log4j",
+			context: Context{
+				Source: &source.Description{
+					Metadata: CPELiteralMetadata{
+						CPE: "cpe:/a:apache:log4j",
+					},
+				},
+			},
+			pkgs: []Package{
+				{
+					Name: "log4j",
+					CPEs: []cpe.CPE{
+						cpe.Must("cpe:/a:apache:log4j", ""),
+					},
+				},
+			},
+			sbom: &sbom.SBOM{
+				Artifacts: sbom.Artifacts{
+					Packages: pkg.NewCollection(pkg.Package{
+						Name: "log4j",
+						CPEs: []cpe.CPE{
+							cpe.Must("cpe:/a:apache:log4j", ""),
+						},
+					}),
+				},
+			},
+		},
+		{
+			name:      "takes CPE 2.3 format",
+			userInput: "cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*",
+			context: Context{
+				Source: &source.Description{
+					Metadata: CPELiteralMetadata{
+						CPE: "cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*",
+					},
+				},
+			},
+			pkgs: []Package{
+				{
+					Name:    "log4j",
+					Version: "2.14.1",
+					CPEs: []cpe.CPE{
+						cpe.Must("cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*", ""),
+					},
+				},
+			},
+			sbom: &sbom.SBOM{
+				Artifacts: sbom.Artifacts{
+					Packages: pkg.NewCollection(pkg.Package{
+						Name:    "log4j",
+						Version: "2.14.1",
+						CPEs: []cpe.CPE{
+							cpe.Must("cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*", ""),
+						},
+					}),
+				},
+			},
+		},
+
+		{
+			name:      "invalid prefix",
+			userInput: "dir:test-fixtures/cpe",
+			wantErr:   require.Error,
+		},
+	}
+
+	opts := []cmp.Option{
+		cmpopts.IgnoreFields(Package{}, "ID", "Locations", "Licenses", "Metadata", "Type", "Language"),
+	}
+
+	syftPkgOpts := []cmp.Option{
+		cmpopts.IgnoreFields(pkg.Package{}, "id", "Type", "Language"),
+		cmpopts.IgnoreUnexported(pkg.Package{}, file.LocationSet{}, pkg.LicenseSet{}),
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.wantErr == nil {
+				tc.wantErr = require.NoError
+			}
+
+			packages, ctx, gotSBOM, err := cpeProvider(tc.userInput)
+
+			tc.wantErr(t, err)
+			if err != nil {
+				require.Nil(t, packages)
+				return
+			}
+
+			if d := cmp.Diff(tc.context, ctx, opts...); d != "" {
+				t.Errorf("unexpected context (-want +got):\n%s", d)
+			}
+
+			require.Len(t, packages, len(tc.pkgs))
+			for idx, expected := range tc.pkgs {
+				if d := cmp.Diff(expected, packages[idx], opts...); d != "" {
+					t.Errorf("unexpected package (-want +got):\n%s", d)
+				}
+			}
+
+			gotSyftPkgs := gotSBOM.Artifacts.Packages.Sorted()
+			wantSyftPkgs := tc.sbom.Artifacts.Packages.Sorted()
+			require.Equal(t, len(gotSyftPkgs), len(wantSyftPkgs))
+			for idx, wantPkg := range wantSyftPkgs {
+				if d := cmp.Diff(wantPkg, gotSyftPkgs[idx], syftPkgOpts...); d != "" {
+					t.Errorf("unexpected Syft Package (-want +got):\n%s", d)
+				}
+			}
+		})
+	}
+}
diff --git a/grype/presenter/models/source_test.go b/grype/presenter/models/source_test.go
index 2e389345..325b772a 100644
--- a/grype/presenter/models/source_test.go
+++ b/grype/presenter/models/source_test.go
@@ -6,6 +6,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
+	"github.com/anchore/grype/grype/pkg"
 	syftSource "github.com/anchore/syft/syft/source"
 )
 
@@ -61,6 +62,52 @@ func TestNewSource(t *testing.T) {
 				Target: "/foo/bar/test.zip",
 			},
 		},
+		{
+			name: "purl-file",
+			metadata: syftSource.Description{
+				Metadata: pkg.PURLFileMetadata{
+					Path: "/path/to/purls.txt",
+				},
+			},
+			expected: source{
+				Type:   "purl-file",
+				Target: "/path/to/purls.txt",
+			},
+		},
+		{
+			name: "purl-literal",
+			metadata: syftSource.Description{
+				Metadata: pkg.PURLLiteralMetadata{
+					PURL: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
+				},
+			},
+			expected: source{
+				Type:   "purl",
+				Target: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
+			},
+		},
+		{
+			name: "cpe-literal",
+			metadata: syftSource.Description{
+				Metadata: pkg.CPELiteralMetadata{
+					CPE: "cpe:/a:apache:log4j:2.14.1",
+				},
+			},
+			expected: source{
+				Type:   "cpe",
+				Target: "cpe:/a:apache:log4j:2.14.1",
+			},
+		},
+		{
+			name: "nil metadata",
+			metadata: syftSource.Description{
+				Metadata: nil,
+			},
+			expected: source{
+				Type:   "unknown",
+				Target: "unknown",
+			},
+		},
 	}
 
 	for _, testCase := range testCases {
diff --git a/internal/schemaver/schema_ver_test.go b/internal/schemaver/schema_ver_test.go
index 2f37088a..04d5bf84 100644
--- a/internal/schemaver/schema_ver_test.go
+++ b/internal/schemaver/schema_ver_test.go
@@ -1,99 +1,11 @@
 package schemaver
 
 import (
-	"fmt"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
 )
 
-func TestSchemaVer_VersionComponents(t *testing.T) {
-	tests := []struct {
-		name             string
-		version          SchemaVer
-		expectedModel    int
-		expectedRevision int
-		expectedAddition int
-	}{
-		{
-			name:             "go case",
-			version:          "1.2.3",
-			expectedModel:    1,
-			expectedRevision: 2,
-			expectedAddition: 3,
-		},
-		{
-			name:             "model only",
-			version:          "1.0.0",
-			expectedModel:    1,
-			expectedRevision: 0,
-			expectedAddition: 0,
-		},
-		{
-			name:             "invalid model",
-			version:          "0.2.3",
-			expectedModel:    -1,
-			expectedRevision: 2,
-			expectedAddition: 3,
-		},
-		{
-			name:             "invalid version format",
-			version:          "invalid.version",
-			expectedModel:    -1,
-			expectedRevision: -1,
-			expectedAddition: -1,
-		},
-		{
-			name:             "zero version",
-			version:          "0.0.0",
-			expectedModel:    -1,
-			expectedRevision: 0,
-			expectedAddition: 0,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			type subject struct {
-				name string
-				exp  int
-				fn   func() (int, bool)
-			}
-
-			for _, sub := range []subject{
-				{
-					name: "model",
-					exp:  tt.expectedModel,
-					fn:   tt.version.ModelPart,
-				},
-				{
-					name: "revision",
-					exp:  tt.expectedRevision,
-					fn:   tt.version.RevisionPart,
-				},
-				{
-					name: "addition",
-					exp:  tt.expectedAddition,
-					fn:   tt.version.AdditionPart,
-				},
-			} {
-				t.Run(sub.name, func(t *testing.T) {
-					act, ok := sub.fn()
-
-					if sub.exp == -1 {
-						require.False(t, ok, fmt.Sprintf("Expected %s to be invalid", sub.name))
-						return
-					}
-					require.True(t, ok, fmt.Sprintf("Expected %s to be valid", sub.name))
-					assert.Equal(t, sub.exp, act, fmt.Sprintf("Expected %s to be %d, got %d", sub.name, sub.exp, act))
-				})
-			}
-
-		})
-	}
-}
-
 func TestSchemaVerComparisons(t *testing.T) {
 	tests := []struct {
 		name           string
@@ -164,3 +76,170 @@ func TestSchemaVerComparisons(t *testing.T) {
 		})
 	}
 }
+
+func TestParse(t *testing.T) {
+	tests := []struct {
+		name    string
+		input   string
+		want    SchemaVer
+		wantErr bool
+	}{
+		{
+			name:    "valid version",
+			input:   "1.2.3",
+			want:    New(1, 2, 3),
+			wantErr: false,
+		},
+		{
+			name:    "valid large numbers",
+			input:   "999.888.777",
+			want:    New(999, 888, 777),
+			wantErr: false,
+		},
+		{
+			name:    "valid with whitespace",
+			input:   "  1.2.3  ",
+			want:    New(1, 2, 3),
+			wantErr: false,
+		},
+		{
+			name:    "invalid version with zeros",
+			input:   "0.0.0",
+			want:    New(0, 0, 0),
+			wantErr: true,
+		},
+		{
+			name:    "invalid empty string",
+			input:   "",
+			wantErr: true,
+		},
+		{
+			name:    "invalid too few parts",
+			input:   "1.2",
+			wantErr: true,
+		},
+		{
+			name:    "invalid too many parts",
+			input:   "1.2.3.4",
+			wantErr: true,
+		},
+		{
+			name:    "invalid non-numeric model",
+			input:   "a.2.3",
+			wantErr: true,
+		},
+		{
+			name:    "invalid non-numeric revision",
+			input:   "1.b.3",
+			wantErr: true,
+		},
+		{
+			name:    "invalid non-numeric addition",
+			input:   "1.2.c",
+			wantErr: true,
+		},
+		{
+			name:    "invalid negative number",
+			input:   "-1.2.3",
+			wantErr: true,
+		},
+		{
+			name:    "invalid format with spaces",
+			input:   "1 . 2 . 3",
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := Parse(tt.input)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !tt.wantErr && (got.Model != tt.want.Model ||
+				got.Revision != tt.want.Revision ||
+				got.Addition != tt.want.Addition) {
+				t.Errorf("Parse() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestSchemaVer_Valid(t *testing.T) {
+	tests := []struct {
+		name     string
+		schema   SchemaVer
+		expected bool
+	}{
+		{
+			name: "valid schema version - all positive",
+			schema: SchemaVer{
+				Model:    1,
+				Revision: 1,
+				Addition: 1,
+			},
+			expected: true,
+		},
+		{
+			name: "valid schema version - zero revision and addition",
+			schema: SchemaVer{
+				Model:    1,
+				Revision: 0,
+				Addition: 0,
+			},
+			expected: true,
+		},
+		{
+			name: "invalid - zero model",
+			schema: SchemaVer{
+				Model:    0,
+				Revision: 1,
+				Addition: 1,
+			},
+			expected: false,
+		},
+		{
+			name: "invalid - negative model",
+			schema: SchemaVer{
+				Model:    -1,
+				Revision: 1,
+				Addition: 1,
+			},
+			expected: false,
+		},
+		{
+			name: "invalid - negative revision",
+			schema: SchemaVer{
+				Model:    1,
+				Revision: -1,
+				Addition: 1,
+			},
+			expected: false,
+		},
+		{
+			name: "invalid - negative addition",
+			schema: SchemaVer{
+				Model:    1,
+				Revision: 1,
+				Addition: -1,
+			},
+			expected: false,
+		},
+		{
+			name: "invalid - all negative",
+			schema: SchemaVer{
+				Model:    -1,
+				Revision: -1,
+				Addition: -1,
+			},
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.expected, tt.schema.Valid())
+		})
+	}
+}