Skip to content

Commit 5ae01f0

Browse files
validate cron expressions (#59)
1 parent ed75173 commit 5ae01f0

File tree

11 files changed

+175
-30
lines changed

11 files changed

+175
-30
lines changed

examples/nullify.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ notifications:
3232
scheduled_notifications:
3333
new-findings:
3434
schedule: "0 0 * * *"
35+
timezone: "America/Los_Angeles"
3536
topics:
3637
all: true
3738
targets:

go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@ require (
88
gopkg.in/yaml.v3 v3.0.1
99
)
1010

11+
require (
12+
github.com/kr/pretty v0.3.0 // indirect
13+
github.com/rogpeppe/go-internal v1.8.1 // indirect
14+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
15+
)
16+
1117
require (
1218
github.com/davecgh/go-spew v1.1.1 // indirect
1319
github.com/pmezard/go-difflib v1.0.0 // indirect
20+
github.com/robfig/cron/v3 v3.0.1
1421
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
1522
)

go.sum

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
1+
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
12
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
23
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
34
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
45
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
6+
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
7+
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
8+
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
9+
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
10+
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
11+
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
12+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
13+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
14+
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
515
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
616
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
17+
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
18+
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
19+
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
20+
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
21+
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
722
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
823
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
924
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
1025
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
11-
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
1226
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
27+
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
28+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
29+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
30+
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
1331
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
1432
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

pkg/models/scheduled_notifications.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const (
1111

1212
type ScheduledNotification struct {
1313
Schedule string `yaml:"schedule,omitempty"`
14+
Timezone string `yaml:"timezone,omitempty"`
1415
Topics ScheduledNotificationTopics `yaml:"topics,omitempty"`
1516
Targets ScheduledNotificationTargets `yaml:"targets,omitempty"`
1617

pkg/validator/notifications.go

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,25 +31,5 @@ func ValidateNotifications(config *models.Configuration) bool {
3131
}
3232
}
3333

34-
for _, notification := range config.ScheduledNotifications {
35-
if notification.Targets.Email == nil {
36-
continue
37-
}
38-
39-
if notification.Targets.Email.Address != "" {
40-
_, err := mail.ParseAddress(notification.Targets.Email.Address)
41-
if err != nil {
42-
return false
43-
}
44-
}
45-
46-
for _, email := range notification.Targets.Email.Addresses {
47-
_, err := mail.ParseAddress(email)
48-
if err != nil {
49-
return false
50-
}
51-
}
52-
}
53-
5434
return true
5535
}

pkg/validator/glob.go renamed to pkg/validator/paths.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"github.com/nullify-platform/config-file-parser/pkg/models"
66
)
77

