604 lines
18 KiB
Go
604 lines
18 KiB
Go
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 <name> -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
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 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
|
|
}
|
|
|
|
// AddAllowlistEntry adds a value to a named allowlist.
|
|
func (c *CLIClient) AddAllowlistEntry(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", "items", "add", listName, value)
|
|
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", "items", "del", 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,
|
|
"items": true,
|
|
"del": 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 <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 {"<kind>": [...]} 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
|
|
}
|