//go:build !windows package client import ( "fmt" "os" "os/exec" "os/user" "path/filepath" "strconv" "strings" "time" ) func collectPosture() *PostureReport { if postureCollector != nil { return postureCollector() } r := &PostureReport{AgentServiceOK: boolPtr(true)} // ── Firewall ─────────────────────────────────────────────────────────────── fwActive := probeUnixFirewall() r.FirewallDomain = &fwActive r.FirewallPrivate = &fwActive // Public profile has no direct Linux equivalent; leave nil. // ── AV / security daemons ───────────────────────────────────────────────── avProducts := probeUnixAV() r.AVProducts = avProducts avOK := fwActive || len(avProducts) > 0 r.DefenderEnabled = &avOK // ── SSH ─────────────────────────────────────────────────────────────────── ssh := probeSSH() r.SSHListening = &ssh // ── Patch age ───────────────────────────────────────────────────────────── days, dateStr := probeUnixPatchAge() if days >= 0 { r.LastPatchDays = &days recent := days <= 30 r.PatchRecent = &recent if dateStr != "" { r.LastPatch = &dateStr } } // ── Pending updates ─────────────────────────────────────────────────────── pending := probeUnixPendingUpdates() r.PendingUpdates = &pending // ── Reboot pending ──────────────────────────────────────────────────────── rp := probeUnixRebootPending() r.RebootPending = &rp // ── T1007 Service Discovery ─────────────────────────────────────────────── r.Services = probeUnixServices() // ── Elevation ───────────────────────────────────────────────────────────── elevated := probeUnixElevated() r.AgentElevated = &elevated r.PostureScore = computePostureScore(r) return r } // selfServiceName returns the base name of the running binary (no extension). func selfServiceName() string { exe, err := os.Executable() if err != nil || exe == "" { return "aetherforge" } return filepath.Base(exe) } // probeUnixServices checks a fixed allowlist via systemctl. // Only the services relevant to this project are queried (T1007-compliant). func probeUnixServices() []ServiceStatus { self := selfServiceName() allowlist := []string{"sshd", "ssh", "cloudflared", "wuauserv", "ufw", "fail2ban", self} // Deduplicate seen := map[string]bool{} var names []string for _, n := range allowlist { if n != "" && !seen[n] { seen[n] = true names = append(names, n) } } var out []ServiceStatus queried := map[string]bool{} for _, name := range names { if queried[name] { continue } queried[name] = true status := "not_found" startType := "unknown" // ActiveState if activeOut, err := exec.Command("systemctl", "is-active", name).CombinedOutput(); err == nil { active := strings.TrimSpace(string(activeOut)) switch active { case "active": status = "running" case "inactive", "failed", "dead": status = "stopped" default: // service exists but is in an odd state if active != "unknown" { status = "stopped" } } } // UnitFileState (start type) if enableOut, err := exec.Command("systemctl", "is-enabled", name).CombinedOutput(); err == nil || status != "not_found" { switch strings.TrimSpace(string(enableOut)) { case "enabled": startType = "auto" case "disabled": startType = "disabled" case "static", "manual": startType = "manual" } } out = append(out, ServiceStatus{ Name: name, Status: status, StartType: startType, }) } return out } // probeUnixFirewall returns true if any host firewall appears active. func probeUnixFirewall() bool { if out, _ := exec.Command("ufw", "status").CombinedOutput(); strings.Contains(strings.ToLower(string(out)), "status: active") { return true } if out, _ := exec.Command("systemctl", "is-active", "firewalld").CombinedOutput(); strings.TrimSpace(string(out)) == "active" { return true } if out, err := exec.Command("/bin/sh", "-c", "iptables -L -n 2>/dev/null | wc -l").CombinedOutput(); err == nil { if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 8 { return true } } if out, _ := exec.Command("/bin/sh", "-c", "nft list ruleset 2>/dev/null | wc -l").CombinedOutput(); true { if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 3 { return true } } return false } // probeUnixAV returns names of active security daemons. func probeUnixAV() []string { type entry struct{ svc, label string } candidates := []entry{ {"clamav-daemon", "ClamAV"}, {"clamd", "ClamAV"}, {"fail2ban", "Fail2Ban"}, {"rkhunter", "RKHunter"}, {"chkrootkit", "Chkrootkit"}, {"auditd", "auditd"}, } seen := map[string]bool{} var found []string for _, c := range candidates { out, _ := exec.Command("systemctl", "is-active", c.svc).CombinedOutput() if strings.TrimSpace(string(out)) == "active" && !seen[c.label] { seen[c.label] = true found = append(found, c.label) } } return found } // probeUnixPatchAge returns (days since last update, ISO date string). // Returns (-1, "") when unknown. func probeUnixPatchAge() (int, string) { stamps := []string{ "/var/lib/apt/periodic/update-success-stamp", "/var/cache/apk/lastupdate", } for _, path := range stamps { if info, err := os.Stat(path); err == nil { d := int(time.Since(info.ModTime()).Hours() / 24) if d < 0 { d = 0 } return d, info.ModTime().Format("2006-01-02") } } // YUM/DNF history if out, err := exec.Command("/bin/sh", "-c", `yum history info 2>/dev/null | grep "Begin time" | head -1 | awk '{print $3, $4}'`).CombinedOutput(); err == nil { line := strings.TrimSpace(string(out)) if line != "" { if t, err := time.Parse("2006-01-02 15:04", line); err == nil { return int(time.Since(t).Hours() / 24), t.Format("2006-01-02") } } } // rpm last installed if out, err := exec.Command("/bin/sh", "-c", "rpm -qa --last 2>/dev/null | head -1").CombinedOutput(); err == nil { parts := strings.Fields(string(out)) if len(parts) >= 5 { dateStr := strings.Join(parts[len(parts)-5:], " ") if t, err := time.Parse("Mon Jan 2 15:04:05 2006", dateStr); err == nil { return int(time.Since(t).Hours() / 24), t.Format("2006-01-02") } } } return -1, "" } // probeUnixPendingUpdates returns the count of available package updates. // Returns -1 if the count cannot be determined. func probeUnixPendingUpdates() int { // APT if out, err := exec.Command("/bin/sh", "-c", "apt list --upgradable 2>/dev/null | grep -c upgradable").CombinedOutput(); err == nil { if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { return n } } // APT check (outputs "pkg;security" lines) if out, err := exec.Command("/bin/sh", "-c", "/usr/lib/update-notifier/apt-check 2>&1 | cut -d';' -f1").CombinedOutput(); err == nil { if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n >= 0 { return n } } // YUM/DNF if out, err := exec.Command("/bin/sh", "-c", "yum check-update --quiet 2>/dev/null | grep -c '^[a-zA-Z]'").CombinedOutput(); err == nil { if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { return n } } // apk (Alpine) if out, err := exec.Command("/bin/sh", "-c", "apk version -l '<' 2>/dev/null | wc -l").CombinedOutput(); err == nil { if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { return n } } return -1 } // probeUnixRebootPending checks kernel and package signals for a required reboot. func probeUnixRebootPending() bool { // Debian/Ubuntu explicit flag if _, err := os.Stat("/var/run/reboot-required"); err == nil { return true } // Kernel update check: compare running vs installed if out, err := exec.Command("uname", "-r").CombinedOutput(); err == nil { running := strings.TrimSpace(string(out)) // Check if a newer kernel package exists if out2, err := exec.Command("/bin/sh", "-c", fmt.Sprintf("ls /boot/vmlinuz-* 2>/dev/null | grep -v '%s' | wc -l", running)).CombinedOutput(); err == nil { if n, err := strconv.Atoi(strings.TrimSpace(string(out2))); err == nil && n > 0 { return true } } } return false } // probeUnixElevated returns true when running as root (uid 0). func probeUnixElevated() bool { u, err := user.Current() if err != nil { return false } return u.Uid == "0" }