From 4d5648cce2430b670167d3d66c3d8aee860b5f39 Mon Sep 17 00:00:00 2001 From: Darren <75614232+dmurray-lacework@users.noreply.github.com> Date: Thu, 4 Nov 2021 16:04:35 +0000 Subject: [PATCH] feat(cli): Manage Alert Rules in Lacework CLI (#597) --- api/alert_rules.go | 7 + cli/cmd/alert_rules.go | 372 ++++++++++++++++++++++++++++++++ integration/alert_rules_test.go | 112 ++++++++++ integration/help_test.go | 1 + 4 files changed, 492 insertions(+) create mode 100644 cli/cmd/alert_rules.go create mode 100644 integration/alert_rules_test.go diff --git a/api/alert_rules.go b/api/alert_rules.go index f384f39af..fd65dc799 100644 --- a/api/alert_rules.go +++ b/api/alert_rules.go @@ -169,6 +169,13 @@ func NewAlertRule(name string, rule AlertRuleConfig) AlertRule { } } +func (rule AlertRuleFilter) Status() string { + if rule.Enabled == 1 { + return "Enabled" + } + return "Disabled" +} + // List returns a list of Alert Rules func (svc *AlertRulesService) List() (response AlertRulesResponse, err error) { err = svc.client.RequestDecoder("GET", apiV2AlertRules, nil, &response) diff --git a/cli/cmd/alert_rules.go b/cli/cmd/alert_rules.go new file mode 100644 index 000000000..21010cc2e --- /dev/null +++ b/cli/cmd/alert_rules.go @@ -0,0 +1,372 @@ +// +// Author:: Darren Murray() +// Copyright:: Copyright 2021, Lacework Inc. +// License:: Apache License, Version 2.0 +// +// 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 cmd + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/olekukonko/tablewriter" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/lacework/go-sdk/api" +) + +var ( + // alert-rules command is used to manage lacework alert rules + alertRulesCommand = &cobra.Command{ + Use: "alert-rule", + Aliases: []string{"alert-rules", "ar"}, + Short: "manage alert rules", + Long: `Manage alert rules to route events to the appropriate people or tools. +An alert rule has three parts: + 1. Alert channel(s) that should receive the event notification + 2. Event severity and categories to include + 3. Resource group(s) containing the subset of your environment to consider +`, + } + + // list command is used to list all lacework alert rules + alertRulesListCommand = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "list all alert rules", + Long: "List all alert rules configured in your Lacework account.", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + alertRules, err := cli.LwApi.V2.AlertRules.List() + if err != nil { + return errors.Wrap(err, "unable to get alert rules") + } + if len(alertRules.Data) == 0 { + msg := `There are no alert rules configured in your account. + +Get started by integrating your alert rules to manage alerting using the command: + + lacework alert-rule create + +If you prefer to configure alert rules via the WebUI, log in to your account at: + + https://%s.lacework.net + +Then navigate to Settings > Alert Rules. +` + cli.OutputHuman(fmt.Sprintf(msg, cli.Account)) + return nil + } + if cli.JSONOutput() { + return cli.OutputJSON(alertRules) + } + + var rows [][]string + for _, rule := range alertRules.Data { + rows = append(rows, []string{rule.Guid, rule.Filter.Name, rule.Filter.Status()}) + } + + cli.OutputHuman(renderSimpleTable([]string{"GUID", "NAME", "ENABLED"}, rows)) + return nil + }, + } + // show command is used to retrieve a lacework alert rule by resource id + alertRulesShowCommand = &cobra.Command{ + Use: "show", + Short: "show an alert rule by id", + Long: "Show a single alert rule by it's ID.", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + var response api.AlertRuleResponse + err := cli.LwApi.V2.AlertRules.Get(args[0], &response) + if err != nil { + return errors.Wrap(err, "unable to get alert rule") + } + + if cli.JSONOutput() { + return cli.OutputJSON(response) + } + + alertRule := response.Data + var headers [][]string + headers = append(headers, []string{alertRule.Guid, alertRule.Filter.Name, alertRule.Filter.Status()}) + + cli.OutputHuman(renderSimpleTable([]string{"GUID", "NAME", "ENABLED"}, headers)) + cli.OutputHuman("\n") + cli.OutputHuman(buildAlertRuleDetailsTable(alertRule)) + + return nil + }, + } + + // delete command is used to remove a lacework alert rule by resource id + alertRulesDeleteCommand = &cobra.Command{ + Use: "delete", + Short: "delete a alert rule", + Long: "Delete a single alert rule by it's ID.", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + err := cli.LwApi.V2.AlertRules.Delete(args[0]) + if err != nil { + return errors.Wrap(err, "unable to delete alert rule") + } + cli.OutputHuman(fmt.Sprintf("The alert rule with GUID %s was deleted \n", args[0])) + return nil + }, + } + + // create command is used to create a new lacework alert rule + alertRulesCreateCommand = &cobra.Command{ + Use: "create", + Short: "create a new alert rule", + Long: "Creates a new single alert rule.", + RunE: func(_ *cobra.Command, args []string) error { + if !cli.InteractiveMode() { + return errors.New("interactive mode is disabled") + } + + response, err := promptCreateAlertRule() + if err != nil { + return errors.Wrap(err, "unable to create alert rule") + } + + cli.OutputHuman(fmt.Sprintf("The alert rule was created with GUID %s \n", response.Data.Guid)) + return nil + }, + } +) + +func init() { + // add the alert-rule command + rootCmd.AddCommand(alertRulesCommand) + + // add sub-commands to the alert-rule command + alertRulesCommand.AddCommand(alertRulesListCommand) + alertRulesCommand.AddCommand(alertRulesShowCommand) + alertRulesCommand.AddCommand(alertRulesCreateCommand) + alertRulesCommand.AddCommand(alertRulesDeleteCommand) +} + +func buildAlertRuleDetailsTable(rule api.AlertRule) string { + var ( + details [][]string + updatedTime string + ) + severities := api.NewAlertRuleSeveritiesFromIntSlice(rule.Filter.Severity).ToStringSlice() + + if nano, err := strconv.ParseInt(rule.Filter.CreatedOrUpdatedTime, 10, 64); err == nil { + updatedTime = time.Unix(nano/1000, 0).Format(time.RFC3339) + } + details = append(details, []string{"SEVERITIES", strings.Join(severities, ", ")}) + details = append(details, []string{"EVENT CATEGORIES", strings.Join(rule.Filter.EventCategories, ", ")}) + details = append(details, []string{"DESCRIPTION", rule.Filter.Description}) + details = append(details, []string{"UPDATED BY", rule.Filter.CreatedOrUpdatedBy}) + details = append(details, []string{"LAST UPDATED", updatedTime}) + + detailsTable := &strings.Builder{} + detailsTable.WriteString(renderOneLineCustomTable("ALERT RULE DETAILS", + renderCustomTable([]string{}, details, + tableFunc(func(t *tablewriter.Table) { + t.SetBorder(false) + t.SetColumnSeparator(" ") + t.SetAutoWrapText(false) + t.SetAlignment(tablewriter.ALIGN_LEFT) + }), + ), + tableFunc(func(t *tablewriter.Table) { + t.SetBorder(false) + t.SetAutoWrapText(false) + }), + ), + ) + + if len(rule.Channels) > 0 { + channels := [][]string{{strings.Join(rule.Channels, "\n")}} + detailsTable.WriteString(renderCustomTable([]string{"ALERT CHANNELS"}, channels, + tableFunc(func(t *tablewriter.Table) { + t.SetBorder(false) + t.SetColumnSeparator(" ") + }), + ), + ) + detailsTable.WriteString("\n") + } + + if len(rule.Filter.ResourceGroups) > 0 { + resourceGroups := [][]string{{strings.Join(rule.Filter.ResourceGroups, "\n")}} + detailsTable.WriteString(renderCustomTable([]string{"RESOURCE GROUPS"}, resourceGroups, + tableFunc(func(t *tablewriter.Table) { + t.SetBorder(false) + t.SetColumnSeparator(" ") + }), + ), + ) + } + + return detailsTable.String() +} + +func promptCreateAlertRule() (api.AlertRuleResponse, error) { + channelList, channelMap := getAlertChannels() + + if len(channelList) < 1 { + return api.AlertRuleResponse{}, errors.New("no Alert Channels found.") + } + + questions := []*survey.Question{ + { + Name: "name", + Prompt: &survey.Input{Message: "Name: "}, + Validate: survey.Required, + }, + { + Name: "description", + Prompt: &survey.Input{Message: "Description: "}, + Validate: survey.Required, + }, + { + Name: "channels", + Prompt: &survey.MultiSelect{ + Message: "Select alert channels:", + Options: channelList, + }, + Validate: survey.Required, + }, + { + Name: "severities", + Prompt: &survey.MultiSelect{ + Message: "Select severities:", + Options: []string{"Critical", "High", "Medium", "Low", "Info"}, + }, + }, + { + Name: "eventCategories", + Prompt: &survey.MultiSelect{ + Message: "Select event categories:", + Options: []string{"Compliance", "App", "Cloud", "File", "Machine", "User", "Platform"}, + }, + }, + } + + answers := struct { + Name string + Description string `survey:"description"` + Channels []string `survey:"channels"` + Severities []string `survey:"severities"` + EventCategories []string `survey:"eventCategories"` + ResourceGroups []string `survey:"resourceGroups"` + }{} + + err := survey.Ask(questions, &answers, + survey.WithIcons(promptIconsFunc), + ) + if err != nil { + return api.AlertRuleResponse{}, err + } + + var channels []string + for _, channel := range answers.Channels { + channels = append(channels, channelMap[channel]) + } + + resourceGroups, resourceGroupMap := promptAddResourceGroupsToAlertRule() + var groups []string + for _, group := range resourceGroups { + groups = append(groups, resourceGroupMap[group]) + } + + alertRule := api.NewAlertRule( + answers.Name, + api.AlertRuleConfig{ + Description: answers.Description, + Channels: channels, + Severities: api.NewAlertRuleSeverities(answers.Severities), + EventCategories: answers.EventCategories, + ResourceGroups: groups, + }) + + cli.StartProgress(" Creating alert rule...") + response, err := cli.LwApi.V2.AlertRules.Create(alertRule) + + cli.StopProgress() + return response, err +} + +func getAlertChannels() ([]string, map[string]string) { + response, err := cli.LwApi.V2.AlertChannels.List() + + if err != nil { + return nil, nil + } + var items = make(map[string]string) + var channels = make([]string, 0) + for _, i := range response.Data { + displayName := fmt.Sprintf("%s - %s", i.ID(), i.Name) + channels = append(channels, displayName) + items[displayName] = i.ID() + } + + return channels, items +} + +func getResourceGroups() ([]string, map[string]string) { + response, err := cli.LwApi.V2.ResourceGroups.List() + + if err != nil { + return nil, nil + } + var items = make(map[string]string) + var groups = make([]string, 0) + + for _, i := range response.Data { + displayName := fmt.Sprintf("%s - %s", i.ID(), i.Name) + groups = append(groups, displayName) + items[displayName] = i.ID() + } + + return groups, items +} + +func promptAddResourceGroupsToAlertRule() ([]string, map[string]string) { + addResourceGroups := false + err := survey.AskOne(&survey.Confirm{ + Message: "Add Resource Groups to Alert Rule?", + }, &addResourceGroups) + + if err != nil { + return nil, nil + } + + if addResourceGroups { + var groups []string + groupList, groupMap := getResourceGroups() + + err = survey.AskOne(&survey.MultiSelect{ + Message: "Select Resource Groups:", + Options: groupList, + }, &groups) + + if err != nil { + return nil, nil + } + return groups, groupMap + } + return nil, nil +} diff --git a/integration/alert_rules_test.go b/integration/alert_rules_test.go new file mode 100644 index 000000000..93584b327 --- /dev/null +++ b/integration/alert_rules_test.go @@ -0,0 +1,112 @@ +// Author:: Darren Murray () +// Copyright:: Copyright 2021, Lacework Inc. +// License:: Apache License, Version 2.0 +// +// 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 integration + +import ( + "encoding/json" + "log" + "os" + "testing" + + "github.com/lacework/go-sdk/api" + "github.com/stretchr/testify/assert" +) + +func TestAlertRuleRead(t *testing.T) { + alertRule, createErr := createAlertRuleWithSlackAlertChannel() + if createErr != nil { + log.Fatal(createErr) + } + defer LaceworkCLIWithTOMLConfig("alert-rules", "delete", alertRule.Data.Guid) + + out, err, exitcode := LaceworkCLIWithTOMLConfig("alert-rules", "show", alertRule.Data.Guid) + + expectedHeaders := []string{ + "GUID", + "NAME", + "DESCRIPTION", + "ENABLED", + "CHANNELS", + "SEVERITIES", + "EVENT CATEGORIES", + } + + expectedFields := []string{ + "Alert Rule Test", + "This is a test Alert Rule", + "Critical, High", + "Compliance", + } + + t.Run("verify table headers", func(t *testing.T) { + for _, header := range expectedHeaders { + assert.Contains(t, out.String(), header, "STDOUT table headers changed, please check") + } + }) + + t.Run("verify table fields", func(t *testing.T) { + for _, field := range expectedFields { + assert.Contains(t, out.String(), field, "STDOUT table fields changed, please check") + } + }) + assert.Empty(t, err.String(), "STDERR should be empty") + assert.Equal(t, 0, exitcode, "EXITCODE is not the expected one") +} + +func TestAlertRulesJsonOutput(t *testing.T) { + out, err, exitcode := LaceworkCLIWithTOMLConfig("alert-rules", "list", "--json") + + var rule api.AlertRulesResponse + jsonErr := json.Unmarshal(out.Bytes(), &rule) + + assert.NoError(t, jsonErr) + assert.NotNil(t, rule.Data[0].Filter.Name) + assert.Empty(t, err.String(), "STDERR should be empty") + assert.Equal(t, 0, exitcode, "EXITCODE is not the expected one") +} + +func createAlertRuleWithSlackAlertChannel() (alertRule api.AlertRuleResponse, err error) { + var slackChannel string + lacework, err := api.NewClient(os.Getenv("CI_V2_ACCOUNT"), + api.WithSubaccount(os.Getenv("CI_ACCOUNT")), + api.WithApiKeys(os.Getenv("CI_API_KEY"), os.Getenv("CI_API_SECRET")), + api.WithApiV2(), + ) + if err != nil { + return + } + channels, err := lacework.V2.AlertChannels.List() + + if err != nil { + return + } + + for _, channel := range channels.Data { + if channel.Name == "#tech-ally-notify" { + slackChannel = channel.ID() + } + } + + rule := api.NewAlertRule("Alert Rule Test", api.AlertRuleConfig{ + Channels: []string{slackChannel}, + Description: "This is a test Alert Rule", + Severities: api.NewAlertRuleSeverities([]string{"Critical", "High"}), + EventCategories: []string{"Compliance"}, + }) + + return lacework.V2.AlertRules.Create(rule) +} diff --git a/integration/help_test.go b/integration/help_test.go index f533a6637..3cc4b525b 100644 --- a/integration/help_test.go +++ b/integration/help_test.go @@ -148,6 +148,7 @@ Available Commands: access-token generate temporary API access tokens account manage accounts in an organization (org admins only) agent manage Lacework agents + alert-rule manage alert rules api helper to call Lacework's API compliance manage compliance reports configure configure the Lacework CLI