Skip to content

Commit 55771f6

Browse files
committed
Add support for expiry date in ignore
Signed-off-by: lauren_tb <lauren.taylor-brown@justice.gov.uk>
1 parent fd26c40 commit 55771f6

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

cmd/grype/cli/options/grype.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ default is unset which will skip this validation (options: negligible, low, medi
200200
This is the full set of supported rule fields:
201201
- vulnerability: CVE-2008-4318
202202
fix-state: unknown
203+
expires-after: "2027-01-01"
203204
package:
204205
name: libcurl
205206
version: 1.5.1

grype/match/ignore.go

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

33
import (
4+
"fmt"
45
"regexp"
56
"slices"
7+
"time"
68

79
"github.com/bmatcuk/doublestar/v2"
810

@@ -12,6 +14,37 @@ import (
1214
"github.com/anchore/syft/syft/artifact"
1315
)
1416

17+
// expiresAfterDateFmt is the date layout accepted for IgnoreRule.ExpiresAfter (YYYY-MM-DD).
18+
const expiresAfterDateFmt = "2006-01-02"
19+
20+
// parseExpiresAfter parses a YYYY-MM-DD string into a UTC time.Time.
21+
// An empty string returns the zero value with no error.
22+
func parseExpiresAfter(s string) (time.Time, error) {
23+
if s == "" {
24+
return time.Time{}, nil
25+
}
26+
t, err := time.ParseInLocation(expiresAfterDateFmt, s, time.UTC)
27+
if err != nil {
28+
return time.Time{}, fmt.Errorf("invalid expires-after %q (expected YYYY-MM-DD): %w", s, err)
29+
}
30+
return t, nil
31+
}
32+
33+
// isExpiresAfterInPast reports whether the given expires-after string represents a date that has already passed.
34+
// An empty string is never considered expired. Malformed values are treated as not expired (a warning is logged).
35+
func isExpiresAfterInPast(s string) bool {
36+
if s == "" {
37+
return false
38+
}
39+
t, err := parseExpiresAfter(s)
40+
if err != nil {
41+
log.WithFields("expires-after", s, "error", err).Warn("ignoring malformed expires-after on ignore rule")
42+
return false
43+
}
44+
// the rule remains active for the entire calendar day it expires on
45+
return time.Now().UTC().After(t.Add(24 * time.Hour))
46+
}
47+
1548
// IgnoreFilter implementations are used to filter matches, returning all applicable IgnoreRule(s) that applied,
1649
// these could include an IgnoreRule with only a Reason value filled in for synthetically generated rules
1750
type IgnoreFilter interface {
@@ -40,6 +73,7 @@ type IgnoreRule struct {
4073
VexStatus string `yaml:"vex-status" json:"vex-status" mapstructure:"vex-status"`
4174
VexJustification string `yaml:"vex-justification" json:"vex-justification" mapstructure:"vex-justification"`
4275
MatchType Type `yaml:"match-type" json:"match-type" mapstructure:"match-type"`
76+
ExpiresAfter string `yaml:"expires-after,omitempty" json:"expires-after,omitempty" mapstructure:"expires-after"`
4377
}
4478

4579
// IgnoreRulePackage describes the Package-specific fields that comprise the IgnoreRule.
@@ -138,12 +172,26 @@ func ApplyIgnoreFilters[T IgnoreFilter](matches []Match, filters ...T) ([]Match,
138172
return out, ignoredMatches
139173
}
140174

175+
// Validate returns an error if the rule contains malformed fields. It is intended to be called once
176+
// during config loading so the user gets a clear error at startup rather than at scan time.
177+
func (r IgnoreRule) Validate() error {
178+
if _, err := parseExpiresAfter(r.ExpiresAfter); err != nil {
179+
return err
180+
}
181+
return nil
182+
}
183+
141184
func (r IgnoreRule) IgnoreMatch(match Match) []IgnoreRule {
142185
// VEX rules are handled by the vex processor
143186
if r.VexStatus != "" {
144187
return nil
145188
}
146189

190+
// If the rule has an expiry date and it has passed, the rule no longer applies.
191+
if isExpiresAfterInPast(r.ExpiresAfter) {
192+
return nil
193+
}
194+
147195
ignoreConditions := getIgnoreConditionsForRule(r)
148196
if len(ignoreConditions) == 0 {
149197
// this rule specifies no criteria, so it doesn't apply to the Match

grype/match/ignore_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package match
22

33
import (
44
"testing"
5+
"time"
56

67
"github.com/google/uuid"
78
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
810

911
"github.com/anchore/grype/grype/pkg"
1012
"github.com/anchore/grype/grype/vulnerability"
@@ -1352,6 +1354,42 @@ func TestShouldIgnore(t *testing.T) {
13521354
},
13531355
expected: false,
13541356
},
1357+
{
1358+
name: "rule applies when expires-after is in the future",
1359+
match: exampleMatch,
1360+
rule: IgnoreRule{
1361+
Vulnerability: exampleMatch.Vulnerability.ID,
1362+
ExpiresAfter: time.Now().UTC().Add(48 * time.Hour).Format(expiresAfterDateFmt),
1363+
},
1364+
expected: true,
1365+
},
1366+
{
1367+
name: "rule does not apply when expires-after is in the past",
1368+
match: exampleMatch,
1369+
rule: IgnoreRule{
1370+
Vulnerability: exampleMatch.Vulnerability.ID,
1371+
ExpiresAfter: time.Now().UTC().Add(-48 * time.Hour).Format(expiresAfterDateFmt),
1372+
},
1373+
expected: false,
1374+
},
1375+
{
1376+
name: "rule applies when expires-after is empty",
1377+
match: exampleMatch,
1378+
rule: IgnoreRule{
1379+
Vulnerability: exampleMatch.Vulnerability.ID,
1380+
ExpiresAfter: "",
1381+
},
1382+
expected: true,
1383+
},
1384+
{
1385+
name: "rule applies (does not crash) when expires-after is malformed",
1386+
match: exampleMatch,
1387+
rule: IgnoreRule{
1388+
Vulnerability: exampleMatch.Vulnerability.ID,
1389+
ExpiresAfter: "not-a-date",
1390+
},
1391+
expected: true,
1392+
},
13551393
}
13561394

13571395
for _, testCase := range cases {
@@ -1361,3 +1399,44 @@ func TestShouldIgnore(t *testing.T) {
13611399
})
13621400
}
13631401
}
1402+
1403+
func TestParseExpiresAfter(t *testing.T) {
1404+
cases := []struct {
1405+
name string
1406+
input string
1407+
expectZero bool
1408+
expectError bool
1409+
expectDate string
1410+
}{
1411+
{name: "valid date", input: "2026-12-31", expectDate: "2026-12-31"},
1412+
{name: "empty string returns zero with no error", input: "", expectZero: true},
1413+
{name: "invalid format returns error", input: "31-12-2026", expectError: true},
1414+
{name: "not a date returns error", input: "tomorrow", expectError: true},
1415+
}
1416+
1417+
for _, tc := range cases {
1418+
t.Run(tc.name, func(t *testing.T) {
1419+
got, err := parseExpiresAfter(tc.input)
1420+
if tc.expectError {
1421+
require.Error(t, err)
1422+
return
1423+
}
1424+
require.NoError(t, err)
1425+
if tc.expectZero {
1426+
assert.True(t, got.IsZero())
1427+
return
1428+
}
1429+
assert.Equal(t, tc.expectDate, got.Format(expiresAfterDateFmt))
1430+
})
1431+
}
1432+
}
1433+
1434+
func TestIsExpiresAfterInPast(t *testing.T) {
1435+
past := time.Now().UTC().Add(-48 * time.Hour).Format(expiresAfterDateFmt)
1436+
future := time.Now().UTC().Add(48 * time.Hour).Format(expiresAfterDateFmt)
1437+
1438+
assert.True(t, isExpiresAfterInPast(past), "a past date should be expired")
1439+
assert.False(t, isExpiresAfterInPast(future), "a future date should not be expired")
1440+
assert.False(t, isExpiresAfterInPast(""), "empty string should not be expired")
1441+
assert.False(t, isExpiresAfterInPast("not-a-date"), "malformed input should not be expired (defensive)")
1442+
}

0 commit comments

Comments
 (0)