// SPDX-License-Identifier: GPL-3.0-or-later // Package config loads the flat subset of enodia-sentinel.toml needed by the // parallel Go agent. It intentionally uses only the Go standard library. package config import ( "encoding/csv" "fmt" "os" "strconv" "strings" "time" ) var defaultDetectors = []string{ "reverse_shell", "ld_preload", "deleted_exe", "input_snooper", "credential_access", "stealth_network", "memory_obfuscation", "first_seen", "new_listener", "new_suid", "persistence", "egress", } // Config mirrors the Phase 1 settings consumed by the Go sweep loop. More // fields are added as their detectors are ported. type Config struct { SampleInterval time.Duration HeartbeatMaxAge int Detectors map[string]bool } // Default returns Python-compatible defaults for the fields currently used. func Default() Config { detectors := make(map[string]bool, len(defaultDetectors)) for _, name := range defaultDetectors { detectors[name] = true } return Config{ SampleInterval: 4 * time.Second, HeartbeatMaxAge: 120, Detectors: detectors, } } // Enabled reports whether a detector is enabled by configuration. func (c Config) Enabled(name string) bool { return c.Detectors[name] } // Load reads a flat Sentinel TOML file. Missing files keep defaults, matching // Python Config.load. Unknown keys are ignored for forward compatibility. func Load(path string) (Config, error) { cfg := Default() if path == "" { path = os.Getenv("ENODIA_CONFIG") } if path == "" { path = "/etc/enodia-sentinel.toml" } raw, err := os.ReadFile(path) if os.IsNotExist(err) { return cfg, nil } if err != nil { return Config{}, err } for _, assignment := range assignments(string(raw)) { key, value, ok := strings.Cut(assignment, "=") if !ok { continue } key, value = strings.TrimSpace(key), strings.TrimSpace(value) switch key { case "sample_interval": seconds, err := strconv.ParseFloat(value, 64) if err != nil || seconds <= 0 { return Config{}, fmt.Errorf("sample_interval must be positive: %q", value) } cfg.SampleInterval = time.Duration(seconds * float64(time.Second)) case "heartbeat_max_age": age, err := strconv.Atoi(value) if err != nil || age < 0 { return Config{}, fmt.Errorf("heartbeat_max_age must be non-negative: %q", value) } cfg.HeartbeatMaxAge = age case "detectors": names, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("detectors: %w", err) } cfg.Detectors = make(map[string]bool, len(names)) for _, name := range names { cfg.Detectors[name] = true } } } return cfg, nil } func assignments(text string) []string { var out []string var current strings.Builder depth := 0 for _, raw := range strings.Split(text, "\n") { line := strings.TrimSpace(stripComment(raw)) if line == "" { continue } if current.Len() > 0 { current.WriteByte(' ') } current.WriteString(line) depth += strings.Count(line, "[") - strings.Count(line, "]") if depth <= 0 { out = append(out, current.String()) current.Reset() depth = 0 } } if current.Len() > 0 { out = append(out, current.String()) } return out } func stripComment(line string) string { inString, escaped := false, false for i, r := range line { if escaped { escaped = false continue } if r == '\\' && inString { escaped = true continue } if r == '"' { inString = !inString continue } if r == '#' && !inString { return line[:i] } } return line } func stringArray(value string) ([]string, error) { value = strings.TrimSpace(value) if len(value) < 2 || value[0] != '[' || value[len(value)-1] != ']' { return nil, fmt.Errorf("expected array, got %q", value) } body := strings.TrimSpace(value[1 : len(value)-1]) if body == "" { return []string{}, nil } reader := csv.NewReader(strings.NewReader(body)) reader.TrimLeadingSpace = true fields, err := reader.Read() if err != nil { return nil, err } out := make([]string, 0, len(fields)) for _, field := range fields { field = strings.TrimSpace(field) if field != "" { out = append(out, field) } } return out, nil }