From a9a3b59d1fcd62323113d92e5d42b039c352aa14 Mon Sep 17 00:00:00 2001 From: Seila Gamo Date: Thu, 18 Jul 2024 10:41:24 +0200 Subject: [PATCH] internal/config: add ReportConfig.Exclusions.ExpirationDate field --- cmd/lava/internal/help/helpdoc.go | 2 + internal/config/config.go | 48 ++++++++++ internal/config/config_test.go | 90 ++++++++++++++++++- .../testdata/invalid_expiration_date.yaml | 11 +++ .../testdata/valid_expiration_date.yaml | 11 +++ internal/report/report.go | 8 ++ internal/report/report_test.go | 53 +++++++++++ 7 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 internal/config/testdata/invalid_expiration_date.yaml create mode 100644 internal/config/testdata/valid_expiration_date.yaml diff --git a/cmd/lava/internal/help/helpdoc.go b/cmd/lava/internal/help/helpdoc.go index 6403304..e369aa4 100644 --- a/cmd/lava/internal/help/helpdoc.go +++ b/cmd/lava/internal/help/helpdoc.go @@ -172,6 +172,8 @@ The exclusion rules support the following filters: and the checktype options. - summary: regular expression that matches the summary of the vulnerability. + - expiration: is the date on which the exclusion becomes inactive. + The format is YYYY/MM/DD. A finding is excluded if it matches all the filters of an exclusion rule. diff --git a/internal/config/config.go b/internal/config/config.go index c5f3d1d..30b5aa8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "os" "regexp" "strings" + "time" agentconfig "github.com/adevinta/vulcan-agent/config" types "github.com/adevinta/vulcan-types" @@ -50,6 +51,10 @@ var ( // ErrInvalidOutputFormat means that the output format is // invalid. ErrInvalidOutputFormat = errors.New("invalid output format") + + // ErrInvalidExpirationDate means that the expiration date is + // invalid. + ErrInvalidExpirationDate = errors.New("invalid expiration date") ) // Config represents a Lava configuration. @@ -391,6 +396,49 @@ type Exclusion struct { // the vulnerability. Summary string `yaml:"summary"` + // ExpirationDate is the date on which the exclusion becomes inactive. + // The format is YYYY/MM/DD. + ExpirationDate ExpirationDate `yaml:"expiration"` + // Description describes the exclusion. Description string `yaml:"description"` } + +// ExpirationDateLayout is the input format for the [ExpirationDate]. +const ExpirationDateLayout = "2006/01/02" + +// ExpirationDate represents when an exclusion is not valid any more. +type ExpirationDate struct { + time.Time +} + +// parseExpirationDate converts a string into an [ExpirationDate] value. +func parseExpirationDate(date string) (ExpirationDate, error) { + t, err := time.Parse(ExpirationDateLayout, date) + if err != nil { + return ExpirationDate{}, fmt.Errorf("%w: %w", ErrInvalidExpirationDate, err) + } + return ExpirationDate{Time: t}, nil +} + +// UnmarshalText decodes an [ExpirationDate] text into an [ExpirationDate] +// value. It returns error if the provided string does not match the +// date format. +func (ed *ExpirationDate) UnmarshalText(text []byte) error { + expirationDate, err := parseExpirationDate(string(text)) + if err != nil { + return err + } + *ed = expirationDate + return nil +} + +// MarshalText encodes an [ExpirationDate] value as text. +func (ed ExpirationDate) MarshalText() (text []byte, err error) { + return []byte(ed.String()), nil +} + +// String returns the string representation of the expiration date. +func (ed ExpirationDate) String() string { + return ed.Format(ExpirationDateLayout) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0c7bc75..4b9d85b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,11 +3,13 @@ package config import ( + "bytes" "errors" "io" "log/slog" "regexp" "testing" + "time" agentconfig "github.com/adevinta/vulcan-agent/config" types "github.com/adevinta/vulcan-types" @@ -234,6 +236,39 @@ func TestParse(t *testing.T) { want: Config{}, wantErrRegexp: regexp.MustCompile(`level string ".*": unknown name`), }, + { + name: "valid expiration date", + file: "testdata/valid_expiration_date.yaml", + want: Config{ + LavaVersion: "v1.0.0", + ChecktypeURLs: []string{ + "checktypes.json", + }, + Targets: []Target{ + { + Identifier: "example.com", + AssetType: types.DomainName, + }, + }, + ReportConfig: ReportConfig{ + Format: OutputFormatHuman, + OutputFile: "", + Exclusions: []Exclusion{ + { + Summary: "Secret Leaked in Git Repository", + Description: "Ignore test certificates.", + ExpirationDate: mustParseExpDate("2024/07/05"), + }, + }, + }, + }, + }, + { + name: "invalid expiration date", + file: "testdata/invalid_expiration_date.yaml", + want: Config{}, + wantErr: ErrInvalidExpirationDate, + }, } for _, tt := range tests { @@ -259,7 +294,6 @@ func TestParse(t *testing.T) { t.Errorf("unexpected error: %v", err) } } - if diff := cmp.Diff(tt.want, got); diff != "" { t.Errorf("configs mismatch (-want +got):\n%v", diff) } @@ -377,3 +411,57 @@ func TestSeverity_MarshalText(t *testing.T) { func ptr[V any](v V) *V { return &v } + +func TestParseExpirationDate(t *testing.T) { + tests := []struct { + name string + date string + want ExpirationDate + wantErr error + }{ + { + name: "valid date", + date: "2024/07/05", + want: mustParseExpDate("2024/07/05"), + wantErr: nil, + }, + { + name: "invalid date", + date: "2024-07-05", + want: ExpirationDate{}, + wantErr: ErrInvalidExpirationDate, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseExpirationDate(tt.date) + if !errors.Is(err, tt.wantErr) { + t.Errorf("unexpected error: want: %v, got: %v", tt.wantErr, err) + } + if !got.Equal(tt.want.Time) { + t.Errorf("unexpected date: want: %v, got: %v", tt.want, got) + } + }) + } +} + +func TestExpirationDate_MarshalText(t *testing.T) { + date := mustParseExpDate("2024/07/05") + want := []byte("2024/07/05") + + got, err := date.MarshalText() + if err != nil { + t.Errorf("unexpected error: want: %v, got: %v", nil, err) + } + if !bytes.Equal(got, want) { + t.Errorf("unexpected expiration date string: want: %s, got: %s", want, got) + } +} + +func mustParseExpDate(date string) ExpirationDate { + t, err := time.Parse(ExpirationDateLayout, date) + if err != nil { + panic(err) + } + return ExpirationDate{Time: t} +} diff --git a/internal/config/testdata/invalid_expiration_date.yaml b/internal/config/testdata/invalid_expiration_date.yaml new file mode 100644 index 0000000..b481d45 --- /dev/null +++ b/internal/config/testdata/invalid_expiration_date.yaml @@ -0,0 +1,11 @@ +lava: v1.0.0 +checktypes: + - checktypes.json +targets: + - identifier: example.com + type: DomainName +report: + exclusions: + - description: Ignore test certificates. + summary: 'Secret Leaked in Git Repository' + expiration: 2024-07-05 diff --git a/internal/config/testdata/valid_expiration_date.yaml b/internal/config/testdata/valid_expiration_date.yaml new file mode 100644 index 0000000..62a5ad2 --- /dev/null +++ b/internal/config/testdata/valid_expiration_date.yaml @@ -0,0 +1,11 @@ +lava: v1.0.0 +checktypes: + - checktypes.json +targets: + - identifier: example.com + type: DomainName +report: + exclusions: + - description: Ignore test certificates. + summary: 'Secret Leaked in Git Repository' + expiration: 2024/07/05 diff --git a/internal/report/report.go b/internal/report/report.go index ff9afc2..2c532e9 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -12,6 +12,7 @@ import ( "os" "regexp" "slices" + "time" report "github.com/adevinta/vulcan-report" @@ -30,6 +31,9 @@ type Writer struct { exclusions []config.Exclusion } +// timeNow is set by tests to mock the current time. +var timeNow = time.Now + // NewWriter creates a new instance of a report writer. func NewWriter(cfg config.ReportConfig) (Writer, error) { var prn printer @@ -138,6 +142,10 @@ func (writer Writer) parseReport(er engine.Report) ([]vulnerability, error) { // excluded based on the [Writer] configuration and the affected target. func (writer Writer) isExcluded(v report.Vulnerability, target string) (bool, error) { for _, excl := range writer.exclusions { + if !excl.ExpirationDate.IsZero() && excl.ExpirationDate.Before(timeNow()) { + continue + } + if excl.Fingerprint != "" && v.Fingerprint != excl.Fingerprint { continue } diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 24c240b..b8189a2 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -7,6 +7,7 @@ import ( "os" "path" "testing" + "time" vreport "github.com/adevinta/vulcan-report" "github.com/google/go-cmp/cmp" @@ -631,9 +632,53 @@ func TestWriter_isExcluded(t *testing.T) { want: false, wantNilErr: true, }, + { + name: "active exclusion", + vulnerability: vreport.Vulnerability{ + Summary: "Vulnerability Summary 1", + Score: 6.7, + }, + target: ".", + rConfig: config.ReportConfig{ + Exclusions: []config.Exclusion{ + { + Summary: "Summary 1", + Description: "Excluded vulnerabilities Summary 1", + ExpirationDate: mustParseExpDate("2024/05/06"), + }, + }, + }, + want: true, + wantNilErr: true, + }, + { + name: "expired exclusion", + vulnerability: vreport.Vulnerability{ + Summary: "Vulnerability Summary 1", + Score: 6.7, + }, + target: ".", + rConfig: config.ReportConfig{ + Exclusions: []config.Exclusion{ + { + Summary: "Summary 1", + Description: "Excluded vulnerabilities Summary 1", + ExpirationDate: mustParseExpDate("2023/05/06"), + }, + }, + }, + want: false, + wantNilErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + oldTimeNow := timeNow + defer func() { timeNow = oldTimeNow }() + timeNow = func() time.Time { + tn, _ := time.Parse(time.RFC3339, "2024-01-02T15:04:05Z") + return tn + } w, err := NewWriter(tt.rConfig) if err != nil { t.Fatalf("unable to create a report writer: %v", err) @@ -1458,3 +1503,11 @@ func statusLess(a, b checkStatus) bool { func ptr[V any](v V) *V { return &v } + +func mustParseExpDate(date string) config.ExpirationDate { + t, err := time.Parse(config.ExpirationDateLayout, date) + if err != nil { + panic(err) + } + return config.ExpirationDate{Time: t} +}