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