280 lines
9.0 KiB
Go
280 lines
9.0 KiB
Go
//go:build !windows
|
|
|
|
package client
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func collectPosture() *PostureReport {
|
|
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"
|
|
}
|