Skip to content

Commit 4c1c41b

Browse files
feat: add config for handling missing epochs on package versions (#2976)
Add config to RPM and DPKG matcher for handling of missing package epochs. Both RPMs and DPKGs (debs) can have an epoch in the version number, but this epoch is often omitted from user facing tools. Add the capacity to configure grype's matching behavior when the epoch is missing to two options: - "zero": assume that the missing epoch is "0" - "auto": assume that the missing epoch matches the vulnerability in question So for example if given package version 2.0.0 and no epoch, and vuln with constraint < 1:1.5.0 (epoch 1) the configs result in: - "zero": 2.0.0 becomes "0:2.0.0" which is less than 1:1.5.0 - "auto": drops the epoch on both sides, so 2.0.0 > 1.5.0 When not specified, these config values default to the behavior grype had previously, which is "zero" in the DPKG matcher and "auto" in the RPM matcher. This difference in defaults should be revisited before 1.0. --------- Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
1 parent edaf953 commit 4c1c41b

File tree

22 files changed

+1362
-87
lines changed

22 files changed

+1362
-87
lines changed

cmd/grype/cli/commands/root.go

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ import (
1919
"github.com/anchore/grype/grype/match"
2020
"github.com/anchore/grype/grype/matcher"
2121
"github.com/anchore/grype/grype/matcher/dotnet"
22+
"github.com/anchore/grype/grype/matcher/dpkg"
2223
"github.com/anchore/grype/grype/matcher/golang"
2324
"github.com/anchore/grype/grype/matcher/java"
2425
"github.com/anchore/grype/grype/matcher/javascript"
2526
"github.com/anchore/grype/grype/matcher/python"
27+
"github.com/anchore/grype/grype/matcher/rpm"
2628
"github.com/anchore/grype/grype/matcher/ruby"
2729
"github.com/anchore/grype/grype/matcher/stock"
2830
"github.com/anchore/grype/grype/pkg"
@@ -323,25 +325,33 @@ func checkForAppUpdate(id clio.Identification, opts *options.Grype) {
323325
}
324326
}
325327

326-
func getMatchers(opts *options.Grype) []match.Matcher {
327-
return matcher.NewDefaultMatchers(
328-
matcher.Config{
329-
Java: java.MatcherConfig{
330-
ExternalSearchConfig: opts.ExternalSources.ToJavaMatcherConfig(),
331-
UseCPEs: opts.Match.Java.UseCPEs,
332-
},
333-
Ruby: ruby.MatcherConfig(opts.Match.Ruby),
334-
Python: python.MatcherConfig(opts.Match.Python),
335-
Dotnet: dotnet.MatcherConfig(opts.Match.Dotnet),
336-
Javascript: javascript.MatcherConfig(opts.Match.Javascript),
337-
Golang: golang.MatcherConfig{
338-
UseCPEs: opts.Match.Golang.UseCPEs,
339-
AlwaysUseCPEForStdlib: opts.Match.Golang.AlwaysUseCPEForStdlib,
340-
AllowMainModulePseudoVersionComparison: opts.Match.Golang.AllowMainModulePseudoVersionComparison,
341-
},
342-
Stock: stock.MatcherConfig(opts.Match.Stock),
328+
func getMatcherConfig(opts *options.Grype) matcher.Config {
329+
return matcher.Config{
330+
Java: java.MatcherConfig{
331+
ExternalSearchConfig: opts.ExternalSources.ToJavaMatcherConfig(),
332+
UseCPEs: opts.Match.Java.UseCPEs,
343333
},
344-
)
334+
Ruby: ruby.MatcherConfig(opts.Match.Ruby),
335+
Python: python.MatcherConfig(opts.Match.Python),
336+
Dotnet: dotnet.MatcherConfig(opts.Match.Dotnet),
337+
Javascript: javascript.MatcherConfig(opts.Match.Javascript),
338+
Golang: golang.MatcherConfig{
339+
UseCPEs: opts.Match.Golang.UseCPEs,
340+
AlwaysUseCPEForStdlib: opts.Match.Golang.AlwaysUseCPEForStdlib,
341+
AllowMainModulePseudoVersionComparison: opts.Match.Golang.AllowMainModulePseudoVersionComparison,
342+
},
343+
Stock: stock.MatcherConfig(opts.Match.Stock),
344+
Rpm: rpm.MatcherConfig{
345+
MissingEpochStrategy: opts.Match.Rpm.MissingEpochStrategy,
346+
},
347+
Dpkg: dpkg.MatcherConfig{
348+
MissingEpochStrategy: opts.Match.Dpkg.MissingEpochStrategy,
349+
},
350+
}
351+
}
352+
353+
func getMatchers(opts *options.Grype) []match.Matcher {
354+
return matcher.NewDefaultMatchers(getMatcherConfig(opts))
345355
}
346356

347357
func getProviderConfig(opts *options.Grype) pkg.ProviderConfig {

cmd/grype/cli/commands/root_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ import (
1212
"github.com/anchore/grype/cmd/grype/cli/options"
1313
"github.com/anchore/grype/grype/distro"
1414
"github.com/anchore/grype/grype/match"
15+
"github.com/anchore/grype/grype/matcher"
16+
"github.com/anchore/grype/grype/matcher/dotnet"
17+
"github.com/anchore/grype/grype/matcher/dpkg"
18+
"github.com/anchore/grype/grype/matcher/golang"
19+
"github.com/anchore/grype/grype/matcher/java"
20+
"github.com/anchore/grype/grype/matcher/javascript"
21+
"github.com/anchore/grype/grype/matcher/python"
22+
"github.com/anchore/grype/grype/matcher/rpm"
23+
"github.com/anchore/grype/grype/matcher/ruby"
24+
"github.com/anchore/grype/grype/matcher/stock"
1525
"github.com/anchore/grype/grype/pkg"
1626
"github.com/anchore/grype/grype/version"
1727
vexStatus "github.com/anchore/grype/grype/vex/status"
@@ -74,6 +84,123 @@ func Test_getProviderConfig(t *testing.T) {
7484
}
7585
}
7686

87+
func Test_getMatcherConfig(t *testing.T) {
88+
tests := []struct {
89+
name string
90+
opts *options.Grype
91+
want matcher.Config
92+
}{
93+
{
94+
name: "default options",
95+
opts: options.DefaultGrype(clio.Identification{
96+
Name: "test",
97+
Version: "1.0",
98+
}),
99+
want: matcher.Config{
100+
Java: java.MatcherConfig{
101+
ExternalSearchConfig: java.ExternalSearchConfig{
102+
SearchMavenUpstream: false,
103+
MavenBaseURL: "https://search.maven.org/solrsearch/select",
104+
MavenRateLimit: 300000000, // 300ms in nanoseconds
105+
},
106+
UseCPEs: false,
107+
},
108+
Ruby: ruby.MatcherConfig{},
109+
Python: python.MatcherConfig{},
110+
Dotnet: dotnet.MatcherConfig{},
111+
Javascript: javascript.MatcherConfig{},
112+
Golang: golang.MatcherConfig{
113+
UseCPEs: false,
114+
AlwaysUseCPEForStdlib: true,
115+
AllowMainModulePseudoVersionComparison: false,
116+
},
117+
Stock: stock.MatcherConfig{UseCPEs: true},
118+
Rpm: rpm.MatcherConfig{
119+
MissingEpochStrategy: "auto",
120+
},
121+
Dpkg: dpkg.MatcherConfig{
122+
MissingEpochStrategy: "zero",
123+
},
124+
},
125+
},
126+
{
127+
name: "rpm missing-epoch-strategy set to zero",
128+
opts: func() *options.Grype {
129+
opts := options.DefaultGrype(clio.Identification{Name: "test", Version: "1.0"})
130+
opts.Match.Rpm.MissingEpochStrategy = "zero"
131+
return opts
132+
}(),
133+
want: matcher.Config{
134+
Java: java.MatcherConfig{
135+
ExternalSearchConfig: java.ExternalSearchConfig{
136+
SearchMavenUpstream: false,
137+
MavenBaseURL: "https://search.maven.org/solrsearch/select",
138+
MavenRateLimit: 300000000,
139+
},
140+
UseCPEs: false,
141+
},
142+
Ruby: ruby.MatcherConfig{},
143+
Python: python.MatcherConfig{},
144+
Dotnet: dotnet.MatcherConfig{},
145+
Javascript: javascript.MatcherConfig{},
146+
Golang: golang.MatcherConfig{
147+
UseCPEs: false,
148+
AlwaysUseCPEForStdlib: true,
149+
AllowMainModulePseudoVersionComparison: false,
150+
},
151+
Stock: stock.MatcherConfig{UseCPEs: true},
152+
Rpm: rpm.MatcherConfig{
153+
MissingEpochStrategy: "zero",
154+
},
155+
Dpkg: dpkg.MatcherConfig{
156+
MissingEpochStrategy: "zero",
157+
},
158+
},
159+
},
160+
{
161+
name: "dpkg missing-epoch-strategy set to auto",
162+
opts: func() *options.Grype {
163+
opts := options.DefaultGrype(clio.Identification{Name: "test", Version: "1.0"})
164+
opts.Match.Dpkg.MissingEpochStrategy = "auto"
165+
return opts
166+
}(),
167+
want: matcher.Config{
168+
Java: java.MatcherConfig{
169+
ExternalSearchConfig: java.ExternalSearchConfig{
170+
SearchMavenUpstream: false,
171+
MavenBaseURL: "https://search.maven.org/solrsearch/select",
172+
MavenRateLimit: 300000000,
173+
},
174+
UseCPEs: false,
175+
},
176+
Ruby: ruby.MatcherConfig{},
177+
Python: python.MatcherConfig{},
178+
Dotnet: dotnet.MatcherConfig{},
179+
Javascript: javascript.MatcherConfig{},
180+
Golang: golang.MatcherConfig{
181+
UseCPEs: false,
182+
AlwaysUseCPEForStdlib: true,
183+
AllowMainModulePseudoVersionComparison: false,
184+
},
185+
Stock: stock.MatcherConfig{UseCPEs: true},
186+
Rpm: rpm.MatcherConfig{
187+
MissingEpochStrategy: "auto",
188+
},
189+
Dpkg: dpkg.MatcherConfig{
190+
MissingEpochStrategy: "auto",
191+
},
192+
},
193+
},
194+
}
195+
for _, tt := range tests {
196+
t.Run(tt.name, func(t *testing.T) {
197+
if d := cmp.Diff(tt.want, getMatcherConfig(tt.opts)); d != "" {
198+
t.Errorf("getMatcherConfig() mismatch (-want +got):\n%s", d)
199+
}
200+
})
201+
}
202+
}
203+
77204
func Test_applyVexRules(t *testing.T) {
78205
tests := []struct {
79206
name string

cmd/grype/cli/options/match.go

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package options
22

3-
import "github.com/anchore/clio"
3+
import (
4+
"fmt"
5+
6+
"github.com/anchore/clio"
7+
"github.com/anchore/grype/grype/version"
8+
)
49

510
// matchConfig contains all matching-related configuration options available to the user via the application config.
611
type matchConfig struct {
@@ -13,10 +18,13 @@ type matchConfig struct {
1318
Ruby matcherConfig `yaml:"ruby" json:"ruby" mapstructure:"ruby"` // settings for the ruby matcher
1419
Rust matcherConfig `yaml:"rust" json:"rust" mapstructure:"rust"` // settings for the rust matcher
1520
Stock matcherConfig `yaml:"stock" json:"stock" mapstructure:"stock"` // settings for the default/stock matcher
21+
Rpm rpmConfig `yaml:"rpm" json:"rpm" mapstructure:"rpm"` // settings for the rpm matcher
22+
Dpkg dpkgConfig `yaml:"dpkg" json:"dpkg" mapstructure:"dpkg"` // settings for the dpkg matcher
1623
}
1724

1825
var _ interface {
1926
clio.FieldDescriber
27+
clio.PostLoader
2028
} = (*matchConfig)(nil)
2129

2230
type matcherConfig struct {
@@ -29,6 +37,54 @@ type golangConfig struct {
2937
AllowMainModulePseudoVersionComparison bool `yaml:"allow-main-module-pseudo-version-comparison" json:"allow-main-module-pseudo-version-comparison" mapstructure:"allow-main-module-pseudo-version-comparison"` // if pseudo versions should be compared
3038
}
3139

40+
// rpmConfig contains configuration for the RPM matcher.
41+
type rpmConfig struct {
42+
matcherConfig `yaml:",inline" mapstructure:",squash"`
43+
// MissingEpochStrategy controls how missing epochs in package versions are handled
44+
// during vulnerability matching.
45+
//
46+
// Valid values:
47+
// - "zero" (default): Treat missing epochs as 0
48+
// - "auto": Assume missing epoch matches the constraint's epoch
49+
//
50+
// The "zero" strategy follows RPM specification guidance and maintains backward
51+
// compatibility with existing Grype behavior. The "auto" strategy reduces false
52+
// positives by recognizing that distros rarely track multiple epochs of the same
53+
// package in the same release.
54+
//
55+
// Example:
56+
// Package version: 2.0.0 (no epoch)
57+
// Constraint: < 1:1.5.0 (epoch 1)
58+
//
59+
// With "zero": Treat package as 0:2.0.0 → MATCH (0 < 1)
60+
// With "auto": Treat package as 1:2.0.0 → NO MATCH (2.0.0 > 1.5.0)
61+
MissingEpochStrategy version.MissingEpochStrategy `yaml:"missing-epoch-strategy" json:"missing-epoch-strategy" mapstructure:"missing-epoch-strategy"`
62+
}
63+
64+
// dpkgConfig contains configuration for the dpkg matcher.
65+
type dpkgConfig struct {
66+
matcherConfig `yaml:",inline" mapstructure:",squash"`
67+
// MissingEpochStrategy controls how missing epochs in package versions are handled
68+
// during vulnerability matching.
69+
//
70+
// Valid values:
71+
// - "zero" (default): Treat missing epochs as 0
72+
// - "auto": Assume missing epoch matches the constraint's epoch
73+
//
74+
// The "zero" strategy follows dpkg specification guidance and maintains backward
75+
// compatibility with existing Grype behavior. The "auto" strategy reduces false
76+
// positives by recognizing that distros rarely track multiple epochs of the same
77+
// package in the same release.
78+
//
79+
// Example:
80+
// Package version: 2.0.0 (no epoch)
81+
// Constraint: < 1:1.5.0 (epoch 1)
82+
//
83+
// With "zero": Treat package as 0:2.0.0 → MATCH (0 < 1)
84+
// With "auto": Treat package as 1:2.0.0 → NO MATCH (2.0.0 > 1.5.0)
85+
MissingEpochStrategy version.MissingEpochStrategy `yaml:"missing-epoch-strategy" json:"missing-epoch-strategy" mapstructure:"missing-epoch-strategy"`
86+
}
87+
3288
func defaultGolangConfig() golangConfig {
3389
return golangConfig{
3490
matcherConfig: matcherConfig{
@@ -39,6 +95,20 @@ func defaultGolangConfig() golangConfig {
3995
}
4096
}
4197

98+
func defaultRpmConfig() rpmConfig {
99+
return rpmConfig{
100+
matcherConfig: matcherConfig{UseCPEs: false},
101+
MissingEpochStrategy: version.MissingEpochStrategyAuto,
102+
}
103+
}
104+
105+
func defaultDpkgConfig() dpkgConfig {
106+
return dpkgConfig{
107+
matcherConfig: matcherConfig{UseCPEs: false},
108+
MissingEpochStrategy: version.MissingEpochStrategyZero,
109+
}
110+
}
111+
42112
func defaultMatchConfig() matchConfig {
43113
useCpe := matcherConfig{UseCPEs: true}
44114
dontUseCpe := matcherConfig{UseCPEs: false}
@@ -52,7 +122,37 @@ func defaultMatchConfig() matchConfig {
52122
Ruby: dontUseCpe,
53123
Rust: dontUseCpe,
54124
Stock: useCpe,
125+
Rpm: defaultRpmConfig(),
126+
Dpkg: defaultDpkgConfig(),
127+
}
128+
}
129+
130+
func (cfg *matchConfig) PostLoad() error {
131+
if err := cfg.Rpm.PostLoad(); err != nil {
132+
return err
133+
}
134+
if err := cfg.Dpkg.PostLoad(); err != nil {
135+
return err
136+
}
137+
return nil
138+
}
139+
140+
// PostLoad validates the RPM configuration.
141+
func (cfg *rpmConfig) PostLoad() error {
142+
if cfg.MissingEpochStrategy != version.MissingEpochStrategyZero && cfg.MissingEpochStrategy != version.MissingEpochStrategyAuto {
143+
return fmt.Errorf("invalid rpm.missing-epoch-strategy: %q (allowable: %s, %s)",
144+
cfg.MissingEpochStrategy, version.MissingEpochStrategyZero, version.MissingEpochStrategyAuto)
145+
}
146+
return nil
147+
}
148+
149+
// PostLoad validates the dpkg configuration.
150+
func (cfg *dpkgConfig) PostLoad() error {
151+
if cfg.MissingEpochStrategy != version.MissingEpochStrategyZero && cfg.MissingEpochStrategy != version.MissingEpochStrategyAuto {
152+
return fmt.Errorf("invalid dpkg.missing-epoch-strategy: %q (allowable: %s, %s)",
153+
cfg.MissingEpochStrategy, version.MissingEpochStrategyZero, version.MissingEpochStrategyAuto)
55154
}
155+
return nil
56156
}
57157

58158
func (cfg *matchConfig) DescribeFields(descriptions clio.FieldDescriptionSet) {
@@ -67,4 +167,8 @@ func (cfg *matchConfig) DescribeFields(descriptions clio.FieldDescriptionSet) {
67167
descriptions.Add(&cfg.Ruby.UseCPEs, usingCpeDescription)
68168
descriptions.Add(&cfg.Rust.UseCPEs, usingCpeDescription)
69169
descriptions.Add(&cfg.Stock.UseCPEs, usingCpeDescription)
170+
descriptions.Add(&cfg.Rpm.MissingEpochStrategy,
171+
`strategy for handling missing epochs in RPM package versions during matching (options: zero, auto)`)
172+
descriptions.Add(&cfg.Dpkg.MissingEpochStrategy,
173+
`strategy for handling missing epochs in dpkg package versions during matching (options: zero, auto)`)
70174
}

0 commit comments

Comments
 (0)