package crowdsec import ( "bufio" "bytes" "context" "encoding/json" "fmt" "os/exec" "regexp" "strings" "time" ) // CLIClient wraps the cscli binary. type CLIClient struct { cscliPath string } // NewCLIClient creates a new CLI client. // cscliPath should be the absolute path to the cscli binary. func NewCLIClient(cscliPath string) *CLIClient { return &CLIClient{cscliPath: cscliPath} } // ----------------------------------------------------------------------- // Decisions // ----------------------------------------------------------------------- // ListDecisions returns decisions via cscli, applying filter options. // cscli decisions list -o json returns alert objects with nested decisions — // not a flat decision list. We extract and flatten the nested decisions. // cscli does not support offset, so pagination is Go-side. func (c *CLIClient) ListDecisions(ctx context.Context, f DecisionFilter) ([]Decision, error) { // Fetch enough alerts to cover offset+limit decisions. // Since --limit is per-alert and each alert typically has one decision, // multiply by a small factor; minimum fetch covers the full page range. fetchLimit := f.Limit if f.Offset > 0 { fetchLimit = f.Offset + f.Limit } // Always fetch at least 500 so small offsets don't under-fetch. if fetchLimit < 500 { fetchLimit = 500 } args := []string{"decisions", "list", "-o", "json"} args = append(args, "--limit", fmt.Sprintf("%d", fetchLimit)) if f.Type != "" && safeArg.MatchString(f.Type) { args = append(args, "--type", f.Type) } if f.Scope != "" && safeArg.MatchString(f.Scope) { args = append(args, "--scope", f.Scope) } if f.Value != "" && safeArg.MatchString(f.Value) { args = append(args, "--value", f.Value) } if f.Origin != "" && safeArg.MatchString(f.Origin) { args = append(args, "--origin", f.Origin) } out, err := c.run(ctx, args...) if err != nil { return nil, err } // cscli returns null when no decisions exist if len(out) == 0 || string(out) == "null\n" || string(out) == "null" { return []Decision{}, nil } // cscli decisions list -o json returns alert objects, each containing // a "decisions" array. Extract and flatten those nested decisions. var alerts []Alert if err := json.Unmarshal(out, &alerts); err != nil { return nil, fmt.Errorf("parse decisions: %w\noutput: %s", err, string(out)) } var decisions []Decision for _, a := range alerts { decisions = append(decisions, a.Decisions...) } // Apply Go-side offset slice. if f.Offset > 0 { if f.Offset >= len(decisions) { return []Decision{}, nil } decisions = decisions[f.Offset:] } return decisions, nil } // AddDecision adds a manual decision via cscli. func (c *CLIClient) AddDecision(ctx context.Context, d DecisionInput) error { args := []string{"decisions", "add", "--duration", d.Duration, "--type", d.Type} switch d.Scope { case "Ip": args = append(args, "--ip", d.Value) case "Range": args = append(args, "--range", d.Value) default: args = append(args, "--scope", d.Scope, "--value", d.Value) } if d.Scenario != "" && safeArg.MatchString(d.Scenario) { args = append(args, "--reason", d.Scenario) } _, err := c.run(ctx, args...) return err } // DeleteDecision removes a decision by ID via cscli. func (c *CLIClient) DeleteDecision(ctx context.Context, id int64) error { _, err := c.run(ctx, "decisions", "delete", "--id", fmt.Sprintf("%d", id)) return err } // ----------------------------------------------------------------------- // Bouncers // ----------------------------------------------------------------------- // ListBouncers returns all registered bouncers. func (c *CLIClient) ListBouncers(ctx context.Context) ([]Bouncer, error) { out, err := c.run(ctx, "bouncers", "list", "-o", "json") if err != nil { return nil, err } var bouncers []Bouncer if err := json.Unmarshal(out, &bouncers); err != nil { return nil, fmt.Errorf("parse bouncers: %w\noutput: %s", err, string(out)) } return bouncers, nil } // AddBouncer registers a new bouncer and returns the generated API key. func (c *CLIClient) AddBouncer(ctx context.Context, name string) (*AddBouncerResult, error) { if err := validateName(name); err != nil { return nil, err } // cscli bouncers add -o raw → prints only the API key out, err := c.run(ctx, "bouncers", "add", name, "-o", "raw") if err != nil { return nil, err } apiKey := strings.TrimSpace(string(out)) if apiKey == "" { return nil, fmt.Errorf("no API key returned for bouncer %q", name) } return &AddBouncerResult{Name: name, APIKey: apiKey}, nil } // DeleteBouncer removes a bouncer by name. func (c *CLIClient) DeleteBouncer(ctx context.Context, name string) error { if err := validateName(name); err != nil { return err } _, err := c.run(ctx, "bouncers", "delete", name) return err } // ----------------------------------------------------------------------- // Machines // ----------------------------------------------------------------------- // ListMachines returns all registered machines. func (c *CLIClient) ListMachines(ctx context.Context) ([]Machine, error) { out, err := c.run(ctx, "machines", "list", "-o", "json") if err != nil { return nil, err } var machines []Machine if err := json.Unmarshal(out, &machines); err != nil { return nil, fmt.Errorf("parse machines: %w\noutput: %s", err, string(out)) } return machines, nil } // DeleteMachine removes a machine by ID. func (c *CLIClient) DeleteMachine(ctx context.Context, machineID string) error { if err := validateName(machineID); err != nil { return err } _, err := c.run(ctx, "machines", "delete", "--machine-id", machineID) return err } // ValidateMachine validates a pending machine registration. func (c *CLIClient) ValidateMachine(ctx context.Context, machineID string) error { if err := validateName(machineID); err != nil { return err } _, err := c.run(ctx, "machines", "validate", machineID) return err } // ----------------------------------------------------------------------- // Hub — Collections // ----------------------------------------------------------------------- // ListCollections returns all known collections. func (c *CLIClient) ListCollections(ctx context.Context) ([]HubItem, error) { return c.listHubItems(ctx, "collections") } // InstallCollection installs a hub collection. func (c *CLIClient) InstallCollection(ctx context.Context, name string) error { return c.hubAction(ctx, "collections", "install", name) } // RemoveCollection removes a hub collection. func (c *CLIClient) RemoveCollection(ctx context.Context, name string) error { return c.hubAction(ctx, "collections", "remove", name) } // ----------------------------------------------------------------------- // Hub — Parsers // ----------------------------------------------------------------------- // ListParsers returns all known parsers. func (c *CLIClient) ListParsers(ctx context.Context) ([]HubItem, error) { return c.listHubItems(ctx, "parsers") } // InstallParser installs a parser. func (c *CLIClient) InstallParser(ctx context.Context, name string) error { return c.hubAction(ctx, "parsers", "install", name) } // RemoveParser removes a parser. func (c *CLIClient) RemoveParser(ctx context.Context, name string) error { return c.hubAction(ctx, "parsers", "remove", name) } // ----------------------------------------------------------------------- // Hub — Scenarios // ----------------------------------------------------------------------- // ListScenarios returns all known scenarios. func (c *CLIClient) ListScenarios(ctx context.Context) ([]HubItem, error) { return c.listHubItems(ctx, "scenarios") } // InstallScenario installs a scenario. func (c *CLIClient) InstallScenario(ctx context.Context, name string) error { return c.hubAction(ctx, "scenarios", "install", name) } // RemoveScenario removes a scenario. func (c *CLIClient) RemoveScenario(ctx context.Context, name string) error { return c.hubAction(ctx, "scenarios", "remove", name) } // ----------------------------------------------------------------------- // Hub — Postoverflows // ----------------------------------------------------------------------- // ListPostoverflows returns all known postoverflows. func (c *CLIClient) ListPostoverflows(ctx context.Context) ([]HubItem, error) { return c.listHubItems(ctx, "postoverflows") } // InstallPostoverflow installs a postoverflow. func (c *CLIClient) InstallPostoverflow(ctx context.Context, name string) error { return c.hubAction(ctx, "postoverflows", "install", name) } // RemovePostoverflow removes a postoverflow. func (c *CLIClient) RemovePostoverflow(ctx context.Context, name string) error { return c.hubAction(ctx, "postoverflows", "remove", name) } // HubUpdate runs cscli hub update && cscli hub upgrade. func (c *CLIClient) HubUpdate(ctx context.Context) error { if _, err := c.run(ctx, "hub", "update"); err != nil { return fmt.Errorf("hub update: %w", err) } if _, err := c.run(ctx, "hub", "upgrade"); err != nil { return fmt.Errorf("hub upgrade: %w", err) } return nil } // ----------------------------------------------------------------------- // Metrics // ----------------------------------------------------------------------- // GetMetrics runs cscli metrics and returns parsed sections plus raw output. func (c *CLIClient) GetMetrics(ctx context.Context) ([]MetricsSection, string, error) { // cscli metrics does not support JSON output reliably — parse table output ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() out, err := c.run(ctx, "metrics") if err != nil { return nil, "", err } raw := string(out) return parseMetricsOutput(raw), raw, nil } // GetVersion returns cscli version information. func (c *CLIClient) GetVersion(ctx context.Context) (string, error) { out, err := c.run(ctx, "version") if err != nil { return "", err } // version output is plain text like "v1.6.4-..." return strings.TrimSpace(string(out)), nil } // ----------------------------------------------------------------------- // Allowlists // ----------------------------------------------------------------------- // ListAllowlists returns all configured allowlists. func (c *CLIClient) ListAllowlists(ctx context.Context) ([]Allowlist, error) { out, err := c.run(ctx, "allowlists", "list", "-o", "json") if err != nil { return nil, err } if len(out) == 0 || string(out) == "null\n" || string(out) == "null" { return []Allowlist{}, nil } var lists []Allowlist if err := json.Unmarshal(out, &lists); err != nil { // try wrapped: {"allowlists": [...]} var wrapper struct { Allowlists []Allowlist `json:"allowlists"` } if err2 := json.Unmarshal(out, &wrapper); err2 != nil { return nil, fmt.Errorf("parse allowlists: %w\noutput: %s", err, string(out)) } return wrapper.Allowlists, nil } return lists, nil } // InspectAllowlist returns details and items for a named allowlist. func (c *CLIClient) InspectAllowlist(ctx context.Context, name string) (*Allowlist, error) { if !safeArg.MatchString(name) { return nil, fmt.Errorf("invalid allowlist name: %q", name) } out, err := c.run(ctx, "allowlists", "inspect", name, "-o", "json") if err != nil { return nil, err } var al Allowlist if err := json.Unmarshal(out, &al); err != nil { return nil, fmt.Errorf("parse allowlist inspect: %w\noutput: %s", err, string(out)) } return &al, nil } // CreateAllowlist creates a new allowlist. Ignores "already exists" errors. // cscli v1.7 requires the -d (description) flag. func (c *CLIClient) CreateAllowlist(ctx context.Context, name string) error { if !safeArg.MatchString(name) { return fmt.Errorf("invalid allowlist name: %q", name) } _, err := c.run(ctx, "allowlists", "create", name, "-d", "crowdsec-dashy") if err != nil { msg := err.Error() if strings.Contains(msg, "already exists") || strings.Contains(msg, "already exist") { return nil } return err } return nil } // AddAllowlistEntries adds one or more values to a named allowlist in a single call. // comment is optional; if non-empty it is passed as -d to cscli. func (c *CLIClient) AddAllowlistEntries(ctx context.Context, listName, comment string, values []string) error { if !safeArg.MatchString(listName) { return fmt.Errorf("invalid allowlist name: %q", listName) } if len(values) == 0 { return fmt.Errorf("no values provided") } args := []string{"allowlists", "add", listName} for _, v := range values { if !safeArg.MatchString(v) { return fmt.Errorf("invalid allowlist value: %q", v) } args = append(args, v) } if comment != "" { if !safeFlagValue.MatchString(comment) { return fmt.Errorf("invalid comment: only letters, digits, spaces, and common punctuation allowed") } args = append(args, "-d", comment) } _, err := c.run(ctx, args...) return err } // DeleteAllowlist removes an entire allowlist. func (c *CLIClient) DeleteAllowlist(ctx context.Context, name string) error { if !safeArg.MatchString(name) { return fmt.Errorf("invalid allowlist name: %q", name) } _, err := c.run(ctx, "allowlists", "delete", name) return err } // RemoveAllowlistEntry removes a value from a named allowlist. func (c *CLIClient) RemoveAllowlistEntry(ctx context.Context, listName, value string) error { if !safeArg.MatchString(listName) { return fmt.Errorf("invalid allowlist name: %q", listName) } if !safeArg.MatchString(value) { return fmt.Errorf("invalid allowlist value: %q", value) } _, err := c.run(ctx, "allowlists", "remove", listName, value) return err } // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- // run executes cscli with the given arguments. // Arguments are passed as a slice — never through a shell — preventing injection. // Only specific subcommands are allowed. func (c *CLIClient) run(ctx context.Context, args ...string) ([]byte, error) { if err := validateArgs(args); err != nil { return nil, err } ctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() cmd := exec.CommandContext(ctx, c.cscliPath, args...) //nolint:gosec — path validated at startup var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("cscli %s: %w — %s", strings.Join(args, " "), err, stderr.String()) } return stdout.Bytes(), nil } // allowedSubcommands is the strict allow-list of cscli first arguments. var allowedSubcommands = map[string]bool{ "bouncers": true, "machines": true, "collections": true, "parsers": true, "scenarios": true, "postoverflows": true, "hub": true, "metrics": true, "version": true, "decisions": true, "allowlists": true, } // allowedActions for each subcommand. var allowedActions = map[string]bool{ "list": true, "add": true, "delete": true, "install": true, "remove": true, "update": true, "upgrade": true, "validate": true, "create": true, "inspect": true, "del": true, } // safeArg matches strings that are safe to pass as positional cscli arguments. var safeArg = regexp.MustCompile(`^[a-zA-Z0-9_./:@\-]+$`) // safeFlagValue matches flag values (after --flag). More permissive: allows spaces and // common punctuation, but blocks null bytes, control chars, and shell metacharacters. var safeFlagValue = regexp.MustCompile(`^[a-zA-Z0-9 _./:@\-,!?()']+$`) func validateArgs(args []string) error { if len(args) == 0 { return fmt.Errorf("no subcommand provided") } if !allowedSubcommands[args[0]] { return fmt.Errorf("disallowed cscli subcommand: %q", args[0]) } // Iterate remaining args. Flag names (start with -) are allowed as-is. // The arg immediately after a flag is treated as its value and validated with // the more permissive safeFlagValue rather than safeArg. prevWasFlag := false for i := 1; i < len(args); i++ { arg := args[i] if strings.HasPrefix(arg, "-") { prevWasFlag = true continue } if prevWasFlag { prevWasFlag = false if !safeFlagValue.MatchString(arg) { return fmt.Errorf("unsafe cscli flag value: %q", arg) } continue } prevWasFlag = false if allowedActions[arg] { continue } if arg == "raw" || arg == "json" || arg == "human" { continue } if !safeArg.MatchString(arg) { return fmt.Errorf("unsafe cscli argument: %q", arg) } } return nil } // validateName checks that a name passed by the user is safe to use as a cscli argument. var validName = regexp.MustCompile(`^[a-zA-Z0-9_\-]{1,64}$`) func validateName(name string) error { if !validName.MatchString(name) { return fmt.Errorf("invalid name %q: must be 1-64 alphanumeric/dash/underscore characters", name) } return nil } func (c *CLIClient) listHubItems(ctx context.Context, kind string) ([]HubItem, error) { out, err := c.run(ctx, kind, "list", "-o", "json") if err != nil { return nil, err } // cscli returns either {"": [...]} or just [...] // Try array first var items []HubItem if err := json.Unmarshal(out, &items); err == nil { return items, nil } // Try object wrapper var wrapper map[string][]HubItem if err := json.Unmarshal(out, &wrapper); err != nil { return nil, fmt.Errorf("parse %s: %w\noutput: %s", kind, err, string(out)) } for _, v := range wrapper { return v, nil } return []HubItem{}, nil } func (c *CLIClient) hubAction(ctx context.Context, kind, action, name string) error { // Hub item names can contain slashes (e.g. crowdsecurity/ssh-bf); use safeArg not validateName. if !safeArg.MatchString(name) || name == "" { return fmt.Errorf("invalid hub item name: %q", name) } _, err := c.run(ctx, kind, action, name) return err } // colSepInLine reports whether a line contains a column separator (Unicode │ or ASCII |). func colSepInLine(s string) bool { return strings.ContainsRune(s, '│') || strings.ContainsRune(s, '|') } // isTableBorder reports whether a line consists entirely of border/line characters // (handles both Unicode box-drawing and ASCII +/-/| borders). func isTableBorder(s string) bool { t := strings.TrimSpace(s) if len(t) < 3 { return false } for _, r := range t { switch r { case '─', '-', '+', '│', '|', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴', '┼', ' ': // border char — ok default: return false } } return true } // parseMetricsOutput parses the tabular output of `cscli metrics`. // Handles both Unicode box-drawing (╭│─╮) and ASCII (+|-) table borders. func parseMetricsOutput(output string) []MetricsSection { var sections []MetricsSection var current *MetricsSection var headerFound bool scanner := bufio.NewScanner(strings.NewReader(output)) for scanner.Scan() { line := scanner.Text() trimmed := strings.TrimSpace(line) // Skip blank lines and log-prefix lines if trimmed == "" || strings.HasPrefix(trimmed, "time=") || strings.HasPrefix(trimmed, "level=") { continue } // Skip pure border/separator rows if isTableBorder(line) { continue } // Lines with a column separator are header or data rows if colSepInLine(line) { if current == nil { continue } parts := splitTableRow(line) if len(parts) == 0 { continue } if !headerFound { current.Headers = parts headerFound = true } else { current.Rows = append(current.Rows, parts) } continue } // Plain non-empty text → section title if current != nil && len(current.Rows) > 0 { sections = append(sections, *current) } current = &MetricsSection{Title: trimmed} headerFound = false } if current != nil && len(current.Rows) > 0 { sections = append(sections, *current) } return sections } func splitTableRow(line string) []string { sep := "│" if !strings.ContainsRune(line, '│') { sep = "|" } parts := strings.Split(line, sep) var cells []string for _, p := range parts { trimmed := strings.TrimSpace(p) if trimmed != "" { cells = append(cells, trimmed) } } return cells }