Skip to content

Commit 4dd09a7

Browse files
feat: populate package architecture for matching (#3504)
* feat: populate package architecture for matching Enable matchers to filter on architecture-specific vulnerabilities or fixes. There are basically three changes here: First, wire up the package collection creator to populate architecture on the packages when an image is scanned or an sbom is loaded in Grype. Second, wire up the architecture package qualifier so that if a vulnerability or fix specifies that it is only applicable to a particular architecture, the matchers will respect this, and third, in the RPM matcher, specify `arch=src` when synthesizing the upstream package to search by (rather than letting it inherit the binary package's architecture). The qualifier logic is like this: If either the package or the DB record has a blank architecture, the filtering is a noop. If the package has a specific architecture and the DB record has 'src', the package is qualified, because a disclosure or fix in a src RPM for example is assumed to affect things built from it. If the DB record and the package both have specific binary architectures, but they don't match, e.g. x86_64 != aarch64, the package is not qualified and filters out. Architecture qualifiers are canonicalized at match time so, for example, an RPM that has x86_64 architecture is a qualified package on a vuln that affects "amd64" processors (assuming distro and everything else line up). This canonicalization is probably more important for future expansion since a given distro will probably use the same architecture strings as their own package manager. Hummingbird also has a special "binary-no-arch-specified" architecture that means, "this is not a src RPM". That is so that in Hummingbird data, if we have a package that says, 'upstream=glibc' in its PURL, and we have a glibc vuln against the binary but not the src RPM, we can fitler out this false positive. If a DB record has 'binary-no-arch-specified' as its architecture, any package that has any non-src architecture (or a blank architecture) is qualified, but src architecture is not. Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com> * Make architecture aliases data driven Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com> * handle noarch / all arch Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com> * Wire up end-to-end architecture specific matching for OL OracleLinux had some existing false positives where the x86_64 package was fixed in an earlier version of the RPM than the aarch64 version, resulting in false positives when Grype checked against the aarch64 version. Add a dbtest for this scenario and update the RPM matcher and OS transformer to fix this case. Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com> * move setting src arch on upstream to helper Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com> * exhaustive tests for package arch qualifier Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com> --------- Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
1 parent ddf6482 commit 4dd09a7

41 files changed

Lines changed: 1044 additions & 185 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

grype/db/internal/provider/unmarshal/os_vulnerability.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ type OSFixedIn struct {
2121
} `json:"AdvisorySummary"`
2222
NoAdvisory bool `json:"NoAdvisory"`
2323
} `json:"VendorAdvisory"`
24-
Version string `json:"Version"`
25-
VersionFormat string `json:"VersionFormat"`
26-
VulnerableRange string `json:"VulnerableRange"`
24+
Version string `json:"Version"`
25+
VersionFormat string `json:"VersionFormat"`
26+
// Arch is set only when an advisory ships a different fix per architecture (e.g. an x86_64 and
27+
// aarch64 build respun at different revisions); nil/absent means the fix applies to all arches.
28+
Arch *string `json:"Arch,omitempty"`
29+
VulnerableRange string `json:"VulnerableRange"`
2730
Available struct {
2831
Date string `json:"Date,omitempty"`
2932
Kind string `json:"Kind,omitempty"`
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package v6
2+
3+
import (
4+
"fmt"
5+
6+
"gorm.io/gorm"
7+
)
8+
9+
type ArchitectureAliasStoreReader interface {
10+
// GetArchitectureAliases returns the architecture alias table (alias spelling -> canonical
11+
// token), used by the architecture qualifier to fold dialect spellings at match time.
12+
GetArchitectureAliases() (map[string]string, error)
13+
}
14+
15+
type architectureAliasStore struct {
16+
db *gorm.DB
17+
}
18+
19+
func newArchitectureAliasStore(db *gorm.DB) *architectureAliasStore {
20+
return &architectureAliasStore{db: db}
21+
}
22+
23+
// GetArchitectureAliases returns the architecture alias table as a map of alias spelling to
24+
// canonical token. A database built before this table existed has no such table; that is not
25+
// an error — an empty map is returned, which the architecture qualifier reads as "fall back to
26+
// the built-in default aliases".
27+
func (s *architectureAliasStore) GetArchitectureAliases() (map[string]string, error) {
28+
if !s.db.Migrator().HasTable(&ArchitectureAlias{}) {
29+
return map[string]string{}, nil
30+
}
31+
32+
var rows []ArchitectureAlias
33+
if err := s.db.Find(&rows).Error; err != nil {
34+
return nil, fmt.Errorf("unable to read architecture aliases: %w", err)
35+
}
36+
37+
out := make(map[string]string, len(rows))
38+
for _, r := range rows {
39+
out[r.Alias] = r.Canonical
40+
}
41+
return out, nil
42+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package v6
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/anchore/grype/grype/pkg/qualifier/architecture"
9+
)
10+
11+
func TestArchitectureAliasStore_GetArchitectureAliases_SeededDefaults(t *testing.T) {
12+
// setupTestStore opens an empty+writable DB, which seeds InitialData (including the
13+
// architecture aliases). The read-back must equal the build-time default table.
14+
s := setupTestStore(t)
15+
16+
got, err := s.GetArchitectureAliases()
17+
require.NoError(t, err)
18+
require.Equal(t, architecture.DefaultAliases(), got)
19+
}
20+
21+
func TestArchitectureAliasStore_GetArchitectureAliases_MissingTableIsEmptyNotError(t *testing.T) {
22+
// a database built before the architecture_aliases table existed has no such table; that
23+
// must read as an empty map (the qualifier's signal to fall back to built-in defaults),
24+
// never an error.
25+
s := setupTestStore(t)
26+
require.NoError(t, s.db.Migrator().DropTable(&ArchitectureAlias{}))
27+
28+
got, err := s.GetArchitectureAliases()
29+
require.NoError(t, err)
30+
require.Empty(t, got)
31+
}

grype/db/v6/blobs.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,11 @@ type PackageQualifiers struct {
150150
// Architecture is the architecture of the affected RPM, copied from the source PURL's
151151
// `arch` qualifier when present (e.g. "src", "x86_64") or set to a synthesized sentinel
152152
// like "binary-no-arch-specified" when a binary RPM was disclosed without an explicit
153-
// arch. Upstream-indirected RPM matches drop entries tagged with any arch other than
154-
// "src", so providers that disclose at binary granularity (e.g. hummingbird CSAF VEX)
155-
// avoid FP-matching unrelated sibling binaries built from the same source. See
156-
// pkg/qualifier/architecture for the constants.
153+
// arch. At match time the architecture qualifier compares this value against the scanned
154+
// package's arch (see pkg/qualifier/architecture, Satisfied): a "binary-no-arch-specified"
155+
// entry matches any binary package but rejects the rpm matcher's synthesized "src" upstream,
156+
// so providers that disclose at binary granularity (e.g. hummingbird CSAF VEX) avoid
157+
// FP-matching unrelated sibling binaries built from the same source.
157158
Architecture *string `json:"architecture,omitempty"`
158159

159160
// RootIO indicates that the vulnerability applies only to Root IO packages (packages with Root IO fixes).

grype/db/v6/build/transformers/os/transform.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ func getPackages(vuln unmarshal.OSVulnerability) ([]db.AffectedPackageHandle, []
110110
qualifiers = &db.PackageQualifiers{
111111
RpmModularity: &module,
112112
}
113+
// when the advisory scoped this fix to a specific architecture, carry it so the
114+
// architecture qualifier only applies the fix to packages of that arch (see
115+
// pkg/qualifier/architecture). Absent arch means the fix applies to all arches.
116+
if group.arch != "" {
117+
arch := group.arch
118+
qualifiers.Architecture = &arch
119+
}
113120
}
114121

115122
aph := db.AffectedPackageHandle{
@@ -251,6 +258,7 @@ type groupIndex struct {
251258
hasModule bool
252259
module string
253260
format string
261+
arch string
254262
}
255263

256264
func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OSFixedIn {
@@ -262,6 +270,10 @@ func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OS
262270
if fixedIn.Module != nil {
263271
mod = *fixedIn.Module
264272
}
273+
var arch string
274+
if fixedIn.Arch != nil {
275+
arch = *fixedIn.Arch
276+
}
265277
g := groupIndex{
266278
name: fixedIn.Name,
267279
id: oi.id,
@@ -271,6 +283,9 @@ func groupFixedIns(vuln unmarshal.OSVulnerability) map[groupIndex][]unmarshal.OS
271283
hasModule: fixedIn.Module != nil,
272284
module: mod,
273285
format: fixedIn.VersionFormat,
286+
// arch splits a per-arch fix into its own affected package handle so the architecture
287+
// qualifier can scope it; empty means the fix applies to all arches.
288+
arch: arch,
274289
}
275290

276291
grouped[g] = append(grouped[g], fixedIn)

grype/db/v6/build/transformers/os/transform_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1825,3 +1825,44 @@ func timeRef(ti time.Time) *time.Time {
18251825
func strRef(s string) *string {
18261826
return &s
18271827
}
1828+
1829+
// Test_getPackages_perArchFix pins that an Oracle-style advisory with a different fix per
1830+
// architecture (the ELSA-2022-4803 false-positive shape) yields one affected package handle per
1831+
// arch, each carrying its own Architecture qualifier and fix version, while an arch-less FixedIn
1832+
// stays arch-agnostic. Without per-arch handles, the higher aarch64 revision would over-match a
1833+
// patched x86_64 package.
1834+
func Test_getPackages_perArchFix(t *testing.T) {
1835+
vuln := unmarshal.OSVulnerability{}
1836+
vuln.Vulnerability.Name = "ELSA-2022-4803"
1837+
vuln.Vulnerability.NamespaceName = "ol:7"
1838+
vuln.Vulnerability.FixedIn = []unmarshal.OSFixedIn{
1839+
{Name: "rsyslog", NamespaceName: "ol:7", Version: "0:8.24.0-57.0.1.el7_9.3", VersionFormat: "rpm", Arch: strRef("x86_64")},
1840+
{Name: "rsyslog", NamespaceName: "ol:7", Version: "0:8.24.0-57.0.4.el7_9.3", VersionFormat: "rpm", Arch: strRef("aarch64")},
1841+
{Name: "zlib", NamespaceName: "ol:7", Version: "0:1.2.7-21.el7", VersionFormat: "rpm"}, // arch-less: applies to all
1842+
}
1843+
1844+
affected, unaffected := getPackages(vuln)
1845+
require.Empty(t, unaffected)
1846+
1847+
type got struct {
1848+
name string
1849+
arch string // "" when no architecture qualifier
1850+
fixVers string
1851+
}
1852+
var results []got
1853+
for _, aph := range affected {
1854+
g := got{name: aph.Package.Name}
1855+
if aph.BlobValue.Qualifiers != nil && aph.BlobValue.Qualifiers.Architecture != nil {
1856+
g.arch = *aph.BlobValue.Qualifiers.Architecture
1857+
}
1858+
require.Len(t, aph.BlobValue.Ranges, 1)
1859+
g.fixVers = aph.BlobValue.Ranges[0].Fix.Version
1860+
results = append(results, g)
1861+
}
1862+
1863+
assert.ElementsMatch(t, []got{
1864+
{name: "rsyslog", arch: "x86_64", fixVers: "0:8.24.0-57.0.1.el7_9.3"},
1865+
{name: "rsyslog", arch: "aarch64", fixVers: "0:8.24.0-57.0.4.el7_9.3"},
1866+
{name: "zlib", arch: "", fixVers: "0:1.2.7-21.el7"},
1867+
}, results)
1868+
}

grype/db/v6/data.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package v6
22

33
import (
4+
"sort"
45
"strings"
56

7+
"github.com/anchore/grype/grype/pkg/qualifier/architecture"
68
"github.com/anchore/syft/syft/pkg"
79
)
810

@@ -162,6 +164,25 @@ func KnownPackageSpecifierOverrides() []PackageSpecifierOverride {
162164
return ret
163165
}
164166

167+
// KnownArchitectureAliases is the default arch alias table seeded into the DB at build time.
168+
// It is sourced from architecture.DefaultAliases so the build-time seed and the match-time
169+
// fallback (used by clients whose DB predates this table) can never drift apart.
170+
func KnownArchitectureAliases() []ArchitectureAlias {
171+
defaults := architecture.DefaultAliases()
172+
173+
aliases := make([]string, 0, len(defaults))
174+
for alias := range defaults {
175+
aliases = append(aliases, alias)
176+
}
177+
sort.Strings(aliases) // deterministic seed order
178+
179+
ret := make([]ArchitectureAlias, 0, len(defaults))
180+
for _, alias := range aliases {
181+
ret = append(ret, ArchitectureAlias{Alias: alias, Canonical: defaults[alias]})
182+
}
183+
return ret
184+
}
185+
165186
func ptr[T any](v T) *T {
166187
return &v
167188
}

grype/db/v6/db.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const (
2424
Revision = 1
2525

2626
// Addition indicates how many changes have been introduced that are compatible with all historical data
27-
Addition = 7
27+
Addition = 8
2828

2929
// v6 model changelog:
3030
// 6.0.0: Initial version 🎉
@@ -48,6 +48,10 @@ const (
4848
// architecture). The field's semantics are unchanged; the rename drops the rpm-
4949
// specific prefix because the value already lives in PackageQualifiers and can
5050
// carry any architecture string for future arch-scoped advisories.
51+
// 6.1.8: Add ArchitectureAlias table (architecture_aliases). The architecture qualifier
52+
// reads it at match time to fold dialect arch spellings (e.g. "x86_64" <-> "amd64")
53+
// onto a canonical token. Older clients ignore the table; clients reading a DB built
54+
// before it existed fall back to the built-in default aliases.
5155
)
5256

5357
const (
@@ -77,6 +81,7 @@ type Reader interface {
7781
UnaffectedPackageStoreReader
7882
AffectedCPEStoreReader
7983
UnaffectedCPEStoreReader
84+
ArchitectureAliasStoreReader
8085
io.Closer
8186
attachBlobValue(...blobable) error
8287
}

grype/db/v6/models.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func Models() []any {
5252
&OperatingSystemSpecifierOverride{},
5353
&Package{},
5454
&PackageSpecifierOverride{},
55+
&ArchitectureAlias{},
5556

5657
// CPE related search tables
5758
&AffectedCPEHandle{}, // join on CPE
@@ -533,6 +534,22 @@ type PackageSpecifierOverride struct {
533534
ReplacementEcosystem *string `gorm:"column:replacement_ecosystem;primaryKey"`
534535
}
535536

537+
// ArchitectureAlias folds an architecture spelling onto a canonical token shared by every
538+
// dialect spelling of the same CPU architecture (e.g. rpm "x86_64" and deb/OCI "amd64" both
539+
// fold to "amd64"). The architecture qualifier reads this table at match time so cross-dialect
540+
// arch comparisons don't drop real matches; the data lives in the DB rather than the grype
541+
// binary so the folding can change without a release. See pkg/qualifier/architecture.
542+
type ArchitectureAlias struct {
543+
// Alias is an architecture spelling as it may appear on a package or vulnerability entry
544+
// (e.g. "x86_64"). Only non-canonical spellings are stored; a canonical token resolves to
545+
// itself by absence.
546+
Alias string `gorm:"column:alias;primaryKey"`
547+
548+
// Canonical is the token Alias folds to (e.g. "amd64"). The choice of token is arbitrary;
549+
// only equivalence across spellings matters.
550+
Canonical string `gorm:"column:canonical;not null"`
551+
}
552+
536553
// OperatingSystem represents a specific release of an operating system. The resolution of the version is
537554
// relative to the available data by the vulnerability data provider, so though there may be major.minor.patch OS
538555
// releases, there may only be data available for major.minor.

grype/db/v6/store.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type store struct {
1919
*affectedCPEStore
2020
*unaffectedCPEStore
2121
*vulnerabilityDecoratorStore
22+
*architectureAliasStore
2223
blobStore *blobStore
2324
db *gorm.DB
2425
config Config
@@ -45,6 +46,11 @@ func InitialData() []any {
4546
for i := range p {
4647
data = append(data, &p[i])
4748
}
49+
50+
a := KnownArchitectureAliases()
51+
for i := range a {
52+
data = append(data, &a[i])
53+
}
4854
return data
4955
}
5056

@@ -96,6 +102,7 @@ func newStore(cfg Config, empty, writable bool) (*store, error) {
96102
affectedCPEStore: newAffectedCPEStore(db, bs),
97103
vulnerabilityDecoratorStore: newVulnerabilityDecoratorStore(db, bs, dbVersion),
98104
unaffectedCPEStore: newUnaffectedCPEStore(db, bs),
105+
architectureAliasStore: newArchitectureAliasStore(db),
99106
blobStore: bs,
100107
db: db,
101108
config: cfg,

0 commit comments

Comments
 (0)