feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack
This commit is contained in:
@@ -586,8 +586,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
defer ticker.Stop()
|
||||
|
||||
var samples []float64
|
||||
var sshTick int
|
||||
var probeTick int
|
||||
var lastSSH *bool
|
||||
var lastPosture *PostureReport
|
||||
var postureReady bool
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
@@ -626,14 +628,21 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
accepted := c.sharesAccepted
|
||||
c.mu.Unlock()
|
||||
|
||||
// Probe SSH every 6 ticks (~60s) to avoid overhead
|
||||
if sshTick%6 == 0 {
|
||||
// Probe SSH + full posture every 6 ticks (~60s)
|
||||
if probeTick%6 == 0 {
|
||||
ok := probeSSH()
|
||||
lastSSH = &ok
|
||||
if p := collectPosture(); p != nil {
|
||||
lastPosture = p
|
||||
postureReady = true
|
||||
if p.SSHListening != nil {
|
||||
lastSSH = p.SSHListening
|
||||
}
|
||||
}
|
||||
}
|
||||
sshTick++
|
||||
probeTick++
|
||||
|
||||
payload, _ := json.Marshal(StatsPayload{
|
||||
stats := StatsPayload{
|
||||
Hashrate15s: avg15s,
|
||||
Hashrate1m: avg1m,
|
||||
Hashrate15m: avg15m,
|
||||
@@ -643,7 +652,23 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
MemoryUsagePct: memPct,
|
||||
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
|
||||
SSHAvailable: lastSSH,
|
||||
})
|
||||
}
|
||||
if postureReady && lastPosture != nil {
|
||||
score := lastPosture.PostureScore
|
||||
stats.PostureScore = &score
|
||||
stats.DefenderEnabled = lastPosture.DefenderEnabled
|
||||
stats.DefenderRTP = lastPosture.DefenderRTP
|
||||
stats.AVProducts = lastPosture.AVProducts
|
||||
stats.FirewallDomain = lastPosture.FirewallDomain
|
||||
stats.FirewallPrivate = lastPosture.FirewallPrivate
|
||||
stats.FirewallPublic = lastPosture.FirewallPublic
|
||||
stats.LastPatchDays = lastPosture.LastPatchDays
|
||||
stats.LastPatch = lastPosture.LastPatch
|
||||
stats.PendingUpdates = lastPosture.PendingUpdates
|
||||
stats.RebootPending = lastPosture.RebootPending
|
||||
stats.AgentElevated = lastPosture.AgentElevated
|
||||
}
|
||||
payload, _ := json.Marshal(stats)
|
||||
_ = c.write(Message{Type: "stats", Payload: payload})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
|
||||
return true, false, "clipboard read unsupported on this host"
|
||||
case "wifi":
|
||||
out, err = exec.Command("/bin/sh", "-c", "networksetup -listallhardwareports 2>/dev/null; nmcli dev wifi list 2>/dev/null | head -20").CombinedOutput()
|
||||
case "posture":
|
||||
if p := collectPosture(); p != nil {
|
||||
return true, true, p.JSON()
|
||||
}
|
||||
return true, false, "posture probe failed"
|
||||
default:
|
||||
return false, false, ""
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
|
||||
return true, true, strings.TrimSpace(string(out))
|
||||
}
|
||||
return true, false, formatCmdErr(err, out)
|
||||
case "posture":
|
||||
if p := collectPosture(); p != nil {
|
||||
return true, true, p.JSON()
|
||||
}
|
||||
return true, false, "posture probe failed"
|
||||
default:
|
||||
return false, false, ""
|
||||
}
|
||||
|
||||
103
agent/client/posture_types.go
Normal file
103
agent/client/posture_types.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package client
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// PostureReport is a read-only ATT&CK T1685/T1686 defense-impairment baseline
|
||||
// combined with a Tenable-style patch-exposure snapshot.
|
||||
// Every field is an observation — the agent never modifies any security control.
|
||||
type PostureReport struct {
|
||||
// ── Antivirus / Defender ──────────────────────────────────────────────────
|
||||
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
|
||||
DefenderRTP *bool `json:"defender_rtp,omitempty"`
|
||||
AVProducts []string `json:"av_products,omitempty"`
|
||||
|
||||
// ── Firewall — per-profile (T1686.003) ───────────────────────────────────
|
||||
FirewallDomain *bool `json:"firewall_domain,omitempty"`
|
||||
FirewallPrivate *bool `json:"firewall_private,omitempty"`
|
||||
FirewallPublic *bool `json:"firewall_public,omitempty"`
|
||||
|
||||
// ── SSH ───────────────────────────────────────────────────────────────────
|
||||
SSHListening *bool `json:"ssh_listening,omitempty"`
|
||||
|
||||
// ── Patch exposure (Tenable-style) ───────────────────────────────────────
|
||||
PendingUpdates *int `json:"pending_updates,omitempty"` // -1 = unknown/timed out
|
||||
LastPatch *string `json:"last_patch,omitempty"` // ISO date YYYY-MM-DD
|
||||
LastPatchDays *int `json:"last_patch_days,omitempty"`
|
||||
PatchRecent *bool `json:"patch_recent,omitempty"` // last_patch_days <= 30
|
||||
RebootPending *bool `json:"reboot_pending,omitempty"`
|
||||
|
||||
// ── Process context ───────────────────────────────────────────────────────
|
||||
AgentElevated *bool `json:"agent_elevated,omitempty"`
|
||||
AgentServiceOK *bool `json:"agent_service_ok,omitempty"`
|
||||
|
||||
// ── Computed score (0-100, 5 pillars × 20) ───────────────────────────────
|
||||
PostureScore int `json:"posture_score"`
|
||||
}
|
||||
|
||||
// computePostureScore produces a 0-100 score across 5 equal pillars.
|
||||
func computePostureScore(r *PostureReport) int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
score := 0
|
||||
|
||||
// Pillar 1 — AV active
|
||||
avOK := (r.DefenderEnabled != nil && *r.DefenderEnabled) || len(r.AVProducts) > 0
|
||||
if avOK {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Pillar 2 — At least one firewall profile enabled
|
||||
for _, p := range []*bool{r.FirewallDomain, r.FirewallPrivate, r.FirewallPublic} {
|
||||
if p != nil && *p {
|
||||
score += 20
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Pillar 3 — SSH reachable (enables Crucible access)
|
||||
if boolTrue(r.SSHListening) {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Pillar 4 — Patch health:
|
||||
// 20 pts : recently patched AND no reboot pending
|
||||
// 10 pts : one of the two conditions is true
|
||||
// 0 pts : stale AND/OR reboot required
|
||||
recentOK := boolTrue(r.PatchRecent)
|
||||
rebootOK := r.RebootPending == nil || !*r.RebootPending
|
||||
if recentOK && rebootOK {
|
||||
score += 20
|
||||
} else if recentOK || rebootOK {
|
||||
score += 10
|
||||
}
|
||||
|
||||
// Pillar 5 — Agent alive (always true while connected)
|
||||
if boolTrue(r.AgentServiceOK) {
|
||||
score += 20
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func boolTrue(v *bool) bool { return v != nil && *v }
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
func intPtr(v int) *int { return &v }
|
||||
func strPtr(v string) *string { return &v }
|
||||
|
||||
func boolPoints(v *bool) int {
|
||||
if v == nil {
|
||||
return -1
|
||||
}
|
||||
if *v {
|
||||
return 20
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// JSON serialises the report, recomputing the score first.
|
||||
func (r *PostureReport) JSON() string {
|
||||
r.PostureScore = computePostureScore(r)
|
||||
b, _ := json.Marshal(r)
|
||||
return string(b)
|
||||
}
|
||||
202
agent/client/posture_unix.go
Normal file
202
agent/client/posture_unix.go
Normal file
@@ -0,0 +1,202 @@
|
||||
//go:build !windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func collectPosture() *PostureReport {
|
||||
r := &PostureReport{AgentServiceOK: boolPtr(true)}
|
||||
|
||||
// ── Firewall ───────────────────────────────────────────────────────────────
|
||||
fwActive := probeUnixFirewall()
|
||||
r.FirewallDomain = &fwActive
|
||||
r.FirewallPrivate = &fwActive
|
||||
// Public profile has no direct Linux equivalent; leave nil.
|
||||
|
||||
// ── AV / security daemons ─────────────────────────────────────────────────
|
||||
avProducts := probeUnixAV()
|
||||
r.AVProducts = avProducts
|
||||
avOK := fwActive || len(avProducts) > 0
|
||||
r.DefenderEnabled = &avOK
|
||||
|
||||
// ── SSH ───────────────────────────────────────────────────────────────────
|
||||
ssh := probeSSH()
|
||||
r.SSHListening = &ssh
|
||||
|
||||
// ── Patch age ─────────────────────────────────────────────────────────────
|
||||
days, dateStr := probeUnixPatchAge()
|
||||
if days >= 0 {
|
||||
r.LastPatchDays = &days
|
||||
recent := days <= 30
|
||||
r.PatchRecent = &recent
|
||||
if dateStr != "" {
|
||||
r.LastPatch = &dateStr
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pending updates ───────────────────────────────────────────────────────
|
||||
pending := probeUnixPendingUpdates()
|
||||
r.PendingUpdates = &pending
|
||||
|
||||
// ── Reboot pending ────────────────────────────────────────────────────────
|
||||
rp := probeUnixRebootPending()
|
||||
r.RebootPending = &rp
|
||||
|
||||
// ── Elevation ─────────────────────────────────────────────────────────────
|
||||
elevated := probeUnixElevated()
|
||||
r.AgentElevated = &elevated
|
||||
|
||||
r.PostureScore = computePostureScore(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// 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") {
|
||||
return true
|
||||
}
|
||||
if out, _ := exec.Command("systemctl", "is-active", "firewalld").CombinedOutput(); strings.TrimSpace(string(out)) == "active" {
|
||||
return true
|
||||
}
|
||||
if out, err := exec.Command("/bin/sh", "-c", "iptables -L -n 2>/dev/null | wc -l").CombinedOutput(); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 8 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if out, _ := exec.Command("/bin/sh", "-c", "nft list ruleset 2>/dev/null | wc -l").CombinedOutput(); true {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 3 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// probeUnixAV returns names of active security daemons.
|
||||
func probeUnixAV() []string {
|
||||
type entry struct{ svc, label string }
|
||||
candidates := []entry{
|
||||
{"clamav-daemon", "ClamAV"}, {"clamd", "ClamAV"},
|
||||
{"fail2ban", "Fail2Ban"}, {"rkhunter", "RKHunter"},
|
||||
{"chkrootkit", "Chkrootkit"}, {"auditd", "auditd"},
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var found []string
|
||||
for _, c := range candidates {
|
||||
out, _ := exec.Command("systemctl", "is-active", c.svc).CombinedOutput()
|
||||
if strings.TrimSpace(string(out)) == "active" && !seen[c.label] {
|
||||
seen[c.label] = true
|
||||
found = append(found, c.label)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// probeUnixPatchAge returns (days since last update, ISO date string).
|
||||
// Returns (-1, "") when unknown.
|
||||
func probeUnixPatchAge() (int, string) {
|
||||
stamps := []string{
|
||||
"/var/lib/apt/periodic/update-success-stamp",
|
||||
"/var/cache/apk/lastupdate",
|
||||
}
|
||||
for _, path := range stamps {
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
d := int(time.Since(info.ModTime()).Hours() / 24)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
return d, info.ModTime().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
// YUM/DNF history
|
||||
if out, err := exec.Command("/bin/sh", "-c",
|
||||
`yum history info 2>/dev/null | grep "Begin time" | head -1 | awk '{print $3, $4}'`).CombinedOutput(); err == nil {
|
||||
line := strings.TrimSpace(string(out))
|
||||
if line != "" {
|
||||
if t, err := time.Parse("2006-01-02 15:04", line); err == nil {
|
||||
return int(time.Since(t).Hours() / 24), t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
}
|
||||
// rpm last installed
|
||||
if out, err := exec.Command("/bin/sh", "-c", "rpm -qa --last 2>/dev/null | head -1").CombinedOutput(); err == nil {
|
||||
parts := strings.Fields(string(out))
|
||||
if len(parts) >= 5 {
|
||||
dateStr := strings.Join(parts[len(parts)-5:], " ")
|
||||
if t, err := time.Parse("Mon Jan 2 15:04:05 2006", dateStr); err == nil {
|
||||
return int(time.Since(t).Hours() / 24), t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
// probeUnixPendingUpdates returns the count of available package updates.
|
||||
// Returns -1 if the count cannot be determined.
|
||||
func probeUnixPendingUpdates() int {
|
||||
// APT
|
||||
if out, err := exec.Command("/bin/sh", "-c",
|
||||
"apt list --upgradable 2>/dev/null | grep -c upgradable").CombinedOutput(); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
// APT check (outputs "pkg;security" lines)
|
||||
if out, err := exec.Command("/bin/sh", "-c",
|
||||
"/usr/lib/update-notifier/apt-check 2>&1 | cut -d';' -f1").CombinedOutput(); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
// YUM/DNF
|
||||
if out, err := exec.Command("/bin/sh", "-c",
|
||||
"yum check-update --quiet 2>/dev/null | grep -c '^[a-zA-Z]'").CombinedOutput(); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
// apk (Alpine)
|
||||
if out, err := exec.Command("/bin/sh", "-c",
|
||||
"apk version -l '<' 2>/dev/null | wc -l").CombinedOutput(); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// probeUnixRebootPending checks kernel and package signals for a required reboot.
|
||||
func probeUnixRebootPending() bool {
|
||||
// Debian/Ubuntu explicit flag
|
||||
if _, err := os.Stat("/var/run/reboot-required"); err == nil {
|
||||
return true
|
||||
}
|
||||
// Kernel update check: compare running vs installed
|
||||
if out, err := exec.Command("uname", "-r").CombinedOutput(); err == nil {
|
||||
running := strings.TrimSpace(string(out))
|
||||
// Check if a newer kernel package exists
|
||||
if out2, err := exec.Command("/bin/sh", "-c",
|
||||
fmt.Sprintf("ls /boot/vmlinuz-* 2>/dev/null | grep -v '%s' | wc -l", running)).CombinedOutput(); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out2))); err == nil && n > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// probeUnixElevated returns true when running as root (uid 0).
|
||||
func probeUnixElevated() bool {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Uid == "0"
|
||||
}
|
||||
233
agent/client/posture_windows.go
Normal file
233
agent/client/posture_windows.go
Normal file
@@ -0,0 +1,233 @@
|
||||
//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
|
||||
}
|
||||
@@ -75,7 +75,21 @@ type StatsPayload struct {
|
||||
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||
UptimeSeconds int `json:"uptime_seconds"`
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
|
||||
// SSH + posture
|
||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||
PostureScore *int `json:"posture_score,omitempty"`
|
||||
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
|
||||
DefenderRTP *bool `json:"defender_rtp,omitempty"`
|
||||
AVProducts []string `json:"av_products,omitempty"`
|
||||
FirewallDomain *bool `json:"firewall_domain,omitempty"`
|
||||
FirewallPrivate *bool `json:"firewall_private,omitempty"`
|
||||
FirewallPublic *bool `json:"firewall_public,omitempty"`
|
||||
LastPatchDays *int `json:"last_patch_days,omitempty"`
|
||||
LastPatch *string `json:"last_patch,omitempty"`
|
||||
PendingUpdates *int `json:"pending_updates,omitempty"`
|
||||
RebootPending *bool `json:"reboot_pending,omitempty"`
|
||||
AgentElevated *bool `json:"agent_elevated,omitempty"`
|
||||
}
|
||||
|
||||
type ShareResult struct {
|
||||
|
||||
Reference in New Issue
Block a user