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

@@ -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
}