Skip to content

Commit 6d41136

Browse files
xnoxclaude
andcommitted
feat(vex): synthesize matches from SBOM for affected VEX statements
Previously, AugmentMatches could only promote a vulnerability that grype's DB had already found and that another rule had filtered into the ignored list. When the DB had no record of a (vulnerability, package) pair, an "affected" VEX statement naming that package was silently ignored, even though the statement is the strongest possible claim that the package is vulnerable. This left a visible gap versus tools like govulncheck, which report findings the grype DB simply does not carry. This change lets VEX `affected` / `under_investigation` statements synthesize a finding directly from the SBOM: * The vexProcessorImplementation interface and ApplyVEX now receive the package catalog, plumbed through findVEXMatches in the vulnerability matcher. * OpenVEX: after the existing ignored-match loop, walk the catalog and add a match for each statement that names a package by purl. Version matching is exact (or wildcard when the statement omits a version), matching the OpenVEX spec — no implicit ranges. * CSAF: same synthesis loop, but with status-aware version semantics that follow the CSAF spec: - last_affected → pkg.version <= stmt.version (ceiling) - first_affected → pkg.version >= stmt.version (floor) - known_affected / recommended / under_investigation → exact - fixed / known_not_affected → never synthesize Comparisons use grype/version with pkg.VersionFormat, so they are ecosystem-aware (semver, deb, rpm, apk, go-module, etc.). Statement qualifiers must be a subset of the package's qualifiers; type, namespace and name must match exactly. Dedup: synthesis keys on (vulnerability ID, package purl) and skips any pair already present in the remaining or ignored match sets, so the new path never duplicates a DB-backed finding. Behavior is gated by the existing VEX configuration: users still need `vex-add: [affected, under_investigation]` plus a matching ignore rule for the synthesized matches to surface, so default scans are unchanged. Tests: * grype/vex/openvex/implementation_test.go covers exact-match synthesis, status filtering, purl mismatch, empty catalog, ignore-rule vulnerability filtering, and dedup against existing matches. * grype/vex/csaf/implementation_test.go adds TestPackageMatchesStatement (16 cases for ceiling/floor/exact/wildcard + identity mismatches), TestAugmentMatches_SynthesizesFromPackageCatalog (9 cases per status against lower/equal/higher SBOM versions), and a dedup test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4f57d03 commit 6d41136

9 files changed

Lines changed: 746 additions & 14 deletions

File tree

grype/vex/csaf/csaf.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import (
44
"slices"
55

66
"github.com/gocsaf/csaf/v3/csaf"
7+
8+
"github.com/anchore/grype/grype/pkg"
9+
"github.com/anchore/grype/grype/version"
10+
"github.com/anchore/packageurl-go"
711
)
812

