feat: T1007 System Service Discovery - fixed allowlist probe in posture heartbeat

This commit is contained in:
AetherForge
2026-05-30 23:16:50 -07:00
parent 4207f6c21b
commit d010292333
26 changed files with 2499 additions and 134 deletions

View File

@@ -667,6 +667,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
stats.PendingUpdates = lastPosture.PendingUpdates
stats.RebootPending = lastPosture.RebootPending
stats.AgentElevated = lastPosture.AgentElevated
stats.Services = lastPosture.Services
}
payload, _ := json.Marshal(stats)
_ = c.write(Message{Type: "stats", Payload: payload})

View File

@@ -2,8 +2,17 @@ package client
import "encoding/json"
// ServiceStatus is a single entry from the T1007 System Service Discovery
// allowlist — read-only, fixed list, no enumeration of arbitrary services.
type ServiceStatus struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Status string `json:"status"` // running | stopped | not_found
StartType string `json:"start_type"` // auto | manual | disabled | unknown
}
// PostureReport is a read-only ATT&CK T1685/T1686 defense-impairment baseline
// combined with a Tenable-style patch-exposure snapshot.
// combined with a Tenable-style patch-exposure snapshot and T1007 service audit.
// Every field is an observation — the agent never modifies any security control.
type PostureReport struct {
// ── Antivirus / Defender ──────────────────────────────────────────────────
@@ -26,6 +35,9 @@ type PostureReport struct {
PatchRecent *bool `json:"patch_recent,omitempty"` // last_patch_days <= 30
RebootPending *bool `json:"reboot_pending,omitempty"`
// ── T1007 System Service Discovery — fixed allowlist only ────────────────
Services []ServiceStatus `json:"services,omitempty"`
// ── Process context ───────────────────────────────────────────────────────
AgentElevated *bool `json:"agent_elevated,omitempty"`
AgentServiceOK *bool `json:"agent_service_ok,omitempty"`

View File

@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"time"
@@ -50,6 +51,9 @@ func collectPosture() *PostureReport {
rp := probeUnixRebootPending()
r.RebootPending = &rp
// ── T1007 Service Discovery ───────────────────────────────────────────────
r.Services = probeUnixServices()
// ── Elevation ─────────────────────────────────────────────────────────────
elevated := probeUnixElevated()
r.AgentElevated = &elevated
@@ -58,6 +62,79 @@ func collectPosture() *PostureReport {
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") {

View File

@@ -4,16 +4,48 @@ package client
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// postureProbeScript runs in a single PowerShell invocation.
// Every section is wrapped in try/catch — one failing check never kills the probe.
// The WUA pending-update query runs in a background job capped at 15 s so it
// never blocks the agent's stats loop even on machines with a busy WU stack.
const postureProbeScript = `
// buildPostureScript injects the agent's own service name into the probe script
// so the service allowlist includes the running binary without hard-coding it.
func buildPostureScript() string {
selfName := selfServiceName()
return buildPostureScriptWithSelf(selfName)
}
// selfServiceName returns the base name of the running executable (no .exe).
// This is what the agent registers as a Windows service when installed.
func selfServiceName() string {
exe, err := os.Executable()
if err != nil || exe == "" {
return "AetherForge"
}
base := filepath.Base(exe)
return strings.TrimSuffix(base, ".exe")
}
func buildPostureScriptWithSelf(selfSvc string) string {
// Sanitize: only allow safe service-name characters
var safe strings.Builder
for _, c := range selfSvc {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
safe.WriteRune(c)
}
}
if safe.Len() == 0 {
safe.WriteString("AetherForge")
}
return strings.Replace(postureProbeTemplate, "__SELF_SVC__", safe.String(), 1)
}
// postureProbeTemplate is the base PowerShell probe.
// __SELF_SVC__ is replaced at runtime with the agent's own service name.
const postureProbeTemplate = `
$ErrorActionPreference = 'SilentlyContinue'
$p = [ordered]@{}
@@ -113,13 +145,41 @@ try {
} catch { $p.agent_elevated = $false }
$p.agent_service_ok = $true
$p | ConvertTo-Json -Compress
# ── T1007 Service Discovery — fixed allowlist only ────────────────────────────
$svcNames = @('sshd','OpenSSH SSH Server','cloudflared','wuauserv','WinDefend','__SELF_SVC__')
$svcs = @()
foreach ($n in $svcNames) {
try {
$s = Get-CimInstance -ClassName Win32_Service -Filter "Name='$n'" -ErrorAction SilentlyContinue
if (-not $s) {
# Try matching by DisplayName too
$s = Get-CimInstance -ClassName Win32_Service -Filter "DisplayName='$n'" -ErrorAction SilentlyContinue
}
if ($s) {
$st = if ($s.State -eq 'Running') { 'running' } else { 'stopped' }
$sm = switch ($s.StartMode) {
'Auto' { 'auto' }
'Manual' { 'manual' }
'Disabled' { 'disabled' }
default { 'unknown' }
}
$svcs += [ordered]@{ name = $s.Name; display_name = $s.DisplayName; status = $st; start_type = $sm }
} else {
$svcs += [ordered]@{ name = $n; status = 'not_found'; start_type = 'unknown' }
}
} catch {
$svcs += [ordered]@{ name = $n; status = 'not_found'; start_type = 'unknown' }
}
}
$p.services = $svcs
$p | ConvertTo-Json -Depth 4 -Compress
`
func collectPosture() *PostureReport {
out, err := exec.Command(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
postureProbeScript,
buildPostureScript(),
).CombinedOutput()
if err != nil {
return fallbackPosture()
@@ -150,6 +210,7 @@ func collectPosture() *PostureReport {
PendingUpdates: jsonInt(m, "pending_updates"),
RebootPending: jsonBool(m, "reboot_pending"),
AgentElevated: jsonBool(m, "agent_elevated"),
Services: jsonServiceSlice(m, "services"),
AgentServiceOK: boolPtr(true),
}
r.PostureScore = computePostureScore(r)
@@ -231,3 +292,51 @@ func jsonStringSlice(m map[string]interface{}, key string) []string {
}
return nil
}
// jsonServiceSlice parses the services array from the PS output.
// PS ConvertTo-Json emits either a single object or []interface{}.
func jsonServiceSlice(m map[string]interface{}, key string) []ServiceStatus {
v, ok := m[key]
if !ok {
return nil
}
parseOne := func(raw interface{}) (ServiceStatus, bool) {
obj, ok := raw.(map[string]interface{})
if !ok {
return ServiceStatus{}, false
}
s := ServiceStatus{Status: "not_found", StartType: "unknown"}
if n, ok := obj["name"].(string); ok {
s.Name = n
}
if d, ok := obj["display_name"].(string); ok {
s.DisplayName = d
}
if st, ok := obj["status"].(string); ok {
s.Status = st
}
if sm, ok := obj["start_type"].(string); ok {
s.StartType = sm
}
return s, s.Name != ""
}
switch t := v.(type) {
case map[string]interface{}:
if s, ok := parseOne(t); ok {
return []ServiceStatus{s}
}
case []interface{}:
var out []ServiceStatus
seen := map[string]bool{}
for _, item := range t {
if s, ok := parseOne(item); ok && !seen[s.Name] {
seen[s.Name] = true
out = append(out, s)
}
}
return out
}
return nil
}

View File

@@ -90,6 +90,7 @@ type StatsPayload struct {
PendingUpdates *int `json:"pending_updates,omitempty"`
RebootPending *bool `json:"reboot_pending,omitempty"`
AgentElevated *bool `json:"agent_elevated,omitempty"`
Services []ServiceStatus `json:"services,omitempty"`
}
type ShareResult struct {