Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions cmd/grype/cli/options/grype.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ default is unset which will skip this validation (options: negligible, low, medi
This is the full set of supported rule fields:
- vulnerability: CVE-2008-4318
fix-state: unknown
expires-after: "2027-01-01"
package:
name: libcurl
version: 1.5.1
Expand Down
48 changes: 48 additions & 0 deletions grype/match/ignore.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package match

import (
"fmt"
"regexp"
"slices"
"time"

"github.com/bmatcuk/doublestar/v2"

Expand All @@ -12,6 +14,37 @@
"github.com/anchore/syft/syft/artifact"
)

// expiresAfterDateFmt is the date layout accepted for IgnoreRule.ExpiresAfter (YYYY-MM-DD).
const expiresAfterDateFmt = "2006-01-02"

// parseExpiresAfter parses a YYYY-MM-DD string into a UTC time.Time.
// An empty string returns the zero value with no error.
func parseExpiresAfter(s string) (time.Time, error) {
if s == "" {
return time.Time{}, nil
}
t, err := time.ParseInLocation(expiresAfterDateFmt, s, time.UTC)
if err != nil {
return time.Time{}, fmt.Errorf("invalid expires-after %q (expected YYYY-MM-DD): %w", s, err)
}
return t, nil
}

// isExpiresAfterInPast reports whether the given expires-after string represents a date that has already passed.
// An empty string is never considered expired. Malformed values are treated as not expired (a warning is logged).
func isExpiresAfterInPast(s string) bool {
if s == "" {
return false
}
t, err := parseExpiresAfter(s)
if err != nil {
log.WithFields("expires-after", s, "error", err).Warn("ignoring malformed expires-after on ignore rule")
return false
}
// the rule remains active for the entire calendar day it expires on
return time.Now().UTC().After(t.Add(24 * time.Hour))
}

// IgnoreFilter implementations are used to filter matches, returning all applicable IgnoreRule(s) that applied,
// these could include an IgnoreRule with only a Reason value filled in for synthetically generated rules
type IgnoreFilter interface {
Expand Down Expand Up @@ -40,6 +73,7 @@
VexStatus string `yaml:"vex-status" json:"vex-status" mapstructure:"vex-status"`
VexJustification string `yaml:"vex-justification" json:"vex-justification" mapstructure:"vex-justification"`
MatchType Type `yaml:"match-type" json:"match-type" mapstructure:"match-type"`
ExpiresAfter string `yaml:"expires-after,omitempty" json:"expires-after,omitempty" mapstructure:"expires-after"`

Check failure on line 76 in grype/match/ignore.go

View workflow job for this annotation

GitHub Actions / Static analysis

File is not properly formatted (goimports)

Check failure on line 76 in grype/match/ignore.go

View workflow job for this annotation

GitHub Actions / Static analysis

File is not properly formatted (gofmt)
}

// IgnoreRulePackage describes the Package-specific fields that comprise the IgnoreRule.
Expand Down Expand Up @@ -138,12 +172,26 @@
return out, ignoredMatches
}

// Validate returns an error if the rule contains malformed fields. It is intended to be called once
// during config loading so the user gets a clear error at startup rather than at scan time.
func (r IgnoreRule) Validate() error {
if _, err := parseExpiresAfter(r.ExpiresAfter); err != nil {
return err
}
return nil
}

func (r IgnoreRule) IgnoreMatch(match Match) []IgnoreRule {
// VEX rules are handled by the vex processor
if r.VexStatus != "" {
return nil
}

// If the rule has an expiry date and it has passed, the rule no longer applies.
if isExpiresAfterInPast(r.ExpiresAfter) {
return nil
}

ignoreConditions := getIgnoreConditionsForRule(r)
if len(ignoreConditions) == 0 {
// this rule specifies no criteria, so it doesn't apply to the Match
Expand Down
79 changes: 79 additions & 0 deletions grype/match/ignore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package match

import (
"testing"
"time"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/vulnerability"
Expand Down Expand Up @@ -1352,6 +1354,42 @@ func TestShouldIgnore(t *testing.T) {
},
expected: false,
},
{
name: "rule applies when expires-after is in the future",
match: exampleMatch,
rule: IgnoreRule{
Vulnerability: exampleMatch.Vulnerability.ID,
ExpiresAfter: time.Now().UTC().Add(48 * time.Hour).Format(expiresAfterDateFmt),
},
expected: true,
},
{
name: "rule does not apply when expires-after is in the past",
match: exampleMatch,
rule: IgnoreRule{
Vulnerability: exampleMatch.Vulnerability.ID,
ExpiresAfter: time.Now().UTC().Add(-48 * time.Hour).Format(expiresAfterDateFmt),
},
expected: false,
},
{
name: "rule applies when expires-after is empty",
match: exampleMatch,
rule: IgnoreRule{
Vulnerability: exampleMatch.Vulnerability.ID,
ExpiresAfter: "",
},
expected: true,
},
{
name: "rule applies (does not crash) when expires-after is malformed",
match: exampleMatch,
rule: IgnoreRule{
Vulnerability: exampleMatch.Vulnerability.ID,
ExpiresAfter: "not-a-date",
},
expected: true,
},
}

for _, testCase := range cases {
Expand All @@ -1361,3 +1399,44 @@ func TestShouldIgnore(t *testing.T) {
})
}
}

func TestParseExpiresAfter(t *testing.T) {
cases := []struct {
name string
input string
expectZero bool
expectError bool
expectDate string
}{
{name: "valid date", input: "2026-12-31", expectDate: "2026-12-31"},
{name: "empty string returns zero with no error", input: "", expectZero: true},
{name: "invalid format returns error", input: "31-12-2026", expectError: true},
{name: "not a date returns error", input: "tomorrow", expectError: true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseExpiresAfter(tc.input)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
if tc.expectZero {
assert.True(t, got.IsZero())
return
}
assert.Equal(t, tc.expectDate, got.Format(expiresAfterDateFmt))
})
}
}

func TestIsExpiresAfterInPast(t *testing.T) {
past := time.Now().UTC().Add(-48 * time.Hour).Format(expiresAfterDateFmt)
future := time.Now().UTC().Add(48 * time.Hour).Format(expiresAfterDateFmt)

assert.True(t, isExpiresAfterInPast(past), "a past date should be expired")
assert.False(t, isExpiresAfterInPast(future), "a future date should not be expired")
assert.False(t, isExpiresAfterInPast(""), "empty string should not be expired")
assert.False(t, isExpiresAfterInPast("not-a-date"), "malformed input should not be expired (defensive)")
}
Loading