// 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", } var defaultInputSnooperAllowComms = []string{ "Xorg", "Xwayland", "gnome-shell", "kwin_wayland", "sway", "Hyprland", "systemd-logind", "input-remapper", "keyd", "kanata", } var defaultCredentialAccessAllowComms = []string{ "sshd", "sudo", "su", "login", "gdm-session-worker", "polkitd", "gnome-keyring-daemon", "kwalletd5", "kwalletd6", "firefox", "chromium", "chrome", "google-chrome", "brave", "brave-browser", } var defaultInterpreters = []string{ "bash", "sh", "dash", "zsh", "ksh", "ash", "nc", "ncat", "netcat", "socat", "telnet", "python", "python2", "python3", "perl", "ruby", "php", "lua", "awk", } // These defaults intentionally duplicate the Python Config values. The parity // harness catches detector output drift, while config tests catch tuning drift // before a detector ever runs. var defaultStealthNetworkAllowComms = []string{ "NetworkManager", "systemd-networkd", "wpa_supplicant", "dhcpcd", "tcpdump", "dumpcap", "wireshark", "suricata", "zeek", } var defaultMemoryObfuscationAllowComms = []string{ "java", "node", "firefox", "chromium", "chrome", "google-chrome", "brave", "brave-browser", "WebKitWebProcess", "dotnet", "qemu-system-x86", "qemu-system-x86_64", "wine", "wasmtime", } // 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 InputSnooperAllowComms map[string]bool CredentialAccessAllowComms map[string]bool CredentialAccessExtraPaths []string Interpreters map[string]bool EgressAllowCIDRs []string StealthNetworkAllowComms map[string]bool StealthNetworkAllowKinds map[string]bool MemoryObfuscationAllowComms map[string]bool MemoryObfuscationAllowPaths []string } // 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, InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms), CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms), CredentialAccessExtraPaths: []string{}, Interpreters: stringSet(defaultInterpreters), EgressAllowCIDRs: []string{}, StealthNetworkAllowComms: stringSet(defaultStealthNetworkAllowComms), StealthNetworkAllowKinds: map[string]bool{}, MemoryObfuscationAllowComms: stringSet(defaultMemoryObfuscationAllowComms), MemoryObfuscationAllowPaths: []string{}, } } // 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 } case "input_snooper_allow_comms": names, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("input_snooper_allow_comms: %w", err) } cfg.InputSnooperAllowComms = stringSet(names) case "credential_access_allow_comms": names, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("credential_access_allow_comms: %w", err) } cfg.CredentialAccessAllowComms = stringSet(names) case "credential_access_extra_paths": paths, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("credential_access_extra_paths: %w", err) } cfg.CredentialAccessExtraPaths = paths case "interpreters": // Keep arrays as sets where membership is the runtime operation. TOML // order has no behavioral meaning for allowlists. names, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("interpreters: %w", err) } cfg.Interpreters = stringSet(names) case "egress_allow_cidrs": cidrs, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("egress_allow_cidrs: %w", err) } cfg.EgressAllowCIDRs = cidrs case "stealth_network_allow_comms": names, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("stealth_network_allow_comms: %w", err) } cfg.StealthNetworkAllowComms = stringSet(names) case "stealth_network_allow_kinds": kinds, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("stealth_network_allow_kinds: %w", err) } cfg.StealthNetworkAllowKinds = stringSet(kinds) case "memory_obfuscation_allow_comms": comms, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("memory_obfuscation_allow_comms: %w", err) } cfg.MemoryObfuscationAllowComms = stringSet(comms) case "memory_obfuscation_allow_paths": paths, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("memory_obfuscation_allow_paths: %w", err) } cfg.MemoryObfuscationAllowPaths = paths } } return cfg, nil } func stringSet(values []string) map[string]bool { result := make(map[string]bool, len(values)) for _, value := range values { result[value] = true } return result } 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 }