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 does not support offset, so pagination is handled in Go by fetching // enough rows and slicing. Maximum fetch = page * limit + 1. func (c *CLIClient) ListDecisions(ctx context.Context, f DecisionFilter) ([]Decision, error) { fetchLimit := f.Limit if f.Offset > 0 { fetchLimit = f.Offset + f.Limit } args := []string{"decisions", "list", "-o", "json"} if fetchLimit > 0 { 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 } var decisions []Decision if err := json.Unmarshal(out, &decisions); err != nil { return nil, fmt.Errorf("parse decisions: %w\noutput: %s", err, string(out)) } // 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") } // 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 } // ----------------------------------------------------------------------- // 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, } // 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, } // safeArg matches strings that are safe to pass as arguments (no shell metacharacters). var safeArg = 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]) } // Skip flag-value pairs like "-o json" and "--machine-id " — validate values for i := 1; i < len(args); i++ { arg := args[i] if strings.HasPrefix(arg, "-") { continue // flags are fine } if allowedActions[arg] { continue // known action words } if arg == "raw" || arg == "json" || arg == "human" { continue // output format values } // Everything else (names, IDs) must match safe pattern 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 }