diff --git a/pkg/deviceagent/atomicfile_windows.go b/pkg/deviceagent/atomicfile_windows.go index 68e0426f3..e71529488 100644 --- a/pkg/deviceagent/atomicfile_windows.go +++ b/pkg/deviceagent/atomicfile_windows.go @@ -39,6 +39,7 @@ func replaceRegularFile(path string, data []byte, perm os.FileMode) error { if err := os.Rename(tmp, path); err != nil { _ = os.Rename(old, path) _ = os.Remove(tmp) + return fmt.Errorf("cannot replace file: %w", err) } diff --git a/pkg/deviceagent/checks/checks_freebsd.go b/pkg/deviceagent/checks/checks_freebsd.go index ec9e77ea4..6f48e1175 100644 --- a/pkg/deviceagent/checks/checks_freebsd.go +++ b/pkg/deviceagent/checks/checks_freebsd.go @@ -36,14 +36,18 @@ func freebsdDiskEncryption(ctx context.Context) Result { if !CommandExists("geli") { return unknown(map[string]any{"note": "geli command not found"}) } + out := RunCommand(ctx, "geli", "status") + ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr} if out.Err != nil { return unknown(ev) } + if strings.Contains(out.Stdout, "ACTIVE") { return pass(ev) } + return fail(ev) } @@ -54,6 +58,7 @@ func freebsdScreenLock(ctx context.Context) Result { return pass(map[string]any{"raw": out.Stdout}) } } + return notApplicable( map[string]any{ "note": "FreeBSD does not have a unified screen lock policy", @@ -65,26 +70,33 @@ func freebsdFirewall(ctx context.Context) Result { if !CommandExists("pfctl") { return unknown(map[string]any{"note": "pfctl not found"}) } + out := RunCommand(ctx, "pfctl", "-si") + ev := map[string]any{"raw": truncate(out.Stdout, 400)} if out.Err != nil { return unknown(ev) } + if strings.Contains(out.Stdout, "Status: Enabled") { return pass(ev) } + return fail(ev) } func freebsdTimeSync(ctx context.Context) Result { out := RunCommand(ctx, "service", "ntpd", "status") + ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr} if out.Err != nil { return fail(ev) } + if strings.Contains(strings.ToLower(out.Stdout), "is running") { return pass(ev) } + return fail(ev) } @@ -93,6 +105,7 @@ func freebsdOSVersion(ctx context.Context) Result { if out.Err != nil { return unknown(map[string]any{"error": out.Err.Error()}) } + return pass(map[string]any{"release": out.Stdout}) } @@ -109,15 +122,18 @@ func freebsdPasswordPolicy(ctx context.Context) Result { if err != nil { return unknown(map[string]any{"error": err.Error()}) } + body := string(data) hasPolicy := strings.Contains(body, "minpasswordlen=") || strings.Contains(body, "passwordtime=") + ev := map[string]any{ "login_conf_snippet": truncate(body, 400), } if hasPolicy { return pass(ev) } + return fail(ev) } @@ -129,25 +145,32 @@ func freebsdMalwareProtection(ctx context.Context) Result { }, ) } + out := RunCommand(ctx, "service", "clamav_clamd", "status") + ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr} if out.Err != nil { return unknown(ev) } + if strings.Contains(strings.ToLower(out.Stdout), "is running") { return pass(ev) } + return fail(ev) } func freebsdRemoteLogin(ctx context.Context) Result { out := RunCommand(ctx, "service", "sshd", "status") + ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr} if out.Err != nil { return unknown(ev) } + if strings.Contains(strings.ToLower(out.Stdout), "is running") { return fail(ev) } + return pass(ev) } diff --git a/pkg/deviceagent/checks/checks_linux.go b/pkg/deviceagent/checks/checks_linux.go index 479e69ab0..50b845165 100644 --- a/pkg/deviceagent/checks/checks_linux.go +++ b/pkg/deviceagent/checks/checks_linux.go @@ -39,6 +39,7 @@ func linuxDiskEncryption(ctx context.Context) Result { if data, err := os.ReadFile("/etc/crypttab"); err == nil { body := strings.TrimSpace(string(data)) ev["crypttab_present"] = true + ev["crypttab_lines"] = nonCommentLines(body) if len(nonCommentLines(body)) > 0 { return pass(ev) @@ -50,12 +51,14 @@ func linuxDiskEncryption(ctx context.Context) Result { lsblk := RunCommand(ctx, "lsblk", "-o", "NAME,TYPE,FSTYPE,MOUNTPOINT", "-r") if lsblk.Err == nil { ev["lsblk"] = truncate(lsblk.Stdout, 800) + lines := strings.SplitSeq(lsblk.Stdout, "\n") for line := range lines { fields := strings.Fields(line) if len(fields) < 2 { continue } + if fields[1] == "crypt" { return pass(ev) } @@ -67,6 +70,7 @@ func linuxDiskEncryption(ctx context.Context) Result { if lsblk.Err != nil { return unknown(ev) } + return fail(ev) } @@ -78,6 +82,7 @@ func linuxScreenLock(ctx context.Context) Result { }, ) } + idle := RunCommand(ctx, "gsettings", "get", "org.gnome.desktop.screensaver", "lock-enabled") if idle.Err != nil { return unknown( @@ -86,11 +91,14 @@ func linuxScreenLock(ctx context.Context) Result { }, ) } + on := strings.TrimSpace(idle.Stdout) == "true" + ev := map[string]any{"lock_enabled": idle.Stdout} if on { return pass(ev) } + return fail(ev) } @@ -99,24 +107,30 @@ func linuxFirewall(ctx context.Context) Result { out := RunCommand(ctx, "ufw", "status") if out.Err == nil { active := strings.Contains(strings.ToLower(out.Stdout), "status: active") + ev := map[string]any{"backend": "ufw", "raw": out.Stdout} if active { return pass(ev) } + return fail(ev) } } + if CommandExists("firewall-cmd") { out := RunCommand(ctx, "firewall-cmd", "--state") + ev := map[string]any{"backend": "firewalld", "raw": out.Stdout} if out.Err == nil && strings.Contains(strings.ToLower(out.Stdout), "running") { return pass(ev) } + return fail(ev) } if CommandExists("nft") { out := RunCommand(ctx, "nft", "list", "ruleset") + ev := map[string]any{ "backend": "nftables", "rules_excerpt": truncate(out.Stdout, 400), @@ -125,21 +139,26 @@ func linuxFirewall(ctx context.Context) Result { ev["error"] = out.Err.Error() return unknown(ev) } + if strings.Contains(out.Stdout, "chain ") { return pass(ev) } + return fail(ev) } if CommandExists("iptables") { out := RunCommand(ctx, "iptables", "-S", "INPUT") + ev := map[string]any{"backend": "iptables"} if out.Err != nil { ev["error"] = out.Err.Error() return unknown(ev) } + policy, rules := parseIptablesInput(out.Stdout) ev["input_policy"] = policy + ev["input_rules"] = rules if policy == "DROP" || policy == "REJECT" { return pass(ev) @@ -148,11 +167,13 @@ func linuxFirewall(ctx context.Context) Result { if rules == 0 { return fail(ev) } + // ACCEPT policy with some rules means the operator is filtering, // but we cannot tell from -S whether the rules are restrictive // or permissive without modelling the chain. return unknown(ev) } + return unknown( map[string]any{ "note": "no known firewall tool found", @@ -192,14 +213,17 @@ func linuxTimeSync(ctx context.Context) Result { }, ) } + out := RunCommand(ctx, "timedatectl", "show") if out.Err != nil { return unknown(map[string]any{"error": out.Err.Error()}) } + ev := map[string]any{"raw": truncate(out.Stdout, 400)} if strings.Contains(out.Stdout, "NTPSynchronized=yes") { return pass(ev) } + return fail(ev) } @@ -208,12 +232,14 @@ func linuxOSVersion(ctx context.Context) Result { if err != nil { return unknown(map[string]any{"error": err.Error()}) } + body := string(data) ev := map[string]any{ "pretty_name": kvLookup(body, "PRETTY_NAME"), "version_id": kvLookup(body, "VERSION_ID"), "id": kvLookup(body, "ID"), } + return pass(ev) } @@ -221,6 +247,7 @@ func linuxAutoUpdate(ctx context.Context) Result { if _, err := os.Stat("/etc/apt/apt.conf.d/20auto-upgrades"); err == nil { data, _ := os.ReadFile("/etc/apt/apt.conf.d/20auto-upgrades") body := string(data) + ev := map[string]any{ "backend": "unattended-upgrades", "raw": body, @@ -228,8 +255,10 @@ func linuxAutoUpdate(ctx context.Context) Result { if strings.Contains(body, `"1"`) { return pass(ev) } + return fail(ev) } + if CommandExists("systemctl") { out := RunCommand(ctx, "systemctl", "is-enabled", "dnf-automatic.timer") if out.Err == nil { @@ -237,9 +266,11 @@ func linuxAutoUpdate(ctx context.Context) Result { if strings.TrimSpace(out.Stdout) == "enabled" { return pass(ev) } + return fail(ev) } } + return notApplicable( map[string]any{ "note": "no known auto-update mechanism", @@ -252,9 +283,11 @@ func linuxPasswordPolicy(ctx context.Context) Result { if err != nil { return unknown(map[string]any{"error": err.Error()}) } + body := string(data) minLen := loginDefsLookup(body, "PASS_MIN_LEN") maxDays := loginDefsLookup(body, "PASS_MAX_DAYS") + ev := map[string]any{ "pass_min_len": minLen, "pass_max_days": maxDays, @@ -284,12 +317,15 @@ func linuxRemoteLogin(ctx context.Context) Result { if !CommandExists("systemctl") { return unknown(map[string]any{"note": "systemctl unavailable"}) } + state := RunCommand(ctx, "systemctl", "is-active", "ssh.service") stateAlt := RunCommand(ctx, "systemctl", "is-active", "sshd.service") + merged := strings.TrimSpace(state.Stdout) if merged == "" { merged = strings.TrimSpace(stateAlt.Stdout) } + ev := map[string]any{"is_active": merged} switch merged { case "active": @@ -299,6 +335,7 @@ func linuxRemoteLogin(ctx context.Context) Result { case "": return notApplicable(ev) } + return unknown(ev) } @@ -333,6 +370,7 @@ func linuxMalwareProtection(ctx context.Context) Result { } var active, installed []string + for _, c := range candidates { state := strings.TrimSpace( RunCommand(ctx, "systemctl", "is-active", c.unit).Stdout) @@ -351,21 +389,26 @@ func linuxMalwareProtection(ctx context.Context) Result { if len(active) > 0 { return pass(ev) } + if len(installed) > 0 { return fail(ev) } + return unknown(ev) } func nonCommentLines(s string) []string { out := []string{} + for line := range strings.SplitSeq(s, "\n") { t := strings.TrimSpace(line) if t == "" || strings.HasPrefix(t, "#") { continue } + out = append(out, t) } + return out } @@ -375,12 +418,15 @@ func kvLookup(body, key string) string { if eq <= 0 { continue } + if strings.TrimSpace(line[:eq]) == key { v := strings.TrimSpace(line[eq+1:]) v = strings.Trim(v, `"`) + return v } } + return "" } @@ -390,10 +436,12 @@ func loginDefsLookup(body, key string) string { if line == "" || strings.HasPrefix(line, "#") { continue } + fields := strings.Fields(line) if len(fields) >= 2 && fields[0] == key { return fields[1] } } + return "" } diff --git a/pkg/deviceagent/checks/checks_windows.go b/pkg/deviceagent/checks/checks_windows.go index 9d4faa848..f26eb8ddc 100644 --- a/pkg/deviceagent/checks/checks_windows.go +++ b/pkg/deviceagent/checks/checks_windows.go @@ -43,6 +43,7 @@ func windowsDiskEncryption(ctx context.Context) Result { } out := RunCommand(ctx, "manage-bde", "-status") + ev := map[string]any{"raw": truncate(out.Stdout, 600)} if out.Err != nil { return unknown(ev) @@ -77,6 +78,7 @@ func windowsScreenLock(ctx context.Context) Result { if v == "1" { return pass(ev) } + return fail(ev) } } @@ -107,6 +109,7 @@ func windowsScreenLock(ctx context.Context) Result { "raw": truncate(users.Stdout, 400), } users_, anyDisabled, anyEnabled := parseWindowsUserScreenLock(users.Stdout) + ev["users"] = users_ if len(users_) == 0 { ev["note"] = "no interactive user hives loaded" @@ -125,7 +128,9 @@ func windowsScreenLock(ctx context.Context) Result { // saver locking enabled. func parseWindowsUserScreenLock(s string) (map[string]string, bool, bool) { users := map[string]string{} + var anyEnabled, anyDisabled bool + for line := range strings.SplitSeq(s, "\n") { line = strings.TrimSpace(line) if line == "" { @@ -139,6 +144,7 @@ func parseWindowsUserScreenLock(s string) (map[string]string, bool, bool) { sid := strings.TrimSpace(line[:idx]) value := strings.TrimSpace(line[idx+1:]) + if sid == "" { continue } @@ -168,10 +174,12 @@ func windowsFirewall(ctx context.Context) Result { "raw": primary.Stdout, } profiles, allEnabled := parseWindowsFirewallProfiles(primary.Stdout) + ev["profiles"] = profiles if allEnabled { return pass(ev) } + return fail(ev) } @@ -185,11 +193,13 @@ func windowsFirewall(ctx context.Context) Result { }, ) } + ev := map[string]any{ "backend": "netsh", "raw": truncate(fallback.Stdout, 600), } stateLines, anyOff := parseNetshFirewallStates(fallback.Stdout) + ev["state_lines"] = stateLines if len(stateLines) > 0 && !anyOff { return pass(ev) @@ -205,22 +215,28 @@ func parseWindowsFirewallProfiles(s string) (map[string]string, bool) { profiles := map[string]string{} allEnabled := true any := false + for profile := range strings.SplitSeq(s, ";") { parts := strings.SplitN(strings.TrimSpace(profile), "=", 2) if len(parts) != 2 { continue } + name := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) + if name == "" { continue } + profiles[name] = value any = true + if !strings.EqualFold(value, "true") { allEnabled = false } } + return profiles, any && allEnabled } @@ -229,9 +245,12 @@ func parseWindowsFirewallProfiles(s string) (map[string]string, bool) { // case-insensitive. func parseNetshFirewallStates(s string) ([]string, bool) { var states []string + anyOff := false + for line := range strings.SplitSeq(s, "\n") { trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) if !strings.HasPrefix(lower, "state") { continue @@ -243,6 +262,7 @@ func parseNetshFirewallStates(s string) ([]string, bool) { } value := fields[len(fields)-1] + states = append(states, value) if value != "on" { anyOff = true @@ -264,6 +284,7 @@ func windowsTimeSync(ctx context.Context) Result { } ev := map[string]any{"raw": truncate(out.Stdout, 400)} + lower := strings.ToLower(out.Stdout) if strings.Contains(lower, "source:") && !strings.Contains(lower, "local cmos clock") { return pass(ev) @@ -279,6 +300,7 @@ func windowsOSVersion(ctx context.Context) Result { } caption := powershell(ctx, `(Get-CimInstance Win32_OperatingSystem).Caption`) + return pass( map[string]any{ "version": out.Stdout, @@ -294,21 +316,26 @@ func windowsAutoUpdate(ctx context.Context) Result { `-ErrorAction SilentlyContinue; `+ `"$($au.NoAutoUpdate);$($au.AUOptions)"`, ) + ev := map[string]any{} if out.Err != nil { ev["error"] = out.Err.Error() ev["stderr"] = out.Stderr + return unknown(ev) } parts := strings.SplitN(strings.TrimSpace(out.Stdout), ";", 2) + var noAutoUpdate, auOptions string if len(parts) >= 1 { noAutoUpdate = strings.TrimSpace(parts[0]) } + if len(parts) >= 2 { auOptions = strings.TrimSpace(parts[1]) } + ev["no_auto_update"] = noAutoUpdate ev["au_options"] = auOptions @@ -316,6 +343,7 @@ func windowsAutoUpdate(ctx context.Context) Result { if noAutoUpdate == "1" { return fail(ev) } + // AUOptions semantics: // 2 — notify before download (no auto-install) // 3 — auto download, prompt to install @@ -340,7 +368,9 @@ func windowsAutoUpdate(ctx context.Context) Result { ev["wuauserv"] = "running" return pass(ev) } + ev["wuauserv"] = "stopped" + return fail(ev) } @@ -356,6 +386,7 @@ func windowsPasswordPolicy(ctx context.Context) Result { } ev := map[string]any{"raw": truncate(out.Stdout, 400)} + lower := strings.ToLower(out.Stdout) if strings.Contains(lower, "minimum password length") && !strings.Contains(lower, "length: 0") { return pass(ev) @@ -381,6 +412,7 @@ func windowsMalwareProtection(ctx context.Context) Result { } parts := strings.Split(out.Stdout, ";") + ev := map[string]any{"raw": out.Stdout} if len(parts) < 3 { return unknown(ev) @@ -389,8 +421,10 @@ func windowsMalwareProtection(ctx context.Context) Result { antivirusOn := strings.EqualFold(strings.TrimSpace(parts[0]), "True") realtimeOn := strings.EqualFold(strings.TrimSpace(parts[1]), "True") serviceOn := strings.EqualFold(strings.TrimSpace(parts[2]), "True") + ev["antivirus_enabled"] = antivirusOn ev["real_time_protection"] = realtimeOn + ev["am_service_enabled"] = serviceOn if len(parts) >= 4 { ev["signatures_last_updated"] = strings.TrimSpace(parts[3]) diff --git a/pkg/deviceagent/checks/runcmd_paths_windows.go b/pkg/deviceagent/checks/runcmd_paths_windows.go index 9bf327f22..7aaae0362 100644 --- a/pkg/deviceagent/checks/runcmd_paths_windows.go +++ b/pkg/deviceagent/checks/runcmd_paths_windows.go @@ -25,6 +25,7 @@ func commandCandidates(cmd string) []string { if systemRoot == "" { systemRoot = `C:\Windows` } + system32 := filepath.Join(systemRoot, "System32") switch strings.ToLower(cmd) { diff --git a/pkg/deviceagent/hostinfo_freebsd.go b/pkg/deviceagent/hostinfo_freebsd.go index eee97efa8..c9029b415 100644 --- a/pkg/deviceagent/hostinfo_freebsd.go +++ b/pkg/deviceagent/hostinfo_freebsd.go @@ -33,6 +33,7 @@ func collectOSVersion() string { } out, _ = runQuiet(ctx, "uname", "-sr") + return out } @@ -53,5 +54,6 @@ func collectSerialNumber() string { defer cancel() out, _ := runQuiet(ctx, "kenv", "smbios.system.serial") + return out } diff --git a/pkg/deviceagent/hostinfo_linux.go b/pkg/deviceagent/hostinfo_linux.go index 29757ace4..f976bf195 100644 --- a/pkg/deviceagent/hostinfo_linux.go +++ b/pkg/deviceagent/hostinfo_linux.go @@ -38,6 +38,7 @@ func collectOSVersion() string { defer cancel() out, _ := runQuiet(ctx, "uname", "-sr") + return out } diff --git a/pkg/deviceagent/hostinfo_windows.go b/pkg/deviceagent/hostinfo_windows.go index 2e06622b8..123001c55 100644 --- a/pkg/deviceagent/hostinfo_windows.go +++ b/pkg/deviceagent/hostinfo_windows.go @@ -33,6 +33,7 @@ func collectOSVersion() string { } out, _ = runQuiet(ctx, "uname", "-sr") + return out } @@ -66,5 +67,6 @@ func collectSerialNumber() string { "-Command", "(Get-CimInstance Win32_BIOS).SerialNumber", ) + return out } diff --git a/pkg/deviceagent/service/service_freebsd.go b/pkg/deviceagent/service/service_freebsd.go index 6617c6f74..caa65d162 100644 --- a/pkg/deviceagent/service/service_freebsd.go +++ b/pkg/deviceagent/service/service_freebsd.go @@ -74,6 +74,7 @@ func Install(cfg Config) error { } defer func() { _ = sf.Close() }() + if err := rcTmpl.Execute(sf, cfg); err != nil { return fmt.Errorf("cannot render rc.d script: %w", err) } diff --git a/pkg/deviceagent/service/service_linux.go b/pkg/deviceagent/service/service_linux.go index 5c46ac852..ce37f592a 100644 --- a/pkg/deviceagent/service/service_linux.go +++ b/pkg/deviceagent/service/service_linux.go @@ -70,6 +70,7 @@ func Install(cfg Config) error { } defer func() { _ = f.Close() }() + if err := tmpl.Execute(f, cfg); err != nil { return fmt.Errorf("cannot render systemd unit: %w", err) } @@ -87,6 +88,7 @@ func Install(cfg Config) error { func Uninstall(cfg Config) error { _ = exec.Command("systemctl", "disable", "--now", "probo-agent.service").Run() + if err := os.Remove(systemdUnitPath); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("cannot remove systemd unit: %w", err) } diff --git a/pkg/deviceagent/service/service_windows.go b/pkg/deviceagent/service/service_windows.go index 48a22e800..10e3bc17a 100644 --- a/pkg/deviceagent/service/service_windows.go +++ b/pkg/deviceagent/service/service_windows.go @@ -26,9 +26,11 @@ func Install(cfg Config) error { if cfg.ExePath == "" { return errors.New("executable path is required") } + if cfg.Dir == "" { return errors.New("state directory is required") } + name := DefaultWindowsName bin := fmt.Sprintf(`"%s" run --dir "%s"`, cfg.ExePath, cfg.Dir) @@ -45,6 +47,7 @@ func Install(cfg Config) error { ).CombinedOutput(); err != nil { return fmt.Errorf("cannot run sc.exe create: %w: %s", err, strings.TrimSpace(string(out))) } + // Restart on failure. if out, err := exec.Command( "sc.exe", @@ -57,14 +60,17 @@ func Install(cfg Config) error { ).CombinedOutput(); err != nil { return fmt.Errorf("cannot run sc.exe failure: %w: %s", err, strings.TrimSpace(string(out))) } + if out, err := exec.Command("sc.exe", "start", name).CombinedOutput(); err != nil { return fmt.Errorf("cannot run sc.exe start: %w: %s", err, strings.TrimSpace(string(out))) } + return nil } func Uninstall(cfg Config) error { name := DefaultWindowsName + _ = exec.Command("sc.exe", "stop", name).Run() if out, err := exec.Command("sc.exe", "delete", name).CombinedOutput(); err != nil { msg := strings.TrimSpace(string(out)) @@ -74,5 +80,6 @@ func Uninstall(cfg Config) error { return fmt.Errorf("cannot run sc.exe delete: %w: %s", err, msg) } + return nil } diff --git a/pkg/deviceagent/service/validate_windows.go b/pkg/deviceagent/service/validate_windows.go index 8a4d2aaae..735af797e 100644 --- a/pkg/deviceagent/service/validate_windows.go +++ b/pkg/deviceagent/service/validate_windows.go @@ -18,6 +18,7 @@ import "strings" func isWindowsServiceMissing(out string) bool { lower := strings.ToLower(out) + return strings.Contains(lower, "1060") || strings.Contains(lower, "does not exist as an installed service") || strings.Contains(lower, "specified service does not exist") diff --git a/pkg/deviceagent/update/install_windows.go b/pkg/deviceagent/update/install_windows.go index 88c6da331..000206e75 100644 --- a/pkg/deviceagent/update/install_windows.go +++ b/pkg/deviceagent/update/install_windows.go @@ -55,6 +55,7 @@ func replaceBinary(dst, src string) error { // Try to roll back the running binary swap. _ = os.Rename(oldPath, dst) _ = os.Remove(staging) + return fmt.Errorf("cannot install new binary at %s: %w", dst, err) } @@ -68,5 +69,6 @@ func CleanupAfterRestart(exePath string) { if exePath == "" { return } + _ = os.Remove(exePath + oldSuffix) }