Skip to content

Commit

Permalink
vex: account for package module when parsing VEX
Browse files Browse the repository at this point in the history
Previously, we'd made the decision to ignore the package module and rely
on the repo and package for matching but now that the VEX data has the
package module data and it helps filter the results on the DB side, it
seems best to add the package module information and keep the matcher
constraints as it is.

Signed-off-by: crozzy <[email protected]>
  • Loading branch information
crozzy committed Sep 20, 2024
1 parent f96bfb4 commit 3e77536
Show file tree
Hide file tree
Showing 4 changed files with 205 additions and 35 deletions.
124 changes: 96 additions & 28 deletions rhel/vex/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,31 +183,49 @@ type creator struct {
rc *repoCache
}

// WalkRelationships attempts to resolve a relationship until we have a package product_id and
// a repo product_id. Relationships can be nested. If the pkgID and the repoID are the same we
// either found no relationship or a relationship where both ends are pointing to the same
// product_id, either way we don't have enough data to create a vulnerability.
func walkRelationships(productID string, doc *csaf.CSAF) (string, string, error) {
pkgID, repoID := extractProductNames(productID, productID, doc)
if pkgID == repoID {
return "", "", fmt.Errorf("could not extract a distict pkgID and repoID from %q", productID)
// WalkRelationships attempts to resolve a relationship until we have a package product_id,
// a repo product_id and possibly a package module product_id. Relationships can be nested.
// If we don't get an initial relationship or we don't get two component parts we cannot
// create a vulnerability. We never see more than 3 components in the wild but if we did
// we'd assume the component next to the repo product_id is the package module product_id.
func walkRelationships(productID string, doc *csaf.CSAF) (string, string, string, error) {
prodRel := doc.FindRelationship(productID, "default_component_of")
if prodRel == nil {
return "", "", "", fmt.Errorf("cannot determine initial relationship for %q", productID)
}
comps := extractProductNames(prodRel.ProductRef, prodRel.RelatesToProductRef, []string{}, doc)
switch {
case len(comps) == 2:
// We have a package and repo
return comps[0], "", comps[1], nil
case len(comps) > 2:
// We have a package, module and repo
return comps[0], comps[len(comps)-2], comps[len(comps)-1], nil
default:
return "", "", "", fmt.Errorf("cannot determine relationships for %q", productID)
}
return pkgID, repoID, nil
}

// ExtractProductNames recursively looks up the package product_id and the repo product_id.
// The assumtion is that the repo is always the last found relates_to_product_reference and the
// package is the last found product_reference.
func extractProductNames(prodRelID string, repoRelID string, c *csaf.CSAF) (string, string) {
prodRel := c.FindRelationship(prodRelID, "default_component_of")
// ExtractProductNames recursively looks up product_id relationships and adds them to a
// component slice in order. prodRef (and it's potential children) are leftmost in the return
// slice and relatesToProdRef (and it's potential children) are rightmost.
// For example: prodRef=a_pkg and relatesToProdRef=a_repo:a_module and a Relationship where
// Relationship.ProductRef=a_module and Relationship.RelatesToProductRef=a_repo the return
// slice would be: ["a_pkg", "a_module", "a_repo"].
func extractProductNames(prodRef, relatesToProdRef string, comps []string, c *csaf.CSAF) []string {
prodRel := c.FindRelationship(prodRef, "default_component_of")
if prodRel != nil {
prodRelID, _ = extractProductNames(prodRel.ProductRef, prodRel.RelatesToProductRef, c)
comps = extractProductNames(prodRel.ProductRef, prodRel.RelatesToProductRef, comps, c)
} else {
comps = append(comps, prodRef)
}
repoRel := c.FindRelationship(repoRelID, "default_component_of")
repoRel := c.FindRelationship(relatesToProdRef, "default_component_of")
if repoRel != nil {
_, repoRelID = extractProductNames(repoRel.ProductRef, repoRel.RelatesToProductRef, c)
comps = extractProductNames(repoRel.ProductRef, repoRel.RelatesToProductRef, comps, c)
} else {
comps = append(comps, relatesToProdRef)
}
return prodRelID, repoRelID
return comps
}

// KnownAffectedVulnerabilities processes the "known_affected" array of products
Expand All @@ -217,7 +235,7 @@ func (c *creator) knownAffectedVulnerabilities(ctx context.Context, v csaf.Vulne
debugEnabled := zlog.Debug(ctx).Enabled()
out := []*claircore.Vulnerability{}
for _, pc := range v.ProductStatus["known_affected"] {
pkgName, repoName, err := walkRelationships(pc, c.c)
pkgName, modName, repoName, err := walkRelationships(pc, c.c)
if err != nil {
// It's possible to get here due to middleware not having a defined component:package
// relationship.
Expand Down Expand Up @@ -247,6 +265,18 @@ func (c *creator) knownAffectedVulnerabilities(ctx context.Context, v csaf.Vulne
continue
}

// Deal with modules if we found one.
if modName != "" {
modProd := c.pc.Get(modName, c.c)
modName, err = createPackageModule(modProd)
if err != nil {
zlog.Warn(ctx).
Str("module", modName).
Err(err).
Msg("could not create package module")
}
}

// pkgName will be overridden if we find a valid pURL
compProd := c.pc.Get(pkgName, c.c)
if compProd == nil {
Expand Down Expand Up @@ -281,8 +311,9 @@ func (c *creator) knownAffectedVulnerabilities(ctx context.Context, v csaf.Vulne
// What is the deal here? Just stick the package name in and f-it?
// That's the plan so far as there's no PURL product ID helper.
vuln.Package = &claircore.Package{
Name: pkgName,
Kind: claircore.SOURCE,
Name: pkgName,
Kind: claircore.SOURCE,
Module: modName,
}
ch := escapeCPE(cpeHelper)
wfn, err := cpe.Unbind(ch)
Expand Down Expand Up @@ -337,7 +368,7 @@ func (c *creator) fixedVulnerabilities(ctx context.Context, v csaf.Vulnerability
unrelatedProductIDs := []string{}
debugEnabled := zlog.Debug(ctx).Enabled()
for _, pc := range v.ProductStatus["fixed"] {
pkgName, repoName, err := walkRelationships(pc, c.c)
pkgName, modName, repoName, err := walkRelationships(pc, c.c)
if err != nil {
// It's possible to get here due to middleware not having a defined component:package
// relationship.
Expand All @@ -362,6 +393,19 @@ func (c *creator) fixedVulnerabilities(ctx context.Context, v csaf.Vulnerability
Msg("could not find cpe helper type in product")
continue
}

// Deal with modules if we found one.
if modName != "" {
modProd := c.pc.Get(modName, c.c)
modName, err = createPackageModule(modProd)
if err != nil {
zlog.Warn(ctx).
Str("module", modName).
Err(err).
Msg("could not create package module")
}
}

compProd := c.pc.Get(pkgName, c.c)
if compProd == nil {
// Should never get here, error in data
Expand Down Expand Up @@ -396,16 +440,17 @@ func (c *creator) fixedVulnerabilities(ctx context.Context, v csaf.Vulnerability
}

fixedIn := epochVersion(&purl)
vulnKey := createPackageKey(repoName, purl.Name, fixedIn)
vulnKey := createPackageKey(repoName, modName, purl.Name, fixedIn)
arch := purl.Qualifiers.Map()["arch"]
if vuln, ok := c.lookupVulnerability(vulnKey, protoVulnFunc); ok && arch != "" {
// We've already found this package, just append the arch
vuln.Package.Arch = vuln.Package.Arch + "|" + arch
} else {
vuln.FixedInVersion = fixedIn
vuln.Package = &claircore.Package{
Name: purl.Name,
Kind: claircore.BINARY,
Name: purl.Name,
Kind: claircore.BINARY,
Module: modName,
}

if arch != "" {
Expand Down Expand Up @@ -501,11 +546,34 @@ func cvssVectorFromScore(sc *csaf.Score) (vec string, err error) {

// CreatePackageKey creates a unique key to describe an arch agnostic
// package for deduplication purposes.
// i.e. AppStream-8.2.0.Z.TUS:python3-idle-0:3.6.8-24.el8_2.2
func createPackageKey(repo, name, fixedIn string) string {
// i.e. AppStream-8.2.0.Z.TUS:a_module:python3-idle-0:3.6.8-24.el8_2.2
func createPackageKey(repo, mod, name, fixedIn string) string {
// The other option here is just to use repo + PURL string
// w/o the qualifiers I suppose instead of repo + NEVR.
return repo + ":" + name + "-" + fixedIn
return repo + ":" + mod + ":" + name + "-" + fixedIn
}

func createPackageModule(p *csaf.Product) (string, error) {
var modName string
modPURLHelper, ok := p.IdentificationHelper["purl"]
if !ok {
return modName, nil
}
purl, err := packageurl.FromString(modPURLHelper)
if purl.Type != "rpmmod" {
return "", fmt.Errorf("invalid RPM module PURL: %q", purl.String())
}
switch {
case err != nil:
return "", err
default:
parts := strings.SplitN(purl.Version, ":", 2)
if len(parts) > 0 {
modName = purl.Name + ":" + parts[0]
}
fmt.Println(parts)
}
return modName, nil
}

func epochVersion(purl *packageurl.PackageURL) string {
Expand Down
113 changes: 107 additions & 6 deletions rhel/vex/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,78 @@ import (
"github.com/quay/claircore/toolkit/types/csaf"
)

func TestCreatePackageModule(t *testing.T) {
testcases := []struct {
name string
in *csaf.Product
expectedModule string
err bool
}{
{
name: "simple",
in: &csaf.Product{
IdentificationHelper: map[string]string{
"purl": "pkg:rpmmod/redhat/postgresql@13:8060020240903094008:ad008a3a",
},
},
expectedModule: "postgresql:13",
},
{
name: "with minor",
in: &csaf.Product{
IdentificationHelper: map[string]string{
"purl": "pkg:rpmmod/redhat/[email protected]:8060020240903094008:ad008a3a",
},
},
expectedModule: "postgresql:9.2",
},
{
name: "no colon",
in: &csaf.Product{
IdentificationHelper: map[string]string{
"purl": "pkg:rpmmod/redhat/postgresql@9",
},
},
expectedModule: "postgresql:9",
},
{
name: "invalid purl",
in: &csaf.Product{
IdentificationHelper: map[string]string{
"purl": "invalid",
},
},
err: true,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
modName, err := createPackageModule(tc.in)
if err != nil && !tc.err {
t.Errorf("expected no error but got %q", err)
}
if modName != tc.expectedModule {
t.Errorf("expected %s but got %s", tc.expectedModule, modName)
}
})
}
}

func TestWalkRelationships(t *testing.T) {
testcases := []struct {
name string
in string
c *csaf.CSAF
expectedPkgName, expectedRepoName string
err bool
name string
in string
c *csaf.CSAF
expectedPkgName, expectedModName, expectedRepoName string
err bool
}{
{
c: &csaf.CSAF{
ProductTree: csaf.ProductBranch{},
},
in: "EAP 7.4 log4j async",
expectedPkgName: "",
expectedModName: "",
expectedRepoName: "",
name: "no_relationship",
err: true,
Expand All @@ -52,6 +110,7 @@ func TestWalkRelationships(t *testing.T) {
},
in: "ResilientStorage-9.4.0.Z.MAIN.EUS:fence-agents-common-0:4.10.0-62.el9_4.3.noarch",
expectedPkgName: "fence-agents-common-0:4.10.0-62.el9_4.3.noarch",
expectedModName: "",
expectedRepoName: "ResilientStorage-9.4.0.Z.MAIN.EUS",
name: "simple_relationship",
},
Expand Down Expand Up @@ -82,6 +141,7 @@ func TestWalkRelationships(t *testing.T) {
},
in: "AppStream-8.10.0.Z.MAIN.EUS:httpd:2.4:8100020240612075645:489197e6:httpd-0:2.4.37-65.module+el8.10.0+21982+14717793.aarch64",
expectedPkgName: "httpd-0:2.4.37-65.module+el8.10.0+21982+14717793.aarch64",
expectedModName: "httpd:2.4:8100020240612075645:489197e6",
expectedRepoName: "AppStream-8.10.0.Z.MAIN.EUS",
name: "two_level_relationship",
},
Expand Down Expand Up @@ -121,19 +181,54 @@ func TestWalkRelationships(t *testing.T) {
},
in: "CROS:J-MOD:J-COMP:JMC",
expectedPkgName: "JMC",
expectedModName: "J-MOD",
expectedRepoName: "CROS",
name: "two_times_two_level_relationship",
},
{
c: &csaf.CSAF{
ProductTree: csaf.ProductBranch{
Relationships: csaf.Relationships{
csaf.Relationship{
Category: "default_component_of",
FullProductName: csaf.Product{
Name: "perl:5.32:8100020240314121426:9fe1d287 as a component of Red Hat Enterprise Linux AppStream (v. 8)",
ID: "AppStream-8.10.0.GA:perl:5.32:8100020240314121426:9fe1d287",
},
ProductRef: "perl:5.32:8100020240314121426:9fe1d287",
RelatesToProductRef: "AppStream-8.10.0.GA",
},
csaf.Relationship{
Category: "default_component_of",
FullProductName: csaf.Product{
Name: "perl-Carp-0:1.50-439.module+el8.10.0+21354+3ad137bb.noarch as a component of perl:5.32:8100020240314121426:9fe1d287 as a component of Red Hat Enterprise Linux AppStream (v. 8)",
ID: "AppStream-8.10.0.GA:perl:5.32:8100020240314121426:9fe1d287:perl-Carp-0:1.50-439.module+el8.10.0+21354+3ad137bb.noarch",
},
ProductRef: "perl-Carp-0:1.50-439.module+el8.10.0+21354+3ad137bb.noarch",
RelatesToProductRef: "AppStream-8.10.0.GA:perl:5.32:8100020240314121426:9fe1d287",
},
},
},
},
in: "AppStream-8.10.0.GA:perl:5.32:8100020240314121426:9fe1d287:perl-Carp-0:1.50-439.module+el8.10.0+21354+3ad137bb.noarch",
expectedPkgName: "perl-Carp-0:1.50-439.module+el8.10.0+21354+3ad137bb.noarch",
expectedModName: "perl:5.32:8100020240314121426:9fe1d287",
expectedRepoName: "AppStream-8.10.0.GA",
name: "perl_module_relationships",
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
pkgName, repoName, err := walkRelationships(tc.in, tc.c)
pkgName, modName, repoName, err := walkRelationships(tc.in, tc.c)
if err != nil && !tc.err {
t.Errorf("expected no error but got %q", err)
}
if pkgName != tc.expectedPkgName {
t.Errorf("expected %s but got %s", tc.expectedPkgName, pkgName)
}
if modName != tc.expectedModName {
t.Errorf("expected %s but got %s", tc.expectedModName, modName)
}
if repoName != tc.expectedRepoName {
t.Errorf("expected %s but got %s", tc.expectedRepoName, repoName)
}
Expand Down Expand Up @@ -217,6 +312,12 @@ func TestParse(t *testing.T) {
expectedVulns: 40,
expectedDeleted: 0,
},
{
name: "cve-2024-7348",
filename: "testdata/cve-2024-7348.jsonl",
expectedVulns: 910,
expectedDeleted: 0,
},
}

u := &Updater{url: url, client: http.DefaultClient}
Expand Down
1 change: 1 addition & 0 deletions rhel/vex/testdata/cve-2024-7348.jsonl

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion rhel/vex/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const (
deletionsFile = "deletions.csv"
lookBackToYear = 2014
repoKey = "rhel-cpe-repository"
updaterVersion = "1"
updaterVersion = "2"
)

// Factory creates an Updater to process all of the Red Hat VEX data.
Expand Down

0 comments on commit 3e77536

Please sign in to comment.