Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions grype/vex/csaf/csaf.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import (
"slices"

"github.com/gocsaf/csaf/v3/csaf"

"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/version"
"github.com/anchore/packageurl-go"
)

// advisoryMatch captures the criteria that caused a vulnerability to match a CSAF advisory
Expand Down Expand Up @@ -127,3 +131,157 @@ func purlsFromProductIdentificationHelpers(helpers []*csaf.ProductIdentification
}
return purls
}

// synthesisCandidate describes a (vulnerability, package) pair that should be
// added to grype's results based on a CSAF advisory, when no DB-backed match
// already exists.
type synthesisCandidate struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like we now have a synthesisCandidate which is converted to an advisoryMatch which is converted to a match.Match... could we avoid the middlemen on these and just directly create Vulnerability, IgnoreRule/IgnoreFilter, and Match objects or similar? We could move the IgnoreFilter indexing to some shared location

Vulnerability *csaf.Vulnerability
Status status
ProductID csaf.ProductID
Package *pkg.Package
}

// findSynthesisCandidates walks every advisory and yields (vuln, package)
// pairs eligible for synthesis. Range semantics are applied per status:
// - last_affected: pkg.version <= stmt.version (ceiling)
// - first_affected: pkg.version >= stmt.version (floor)
// - known_affected, recommended, under_investigation: exact match
// (or wildcard if the statement purl has no version)
//
// Statuses that are not "affected-like" (fixed, known_not_affected) never
// trigger synthesis.
//
//nolint:gocognit
func (advisories advisories) findSynthesisCandidates(pkgs []pkg.Package) []synthesisCandidate {
var out []synthesisCandidate
if len(pkgs) == 0 {
return out
}

for _, adv := range advisories {
if adv == nil || adv.Vulnerabilities == nil {
continue
}

for _, vuln := range adv.Vulnerabilities {
if vuln == nil || vuln.CVE == nil {
continue
}

productsByStatus := map[status]*csaf.Products{
firstAffected: vuln.ProductStatus.FirstAffected,
knownAffected: vuln.ProductStatus.KnownAffected,
lastAffected: vuln.ProductStatus.LastAffected,
recommended: vuln.ProductStatus.Recommended,
underInvestigation: vuln.ProductStatus.UnderInvestigation,
}

for st, products := range productsByStatus {
if products == nil {
continue
}
for _, productIDPtr := range *products {
if productIDPtr == nil {
continue
}
productID := *productIDPtr
helpers := adv.ProductTree.CollectProductIdentificationHelpers(productID)
for _, stmtPURL := range purlsFromProductIdentificationHelpers(helpers) {
for i := range pkgs {
p := &pkgs[i]
Comment thread
xnox marked this conversation as resolved.
Outdated
if p.PURL == "" {
continue
}
if !packageMatchesStatement(stmtPURL, p, st) {
continue
}
out = append(out, synthesisCandidate{
Vulnerability: vuln,
Status: st,
ProductID: productID,
Package: p,
})
}
}
}
}
}
}

return out
}

// packageMatchesStatement reports whether the given package's purl falls
// within the scope of a VEX statement that names stmtPURL with the given
// CSAF status. Type/namespace/name/qualifiers must always match; the version
// dimension is interpreted according to the status.
func packageMatchesStatement(stmtPURL string, p *pkg.Package, st status) bool {
stmt, err := packageurl.FromString(stmtPURL)
if err != nil {
return false
}
pkgPURL, err := packageurl.FromString(p.PURL)
if err != nil {
return false
}

if stmt.Type != pkgPURL.Type || stmt.Namespace != pkgPURL.Namespace || stmt.Name != pkgPURL.Name {
return false
}
if !qualifierSubset(stmt.Qualifiers, pkgPURL.Qualifiers) {
return false
}

// No version in the statement -> wildcard, matches any pkg version.
if stmt.Version == "" {
return true
}
if pkgPURL.Version == "" {
// Statement is version-specific but the package's purl has none.
return false
}

format := pkg.VersionFormat(*p)

switch st {
case lastAffected:
return compareVersions(pkgPURL.Version, stmt.Version, format, version.LTE)
case firstAffected:
return compareVersions(pkgPURL.Version, stmt.Version, format, version.GTE)
default:
// knownAffected, recommended, underInvestigation: exact match.
return stmt.Version == pkgPURL.Version
}
}

func compareVersions(pkgVersion, stmtVersion string, format version.Format, op version.Operator) bool {
pkgV := version.New(pkgVersion, format)
stmtV := version.New(stmtVersion, format)
ok, err := pkgV.Is(op, stmtV)
if err != nil {
return false
}
return ok
}

