Browse Source

CTI API Helpers in expr (#1851)

* Add CTI API helpers in expr
* Allow profiles to have an `on_error` option to profiles

Co-authored-by: Sebastien Blot <sebastien@crowdsec.net>
Thibault "bui" Koechlin 2 years ago
parent
commit
4f29ce2ee7

+ 7 - 0
cmd/crowdsec/serve.go

@@ -284,6 +284,13 @@ func Serve(cConfig *csconfig.Config, apiReady chan bool, agentReady chan bool) e
 		log.Warningln("Exprhelpers loaded without database client.")
 		log.Warningln("Exprhelpers loaded without database client.")
 	}
 	}
 
 
+	if cConfig.API.CTI != nil && *cConfig.API.CTI.Enabled {
+		log.Infof("Crowdsec CTI helper enabled")
+		if err := exprhelpers.InitCrowdsecCTI(cConfig.API.CTI.Key, cConfig.API.CTI.CacheTimeout, cConfig.API.CTI.CacheSize, cConfig.API.CTI.LogLevel); err != nil {
+			return errors.Wrap(err, "failed to init crowdsec cti")
+		}
+	}
+
 	if !cConfig.DisableAPI {
 	if !cConfig.DisableAPI {
 		if cConfig.API.Server.OnlineClient == nil || cConfig.API.Server.OnlineClient.Credentials == nil {
 		if cConfig.API.Server.OnlineClient == nil || cConfig.API.Server.OnlineClient.Credentials == nil {
 			log.Warningf("Communication with CrowdSec Central API disabled from configuration file")
 			log.Warningf("Communication with CrowdSec Central API disabled from configuration file")

+ 2 - 0
go.sum

@@ -120,6 +120,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
 github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
 github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
 github.com/blackfireio/osinfo v1.0.3 h1:Yk2t2GTPjBcESv6nDSWZKO87bGMQgO+Hi9OoXPpxX8c=
 github.com/blackfireio/osinfo v1.0.3 h1:Yk2t2GTPjBcESv6nDSWZKO87bGMQgO+Hi9OoXPpxX8c=
 github.com/blackfireio/osinfo v1.0.3/go.mod h1:Pd987poVNmd5Wsx6PRPw4+w7kLlf9iJxoRKPtPAjOrA=
 github.com/blackfireio/osinfo v1.0.3/go.mod h1:Pd987poVNmd5Wsx6PRPw4+w7kLlf9iJxoRKPtPAjOrA=
+github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
+github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
 github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
 github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
 github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
 github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
 github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
 github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=

+ 19 - 5
pkg/apiserver/controllers/v1/alerts.go

@@ -159,12 +159,13 @@ func (c *Controller) CreateAlert(gctx *gin.Context) {
 		}
 		}
 
 
 		alert.MachineID = machineID
 		alert.MachineID = machineID
+		//if coming from cscli, alert already has decisions
 		if len(alert.Decisions) != 0 {
 		if len(alert.Decisions) != 0 {
 			for pIdx, profile := range c.Profiles {
 			for pIdx, profile := range c.Profiles {
 				_, matched, err := profile.EvaluateProfile(alert)
 				_, matched, err := profile.EvaluateProfile(alert)
 				if err != nil {
 				if err != nil {
-					gctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
-					return
+					profile.Logger.Warningf("error while evaluating profile %s : %v", profile.Cfg.Name, err)
+					continue
 				}
 				}
 				if !matched {
 				if !matched {
 					continue
 					continue
@@ -183,9 +184,22 @@ func (c *Controller) CreateAlert(gctx *gin.Context) {
 
 
 		for pIdx, profile := range c.Profiles {
 		for pIdx, profile := range c.Profiles {
 			profileDecisions, matched, err := profile.EvaluateProfile(alert)
 			profileDecisions, matched, err := profile.EvaluateProfile(alert)
+			forceBreak := false
 			if err != nil {
 			if err != nil {
-				gctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
-				return
+				switch profile.Cfg.OnError {
+				case "apply":
+					profile.Logger.Warningf("applying profile %s despite error: %s", profile.Cfg.Name, err)
+					matched = true
+				case "continue":
+					profile.Logger.Warningf("skipping %s profile due to error: %s", profile.Cfg.Name, err)
+				case "break":
+					forceBreak = true
+				case "ignore":
+					profile.Logger.Warningf("ignoring error: %s", err)
+				default:
+					gctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
+					return
+				}
 			}
 			}
 
 
 			if !matched {
 			if !matched {
@@ -197,7 +211,7 @@ func (c *Controller) CreateAlert(gctx *gin.Context) {
 			}
 			}
 			profileAlert := *alert
 			profileAlert := *alert
 			c.sendAlertToPluginChannel(&profileAlert, uint(pIdx))
 			c.sendAlertToPluginChannel(&profileAlert, uint(pIdx))
-			if profile.Cfg.OnSuccess == "break" {
+			if profile.Cfg.OnSuccess == "break" || forceBreak {
 				break
 				break
 			}
 			}
 		}
 		}

+ 42 - 1
pkg/csconfig/api.go

@@ -21,6 +21,7 @@ import (
 type APICfg struct {
 type APICfg struct {
 	Client *LocalApiClientCfg `yaml:"client"`
 	Client *LocalApiClientCfg `yaml:"client"`
 	Server *LocalApiServerCfg `yaml:"server"`
 	Server *LocalApiServerCfg `yaml:"server"`
+	CTI    *CTICfg            `yaml:"cti"`
 }
 }
 
 
 type ApiCredentialsCfg struct {
 type ApiCredentialsCfg struct {
@@ -45,6 +46,37 @@ type LocalApiClientCfg struct {
 	InsecureSkipVerify  *bool              `yaml:"insecure_skip_verify"` // check if api certificate is bad or not
 	InsecureSkipVerify  *bool              `yaml:"insecure_skip_verify"` // check if api certificate is bad or not
 }
 }
 
 
+type CTICfg struct {
+	Key          *string        `yaml:"key,omitempty"`
+	CacheTimeout *time.Duration `yaml:"cache_timeout,omitempty"`
+	CacheSize    *int           `yaml:"cache_size,omitempty"`
+	Enabled      *bool          `yaml:"enabled,omitempty"`
+	LogLevel     *log.Level     `yaml:"log_level,omitempty"`
+}
+
+func (a *CTICfg) Load() error {
+
+	if a.Key == nil {
+		*a.Enabled = false
+	}
+	if a.Key != nil && *a.Key == "" {
+		return fmt.Errorf("empty cti key")
+	}
+	if a.Enabled == nil {
+		a.Enabled = new(bool)
+		*a.Enabled = true
+	}
+	if a.CacheTimeout == nil {
+		a.CacheTimeout = new(time.Duration)
+		*a.CacheTimeout = 10 * time.Minute
+	}
+	if a.CacheSize == nil {
+		a.CacheSize = new(int)
+		*a.CacheSize = 100
+	}
+	return nil
+}
+
 func (o *OnlineApiClientCfg) Load() error {
 func (o *OnlineApiClientCfg) Load() error {
 	o.Credentials = new(ApiCredentialsCfg)
 	o.Credentials = new(ApiCredentialsCfg)
 	fcontent, err := os.ReadFile(o.CredentialsFilePath)
 	fcontent, err := os.ReadFile(o.CredentialsFilePath)
@@ -92,7 +124,7 @@ func (l *LocalApiClientCfg) Load() error {
 		apiclient.InsecureSkipVerify = *l.InsecureSkipVerify
 		apiclient.InsecureSkipVerify = *l.InsecureSkipVerify
 	}
 	}
 
 
-	if l.Credentials.CACertPath != ""  {
+	if l.Credentials.CACertPath != "" {
 		caCert, err := os.ReadFile(l.Credentials.CACertPath)
 		caCert, err := os.ReadFile(l.Credentials.CACertPath)
 		if err != nil {
 		if err != nil {
 			return errors.Wrapf(err, "failed to load cacert")
 			return errors.Wrapf(err, "failed to load cacert")
@@ -230,6 +262,15 @@ func (c *Config) LoadAPIServer() error {
 			return errors.Wrap(err, "loading online client credentials")
 			return errors.Wrap(err, "loading online client credentials")
 		}
 		}
 	}
 	}
+	if c.API.Server.OnlineClient == nil || c.API.Server.OnlineClient.Credentials == nil {
+		log.Printf("push and pull to Central API disabled")
+	}
+
+	if c.API.CTI != nil {
+		if err := c.API.CTI.Load(); err != nil {
+			return errors.Wrap(err, "loading CTI configuration")
+		}
+	}
 
 
 	if err := c.LoadDBConfig(); err != nil {
 	if err := c.LoadDBConfig(); err != nil {
 		return err
 		return err

+ 3 - 0
pkg/csconfig/config.go

@@ -110,6 +110,9 @@ func NewDefaultConfig() *Config {
 				CredentialsFilePath: DefaultConfigPath("config", "online-api-secrets.yaml"),
 				CredentialsFilePath: DefaultConfigPath("config", "online-api-secrets.yaml"),
 			},
 			},
 		},
 		},
+		CTI: &CTICfg{
+			Enabled: types.BoolPtr(false),
+		},
 	}
 	}
 
 
 	dbConfig := DatabaseCfg{
 	dbConfig := DatabaseCfg{

+ 8 - 1
pkg/csconfig/profiles.go

@@ -11,7 +11,13 @@ import (
 	"gopkg.in/yaml.v2"
 	"gopkg.in/yaml.v2"
 )
 )
 
 
-//Profile structure(s) are used by the local API to "decide" what kind of decision should be applied when a scenario with an active remediation has been triggered
+// var OnErrorDefault = OnErrorIgnore
+// var OnErrorContinue = "continue"
+// var OnErrorBreak = "break"
+// var OnErrorApply = "apply"
+// var OnErrorIgnore = "ignore"
+
+// Profile structure(s) are used by the local API to "decide" what kind of decision should be applied when a scenario with an active remediation has been triggered
 type ProfileCfg struct {
 type ProfileCfg struct {
 	Name          string            `yaml:"name,omitempty"`
 	Name          string            `yaml:"name,omitempty"`
 	Debug         *bool             `yaml:"debug,omitempty"`
 	Debug         *bool             `yaml:"debug,omitempty"`
@@ -20,6 +26,7 @@ type ProfileCfg struct {
 	DurationExpr  string            `yaml:"duration_expr,omitempty"`
 	DurationExpr  string            `yaml:"duration_expr,omitempty"`
 	OnSuccess     string            `yaml:"on_success,omitempty"` //continue or break
 	OnSuccess     string            `yaml:"on_success,omitempty"` //continue or break
 	OnFailure     string            `yaml:"on_failure,omitempty"` //continue or break
 	OnFailure     string            `yaml:"on_failure,omitempty"` //continue or break
+	OnError       string            `yaml:"on_error,omitempty"`   //continue, break, error, report, apply, ignore
 	Notifications []string          `yaml:"notifications,omitempty"`
 	Notifications []string          `yaml:"notifications,omitempty"`
 }
 }
 
 

+ 2 - 0
pkg/csplugin/helpers.go

@@ -3,6 +3,7 @@ package csplugin
 import (
 import (
 	"text/template"
 	"text/template"
 
 
+	"github.com/crowdsecurity/crowdsec/pkg/exprhelpers"
 	"github.com/crowdsecurity/crowdsec/pkg/models"
 	"github.com/crowdsecurity/crowdsec/pkg/models"
 )
 )
 
 
@@ -18,6 +19,7 @@ var helpers = template.FuncMap{
 		}
 		}
 		return metaValues
 		return metaValues
 	},
 	},
+	"CrowdsecCTI": exprhelpers.CrowdsecCTI,
 }
 }
 
 
 func funcMap() template.FuncMap {
 func funcMap() template.FuncMap {

+ 7 - 2
pkg/csprofiles/csprofiles.go

@@ -46,7 +46,12 @@ func NewProfile(profilesCfg []*csconfig.ProfileCfg) ([]*Runtime, error) {
 		runtime.RuntimeFilters = make([]*vm.Program, len(profile.Filters))
 		runtime.RuntimeFilters = make([]*vm.Program, len(profile.Filters))
 		runtime.DebugFilters = make([]*exprhelpers.ExprDebugger, len(profile.Filters))
 		runtime.DebugFilters = make([]*exprhelpers.ExprDebugger, len(profile.Filters))
 		runtime.Cfg = profile
 		runtime.Cfg = profile
-
+		if runtime.Cfg.OnSuccess != "" && runtime.Cfg.OnSuccess != "continue" && runtime.Cfg.OnSuccess != "break" {
+			return []*Runtime{}, errors.Wrapf(err, "invalid 'on_success' for '%s' : %s", profile.Name, runtime.Cfg.OnSuccess)
+		}
+		if runtime.Cfg.OnFailure != "" && runtime.Cfg.OnFailure != "continue" && runtime.Cfg.OnFailure != "break" && runtime.Cfg.OnFailure != "apply" {
+			return []*Runtime{}, errors.Wrapf(err, "invalid 'on_failure' for '%s' : %s", profile.Name, runtime.Cfg.OnFailure)
+		}
 		for fIdx, filter := range profile.Filters {
 		for fIdx, filter := range profile.Filters {
 			if runtimeFilter, err = expr.Compile(filter, expr.Env(exprhelpers.GetExprEnv(map[string]interface{}{"Alert": &models.Alert{}}))); err != nil {
 			if runtimeFilter, err = expr.Compile(filter, expr.Env(exprhelpers.GetExprEnv(map[string]interface{}{"Alert": &models.Alert{}}))); err != nil {
 				return []*Runtime{}, errors.Wrapf(err, "error compiling filter of '%s'", profile.Name)
 				return []*Runtime{}, errors.Wrapf(err, "error compiling filter of '%s'", profile.Name)
@@ -153,7 +158,7 @@ func (Profile *Runtime) GenerateDecisionFromProfile(Alert *models.Alert) ([]*mod
 	return decisions, nil
 	return decisions, nil
 }
 }
 
 
-//EvaluateProfile is going to evaluate an Alert against a profile to generate Decisions
+// EvaluateProfile is going to evaluate an Alert against a profile to generate Decisions
 func (Profile *Runtime) EvaluateProfile(Alert *models.Alert) ([]*models.Decision, bool, error) {
 func (Profile *Runtime) EvaluateProfile(Alert *models.Alert) ([]*models.Decision, bool, error) {
 	var decisions []*models.Decision
 	var decisions []*models.Decision
 
 

+ 157 - 0
pkg/cticlient/client.go

@@ -0,0 +1,157 @@
+package cticlient
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+
+	log "github.com/sirupsen/logrus"
+)
+
+const (
+	CTIBaseUrl    = "https://cti.api.crowdsec.net/v2"
+	smokeEndpoint = "/smoke"
+	fireEndpoint  = "/fire"
+)
+
+var (
+	ErrUnauthorized = errors.New("unauthorized")
+	ErrLimit        = errors.New("request quota exceeded, please reduce your request rate")
+	ErrNotFound     = errors.New("ip not found")
+	ErrDisabled     = errors.New("cti is disabled")
+	ErrUnknown      = errors.New("unknown error")
+)
+
+type CrowdsecCTIClient struct {
+	httpClient *http.Client
+	apiKey     string
+	Logger     *log.Entry
+}
+
+func (c *CrowdsecCTIClient) doRequest(method string, endpoint string, params map[string]string) ([]byte, error) {
+	url := CTIBaseUrl + endpoint
+	if len(params) > 0 {
+		url += "?"
+		for k, v := range params {
+			url += fmt.Sprintf("%s=%s&", k, v)
+		}
+	}
+	req, err := http.NewRequest(method, url, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("x-api-key", c.apiKey)
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		if resp.StatusCode == http.StatusForbidden {
+			return nil, ErrUnauthorized
+		}
+		if resp.StatusCode == http.StatusTooManyRequests {
+			return nil, ErrLimit
+		}
+		if resp.StatusCode == http.StatusNotFound {
+			return nil, ErrNotFound
+		}
+		return nil, fmt.Errorf("unexpected http code : %s", resp.Status)
+	}
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+	return respBody, nil
+}
+
+func (c *CrowdsecCTIClient) GetIPInfo(ip string) (*SmokeItem, error) {
+	body, err := c.doRequest(http.MethodGet, smokeEndpoint+"/"+ip, nil)
+	if err != nil {
+		if err == ErrNotFound {
+			return &SmokeItem{}, nil
+		}
+		return nil, err
+	}
+	item := SmokeItem{}
+	err = json.Unmarshal(body, &item)
+	if err != nil {
+		return nil, err
+	}
+	return &item, nil
+}
+
+func (c *CrowdsecCTIClient) SearchIPs(ips []string) (*SearchIPResponse, error) {
+	params := make(map[string]string)
+	params["ips"] = strings.Join(ips, ",")
+	body, err := c.doRequest(http.MethodGet, smokeEndpoint, params)
+	if err != nil {
+		return nil, err
+	}
+	searchIPResponse := SearchIPResponse{}
+	err = json.Unmarshal(body, &searchIPResponse)
+	if err != nil {
+		return nil, err
+	}
+	return &searchIPResponse, nil
+}
+
+func (c *CrowdsecCTIClient) Fire(params FireParams) (*FireResponse, error) {
+	paramsMap := make(map[string]string)
+	if params.Page != nil {
+		paramsMap["page"] = fmt.Sprintf("%d", *params.Page)
+	}
+	if params.Since != nil {
+		paramsMap["since"] = *params.Since
+	}
+	if params.Limit != nil {
+		paramsMap["limit"] = fmt.Sprintf("%d", *params.Limit)
+	}
+
+	body, err := c.doRequest(http.MethodGet, fireEndpoint, paramsMap)
+	if err != nil {
+		return nil, err
+	}
+	fireResponse := FireResponse{}
+	err = json.Unmarshal(body, &fireResponse)
+	if err != nil {
+		return nil, err
+	}
+	return &fireResponse, nil
+}
+
+func NewCrowdsecCTIClient(options ...func(*CrowdsecCTIClient)) *CrowdsecCTIClient {
+	client := &CrowdsecCTIClient{}
+	for _, option := range options {
+		option(client)
+	}
+	if client.httpClient == nil {
+		client.httpClient = &http.Client{}
+	}
+	// we cannot return with a ni logger, so we set a default one
+	if client.Logger == nil {
+		client.Logger = log.NewEntry(log.New())
+	}
+	return client
+}
+
+func WithLogger(logger *log.Entry) func(*CrowdsecCTIClient) {
+	return func(c *CrowdsecCTIClient) {
+		c.Logger = logger
+	}
+}
+
+func WithHTTPClient(httpClient *http.Client) func(*CrowdsecCTIClient) {
+	return func(c *CrowdsecCTIClient) {
+		c.httpClient = httpClient
+	}
+}
+
+func WithAPIKey(apiKey string) func(*CrowdsecCTIClient) {
+	return func(c *CrowdsecCTIClient) {
+		c.apiKey = apiKey
+	}
+}

File diff suppressed because it is too large
+ 22 - 0
pkg/cticlient/client_test.go


+ 303 - 0
pkg/cticlient/cti_test.go

@@ -0,0 +1,303 @@
+package cticlient
+
+// import (
+// 	"encoding/json"
+// 	"net/http"
+// 	"net/http/httptest"
+// 	"net/url"
+// 	"strings"
+// 	"testing"
+// 	"time"
+
+// 	"github.com/stretchr/testify/assert"
+// )
+
+// var sampledata = map[string]CTIResponse{
+// 	//1.2.3.4 is a known false positive
+// 	"1.2.3.4": {
+// 		Ip: "1.2.3.4",
+// 		Classifications: CTIClassifications{
+// 			FalsePositives: []CTIClassification{
+// 				{
+// 					Name:  "example_false_positive",
+// 					Label: "Example False Positive",
+// 				},
+// 			},
+// 		},
+// 	},
+// 	//1.2.3.5 is a known bad-guy, and part of FIRE
+// 	"1.2.3.5": {
+// 		Ip: "1.2.3.5",
+// 		Classifications: CTIClassifications{
+// 			Classifications: []CTIClassification{
+// 				{
+// 					Name:        "community-blocklist",
+// 					Label:       "CrowdSec Community Blocklist",
+// 					Description: "IP belong to the CrowdSec Community Blocklist",
+// 				},
+// 			},
+// 		},
+// 	},
+// 	//1.2.3.6 is a bad guy (high bg noise), but not in FIRE
+// 	"1.2.3.6": {
+// 		Ip:                   "1.2.3.6",
+// 		BackgroundNoiseScore: new(int),
+// 		Behaviors: []*CTIBehavior{
+// 			{Name: "ssh:bruteforce", Label: "SSH Bruteforce", Description: "SSH Bruteforce"},
+// 		},
+// 		AttackDetails: []*CTIAttackDetails{
+// 			{Name: "crowdsecurity/ssh-bf", Label: "Example Attack"},
+// 			{Name: "crowdsecurity/ssh-slow-bf", Label: "Example Attack"},
+// 		},
+// 	},
+// 	//1.2.3.7 is a ok guy, but part of a bad range
+// 	"1.2.3.7": CTIResponse{},
+// }
+
+// func EmptyCTIResponse(ip string) CTIResponse {
+// 	return CTIResponse{
+// 		IpRangeScore: 0,
+// 		Ip:           ip,
+// 		Location:     CTILocationInfo{},
+// 	}
+// }
+
+// /*
+// TBD : Simulate correctly quotas exhaustion
+// */
+// func setup() (Router *http.ServeMux, serverURL string, teardown func()) {
+
+// 	//set static values
+// 	*sampledata["1.2.3.6"].BackgroundNoiseScore = 10
+
+// 	// mux is the HTTP request multiplexer used with the test server.
+// 	Router = http.NewServeMux()
+// 	baseURLPath := "/v2"
+
+// 	apiHandler := http.NewServeMux()
+// 	apiHandler.Handle(baseURLPath+"/", http.StripPrefix(baseURLPath, Router))
+
+// 	// server is a test HTTP server used to provide mock API responses.
+// 	server := httptest.NewServer(apiHandler)
+
+// 	// let's mock the API endpoints
+// 	Router.HandleFunc("/smoke/", func(w http.ResponseWriter, r *http.Request) {
+// 		//testMethod(t, r, "GET")
+// 		if r.Header.Get("X-Api-Key") != "EXAMPLE_API_KEY" {
+// 			w.WriteHeader(http.StatusForbidden)
+// 			w.Write([]byte(`{"message":"Forbidden"}`))
+// 			return
+// 		}
+
+// 		frags := strings.Split(r.RequestURI, "/")
+// 		//[empty] [smoke] [v2] [actual_ip]
+// 		if len(frags) != 4 {
+// 			w.WriteHeader(http.StatusBadRequest)
+// 			w.Write([]byte(`{"message":"Bad Request"}`))
+// 			return
+// 		}
+// 		ip := frags[3]
+
+// 		if ip == "" {
+// 			//to be fixed to stick w/ real behavior
+// 			panic("empty ip")
+
+// 		}
+// 		// vars := mux.Vars(r)
+// 		if v, ok := sampledata[ip]; ok {
+// 			data, err := json.Marshal(v)
+// 			if err != nil {
+// 				panic("unable to marshal")
+// 			}
+// 			w.WriteHeader(http.StatusOK)
+// 			w.Write(data)
+// 			return
+// 		}
+// 		w.WriteHeader(http.StatusOK)
+// 		data, err := json.Marshal(EmptyCTIResponse(ip))
+// 		if err != nil {
+// 			panic("unable to marshal")
+// 		}
+// 		w.Write(data)
+// 		return
+// 	})
+// 	return Router, server.URL, server.Close
+// }
+
+// func TestCTIAuthKO(t *testing.T) {
+
+// 	_, urlx, teardown := setup()
+// 	apiURL, err := url.Parse(urlx + "/")
+// 	if err != nil {
+// 		t.Fatalf("parsing api url: %s", apiURL)
+// 	}
+
+// 	defer teardown()
+// 	defer ShutdownCTI()
+// 	CTIUrl = urlx
+// 	key := "BAD_KEY"
+// 	if err := InitCTI(&key, nil, nil); err != nil {
+// 		t.Fatalf("InitCTI failed: %s", err)
+// 	}
+
+// 	ret := IpCTI("1.2.3.4")
+// 	assert.Equal(t, false, ret.Ok(), "should be ko")
+// 	assert.Equal(t, CTIResponse{}, ret, "auth failed, empty answer")
+// 	assert.Equal(t, CTIApiEnabled, false, "auth failed, api disabled")
+// 	//auth is disabled, we should always receive empty object
+// 	ret = IpCTI("1.2.3.4")
+// 	assert.Equal(t, false, ret.Ok(), "should be ko")
+// 	assert.Equal(t, CTIResponse{}, ret, "auth failed, empty answer")
+// }
+
+// func TestCTINoKey(t *testing.T) {
+
+// 	_, urlx, teardown := setup()
+// 	apiURL, err := url.Parse(urlx + "/")
+// 	if err != nil {
+// 		t.Fatalf("parsing api url: %s", apiURL)
+// 	}
+
+// 	defer teardown()
+// 	defer ShutdownCTI()
+// 	CTIUrl = urlx
+// 	//key := ""
+// 	err = InitCTI(nil, nil, nil)
+// 	assert.NotEqual(t, err, nil, "InitCTI should fail")
+// 	ret := IpCTI("1.2.3.4")
+// 	assert.Equal(t, false, ret.Ok(), "should be ko")
+// 	assert.Equal(t, CTIResponse{}, ret, "auth failed, empty answer")
+// 	assert.Equal(t, CTIApiEnabled, false, "auth failed, api disabled")
+// }
+
+// func TestCTIAuthOK(t *testing.T) {
+
+// 	_, urlx, teardown := setup()
+// 	apiURL, err := url.Parse(urlx + "/")
+// 	if err != nil {
+// 		t.Fatalf("parsing api url: %s", apiURL)
+// 	}
+
+// 	defer teardown()
+// 	defer ShutdownCTI()
+
+// 	CTIUrl = urlx
+// 	key := "EXAMPLE_API_KEY"
+// 	if err := InitCTI(&key, nil, nil); err != nil {
+// 		t.Fatalf("InitCTI failed: %s", err)
+// 	}
+
+// 	ret := IpCTI("1.2.3.4")
+// 	assert.Equal(t, true, ret.Ok(), "should be ok")
+// 	assert.Equal(t, "1.2.3.4", ret.Ip, "auth failed, empty answer")
+// 	assert.Equal(t, CTIApiEnabled, true, "auth failed, api disabled")
+// }
+// func TestCTIKnownFP(t *testing.T) {
+// 	_, urlx, teardown := setup()
+// 	apiURL, err := url.Parse(urlx + "/")
+// 	if err != nil {
+// 		t.Fatalf("parsing api url: %s", apiURL)
+// 	}
+
+// 	defer teardown()
+// 	defer ShutdownCTI()
+
+// 	CTIUrl = urlx
+// 	key := "EXAMPLE_API_KEY"
+// 	if err := InitCTI(&key, nil, nil); err != nil {
+// 		t.Fatalf("InitCTI failed: %s", err)
+// 	}
+
+// 	ret := IpCTI("1.2.3.4")
+// 	assert.Equal(t, true, ret.Ok(), "should be ok")
+// 	assert.Equal(t, "1.2.3.4", ret.Ip, "auth failed, empty answer")
+// 	assert.Equal(t, ret.IsFalsePositive(), true, "1.2.3.4 is a known false positive")
+// }
+
+// func TestCTIBelongsToFire(t *testing.T) {
+// 	_, urlx, teardown := setup()
+// 	apiURL, err := url.Parse(urlx + "/")
+// 	if err != nil {
+// 		t.Fatalf("parsing api url: %s", apiURL)
+// 	}
+
+// 	defer teardown()
+// 	defer ShutdownCTI()
+
+// 	CTIUrl = urlx
+// 	key := "EXAMPLE_API_KEY"
+// 	if err := InitCTI(&key, nil, nil); err != nil {
+// 		t.Fatalf("InitCTI failed: %s", err)
+// 	}
+
+// 	ret := IpCTI("1.2.3.5")
+// 	assert.Equal(t, true, ret.Ok(), "should be ok")
+// 	assert.Equal(t, "1.2.3.5", ret.Ip, "auth failed, empty answer")
+// 	assert.Equal(t, ret.IsPartOfCommunityBlocklist(), true, "1.2.3.5 is a known false positive")
+// }
+
+// func TestCTIBehaviors(t *testing.T) {
+// 	_, urlx, teardown := setup()
+// 	apiURL, err := url.Parse(urlx + "/")
+// 	if err != nil {
+// 		t.Fatalf("parsing api url: %s", apiURL)
+// 	}
+
+// 	defer teardown()
+// 	defer ShutdownCTI()
+
+// 	CTIUrl = urlx
+// 	key := "EXAMPLE_API_KEY"
+// 	if err := InitCTI(&key, nil, nil); err != nil {
+// 		t.Fatalf("InitCTI failed: %s", err)
+// 	}
+
+// 	ret := IpCTI("1.2.3.6")
+// 	assert.Equal(t, true, ret.Ok(), "should be ok")
+// 	assert.Equal(t, ret.Ip, "1.2.3.6", "auth failed, empty answer")
+// 	//ssh:bruteforce
+// 	assert.Equal(t, []string{"ssh:bruteforce"}, ret.GetBehaviors(), "error matching behaviors")
+// 	assert.Equal(t, []string{"crowdsecurity/ssh-bf", "crowdsecurity/ssh-slow-bf"}, ret.GetAttackDetails(), "error matching behaviors")
+// 	assert.Equal(t, 10, ret.GetBackgroundNoiseScore(), "error matching bg noise")
+// }
+
+// func TestCacheFetch(t *testing.T) {
+// 	_, urlx, teardown := setup()
+// 	apiURL, err := url.Parse(urlx + "/")
+// 	if err != nil {
+// 		t.Fatalf("parsing api url: %s", apiURL)
+// 	}
+
+// 	defer teardown()
+// 	defer ShutdownCTI()
+
+// 	CTIUrl = urlx
+// 	key := "EXAMPLE_API_KEY"
+// 	ttl := 1 * time.Second
+// 	if err := InitCTI(&key, &ttl, nil); err != nil {
+// 		t.Fatalf("InitCTI failed: %s", err)
+// 	}
+
+// 	ret := IpCTI("1.2.3.6")
+// 	assert.Equal(t, true, ret.Ok(), "should be ok")
+// 	assert.Equal(t, "1.2.3.6", ret.Ip, "initial fetch : bad item")
+// 	assert.Equal(t, 1, CTICache.Len(true), "initial fetch : bad cache size")
+// 	assert.Equal(t, "1.2.3.6", CTICache.Keys(true)[0].(string), "initial fetch : bad cache keys")
+// 	//get it a second time before it expires
+// 	ret = IpCTI("1.2.3.6")
+// 	assert.Equal(t, true, ret.Ok(), "should be ok")
+// 	assert.Equal(t, "1.2.3.6", ret.Ip, "initial fetch : bad item")
+// 	assert.Equal(t, 1, CTICache.Len(true), "initial fetch : bad cache size")
+// 	assert.Equal(t, "1.2.3.6", CTICache.Keys(true)[0].(string), "initial fetch : bad cache keys")
+// 	//let data expire
+// 	time.Sleep(1 * time.Second)
+// 	assert.Equal(t, 0, CTICache.Len(true), "after ttl : bad cache size")
+// 	//fetch again
+// 	ret = IpCTI("1.2.3.6")
+// 	assert.Equal(t, true, ret.Ok(), "should be ok")
+// 	assert.Equal(t, "1.2.3.6", ret.Ip, "second fetch : bad item")
+// 	assert.Equal(t, 1, CTICache.Len(true), "second fetch : bad cache size")
+// 	assert.Equal(t, "1.2.3.6", CTICache.Keys(true)[0].(string), "initial fetch : bad cache keys")
+// }
+
+// //GetMaliciousnessScore

+ 59 - 0
pkg/cticlient/example/fire.go

@@ -0,0 +1,59 @@
+package main
+
+import (
+	"encoding/csv"
+	"fmt"
+	"os"
+	"time"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cticlient"
+)
+
+func intPtr(i int) *int {
+	return &i
+}
+
+func main() {
+	client := cticlient.NewCrowdsecCTIClient(cticlient.WithAPIKey(os.Getenv("CTI_API_KEY")))
+	paginator := cticlient.NewFirePaginator(client, cticlient.FireParams{
+		Limit: intPtr(1000),
+	})
+
+	csvHeader := []string{
+		"value",
+		"reason",
+		"type",
+		"scope",
+		"duration",
+	}
+	csvFile, err := os.Create("fire.csv")
+	if err != nil {
+		panic(err)
+	}
+	defer csvFile.Close()
+	csvWriter := csv.NewWriter(csvFile)
+	allItems := make([][]string, 0)
+
+	for {
+		items, err := paginator.Next()
+		if err != nil {
+			panic(err)
+		}
+		if items == nil {
+			break
+		}
+
+		for _, item := range items {
+			banDuration := time.Until(item.Expiration.Time)
+			allItems = append(allItems, []string{
+				item.Ip,
+				"fire-import",
+				"ban",
+				"ip",
+				fmt.Sprintf("%ds", int(banDuration.Seconds())),
+			})
+		}
+	}
+	csvWriter.Write(csvHeader)
+	csvWriter.WriteAll(allItems)
+}

+ 36 - 0
pkg/cticlient/pagination.go

@@ -0,0 +1,36 @@
+package cticlient
+
+type FirePaginator struct {
+	client      *CrowdsecCTIClient
+	params      FireParams
+	currentPage int
+	done        bool
+}
+
+func (p *FirePaginator) Next() ([]FireItem, error) {
+	if p.done {
+		return nil, nil
+	}
+	p.params.Page = &p.currentPage
+	resp, err := p.client.Fire(p.params)
+	if err != nil {
+		return nil, err
+	}
+	p.currentPage++
+	if resp.Links.Next == nil {
+		p.done = true
+	}
+	return resp.Items, nil
+}
+
+func NewFirePaginator(client *CrowdsecCTIClient, params FireParams) *FirePaginator {
+	startPage := 1
+	if params.Page != nil {
+		startPage = *params.Page
+	}
+	return &FirePaginator{
+		client:      client,
+		params:      params,
+		currentPage: startPage,
+	}
+}

+ 320 - 0
pkg/cticlient/tests/fire-page1.json

@@ -0,0 +1,320 @@
+{
+  "_links": {
+    "first": {
+      "href": "https://cti.api.crowdsec.net/v2/fire"
+    },
+    "self": {
+      "href": "https://cti.api.crowdsec.net/v2/fire?page=1&limit=3"
+    },
+    "next": {
+      "href": "https://cti.api.crowdsec.net/v2/fire?page=2&limit=3"
+    }
+  },
+  "items": [
+    {
+      "ip_range_score": 5,
+      "ip": "1.2.3.4",
+      "ip_range": "1.2.3.0/24",
+      "as_name": "AFFINITY-FTL",
+      "as_num": 3064,
+      "location": {
+        "country": "US",
+        "city": null,
+        "latitude": 37.751,
+        "longitude": -97.822
+      },
+      "reverse_dns": "lsxx.com",
+      "behaviors": [
+        {
+          "name": "http:bruteforce",
+          "label": "HTTP Bruteforce",
+          "description": "IP has been reported for performing a HTTP brute force attack (either generic http probing or applicative related brute force)."
+        },
+        {
+          "name": "http:scan",
+          "label": "HTTP Scan",
+          "description": "IP has been reported for performing actions related to HTTP vulnerability scanning and discovery."
+        }
+      ],
+      "history": {
+        "first_seen": "2022-09-18T14:00:00+00:00",
+        "last_seen": "2022-11-26T12:00:00+00:00",
+        "full_age": 77,
+        "days_age": 69
+      },
+      "classifications": {
+        "false_positives": [],
+        "classifications": []
+      },
+      "attack_details": [
+        {
+          "name": "crowdsecurity/http-wordpress_user-enum",
+          "label": "WordPress Bruteforce",
+          "description": "Detect wordpress brute force",
+          "references": []
+        },
+        {
+          "name": "crowdsecurity/http-probing",
+          "label": "HTTP Scanner",
+          "description": "Detect site scanning/probing from a single ip",
+          "references": []
+        },
+        {
+          "name": "crowdsecurity/http-bf-wordpress_bf_xmlrpc",
+          "label": "WordPress XMLRPC Bruteforce",
+          "description": "Detect wordpress brute force on xmlrpc",
+          "references": []
+        },
+        {
+          "name": "crowdsecurity/http-bad-user-agent",
+          "label": "Known Bad User-Agent",
+          "description": "Detect bad user-agents",
+          "references": []
+        }
+      ],
+      "state": "validated",
+      "expiration": "2022-12-11T14:15:47.553000",
+      "target_countries": {
+        "US": 43,
+        "DE": 20,
+        "NL": 8,
+        "GB": 7,
+        "FR": 6,
+        "PL": 3,
+        "SG": 2,
+        "CA": 2,
+        "DK": 2,
+        "ZA": 1
+      },
+      "background_noise_score": 5,
+      "scores": {
+        "overall": {
+          "aggressiveness": 5,
+          "threat": 0,
+          "trust": 5,
+          "anomaly": 0,
+          "total": 3
+        },
+        "last_day": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 0,
+          "total": 0
+        },
+        "last_week": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 0,
+          "total": 0
+        },
+        "last_month": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 0,
+          "total": 0
+        }
+      },
+      "references": []
+    },
+    {
+      "ip_range_score": 5,
+      "ip": "2.3.4.5",
+      "ip_range": "2.3.0./16",
+      "as_name": "Linode, LLC",
+      "as_num": 63949,
+      "location": {
+        "country": "DE",
+        "city": "Frankfurt am Main",
+        "latitude": 50.1188,
+        "longitude": 8.6843
+      },
+      "reverse_dns": "172xxent.com",
+      "behaviors": [
+        {
+          "name": "http:exploit",
+          "label": "HTTP Exploit",
+          "description": "IP has been reported for attempting to exploit a vulnerability in a web application."
+        },
+        {
+          "name": "http:scan",
+          "label": "HTTP Scan",
+          "description": "IP has been reported for performing actions related to HTTP vulnerability scanning and discovery."
+        },
+        {
+          "name": "http:crawl",
+          "label": "HTTP Crawl",
+          "description": "IP has been reported for performing aggressive crawling of web applications."
+        }
+      ],
+      "history": {
+        "first_seen": "2022-10-15T16:00:00+00:00",
+        "last_seen": "2022-11-18T18:15:00+00:00",
+        "full_age": 50,
+        "days_age": 35
+      },
+      "classifications": {
+        "false_positives": [],
+        "classifications": []
+      },
+      "attack_details": [
+        {
+          "name": "crowdsecurity/jira_cve-2021-26086",
+          "label": "Atlassian Jira CVE-2021-26086",
+          "description": "Detect Atlassian Jira CVE-2021-26086 exploitation attemps",
+          "references": []
+        },
+        {
+          "name": "crowdsecurity/http-probing",
+          "label": "HTTP Scanner",
+          "description": "Detect site scanning/probing from a single ip",
+          "references": []
+        },
+        {
+          "name": "crowdsecurity/CVE-2022-40684",
+          "label": "CVE-2022-40684",
+          "description": "Detect CVE-2022-40684 exploitation attempts (fortinet)",
+          "references": [
+            "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-40684"
+          ]
+        },
+        {
+          "name": "crowdsecurity/http-crawl-non_statics",
+          "label": "HTTP Crawler",
+          "description": "Detect aggressive crawl from single ip",
+          "references": []
+        }
+      ],
+      "state": "validated",
+      "expiration": "2022-12-14T16:16:46.507000",
+      "target_countries": {
+        "US": 36,
+        "DE": 19,
+        "FR": 17,
+        "RU": 8,
+        "NL": 5,
+        "GB": 4,
+        "CA": 2,
+        "RO": 2,
+        "IT": 1,
+        "BR": 1
+      },
+      "background_noise_score": 9,
+      "scores": {
+        "overall": {
+          "aggressiveness": 5,
+          "threat": 2,
+          "trust": 5,
+          "anomaly": 0,
+          "total": 4
+        },
+        "last_day": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 0,
+          "total": 0
+        },
+        "last_week": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 0,
+          "total": 0
+        },
+        "last_month": {
+          "aggressiveness": 2,
+          "threat": 2,
+          "trust": 0,
+          "anomaly": 0,
+          "total": 1
+        }
+      },
+      "references": []
+    },
+    {
+      "ip_range_score": 0,
+      "ip": "3.2.3.4",
+      "ip_range": "3.2.3.0/24",
+      "as_name": "TOTxxited",
+      "as_num": 23969,
+      "location": {
+        "country": "TH",
+        "city": "Bangkok",
+        "latitude": 13.7366,
+        "longitude": 100.4995
+      },
+      "reverse_dns": "nxxxt.net",
+      "behaviors": [
+        {
+          "name": "smb:bruteforce",
+          "label": "SMB Bruteforce",
+          "description": "IP has been reported for performing brute force on samba services."
+        }
+      ],
+      "history": {
+        "first_seen": "2022-11-26T05:15:00+00:00",
+        "last_seen": "2022-11-26T12:00:00+00:00",
+        "full_age": 9,
+        "days_age": 1
+      },
+      "classifications": {
+        "false_positives": [],
+        "classifications": [
+          {
+            "name": "profile:insecure_services",
+            "label": "Dangerous Services Exposed",
+            "description": "IP exposes dangerous services (vnc, telnet, rdp), possibly due to a misconfiguration or because it's a honeypot."
+          }
+        ]
+      },
+      "attack_details": [
+        {
+          "name": "crowdsecurity/smb-bf",
+          "label": "Samba Bruteforce",
+          "description": "Detect smb brute force",
+          "references": []
+        }
+      ],
+      "state": "validated",
+      "expiration": "2022-12-14T16:18:00.671000",
+      "target_countries": {
+        "GB": 100
+      },
+      "background_noise_score": 5,
+      "scores": {
+        "overall": {
+          "aggressiveness": 2,
+          "threat": 4,
+          "trust": 5,
+          "anomaly": 1,
+          "total": 4
+        },
+        "last_day": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 1,
+          "total": 0
+        },
+        "last_week": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 1,
+          "total": 0
+        },
+        "last_month": {
+          "aggressiveness": 2,
+          "threat": 4,
+          "trust": 5,
+          "anomaly": 1,
+          "total": 4
+        }
+      },
+      "references": []
+    }
+  ]
+}

+ 315 - 0
pkg/cticlient/tests/fire-page2.json

@@ -0,0 +1,315 @@
+{
+  "_links": {
+    "first": {
+      "href": "https://cti.api.crowdsec.net/v2/fire"
+    },
+    "self": {
+      "href": "https://cti.api.crowdsec.net/v2/fire?page=2&limit=3"
+    },
+    "prev": {
+      "href": "https://cti.api.crowdsec.net/v2/fire?page=1&limit=3"
+    },
+    "next": {
+      "href": "https://cti.api.crowdsec.net/v2/fire?page=3&limit=3"
+    }
+  },
+  "items": [
+    {
+      "ip_range_score": 0,
+      "ip": "4.2.3.4",
+      "ip_range": "4.2.0.0/16",
+      "as_name": "Chxxoup",
+      "as_num": 4812,
+      "location": {
+        "country": "CN",
+        "city": null,
+        "latitude": 34.7732,
+        "longitude": 113.722
+      },
+      "reverse_dns": "xxxweqwwe.com.cn",
+      "behaviors": [
+        {
+          "name": "smb:bruteforce",
+          "label": "SMB Bruteforce",
+          "description": "IP has been reported for performing brute force on samba services."
+        },
+        {
+          "name": "windows:bruteforce",
+          "label": "SMB/RDP bruteforce",
+          "description": "IP has been reported for performing brute force on Windows (samba, remote desktop) services."
+        }
+      ],
+      "history": {
+        "first_seen": "2022-11-25T04:15:00+00:00",
+        "last_seen": "2022-11-25T13:30:00+00:00",
+        "full_age": 9,
+        "days_age": 1
+      },
+      "classifications": {
+        "false_positives": [],
+        "classifications": [
+          {
+            "name": "proxy:vpn",
+            "label": "VPN",
+            "description": "IP exposes a VPN service or is being flagged as one."
+          }
+        ]
+      },
+      "attack_details": [
+        {
+          "name": "crowdsecurity/smb-bf",
+          "label": "Samba Bruteforce",
+          "description": "Detect smb brute force",
+          "references": []
+        },
+        {
+          "name": "crowdsecurity/windows-bf",
+          "label": "SMB/RDP brute force",
+          "description": "Detect samba/remote-desktop user brute force",
+          "references": []
+        }
+      ],
+      "state": "validated",
+      "expiration": "2022-12-14T16:17:24.865000",
+      "target_countries": {
+        "FR": 100
+      },
+      "background_noise_score": 6,
+      "scores": {
+        "overall": {
+          "aggressiveness": 2,
+          "threat": 4,
+          "trust": 5,
+          "anomaly": 1,
+          "total": 4
+        },
+        "last_day": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 1,
+          "total": 0
+        },
+        "last_week": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 1,
+          "total": 0
+        },
+        "last_month": {
+          "aggressiveness": 2,
+          "threat": 4,
+          "trust": 5,
+          "anomaly": 1,
+          "total": 4
+        }
+      },
+      "references": []
+    },
+    {
+      "ip_range_score": 2,
+      "ip": "5.2.3.4",
+      "ip_range": "5.2.3.0/24",
+      "as_name": "Turxxri A.s.",
+      "as_num": 16135,
+      "location": {
+        "country": "TR",
+        "city": "Istanbul",
+        "latitude": 41.0551,
+        "longitude": 28.9347
+      },
+      "reverse_dns": null,
+      "behaviors": [
+        {
+          "name": "ssh:bruteforce",
+          "label": "SSH Bruteforce",
+          "description": "IP has been reported for performing brute force on ssh services."
+        },
+        {
+          "name": "tcp:scan",
+          "label": "TCP Scan",
+          "description": "IP has been reported for performing TCP port scanning."
+        }
+      ],
+      "history": {
+        "first_seen": "2022-08-26T02:00:00+00:00",
+        "last_seen": "2022-11-18T09:45:00+00:00",
+        "full_age": 100,
+        "days_age": 85
+      },
+      "classifications": {
+        "false_positives": [],
+        "classifications": [
+          {
+            "name": "profile:insecure_services",
+            "label": "Dangerous Services Exposed",
+            "description": "IP exposes dangerous services (vnc, telnet, rdp), possibly due to a misconfiguration or because it's a honeypot."
+          },
+          {
+            "name": "profile:many_services",
+            "label": "Many Services Exposed",
+            "description": "IP exposes many open port, possibly due to a misconfiguration or because it's a honeypot."
+          }
+        ]
+      },
+      "attack_details": [
+        {
+          "name": "crowdsecurity/ssh-slow-bf",
+          "label": "Slow SSH Bruteforce",
+          "description": "Detect slow ssh brute force",
+          "references": []
+        },
+        {
+          "name": "crowdsecurity/ssh-bf",
+          "label": "SSH Bruteforce",
+          "description": "Detect ssh brute force",
+          "references": []
+        },
+        {
+          "name": "crowdsecurity/iptables-scan-multi_ports",
+          "label": "Port Scanner",
+          "description": "Detect tcp port scan",
+          "references": []
+        }
+      ],
+      "state": "validated",
+      "expiration": "2022-12-12T15:16:33.246000",
+      "target_countries": {
+        "FR": 21,
+        "HK": 19,
+        "US": 19,
+        "DE": 11,
+        "AU": 7,
+        "GB": 4,
+        "RU": 4,
+        "BR": 4,
+        "CA": 4,
+        "VE": 2
+      },
+      "background_noise_score": 4,
+      "scores": {
+        "overall": {
+          "aggressiveness": 2,
+          "threat": 3,
+          "trust": 2,
+          "anomaly": 3,
+          "total": 3
+        },
+        "last_day": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 3,
+          "total": 0
+        },
+        "last_week": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 3,
+          "total": 0
+        },
+        "last_month": {
+          "aggressiveness": 1,
+          "threat": 3,
+          "trust": 1,
+          "anomaly": 3,
+          "total": 2
+        }
+      },
+      "references": []
+    },
+    {
+      "ip_range_score": 5,
+      "ip": "6.2.3.4",
+      "ip_range": "6.2.0.0/17",
+      "as_name": "SMILESERV",
+      "as_num": 38700,
+      "location": {
+        "country": "KR",
+        "city": null,
+        "latitude": 37.5112,
+        "longitude": 126.9741
+      },
+      "reverse_dns": null,
+      "behaviors": [
+        {
+          "name": "ssh:bruteforce",
+          "label": "SSH Bruteforce",
+          "description": "IP has been reported for performing brute force on ssh services."
+        }
+      ],
+      "history": {
+        "first_seen": "2022-09-20T15:30:00+00:00",
+        "last_seen": "2022-11-25T11:30:00+00:00",
+        "full_age": 74,
+        "days_age": 66
+      },
+      "classifications": {
+        "false_positives": [],
+        "classifications": []
+      },
+      "attack_details": [
+        {
+          "name": "crowdsecurity/ssh-slow-bf",
+          "label": "Slow SSH Bruteforce",
+          "description": "Detect slow ssh brute force",
+          "references": []
+        },
+        {
+          "name": "crowdsecurity/ssh-bf",
+          "label": "SSH Bruteforce",
+          "description": "Detect ssh brute force",
+          "references": []
+        }
+      ],
+      "state": "validated",
+      "expiration": "2022-12-14T16:19:30.654000",
+      "target_countries": {
+        "FR": 32,
+        "US": 21,
+        "DE": 17,
+        "NL": 5,
+        "FI": 5,
+        "RU": 3,
+        "GB": 3,
+        "SI": 2,
+        "RO": 2,
+        "HK": 2
+      },
+      "background_noise_score": 4,
+      "scores": {
+        "overall": {
+          "aggressiveness": 4,
+          "threat": 4,
+          "trust": 5,
+          "anomaly": 1,
+          "total": 4
+        },
+        "last_day": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 1,
+          "total": 0
+        },
+        "last_week": {
+          "aggressiveness": 0,
+          "threat": 0,
+          "trust": 0,
+          "anomaly": 1,
+          "total": 0
+        },
+        "last_month": {
+          "aggressiveness": 3,
+          "threat": 4,
+          "trust": 1,
+          "anomaly": 1,
+          "total": 3
+        }
+      },
+      "references": []
+    }
+  ]
+}

+ 295 - 0
pkg/cticlient/types.go

@@ -0,0 +1,295 @@
+package cticlient
+
+import (
+	"time"
+)
+
+type CTIScores struct {
+	Overall   CTIScore `json:"overall"`
+	LastDay   CTIScore `json:"last_day"`
+	LastWeek  CTIScore `json:"last_week"`
+	LastMonth CTIScore `json:"last_month"`
+}
+
+type CTIScore struct {
+	Aggressiveness int `json:"aggressiveness"`
+	Threat         int `json:"threat"`
+	Trust          int `json:"trust"`
+	Anomaly        int `json:"anomaly"`
+	Total          int `json:"total"`
+}
+
+type CTIAttackDetails struct {
+	Name        string   `json:"name"`
+	Label       string   `json:"label"`
+	Description string   `json:"description"`
+	References  []string `json:"references"`
+}
+
+type CTIClassifications struct {
+	FalsePositives  []CTIClassification `json:"false_positives"`
+	Classifications []CTIClassification `json:"classifications"`
+}
+
+type CTIClassification struct {
+	Name        string `json:"name"`
+	Label       string `json:"label"`
+	Description string `json:"description"`
+}
+type CTIHistory struct {
+	FirstSeen *string `json:"first_seen"`
+	LastSeen  *string `json:"last_seen"`
+	FullAge   int     `json:"full_age"`
+	DaysAge   int     `json:"days_age"`
+}
+
+type CTIBehavior struct {
+	Name        string `json:"name"`
+	Label       string `json:"label"`
+	Description string `json:"description"`
+}
+type CTILocationInfo struct {
+	Country   *string  `json:"country"`
+	City      *string  `json:"city"`
+	Latitude  *float64 `json:"latitude"`
+	Longitude *float64 `json:"longitude"`
+}
+
+type CTIReferences struct {
+	Name        string `json:"name"`
+	Label       string `json:"label"`
+	Description string `json:"description"`
+}
+
+type SmokeItem struct {
+	IpRangeScore         int                 `json:"ip_range_score"`
+	Ip                   string              `json:"ip"`
+	IpRange              *string             `json:"ip_range"`
+	AsName               *string             `json:"as_name"`
+	AsNum                *int                `json:"as_num"`
+	Location             CTILocationInfo     `json:"location"`
+	ReverseDNS           *string             `json:"reverse_dns"`
+	Behaviors            []*CTIBehavior      `json:"behaviors"`
+	History              CTIHistory          `json:"history"`
+	Classifications      CTIClassifications  `json:"classifications"`
+	AttackDetails        []*CTIAttackDetails `json:"attack_details"`
+	TargetCountries      map[string]int      `json:"target_countries"`
+	BackgroundNoiseScore *int                `json:"background_noise_score"`
+	Scores               CTIScores           `json:"scores"`
+	References           []CTIReferences     `json:"references"`
+	IsOk                 bool                `json:"-"`
+}
+
+type SearchIPResponse struct {
+	Total    int         `json:"total"`
+	NotFound int         `json:"not_found"`
+	Items    []SmokeItem `json:"items"`
+}
+
+type CustomTime struct {
+	time.Time
+}
+
+func (ct *CustomTime) UnmarshalJSON(b []byte) error {
+	if string(b) == "null" {
+		return nil
+	}
+
+	t, err := time.Parse(`"2006-01-02T15:04:05.999999999"`, string(b))
+	if err != nil {
+		return err
+	}
+
+	ct.Time = t
+	return nil
+}
+
+type FireItem struct {
+	IpRangeScore         int                 `json:"ip_range_score"`
+	Ip                   string              `json:"ip"`
+	IpRange              *string             `json:"ip_range"`
+	AsName               *string             `json:"as_name"`
+	AsNum                *int                `json:"as_num"`
+	Location             CTILocationInfo     `json:"location"`
+	ReverseDNS           *string             `json:"reverse_dns"`
+	Behaviors            []*CTIBehavior      `json:"behaviors"`
+	History              CTIHistory          `json:"history"`
+	Classifications      CTIClassifications  `json:"classifications"`
+	AttackDetails        []*CTIAttackDetails `json:"attack_details"`
+	TargetCountries      map[string]int      `json:"target_countries"`
+	BackgroundNoiseScore *int                `json:"background_noise_score"`
+	Scores               CTIScores           `json:"scores"`
+	References           []CTIReferences     `json:"references"`
+	Status               string              `json:"status"`
+	Expiration           CustomTime          `json:"expiration"`
+}
+
+type FireParams struct {
+	Since *string `json:"since"`
+	Page  *int    `json:"page"`
+	Limit *int    `json:"limit"`
+}
+
+type Href struct {
+	Href string `json:"href"`
+}
+
+type Links struct {
+	First *Href `json:"first"`
+	Self  *Href `json:"self"`
+	Prev  *Href `json:"prev"`
+	Next  *Href `json:"next"`
+}
+
+type FireResponse struct {
+	Links Links      `json:"_links"`
+	Items []FireItem `json:"items"`
+}
+
+func (c *SmokeItem) GetAttackDetails() []string {
+	var ret []string = make([]string, 0)
+
+	if c.AttackDetails != nil {
+		for _, b := range c.AttackDetails {
+			ret = append(ret, b.Name)
+		}
+	}
+	return ret
+}
+
+func (c *SmokeItem) GetBehaviors() []string {
+	var ret []string = make([]string, 0)
+
+	if c.Behaviors != nil {
+		for _, b := range c.Behaviors {
+			ret = append(ret, b.Name)
+		}
+	}
+	return ret
+}
+
+// Provide the likelihood of the IP being bad
+func (c *SmokeItem) GetMaliciousnessScore() float32 {
+	if c.IsPartOfCommunityBlocklist() {
+		return 1.0
+	}
+	if c.Scores.LastDay.Total > 0 {
+		return float32(c.Scores.LastDay.Total) / 10.0
+	}
+	return 0.0
+}
+
+func (c *SmokeItem) IsPartOfCommunityBlocklist() bool {
+	if c.Classifications.Classifications != nil {
+		for _, v := range c.Classifications.Classifications {
+			if v.Name == "community-blocklist" {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
+func (c *SmokeItem) GetBackgroundNoiseScore() int {
+	if c.BackgroundNoiseScore != nil {
+		return *c.BackgroundNoiseScore
+	}
+	return 0
+}
+
+func (c *SmokeItem) GetFalsePositives() []string {
+	var ret []string = make([]string, 0)
+
+	if c.Classifications.FalsePositives != nil {
+		for _, b := range c.Classifications.FalsePositives {
+			ret = append(ret, b.Name)
+		}
+	}
+	return ret
+}
+
+func (c *SmokeItem) IsFalsePositive() bool {
+
+	if c.Classifications.FalsePositives != nil {
+		if len(c.Classifications.FalsePositives) > 0 {
+			return true
+		}
+	}
+
+	return false
+}
+
+func (c *FireItem) GetAttackDetails() []string {
+	var ret []string = make([]string, 0)
+
+	if c.AttackDetails != nil {
+		for _, b := range c.AttackDetails {
+			ret = append(ret, b.Name)
+		}
+	}
+	return ret
+}
+
+func (c *FireItem) GetBehaviors() []string {
+	var ret []string = make([]string, 0)
+
+	if c.Behaviors != nil {
+		for _, b := range c.Behaviors {
+			ret = append(ret, b.Name)
+		}
+	}
+	return ret
+}
+
+// Provide the likelihood of the IP being bad
+func (c *FireItem) GetMaliciousnessScore() float32 {
+	if c.IsPartOfCommunityBlocklist() {
+		return 1.0
+	}
+	if c.Scores.LastDay.Total > 0 {
+		return float32(c.Scores.LastDay.Total) / 10.0
+	}
+	return 0.0
+}
+
+func (c *FireItem) IsPartOfCommunityBlocklist() bool {
+	if c.Classifications.Classifications != nil {
+		for _, v := range c.Classifications.Classifications {
+			if v.Name == "community-blocklist" {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
+func (c *FireItem) GetBackgroundNoiseScore() int {
+	if c.BackgroundNoiseScore != nil {
+		return *c.BackgroundNoiseScore
+	}
+	return 0
+}
+
+func (c *FireItem) GetFalsePositives() []string {
+	var ret []string = make([]string, 0)
+
+	if c.Classifications.FalsePositives != nil {
+		for _, b := range c.Classifications.FalsePositives {
+			ret = append(ret, b.Name)
+		}
+	}
+	return ret
+}
+
+func (c *FireItem) IsFalsePositive() bool {
+
+	if c.Classifications.FalsePositives != nil {
+		if len(c.Classifications.FalsePositives) > 0 {
+			return true
+		}
+	}
+
+	return false
+}

+ 114 - 0
pkg/cticlient/types_test.go

@@ -0,0 +1,114 @@
+package cticlient
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/crowdsecurity/crowdsec/pkg/types"
+)
+
+//func (c *SmokeItem) GetAttackDetails() []string {
+
+func getSampleSmokeItem() SmokeItem {
+	lat := 48.8566
+	long := 2.3522
+	emptyItem := SmokeItem{
+		IpRangeScore: 2.0,
+		Ip:           "1.2.3.4",
+		IpRange:      types.StrPtr("1.2.3.0/24"),
+		AsName:       types.StrPtr("AS1234"),
+		AsNum:        types.IntPtr(1234),
+		Location: CTILocationInfo{
+			Country:   types.StrPtr("FR"),
+			City:      types.StrPtr("Paris"),
+			Latitude:  &lat,
+			Longitude: &long,
+		},
+		ReverseDNS: types.StrPtr("foo.bar.com"),
+		Behaviors: []*CTIBehavior{
+			{
+				Name:        "ssh:bruteforce",
+				Label:       "SSH Bruteforce",
+				Description: "IP has been reported for performing brute force on ssh services.",
+			},
+		},
+		History: CTIHistory{
+			FirstSeen: types.StrPtr("2022-12-05T17:45:00+00:00"),
+			LastSeen:  types.StrPtr("2022-12-06T19:15:00+00:00"),
+			FullAge:   3,
+			DaysAge:   1,
+		},
+		Classifications: CTIClassifications{
+			FalsePositives:  []CTIClassification{},
+			Classifications: []CTIClassification{},
+		},
+		AttackDetails: []*CTIAttackDetails{
+			{
+				Name:        "ssh:bruteforce",
+				Label:       "SSH Bruteforce",
+				Description: "Detect ssh brute force",
+				References:  []string{},
+			},
+		},
+		TargetCountries: map[string]int{
+			"HK": 71,
+			"GB": 14,
+			"US": 14,
+		},
+		BackgroundNoiseScore: types.IntPtr(3),
+		Scores: CTIScores{
+			Overall: CTIScore{
+				Aggressiveness: 2,
+				Threat:         1,
+				Trust:          1,
+				Anomaly:        0,
+				Total:          1,
+			},
+			LastDay: CTIScore{
+				Aggressiveness: 2,
+				Threat:         1,
+				Trust:          1,
+				Anomaly:        0,
+				Total:          1,
+			},
+			LastWeek: CTIScore{
+				Aggressiveness: 2,
+				Threat:         1,
+				Trust:          1,
+				Anomaly:        0,
+				Total:          1,
+			},
+			LastMonth: CTIScore{
+				Aggressiveness: 2,
+				Threat:         1,
+				Trust:          1,
+				Anomaly:        0,
+				Total:          1,
+			},
+		},
+	}
+	return emptyItem
+}
+
+func TestBasicSmokeItem(t *testing.T) {
+	item := getSampleSmokeItem()
+	assert.Equal(t, item.GetAttackDetails(), []string{"ssh:bruteforce"})
+	assert.Equal(t, item.GetBehaviors(), []string{"ssh:bruteforce"})
+	assert.Equal(t, item.GetMaliciousnessScore(), float32(0.1))
+	assert.Equal(t, item.IsPartOfCommunityBlocklist(), false)
+	assert.Equal(t, item.GetBackgroundNoiseScore(), int(3))
+	assert.Equal(t, item.GetFalsePositives(), []string{})
+	assert.Equal(t, item.IsFalsePositive(), false)
+}
+
+func TestEmptySmokeItem(t *testing.T) {
+	item := SmokeItem{}
+	assert.Equal(t, item.GetAttackDetails(), []string{})
+	assert.Equal(t, item.GetBehaviors(), []string{})
+	assert.Equal(t, item.GetMaliciousnessScore(), float32(0.0))
+	assert.Equal(t, item.IsPartOfCommunityBlocklist(), false)
+	assert.Equal(t, item.GetBackgroundNoiseScore(), int(0))
+	assert.Equal(t, item.GetFalsePositives(), []string{})
+	assert.Equal(t, item.IsFalsePositive(), false)
+}

+ 135 - 0
pkg/exprhelpers/crowdsec_cti.go

@@ -0,0 +1,135 @@
+package exprhelpers
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/bluele/gcache"
+	"github.com/crowdsecurity/crowdsec/pkg/cticlient"
+	"github.com/crowdsecurity/crowdsec/pkg/types"
+	"github.com/pkg/errors"
+	log "github.com/sirupsen/logrus"
+)
+
+var CTIUrl = "https://cti.api.crowdsec.net"
+var CTIUrlSuffix = "/v2/smoke/"
+var CTIApiKey = ""
+
+// this is set for non-recoverable errors, such as 403 when querying API or empty API key
+var CTIApiEnabled = true
+
+// when hitting quotas or auth errors, we temporarily disable the API
+var CTIBackOffUntil time.Time
+var CTIBackOffDuration time.Duration = 5 * time.Minute
+
+var ctiClient *cticlient.CrowdsecCTIClient
+
+func InitCrowdsecCTI(Key *string, TTL *time.Duration, Size *int, LogLevel *log.Level) error {
+	if Key != nil {
+		CTIApiKey = *Key
+	} else {
+		CTIApiEnabled = false
+		return fmt.Errorf("CTI API key not set, CTI will not be available")
+	}
+	if Size == nil {
+		Size = new(int)
+		*Size = 1000
+	}
+	if TTL == nil {
+		TTL = new(time.Duration)
+		*TTL = 5 * time.Minute
+	}
+	//dedicated logger
+	clog := log.New()
+	if err := types.ConfigureLogger(clog); err != nil {
+		return errors.Wrap(err, "while configuring datasource logger")
+	}
+	if LogLevel != nil {
+		clog.SetLevel(*LogLevel)
+	}
+	customLog := log.Fields{
+		"type": "crowdsec-cti",
+	}
+	subLogger := clog.WithFields(customLog)
+	CrowdsecCTIInitCache(*Size, *TTL)
+	ctiClient = cticlient.NewCrowdsecCTIClient(cticlient.WithAPIKey(CTIApiKey), cticlient.WithLogger(subLogger))
+	return nil
+}
+
+func ShutdownCrowdsecCTI() {
+	if CTICache != nil {
+		CTICache.Purge()
+	}
+	CTIApiKey = ""
+	CTIApiEnabled = true
+}
+
+// Cache for responses
+var CTICache gcache.Cache
+var CacheExpiration time.Duration
+
+func CrowdsecCTIInitCache(size int, ttl time.Duration) {
+	CTICache = gcache.New(size).LRU().Build()
+	CacheExpiration = ttl
+}
+
+func CrowdsecCTI(ip string) (*cticlient.SmokeItem, error) {
+	if !CTIApiEnabled {
+		ctiClient.Logger.Warningf("Crowdsec CTI API is disabled, please check your configuration")
+		return &cticlient.SmokeItem{}, cticlient.ErrDisabled
+	}
+
+	if CTIApiKey == "" {
+		ctiClient.Logger.Warningf("CrowdsecCTI : no key provided, skipping")
+		return &cticlient.SmokeItem{}, cticlient.ErrDisabled
+	}
+
+	if ctiClient == nil {
+		ctiClient.Logger.Warningf("CrowdsecCTI: no client, skipping")
+		return &cticlient.SmokeItem{}, cticlient.ErrDisabled
+	}
+
+	if val, err := CTICache.Get(ip); err == nil && val != nil {
+		ctiClient.Logger.Debugf("cti cache fetch for %s", ip)
+		ret, ok := val.(*cticlient.SmokeItem)
+		if !ok {
+			ctiClient.Logger.Warningf("CrowdsecCTI: invalid type in cache, removing")
+			CTICache.Remove(ip)
+		} else {
+			return ret, nil
+		}
+	}
+
+	if !CTIBackOffUntil.IsZero() && time.Now().Before(CTIBackOffUntil) {
+		//ctiClient.Logger.Warningf("Crowdsec CTI client is in backoff mode, ending in %s", time.Until(CTIBackOffUntil))
+		return &cticlient.SmokeItem{}, cticlient.ErrLimit
+	}
+
+	ctiClient.Logger.Infof("cti call for %s", ip)
+	before := time.Now()
+	ctiResp, err := ctiClient.GetIPInfo(ip)
+	ctiClient.Logger.Debugf("request for %s took %v", ip, time.Since(before))
+	if err != nil {
+		if err == cticlient.ErrUnauthorized {
+			CTIApiEnabled = false
+			ctiClient.Logger.Errorf("Invalid API key provided, disabling CTI API")
+			return &cticlient.SmokeItem{}, cticlient.ErrUnauthorized
+		} else if err == cticlient.ErrLimit {
+			CTIBackOffUntil = time.Now().Add(CTIBackOffDuration)
+			ctiClient.Logger.Errorf("CTI API is throttled, will try again in %s", CTIBackOffDuration)
+			return &cticlient.SmokeItem{}, cticlient.ErrLimit
+		} else {
+			ctiClient.Logger.Warnf("CTI API error : %s", err)
+			return &cticlient.SmokeItem{}, fmt.Errorf("unexpected error : %v", err)
+		}
+	}
+
+	if err := CTICache.SetWithExpire(ip, ctiResp, CacheExpiration); err != nil {
+		ctiClient.Logger.Warningf("IpCTI : error while caching CTI : %s", err)
+		return &cticlient.SmokeItem{}, cticlient.ErrUnknown
+	}
+
+	ctiClient.Logger.Tracef("CTI response : %v", *ctiResp)
+
+	return ctiResp, nil
+}

+ 181 - 0
pkg/exprhelpers/crowdsec_cti_test.go

@@ -0,0 +1,181 @@
+package exprhelpers
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cticlient"
+	"github.com/crowdsecurity/crowdsec/pkg/types"
+	"github.com/stretchr/testify/assert"
+)
+
+var sampledata = map[string]cticlient.SmokeItem{
+	//1.2.3.4 is a known false positive
+	"1.2.3.4": {
+		Ip: "1.2.3.4",
+		Classifications: cticlient.CTIClassifications{
+			FalsePositives: []cticlient.CTIClassification{
+				{
+					Name:  "example_false_positive",
+					Label: "Example False Positive",
+				},
+			},
+		},
+	},
+	//1.2.3.5 is a known bad-guy, and part of FIRE
+	"1.2.3.5": {
+		Ip: "1.2.3.5",
+		Classifications: cticlient.CTIClassifications{
+			Classifications: []cticlient.CTIClassification{
+				{
+					Name:        "community-blocklist",
+					Label:       "CrowdSec Community Blocklist",
+					Description: "IP belong to the CrowdSec Community Blocklist",
+				},
+			},
+		},
+	},
+	//1.2.3.6 is a bad guy (high bg noise), but not in FIRE
+	"1.2.3.6": {
+		Ip:                   "1.2.3.6",
+		BackgroundNoiseScore: new(int),
+		Behaviors: []*cticlient.CTIBehavior{
+			{Name: "ssh:bruteforce", Label: "SSH Bruteforce", Description: "SSH Bruteforce"},
+		},
+		AttackDetails: []*cticlient.CTIAttackDetails{
+			{Name: "crowdsecurity/ssh-bf", Label: "Example Attack"},
+			{Name: "crowdsecurity/ssh-slow-bf", Label: "Example Attack"},
+		},
+	},
+	//1.2.3.7 is a ok guy, but part of a bad range
+	"1.2.3.7": cticlient.SmokeItem{},
+}
+
+const validApiKey = "my-api-key"
+
+type RoundTripFunc func(req *http.Request) *http.Response
+
+func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+	return f(req), nil
+}
+
+func smokeHandler(req *http.Request) *http.Response {
+	apiKey := req.Header.Get("x-api-key")
+	if apiKey != validApiKey {
+		return &http.Response{
+			StatusCode: http.StatusForbidden,
+			Body:       nil,
+			Header:     make(http.Header),
+		}
+	}
+
+	requestedIP := strings.Split(req.URL.Path, "/")[3]
+	sample, ok := sampledata[requestedIP]
+	if !ok {
+		return &http.Response{
+			StatusCode: http.StatusNotFound,
+			Body:       nil,
+			Header:     make(http.Header),
+		}
+	}
+
+	body, err := json.Marshal(sample)
+	if err != nil {
+		return &http.Response{
+			StatusCode: http.StatusInternalServerError,
+			Body:       nil,
+			Header:     make(http.Header),
+		}
+	}
+
+	reader := io.NopCloser(bytes.NewReader(body))
+
+	return &http.Response{
+		StatusCode: http.StatusOK,
+		// Send response to be tested
+		Body:          reader,
+		Header:        make(http.Header),
+		ContentLength: 0,
+	}
+}
+
+func TestInvalidAuth(t *testing.T) {
+	defer ShutdownCrowdsecCTI()
+	if err := InitCrowdsecCTI(types.StrPtr("asdasd"), nil, nil, nil); err != nil {
+		t.Fatalf("failed to init CTI : %s", err)
+	}
+	//Replace the client created by InitCrowdsecCTI with one that uses a custom transport
+	ctiClient = cticlient.NewCrowdsecCTIClient(cticlient.WithAPIKey("asdasd"), cticlient.WithHTTPClient(&http.Client{
+		Transport: RoundTripFunc(smokeHandler),
+	}))
+
+	item, err := CrowdsecCTI("1.2.3.4")
+	assert.Equal(t, item, &cticlient.SmokeItem{})
+	assert.Equal(t, CTIApiEnabled, false)
+	assert.Equal(t, err, cticlient.ErrUnauthorized)
+
+	//CTI is now disabled, all requests should return empty
+	ctiClient = cticlient.NewCrowdsecCTIClient(cticlient.WithAPIKey(validApiKey), cticlient.WithHTTPClient(&http.Client{
+		Transport: RoundTripFunc(smokeHandler),
+	}))
+
+	item, err = CrowdsecCTI("1.2.3.4")
+	assert.Equal(t, item, &cticlient.SmokeItem{})
+	assert.Equal(t, CTIApiEnabled, false)
+	assert.Equal(t, err, cticlient.ErrDisabled)
+}
+
+func TestNoKey(t *testing.T) {
+	defer ShutdownCrowdsecCTI()
+	err := InitCrowdsecCTI(nil, nil, nil, nil)
+	assert.ErrorContains(t, err, "CTI API key not set")
+	//Replace the client created by InitCrowdsecCTI with one that uses a custom transport
+	ctiClient = cticlient.NewCrowdsecCTIClient(cticlient.WithAPIKey("asdasd"), cticlient.WithHTTPClient(&http.Client{
+		Transport: RoundTripFunc(smokeHandler),
+	}))
+
+	item, err := CrowdsecCTI("1.2.3.4")
+	assert.Equal(t, item, &cticlient.SmokeItem{})
+	assert.Equal(t, CTIApiEnabled, false)
+	assert.Equal(t, err, cticlient.ErrDisabled)
+}
+
+func TestCache(t *testing.T) {
+	defer ShutdownCrowdsecCTI()
+	cacheDuration := 1 * time.Second
+	if err := InitCrowdsecCTI(types.StrPtr(validApiKey), &cacheDuration, nil, nil); err != nil {
+		t.Fatalf("failed to init CTI : %s", err)
+	}
+	//Replace the client created by InitCrowdsecCTI with one that uses a custom transport
+	ctiClient = cticlient.NewCrowdsecCTIClient(cticlient.WithAPIKey(validApiKey), cticlient.WithHTTPClient(&http.Client{
+		Transport: RoundTripFunc(smokeHandler),
+	}))
+
+	item, err := CrowdsecCTI("1.2.3.4")
+	assert.Equal(t, "1.2.3.4", item.Ip)
+	assert.Equal(t, CTIApiEnabled, true)
+	assert.Equal(t, CTICache.Len(true), 1)
+	assert.Equal(t, err, nil)
+
+	item, err = CrowdsecCTI("1.2.3.4")
+	assert.Equal(t, "1.2.3.4", item.Ip)
+	assert.Equal(t, CTIApiEnabled, true)
+	assert.Equal(t, CTICache.Len(true), 1)
+	assert.Equal(t, err, nil)
+
+	time.Sleep(2 * time.Second)
+
+	assert.Equal(t, CTICache.Len(true), 0)
+
+	item, err = CrowdsecCTI("1.2.3.4")
+	assert.Equal(t, "1.2.3.4", item.Ip)
+	assert.Equal(t, CTIApiEnabled, true)
+	assert.Equal(t, CTICache.Len(true), 1)
+	assert.Equal(t, err, nil)
+
+}

+ 2 - 0
pkg/exprhelpers/exprlib.go

@@ -69,6 +69,7 @@ func GetExprEnv(ctx map[string]interface{}) map[string]interface{} {
 		"GetDecisionsCount":      GetDecisionsCount,
 		"GetDecisionsCount":      GetDecisionsCount,
 		"GetDecisionsSinceCount": GetDecisionsSinceCount,
 		"GetDecisionsSinceCount": GetDecisionsSinceCount,
 		"Sprintf":                fmt.Sprintf,
 		"Sprintf":                fmt.Sprintf,
+		"CrowdsecCTI":            CrowdsecCTI,
 		"ParseUnix":              ParseUnix,
 		"ParseUnix":              ParseUnix,
 		"GetFromStash":           cache.GetKey,
 		"GetFromStash":           cache.GetKey,
 		"SetInStash":             cache.SetKey,
 		"SetInStash":             cache.SetKey,
@@ -258,6 +259,7 @@ func GetDecisionsCount(value string) int {
 	if dbClient == nil {
 	if dbClient == nil {
 		log.Error("No database config to call GetDecisionsCount()")
 		log.Error("No database config to call GetDecisionsCount()")
 		return 0
 		return 0
+
 	}
 	}
 	count, err := dbClient.CountDecisionsByValue(value)
 	count, err := dbClient.CountDecisionsByValue(value)
 	if err != nil {
 	if err != nil {

Some files were not shown because too many files changed in this diff