Implement reinject command to send notifications of alerts (#1638)

* implement reinject command to send notifications of alerts using a profile

Co-authored-by: sabban <15465465+sabban@users.noreply.github.com>
This commit is contained in:
Manuel Sabban 2022-08-30 15:45:52 +02:00 committed by GitHub
parent 21255b6391
commit 7d0f89df29
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 200 additions and 29 deletions

View file

@ -1,25 +1,35 @@
package main package main
import ( import (
"context"
"encoding/csv" "encoding/csv"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time"
"github.com/crowdsecurity/crowdsec/pkg/apiclient"
"github.com/crowdsecurity/crowdsec/pkg/csconfig" "github.com/crowdsecurity/crowdsec/pkg/csconfig"
"github.com/crowdsecurity/crowdsec/pkg/csplugin" "github.com/crowdsecurity/crowdsec/pkg/csplugin"
"github.com/crowdsecurity/crowdsec/pkg/csprofiles"
"github.com/crowdsecurity/crowdsec/pkg/cwversion"
"github.com/go-openapi/strfmt"
"github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gopkg.in/tomb.v2"
) )
type NotificationsCfg struct { type NotificationsCfg struct {
Config csplugin.PluginConfig `json:"plugin_config"` Config csplugin.PluginConfig `json:"plugin_config"`
Profiles []*csconfig.ProfileCfg `json:"associated_profiles"` Profiles []*csconfig.ProfileCfg `json:"associated_profiles"`
ids []uint
} }
func NewNotificationsCmd() *cobra.Command { func NewNotificationsCmd() *cobra.Command {
@ -50,8 +60,12 @@ func NewNotificationsCmd() *cobra.Command {
Example: `cscli notifications list`, Example: `cscli notifications list`,
Args: cobra.ExactArgs(0), Args: cobra.ExactArgs(0),
DisableAutoGenTag: true, DisableAutoGenTag: true,
Run: func(cmd *cobra.Command, arg []string) { RunE: func(cmd *cobra.Command, arg []string) error {
ncfgs := getNotificationsConfiguration() ncfgs, err := getNotificationsConfiguration()
if err != nil {
return errors.Wrap(err, "Can't build profiles configuration")
}
if csConfig.Cscli.Output == "human" { if csConfig.Cscli.Output == "human" {
table := tablewriter.NewWriter(os.Stdout) table := tablewriter.NewWriter(os.Stdout)
table.SetCenterSeparator("") table.SetCenterSeparator("")
@ -72,14 +86,14 @@ func NewNotificationsCmd() *cobra.Command {
} else if csConfig.Cscli.Output == "json" { } else if csConfig.Cscli.Output == "json" {
x, err := json.MarshalIndent(ncfgs, "", " ") x, err := json.MarshalIndent(ncfgs, "", " ")
if err != nil { if err != nil {
log.Fatalf("failed to marshal notification configuration") return errors.New("failed to marshal notification configuration")
} }
fmt.Printf("%s", string(x)) fmt.Printf("%s", string(x))
} else if csConfig.Cscli.Output == "raw" { } else if csConfig.Cscli.Output == "raw" {
csvwriter := csv.NewWriter(os.Stdout) csvwriter := csv.NewWriter(os.Stdout)
err := csvwriter.Write([]string{"Name", "Type", "Profile name"}) err := csvwriter.Write([]string{"Name", "Type", "Profile name"})
if err != nil { if err != nil {
log.Fatalf("failed to write raw header: %s", err) return errors.Wrap(err, "failed to write raw header")
} }
for _, b := range ncfgs { for _, b := range ncfgs {
profilesList := []string{} profilesList := []string{}
@ -88,11 +102,12 @@ func NewNotificationsCmd() *cobra.Command {
} }
err := csvwriter.Write([]string{b.Config.Name, b.Config.Type, strings.Join(profilesList, ", ")}) err := csvwriter.Write([]string{b.Config.Name, b.Config.Type, strings.Join(profilesList, ", ")})
if err != nil { if err != nil {
log.Fatalf("failed to write raw content: %s", err) return errors.Wrap(err, "failed to write raw content")
} }
} }
csvwriter.Flush() csvwriter.Flush()
} }
return nil
}, },
} }
cmdNotifications.AddCommand(cmdNotificationsList) cmdNotifications.AddCommand(cmdNotificationsList)
@ -104,7 +119,7 @@ func NewNotificationsCmd() *cobra.Command {
Example: `cscli notifications inspect <plugin_name>`, Example: `cscli notifications inspect <plugin_name>`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
DisableAutoGenTag: true, DisableAutoGenTag: true,
Run: func(cmd *cobra.Command, arg []string) { RunE: func(cmd *cobra.Command, arg []string) error {
var ( var (
cfg NotificationsCfg cfg NotificationsCfg
ok bool ok bool
@ -113,11 +128,14 @@ func NewNotificationsCmd() *cobra.Command {
pluginName := arg[0] pluginName := arg[0]
if pluginName == "" { if pluginName == "" {
log.Fatalf("Please provide a plugin name to inspect") errors.New("Please provide a plugin name to inspect")
}
ncfgs, err := getNotificationsConfiguration()
if err != nil {
return errors.Wrap(err, "Can't build profiles configuration")
} }
ncfgs := getNotificationsConfiguration()
if cfg, ok = ncfgs[pluginName]; !ok { if cfg, ok = ncfgs[pluginName]; !ok {
log.Fatalf("The provided plugin name doesn't exist or isn't active") return errors.New("The provided plugin name doesn't exist or isn't active")
} }
if csConfig.Cscli.Output == "human" || csConfig.Cscli.Output == "raw" { if csConfig.Cscli.Output == "human" || csConfig.Cscli.Output == "raw" {
@ -131,17 +149,140 @@ func NewNotificationsCmd() *cobra.Command {
} else if csConfig.Cscli.Output == "json" { } else if csConfig.Cscli.Output == "json" {
x, err := json.MarshalIndent(cfg, "", " ") x, err := json.MarshalIndent(cfg, "", " ")
if err != nil { if err != nil {
log.Fatalf("failed to marshal notification configuration") return errors.New("failed to marshal notification configuration")
} }
fmt.Printf("%s", string(x)) fmt.Printf("%s", string(x))
} }
return nil
}, },
} }
cmdNotifications.AddCommand(cmdNotificationsInspect) cmdNotifications.AddCommand(cmdNotificationsInspect)
var remediation bool
var alertOverride string
var cmdNotificationsReinject = &cobra.Command{
Use: "reinject",
Short: "reinject alert into notifications system",
Long: `Reinject alert into notifications system`,
Example: `
cscli notifications reinject <alert_id>
cscli notifications reinject <alert_id> --remediation
cscli notifications reinject <alert_id> -a '{"remediation": true,"scenario":"notification/test"}'
`,
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
var (
pluginBroker csplugin.PluginBroker
pluginTomb tomb.Tomb
)
if len(args) != 1 {
printHelp(cmd)
return errors.New("Wrong number of argument: there should be one argument")
}
//first: get the alert
id, err := strconv.Atoi(args[0])
if err != nil {
return errors.New(fmt.Sprintf("bad alert id %s", args[0]))
}
if err := csConfig.LoadAPIClient(); err != nil {
return errors.Wrapf(err, "loading api client")
}
if csConfig.API.Client == nil {
return errors.New("There is no configuration on 'api_client:'")
}
if csConfig.API.Client.Credentials == nil {
return errors.New(fmt.Sprintf("Please provide credentials for the API in '%s'", csConfig.API.Client.CredentialsFilePath))
}
apiURL, err := url.Parse(csConfig.API.Client.Credentials.URL)
if err != nil {
return errors.Wrapf(err, "error parsing the URL of the API")
}
client, err := apiclient.NewClient(&apiclient.Config{
MachineID: csConfig.API.Client.Credentials.Login,
Password: strfmt.Password(csConfig.API.Client.Credentials.Password),
UserAgent: fmt.Sprintf("crowdsec/%s", cwversion.VersionStr()),
URL: apiURL,
VersionPrefix: "v1",
})
if err != nil {
return errors.Wrapf(err, "error creating the client for the API")
}
alert, _, err := client.Alerts.GetByID(context.Background(), id)
if err != nil {
return errors.Wrapf(err, fmt.Sprintf("can't find alert with id %s", args[0]))
}
if alertOverride != "" {
if err = json.Unmarshal([]byte(alertOverride), alert); err != nil {
return errors.Wrapf(err, "Can't unmarshal the data given in the alert flag")
}
}
if !remediation {
alert.Remediation = true
}
// second we start plugins
err = pluginBroker.Init(csConfig.PluginConfig, csConfig.API.Server.Profiles, csConfig.ConfigPaths)
if err != nil {
return errors.Wrapf(err, "Can't initialize plugins")
}
pluginTomb.Go(func() error {
pluginBroker.Run(&pluginTomb)
return nil
})
//third: get the profile(s), and process the whole stuff
profiles, err := csprofiles.NewProfile(csConfig.API.Server.Profiles)
if err != nil {
return errors.Wrap(err, "Cannot extract profiles from configuration")
}
for id, profile := range profiles {
_, matched, err := profile.EvaluateProfile(alert)
if err != nil {
return errors.Wrapf(err, "can't evaluate profile %s", profile.Cfg.Name)
}
if !matched {
log.Infof("The profile %s didn't match", profile.Cfg.Name)
continue
}
log.Infof("The profile %s matched, sending to its configured notification plugins", profile.Cfg.Name)
loop:
for {
select {
case pluginBroker.PluginChannel <- csplugin.ProfileAlert{
ProfileID: uint(id),
Alert: alert,
}:
break loop
default:
time.Sleep(50 * time.Millisecond)
log.Info("sleeping\n")
}
}
if profile.Cfg.OnSuccess == "break" {
log.Infof("The profile %s contains a 'on_success: break' so bailing out", profile.Cfg.Name)
break
}
}
// time.Sleep(2 * time.Second) // There's no mechanism to ensure notification has been sent
pluginTomb.Kill(errors.New("terminating"))
pluginTomb.Wait()
return nil
},
}
cmdNotificationsReinject.Flags().BoolVarP(&remediation, "remediation", "r", false, "Set Alert.Remediation to false in the reinjected alert (see your profile filter configuration)")
cmdNotificationsReinject.Flags().StringVarP(&alertOverride, "alert", "a", "", "JSON string used to override alert fields in the reinjected alert (see crowdsec/pkg/models/alert.go in the source tree for the full definition of the object)")
cmdNotifications.AddCommand(cmdNotificationsReinject)
return cmdNotifications return cmdNotifications
} }
func getNotificationsConfiguration() map[string]NotificationsCfg { func getNotificationsConfiguration() (map[string]NotificationsCfg, error) {
pcfgs := map[string]csplugin.PluginConfig{} pcfgs := map[string]csplugin.PluginConfig{}
wf := func(path string, info fs.FileInfo, err error) error { wf := func(path string, info fs.FileInfo, err error) error {
if info == nil { if info == nil {
@ -161,38 +302,45 @@ func getNotificationsConfiguration() map[string]NotificationsCfg {
} }
if err := filepath.Walk(csConfig.ConfigPaths.NotificationDir, wf); err != nil { if err := filepath.Walk(csConfig.ConfigPaths.NotificationDir, wf); err != nil {
log.Fatalf("Loading notifification plugin configuration: %s", err) return nil, errors.Wrap(err, "Loading notifification plugin configuration")
} }
// A bit of a tricky stuf now: reconcile profiles and notification plugins // A bit of a tricky stuf now: reconcile profiles and notification plugins
ncfgs := map[string]NotificationsCfg{} ncfgs := map[string]NotificationsCfg{}
for _, profile := range csConfig.API.Server.Profiles { profiles, err := csprofiles.NewProfile(csConfig.API.Server.Profiles)
if err != nil {
return nil, errors.Wrap(err, "Cannot extract profiles from configuration")
}
for profileID, profile := range profiles {
loop: loop:
for _, notif := range profile.Notifications { for _, notif := range profile.Cfg.Notifications {
for name, pc := range pcfgs { for name, pc := range pcfgs {
if notif == name { if notif == name {
if _, ok := ncfgs[pc.Name]; !ok { if _, ok := ncfgs[pc.Name]; !ok {
ncfgs[pc.Name] = NotificationsCfg{ ncfgs[pc.Name] = NotificationsCfg{
Config: pc, Config: pc,
Profiles: []*csconfig.ProfileCfg{profile}, Profiles: []*csconfig.ProfileCfg{profile.Cfg},
ids: []uint{uint(profileID)},
} }
continue loop continue loop
} }
tmp := ncfgs[pc.Name] tmp := ncfgs[pc.Name]
for _, pr := range tmp.Profiles { for _, pr := range tmp.Profiles {
var profiles []*csconfig.ProfileCfg var profiles []*csconfig.ProfileCfg
if pr.Name == profile.Name { if pr.Name == profile.Cfg.Name {
continue continue
} }
profiles = append(tmp.Profiles, profile) profiles = append(tmp.Profiles, profile.Cfg)
ids := append(tmp.ids, uint(profileID))
ncfgs[pc.Name] = NotificationsCfg{ ncfgs[pc.Name] = NotificationsCfg{
Config: tmp.Config, Config: tmp.Config,
Profiles: profiles, Profiles: profiles,
ids: ids,
} }
} }
} }
} }
} }
} }
return ncfgs return ncfgs, nil
} }

View file

@ -96,9 +96,10 @@ func (pb *PluginBroker) Kill() {
} }
} }
func (pb *PluginBroker) Run(tomb *tomb.Tomb) { func (pb *PluginBroker) Run(pluginTomb *tomb.Tomb) {
//we get signaled via the channel when notifications need to be delivered to plugin (via the watcher) //we get signaled via the channel when notifications need to be delivered to plugin (via the watcher)
pb.watcher.Start(tomb) pb.watcher.Start(&tomb.Tomb{})
loop:
for { for {
select { select {
case profileAlert := <-pb.PluginChannel: case profileAlert := <-pb.PluginChannel:
@ -117,13 +118,32 @@ func (pb *PluginBroker) Run(tomb *tomb.Tomb) {
} }
}() }()
case <-tomb.Dying(): case <-pluginTomb.Dying():
log.Info("killing all plugins") log.Infof("plugingTomb dying")
pb.Kill() pb.watcher.tomb.Kill(errors.New("Terminating"))
return for {
select {
case <-pb.watcher.tomb.Dead():
log.Info("killing all plugins")
pb.Kill()
break loop
case pluginName := <-pb.watcher.PluginEvents:
// this can be ran in goroutine, but then locks will be needed
pluginMutex.Lock()
log.Tracef("going to deliver %d alerts to plugin %s", len(pb.alertsByPluginName[pluginName]), pluginName)
tmpAlerts := pb.alertsByPluginName[pluginName]
pb.alertsByPluginName[pluginName] = make([]*models.Alert, 0)
pluginMutex.Unlock()
if err := pb.pushNotificationsToPlugin(pluginName, tmpAlerts); err != nil {
log.WithField("plugin:", pluginName).Error(err)
}
}
}
} }
} }
} }
func (pb *PluginBroker) addProfileAlert(profileAlert ProfileAlert) { func (pb *PluginBroker) addProfileAlert(profileAlert ProfileAlert) {
for _, pluginName := range pb.profileConfigs[profileAlert.ProfileID].Notifications { for _, pluginName := range pb.profileConfigs[profileAlert.ProfileID].Notifications {
if _, ok := pb.pluginConfigByName[pluginName]; !ok { if _, ok := pb.pluginConfigByName[pluginName]; !ok {

View file

@ -139,6 +139,9 @@ func (pw *PluginWatcher) watchPluginTicker(pluginName string) {
} }
case <-pw.tomb.Dying(): case <-pw.tomb.Dying():
ticker.Stop() ticker.Stop()
// emptying
// no lock here because we have the broker still listening even in dying state before killing us
pw.PluginEvents <- pluginName
return return
} }
} }

View file

@ -14,8 +14,9 @@ import (
var ctx = context.Background() var ctx = context.Background()
func resetTestTomb(testTomb *tomb.Tomb) { func resetTestTomb(testTomb *tomb.Tomb, pw *PluginWatcher) {
testTomb.Kill(nil) testTomb.Kill(nil)
<-pw.PluginEvents
if err := testTomb.Wait(); err != nil { if err := testTomb.Wait(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -64,8 +65,7 @@ func TestPluginWatcherInterval(t *testing.T) {
defer cancel() defer cancel()
err := listenChannelWithTimeout(ct, pw.PluginEvents) err := listenChannelWithTimeout(ct, pw.PluginEvents)
assert.ErrorContains(t, err, "context deadline exceeded") assert.ErrorContains(t, err, "context deadline exceeded")
resetTestTomb(&testTomb, &pw)
resetTestTomb(&testTomb)
testTomb = tomb.Tomb{} testTomb = tomb.Tomb{}
pw.Start(&testTomb) pw.Start(&testTomb)
@ -73,7 +73,7 @@ func TestPluginWatcherInterval(t *testing.T) {
defer cancel() defer cancel()
err = listenChannelWithTimeout(ct, pw.PluginEvents) err = listenChannelWithTimeout(ct, pw.PluginEvents)
assert.NilError(t, err) assert.NilError(t, err)
resetTestTomb(&testTomb) resetTestTomb(&testTomb, &pw)
// This is to avoid the int complaining // This is to avoid the int complaining
} }
@ -113,5 +113,5 @@ func TestPluginAlertCountWatcher(t *testing.T) {
defer cancel() defer cancel()
err = listenChannelWithTimeout(ct, pw.PluginEvents) err = listenChannelWithTimeout(ct, pw.PluginEvents)
assert.NilError(t, err) assert.NilError(t, err)
resetTestTomb(&testTomb) resetTestTomb(&testTomb, &pw)
} }