Files
AetherForge/agent/client/posture_windows.go

234 lines
7.6 KiB
Go

//go:build windows
package client
import (
"encoding/json"
"os/exec"
"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 = `
$ErrorActionPreference = 'SilentlyContinue'
$p = [ordered]@{}
# ── Defender ──────────────────────────────────────────────────────────────────
try {
$mp = Get-MpComputerStatus
if ($mp) {
$p.defender_enabled = [bool]$mp.AntivirusEnabled
$p.defender_rtp = [bool]$mp.RealTimeProtectionEnabled
}
} catch {}
# ── AV products via WMI / CIM (catches third-party AV) ───────────────────────
try {
$avList = @(Get-CimInstance -Namespace root\SecurityCenter2 -ClassName AntiVirusProduct |
Select-Object -ExpandProperty displayName)
if ($avList.Count -gt 0) { $p.av_products = $avList }
} catch {
try {
$avList = @(Get-WmiObject -Namespace root\SecurityCenter2 -Class AntiVirusProduct |
Select-Object -ExpandProperty displayName)
if ($avList.Count -gt 0) { $p.av_products = $avList }
} catch {}
}
# ── Firewall — per profile (T1686.003) ───────────────────────────────────────
try {
foreach ($profile in (Get-NetFirewallProfile)) {
switch ($profile.Name) {
'Domain' { $p.firewall_domain = [bool]$profile.Enabled }
'Private' { $p.firewall_private = [bool]$profile.Enabled }
'Public' { $p.firewall_public = [bool]$profile.Enabled }
}
}
} catch {}
# ── SSH service ───────────────────────────────────────────────────────────────
try {
$svc = Get-Service -Name sshd
$p.ssh_listening = ($svc.Status -eq 'Running')
} catch { $p.ssh_listening = $false }
# ── Patch age — last installed hotfix ─────────────────────────────────────────
try {
$hf = Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First 1
if ($hf -and $hf.InstalledOn) {
$d = [datetime]$hf.InstalledOn
$days = [int]((Get-Date) - $d).TotalDays
if ($days -lt 0) { $days = 0 }
$p.last_patch = $d.ToString('yyyy-MM-dd')
$p.last_patch_days = $days
$p.patch_recent = ($days -le 30)
}
} catch {}
# ── Pending updates — WUA COM, capped at 15 s ────────────────────────────────
# Runs in a background job so a stuck WU stack cannot block the agent.
try {
$job = Start-Job -ScriptBlock {
$sess = New-Object -ComObject Microsoft.Update.Session
$searcher = $sess.CreateUpdateSearcher()
$result = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
$result.Updates.Count
}
$done = Wait-Job $job -Timeout 15
if ($done -and $done.State -eq 'Completed') {
$cnt = [int](Receive-Job $job)
$p.pending_updates = $cnt
} else {
$p.pending_updates = -1 # timed out or failed
Remove-Job $job -Force
}
} catch { $p.pending_updates = -1 }
# ── Reboot pending — registry tripwires ──────────────────────────────────────
try {
$rp = $false
# Windows Update requested reboot
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
$rp = $true
}
# CBS / Feature Update pending
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
$rp = $true
}
# Session Manager pending file rename (common post-patch signal)
$pfro = (Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
if ($pfro -and $pfro.PendingFileRenameOperations) { $rp = $true }
$p.reboot_pending = $rp
} catch { $p.reboot_pending = $false }
# ── Elevation ─────────────────────────────────────────────────────────────────
try {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$pr = New-Object Security.Principal.WindowsPrincipal($id)
$p.agent_elevated = $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} catch { $p.agent_elevated = $false }
$p.agent_service_ok = $true
$p | ConvertTo-Json -Compress
`
func collectPosture() *PostureReport {
out, err := exec.Command(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
postureProbeScript,
).CombinedOutput()
if err != nil {
return fallbackPosture()
}
raw := strings.TrimSpace(string(out))
// PowerShell may emit warnings before the JSON; find the last '{'.
if idx := strings.LastIndex(raw, "{"); idx > 0 {
raw = raw[idx:]
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return fallbackPosture()
}
r := &PostureReport{
DefenderEnabled: jsonBool(m, "defender_enabled"),
DefenderRTP: jsonBool(m, "defender_rtp"),
AVProducts: jsonStringSlice(m, "av_products"),
FirewallDomain: jsonBool(m, "firewall_domain"),
FirewallPrivate: jsonBool(m, "firewall_private"),
FirewallPublic: jsonBool(m, "firewall_public"),
SSHListening: jsonBool(m, "ssh_listening"),
LastPatch: jsonString(m, "last_patch"),
LastPatchDays: jsonInt(m, "last_patch_days"),
PatchRecent: jsonBool(m, "patch_recent"),
PendingUpdates: jsonInt(m, "pending_updates"),
RebootPending: jsonBool(m, "reboot_pending"),
AgentElevated: jsonBool(m, "agent_elevated"),
AgentServiceOK: boolPtr(true),
}
r.PostureScore = computePostureScore(r)
return r
}
func fallbackPosture() *PostureReport {
ssh := probeSSH()
r := &PostureReport{SSHListening: &ssh, AgentServiceOK: boolPtr(true)}
r.PostureScore = computePostureScore(r)
return r
}
// ── JSON field helpers ────────────────────────────────────────────────────────
func jsonBool(m map[string]interface{}, key string) *bool {
v, ok := m[key]
if !ok {
return nil
}
switch t := v.(type) {
case bool:
return &t
case string:
b := strings.EqualFold(t, "true") || t == "1"
return &b
}
return nil
}
func jsonInt(m map[string]interface{}, key string) *int {
v, ok := m[key]
if !ok {
return nil
}
switch t := v.(type) {
case float64:
n := int(t)
return &n
case int:
return &t
case string:
if n, err := strconv.Atoi(t); err == nil {
return &n
}
}
return nil
}
func jsonString(m map[string]interface{}, key string) *string {
v, ok := m[key]
if !ok {
return nil
}
if s, ok := v.(string); ok && s != "" {
return &s
}
return nil
}
func jsonStringSlice(m map[string]interface{}, key string) []string {
v, ok := m[key]
if !ok {
return nil
}
switch t := v.(type) {
case string:
if t != "" {
return []string{t}
}
case []interface{}:
var out []string
for _, item := range t {
if s, ok := item.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
return nil
}