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 }