// 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 Cooldown time.Duration BaselineGrace time.Duration SUIDRefresh time.Duration SUIDScanInterval time.Duration HeartbeatMaxAge int MaxSnapshots int MaxSnapshotAgeDays int IncidentTracking bool IncidentWindow int IncidentLineageDepth int Detectors map[string]bool InputSnooperAllowComms map[string]bool CredentialAccessAllowComms map[string]bool CredentialAccessExtraPaths []string Interpreters map[string]bool EgressAllowCIDRs []string ListenerAllowPorts map[string]bool ListenerAllowComms map[string]bool SuppressPackageOwnedListeners bool FirstSeenEnabled bool SUIDHotDirs []string SUIDScanExtraDirs []string WatchPersistence []string StealthNetworkAllowComms map[string]bool StealthNetworkAllowKinds map[string]bool MemoryObfuscationAllowComms map[string]bool MemoryObfuscationAllowPaths []string ExecRulesFile string LogDir 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, Cooldown: 60 * time.Second, BaselineGrace: 10 * time.Second, SUIDRefresh: time.Hour, SUIDScanInterval: time.Minute, HeartbeatMaxAge: 120, MaxSnapshots: 300, MaxSnapshotAgeDays: 60, IncidentTracking: true, IncidentWindow: 1800, IncidentLineageDepth: 8, Detectors: detectors, InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms), CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms), CredentialAccessExtraPaths: []string{}, Interpreters: stringSet(defaultInterpreters), EgressAllowCIDRs: []string{}, ListenerAllowPorts: map[string]bool{}, ListenerAllowComms: map[string]bool{}, SuppressPackageOwnedListeners: false, FirstSeenEnabled: true, SUIDHotDirs: []string{"/tmp", "/dev/shm", "/var/tmp", "/home", "/run/user"}, SUIDScanExtraDirs: []string{"/tmp", "/dev/shm", "/var/tmp", "/run/user"}, WatchPersistence: []string{ "/etc/cron.d", "/etc/crontab", "/etc/cron.daily", "/etc/cron.hourly", "/etc/cron.weekly", "/var/spool/cron", "/etc/systemd/system", "/etc/ld.so.preload", "/etc/passwd", "/etc/sudoers", "/etc/sudoers.d", "/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile", }, StealthNetworkAllowComms: stringSet(defaultStealthNetworkAllowComms), StealthNetworkAllowKinds: map[string]bool{}, MemoryObfuscationAllowComms: stringSet(defaultMemoryObfuscationAllowComms), MemoryObfuscationAllowPaths: []string{}, ExecRulesFile: "", LogDir: envOrDefault("ENODIA_LOG_DIR", "/var/log/enodia-sentinel"), } } // Enabled reports whether a detector is enabled by configuration. func (c Config) Enabled(name string) bool { if name == "first_seen" && !c.FirstSeenEnabled { return false } 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 "cooldown": duration, err := nonNegativeSeconds(value) if err != nil { return Config{}, fmt.Errorf("cooldown: %w", err) } cfg.Cooldown = duration case "baseline_grace": duration, err := nonNegativeSeconds(value) if err != nil { return Config{}, fmt.Errorf("baseline_grace: %w", err) } cfg.BaselineGrace = duration case "suid_refresh": duration, err := positiveSeconds(value) if err != nil { return Config{}, fmt.Errorf("suid_refresh: %w", err) } cfg.SUIDRefresh = duration case "suid_scan_interval": duration, err := positiveSeconds(value) if err != nil { return Config{}, fmt.Errorf("suid_scan_interval: %w", err) } cfg.SUIDScanInterval = duration 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 "max_snapshots": count, err := strconv.Atoi(value) if err != nil || count < 0 { return Config{}, fmt.Errorf("max_snapshots must be non-negative: %q", value) } cfg.MaxSnapshots = count case "max_snapshot_age_days": days, err := strconv.Atoi(value) if err != nil || days < 0 { return Config{}, fmt.Errorf("max_snapshot_age_days must be non-negative: %q", value) } cfg.MaxSnapshotAgeDays = days case "incident_tracking": enabled, err := boolean(value) if err != nil { return Config{}, fmt.Errorf("incident_tracking: %w", err) } cfg.IncidentTracking = enabled case "incident_window": window, err := nonNegativeInt(value) if err != nil { return Config{}, fmt.Errorf("incident_window: %w", err) } cfg.IncidentWindow = window case "incident_lineage_depth": depth, err := nonNegativeInt(value) if err != nil { return Config{}, fmt.Errorf("incident_lineage_depth: %w", err) } cfg.IncidentLineageDepth = depth 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 "listener_allow_ports": ports, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("listener_allow_ports: %w", err) } cfg.ListenerAllowPorts = stringSet(ports) case "listener_allow_comms": comms, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("listener_allow_comms: %w", err) } cfg.ListenerAllowComms = stringSet(comms) case "suppress_package_owned_listeners": enabled, err := boolean(value) if err != nil { return Config{}, fmt.Errorf("suppress_package_owned_listeners: %w", err) } cfg.SuppressPackageOwnedListeners = enabled case "first_seen_enabled": enabled, err := boolean(value) if err != nil { return Config{}, fmt.Errorf("first_seen_enabled: %w", err) } cfg.FirstSeenEnabled = enabled case "suid_hot_dirs": dirs, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("suid_hot_dirs: %w", err) } cfg.SUIDHotDirs = dirs case "suid_scan_extra_dirs": dirs, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("suid_scan_extra_dirs: %w", err) } cfg.SUIDScanExtraDirs = dirs case "watch_persistence": paths, err := stringArray(value) if err != nil { return Config{}, fmt.Errorf("watch_persistence: %w", err) } cfg.WatchPersistence = paths 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 case "exec_rules_file": path, err := strconv.Unquote(value) if err != nil { return Config{}, fmt.Errorf("exec_rules_file must be a string: %q", value) } cfg.ExecRulesFile = path case "log_dir": path, err := strconv.Unquote(value) if err != nil || path == "" { return Config{}, fmt.Errorf("log_dir must be a non-empty string: %q", value) } cfg.LogDir = path } } return cfg, nil } func envOrDefault(name, fallback string) string { if value := os.Getenv(name); value != "" { return value } return fallback } func positiveSeconds(value string) (time.Duration, error) { seconds, err := strconv.ParseFloat(value, 64) if err != nil || seconds <= 0 { return 0, fmt.Errorf("must be positive: %q", value) } return time.Duration(seconds * float64(time.Second)), nil } func nonNegativeSeconds(value string) (time.Duration, error) { seconds, err := strconv.ParseFloat(value, 64) if err != nil || seconds < 0 { return 0, fmt.Errorf("must be non-negative: %q", value) } return time.Duration(seconds * float64(time.Second)), nil } func nonNegativeInt(value string) (int, error) { parsed, err := strconv.Atoi(value) if err != nil || parsed < 0 { return 0, fmt.Errorf("must be non-negative: %q", value) } return parsed, nil } func boolean(value string) (bool, error) { switch value { case "true": return true, nil case "false": return false, nil default: return false, fmt.Errorf("must be true or false: %q", value) } } 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 }