diff --git a/cmd/terrascan/main.go b/cmd/terrascan/main.go index 6b33c1f82..7cbaaf0e7 100644 --- a/cmd/terrascan/main.go +++ b/cmd/terrascan/main.go @@ -45,6 +45,9 @@ func main() { // logging flags logLevel = flag.String("log-level", "info", "logging level (debug, info, warn, error, panic, fatal)") logType = flag.String("log-type", "console", "log type (json, console)") + + // config file + configFile = flag.String("config", "", "config file path") ) flag.Parse() @@ -61,6 +64,6 @@ func main() { } else { logging.Init(*logType, *logLevel) zap.S().Debug("running terrascan in cli mode") - cli.Run(*iacType, *iacVersion, *cloudType, *iacFilePath, *iacDirPath) + cli.Run(*iacType, *iacVersion, *cloudType, *iacFilePath, *iacDirPath, *configFile) } } diff --git a/config/terrascan.toml b/config/terrascan.toml new file mode 100644 index 000000000..d1af36565 --- /dev/null +++ b/config/terrascan.toml @@ -0,0 +1,6 @@ +# terrascan configuration file + +# notifications configuration +[notifications] + [notifications.webhook] + url = "https://httpbin.org/post" diff --git a/go.mod b/go.mod index 321a14d32..48eb15a73 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,12 @@ go 1.14 require ( github.com/gorilla/mux v1.7.4 + github.com/hashicorp/go-retryablehttp v0.6.6 github.com/hashicorp/go-version v1.2.0 github.com/hashicorp/hcl/v2 v2.3.0 github.com/hashicorp/terraform v0.12.28 + github.com/pelletier/go-toml v1.8.0 + github.com/pkg/errors v0.9.1 github.com/spf13/afero v1.3.2 github.com/zclconf/go-cty v1.2.1 go.uber.org/zap v1.9.1 diff --git a/go.sum b/go.sum index 442aee8e5..8448a0219 100644 --- a/go.sum +++ b/go.sum @@ -133,16 +133,20 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/go-azure-helpers v0.10.0/go.mod h1:YuAtHxm2v74s+IjQwUG88dHBJPd5jL+cXr5BGVzSKhE= github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-getter v1.4.2-0.20200106182914-9813cbd4eb02/go.mod h1:7qxyCd8rBfcShwsvxgIguu4KbS3l8bUCwg2Umn7RjeY= github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= github.com/hashicorp/go-hclog v0.0.0-20181001195459-61d530d6c27f/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-immutable-radix v0.0.0-20180129170900-7f3cd4390caa/go.mod h1:6ij3Z20p+OhOkCSrA0gImAWoHYQRGbnlcuk6XYTiaRw= github.com/hashicorp/go-msgpack v0.5.4/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-plugin v1.3.0/go.mod h1:F9eH4LrE/ZsRdbwhfjs9k9HoDUwAHnYtXdgmf1AVNs0= github.com/hashicorp/go-retryablehttp v0.5.2/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-retryablehttp v0.6.6 h1:HJunrbHTDDbBb/ay4kxa1n+dLmttUlnP3V9oNE4hmsM= +github.com/hashicorp/go-retryablehttp v0.6.6/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= github.com/hashicorp/go-slug v0.4.1/go.mod h1:I5tq5Lv0E2xcNXNkmx7BSfzi1PsJ2cNjs3cC3LwyhK8= @@ -235,6 +239,8 @@ github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/packer-community/winrmcp v0.0.0-20180102160824-81144009af58/go.mod h1:f6Izs6JvFTdnRbziASagjZ2vmf55NSIkC/weStxCHqk= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.8.0 h1:Keo9qb7iRJs2voHvunFtuuYFsbWeOBh8/P9v/kVMFtw= +github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -438,6 +444,7 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/cli/run.go b/pkg/cli/run.go index e4dbd3810..e3e76d8f3 100644 --- a/pkg/cli/run.go +++ b/pkg/cli/run.go @@ -17,24 +17,24 @@ package cli import ( - "os" + // "os" "github.com/accurics/terrascan/pkg/runtime" - "github.com/accurics/terrascan/pkg/utils" + // "github.com/accurics/terrascan/pkg/utils" ) // Run executes terrascan in CLI mode -func Run(iacType, iacVersion, cloudType, iacFilePath, iacDirPath string) { +func Run(iacType, iacVersion, cloudType, iacFilePath, iacDirPath, configFile string) { // create a new runtime executor for processing IaC executor, err := runtime.NewExecutor(iacType, iacVersion, cloudType, iacFilePath, - iacDirPath) + iacDirPath, configFile) if err != nil { return } - normalized, err := executor.Execute() + _, err = executor.Execute() if err != nil { return } - utils.PrintJSON(normalized, os.Stdout) + // utils.PrintJSON(normalized, os.Stdout) } diff --git a/pkg/http-server/file-scan.go b/pkg/http-server/file-scan.go index 3a5564aaa..5a60498f3 100644 --- a/pkg/http-server/file-scan.go +++ b/pkg/http-server/file-scan.go @@ -83,7 +83,7 @@ func (g *APIHandler) scanFile(w http.ResponseWriter, r *http.Request) { // create a new runtime executor for scanning the uploaded file executor, err := runtime.NewExecutor(iacType, iacVersion, cloudType, - tempFile.Name(), "") + tempFile.Name(), "", "") if err != nil { zap.S().Error(err) apiErrorResponse(w, err.Error(), http.StatusBadRequest) diff --git a/pkg/notifications/interface.go b/pkg/notifications/interface.go new file mode 100644 index 000000000..49d133b6c --- /dev/null +++ b/pkg/notifications/interface.go @@ -0,0 +1,8 @@ +package notifications + +// Notifier defines the interface which every type of notification provider +// needs to implement to claim support in terrascan +type Notifier interface { + Init(interface{}) error + SendNotification(interface{}) error +} diff --git a/pkg/notifications/notifiers.go b/pkg/notifications/notifiers.go new file mode 100644 index 000000000..add3b8e1f --- /dev/null +++ b/pkg/notifications/notifiers.go @@ -0,0 +1,137 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package notifications + +import ( + "fmt" + "os" + "reflect" + + "github.com/accurics/terrascan/pkg/utils" + "github.com/pelletier/go-toml" + "go.uber.org/zap" +) + +const ( + notificationsConfigKey = "notifications" +) + +var ( + errNotPresent = fmt.Errorf("config file not present") + errNotifierNotSupported = fmt.Errorf("notifier not supported") + errTomlLoadConfig = fmt.Errorf("failed to load toml config") + errTomlKeyNotPresent = fmt.Errorf("key not present in toml config") +) + +// NewNotifier returns a new notifier +func NewNotifier(notifierType string) (notifier Notifier, err error) { + + // get notifier from supportedNotifierss + notifierObject, supported := supportedNotifiers[supportedNotifierType(notifierType)] + if !supported { + zap.S().Errorf("notifier type '%s' not supported", notifierType) + return notifier, errNotifierNotSupported + } + + // successful + return reflect.New(notifierObject).Interface().(Notifier), nil +} + +// NewNotifiers returns a list of notifiers configured in the config file +func NewNotifiers(configFile string) ([]Notifier, error) { + + var notifiers []Notifier + + // empty config file path + if configFile == "" { + zap.S().Infof("no config file specified") + return notifiers, nil + } + + // check if file exists + _, err := os.Stat(configFile) + if err != nil { + zap.S().Errorf("config file '%s' not present", configFile) + return notifiers, errNotPresent + } + + // parse toml config file + config, err := toml.LoadFile(configFile) + if err != nil { + zap.S().Errorf("failed to load toml config file '%s'. error: '%v'", err) + return notifiers, errTomlLoadConfig + } + + // get config for 'notifications' + keyConfig := config.Get(notificationsConfigKey) + if keyConfig == nil { + zap.S().Infof("key '%s' not present in toml config", notificationsConfigKey) + return notifiers, errTomlKeyNotPresent + } + + // get all the notifier types configured in TOML config + keyTomlConfig := keyConfig.(*toml.Tree) + notifierTypes := keyTomlConfig.Keys() + + // create notifiers + var allErrs error + for _, nType := range notifierTypes { + + if !IsNotifierSupported(nType) { + zap.S().Errorf("notifier type '%s' not supported", nType) + allErrs = utils.WrapError(errNotifierNotSupported, allErrs) + continue + } + + // check if toml config present for notifier type + nTypeConfig := keyTomlConfig.Get(nType) + if nTypeConfig.(*toml.Tree).String() == "" { + zap.S().Errorf("notifier '%v' config not present", nType) + allErrs = utils.WrapError(errTomlKeyNotPresent, allErrs) + continue + } + + // create a new notifier + n, err := NewNotifier(nType) + if err != nil { + allErrs = utils.WrapError(err, allErrs) + continue + } + + // populate data + err = n.Init(nTypeConfig) + if err != nil { + allErrs = utils.WrapError(err, allErrs) + continue + } + + // add to the list of notifiers + notifiers = append(notifiers, n) + } + + // return list of notifiers + return notifiers, allErrs +} + +// IsNotifierSupported returns true/false depending on whether the notifier +// is supported in terrascan or not +func IsNotifierSupported(notifierType string) bool { + if _, supported := supportedNotifiers[supportedNotifierType(notifierType)]; !supported { + return false + } + return true +} diff --git a/pkg/notifications/notifiers_test.go b/pkg/notifications/notifiers_test.go new file mode 100644 index 000000000..2f71af0c9 --- /dev/null +++ b/pkg/notifications/notifiers_test.go @@ -0,0 +1,91 @@ +package notifications + +import ( + "reflect" + "testing" + + "github.com/accurics/terrascan/pkg/notifications/webhook" +) + +func TestNewNotifier(t *testing.T) { + + table := []struct { + name string + nType string + wantType Notifier + wantErr error + }{ + { + name: "valid notifier", + nType: "webhook", + wantType: &webhook.Webhook{}, + wantErr: nil, + }, + { + name: "invalid notifier", + nType: "notthere", + wantErr: errNotifierNotSupported, + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + gotType, gotErr := NewNotifier(tt.nType) + if !reflect.DeepEqual(gotType, tt.wantType) { + t.Errorf("got: '%v', want: '%v'", gotType, tt.wantType) + } + if !reflect.DeepEqual(gotErr, tt.wantErr) { + t.Errorf("incorrect error; got: '%v', want: '%v'", gotErr, tt.wantErr) + } + }) + } +} + +func TestNewNotifiers(t *testing.T) { + + table := []struct { + name string + configFile string + wantErr error + }{ + { + name: "config not present", + configFile: "notthere", + wantErr: errNotPresent, + }, + { + name: "invalid toml", + configFile: "testdata/invalid.toml", + wantErr: errTomlLoadConfig, + }, + { + name: "key not present", + configFile: "testdata/nokey.toml", + wantErr: errTomlKeyNotPresent, + }, + { + name: "invalid notifier", + configFile: "testdata/invalid-notifier-type.toml", + wantErr: errNotifierNotSupported, + }, + { + name: "empty notifier config", + configFile: "testdata/empty-notifier-config.toml", + wantErr: errTomlKeyNotPresent, + }, + { + name: "invalid notifier config", + configFile: "testdata/invalid-notifier-config.toml", + wantErr: nil, + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + _, gotErr := NewNotifiers(tt.configFile) + if !reflect.DeepEqual(gotErr, tt.wantErr) { + t.Errorf("incorrect error; got: '%v', want: '%v'", gotErr, tt.wantErr) + } + }) + } +} diff --git a/pkg/notifications/register.go b/pkg/notifications/register.go new file mode 100644 index 000000000..c875a7fda --- /dev/null +++ b/pkg/notifications/register.go @@ -0,0 +1,29 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package notifications + +import ( + "reflect" +) + +// map of supported notifier types +var supportedNotifiers = make(map[supportedNotifierType]reflect.Type) + +// RegisterNotifier registers an notifier provider for terrascan +func RegisterNotifier(notifierType supportedNotifierType, notifierProvider reflect.Type) { + supportedNotifiers[notifierType] = notifierProvider +} diff --git a/pkg/notifications/testdata/empty-notifier-config.toml b/pkg/notifications/testdata/empty-notifier-config.toml new file mode 100644 index 000000000..299652e1f --- /dev/null +++ b/pkg/notifications/testdata/empty-notifier-config.toml @@ -0,0 +1,2 @@ +[notifications] + [notifications.webhook] diff --git a/pkg/notifications/testdata/invalid-notifier-config.toml b/pkg/notifications/testdata/invalid-notifier-config.toml new file mode 100644 index 000000000..4b2914425 --- /dev/null +++ b/pkg/notifications/testdata/invalid-notifier-config.toml @@ -0,0 +1,4 @@ +[notifications] + [notifications.webhook] + key1 = "val1" + key2 = "val2" diff --git a/pkg/notifications/testdata/invalid-notifier-type.toml b/pkg/notifications/testdata/invalid-notifier-type.toml new file mode 100644 index 000000000..74c795e09 --- /dev/null +++ b/pkg/notifications/testdata/invalid-notifier-type.toml @@ -0,0 +1,6 @@ +# terrascan configuration file + +# notifications configuration +[notifications] + [notifications.invalid] + url = "https://httpbin.org/post" diff --git a/pkg/notifications/testdata/invalid.toml b/pkg/notifications/testdata/invalid.toml new file mode 100644 index 000000000..030ffe2ec --- /dev/null +++ b/pkg/notifications/testdata/invalid.toml @@ -0,0 +1 @@ +I am an invalid toml diff --git a/pkg/notifications/testdata/nokey.toml b/pkg/notifications/testdata/nokey.toml new file mode 100644 index 000000000..b2529fbd5 --- /dev/null +++ b/pkg/notifications/testdata/nokey.toml @@ -0,0 +1,2 @@ +[somefield] +somekey = "somevalue" diff --git a/pkg/notifications/types.go b/pkg/notifications/types.go new file mode 100644 index 000000000..ce13c45f5 --- /dev/null +++ b/pkg/notifications/types.go @@ -0,0 +1,20 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package notifications + +// SupportedNotifierType data type for supported IaC provider +type supportedNotifierType string diff --git a/pkg/notifications/webhook.go b/pkg/notifications/webhook.go new file mode 100644 index 000000000..695b4a1cd --- /dev/null +++ b/pkg/notifications/webhook.go @@ -0,0 +1,35 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package notifications + +import ( + "reflect" + + webhookNotifier "github.com/accurics/terrascan/pkg/notifications/webhook" +) + +// terraform specific constants +const ( + terraform supportedNotifierType = "webhook" +) + +// register terraform as an IaC provider with terrascan +func init() { + + // register iac provider + RegisterNotifier(terraform, reflect.TypeOf(webhookNotifier.Webhook{})) +} diff --git a/pkg/notifications/webhook/types.go b/pkg/notifications/webhook/types.go new file mode 100644 index 000000000..0bfebcb60 --- /dev/null +++ b/pkg/notifications/webhook/types.go @@ -0,0 +1,23 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package webhook + +// Webhook implements the Notifier interface +type Webhook struct { + URL string + Token string +} diff --git a/pkg/notifications/webhook/webhook.go b/pkg/notifications/webhook/webhook.go new file mode 100644 index 000000000..fa59598fc --- /dev/null +++ b/pkg/notifications/webhook/webhook.go @@ -0,0 +1,75 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package webhook + +import ( + "encoding/json" + "fmt" + "net/http" + + httputils "github.com/accurics/terrascan/pkg/utils/http" + "github.com/pelletier/go-toml" + "go.uber.org/zap" +) + +var ( + errInitFailed = fmt.Errorf("failed to initialize webhook notifier") +) + +// Init initalizes the webhook notifier, reads config file and configures the +// necessary parameters for webhook notifications to work +func (w *Webhook) Init(config interface{}) error { + + // config to *toml.Tree + tomlConfig := config.(*toml.Tree) + + // initalize Webhook struct with url and token + err := tomlConfig.Unmarshal(w) + if err != nil { + zap.S().Error(errInitFailed.Error()) + return errInitFailed + } + + // succesful + zap.S().Debug("initialized webhook notifier") + return nil +} + +// SendNotification sends webhook notification i.e sends a http POST request +// to the configured URL +func (w *Webhook) SendNotification(data interface{}) error { + + // convert data to json + dataBytes, _ := json.Marshal(data) + + // make http POST request + resp, err := httputils.SendPOSTRequest(w.URL, w.Token, dataBytes) + if err != nil { + zap.S().Errorf("failed to send webhook notification. error: '%v'", err) + return err + } + + // validate http response + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { + zap.S().Errorf("failed to webhook notification. Incorrect status code: '%v'", resp.StatusCode) + return fmt.Errorf("webhook notification failed") + } + + // successful + zap.S().Debug("sent webhook notification") + return nil +} diff --git a/pkg/runtime/executor.go b/pkg/runtime/executor.go index 1f40cf967..54482321e 100644 --- a/pkg/runtime/executor.go +++ b/pkg/runtime/executor.go @@ -20,6 +20,7 @@ import ( "go.uber.org/zap" iacProvider "github.com/accurics/terrascan/pkg/iac-providers" + "github.com/accurics/terrascan/pkg/notifications" ) // Executor object @@ -29,17 +30,20 @@ type Executor struct { cloudType string iacType string iacVersion string + configFile string iacProvider iacProvider.IacProvider + notifiers []notifications.Notifier } // NewExecutor creates a runtime object -func NewExecutor(iacType, iacVersion, cloudType, filePath, dirPath string) (e *Executor, err error) { +func NewExecutor(iacType, iacVersion, cloudType, filePath, dirPath, configFile string) (e *Executor, err error) { e = &Executor{ filePath: filePath, dirPath: dirPath, cloudType: cloudType, iacType: iacType, iacVersion: iacVersion, + configFile: configFile, } // initialized executor @@ -66,23 +70,36 @@ func (e *Executor) Init() error { return err } + // create new notifiers + e.notifiers, err = notifications.NewNotifiers(e.configFile) + if err != nil { + zap.S().Errorf("failed to create notifier(s). error: '%s'", err) + return err + } + + zap.S().Debug("initialized executor") return nil } // Execute validates the inputs, processes the IaC, creates json output func (e *Executor) Execute() (normalized interface{}, err error) { + // create normalized output from Iac if e.dirPath != "" { normalized, err = e.iacProvider.LoadIacDir(e.dirPath) } else { - // create config from IaC normalized, err = e.iacProvider.LoadIacFile(e.filePath) } if err != nil { return normalized, err } - // write output + // evaluate policies + + // send notifications, if configured + if err = e.SendNotifications(normalized); err != nil { + return normalized, err + } // successful return normalized, nil diff --git a/pkg/runtime/executor_test.go b/pkg/runtime/executor_test.go index 46b5ed607..62d163754 100644 --- a/pkg/runtime/executor_test.go +++ b/pkg/runtime/executor_test.go @@ -24,6 +24,8 @@ import ( iacProvider "github.com/accurics/terrascan/pkg/iac-providers" "github.com/accurics/terrascan/pkg/iac-providers/output" tfv12 "github.com/accurics/terrascan/pkg/iac-providers/terraform/v12" + "github.com/accurics/terrascan/pkg/notifications" + "github.com/accurics/terrascan/pkg/notifications/webhook" ) var ( @@ -85,6 +87,22 @@ func TestExecute(t *testing.T) { }, wantErr: nil, }, + { + name: "test SendNofitications no error", + executor: Executor{ + iacProvider: MockIacProvider{err: nil}, + notifiers: []notifications.Notifier{&MockNotifier{err: nil}}, + }, + wantErr: nil, + }, + { + name: "test SendNofitications no error", + executor: Executor{ + iacProvider: MockIacProvider{err: nil}, + notifiers: []notifications.Notifier{&MockNotifier{err: errMockNotifier}}, + }, + wantErr: errMockNotifier, + }, } for _, tt := range table { @@ -104,6 +122,7 @@ func TestInit(t *testing.T) { executor Executor wantErr error wantIacProvider iacProvider.IacProvider + wantNotifiers []notifications.Notifier }{ { name: "valid filePath", @@ -116,16 +135,65 @@ func TestInit(t *testing.T) { }, wantErr: nil, wantIacProvider: &tfv12.TfV12{}, + wantNotifiers: []notifications.Notifier{}, + }, + { + name: "valid notifier", + executor: Executor{ + filePath: "./testdata/testfile", + dirPath: "", + cloudType: "aws", + iacType: "terraform", + iacVersion: "v12", + configFile: "./testdata/webhook.toml", + }, + wantErr: nil, + wantIacProvider: &tfv12.TfV12{}, + wantNotifiers: []notifications.Notifier{&webhook.Webhook{}}, + }, + { + name: "invalid notifier", + executor: Executor{ + filePath: "./testdata/testfile", + dirPath: "", + cloudType: "aws", + iacType: "terraform", + iacVersion: "v12", + configFile: "testdata/invalid-notifier.toml", + }, + wantErr: fmt.Errorf("notifier not supported"), + wantIacProvider: &tfv12.TfV12{}, + wantNotifiers: []notifications.Notifier{&webhook.Webhook{}}, + }, + { + name: "config not present", + executor: Executor{ + filePath: "./testdata/testfile", + dirPath: "", + cloudType: "aws", + iacType: "terraform", + iacVersion: "v12", + configFile: "./testdata/does-not-exist", + }, + wantErr: fmt.Errorf("config file not present"), + wantIacProvider: &tfv12.TfV12{}, }, } for _, tt := range table { - gotErr := tt.executor.Init() - if !reflect.DeepEqual(gotErr, tt.wantErr) { - t.Errorf("unexpected error; gotErr: '%v', wantErr: '%v'", gotErr, tt.wantErr) - } - if !reflect.DeepEqual(tt.executor.iacProvider, tt.wantIacProvider) { - t.Errorf("got: '%v', want: '%v'", tt.executor.iacProvider, tt.wantIacProvider) - } + t.Run(tt.name, func(t *testing.T) { + gotErr := tt.executor.Init() + if !reflect.DeepEqual(gotErr, tt.wantErr) { + t.Errorf("unexpected error; gotErr: '%v', wantErr: '%v'", gotErr, tt.wantErr) + } + if !reflect.DeepEqual(tt.executor.iacProvider, tt.wantIacProvider) { + t.Errorf("got: '%v', want: '%v'", tt.executor.iacProvider, tt.wantIacProvider) + } + for i, notifier := range tt.executor.notifiers { + if !reflect.DeepEqual(reflect.TypeOf(notifier), reflect.TypeOf(tt.wantNotifiers[i])) { + t.Errorf("got: '%v', want: '%v'", reflect.TypeOf(notifier), reflect.TypeOf(tt.wantNotifiers[i])) + } + } + }) } } diff --git a/pkg/runtime/notifications.go b/pkg/runtime/notifications.go new file mode 100644 index 000000000..8cda3bf50 --- /dev/null +++ b/pkg/runtime/notifications.go @@ -0,0 +1,35 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package runtime + +import ( + "github.com/accurics/terrascan/pkg/utils" +) + +// SendNotifications sends notifications via all the configured notifiers +func (e *Executor) SendNotifications(data interface{}) error { + var allErrs error + // send notifications using configured notifiers + for _, notifier := range e.notifiers { + err := notifier.SendNotification(data) + if err != nil { + allErrs = utils.WrapError(err, allErrs) + continue + } + } + return allErrs +} diff --git a/pkg/runtime/notifications_test.go b/pkg/runtime/notifications_test.go new file mode 100644 index 000000000..584ed2c15 --- /dev/null +++ b/pkg/runtime/notifications_test.go @@ -0,0 +1,59 @@ +package runtime + +import ( + "fmt" + "reflect" + "testing" + + "github.com/accurics/terrascan/pkg/notifications" +) + +// MockNotifier mocks notifications.Notifier interface +type MockNotifier struct { + err error +} + +var ( + errMockNotifier = fmt.Errorf("mock notification error") +) + +func (m MockNotifier) Init(config interface{}) error { + return m.err +} + +func (m MockNotifier) SendNotification(config interface{}) error { + return m.err +} + +func TestSendNotifications(t *testing.T) { + + table := []struct { + name string + executor Executor + wantErr error + }{ + { + name: "no notifier error", + executor: Executor{ + notifiers: []notifications.Notifier{&MockNotifier{err: nil}}, + }, + wantErr: nil, + }, + { + name: "no notifier error", + executor: Executor{ + notifiers: []notifications.Notifier{&MockNotifier{err: errMockNotifier}}, + }, + wantErr: errMockNotifier, + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + gotErr := tt.executor.SendNotifications("some data") + if !reflect.DeepEqual(gotErr, tt.wantErr) { + t.Errorf("incorrect error; got: '%v', want: '%v'", gotErr, tt.wantErr) + } + }) + } +} diff --git a/pkg/runtime/testdata/invalid-notifier.toml b/pkg/runtime/testdata/invalid-notifier.toml new file mode 100644 index 000000000..74c795e09 --- /dev/null +++ b/pkg/runtime/testdata/invalid-notifier.toml @@ -0,0 +1,6 @@ +# terrascan configuration file + +# notifications configuration +[notifications] + [notifications.invalid] + url = "https://httpbin.org/post" diff --git a/pkg/runtime/testdata/webhook.toml b/pkg/runtime/testdata/webhook.toml new file mode 100644 index 000000000..d1af36565 --- /dev/null +++ b/pkg/runtime/testdata/webhook.toml @@ -0,0 +1,6 @@ +# terrascan configuration file + +# notifications configuration +[notifications] + [notifications.webhook] + url = "https://httpbin.org/post" diff --git a/pkg/utils/http/request.go b/pkg/utils/http/request.go new file mode 100644 index 000000000..7127ea171 --- /dev/null +++ b/pkg/utils/http/request.go @@ -0,0 +1,57 @@ +package httputils + +import ( + "bytes" + "fmt" + "net/http" + + "github.com/hashicorp/go-retryablehttp" + "go.uber.org/zap" +) + +var ( + errNewRequest = fmt.Errorf("failed to create http request") + errDoRequest = fmt.Errorf("failed to make http request") +) + +// default global http client +var client *http.Client + +// init creates a http client which retries on errors like connection timeouts, +// server too slow respond etc. +func init() { + retryClient := retryablehttp.NewClient() + retryClient.RetryMax = 10 + client = retryClient.StandardClient() +} + +// SendRequest sends a http request on the given url +func SendRequest(method, url, token string, data []byte) (*http.Response, error) { + + var resp *http.Response + + // new http request + req, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + if err != nil { + zap.S().Errorf("failed to create http request; method: '%v', url: '%v'") + return resp, errNewRequest + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer: '%s'", token)) + } + + // make request + resp, err = client.Do(req) + if err != nil { + zap.S().Errorf("failed to make http request; method: '%v', url: '%v'") + return resp, errDoRequest + } + + return resp, err +} + +// SendPOSTRequest sends a http POST request +func SendPOSTRequest(url, token string, data []byte) (*http.Response, error) { + return SendRequest("POST", url, token, data) +} diff --git a/pkg/utils/wrap_errors.go b/pkg/utils/wrap_errors.go new file mode 100644 index 000000000..c1f85ac4b --- /dev/null +++ b/pkg/utils/wrap_errors.go @@ -0,0 +1,38 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package utils + +import ( + "github.com/pkg/errors" +) + +// WrapError wraps given err with allErrs and returns a unified error +func WrapError(err, allErrs error) error { + // if allErrs is empty, return err + if allErrs == nil { + return err + } + + // if err empty return allErrs + if err == nil { + return allErrs + } + + // wrap err with allErrs + allErrs = errors.Wrap(err, allErrs.Error()) + return allErrs +} diff --git a/pkg/utils/wrap_errors_test.go b/pkg/utils/wrap_errors_test.go new file mode 100644 index 000000000..f5235c08a --- /dev/null +++ b/pkg/utils/wrap_errors_test.go @@ -0,0 +1,53 @@ +package utils + +import ( + "fmt" + "reflect" + "testing" +) + +func TestWrapError(t *testing.T) { + + mockErr := fmt.Errorf("mock error") + + table := []struct { + name string + err error + allErr error + wantErr error + }{ + { + name: "empty allErrs", + allErr: nil, + err: mockErr, + wantErr: mockErr, + }, + { + name: "empty err", + err: nil, + allErr: mockErr, + wantErr: mockErr, + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + gotErr := WrapError(tt.err, tt.allErr) + if !reflect.DeepEqual(gotErr, tt.wantErr) { + t.Errorf("incorrect error; got: '%+v', want: '%+v'", gotErr, tt.wantErr) + } + }) + } + + t.Run("wrapped error", func(t *testing.T) { + var ( + err = fmt.Errorf("mock err") + allErrs = fmt.Errorf("mock allErrs") + wantErr = fmt.Errorf("%s: %s", allErrs.Error(), err.Error()) + ) + gotErr := WrapError(err, allErrs) + if gotErr.Error() != wantErr.Error() { + t.Errorf("incorrect error: got: '%v', want: '%v'", gotErr, wantErr) + } + }) +}