func qualifierSubset(stmtQ, pkgQ packageurl.Qualifiers) bool {
pkgMap := pkgQ.Map()
for _, sq := range stmtQ {
if v, ok := pkgMap[sq.Key]; !ok || v != sq.Value {
return false
}
}
return true
}

// toAdvisoryMatch returns the advisoryMatch shape expected by the rest of the
// CSAF code (so a synthesis candidate plugs into matchingRule, statement(),
// etc.).
func (c synthesisCandidate) toAdvisoryMatch() *advisoryMatch {
return &advisoryMatch{
Vulnerability: c.Vulnerability,
Status: c.Status,
ProductID: c.ProductID,
}
}
86 changes: 84 additions & 2 deletions grype/vex/csaf/implementation.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/pkg"
vexStatus "github.com/anchore/grype/grype/vex/status"
"github.com/anchore/grype/grype/vulnerability"
)

// searchedBy captures the parameters used to search through the VEX data
Expand Down Expand Up @@ -121,9 +122,13 @@ func (*Processor) FilterMatches(

// AugmentMatches adds results to the match.Matches array when matching data
// about an affected VEX product is found on loaded VEX documents. Matches
// are moved from the ignore list back to active matches.
// are moved from the ignore list back to active matches, or synthesized from
// the package catalog when the vulnerability database has no record of the
// affected (vulnerability, package) pair. last_affected and first_affected
// statuses are interpreted as version range bounds; other affected-like
// statuses use exact version match.
func (*Processor) AugmentMatches(
docRaw any, ignoreRules []match.IgnoreRule, _ *pkg.Context, matches *match.Matches, ignoredMatches []match.IgnoredMatch,
docRaw any, ignoreRules []match.IgnoreRule, _ *pkg.Context, pkgs []pkg.Package, matches *match.Matches, ignoredMatches []match.IgnoredMatch,
) (*match.Matches, []match.IgnoredMatch, error) {
advisories, ok := docRaw.(advisories)
if !ok {
Expand Down Expand Up @@ -152,9 +157,86 @@ func (*Processor) AugmentMatches(
remainingIgnoredMatches = append(remainingIgnoredMatches, m)
}

synthesizeFromCatalog(advisories, ignoreRules, pkgs, matches, remainingIgnoredMatches)

return matches, remainingIgnoredMatches, nil
}

// synthesizeFromCatalog walks the package catalog and creates new matches for
// any (vulnerability, package) pair named as affected (or under_investigation)
// in the loaded CSAF advisories that is not already represented in the
// remaining or ignored match sets.
func synthesizeFromCatalog(
advs advisories,
ignoreRules []match.IgnoreRule,
pkgs []pkg.Package,
remainingMatches *match.Matches,
ignoredMatches []match.IgnoredMatch,
) {
candidates := advs.findSynthesisCandidates(pkgs)
if len(candidates) == 0 {
return
}

known := existingVulnPackageKeys(remainingMatches, ignoredMatches)

for _, c := range candidates {
advMatch := c.toAdvisoryMatch()
vulnID := advMatch.cve()
if vulnID == "" {
continue
}
key := vulnPackageKey(vulnID, c.Package.PURL)
if _, seen := known[key]; seen {
continue
}

synthesized := match.Match{
Vulnerability: vulnerability.Vulnerability{
Reference: vulnerability.Reference{
ID: vulnID,
Namespace: "vex",
},
},
Package: *c.Package,
}
if rule := matchingRule(ignoreRules, synthesized, advMatch, vexStatus.AugmentList()); rule == nil {
continue
}

synthesized.Details = []match.Detail{
{
Type: match.ExactDirectMatch,
SearchedBy: &searchedBy{
Vulnerability: vulnID,
Purl: c.Package.PURL,
},
Found: advMatch,
Matcher: match.CsafVexMatcher,
Confidence: 1,
},
}

remainingMatches.Add(synthesized)
known[key] = struct{}{}
}
}

func existingVulnPackageKeys(remainingMatches *match.Matches, ignoredMatches []match.IgnoredMatch) map[string]struct{} {
known := map[string]struct{}{}
for _, m := range remainingMatches.Sorted() {
known[vulnPackageKey(m.Vulnerability.ID, m.Package.PURL)] = struct{}{}
}
for _, m := range ignoredMatches {
known[vulnPackageKey(m.Vulnerability.ID, m.Package.PURL)] = struct{}{}
}
return known
}

func vulnPackageKey(vulnID, purl string) string {
return vulnID + "\x00" + purl
}

// matchingRule cycles through a set of ignore rules and returns the first
// one that matches the statement and the match. Returns nil if none match.
func matchingRule(ignoreRules []match.IgnoreRule, m match.Match, advMatch *advisoryMatch, allowedStatuses []vexStatus.Status) *match.IgnoreRule {
Expand Down
Loading