crowdsec/cmd/crowdsec-cli/clidecision/decisions.go

498 lines
15 KiB
Go

package clidecision
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/fatih/color"
"github.com/go-openapi/strfmt"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/args"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/clialert"
"github.com/crowdsecurity/crowdsec/pkg/apiclient"
"github.com/crowdsecurity/crowdsec/pkg/csconfig"
"github.com/crowdsecurity/crowdsec/pkg/models"
"github.com/crowdsecurity/crowdsec/pkg/types"
"github.com/crowdsecurity/go-cs-lib/cstime"
)
type configGetter func() *csconfig.Config
type cliDecisions struct {
client *apiclient.ApiClient
cfg configGetter
}
func (cli *cliDecisions) decisionsToTable(alerts *models.GetAlertsResponse, printMachine bool) error {
/*here we cheat a bit : to make it more readable for the user, we dedup some entries*/
spamLimit := make(map[string]bool)
skipped := 0
for aIdx := range len(*alerts) {
alertItem := (*alerts)[aIdx]
newDecisions := make([]*models.Decision, 0)
for _, decisionItem := range alertItem.Decisions {
spamKey := fmt.Sprintf("%t:%s:%s:%s", *decisionItem.Simulated, *decisionItem.Type, *decisionItem.Scope, *decisionItem.Value)
if _, ok := spamLimit[spamKey]; ok {
skipped++
continue
}
spamLimit[spamKey] = true
newDecisions = append(newDecisions, decisionItem)
}
alertItem.Decisions = newDecisions
}
switch cli.cfg().Cscli.Output {
case "raw":
csvwriter := csv.NewWriter(os.Stdout)
header := []string{"id", "source", "ip", "reason", "action", "country", "as", "events_count", "expiration", "simulated", "alert_id"}
if printMachine {
header = append(header, "machine")
}
err := csvwriter.Write(header)
if err != nil {
return err
}
for _, alertItem := range *alerts {
for _, decisionItem := range alertItem.Decisions {
raw := []string{
strconv.FormatInt(decisionItem.ID, 10),
*decisionItem.Origin,
*decisionItem.Scope + ":" + *decisionItem.Value,
*decisionItem.Scenario,
*decisionItem.Type,
alertItem.Source.Cn,
alertItem.Source.GetAsNumberName(),
strconv.FormatInt(int64(*alertItem.EventsCount), 10),
*decisionItem.Duration,
strconv.FormatBool(*decisionItem.Simulated),
strconv.FormatInt(alertItem.ID, 10),
}
if printMachine {
raw = append(raw, alertItem.MachineID)
}
err := csvwriter.Write(raw)
if err != nil {
return err
}
}
}
csvwriter.Flush()
case "json":
if *alerts == nil {
// avoid returning "null" in `json"
// could be cleaner if we used slice of alerts directly
fmt.Fprintln(os.Stdout, "[]")
return nil
}
x, _ := json.MarshalIndent(alerts, "", " ")
fmt.Fprintln(os.Stdout, string(x))
case "human":
if len(*alerts) == 0 {
fmt.Fprintln(os.Stdout, "No active decisions")
return nil
}
cli.decisionsTable(color.Output, alerts, printMachine)
if skipped > 0 {
fmt.Fprintf(os.Stdout, "%d duplicated entries skipped\n", skipped)
}
}
return nil
}
func New(cfg configGetter) *cliDecisions {
return &cliDecisions{
cfg: cfg,
}
}
func (cli *cliDecisions) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "decisions [action]",
Short: "Manage decisions",
Long: `Add/List/Delete/Import decisions from LAPI`,
Example: `cscli decisions [action] [filter]`,
Aliases: []string{"decision"},
/*TBD example*/
DisableAutoGenTag: true,
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
cfg := cli.cfg()
if err := cfg.LoadAPIClient(); err != nil {
return fmt.Errorf("loading api client: %w", err)
}
apiURL, err := url.Parse(cfg.API.Client.Credentials.URL)
if err != nil {
return fmt.Errorf("parsing api url: %w", err)
}
cli.client, err = apiclient.NewClient(&apiclient.Config{
MachineID: cfg.API.Client.Credentials.Login,
Password: strfmt.Password(cfg.API.Client.Credentials.Password),
URL: apiURL,
VersionPrefix: "v1",
})
if err != nil {
return fmt.Errorf("creating api client: %w", err)
}
return nil
},
}
cmd.AddCommand(cli.newListCmd())
cmd.AddCommand(cli.newAddCmd())
cmd.AddCommand(cli.newDeleteCmd())
cmd.AddCommand(cli.newImportCmd())
return cmd
}
func (cli *cliDecisions) list(ctx context.Context, filter apiclient.AlertsListOpts, noSimu *bool, contained *bool, printMachine bool) error {
var err error
filter.ScopeEquals, err = clialert.SanitizeScope(filter.ScopeEquals, filter.IPEquals, filter.RangeEquals)
if err != nil {
return err
}
filter.ActiveDecisionEquals = new(bool)
*filter.ActiveDecisionEquals = true
if noSimu != nil && *noSimu {
filter.IncludeSimulated = new(bool)
}
/* nullify the empty entries to avoid bad filter */
if *filter.IncludeCAPI {
*filter.Limit = 0
}
if contained != nil && *contained {
filter.Contains = new(bool)
}
alerts, _, err := cli.client.Alerts.List(ctx, filter)
if err != nil {
return fmt.Errorf("unable to retrieve decisions: %w", err)
}
err = cli.decisionsToTable(alerts, printMachine)
if err != nil {
return fmt.Errorf("unable to print decisions: %w", err)
}
return nil
}
func (cli *cliDecisions) newListCmd() *cobra.Command {
filter := apiclient.AlertsListOpts{
ValueEquals: "",
ScopeEquals: "",
ScenarioEquals: "",
OriginEquals: "",
IPEquals: "",
RangeEquals: "",
Since: cstime.DurationWithDays(0),
Until: cstime.DurationWithDays(0),
TypeEquals: "",
IncludeCAPI: new(bool),
Limit: new(int),
}
NoSimu := new(bool)
contained := new(bool)
var printMachine bool
cmd := &cobra.Command{
Use: "list [options]",
Short: "List decisions from LAPI",
Example: `cscli decisions list -i 1.2.3.4
cscli decisions list -r 1.2.3.0/24
cscli decisions list -s crowdsecurity/ssh-bf
cscli decisions list --origin lists --scenario list_name
`,
Args: args.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return cli.list(cmd.Context(), filter, NoSimu, contained, printMachine)
},
}
flags := cmd.Flags()
flags.SortFlags = false
flags.BoolVarP(filter.IncludeCAPI, "all", "a", false, "Include decisions from Central API")
flags.Var(&filter.Since, "since", "restrict to alerts newer than since (ie. 4h, 30d)")
flags.Var(&filter.Until, "until", "restrict to alerts older than until (ie. 4h, 30d)")
flags.StringVarP(&filter.TypeEquals, "type", "t", "", "restrict to this decision type (ie. ban,captcha)")
flags.StringVar(&filter.ScopeEquals, "scope", "", "restrict to this scope (ie. ip,range,session)")
flags.StringVar(&filter.OriginEquals, "origin", "", fmt.Sprintf("the value to match for the specified origin (%s ...)", strings.Join(types.GetOrigins(), ",")))
flags.StringVarP(&filter.ValueEquals, "value", "v", "", "restrict to this value (ie. 1.2.3.4,userName)")
flags.StringVarP(&filter.ScenarioEquals, "scenario", "s", "", "restrict to this scenario (ie. crowdsecurity/ssh-bf)")
flags.StringVarP(&filter.IPEquals, "ip", "i", "", "restrict to alerts from this source ip (shorthand for --scope ip --value <IP>)")
flags.StringVarP(&filter.RangeEquals, "range", "r", "", "restrict to alerts from this source range (shorthand for --scope range --value <RANGE>)")
flags.IntVarP(filter.Limit, "limit", "l", 100, "number of alerts to get (use 0 to remove the limit)")
flags.BoolVar(NoSimu, "no-simu", false, "exclude decisions in simulation mode")
flags.BoolVarP(&printMachine, "machine", "m", false, "print machines that triggered decisions")
flags.BoolVar(contained, "contained", false, "query decisions contained by range")
return cmd
}
//nolint:revive // we'll reduce the number of args later
func (cli *cliDecisions) add(ctx context.Context, addIP, addRange, addDuration, addValue, addScope, addReason, addType string, bypassAllowlist bool) error {
alerts := models.AddAlertsRequest{}
origin := types.CscliOrigin
capacity := int32(0)
leakSpeed := "0"
eventsCount := int32(1)
empty := ""
simulated := false
startAt := time.Now().UTC().Format(time.RFC3339)
stopAt := time.Now().UTC().Format(time.RFC3339)
createdAt := time.Now().UTC().Format(time.RFC3339)
var err error
addScope, err = clialert.SanitizeScope(addScope, addIP, addRange)
if err != nil {
return err
}
if addIP != "" {
addValue = addIP
addScope = types.Ip
} else if addRange != "" {
addValue = addRange
addScope = types.Range
} else if addValue == "" {
return errors.New("missing arguments, a value is required (--ip, --range or --scope and --value)")
}
if addReason == "" {
addReason = fmt.Sprintf("manual '%s' from '%s'", addType, cli.cfg().API.Client.Credentials.Login)
}
if !bypassAllowlist && (addScope == types.Ip || addScope == types.Range) {
resp, _, err := cli.client.Allowlists.CheckIfAllowlistedWithReason(ctx, addValue)
if err != nil {
log.Errorf("Cannot check if %s is in allowlist: %s", addValue, err)
} else if resp.Allowlisted {
return fmt.Errorf("%s is allowlisted by item %s, use --bypass-allowlist to add the decision anyway", addValue, resp.Reason)
}
}
decision := models.Decision{
Duration: &addDuration,
Scope: &addScope,
Value: &addValue,
Type: &addType,
Scenario: &addReason,
Origin: &origin,
}
alert := models.Alert{
Capacity: &capacity,
Decisions: []*models.Decision{&decision},
Events: []*models.Event{},
EventsCount: &eventsCount,
Leakspeed: &leakSpeed,
Message: &addReason,
ScenarioHash: &empty,
Scenario: &addReason,
ScenarioVersion: &empty,
Simulated: &simulated,
// setting empty scope/value broke plugins, and it didn't seem to be needed anymore w/ latest papi changes
Source: &models.Source{
AsName: "",
AsNumber: "",
Cn: "",
IP: addValue,
Range: "",
Scope: &addScope,
Value: &addValue,
},
StartAt: &startAt,
StopAt: &stopAt,
CreatedAt: createdAt,
Remediation: true,
}
alerts = append(alerts, &alert)
_, _, err = cli.client.Alerts.Add(ctx, alerts)
if err != nil {
return err
}
log.Info("Decision successfully added")
return nil
}
func (cli *cliDecisions) newAddCmd() *cobra.Command {
var (
addIP string
addRange string
addDuration string
addValue string
addScope string
addReason string
addType string
bypassAllowlist bool
)
cmd := &cobra.Command{
Use: "add [options]",
Short: "Add decision to LAPI",
Example: `cscli decisions add --ip 1.2.3.4
cscli decisions add --range 1.2.3.0/24
cscli decisions add --ip 1.2.3.4 --duration 24h --type captcha
cscli decisions add --scope username --value foobar
`,
/*TBD : fix long and example*/
Args: args.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return cli.add(cmd.Context(), addIP, addRange, addDuration, addValue, addScope, addReason, addType, bypassAllowlist)
},
}
flags := cmd.Flags()
flags.SortFlags = false
flags.StringVarP(&addIP, "ip", "i", "", "Source ip (shorthand for --scope ip --value <IP>)")
flags.StringVarP(&addRange, "range", "r", "", "Range source ip (shorthand for --scope range --value <RANGE>)")
flags.StringVarP(&addDuration, "duration", "d", "4h", "Decision duration (ie. 1h,4h,30m)")
flags.StringVarP(&addValue, "value", "v", "", "The value (ie. --scope username --value foobar)")
flags.StringVar(&addScope, "scope", types.Ip, "Decision scope (ie. ip,range,username)")
flags.StringVarP(&addReason, "reason", "R", "", "Decision reason (ie. scenario-name)")
flags.StringVarP(&addType, "type", "t", "ban", "Decision type (ie. ban,captcha,throttle)")
flags.BoolVarP(&bypassAllowlist, "bypass-allowlist", "B", false, "Add decision even if value is in allowlist")
return cmd
}
func (cli *cliDecisions) delete(ctx context.Context, delFilter apiclient.DecisionsDeleteOpts, delDecisionID string, contained *bool) error {
var err error
/*take care of shorthand options*/
delFilter.ScopeEquals, err = clialert.SanitizeScope(delFilter.ScopeEquals, delFilter.IPEquals, delFilter.RangeEquals)
if err != nil {
return err
}
if contained != nil && *contained {
delFilter.Contains = new(bool)
}
var decisions *models.DeleteDecisionResponse
if delDecisionID == "" {
decisions, _, err = cli.client.Decisions.Delete(ctx, delFilter)
if err != nil {
return fmt.Errorf("unable to delete decisions: %w", err)
}
} else {
if _, err = strconv.Atoi(delDecisionID); err != nil {
return fmt.Errorf("id '%s' is not an integer: %w", delDecisionID, err)
}
decisions, _, err = cli.client.Decisions.DeleteOne(ctx, delDecisionID)
if err != nil {
return fmt.Errorf("unable to delete decision: %w", err)
}
}
log.Infof("%s decision(s) deleted", decisions.NbDeleted)
return nil
}
func (cli *cliDecisions) newDeleteCmd() *cobra.Command {
delFilter := apiclient.DecisionsDeleteOpts{
ScopeEquals: "",
ValueEquals: "",
TypeEquals: "",
IPEquals: "",
RangeEquals: "",
ScenarioEquals: "",
OriginEquals: "",
}
var delDecisionID string
var delDecisionAll bool
contained := new(bool)
cmd := &cobra.Command{
Use: "delete [options]",
Short: "Delete decisions",
Args: args.NoArgs,
DisableAutoGenTag: true,
Aliases: []string{"remove"},
Example: `cscli decisions delete -r 1.2.3.0/24
cscli decisions delete -i 1.2.3.4
cscli decisions delete --id 42
cscli decisions delete --type captcha
cscli decisions delete --origin lists --scenario list_name
`,
/*TBD : refaire le Long/Example*/
PreRunE: func(cmd *cobra.Command, _ []string) error {
if delDecisionAll {
return nil
}
if delFilter.ScopeEquals == "" && delFilter.ValueEquals == "" &&
delFilter.TypeEquals == "" && delFilter.IPEquals == "" &&
delFilter.RangeEquals == "" && delFilter.ScenarioEquals == "" &&
delFilter.OriginEquals == "" && delDecisionID == "" {
_ = cmd.Usage()
return errors.New("at least one filter or --all must be specified")
}
return nil
},
RunE: func(cmd *cobra.Command, _ []string) error {
return cli.delete(cmd.Context(), delFilter, delDecisionID, contained)
},
}
flags := cmd.Flags()
flags.SortFlags = false
flags.StringVarP(&delFilter.IPEquals, "ip", "i", "", "Source ip (shorthand for --scope ip --value <IP>)")
flags.StringVarP(&delFilter.RangeEquals, "range", "r", "", "Range source ip (shorthand for --scope range --value <RANGE>)")
flags.StringVarP(&delFilter.TypeEquals, "type", "t", "", "the decision type (ie. ban,captcha)")
flags.StringVarP(&delFilter.ValueEquals, "value", "v", "", "the value to match for in the specified scope")
flags.StringVarP(&delFilter.ScenarioEquals, "scenario", "s", "", "the scenario name (ie. crowdsecurity/ssh-bf)")
flags.StringVar(&delFilter.OriginEquals, "origin", "", fmt.Sprintf("the value to match for the specified origin (%s ...)", strings.Join(types.GetOrigins(), ",")))
flags.StringVar(&delDecisionID, "id", "", "decision id")
flags.BoolVar(&delDecisionAll, "all", false, "delete all decisions")
flags.BoolVar(contained, "contained", false, "query decisions contained by range")
return cmd
}