8-
func ValidateGlob(config *models.Configuration) bool {
8+
func ValidatePaths(config *models.Configuration) bool {
99
if config.IgnorePaths == nil {
1010
return true
1111
}

pkg/validator/glob_test.go renamed to pkg/validator/paths_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func TestValidGlobs(t *testing.T) {
4646
},
4747
} {
4848
t.Run(scenario.name, func(t *testing.T) {
49-
isValid := ValidateGlob(scenario.config)
49+
isValid := ValidatePaths(scenario.config)
5050
assert.Equalf(t, isValid, scenario.expected, fmt.Sprintf("failed test, globs: %s, len: %d\n", scenario.config.IgnorePaths, len(scenario.config.IgnorePaths)))
5151
})
5252
}
@@ -90,20 +90,20 @@ ignore_paths: ["*[abc", "*d"]
9090
func TestParsingAndValidGlobs(t *testing.T) {
9191
config1, err := parser.ParseConfiguration([]byte(validGlob))
9292
require.NoError(t, err)
93-
require.Equal(t, true, ValidateGlob(config1))
93+
require.Equal(t, true, ValidatePaths(config1))
9494
config2, err := parser.ParseConfiguration([]byte(emptyGlob))
9595
require.NoError(t, err)
96-
require.Equal(t, true, ValidateGlob(config2))
96+
require.Equal(t, true, ValidatePaths(config2))
9797
config3, err := parser.ParseConfiguration([]byte(twoValidGlob))
9898
require.NoError(t, err)
99-
require.Equal(t, true, ValidateGlob(config3))
99+
require.Equal(t, true, ValidatePaths(config3))
100100
config4, err := parser.ParseConfiguration([]byte(endInvalidGlob))
101101
require.NoError(t, err)
102-
require.Equal(t, false, ValidateGlob(config4))
102+
require.Equal(t, false, ValidatePaths(config4))
103103
config5, err := parser.ParseConfiguration([]byte(startInvalidGlob))
104104
require.NoError(t, err)
105-
require.Equal(t, false, ValidateGlob(config5))
105+
require.Equal(t, false, ValidatePaths(config5))
106106
config6, err := parser.ParseConfiguration([]byte(invalidGlob))
107107
require.NoError(t, err)
108-
require.Equal(t, false, ValidateGlob(config6))
108+
require.Equal(t, false, ValidatePaths(config6))
109109
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package validator
2+
3+
import (
4+
"fmt"
5+
"net/mail"
6+
"time"
7+
8+
"github.com/nullify-platform/config-file-parser/pkg/models"
9+
"github.com/robfig/cron/v3"
10+
)
11+
12+
func ValidateScheduledNotifications(config *models.Configuration) bool {
13+
if config.ScheduledNotifications == nil {
14+
return true
15+
}
16+
17+
for _, notification := range config.ScheduledNotifications {
18+
if !validateScheduledNotificationSchedule(notification.Schedule, notification.Timezone) {
19+
return false
20+
}
21+
22+
if !validateScheduledNotificationEmails(notification) {
23+
return false
24+
}
25+
}
26+
27+
return true
28+
}
29+
30+
// validateScheduledNotificationSchedule return true if provided schedule is a valid cron expression.
31+
// The cron expression can also only trigger at most once per hour.
32+
func validateScheduledNotificationSchedule(schedule string, timezone string) bool {
33+
spec := "TZ=" + timezone + " " + schedule
34+
35+
if timezone == "" {
36+
spec = "TZ=UTC " + schedule
37+
}
38+
39+
p := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
40+
41+
// TODO this function panics with the following input "TZ=UTC"
42+
cronSchedule, err := p.Parse(spec)
43+
if err != nil {
44+
fmt.Printf("failed to parse cron expression: %s", err.Error())
45+
return false
46+
}
47+
48+
// check if the cron expression triggers more often than once per hour
49+
start := cronSchedule.Next(time.Now())
50+
finish := cronSchedule.Next(start)
51+
52+
return finish.Sub(start) >= time.Hour
53+
}
54+
55+
func validateScheduledNotificationEmails(notification models.ScheduledNotification) bool {
56+
if notification.Targets.Email == nil {
57+
return true
58+
}
59+
60+
if notification.Targets.Email.Address != "" {
61+
_, err := mail.ParseAddress(notification.Targets.Email.Address)
62+
if err != nil {
63+
return false
64+
}
65+
}
66+
67+
for _, email := range notification.Targets.Email.Addresses {
68+
_, err := mail.ParseAddress(email)
69+
if err != nil {
70+
return false
71+
}
72+
}
73+
74+
return true
75+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package validator
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/nullify-platform/config-file-parser/pkg/models"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestValidateScheduledNotifications(t *testing.T) {
13+
for _, scenario := range []struct {
14+
name string
15+
config *models.Configuration
16+
expected bool
17+
}{
18+
{
19+
name: "empty config",
20+
config: &models.Configuration{},
21+
expected: true,
22+
},
23+
{
24+
name: "empty scheduled notifications",
25+
config: &models.Configuration{
26+
ScheduledNotifications: map[string]models.ScheduledNotification{},
27+
},
28+
expected: true,
29+
},
30+
{
31+
name: "cron expression triggers every 59 minutes",
32+
config: &models.Configuration{
33+
ScheduledNotifications: map[string]models.ScheduledNotification{
34+
"test": {
35+
Schedule: "*/59 * * * *",
36+
},
37+
},
38+
},
39+
expected: false,
40+
},
41+
{
42+
name: "cron expression triggers every hour",
43+
config: &models.Configuration{
44+
ScheduledNotifications: map[string]models.ScheduledNotification{
45+
"test": {
46+
Schedule: "0 * * * *",
47+
},
48+
},
49+
},
50+
expected: true,
51+
},
52+
} {
53+
t.Run(scenario.name, func(t *testing.T) {
54+
isValid := ValidateScheduledNotifications(scenario.config)
55+
assert.Equalf(t, scenario.expected, isValid, fmt.Sprintf("failed test: %s\n", scenario.name))
56+
})
57+
}
58+
}

pkg/validator/validate.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,8 @@ import (
66

77
// ValidateConfig return true if provided configuration is valid
88
func ValidateConfig(config *models.Configuration) bool {
9-
return ValidateSeverityThreshold(config) && ValidateNotifications(config) && ValidateGlob(config)
9+
return ValidateSeverityThreshold(config) &&
10+
ValidateNotifications(config) &&
11+
ValidateScheduledNotifications(config) &&
12+
ValidatePaths(config)
1013
}

0 commit comments

Comments
 (0)