913
// advisoryMatch captures the criteria that caused a vulnerability to match a CSAF advisory
@@ -127,3 +131,157 @@ func purlsFromProductIdentificationHelpers(helpers []*csaf.ProductIdentification
127131
}
128132
return purls
129133
}
134+
135+
// synthesisCandidate describes a (vulnerability, package) pair that should be
136+
// added to grype's results based on a CSAF advisory, when no DB-backed match
137+
// already exists.
138+
type synthesisCandidate struct {
139+
Vulnerability *csaf.Vulnerability
140+
Status status
141+
ProductID csaf.ProductID
142+
Package *pkg.Package
143+
}
144+
145+
// findSynthesisCandidates walks every advisory and yields (vuln, package)
146+
// pairs eligible for synthesis. Range semantics are applied per status:
147+
// - last_affected: pkg.version <= stmt.version (ceiling)
148+
// - first_affected: pkg.version >= stmt.version (floor)
149+
// - known_affected, recommended, under_investigation: exact match
150+
// (or wildcard if the statement purl has no version)
151+
//
152+
// Statuses that are not "affected-like" (fixed, known_not_affected) never
153+
// trigger synthesis.
154+
//
155+
//nolint:gocognit
156+
func (advisories advisories) findSynthesisCandidates(pkgs []pkg.Package) []synthesisCandidate {
157+
var out []synthesisCandidate
158+
if len(pkgs) == 0 {
159+
return out
160+
}
161+
162+
for _, adv := range advisories {
163+
if adv == nil || adv.Vulnerabilities == nil {
164+
continue
165+
}
166+
167+
for _, vuln := range adv.Vulnerabilities {
168+
if vuln == nil || vuln.CVE == nil {
169+
continue
170+
}
171+
172+
productsByStatus := map[status]*csaf.Products{
173+
firstAffected: vuln.ProductStatus.FirstAffected,
174+
knownAffected: vuln.ProductStatus.KnownAffected,
175+
lastAffected: vuln.ProductStatus.LastAffected,
176+
recommended: vuln.ProductStatus.Recommended,
177+
underInvestigation: vuln.ProductStatus.UnderInvestigation,
178+
}
179+
180+
for st, products := range productsByStatus {
181+
if products == nil {
182+
continue
183+
}
184+
for _, productIDPtr := range *products {
185+
if productIDPtr == nil {
186+
continue
187+
}
188+
productID := *productIDPtr
189+
helpers := adv.ProductTree.CollectProductIdentificationHelpers(productID)
190+
for _, stmtPURL := range purlsFromProductIdentificationHelpers(helpers) {
191+
for i := range pkgs {
192+
p := &pkgs[i]
193+
if p.PURL == "" {
194+
continue
195+
}
196+
if !packageMatchesStatement(stmtPURL, p, st) {
197+
continue
198+
}
199+
out = append(out, synthesisCandidate{
200+
Vulnerability: vuln,
201+
Status: st,
202+
ProductID: productID,
203+
Package: p,
204+
})
205+
}
206+
}
207+
}
208+
}
209+
}
210+
}
211+
212+
return out
213+
}
214+
215+
// packageMatchesStatement reports whether the given package's purl falls
216+
// within the scope of a VEX statement that names stmtPURL with the given
217+
// CSAF status. Type/namespace/name/qualifiers must always match; the version
218+
// dimension is interpreted according to the status.
219+
func packageMatchesStatement(stmtPURL string, p *pkg.Package, st status) bool {
220+
stmt, err := packageurl.FromString(stmtPURL)
221+
if err != nil {
222+
return false
223+
}
224+
pkgPURL, err := packageurl.FromString(p.PURL)
225+
if err != nil {
226+
return false
227+
}
228+
229+
if stmt.Type != pkgPURL.Type || stmt.Namespace != pkgPURL.Namespace || stmt.Name != pkgPURL.Name {
230+
return false
231+
}
232+
if !qualifierSubset(stmt.Qualifiers, pkgPURL.Qualifiers) {
233+
return false
234+
}
235+
236+
// No version in the statement -> wildcard, matches any pkg version.
237+
if stmt.Version == "" {
238+
return true
239+
}
240+
if pkgPURL.Version == "" {
241+
// Statement is version-specific but the package's purl has none.
242+
return false
243+
}
244+
245+
format := pkg.VersionFormat(*p)
246+
247+
switch st {
248+
case lastAffected:
249+
return compareVersions(pkgPURL.Version, stmt.Version, format, version.LTE)
250+
case firstAffected:
251+
return compareVersions(pkgPURL.Version, stmt.Version, format, version.GTE)
252+
default:
253+
// knownAffected, recommended, underInvestigation: exact match.
254+
return stmt.Version == pkgPURL.Version
255+
}
256+
}
257+
258+
func compareVersions(pkgVersion, stmtVersion string, format version.Format, op version.Operator) bool {
259+
pkgV := version.New(pkgVersion, format)
260+
stmtV := version.New(stmtVersion, format)
261+
ok, err := pkgV.Is(op, stmtV)
262+
if err != nil {
263+
return false
264+
}
265+
return ok
266+
}
267+
268+
func qualifierSubset(stmtQ, pkgQ packageurl.Qualifiers) bool {
269+
pkgMap := pkgQ.Map()
270+
for _, sq := range stmtQ {
271+
if v, ok := pkgMap[sq.Key]; !ok || v != sq.Value {
272+
return false
273+
}
274+
}
275+
return true
276+
}
277+
278+
// toAdvisoryMatch returns the advisoryMatch shape expected by the rest of the
279+
// CSAF code (so a synthesis candidate plugs into matchingRule, statement(),
280+
// etc.).
281+
func (c synthesisCandidate) toAdvisoryMatch() *advisoryMatch {
282+
return &advisoryMatch{
283+
Vulnerability: c.Vulnerability,
284+
Status: c.Status,
285+
ProductID: c.ProductID,
286+
}
287+
}

