feat: T1007 System Service Discovery - fixed allowlist probe in posture heartbeat
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user