From b9ae94b8746186a422b043750d333e2f8242b931 Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" Date: Wed, 1 Jul 2020 17:04:29 +0200 Subject: [PATCH 01/20] Sqlite : Support automatic db flushing (#91) * add support for sqlite retention : max_records, max_records_age * reduce verbosity of cwhub --- cmd/crowdsec-cli/api.go | 3 +- cmd/crowdsec-cli/backup-restore.go | 6 +- cmd/crowdsec-cli/ban.go | 15 ++-- cmd/crowdsec/main.go | 15 +++- cmd/crowdsec/serve.go | 2 - config/dev.yaml | 6 +- config/plugins/backend/sqlite.yaml | 1 - config/prod.yaml | 2 +- config/user.yaml | 1 - pkg/csconfig/config.go | 20 ++--- pkg/cwhub/hubMgmt.go | 34 ++++--- pkg/cwplugin/backend.go | 51 +++++++++-- pkg/exprhelpers/exprlib.go | 1 - pkg/outputs/ouputs.go | 56 ++++++++---- pkg/parser/stage.go | 3 +- pkg/sqlite/commit.go | 139 +++++++++++++++++++++++++++-- pkg/sqlite/sqlite.go | 26 ++++-- plugins/backend/sqlite.go | 4 + 18 files changed, 296 insertions(+), 89 deletions(-) diff --git a/cmd/crowdsec-cli/api.go b/cmd/crowdsec-cli/api.go index a11564275..81876e8a7 100644 --- a/cmd/crowdsec-cli/api.go +++ b/cmd/crowdsec-cli/api.go @@ -157,8 +157,9 @@ cscli api credentials # Display your API credentials outputConfig := outputs.OutputFactory{ BackendFolder: config.BackendPluginFolder, + Flush: false, } - outputCTX, err = outputs.NewOutput(&outputConfig, false) + outputCTX, err = outputs.NewOutput(&outputConfig) if err != nil { return err } diff --git a/cmd/crowdsec-cli/backup-restore.go b/cmd/crowdsec-cli/backup-restore.go index 747de7124..c9d1536a0 100644 --- a/cmd/crowdsec-cli/backup-restore.go +++ b/cmd/crowdsec-cli/backup-restore.go @@ -412,8 +412,9 @@ cscli backup restore ./my-backup`, outputConfig := outputs.OutputFactory{ BackendFolder: config.BackendPluginFolder, + Flush: false, } - outputCTX, err = outputs.NewOutput(&outputConfig, false) + outputCTX, err = outputs.NewOutput(&outputConfig) if err != nil { log.Fatalf("Failed to load output plugins : %v", err) } @@ -453,8 +454,9 @@ cscli backup restore ./my-backup`, outputConfig := outputs.OutputFactory{ BackendFolder: config.BackendPluginFolder, + Flush: false, } - outputCTX, err = outputs.NewOutput(&outputConfig, false) + outputCTX, err = outputs.NewOutput(&outputConfig) if err != nil { log.Fatalf("Failed to load output plugins : %v", err) } diff --git a/cmd/crowdsec-cli/ban.go b/cmd/crowdsec-cli/ban.go index 023e7808a..61aba814e 100644 --- a/cmd/crowdsec-cli/ban.go +++ b/cmd/crowdsec-cli/ban.go @@ -167,7 +167,7 @@ func BanAdd(target string, duration string, reason string, action string) error if err != nil { return err } - log.Infof("Wrote ban to database.") + log.Infof("%s %s for %s (%s)", action, target, duration, reason) return nil } @@ -188,9 +188,10 @@ You can add/delete/list or flush current bans in your local ban DB.`, outputConfig := outputs.OutputFactory{ BackendFolder: config.BackendPluginFolder, + Flush: false, } - outputCTX, err = outputs.NewOutput(&outputConfig, false) + outputCTX, err = outputs.NewOutput(&outputConfig) if err != nil { return fmt.Errorf(err.Error()) } @@ -220,9 +221,10 @@ cscli ban add range 1.2.3.0/24 24h "the whole range"`, Short: "Adds the specific ip to the ban db", Long: `Duration must be [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration), expressed in s/m/h.`, Example: `cscli ban add ip 1.2.3.4 12h "the scan"`, - Args: cobra.ExactArgs(3), + Args: cobra.MinimumNArgs(3), Run: func(cmd *cobra.Command, args []string) { - if err := BanAdd(args[0], args[1], args[2], remediationType); err != nil { + reason := strings.Join(args[2:], " ") + if err := BanAdd(args[0], args[1], reason, remediationType); err != nil { log.Fatalf("failed to add ban to sqlite : %v", err) } }, @@ -233,9 +235,10 @@ cscli ban add range 1.2.3.0/24 24h "the whole range"`, Short: "Adds the specific ip to the ban db", Long: `Duration must be [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) compatible, expressed in s/m/h.`, Example: `cscli ban add range 1.2.3.0/24 12h "the whole range"`, - Args: cobra.ExactArgs(3), + Args: cobra.MinimumNArgs(3), Run: func(cmd *cobra.Command, args []string) { - if err := BanAdd(args[0], args[1], args[2], remediationType); err != nil { + reason := strings.Join(args[2:], " ") + if err := BanAdd(args[0], args[1], reason, remediationType); err != nil { log.Fatalf("failed to add ban to sqlite : %v", err) } }, diff --git a/cmd/crowdsec/main.go b/cmd/crowdsec/main.go index 89575984d..643e1027d 100644 --- a/cmd/crowdsec/main.go +++ b/cmd/crowdsec/main.go @@ -14,6 +14,7 @@ import ( "github.com/crowdsecurity/crowdsec/pkg/outputs" "github.com/crowdsecurity/crowdsec/pkg/parser" "github.com/crowdsecurity/crowdsec/pkg/types" + "github.com/pkg/errors" "github.com/sevlyar/go-daemon" log "github.com/sirupsen/logrus" @@ -151,11 +152,22 @@ func LoadOutputs(cConfig *csconfig.CrowdSec) error { return fmt.Errorf("Failed to load output profiles : %v", err) } - OutputRunner, err = outputs.NewOutput(cConfig.OutputConfig, cConfig.Daemonize) + //If the user is providing a single file (ie forensic mode), don't flush expired records + if cConfig.SingleFile != "" { + log.Infof("forensic mode, disable flush") + cConfig.OutputConfig.Flush = false + } else { + cConfig.OutputConfig.Flush = true + } + OutputRunner, err = outputs.NewOutput(cConfig.OutputConfig) if err != nil { return fmt.Errorf("output plugins initialization error : %s", err.Error()) } + if err := OutputRunner.StartAutoCommit(); err != nil { + return errors.Wrap(err, "failed to start autocommit") + } + /* Init the API connector */ if cConfig.APIMode { log.Infof("Loading API client") @@ -277,7 +289,6 @@ func main() { if err := LoadBuckets(cConfig); err != nil { log.Fatalf("Failed to load scenarios: %s", err) - } if err := LoadOutputs(cConfig); err != nil { diff --git a/cmd/crowdsec/serve.go b/cmd/crowdsec/serve.go index ca3a5b095..bb1c61a73 100644 --- a/cmd/crowdsec/serve.go +++ b/cmd/crowdsec/serve.go @@ -129,8 +129,6 @@ func termHandler(sig os.Signal) error { } func serveOneTimeRun(outputRunner outputs.Output) error { - log.Infof("waiting for acquisition to finish") - if err := acquisTomb.Wait(); err != nil { log.Warningf("acquisition returned error : %s", err) } diff --git a/config/dev.yaml b/config/dev.yaml index e80ae4c56..ba6fbc50e 100644 --- a/config/dev.yaml +++ b/config/dev.yaml @@ -6,8 +6,12 @@ cscli_dir: "./config/crowdsec-cli" log_dir: "./logs" log_mode: "stdout" log_level: info +prometheus: true profiling: false -sqlite_path: "./test.db" apimode: false plugin: backend: "./config/plugins/backend" + max_records: 10000 + #30 days = 720 hours + max_records_age: 720h + \ No newline at end of file diff --git a/config/plugins/backend/sqlite.yaml b/config/plugins/backend/sqlite.yaml index b177b22a5..0d04e7664 100644 --- a/config/plugins/backend/sqlite.yaml +++ b/config/plugins/backend/sqlite.yaml @@ -2,4 +2,3 @@ name: sqlite path: /usr/local/lib/crowdsec/plugins/backend/sqlite.so config: db_path: /var/lib/crowdsec/data/crowdsec.db - flush: true diff --git a/config/prod.yaml b/config/prod.yaml index d2337c6e2..73c48f085 100644 --- a/config/prod.yaml +++ b/config/prod.yaml @@ -7,7 +7,6 @@ cscli_dir: ${CFG}/cscli log_mode: file log_level: info profiling: false -sqlite_path: ${DATA}/crowdsec.db apimode: true daemon: true prometheus: true @@ -15,3 +14,4 @@ prometheus: true http_listen: 127.0.0.1:6060 plugin: backend: "/etc/crowdsec/plugins/backend" + max_records_age: 720h diff --git a/config/user.yaml b/config/user.yaml index 8d3f83c6c..61631fa74 100644 --- a/config/user.yaml +++ b/config/user.yaml @@ -7,7 +7,6 @@ cscli_dir: ${CFG}/cscli log_mode: stdout log_level: info profiling: false -sqlite_path: ${DATA}/crowdsec.db apimode: false daemon: false prometheus: false diff --git a/pkg/csconfig/config.go b/pkg/csconfig/config.go index 66a6dbb36..823d79696 100644 --- a/pkg/csconfig/config.go +++ b/pkg/csconfig/config.go @@ -25,14 +25,13 @@ type CrowdSec struct { SingleFileLabel string //for forensic mode PIDFolder string `yaml:"pid_dir,omitempty"` LogFolder string `yaml:"log_dir,omitempty"` - LogMode string `yaml:"log_mode,omitempty"` //like file, syslog or stdout ? - LogLevel log.Level `yaml:"log_level,omitempty"` //trace,debug,info,warning,error - Daemonize bool `yaml:"daemon,omitempty"` //true -> go background - Profiling bool `yaml:"profiling,omitempty"` //true -> enable runtime profiling - SQLiteFile string `yaml:"sqlite_path,omitempty"` //path to sqlite output - APIMode bool `yaml:"apimode,omitempty"` //true -> enable api push - CsCliFolder string `yaml:"cscli_dir"` //cscli folder - NbParsers int `yaml:"parser_routines"` //the number of go routines to start for parsing + LogMode string `yaml:"log_mode,omitempty"` //like file, syslog or stdout ? + LogLevel log.Level `yaml:"log_level,omitempty"` //trace,debug,info,warning,error + Daemonize bool `yaml:"daemon,omitempty"` //true -> go background + Profiling bool `yaml:"profiling,omitempty"` //true -> enable runtime profiling + APIMode bool `yaml:"apimode,omitempty"` //true -> enable api push + CsCliFolder string `yaml:"cscli_dir"` //cscli folder + NbParsers int `yaml:"parser_routines"` //the number of go routines to start for parsing Linter bool Prometheus bool HTTPListen string `yaml:"http_listen,omitempty"` @@ -53,7 +52,6 @@ func NewCrowdSecConfig() *CrowdSec { PIDFolder: "/var/run/", LogFolder: "/var/log/", LogMode: "stdout", - SQLiteFile: "/var/lib/crowdsec/data/crowdsec.db", APIMode: false, NbParsers: 1, Prometheus: false, @@ -89,7 +87,6 @@ func (c *CrowdSec) GetOPT() error { printInfo := flag.Bool("info", false, "print info-level on stdout") printVersion := flag.Bool("version", false, "display version") APIMode := flag.Bool("api", false, "perform pushes to api") - SQLiteMode := flag.Bool("sqlite", true, "write overflows to sqlite") profileMode := flag.Bool("profile", false, "Enable performance profiling") catFile := flag.String("file", "", "Process a single file in time-machine") catFileType := flag.String("type", "", "Labels.type for file in time-machine") @@ -156,9 +153,6 @@ func (c *CrowdSec) GetOPT() error { if *printTrace { c.LogLevel = log.TraceLevel } - if !*SQLiteMode { - c.SQLiteFile = "" - } if *APIMode { c.APIMode = true } diff --git a/pkg/cwhub/hubMgmt.go b/pkg/cwhub/hubMgmt.go index 8fd822ebc..6321e78a0 100644 --- a/pkg/cwhub/hubMgmt.go +++ b/pkg/cwhub/hubMgmt.go @@ -123,7 +123,7 @@ func parser_visit(path string, f os.FileInfo, err error) error { subs := strings.Split(path, "/") - log.Debugf("path:%s, hubdir:%s, installdir:%s", path, Hubdir, Installdir) + log.Tracef("path:%s, hubdir:%s, installdir:%s", path, Hubdir, Installdir) /*we're in hub (~/.cscli/hub/)*/ if strings.HasPrefix(path, Hubdir) { inhub = true @@ -137,7 +137,7 @@ func parser_visit(path string, f os.FileInfo, err error) error { fauthor = subs[len(subs)-2] stage = subs[len(subs)-3] ftype = subs[len(subs)-4] - log.Debugf("HUBB check [%s] by [%s] in stage [%s] of type [%s]", fname, fauthor, stage, ftype) + log.Tracef("HUBB check [%s] by [%s] in stage [%s] of type [%s]", fname, fauthor, stage, ftype) } else if strings.HasPrefix(path, Installdir) { /*we're in install /etc/crowdsec//... */ if len(subs) < 3 { @@ -151,7 +151,7 @@ func parser_visit(path string, f os.FileInfo, err error) error { stage = subs[len(subs)-2] ftype = subs[len(subs)-3] fauthor = "" - log.Debugf("INSTALL check [%s] by [%s] in stage [%s] of type [%s]", fname, fauthor, stage, ftype) + log.Tracef("INSTALL check [%s] by [%s] in stage [%s] of type [%s]", fname, fauthor, stage, ftype) } //log.Printf("%s -> name:%s stage:%s", path, fname, stage) @@ -165,7 +165,7 @@ func parser_visit(path string, f os.FileInfo, err error) error { return fmt.Errorf("unknown prefix in %s : fname:%s, fauthor:%s, stage:%s, ftype:%s", path, fname, fauthor, stage, ftype) } - log.Debugf("CORRECTED [%s] by [%s] in stage [%s] of type [%s]", fname, fauthor, stage, ftype) + log.Tracef("CORRECTED [%s] by [%s] in stage [%s] of type [%s]", fname, fauthor, stage, ftype) /* we can encounter 'collections' in the form of a symlink : @@ -176,7 +176,7 @@ func parser_visit(path string, f os.FileInfo, err error) error { if f.Mode()&os.ModeSymlink == 0 { local = true skippedLocal++ - log.Debugf("%s isn't a symlink", path) + log.Tracef("%s isn't a symlink", path) } else { hubpath, err = os.Readlink(path) if err != nil { @@ -192,7 +192,7 @@ func parser_visit(path string, f os.FileInfo, err error) error { } return nil } - log.Debugf("%s points to %s", path, hubpath) + log.Tracef("%s points to %s", path, hubpath) } //if it's not a symlink and not in hub, it's a local file, don't bother @@ -214,13 +214,13 @@ func parser_visit(path string, f os.FileInfo, err error) error { return nil } //try to find which configuration item it is - log.Debugf("check [%s] of %s", fname, ftype) + log.Tracef("check [%s] of %s", fname, ftype) match := false for k, v := range HubIdx[ftype] { - log.Debugf("check [%s] vs [%s] : %s", fname, v.RemotePath, ftype+"/"+stage+"/"+fname+".yaml") + log.Tracef("check [%s] vs [%s] : %s", fname, v.RemotePath, ftype+"/"+stage+"/"+fname+".yaml") if fname != v.FileName { - log.Debugf("%s != %s (filename)", fname, v.FileName) + log.Tracef("%s != %s (filename)", fname, v.FileName) continue } //wrong stage @@ -238,7 +238,7 @@ func parser_visit(path string, f os.FileInfo, err error) error { continue } if path == Hubdir+"/"+v.RemotePath { - log.Debugf("marking %s as downloaded", v.Name) + log.Tracef("marking %s as downloaded", v.Name) v.Downloaded = true } } else { @@ -274,10 +274,8 @@ func parser_visit(path string, f os.FileInfo, err error) error { target.FileName = x[len(x)-1] } if version == v.Version { - log.Debugf("%s is up-to-date", v.Name) + log.Tracef("%s is up-to-date", v.Name) v.UpToDate = true - } else { - log.Debugf("%s is outdated", v.Name) } match = true @@ -310,18 +308,18 @@ func parser_visit(path string, f os.FileInfo, err error) error { func CollecDepsCheck(v *Item) error { /*if it's a collection, ensure all the items are installed, or tag it as tainted*/ if v.Type == COLLECTIONS { - log.Debugf("checking submembers of %s installed:%t", v.Name, v.Installed) + log.Tracef("checking submembers of %s installed:%t", v.Name, v.Installed) var tmp = [][]string{v.Parsers, v.PostOverflows, v.Scenarios, v.Collections} for idx, ptr := range tmp { ptrtype := ItemTypes[idx] for _, p := range ptr { if val, ok := HubIdx[ptrtype][p]; ok { - log.Debugf("check %s installed:%t", val.Name, val.Installed) + log.Tracef("check %s installed:%t", val.Name, val.Installed) if !v.Installed { continue } if val.Type == COLLECTIONS { - log.Debugf("collec, recurse.") + log.Tracef("collec, recurse.") if err := CollecDepsCheck(&val); err != nil { return fmt.Errorf("sub collection %s is broken : %s", val.Name, err) } @@ -341,7 +339,7 @@ func CollecDepsCheck(v *Item) error { } val.BelongsToCollections = append(val.BelongsToCollections, v.Name) HubIdx[ptrtype][p] = val - log.Debugf("checking for %s - tainted:%t uptodate:%t", p, v.Tainted, v.UpToDate) + log.Tracef("checking for %s - tainted:%t uptodate:%t", p, v.Tainted, v.UpToDate) } else { log.Fatalf("Referred %s %s in collection %s doesn't exist.", ptrtype, p, v.Name) } @@ -653,7 +651,7 @@ func DownloadLatest(target Item, tdir string, overwrite bool, dataFolder string) log.Debugf("Download %s sub-item : %s %s", target.Name, ptrtype, p) //recurse as it's a collection if ptrtype == COLLECTIONS { - log.Debugf("collection, recurse") + log.Tracef("collection, recurse") HubIdx[ptrtype][p], err = DownloadLatest(val, tdir, overwrite, dataFolder) if err != nil { log.Errorf("Encountered error while downloading sub-item %s %s : %s.", ptrtype, p, err) diff --git a/pkg/cwplugin/backend.go b/pkg/cwplugin/backend.go index 4cc59196e..5bccff4d0 100644 --- a/pkg/cwplugin/backend.go +++ b/pkg/cwplugin/backend.go @@ -22,24 +22,35 @@ type Backend interface { Flush() error Shutdown() error DeleteAll() error + StartAutoCommit() error } type BackendPlugin struct { Name string `yaml:"name"` Path string `yaml:"path"` ConfigFilePath string - Config map[string]string `yaml:"config"` - ID string - funcs Backend + //Config is passed to the backend plugin. + //It contains specific plugin config + plugin config from main yaml file + Config map[string]string `yaml:"config"` + ID string + funcs Backend } type BackendManager struct { backendPlugins map[string]BackendPlugin } -func NewBackendPlugin(path string, isDaemon bool) (*BackendManager, error) { +func NewBackendPlugin(outputConfig map[string]string) (*BackendManager, error) { var files []string var backendManager = &BackendManager{} + var path string + + if v, ok := outputConfig["backend"]; ok { + path = v + } else { + return nil, fmt.Errorf("missing 'backend' (path to backend plugins)") + } + //var path = output.BackendFolder err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { if filepath.Ext(path) == ".yaml" { files = append(files, path) @@ -88,17 +99,28 @@ func NewBackendPlugin(path string, isDaemon bool) (*BackendManager, error) { // Add the interface and Init() newPlugin.funcs = bInterface - if isDaemon { - newPlugin.Config["flush"] = "true" - } else { - newPlugin.Config["flush"] = "false" + // Merge backend config from main config file + if v, ok := outputConfig["debug"]; ok { + newPlugin.Config["debug"] = v + } + + if v, ok := outputConfig["max_records"]; ok { + newPlugin.Config["max_records"] = v + } + + if v, ok := outputConfig["max_records_age"]; ok { + newPlugin.Config["max_records_age"] = v + } + + if v, ok := outputConfig["flush"]; ok { + newPlugin.Config["flush"] = v } err = newPlugin.funcs.Init(newPlugin.Config) if err != nil { return nil, fmt.Errorf("plugin '%s' init error : %s", newPlugin.Name, err) } - log.Infof("backend plugin '%s' loaded", newPlugin.Name) + log.Debugf("backend plugin '%s' loaded", newPlugin.Name) backendManager.backendPlugins[newPlugin.Name] = newPlugin } @@ -175,6 +197,17 @@ func (b *BackendManager) IsBackendPlugin(plugin string) bool { return false } +func (b *BackendManager) StartAutoCommit() error { + var err error + for _, plugin := range b.backendPlugins { + err = plugin.funcs.StartAutoCommit() + if err != nil { + return err + } + } + return nil +} + func (b *BackendManager) ReadAT(timeAT time.Time) ([]map[string]string, error) { var ret []map[string]string var err error diff --git a/pkg/exprhelpers/exprlib.go b/pkg/exprhelpers/exprlib.go index 45d2e058e..e1ae0eef2 100644 --- a/pkg/exprhelpers/exprlib.go +++ b/pkg/exprhelpers/exprlib.go @@ -44,7 +44,6 @@ func GetExprEnv(ctx map[string]interface{}) map[string]interface{} { } func Init() error { - log.Infof("Expr helper initiated") dataFile = make(map[string][]string) dataFileRegex = make(map[string][]*regexp.Regexp) return nil diff --git a/pkg/outputs/ouputs.go b/pkg/outputs/ouputs.go index 92d69958a..9b8f97753 100644 --- a/pkg/outputs/ouputs.go +++ b/pkg/outputs/ouputs.go @@ -10,6 +10,7 @@ import ( "github.com/crowdsecurity/crowdsec/pkg/cwplugin" "github.com/crowdsecurity/crowdsec/pkg/exprhelpers" "github.com/crowdsecurity/crowdsec/pkg/types" + "github.com/pkg/errors" "github.com/crowdsecurity/crowdsec/pkg/cwapi" @@ -18,10 +19,19 @@ import ( "gopkg.in/yaml.v2" ) +//OutputFactory is part of the main yaml configuration file, and holds generic backend config type OutputFactory struct { - BackendFolder string `yaml:"backend"` + BackendFolder string `yaml:"backend,omitempty"` + //For the db GC : how many records can we keep at most + MaxRecords string `yaml:"max_records,omitempty"` + //For the db GC what is the oldest records we tolerate + MaxRecordsAge string `yaml:"max_records_age,omitempty"` + //Should we automatically flush expired bans + Flush bool + Debug bool `yaml:"debug"` } +//Output holds the runtime objects of backend type Output struct { API *cwapi.ApiCtx bManager *cwplugin.BackendManager @@ -86,6 +96,10 @@ func OvflwToOrder(sig types.SignalOccurence, prof types.Profile) (*types.BanOrde return &ordr, nil, warn } +func (o *Output) StartAutoCommit() error { + return o.bManager.StartAutoCommit() +} + func (o *Output) Shutdown() error { var reterr error if o.API != nil { @@ -100,8 +114,6 @@ func (o *Output) Shutdown() error { reterr = err } } - //bManager - //TBD : the backend(s) should be stopped in the same way return reterr } @@ -286,19 +298,6 @@ func (o *Output) LoadAPIConfig(configFile string) error { return nil } -func (o *Output) load(config *OutputFactory, isDaemon bool) error { - var err error - if config == nil { - return fmt.Errorf("missing output plugin configuration") - } - log.Debugf("loading backend plugins ...") - o.bManager, err = cwplugin.NewBackendPlugin(config.BackendFolder, isDaemon) - if err != nil { - return err - } - return nil -} - func (o *Output) Delete(target string) (int, error) { nbDel, err := o.bManager.Delete(target) return nbDel, err @@ -327,11 +326,30 @@ func (o *Output) ReadAT(timeAT time.Time) ([]map[string]string, error) { return ret, nil } -func NewOutput(config *OutputFactory, isDaemon bool) (*Output, error) { +func NewOutput(config *OutputFactory) (*Output, error) { var output Output - err := output.load(config, isDaemon) + var err error + + if config == nil { + return nil, fmt.Errorf("missing output plugin configuration") + } + log.Debugf("loading backend plugins ...") + //turn the *OutputFactory into a map[string]string for less constraint + backendConfig := map[string]string{ + "backend": config.BackendFolder, + "flush": strconv.FormatBool(config.Flush), + "debug": strconv.FormatBool(config.Debug)} + + if config.MaxRecords != "" { + backendConfig["max_records"] = config.MaxRecords + } + if config.MaxRecordsAge != "" { + backendConfig["max_records_age"] = config.MaxRecordsAge + } + + output.bManager, err = cwplugin.NewBackendPlugin(backendConfig) if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to load backend plugin") } return &output, nil } diff --git a/pkg/parser/stage.go b/pkg/parser/stage.go index 37bdb9baf..95bb66a31 100644 --- a/pkg/parser/stage.go +++ b/pkg/parser/stage.go @@ -133,7 +133,8 @@ func LoadStages(stageFiles []Stagefile, pctx *UnixParserCtx) ([]Node, error) { pctx.Stages = append(pctx.Stages, k) } sort.Strings(pctx.Stages) - log.Infof("Stages loaded: %+v", pctx.Stages) + log.Infof("Loaded %d nodes, %d stages", len(nodes), len(pctx.Stages)) + return nodes, nil } diff --git a/pkg/sqlite/commit.go b/pkg/sqlite/commit.go index cec4e5c96..01355b718 100644 --- a/pkg/sqlite/commit.go +++ b/pkg/sqlite/commit.go @@ -6,9 +6,21 @@ import ( "time" "github.com/crowdsecurity/crowdsec/pkg/types" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) +func (c *Context) DeleteExpired() error { + //Delete the expired records + if c.flush { + retx := c.Db.Where(`strftime("%s", until) < strftime("%s", "now")`).Delete(types.BanApplication{}) + if retx.RowsAffected > 0 { + log.Infof("Flushed %d expired entries from Ban Application", retx.RowsAffected) + } + } + return nil +} + func (c *Context) Flush() error { c.lock.Lock() defer c.lock.Unlock() @@ -21,18 +33,120 @@ func (c *Context) Flush() error { } c.tx = c.Db.Begin() c.lastCommit = time.Now() - //Delete the expired records - if c.flush { - retx := c.Db.Where(`strftime("%s", until) < strftime("%s", "now")`).Delete(types.BanApplication{}) - if retx.RowsAffected > 0 { - log.Infof("Flushed %d expired entries from Ban Application", retx.RowsAffected) + return nil +} + +func (c *Context) CleanUpRecordsByAge() error { + //let's fetch all expired records that are more than XX days olds + sos := []types.BanApplication{} + + if c.maxDurationRetention == 0 { + return nil + } + + //look for soft-deleted events that are OLDER than maxDurationRetention + ret := c.Db.Unscoped().Table("ban_applications").Where("deleted_at is not NULL"). + Where(fmt.Sprintf("deleted_at > date('now','-%d minutes')", int(c.maxDurationRetention.Minutes()))). + Order("updated_at desc").Find(&sos) + + if ret.Error != nil { + return errors.Wrap(ret.Error, "failed to get count of old records") + } + + //no events elligible + if len(sos) == 0 || ret.RowsAffected == 0 { + log.Debugf("no event older than %s", c.maxDurationRetention.String()) + return nil + } + //let's do it in a single transaction + delTx := c.Db.Unscoped().Begin() + delRecords := 0 + + for _, record := range sos { + copy := record + delTx.Unscoped().Table("signal_occurences").Where("ID = ?", copy.SignalOccurenceID).Delete(&types.SignalOccurence{}) + delTx.Unscoped().Table("event_sequences").Where("signal_occurence_id = ?", copy.SignalOccurenceID).Delete(&types.EventSequence{}) + delTx.Unscoped().Table("ban_applications").Delete(©) + //we need to delete associations : event_sequences, signal_occurences + delRecords++ + } + ret = delTx.Unscoped().Commit() + if ret.Error != nil { + return errors.Wrap(ret.Error, "failed to delete records") + } + log.Printf("max_records_age: deleting %d events (max age:%s)", delRecords, c.maxDurationRetention) + return nil +} + +func (c *Context) CleanUpRecordsByCount() error { + var count int + + if c.maxEventRetention <= 0 { + return nil + } + + ret := c.Db.Unscoped().Table("ban_applications").Order("updated_at desc").Count(&count) + + if ret.Error != nil { + return errors.Wrap(ret.Error, "failed to get bans count") + } + if count < c.maxEventRetention { + log.Debugf("%d < %d, don't cleanup", count, c.maxEventRetention) + return nil + } + + sos := []types.BanApplication{} + /*get soft deleted records oldest to youngest*/ + records := c.Db.Unscoped().Table("ban_applications").Where("deleted_at is not NULL").Where(`strftime("%s", deleted_at) < strftime("%s", "now")`).Find(&sos) + if records.Error != nil { + return errors.Wrap(records.Error, "failed to list expired bans for flush") + } + + //let's do it in a single transaction + delTx := c.Db.Unscoped().Begin() + delRecords := 0 + for _, ld := range sos { + copy := ld + delTx.Unscoped().Table("signal_occurences").Where("ID = ?", copy.SignalOccurenceID).Delete(&types.SignalOccurence{}) + delTx.Unscoped().Table("event_sequences").Where("signal_occurence_id = ?", copy.SignalOccurenceID).Delete(&types.EventSequence{}) + delTx.Unscoped().Table("ban_applications").Delete(©) + //we need to delete associations : event_sequences, signal_occurences + delRecords++ + //let's delete as well the associated event_sequence + if count-delRecords <= c.maxEventRetention { + break } } + if len(sos) > 0 { + //log.Printf("Deleting %d soft-deleted results out of %d total events (%d soft-deleted)", delRecords, count, len(sos)) + log.Printf("max_records: deleting %d events. (%d soft-deleted)", delRecords, len(sos)) + ret = delTx.Unscoped().Commit() + if ret.Error != nil { + return errors.Wrap(ret.Error, "failed to delete records") + } + } else { + log.Debugf("didn't find any record to clean") + } return nil } -func (c *Context) AutoCommit() { +func (c *Context) StartAutoCommit() error { + //TBD : we shouldn't start auto-commit if we are in cli mode ? + c.PusherTomb.Go(func() error { + c.autoCommit() + return nil + }) + return nil +} + +func (c *Context) autoCommit() { + log.Debugf("starting autocommit") ticker := time.NewTicker(200 * time.Millisecond) + cleanUpTicker := time.NewTicker(1 * time.Minute) + expireTicker := time.NewTicker(1 * time.Second) + if !c.flush { + log.Debugf("flush is disabled") + } for { select { case <-c.PusherTomb.Dying(): @@ -51,12 +165,25 @@ func (c *Context) AutoCommit() { log.Errorf("error while closing db : %s", err) } return + case <-expireTicker.C: + if err := c.DeleteExpired(); err != nil { + log.Errorf("Error while deleting expired records: %s", err) + } case <-ticker.C: if atomic.LoadInt32(&c.count) != 0 && (atomic.LoadInt32(&c.count)%100 == 0 || time.Since(c.lastCommit) >= 500*time.Millisecond) { if err := c.Flush(); err != nil { log.Errorf("failed to flush : %s", err) } + + } + case <-cleanUpTicker.C: + if err := c.CleanUpRecordsByCount(); err != nil { + log.Errorf("error in max records cleanup : %s", err) + } + if err := c.CleanUpRecordsByAge(); err != nil { + log.Errorf("error in old records cleanup : %s", err) + } } } diff --git a/pkg/sqlite/sqlite.go b/pkg/sqlite/sqlite.go index b3c5d8a63..1527ee01f 100644 --- a/pkg/sqlite/sqlite.go +++ b/pkg/sqlite/sqlite.go @@ -7,6 +7,7 @@ import ( "time" "github.com/crowdsecurity/crowdsec/pkg/types" + "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/jinzhu/gorm" @@ -23,12 +24,27 @@ type Context struct { count int32 lock sync.Mutex //booboo PusherTomb tomb.Tomb + //to manage auto cleanup : max number of records *or* oldest + maxEventRetention int + maxDurationRetention time.Duration } func NewSQLite(cfg map[string]string) (*Context, error) { var err error c := &Context{} + if v, ok := cfg["max_records"]; ok { + c.maxEventRetention, err = strconv.Atoi(v) + if err != nil { + log.Errorf("Ignoring invalid max_records '%s' : %s", v, err) + } + } + if v, ok := cfg["max_records_age"]; ok { + c.maxDurationRetention, err = time.ParseDuration(v) + if err != nil { + log.Errorf("Ignoring invalid duration '%s' : %s", v, err) + } + } if _, ok := cfg["db_path"]; !ok { return nil, fmt.Errorf("please specify a 'db_path' to SQLite db in the configuration") } @@ -36,6 +52,7 @@ func NewSQLite(cfg map[string]string) (*Context, error) { if cfg["db_path"] == "" { return nil, fmt.Errorf("please specify a 'db_path' to SQLite db in the configuration") } + log.Debugf("Starting SQLite backend, path:%s", cfg["db_path"]) c.Db, err = gorm.Open("sqlite3", cfg["db_path"]+"?_busy_timeout=1000") if err != nil { @@ -47,7 +64,10 @@ func NewSQLite(cfg map[string]string) (*Context, error) { c.Db.LogMode(true) } - c.flush, _ = strconv.ParseBool(cfg["flush"]) + c.flush, err = strconv.ParseBool(cfg["flush"]) + if err != nil { + return nil, errors.Wrap(err, "Unable to parse 'flush' flag") + } // Migrate the schema c.Db.AutoMigrate(&types.EventSequence{}, &types.SignalOccurence{}, &types.BanApplication{}) c.Db.Model(&types.SignalOccurence{}).Related(&types.EventSequence{}) @@ -64,9 +84,5 @@ func NewSQLite(cfg map[string]string) (*Context, error) { if c.tx == nil { return nil, fmt.Errorf("failed to begin sqlite transac : %s", err) } - c.PusherTomb.Go(func() error { - c.AutoCommit() - return nil - }) return c, nil } diff --git a/plugins/backend/sqlite.go b/plugins/backend/sqlite.go index 4e1943018..00be2f269 100644 --- a/plugins/backend/sqlite.go +++ b/plugins/backend/sqlite.go @@ -23,6 +23,10 @@ func (p *pluginDB) Shutdown() error { return nil } +func (p *pluginDB) StartAutoCommit() error { + return p.CTX.StartAutoCommit() +} + func (p *pluginDB) Init(config map[string]string) error { var err error log.Debugf("sqlite config : %+v \n", config) From 454e2850b52f8d1ad03b5d9fa6f7af340b701f97 Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" Date: Wed, 1 Jul 2020 17:14:05 +0200 Subject: [PATCH 02/20] don't trash the database when upgrading (#112) --- wizard.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wizard.sh b/wizard.sh index 72cd8cd0d..e814ace9d 100755 --- a/wizard.sh +++ b/wizard.sh @@ -315,6 +315,8 @@ update_full() { log_info "Backing up existing configuration" ${CSCLI_BIN_INSTALLED} backup save ${BACKUP_DIR} + log_info "Saving default database content" + cp /var/lib/crowdsec/data/crowdsec.db ${BACKUP_DIR}/crowdsec.db log_info "Cleanup existing crowdsec configuration" uninstall_crowdsec log_info "Installing crowdsec" @@ -322,6 +324,8 @@ update_full() { log_info "Restoring configuration" ${CSCLI_BIN_INSTALLED} update ${CSCLI_BIN_INSTALLED} backup restore ${BACKUP_DIR} + log_info "Restoring saved database" + cp ${BACKUP_DIR}/crowdsec.db /var/lib/crowdsec/data/crowdsec.db log_info "Finished, restarting" systemctl restart crowdsec || log_err "Failed to restart crowdsec" } From f6826c7e47c7d7b3f4584f272af36884c6581078 Mon Sep 17 00:00:00 2001 From: erenJag <64777133+erenJag@users.noreply.github.com> Date: Thu, 2 Jul 2020 11:09:40 +0200 Subject: [PATCH 03/20] add expr helper to check if IP is in ipRange (#113) * add expr helper to check if IP is in ipRange * update helper name Co-authored-by: erenJag --- pkg/exprhelpers/expr_test.go | 20 ++++++++++++++++++++ pkg/exprhelpers/exprlib.go | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/pkg/exprhelpers/expr_test.go b/pkg/exprhelpers/expr_test.go index e62e051d0..b44fbd919 100644 --- a/pkg/exprhelpers/expr_test.go +++ b/pkg/exprhelpers/expr_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/antonmedv/expr" + "github.com/stretchr/testify/require" "gotest.tools/assert" ) @@ -113,3 +114,22 @@ func TestFile(t *testing.T) { assert.Equal(t, test.result, result) } } + +func TestIpInRange(t *testing.T) { + env := map[string]interface{}{ + "ip": "192.168.0.1", + "ipRange": "192.168.0.0/24", + "IpInRange": IpInRange, + } + code := "IpInRange(ip, ipRange)" + log.Printf("Running filter : %s", code) + + program, err := expr.Compile(code, expr.Env(env)) + require.NoError(t, err) + + output, err := expr.Run(program, env) + require.NoError(t, err) + + require.Equal(t, true, output) + +} diff --git a/pkg/exprhelpers/exprlib.go b/pkg/exprhelpers/exprlib.go index e1ae0eef2..152ce851e 100644 --- a/pkg/exprhelpers/exprlib.go +++ b/pkg/exprhelpers/exprlib.go @@ -3,6 +3,7 @@ package exprhelpers import ( "bufio" "fmt" + "net" "os" "path" "regexp" @@ -36,6 +37,7 @@ func GetExprEnv(ctx map[string]interface{}) map[string]interface{} { "File": File, "RegexpInFile": RegexpInFile, "Upper": Upper, + "IpInRange": IpInRange, } for k, v := range ctx { ExprLib[k] = v @@ -101,3 +103,23 @@ func RegexpInFile(data string, filename string) bool { } return false } + +func IpInRange(ip string, ipRange string) bool { + var err error + var ipParsed net.IP + var ipRangeParsed *net.IPNet + + ipParsed = net.ParseIP(ip) + if ipParsed == nil { + log.Errorf("'%s' is not a valid IP", ip) + return false + } + if _, ipRangeParsed, err = net.ParseCIDR(ipRange); err != nil { + log.Errorf("'%s' is not a valid IP Range", ipRange) + return false + } + if ipRangeParsed.Contains(ipParsed) { + return true + } + return false +} From eef1847873466067adce4246e5c543c52d3d8726 Mon Sep 17 00:00:00 2001 From: AlteredCoder <64792091+AlteredCoder@users.noreply.github.com> Date: Thu, 2 Jul 2020 11:44:27 +0200 Subject: [PATCH 04/20] add whitelisted flag in signal occurence (#114) --- config/profiles.yaml | 10 +++++++++- pkg/parser/node.go | 1 + pkg/types/signal_occurence.go | 10 +++++----- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/config/profiles.yaml b/config/profiles.yaml index d0cc48d47..e9d56293c 100644 --- a/config/profiles.yaml +++ b/config/profiles.yaml @@ -1,5 +1,5 @@ profile: default_remediation -filter: "sig.Labels.remediation == 'true'" +filter: "sig.Labels.remediation == 'true' && not sig.Whitelisted" api: true # If no api: specified, will use the default config in default.yaml remediation: ban: true @@ -16,3 +16,11 @@ api: false outputs: - plugin: sqlite # If we do not want to push, we can remove this line and the next one store: false +--- +profile: send_false_positif_to_API +filter: "sig.Whitelisted == true && sig.Labels.remediation == 'true'" +#remediation is empty, it means non taken +api: true +outputs: + - plugin: sqlite # If we do not want to push, we can remove this line and the next one + store: false \ No newline at end of file diff --git a/pkg/parser/node.go b/pkg/parser/node.go index 130488721..8c14e7272 100644 --- a/pkg/parser/node.go +++ b/pkg/parser/node.go @@ -202,6 +202,7 @@ func (n *Node) process(p *types.Event, ctx UnixParserCtx) (bool, error) { //Break this for now. Souldn't have been done this way, but that's not taht serious /*only display logs when we discard ban to avoid spam*/ clog.Infof("Ban for %s whitelisted, reason [%s]", p.Overflow.Source.Ip.String(), n.Whitelist.Reason) + p.Overflow.Whitelisted = true } } diff --git a/pkg/types/signal_occurence.go b/pkg/types/signal_occurence.go index 2e9f712f6..91f0c467a 100644 --- a/pkg/types/signal_occurence.go +++ b/pkg/types/signal_occurence.go @@ -35,9 +35,9 @@ type SignalOccurence struct { Dest_ip string `json:"dst_ip,omitempty"` //for now just the destination IP //Policy string `json:"policy,omitempty"` //for now we forward it as well :) //bucket info - Capacity int `json:"capacity,omitempty"` - Leak_speed time.Duration `json:"leak_speed,omitempty"` - - Reprocess bool //Reprocess, when true, will make the overflow being processed again as a fresh log would - Labels map[string]string `gorm:"-"` + Capacity int `json:"capacity,omitempty"` + Leak_speed time.Duration `json:"leak_speed,omitempty"` + Whitelisted bool `gorm:"-"` + Reprocess bool //Reprocess, when true, will make the overflow being processed again as a fresh log would + Labels map[string]string `gorm:"-"` } From 672785ba171df1f5be219c9667d41db5ea893192 Mon Sep 17 00:00:00 2001 From: AlteredCoder <64792091+AlteredCoder@users.noreply.github.com> Date: Thu, 2 Jul 2020 11:46:16 +0200 Subject: [PATCH 05/20] update parser and scenari doc (#116) Co-authored-by: AlteredCoder --- docs/references/parsers.md | 6 +++++- docs/references/scenarios.md | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/references/parsers.md b/docs/references/parsers.md index 0a4638ff0..7f028eea1 100644 --- a/docs/references/parsers.md +++ b/docs/references/parsers.md @@ -151,10 +151,14 @@ It is meant to help understanding parser node behaviour by providing contextual filter: expression ``` -`filter` must be a valid {{expr.htmlname}} expression that will be evaluated against the {{event.name}}. +`filter` must be a valid {{expr.htmlname}} expression that will be evaluated against the {{event.htmlname}}. + If `filter` evaluation returns true or is absent, node will be processed. + If `filter` returns `false` or a non-boolean, node won't be processed. +Here is the [expr documentation](https://github.com/antonmedv/expr/tree/master/docs). + Examples : - `filter: "evt.Meta.foo == 'test'"` diff --git a/docs/references/scenarios.md b/docs/references/scenarios.md index 01c13bfef..311bb5c76 100644 --- a/docs/references/scenarios.md +++ b/docs/references/scenarios.md @@ -87,12 +87,16 @@ The name must be unique (and will define the scenario's name in the hub), and th ### filter ```yaml -filter: evt.Meta.log_type == 'telnet_new_session' +filter: expression ``` +`filter` must be a valid {{expr.htmlname}} expression that will be evaluated against the {{event.htmlname}}. -an {{expr.htmlname}} that must return true if the event is eligible for the bucket. +If `filter` evaluation returns true or is absent, event will be pour in the bucket. +If `filter` returns `false` or a non-boolean, the event will be skip for this bucket. + +Here is the [expr documentation](https://github.com/antonmedv/expr/tree/master/docs). Examples : From 7691e5b6631b2231d41e505e44c79db91690569d Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" Date: Thu, 2 Jul 2020 17:56:39 +0200 Subject: [PATCH 06/20] re-enable postoverflows (#117) * re-enable postoverflows * debug * yoloooo * remove debug * remove error print * fix test * fix leakybucket test * fix Co-authored-by: AlteredCoder --- cmd/crowdsec/main.go | 6 ++++++ cmd/crowdsec/output.go | 8 ++++++++ pkg/exprhelpers/exprlib.go | 5 +++-- pkg/leakybucket/buckets_test.go | 5 +++++ pkg/leakybucket/manager.go | 4 ---- pkg/parser/node.go | 6 ++++-- pkg/parser/parsing_test.go | 5 +++++ pkg/parser/stage.go | 4 ---- 8 files changed, 31 insertions(+), 12 deletions(-) diff --git a/cmd/crowdsec/main.go b/cmd/crowdsec/main.go index 643e1027d..c4ae8eb70 100644 --- a/cmd/crowdsec/main.go +++ b/cmd/crowdsec/main.go @@ -10,6 +10,7 @@ import ( "github.com/crowdsecurity/crowdsec/pkg/acquisition" "github.com/crowdsecurity/crowdsec/pkg/csconfig" "github.com/crowdsecurity/crowdsec/pkg/cwversion" + "github.com/crowdsecurity/crowdsec/pkg/exprhelpers" leaky "github.com/crowdsecurity/crowdsec/pkg/leakybucket" "github.com/crowdsecurity/crowdsec/pkg/outputs" "github.com/crowdsecurity/crowdsec/pkg/parser" @@ -282,6 +283,11 @@ func main() { go runTachymeter(cConfig.HTTPListen) } + err = exprhelpers.Init() + if err != nil { + log.Fatalf("Failed to init expr helpers : %s", err) + } + // Start loading configs if err := LoadParsers(cConfig); err != nil { log.Fatalf("Failed to load parsers: %s", err) diff --git a/cmd/crowdsec/output.go b/cmd/crowdsec/output.go index 925c2f41a..f4c8d2334 100644 --- a/cmd/crowdsec/output.go +++ b/cmd/crowdsec/output.go @@ -1,6 +1,8 @@ package main import ( + "fmt" + log "github.com/sirupsen/logrus" "time" @@ -40,6 +42,12 @@ LOOP: input <- event } + /* process post overflow parser nodes */ + event, err := parser.Parse(poctx, event, ponodes) + if err != nil { + return fmt.Errorf("postoverflow failed : %s", err) + } + if event.Overflow.Scenario == "" && event.Overflow.MapKey != "" { //log.Infof("Deleting expired entry %s", event.Overflow.MapKey) buckets.Bucket_map.Delete(event.Overflow.MapKey) diff --git a/pkg/exprhelpers/exprlib.go b/pkg/exprhelpers/exprlib.go index 152ce851e..614c47a9f 100644 --- a/pkg/exprhelpers/exprlib.go +++ b/pkg/exprhelpers/exprlib.go @@ -52,6 +52,7 @@ func Init() error { } func FileInit(fileFolder string, filename string, fileType string) error { + log.Printf("init (folder:%s) (file:%s) (type:%s)", fileFolder, filename, fileType) filepath := path.Join(fileFolder, filename) file, err := os.Open(filepath) if err != nil { @@ -87,7 +88,7 @@ func File(filename string) []string { if _, ok := dataFile[filename]; ok { return dataFile[filename] } - log.Errorf("file '%s' not found for expr library", filename) + log.Errorf("file '%s' (type:string) not found in expr library", filename) return []string{} } @@ -99,7 +100,7 @@ func RegexpInFile(data string, filename string) bool { } } } else { - log.Errorf("file '%s' not found for expr library", filename) + log.Errorf("file '%s' (type:regexp) not found in expr library", filename) } return false } diff --git a/pkg/leakybucket/buckets_test.go b/pkg/leakybucket/buckets_test.go index cc968cc7f..b490c4634 100644 --- a/pkg/leakybucket/buckets_test.go +++ b/pkg/leakybucket/buckets_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/crowdsecurity/crowdsec/pkg/exprhelpers" "github.com/crowdsecurity/crowdsec/pkg/parser" "github.com/crowdsecurity/crowdsec/pkg/types" "github.com/davecgh/go-spew/spew" @@ -25,6 +26,10 @@ type TestFile struct { func TestBucket(t *testing.T) { var envSetting = os.Getenv("TEST_ONLY") + err := exprhelpers.Init() + if err != nil { + log.Fatalf("exprhelpers init failed: %s", err) + } if envSetting != "" { if err := testOneBucket(t, envSetting); err != nil { diff --git a/pkg/leakybucket/manager.go b/pkg/leakybucket/manager.go index c8caa22e3..f6ec8c8ca 100644 --- a/pkg/leakybucket/manager.go +++ b/pkg/leakybucket/manager.go @@ -112,10 +112,6 @@ func LoadBuckets(files []string, dataFolder string) ([]BucketFactory, chan types ) var seed namegenerator.Generator = namegenerator.NewNameGenerator(time.Now().UTC().UnixNano()) - err := exprhelpers.Init() - if err != nil { - return nil, nil, err - } response = make(chan types.Event, 1) for _, f := range files { diff --git a/pkg/parser/node.go b/pkg/parser/node.go index 8c14e7272..dea90962b 100644 --- a/pkg/parser/node.go +++ b/pkg/parser/node.go @@ -137,7 +137,7 @@ func (n *Node) process(p *types.Event, ctx UnixParserCtx) (bool, error) { NodeState = true clog.Debugf("eval(TRUE) '%s'", n.Filter) } else { - clog.Tracef("Node has not filter, enter") + clog.Debugf("Node has not filter, enter") NodeState = true } @@ -177,7 +177,7 @@ func (n *Node) process(p *types.Event, ctx UnixParserCtx) (bool, error) { clog.Debugf("no ip in event, cidr/ip whitelists not checked") } /* run whitelist expression tests anyway */ - for _, e := range n.Whitelist.B_Exprs { + for eidx, e := range n.Whitelist.B_Exprs { output, err := expr.Run(e, exprhelpers.GetExprEnv(map[string]interface{}{"evt": p})) if err != nil { clog.Warningf("failed to run whitelist expr : %v", err) @@ -192,6 +192,8 @@ func (n *Node) process(p *types.Event, ctx UnixParserCtx) (bool, error) { p.Whitelisted = true set = true } + default: + log.Errorf("unexpected type %t (%v) while running '%s'", output, output, n.Whitelist.Exprs[eidx]) } } if set { diff --git a/pkg/parser/parsing_test.go b/pkg/parser/parsing_test.go index 06d4f518b..0eeb0454f 100644 --- a/pkg/parser/parsing_test.go +++ b/pkg/parser/parsing_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/crowdsecurity/crowdsec/pkg/exprhelpers" "github.com/crowdsecurity/crowdsec/pkg/types" "github.com/davecgh/go-spew/spew" log "github.com/sirupsen/logrus" @@ -139,6 +140,10 @@ func testOneParser(pctx *UnixParserCtx, dir string, b *testing.B) error { func prepTests() (*UnixParserCtx, error) { var pctx *UnixParserCtx var p UnixParser + err := exprhelpers.Init() + if err != nil { + log.Fatalf("exprhelpers init failed: %s", err) + } //Load enrichment datadir := "../../data/" diff --git a/pkg/parser/stage.go b/pkg/parser/stage.go index 95bb66a31..f26594a95 100644 --- a/pkg/parser/stage.go +++ b/pkg/parser/stage.go @@ -43,10 +43,6 @@ func LoadStages(stageFiles []Stagefile, pctx *UnixParserCtx) ([]Node, error) { tmpstages := make(map[string]bool) pctx.Stages = []string{} - err := exprhelpers.Init() - if err != nil { - return nil, err - } for _, stageFile := range stageFiles { if !strings.HasSuffix(stageFile.Filename, ".yaml") { log.Warningf("skip non yaml : %s", stageFile.Filename) From 3dd42bc9fdbec021eea60f893c9d635c174c8ba2 Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" Date: Fri, 3 Jul 2020 11:40:12 +0200 Subject: [PATCH 07/20] add ability to filter 'ban list' output (`--ip` `--range` `--as` `--country` `--reason`) (#115) * add ability to filter 'ban list' output --- cmd/crowdsec-cli/ban.go | 113 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/cmd/crowdsec-cli/ban.go b/cmd/crowdsec-cli/ban.go index 61aba814e..5507c4961 100644 --- a/cmd/crowdsec-cli/ban.go +++ b/cmd/crowdsec-cli/ban.go @@ -22,6 +22,9 @@ var remediationType string var atTime string var all bool +//user supplied filters +var ipFilter, rangeFilter, reasonFilter, countryFilter, asFilter string + func simpleBanToSignal(targetIP string, reason string, expirationStr string, action string, asName string, asNum string, country string, banSource string) (types.SignalOccurence, error) { var signalOcc types.SignalOccurence @@ -84,6 +87,102 @@ func simpleBanToSignal(targetIP string, reason string, expirationStr string, act return signalOcc, nil } +func filterBans(bans []map[string]string) ([]map[string]string, error) { + + var retBans []map[string]string + + for _, ban := range bans { + var banIP net.IP + var banRange *net.IPNet + var keep bool = true + var err error + + if ban["iptext"] != "" { + if strings.Contains(ban["iptext"], "/") { + log.Debugf("%s is a range", ban["iptext"]) + banIP, banRange, err = net.ParseCIDR(ban["iptext"]) + if err != nil { + log.Warningf("failed to parse range '%s' from database : %s", ban["iptext"], err) + } + } else { + log.Debugf("%s is IP", ban["iptext"]) + banIP = net.ParseIP(ban["iptext"]) + } + } + + if ipFilter != "" { + var filterBinIP net.IP = net.ParseIP(ipFilter) + + if banRange != nil { + if banRange.Contains(filterBinIP) { + log.Debugf("[keep] ip filter is set, and range contains ip") + keep = true + } else { + log.Debugf("[discard] ip filter is set, and range doesn't contain ip") + keep = false + } + } else { + if ipFilter == ban["iptext"] { + log.Debugf("[keep] (ip) %s == %s", ipFilter, ban["iptext"]) + keep = true + } else { + log.Debugf("[discard] (ip) %s == %s", ipFilter, ban["iptext"]) + keep = false + } + } + } + if rangeFilter != "" { + _, filterBinRange, err := net.ParseCIDR(rangeFilter) + if err != nil { + return nil, fmt.Errorf("failed to parse range '%s' : %s", rangeFilter, err) + } + if filterBinRange.Contains(banIP) { + log.Debugf("[keep] range filter %s contains %s", rangeFilter, banIP.String()) + keep = true + } else { + log.Debugf("[discard] range filter %s doesn't contain %s", rangeFilter, banIP.String()) + keep = false + } + } + if reasonFilter != "" { + if strings.Contains(ban["reason"], reasonFilter) { + log.Debugf("[keep] reason filter %s matches %s", reasonFilter, ban["reason"]) + keep = true + } else { + log.Debugf("[discard] reason filter %s doesn't match %s", reasonFilter, ban["reason"]) + keep = false + } + } + + if countryFilter != "" { + if ban["cn"] == countryFilter { + log.Debugf("[keep] country filter %s matches %s", countryFilter, ban["cn"]) + keep = true + } else { + log.Debugf("[discard] country filter %s matches %s", countryFilter, ban["cn"]) + keep = false + } + } + + if asFilter != "" { + if strings.Contains(ban["as"], asFilter) { + log.Debugf("[keep] AS filter %s matches %s", asFilter, ban["as"]) + keep = true + } else { + log.Debugf("[discard] AS filter %s doesn't match %s", asFilter, ban["as"]) + keep = false + } + } + + if keep { + retBans = append(retBans, ban) + } else { + log.Debugf("[discard] discard %v", ban) + } + } + return retBans, nil +} + func BanList() error { at := time.Now() if atTime != "" { @@ -96,6 +195,10 @@ func BanList() error { if err != nil { return fmt.Errorf("unable to get records from sqlite : %v", err) } + ret, err = filterBans(ret) + if err != nil { + log.Errorf("Error while filtering : %s", err) + } if config.output == "raw" { fmt.Printf("source,ip,reason,bans,action,country,as,events_count,expiration\n") for _, rm := range ret { @@ -312,6 +415,10 @@ Time can be specified with --at and support a variety of date formats: - 2006-01-02 - 2006-01-02 15:04 `, + Example: `ban list --range 0.0.0.0/0 : will list all + ban list --country CN + ban list --reason crowdsecurity/http-probing + ban list --as OVH`, Args: cobra.ExactArgs(0), Run: func(cmd *cobra.Command, args []string) { if err := BanList(); err != nil { @@ -321,6 +428,12 @@ Time can be specified with --at and support a variety of date formats: } cmdBanList.PersistentFlags().StringVar(&atTime, "at", "", "List bans at given time") cmdBanList.PersistentFlags().BoolVarP(&all, "all", "a", false, "List as well bans received from API") + cmdBanList.PersistentFlags().StringVar(&ipFilter, "ip", "", "List bans for given IP") + cmdBanList.PersistentFlags().StringVar(&rangeFilter, "range", "", "List bans belonging to given range") + cmdBanList.PersistentFlags().StringVar(&reasonFilter, "reason", "", "List bans containing given reason") + cmdBanList.PersistentFlags().StringVar(&countryFilter, "country", "", "List bans belonging to given country code") + cmdBanList.PersistentFlags().StringVar(&asFilter, "as", "", "List bans belonging to given AS name") + cmdBan.AddCommand(cmdBanList) return cmdBan } From a62bac0ca0b04c767a0f8a267c3431906472c96e Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" Date: Fri, 3 Jul 2020 18:26:23 +0200 Subject: [PATCH 08/20] verbosity (#121) --- pkg/exprhelpers/exprlib.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/exprhelpers/exprlib.go b/pkg/exprhelpers/exprlib.go index 614c47a9f..4340b586c 100644 --- a/pkg/exprhelpers/exprlib.go +++ b/pkg/exprhelpers/exprlib.go @@ -52,7 +52,7 @@ func Init() error { } func FileInit(fileFolder string, filename string, fileType string) error { - log.Printf("init (folder:%s) (file:%s) (type:%s)", fileFolder, filename, fileType) + log.Debugf("init (folder:%s) (file:%s) (type:%s)", fileFolder, filename, fileType) filepath := path.Join(fileFolder, filename) file, err := os.Open(filepath) if err != nil { From f0612d4d90dc2795f1e71edc33aad2ae95ea29be Mon Sep 17 00:00:00 2001 From: Thibault bui Koechlin Date: Mon, 6 Jul 2020 16:25:30 +0200 Subject: [PATCH 09/20] make resolve failure a debug-level message --- pkg/parser/enrich_dns.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/parser/enrich_dns.go b/pkg/parser/enrich_dns.go index 39a6e3079..86944a774 100644 --- a/pkg/parser/enrich_dns.go +++ b/pkg/parser/enrich_dns.go @@ -18,7 +18,7 @@ func reverse_dns(field string, p *types.Event, ctx interface{}) (map[string]stri } rets, err := net.LookupAddr(field) if err != nil { - log.Infof("failed to resolve '%s'", field) + log.Debugf("failed to resolve '%s'", field) return nil, nil } //When using the host C library resolver, at most one result will be returned. To bypass the host resolver, use a custom Resolver. From 4db1b0cad143089514abf84fb6ba11c1bb1cf9a3 Mon Sep 17 00:00:00 2001 From: Thibault bui Koechlin Date: Mon, 6 Jul 2020 16:26:45 +0200 Subject: [PATCH 10/20] make nodes warning a simple debug message --- pkg/outputs/ouputs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/outputs/ouputs.go b/pkg/outputs/ouputs.go index 9b8f97753..e51cc289c 100644 --- a/pkg/outputs/ouputs.go +++ b/pkg/outputs/ouputs.go @@ -176,7 +176,7 @@ func (o *Output) ProcessOutput(sig types.SignalOccurence, profiles []types.Profi return err } if warn != nil { - logger.Infof("node warning : %s", warn) + logger.Debugf("node warning : %s", warn) } if ordr != nil { bans, err := types.OrderToApplications(ordr) From d0ac43b00fa80a4d31737d740802ed9aa147935c Mon Sep 17 00:00:00 2001 From: AlteredCoder <64792091+AlteredCoder@users.noreply.github.com> Date: Tue, 7 Jul 2020 16:26:00 +0200 Subject: [PATCH 11/20] Allow comments with `#` in expr wordlists Co-authored-by: AlteredCoder --- pkg/exprhelpers/exprlib.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/exprhelpers/exprlib.go b/pkg/exprhelpers/exprlib.go index 4340b586c..b32cef42c 100644 --- a/pkg/exprhelpers/exprlib.go +++ b/pkg/exprhelpers/exprlib.go @@ -68,6 +68,9 @@ func FileInit(fileFolder string, filename string, fileType string) error { } scanner := bufio.NewScanner(file) for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "#") { // allow comments + continue + } switch fileType { case "regex", "regexp": dataFileRegex[filename] = append(dataFileRegex[filename], regexp.MustCompile(scanner.Text())) From 98297f741f4cd11682a08bddeced13e9df692e02 Mon Sep 17 00:00:00 2001 From: AlteredCoder Date: Tue, 7 Jul 2020 16:48:06 +0200 Subject: [PATCH 12/20] don't profile in test env --- config/dev.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/dev.yaml b/config/dev.yaml index ba6fbc50e..143624683 100644 --- a/config/dev.yaml +++ b/config/dev.yaml @@ -6,7 +6,7 @@ cscli_dir: "./config/crowdsec-cli" log_dir: "./logs" log_mode: "stdout" log_level: info -prometheus: true +prometheus: false profiling: false apimode: false plugin: From 680c5c14ac63f79b710bed6e1ac3ac602b0891c4 Mon Sep 17 00:00:00 2001 From: FaricaUnknown <64791077+FaricaUnknown@users.noreply.github.com> Date: Wed, 8 Jul 2020 10:43:16 +0200 Subject: [PATCH 13/20] give a warning in documentation if syslog-logs parser is missing (#120) --- docs/write_configurations/parsers.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/write_configurations/parsers.md b/docs/write_configurations/parsers.md index 593009ba2..ed5917eb1 100644 --- a/docs/write_configurations/parsers.md +++ b/docs/write_configurations/parsers.md @@ -3,6 +3,12 @@ !!! info Please ensure that you have working env or setup test environment before writing your parser. +!!! warning "Parser dependency" + +The crowdsecurity/syslog-logs parsers is needed by the core parsing +engine. Deletion or modification of this could result of {{crowdsec.name}} +being unable to parse logs, so this should be done very carefully. + > In the current example, we'll write a parser for the logs produced by `iptables` (netfilter) with the `-j LOG` target. > This document aims at detailing the process of writing and testing new parsers. @@ -410,4 +416,4 @@ statics: - meta: http_path expression: "evt.Parsed.request" ``` - --> \ No newline at end of file + --> From c1c1a33dd3dd0b9cab07ca3d1abb02c899213ba0 Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" Date: Wed, 8 Jul 2020 10:46:55 +0200 Subject: [PATCH 14/20] 4 lines installer (#118) * 4 lines installer --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 8bb6a2a75..2fcb78d92 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,39 @@ One of the advantages of Crowdsec when compared to other solutions is its crowde Besides detecting and stopping attacks in real time based on your logs, it allows you to preemptively block known bad actors from accessing your information system. + +## Install it ! + +Find the [latest release](https://github.com/crowdsecurity/crowdsec/releases/latest) + +Ensure you have dependencies : +
+ for Debian based distributions + +```bash +apt-get install bash gettext whiptail curl wget +``` +
+ +
+ for RedHat based distributions + +```bash +yum install bash gettext newt curl wget + ``` +
+ + + +```bash +curl -s https://api.github.com/repos/crowdsecurity/crowdsec/releases/latest | grep browser_download_url| cut -d '"' -f 4 | wget -i - +tar xvzf crowdsec-release.tgz +cd crowdsec-v* +sudo ./wizard.sh -i +``` + + + ## Key points ### Fast assisted installation, no technical barrier From a0c1ca49d0453a9c55f7e292a911bfd387a73033 Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" Date: Wed, 8 Jul 2020 10:58:20 +0200 Subject: [PATCH 15/20] Doc : fix whitelists documentation + document `data` for parsers/scenarios + document expr helpers + link taxonomy (#126) --- docs/references/parsers.md | 24 +++++ docs/references/scenarios.md | 25 +++++ docs/write_configurations/expressions.md | 52 ++++++++++ docs/write_configurations/whitelist.md | 126 +++++++++++++++++------ mkdocs.yml | 9 +- pkg/outputs/ouputs.go | 2 +- pkg/parser/enrich_dns.go | 2 +- 7 files changed, 205 insertions(+), 35 deletions(-) create mode 100644 docs/write_configurations/expressions.md diff --git a/docs/references/parsers.md b/docs/references/parsers.md index 7f028eea1..d887fbe1f 100644 --- a/docs/references/parsers.md +++ b/docs/references/parsers.md @@ -282,6 +282,30 @@ statics: expression: evt.Meta.target_field + ' this_is' + ' a dynamic expression' ``` +### data + +``` +data: + - source_url: https://URL/TO/FILE + dest_file: LOCAL_FILENAME + [type: regexp] +``` + +`data` allows user to specify an external source of data. +This section is only relevant when `cscli` is used to install parser from hub, as it will download the `source_url` and store it to `dest_file`. When the parser is not installed from the hub, {{crowdsec.name}} won't download the URL, but the file must exist for the parser to be loaded correctly. + +If `type` is set to `regexp`, the content of the file must be one valid (re2) regular expression per line. +Those regexps will be compiled and kept in cache. + + +```yaml +name: crowdsecurity/cdn-whitelist +... +data: + - source_url: https://www.cloudflare.com/ips-v4 + dest_file: cloudflare_ips.txt +``` + ## Parser concepts diff --git a/docs/references/scenarios.md b/docs/references/scenarios.md index 311bb5c76..51634bfac 100644 --- a/docs/references/scenarios.md +++ b/docs/references/scenarios.md @@ -347,3 +347,28 @@ overflow_filter: any(queue.Queue, { .Enriched.IsInEU == "true" }) If this expression is present and returns false, the overflow will be discarded. +### data + +``` +data: + - source_url: https://URL/TO/FILE + dest_file: LOCAL_FILENAME + [type: regexp] +``` + +`data` allows user to specify an external source of data. +This section is only relevant when `cscli` is used to install scenario from hub, as ill download the `source_url` and store it to `dest_file`. When the scenario is not installed from the hub, {{crowdsec.name}} won't download the URL, but the file must exist for the scenario to be loaded correctly. + +If `type` is set to `regexp`, the content of the file must be one valid (re2) regular expression per line. +Those regexps will be compiled and kept in cache. + + +```yaml +name: crowdsecurity/cdn-whitelist +... +data: + - source_url: https://www.cloudflare.com/ips-v4 + dest_file: cloudflare_ips.txt +``` + + diff --git a/docs/write_configurations/expressions.md b/docs/write_configurations/expressions.md new file mode 100644 index 000000000..09204f736 --- /dev/null +++ b/docs/write_configurations/expressions.md @@ -0,0 +1,52 @@ +# Expressions + +> {{expr.htmlname}} : Expression evaluation engine for Go: fast, non-Turing complete, dynamic typing, static typing + + +Several places of {{crowdsec.name}}'s configuration use {{expr.htmlname}} : + + - {{filter.Htmlname}} that are used to determine events eligibility in {{parsers.htmlname}} and {{scenarios.htmlname}} or `profiles` + - {{statics.Htmlname}} use expr in the `expression` directive, to compute complex values + - {{whitelists.Htmlname}} rely on `expression` directive to allow more complex whitelists filters + +To learn more about {{expr.htmlname}}, [check the github page of the project](https://github.com/antonmedv/expr/blob/master/docs/Language-Definition.md). + +In order to makes its use in {{crowdsec.name}} more efficient, we added a few helpers that are documented bellow. + +## Atof(string) float64 + +Parses a string representation of a float number to an actual float number (binding on `strconv.ParseFloat`) + +> Atof(evt.Parsed.tcp_port) + + +## JsonExtract(JsonBlob, FieldName) string + +Extract the `FieldName` from the `JsonBlob` and returns it as a string. (binding on [jsonparser](https://github.com/buger/jsonparser/)) + +> JsonExtract(evt.Parsed.some_json_blob, "foo.bar[0].one_item") + +## File(FileName) []string + +Returns the content of `FileName` as an array of string, while providing cache mechanism. + +> evt.Parsed.some_field in File('some_patterns.txt') +> any(File('rdns_seo_bots.txt'), { evt.Enriched.reverse_dns endsWith #}) + +## RegexpInFile(StringToMatch, FileName) bool + +Returns `true` if the `StringToMatch` is matched by one of the expressions contained in `FileName` (uses RE2 regexp engine). + +> RegexpInFile( evt.Enriched.reverse_dns, 'my_legit_seo_whitelists.txt') + +## Upper(string) string + +Returns the uppercase version of the string + +> Upper("yop") + +## IpInRange(IPStr, RangeStr) bool + +Returns true if the IP `IPStr` is contained in the IP range `RangeStr` (uses `net.ParseCIDR`) + +> IpInRange("1.2.3.4", "1.2.3.0/24") diff --git a/docs/write_configurations/whitelist.md b/docs/write_configurations/whitelist.md index 2f91f5624..85ac1909c 100644 --- a/docs/write_configurations/whitelist.md +++ b/docs/write_configurations/whitelist.md @@ -1,15 +1,28 @@ -## Where are whitelists +# What are whitelists -Whitelists are, as for most configuration, YAML files, and allow you to "discard" signals based on : +Whitelists are special parsers that allow you to "discard" events, and can exist at two different steps : - - ip adress or the fact that it belongs to a specific range - - a {{expr.name}} expression + - *Parser whitelists* : Allows you to discard an event at parse time, so that it never hits the buckets. + - *PostOverflow whitelists* : Those are whitelists that are checked *after* the overflow happens. It is usually best for whitelisting process that can be expensive (such as performing reverse DNS on an IP, or performing a `whois` of an IP). -Here is an example : +!!! info + While the whitelists are the same for parser or postoverflows, beware that field names might change. + Source ip is usually in `evt.Meta.source_ip` when it's a log, but `evt.Overflow.Source_ip` when it's an overflow + + +The whitelist can be based on several criteria : + + - specific ip address : if the event/overflow IP is the same, event is whitelisted + - ip ranges : if the event/overflow IP belongs to this range, event is whitelisted + - a list of {{expr.htmlname}} expressions : if any expression returns true, event is whitelisted + +Here is an example showcasing configuration : ```yaml name: crowdsecurity/my-whitelists description: "Whitelist events from my ipv4 addresses" +#it's a normal parser, so we can restrict its scope with filter +filter: "1 == 1" whitelist: reason: "my ipv4 ranges" ip: @@ -19,67 +32,75 @@ whitelist: - "10.0.0.0/8" - "172.16.0.0/12" expression: - - "'mycorp.com' in evt.Meta.source_ip_rdns" + #beware, this one will work *only* if you enabled the reverse dns (crowdsecurity/rdns) enrichment postoverflow parser + - evt.Enriched.reverse_dns endsWith ".mycoolorg.com." + #this one will work *only* if you enabled the geoip (crowdsecurity/geoip-enrich) enrichment parser + - evt.Enriched.IsoCode == 'FR' ``` -## Hands on -Let's assume we have a setup with a `crowdsecurity/base-http-scenarios` scenario enabled and no whitelists. +# Whitelists in parsing + +When a whitelist is present in parsing `/etc/crowdsec/config/parsers/...`, it will be checked/discarded before being poured to any bucket. These whitelists intentionally generate no logs and are useful to discard noisy false positive sources. + +## Whitelist by ip + +Let's assume we have a setup with a `crowdsecurity/nginx` collection enabled and no whitelists. Thus, if I "attack" myself : ```bash -nikto -host 127.0.0.1 +nikto -host myfqdn.com ``` my own IP will be flagged as being an attacker : ```bash $ tail -f /var/log/crowdsec.log -time="07-05-2020 09:23:03" level=warning msg="127.0.0.1 triggered a 4h0m0s ip ban remediation for [crowdsecurity/http-scan-uniques_404]" bucket_id=old-surf event_time="2020-05-07 09:23:03.322277347 +0200 CEST m=+57172.732939890" scenario=crowdsecurity/http-scan-uniques_404 source_ip=127.0.0.1 -time="07-05-2020 09:23:03" level=warning msg="127.0.0.1 triggered a 4h0m0s ip ban remediation for [crowdsecurity/http-crawl-non_statics]" bucket_id=lingering-sun event_time="2020-05-07 09:23:03.345341864 +0200 CEST m=+57172.756004380" scenario=crowdsecurity/http-crawl-non_statics source_ip=127.0.0.1 +ime="07-07-2020 16:13:16" level=warning msg="80.x.x.x triggered a 4h0m0s ip ban remediation for [crowdsecurity/http-bad-user-agent]" bucket_id=cool-smoke event_time="2020-07-07 16:13:16.579581642 +0200 CEST m=+358819.413561109" scenario=crowdsecurity/http-bad-user-agent source_ip=80.x.x.x +time="07-07-2020 16:13:16" level=warning msg="80.x.x.x triggered a 4h0m0s ip ban remediation for [crowdsecurity/http-probing]" bucket_id=green-silence event_time="2020-07-07 16:13:16.737579458 +0200 CEST m=+358819.571558901" scenario=crowdsecurity/http-probing source_ip=80.x.x.x +time="07-07-2020 16:13:17" level=warning msg="80.x.x.x triggered a 4h0m0s ip ban remediation for [crowdsecurity/http-crawl-non_statics]" bucket_id=purple-snowflake event_time="2020-07-07 16:13:17.353641625 +0200 CEST m=+358820.187621068" scenario=crowdsecurity/http-crawl-non_statics source_ip=80.x.x.x +time="07-07-2020 16:13:18" level=warning msg="80.x.x.x triggered a 4h0m0s ip ban remediation for [crowdsecurity/http-sensitive-files]" bucket_id=small-hill event_time="2020-07-07 16:13:18.005919055 +0200 CEST m=+358820.839898498" scenario=crowdsecurity/http-sensitive-files source_ip=80.x.x.x ^C $ {{cli.bin}} ban list -1 local decisions: -+--------+-----------+-------------------------------------+------+--------+---------+----+--------+------------+ -| SOURCE | IP | REASON | BANS | ACTION | COUNTRY | AS | EVENTS | EXPIRATION | -+--------+-----------+-------------------------------------+------+--------+---------+----+--------+------------+ -| local | 127.0.0.1 | crowdsecurity/http-scan-uniques_404 | 2 | ban | | 0 | 47 | 3h55m57s | -+--------+-----------+-------------------------------------+------+--------+---------+----+--------+------------+ +4 local decisions: ++--------+---------------+-----------------------------------+------+--------+---------+---------------------------+--------+------------+ +| SOURCE | IP | REASON | BANS | ACTION | COUNTRY | AS | EVENTS | EXPIRATION | ++--------+---------------+-----------------------------------+------+--------+---------+---------------------------+--------+------------+ +| local | 80.x.x.x | crowdsecurity/http-bad-user-agent | 4 | ban | FR | 21502 SFR SA | 60 | 3h59m3s | +... ``` -## Create the whitelist by IP -Let's create a `/etc/crowdsec/crowdsec/parsers/s02-enrich/whitelists.yaml` file with the following content : +### Create the whitelist by IP + +Let's create a `/etc/crowdsec/crowdsec/parsers/s02-enrich/mywhitelists.yaml` file with the following content : ```yaml name: crowdsecurity/whitelists -description: "Whitelist events from private ipv4 addresses" +description: "Whitelist events from my ip addresses" whitelist: - reason: "private ipv4 ranges" - ip: - - "127.0.0.1" - + reason: "my ip ranges" + ip: + - "80.x.x.x" ``` -and restart {{crowdsec.name}} : `sudo systemctl restart {{crowdsec.name}}` +and reload {{crowdsec.name}} : `sudo systemctl restart crowdsec` -## Test the whitelist +### Test the whitelist Thus, if we restart our attack : ```bash -nikto -host 127.0.0.1 +nikto -host myfqdn.com ``` -And we don't get bans, instead : +And we don't get bans : ```bash $ tail -f /var/log/crowdsec.log ... -time="07-05-2020 09:30:13" level=info msg="Event from [127.0.0.1] is whitelisted by Ips !" filter= name=lively-firefly stage=s02-enrich -... ^C $ {{cli.bin}} ban list No local decisions. @@ -87,11 +108,12 @@ And 21 records from API, 15 distinct AS, 12 distinct countries ``` +Here, we don't get *any* logs, as the event have been discarded at parsing time. ## Create whitelist by expression -Now, let's make something more tricky : let's whitelist a **specific** user-agent (of course, it's just an example, don't do this at home !). +Now, let's make something more tricky : let's whitelist a **specific** user-agent (of course, it's just an example, don't do this at home !). The [hub's taxonomy](https://hub.crowdsec.net/fields) will helps us to find which data is present in which field. Let's change our whitelist to : @@ -109,7 +131,7 @@ again, let's restart {{crowdsec.name}} ! For the record, I edited nikto's configuration to use 'MySecretUserAgent' as user-agent, and thus : ```bash -nikto -host 127.0.0.1 +nikto -host myfqdn.com ``` ```bash @@ -120,3 +142,43 @@ time="07-05-2020 09:39:09" level=info msg="Event is whitelisted by Expr !" filte ``` +# Whitelist in PostOverflows + +Whitelists in PostOverflows are applied *after* the bucket overflow happens. +It has the advantage of being triggered only once we are about to take decision about an IP or Range, and thus happens a lot less often. + +A good example is the [crowdsecurity/whitelist-good-actors](https://hub.crowdsec.net/author/crowdsecurity/collections/whitelist-good-actors) collection. + +But let's craft ours based on our previous example ! +First of all, install the [crowdsecurity/rdns postoverflow](https://hub.crowdsec.net/author/crowdsecurity/configurations/rdns) : it will be in charge of enriching overflows with reverse dns information of the offending IP. + +Let's put the following file in `/etc/crowdsec/config/postoverflows/s01-whitelists/mywhitelists.yaml` : + +```yaml +name: me/my_cool_whitelist +description: lets whitelist our own reverse dns +whitelist: + reason: dont ban my ISP + expression: + #this is the reverse of my ip, you can get it by performing a "host" command on your public IP for example + - evt.Enriched.reverse_dns endsWith '.asnieres.rev.numericable.fr.' +``` + +After reloading {{crowdsec.name}}, and launching (again!) nikto : + +```bash +nikto -host myfqdn.com +``` + + +```bash +$ tail -f /var/log/crowdsec.log +ime="07-07-2020 17:11:09" level=info msg="Ban for 80.x.x.x whitelisted, reason [dont ban my ISP]" id=cold-sunset name=me/my_cool_whitelist stage=s01 +time="07-07-2020 17:11:09" level=info msg="node warning : no remediation" bucket_id=blue-cloud event_time="2020-07-07 17:11:09.175068053 +0200 CEST m=+2308.040825320" scenario=crowdsecurity/http-probing source_ip=80.x.x.x +time="07-07-2020 17:11:09" level=info msg="Processing Overflow with no decisions 80.x.x.x performed 'crowdsecurity/http-probing' (11 events over 313.983994ms) at 2020-07-07 17:11:09.175068053 +0200 CEST m=+2308.040825320" bucket_id=blue-cloud event_time="2020-07-07 17:11:09.175068053 +0200 CEST m=+2308.040825320" scenario=crowdsecurity/http-probing source_ip=80.x.x.x +... + +``` + +This time, we can see that logs are being produced when the event is discarded. + diff --git a/mkdocs.yml b/mkdocs.yml index 0cbc9af04..63d567855 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,6 +17,7 @@ nav: - Cheat Sheets: - Ban Management: cheat_sheets/ban-mgmt.md - Configuration Management: cheat_sheets/config-mgmt.md + - Hub's taxonomy: https://hub.crowdsec.net/fields - Observability: - Overview: observability/overview.md - Logs: observability/logs.md @@ -31,7 +32,8 @@ nav: - Acquisition: write_configurations/acquisition.md - Parsers: write_configurations/parsers.md - Scenarios: write_configurations/scenarios.md - - Whitelist: write_configurations/whitelist.md + - Whitelists: write_configurations/whitelist.md + - Expressions: write_configurations/expressions.md - Blockers: - Overview : blockers/index.md - Nginx: @@ -204,6 +206,11 @@ extra: Name: Overflow htmlname: "[overflow](/getting_started/glossary/#overflow-or-signaloccurence)" Htmlname: "[Overflow](/getting_started/glossary/#overflow-or-signaloccurence)" + whitelists: + name: whitelists + Name: Whitelists + htmlname: "[whitelists](/write_configurations/whitelist/)" + Htmlname: "[Whitelists](/write_configurations/whitelist/)" signal: name: signal Name: Signal diff --git a/pkg/outputs/ouputs.go b/pkg/outputs/ouputs.go index 9b8f97753..e51cc289c 100644 --- a/pkg/outputs/ouputs.go +++ b/pkg/outputs/ouputs.go @@ -176,7 +176,7 @@ func (o *Output) ProcessOutput(sig types.SignalOccurence, profiles []types.Profi return err } if warn != nil { - logger.Infof("node warning : %s", warn) + logger.Debugf("node warning : %s", warn) } if ordr != nil { bans, err := types.OrderToApplications(ordr) diff --git a/pkg/parser/enrich_dns.go b/pkg/parser/enrich_dns.go index 39a6e3079..86944a774 100644 --- a/pkg/parser/enrich_dns.go +++ b/pkg/parser/enrich_dns.go @@ -18,7 +18,7 @@ func reverse_dns(field string, p *types.Event, ctx interface{}) (map[string]stri } rets, err := net.LookupAddr(field) if err != nil { - log.Infof("failed to resolve '%s'", field) + log.Debugf("failed to resolve '%s'", field) return nil, nil } //When using the host C library resolver, at most one result will be returned. To bypass the host resolver, use a custom Resolver. From a099a164e1fd80b9616a4bf852d1dbfcfa3f2589 Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" Date: Thu, 9 Jul 2020 10:12:17 +0200 Subject: [PATCH 16/20] Doc landing page : introduce early the concept of parsers scenarios and collections (#130) --- docs/index.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/index.md b/docs/index.md index 092a32553..9d59cb58a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,6 +27,14 @@ Besides detecting and stopping attacks in real time based on your logs, it allow ![Architecture](assets/images/crowdsec_architecture.png) + +## Core concepts + +{{crowdsec.name}} relies on {{parsers.htmlname}} to normalize and enrich logs, and {{scenarios.htmlname}} to detect attacks, often bundled together in {{collections.htmlname}} to form a coherent configuration set. For example the collection [`crowdsecurity/nginx`](https://hub.crowdsec.net/author/crowdsecurity/collections/nginx) contains all the necessary parsers and scenarios to deal with nginx logs and the common attacks that can be seen on http servers. + +All of those are represented as YAML files, that can be found, shared and kept up-to-date thanks to the {{hub.htmlname}}, or [easily hand-crafted](/write_configurations/scenarios/) to address specific needs. + + ## Moving forward To learn more about {{crowdsec.name}} and give it a try, please see : From 44304a30e73cb64350cbfbc934989f79de5b3e85 Mon Sep 17 00:00:00 2001 From: erenJag <64777133+erenJag@users.noreply.github.com> Date: Thu, 9 Jul 2020 12:41:18 +0200 Subject: [PATCH 17/20] fix #124 (#127) * fix #124 --- pkg/parser/node.go | 20 ++++++++++++------- .../tests/whitelist-base/base-grok.yaml | 3 +++ pkg/parser/tests/whitelist-base/test.yaml | 10 ++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/pkg/parser/node.go b/pkg/parser/node.go index dea90962b..157da54b7 100644 --- a/pkg/parser/node.go +++ b/pkg/parser/node.go @@ -144,7 +144,8 @@ func (n *Node) process(p *types.Event, ctx UnixParserCtx) (bool, error) { if n.Name != "" { NodesHits.With(prometheus.Labels{"source": p.Line.Src, "name": n.Name}).Inc() } - set := false + isWhitelisted := false + hasWhitelist := false var src net.IP /*overflow and log don't hold the source ip in the same field, should be changed */ /* perform whitelist checks for ips, cidr accordingly */ @@ -160,18 +161,22 @@ func (n *Node) process(p *types.Event, ctx UnixParserCtx) (bool, error) { if v.Equal(src) { clog.Debugf("Event from [%s] is whitelisted by Ips !", src) p.Whitelisted = true - set = true + isWhitelisted = true + } else { + clog.Debugf("whitelist: %s is not eq [%s]", src, v) } + hasWhitelist = true } for _, v := range n.Whitelist.B_Cidrs { if v.Contains(src) { clog.Debugf("Event from [%s] is whitelisted by Cidrs !", src) p.Whitelisted = true - set = true + isWhitelisted = true } else { clog.Debugf("whitelist: %s not in [%s]", src, v) } + hasWhitelist = true } } else { clog.Debugf("no ip in event, cidr/ip whitelists not checked") @@ -190,13 +195,14 @@ func (n *Node) process(p *types.Event, ctx UnixParserCtx) (bool, error) { if out { clog.Debugf("Event is whitelisted by Expr !") p.Whitelisted = true - set = true + isWhitelisted = true } + hasWhitelist = true default: log.Errorf("unexpected type %t (%v) while running '%s'", output, output, n.Whitelist.Exprs[eidx]) } } - if set { + if isWhitelisted { p.WhiteListReason = n.Whitelist.Reason /*huglily wipe the ban order if the event is whitelisted and it's an overflow */ if p.Type == types.OVFLW { /*don't do this at home kids */ @@ -298,9 +304,9 @@ func (n *Node) process(p *types.Event, ctx UnixParserCtx) (bool, error) { if n.Name != "" { NodesHitsOk.With(prometheus.Labels{"source": p.Line.Src, "name": n.Name}).Inc() } - if len(n.Statics) > 0 { + if hasWhitelist && isWhitelisted && len(n.Statics) > 0 || len(n.Statics) > 0 && !hasWhitelist { clog.Debugf("+ Processing %d statics", len(n.Statics)) - // if all else is good, process node's statics + // if all else is good in whitelist, process node's statics err := ProcessStatics(n.Statics, p, clog) if err != nil { clog.Fatalf("Failed to process statics : %v", err) diff --git a/pkg/parser/tests/whitelist-base/base-grok.yaml b/pkg/parser/tests/whitelist-base/base-grok.yaml index 2db38dc4e..44cbd1035 100644 --- a/pkg/parser/tests/whitelist-base/base-grok.yaml +++ b/pkg/parser/tests/whitelist-base/base-grok.yaml @@ -9,3 +9,6 @@ whitelist: - "1.2.3.0/24" expression: - "'supertoken1234' == evt.Enriched.test_token" +statics: + - meta: statics + value: success diff --git a/pkg/parser/tests/whitelist-base/test.yaml b/pkg/parser/tests/whitelist-base/test.yaml index 471e635f9..4524e957e 100644 --- a/pkg/parser/tests/whitelist-base/test.yaml +++ b/pkg/parser/tests/whitelist-base/test.yaml @@ -3,41 +3,51 @@ lines: - Meta: test: test1 source_ip: 8.8.8.8 + statics: toto - Meta: test: test2 source_ip: 1.2.3.4 + statics: toto - Meta: test: test3 source_ip: 2.2.3.4 + statics: toto - Meta: test: test4 source_ip: 8.8.8.9 + statics: toto - Enriched: test_token: supertoken1234 Meta: test: test5 + statics: toto #these are the results we expect from the parser results: - Whitelisted: true Process: true Meta: test: test1 + statics: success - Whitelisted: true Process: true Meta: test: test2 + statics: success - Whitelisted: false Process: true Meta: test: test3 + statics: toto - Whitelisted: false Process: true Meta: test: test4 + statics: toto - Whitelisted: true Process: true Meta: test: test5 + statics: success From 18f5a1dfdd737f0024deb4c693681815144f6dc3 Mon Sep 17 00:00:00 2001 From: erenJag <64777133+erenJag@users.noreply.github.com> Date: Thu, 9 Jul 2020 14:28:27 +0200 Subject: [PATCH 18/20] update crowdsec architecture image (#131) Co-authored-by: erenJag --- docs/assets/images/crowdsec_architecture.png | Bin 37939 -> 37569 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/images/crowdsec_architecture.png b/docs/assets/images/crowdsec_architecture.png index 8ba749de297cbb3aab8c23651e50dbd4f3ee48fe..5e5e6184de90cbc9d6e47aa62fb374fc5234324a 100644 GIT binary patch literal 37569 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 z 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 literal 37939 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 From 4ef080e7bd6c117e8e0b179d987cfcce4291ad3b Mon Sep 17 00:00:00 2001 From: AlteredCoder <64792091+AlteredCoder@users.noreply.github.com> Date: Fri, 10 Jul 2020 10:33:39 +0200 Subject: [PATCH 19/20] Add possibility to filter the size of cscli ban list returned array (#129) * Be able to filter/limit the `ban list` output --- cmd/crowdsec-cli/ban.go | 63 ++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/cmd/crowdsec-cli/ban.go b/cmd/crowdsec-cli/ban.go index 5507c4961..4976c87b5 100644 --- a/cmd/crowdsec-cli/ban.go +++ b/cmd/crowdsec-cli/ban.go @@ -20,10 +20,11 @@ import ( var remediationType string var atTime string -var all bool //user supplied filters var ipFilter, rangeFilter, reasonFilter, countryFilter, asFilter string +var displayLimit int +var displayAPI, displayALL bool func simpleBanToSignal(targetIP string, reason string, expirationStr string, action string, asName string, asNum string, country string, banSource string) (types.SignalOccurence, error) { var signalOcc types.SignalOccurence @@ -216,10 +217,9 @@ func BanList() error { table.SetHeader([]string{"Source", "Ip", "Reason", "Bans", "Action", "Country", "AS", "Events", "Expiration"}) dispcount := 0 - totcount := 0 apicount := 0 for _, rm := range ret { - if !all && rm["source"] == "api" { + if !displayAPI && rm["source"] == "api" { apicount++ if _, ok := uniqAS[rm["as"]]; !ok { uniqAS[rm["as"]] = true @@ -227,27 +227,55 @@ func BanList() error { if _, ok := uniqCN[rm["cn"]]; !ok { uniqCN[rm["cn"]] = true } - continue } - if dispcount < 20 { - table.Append([]string{rm["source"], rm["iptext"], rm["reason"], rm["bancount"], rm["action"], rm["cn"], rm["as"], rm["events_count"], rm["until"]}) + if displayALL { + if rm["source"] == "api" { + if displayAPI { + table.Append([]string{rm["source"], rm["iptext"], rm["reason"], rm["bancount"], rm["action"], rm["cn"], rm["as"], rm["events_count"], rm["until"]}) + dispcount++ + continue + } + } else { + table.Append([]string{rm["source"], rm["iptext"], rm["reason"], rm["bancount"], rm["action"], rm["cn"], rm["as"], rm["events_count"], rm["until"]}) + dispcount++ + continue + } + } else if dispcount < displayLimit { + if displayAPI { + if rm["source"] == "api" { + table.Append([]string{rm["source"], rm["iptext"], rm["reason"], rm["bancount"], rm["action"], rm["cn"], rm["as"], rm["events_count"], rm["until"]}) + dispcount++ + continue + } + } else { + if rm["source"] != "api" { + table.Append([]string{rm["source"], rm["iptext"], rm["reason"], rm["bancount"], rm["action"], rm["cn"], rm["as"], rm["events_count"], rm["until"]}) + dispcount++ + continue + } + } } - totcount++ - dispcount++ - } if dispcount > 0 { - if !all { - fmt.Printf("%d local decisions:\n", totcount) + if !displayAPI { + fmt.Printf("%d local decisions:\n", dispcount) + } else if displayAPI && !displayALL { + fmt.Printf("%d decision from API\n", dispcount) + } else if displayALL && displayAPI { + fmt.Printf("%d decision from crowdsec and API\n", dispcount) } table.Render() // Send output - if dispcount > 20 { + if dispcount > displayLimit && !displayALL { fmt.Printf("Additional records stripped.\n") } } else { - fmt.Printf("No local decisions.\n") + if displayAPI { + fmt.Println("No API decisions") + } else { + fmt.Println("No local decisions") + } } - if !all { + if !displayAPI { fmt.Printf("And %d records from API, %d distinct AS, %d distinct countries\n", apicount, len(uniqAS), len(uniqCN)) } } @@ -404,7 +432,8 @@ cscli ban del range 1.2.3.0/24`, Short: "List local or api bans/remediations", Long: `List the bans, by default only local decisions. -If --all/-a is specified, api-provided bans will be displayed too. +If --all/-a is specified, bans will be displayed without limit (--limit). +Default limit is 50. Time can be specified with --at and support a variety of date formats: - Jan 2 15:04:05 @@ -427,12 +456,14 @@ Time can be specified with --at and support a variety of date formats: }, } cmdBanList.PersistentFlags().StringVar(&atTime, "at", "", "List bans at given time") - cmdBanList.PersistentFlags().BoolVarP(&all, "all", "a", false, "List as well bans received from API") + cmdBanList.PersistentFlags().BoolVarP(&displayALL, "all", "a", false, "List bans without limit") + cmdBanList.PersistentFlags().BoolVarP(&displayAPI, "api", "", false, "List as well bans received from API") cmdBanList.PersistentFlags().StringVar(&ipFilter, "ip", "", "List bans for given IP") cmdBanList.PersistentFlags().StringVar(&rangeFilter, "range", "", "List bans belonging to given range") cmdBanList.PersistentFlags().StringVar(&reasonFilter, "reason", "", "List bans containing given reason") cmdBanList.PersistentFlags().StringVar(&countryFilter, "country", "", "List bans belonging to given country code") cmdBanList.PersistentFlags().StringVar(&asFilter, "as", "", "List bans belonging to given AS name") + cmdBanList.PersistentFlags().IntVar(&displayLimit, "limit", 50, "Limit of bans to display (default 50)") cmdBan.AddCommand(cmdBanList) return cmdBan From bc2566f3e57a84ae43e7f8707d4f017638a293b6 Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" Date: Fri, 10 Jul 2020 10:43:22 +0200 Subject: [PATCH 20/20] support multiple args for all `cscli` upgrade/install/remove commands (#132) --- cmd/crowdsec-cli/api.go | 1 - cmd/crowdsec-cli/install.go | 18 +++++++++++++----- cmd/crowdsec-cli/remove.go | 37 ++++++++++++++++--------------------- cmd/crowdsec-cli/upgrade.go | 34 +++++++++++++++++----------------- pkg/cwhub/hubMgmt.go | 3 --- 5 files changed, 46 insertions(+), 47 deletions(-) diff --git a/cmd/crowdsec-cli/api.go b/cmd/crowdsec-cli/api.go index 81876e8a7..4c7af50da 100644 --- a/cmd/crowdsec-cli/api.go +++ b/cmd/crowdsec-cli/api.go @@ -102,7 +102,6 @@ func pullTOP() error { if _, ok := item["scenario"]; !ok { continue } - item["scenario"] = fmt.Sprintf("api: %s", item["scenario"]) if _, ok := item["action"]; !ok { continue diff --git a/cmd/crowdsec-cli/install.go b/cmd/crowdsec-cli/install.go index f0632c592..dc76a7eb0 100644 --- a/cmd/crowdsec-cli/install.go +++ b/cmd/crowdsec-cli/install.go @@ -71,7 +71,7 @@ you should [update cscli](./cscli_update.md). var cmdInstallParser = &cobra.Command{ Use: "parser [config]", - Short: "Install given log parser", + Short: "Install given parser", Long: `Fetch and install given parser from hub`, Example: `cscli install parser crowdsec/xxx`, Args: cobra.MinimumNArgs(1), @@ -79,7 +79,9 @@ you should [update cscli](./cscli_update.md). if err := cwhub.GetHubIdx(); err != nil { log.Fatalf("failed to get Hub index : %v", err) } - InstallItem(args[0], cwhub.PARSERS) + for _, name := range args { + InstallItem(name, cwhub.PARSERS) + } }, } cmdInstall.AddCommand(cmdInstallParser) @@ -93,7 +95,9 @@ you should [update cscli](./cscli_update.md). if err := cwhub.GetHubIdx(); err != nil { log.Fatalf("failed to get Hub index : %v", err) } - InstallItem(args[0], cwhub.SCENARIOS) + for _, name := range args { + InstallItem(name, cwhub.SCENARIOS) + } }, } cmdInstall.AddCommand(cmdInstallScenario) @@ -108,7 +112,9 @@ you should [update cscli](./cscli_update.md). if err := cwhub.GetHubIdx(); err != nil { log.Fatalf("failed to get Hub index : %v", err) } - InstallItem(args[0], cwhub.COLLECTIONS) + for _, name := range args { + InstallItem(name, cwhub.COLLECTIONS) + } }, } cmdInstall.AddCommand(cmdInstallCollection) @@ -124,7 +130,9 @@ As a reminder, postoverflows are parsing configuration that will occur after the if err := cwhub.GetHubIdx(); err != nil { log.Fatalf("failed to get Hub index : %v", err) } - InstallItem(args[0], cwhub.PARSERS_OVFLW) + for _, name := range args { + InstallItem(name, cwhub.PARSERS_OVFLW) + } }, } cmdInstall.AddCommand(cmdInstallPostoverflow) diff --git a/cmd/crowdsec-cli/remove.go b/cmd/crowdsec-cli/remove.go index 984b536e0..55c1e9baf 100644 --- a/cmd/crowdsec-cli/remove.go +++ b/cmd/crowdsec-cli/remove.go @@ -71,15 +71,13 @@ func NewRemoveCmd() *cobra.Command { log.Fatalf("Failed to get Hub index : %v", err) } - if remove_all && len(args) == 0 { + if remove_all { RemoveMany(cwhub.PARSERS, "") - } else if len(args) == 1 { - RemoveMany(cwhub.PARSERS, args[0]) } else { - _ = cmd.Help() - return + for _, name := range args { + RemoveMany(cwhub.PARSERS, name) + } } - //fmt.Println("remove/disable parser: " + strings.Join(args, " ")) }, } cmdRemove.AddCommand(cmdRemoveParser) @@ -92,13 +90,12 @@ func NewRemoveCmd() *cobra.Command { if err := cwhub.GetHubIdx(); err != nil { log.Fatalf("Failed to get Hub index : %v", err) } - if remove_all && len(args) == 0 { + if remove_all { RemoveMany(cwhub.SCENARIOS, "") - } else if len(args) == 1 { - RemoveMany(cwhub.SCENARIOS, args[0]) } else { - _ = cmd.Help() - return + for _, name := range args { + RemoveMany(cwhub.SCENARIOS, name) + } } }, } @@ -112,13 +109,12 @@ func NewRemoveCmd() *cobra.Command { if err := cwhub.GetHubIdx(); err != nil { log.Fatalf("Failed to get Hub index : %v", err) } - if remove_all && len(args) == 0 { + if remove_all { RemoveMany(cwhub.COLLECTIONS, "") - } else if len(args) == 1 { - RemoveMany(cwhub.COLLECTIONS, args[0]) } else { - _ = cmd.Help() - return + for _, name := range args { + RemoveMany(cwhub.COLLECTIONS, name) + } } }, } @@ -133,13 +129,12 @@ func NewRemoveCmd() *cobra.Command { if err := cwhub.GetHubIdx(); err != nil { log.Fatalf("Failed to get Hub index : %v", err) } - if remove_all && len(args) == 0 { + if remove_all { RemoveMany(cwhub.PARSERS_OVFLW, "") - } else if len(args) == 1 { - RemoveMany(cwhub.PARSERS_OVFLW, args[0]) } else { - _ = cmd.Help() - return + for _, name := range args { + RemoveMany(cwhub.PARSERS_OVFLW, name) + } } }, } diff --git a/cmd/crowdsec-cli/upgrade.go b/cmd/crowdsec-cli/upgrade.go index da0bafc08..6fdacb03a 100644 --- a/cmd/crowdsec-cli/upgrade.go +++ b/cmd/crowdsec-cli/upgrade.go @@ -124,14 +124,14 @@ cscli upgrade --force # Overwrite tainted configuration if err := cwhub.GetHubIdx(); err != nil { log.Fatalf("Failed to get Hub index : %v", err) } - if len(args) == 1 { - UpgradeConfig(cwhub.PARSERS, args[0]) - //UpgradeConfig(cwhub.PARSERS_OVFLW, "") - } else if upgrade_all { + if upgrade_all { UpgradeConfig(cwhub.PARSERS, "") } else { - _ = cmd.Help() + for _, name := range args { + UpgradeConfig(cwhub.PARSERS, name) + } } + }, } cmdUpgrade.AddCommand(cmdUpgradeParser) @@ -146,12 +146,12 @@ cscli upgrade --force # Overwrite tainted configuration if err := cwhub.GetHubIdx(); err != nil { log.Fatalf("Failed to get Hub index : %v", err) } - if len(args) == 1 { - UpgradeConfig(cwhub.SCENARIOS, args[0]) - } else if upgrade_all { + if upgrade_all { UpgradeConfig(cwhub.SCENARIOS, "") } else { - _ = cmd.Help() + for _, name := range args { + UpgradeConfig(cwhub.SCENARIOS, name) + } } }, } @@ -168,12 +168,12 @@ cscli upgrade --force # Overwrite tainted configuration if err := cwhub.GetHubIdx(); err != nil { log.Fatalf("Failed to get Hub index : %v", err) } - if len(args) == 1 { - UpgradeConfig(cwhub.COLLECTIONS, args[0]) - } else if upgrade_all { + if upgrade_all { UpgradeConfig(cwhub.COLLECTIONS, "") } else { - _ = cmd.Help() + for _, name := range args { + UpgradeConfig(cwhub.COLLECTIONS, name) + } } }, } @@ -191,12 +191,12 @@ cscli upgrade --force # Overwrite tainted configuration if err := cwhub.GetHubIdx(); err != nil { log.Fatalf("Failed to get Hub index : %v", err) } - if len(args) == 1 { - UpgradeConfig(cwhub.PARSERS_OVFLW, args[0]) - } else if upgrade_all { + if upgrade_all { UpgradeConfig(cwhub.PARSERS_OVFLW, "") } else { - _ = cmd.Help() + for _, name := range args { + UpgradeConfig(cwhub.PARSERS_OVFLW, name) + } } }, } diff --git a/pkg/cwhub/hubMgmt.go b/pkg/cwhub/hubMgmt.go index 6321e78a0..62ba902c4 100644 --- a/pkg/cwhub/hubMgmt.go +++ b/pkg/cwhub/hubMgmt.go @@ -813,9 +813,6 @@ func HubStatus(itype string, name string, list_all bool) []map[string]string { log.Errorf("type %s doesn't exist", itype) return nil } - if list_all { - log.Printf("only enabled ones") - } var mli []map[string]string /*remember, you do it for the user :)*/