grype/vex/csaf/implementation.go

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/anchore/grype/grype/match"
1212
"github.com/anchore/grype/grype/pkg"
1313
vexStatus "github.com/anchore/grype/grype/vex/status"
14+
"github.com/anchore/grype/grype/vulnerability"
1415
)
1516

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

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

160+
synthesizeFromCatalog(advisories, ignoreRules, pkgs, matches, remainingIgnoredMatches)
161+
155162
return matches, remainingIgnoredMatches, nil
156163
}
157164

165+
// synthesizeFromCatalog walks the package catalog and creates new matches for
166+
// any (vulnerability, package) pair named as affected (or under_investigation)
167+
// in the loaded CSAF advisories that is not already represented in the
168+
// remaining or ignored match sets.
169+
func synthesizeFromCatalog(
170+
advs advisories,
171+
ignoreRules []match.IgnoreRule,
172+
pkgs []pkg.Package,
173+
remainingMatches *match.Matches,
174+
ignoredMatches []match.IgnoredMatch,
175+
) {
176+
candidates := advs.findSynthesisCandidates(pkgs)
177+
if len(candidates) == 0 {
178+
return
179+
}
180+
181+
known := existingVulnPackageKeys(remainingMatches, ignoredMatches)
182+
183+
for _, c := range candidates {
184+
advMatch := c.toAdvisoryMatch()
185+
vulnID := advMatch.cve()
186+
if vulnID == "" {
187+
continue
188+
}
189+
key := vulnPackageKey(vulnID, c.Package.PURL)
190+
if _, seen := known[key]; seen {
191+
continue
192+
}
193+
194+
synthesized := match.Match{
195+
Vulnerability: vulnerability.Vulnerability{
196+
Reference: vulnerability.Reference{
197+
ID: vulnID,
198+
Namespace: "vex",
199+
},
200+
},
201+
Package: *c.Package,
202+
}
203+
if rule := matchingRule(ignoreRules, synthesized, advMatch, vexStatus.AugmentList()); rule == nil {
204+
continue
205+
}
206+
207+
synthesized.Details = []match.Detail{
208+
{
209+
Type: match.ExactDirectMatch,
210+
SearchedBy: &searchedBy{
211+
Vulnerability: vulnID,
212+
Purl: c.Package.PURL,
213+
},
214+
Found: advMatch,
215+
Matcher: match.CsafVexMatcher,
216+
Confidence: 1,
217+
},
218+
}
219+
220+
remainingMatches.Add(synthesized)
221+
known[key] = struct{}{}
222+
}
223+
}
224+
225+
func existingVulnPackageKeys(remainingMatches *match.Matches, ignoredMatches []match.IgnoredMatch) map[string]struct{} {
226+
known := map[string]struct{}{}
227+
for _, m := range remainingMatches.Sorted() {
228+
known[vulnPackageKey(m.Vulnerability.ID, m.Package.PURL)] = struct{}{}
229+
}
230+
for _, m := range ignoredMatches {
231+
known[vulnPackageKey(m.Vulnerability.ID, m.Package.PURL)] = struct{}{}
232+
}
233+
return known
234+
}
235+
236+
func vulnPackageKey(vulnID, purl string) string {
237+
return vulnID + "\x00" + purl
238+
}
239+
158240
// matchingRule cycles through a set of ignore rules and returns the first
159241
// one that matches the statement and the match. Returns nil if none match.
160242
func matchingRule(ignoreRules []match.IgnoreRule, m match.Match, advMatch *advisoryMatch, allowedStatuses []vexStatus.Status) *match.IgnoreRule {

0 commit comments

Comments
 (0)