package recon import ( "bufio" "fmt" "io" "net" "net/http" "regexp" "strconv" "strings" "time" ) // Scan runs port scan and optional web crawl for an operator-supplied owned target. func Scan(req ScanRequest) (*ScanReport, error) { return runOwnedTargetScan(req, "", nil) } func scanLegacy(req ScanRequest) (*ScanReport, error) { host, err := normalizeOwnedHost(req.Host) if err != nil { return nil, err } ports := ScanPorts(host, nil) 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, nil, host, false) 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, 6262, 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, stack []StackEntry, host string, cloudMetaProfile bool) []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, }) } } if lane := SuggestDeployKitLane(stack); lane != "" { recs = append(recs, DeployRecommendation{Lane: lane, Reason: fmt.Sprintf("Technology stack suggests %s deploy-kit lane", lane), Priority: 18}) } if cloudMetaProfile && (TargetLooksEC2(host) || ProbeEC2Metadata()) { recs = append(recs, DeployRecommendation{Lane: "ssm_document", Template: "ssm_document", Reason: "cloud_metadata profile - EC2/IMDS reachable; SSM document lane", Priority: 40}) } 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 } func NormalizeHost(host string) (string, error) { return normalizeOwnedHost(host) } func runOwnedTargetScan(req ScanRequest, scanID string, emit StreamEmit) (*ScanReport, error) { host, err := normalizeOwnedHost(req.Host) if err != nil { return nil, err } uxProfile := NormalizeProfile(req.Profile) opts := ProfileOptions(uxProfile) portsToScan, profilesUsed := ResolveScanPortsFromRequest(req) if uxProfile == ProfileQuick && len(req.Profiles) == 0 && len(portsToScan) > 10 { portsToScan = portsToScan[:10] } report := &ScanReport{ScanID: scanID, Host: host, Profile: uxProfile, ProfilesUsed: profilesUsed, Status: "complete", ScannedAt: time.Now().UTC()} if !opts.SkipPorts { ports := scanPortsList(host, portsToScan) report.Ports = ports report.Banners = GrabBanners(host, ports) } if shouldCrawlProfile(req, uxProfile, report.Ports, opts) { crawl, err := crawlWithOptions(host, req.Port, req.Scheme, req.Paths, opts, scanID, emit) if err == nil && crawl != nil { report.Crawl = crawl report.Stack = MergeStack(crawl.Stack) } } report.DeployKitLane = SuggestDeployKitLane(report.Stack) cloudMeta := containsPortProfile(profilesUsed, PortProfileCloudMetadata) report.Recommendations = BuildRecommendations(report.Ports, report.Crawl, report.Stack, host, cloudMeta) return report, nil } const (ProfileQuick = "quick"; ProfileDeep = "deep"; ProfileSSRFOnly = "ssrf_only") type StreamEmit func(eventType string, payload map[string]interface{}) type ScanOptions struct { Ports []int; MaxPages, CrawlDepth int; SkipPorts, SkipCrawl bool } func NormalizeProfile(profile string) string { switch profile { case ProfileQuick, ProfileDeep, ProfileSSRFOnly: return profile; default: return "" } } func ProfileOptions(profile string) ScanOptions { switch NormalizeProfile(profile) { case ProfileQuick: p := FleetPorts if len(p) > 10 { p = p[:10] } return ScanOptions{Ports: p, MaxPages: 1, CrawlDepth: 0} case ProfileDeep: return ScanOptions{Ports: FleetPorts, MaxPages: 50, CrawlDepth: 2} case ProfileSSRFOnly: return ScanOptions{SkipPorts: true, MaxPages: 50, CrawlDepth: 2} default: return ScanOptions{Ports: FleetPorts, MaxPages: DefaultCrawlMaxPages, CrawlDepth: DefaultCrawlDepth} } } func ScanStream(req ScanRequest, scanID string, emit StreamEmit) (*ScanReport, error) { return runOwnedTargetScan(req, scanID, emit) } func scanPortsList(host string, ports []int) []PortResult { if len(ports) == 0 { return ScanPorts(host, nil) } out := make([]PortResult, 0, len(ports)) for _, port := range ports { out = append(out, PortResult{Port: port, Open: dialPort(host, port, DefaultPortDialTimeout)}) } return out } func shouldCrawlProfile(req ScanRequest, profile string, ports []PortResult, opts ScanOptions) bool { if opts.SkipCrawl { return false } if profile == ProfileSSRFOnly { return true } return shouldCrawl(req, ports) } func crawlWithOptions(host string, port int, scheme string, seedPaths []string, opts ScanOptions, scanID string, emit StreamEmit) (*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{"/"} } maxPages, maxDepth := opts.MaxPages, opts.CrawlDepth if maxPages <= 0 { maxPages = DefaultCrawlMaxPages } if maxDepth < 0 { maxDepth = DefaultCrawlDepth } report := &CrawlReport{} var headerSnaps []HTTPHeaderSnap var htmlBodies []string visited := map[string]bool{} queue := []queuedURL{} for _, p := range seeds { if abs, err := resolveSameOrigin(base, p); err == nil { queue = append(queue, queuedURL{url: abs, depth: 0}) } } for len(queue) > 0 && report.PagesFetched < maxPages { item := queue[0]; queue = queue[1:] key := normalizeURLKey(item.url) if visited[key] { continue } visited[key] = true status, body, headers, err := fetchPage(item.url) if err != nil { continue } report.PagesFetched++ title, _ := htmlParseTitle(body) report.Pages = append(report.Pages, PageFinding{URL: item.url, StatusCode: status, Title: title}) if len(headers) > 0 { headerSnaps = append(headerSnaps, HTTPHeaderSnap{URL: item.url, Headers: headers}) } htmlBodies = append(htmlBodies, body) 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 >= maxDepth { continue } for _, link := range extractLinks(body) { abs, err := resolveSameOrigin(base, link) if err != nil || !sameOrigin(base, abs) { continue } if !visited[normalizeURLKey(abs)] { queue = append(queue, queuedURL{url: abs, depth: item.depth + 1}) } } } if report.SSRFScore > 100 { report.SSRFScore = 100 } report.CMSFingerprints = mergeCMS(nil, report.CMSFingerprints) report.Stack = BuildStack(headerSnaps, htmlBodies) return report, nil } func OpenPorts(ports []PortResult) []int { var o []int; for _, p := range ports { if p.Open { o = append(o, p.Port) } }; return o } func DiffReports(prev, cur *ScanReport) *ReconScanDiff { if cur == nil { return nil } d := &ReconScanDiff{} if prev == nil { d.NewPorts = OpenPorts(cur.Ports); return d } po := map[int]bool{} for _, p := range prev.Ports { if p.Open { po[p.Port] = true } } for _, p := range cur.Ports { if p.Open && !po[p.Port] { d.NewPorts = append(d.NewPorts, p.Port) } } return d } func BuildHistory(rows []*ScanReport) []ReconHistoryEntry { out := make([]ReconHistoryEntry, 0, len(rows)) var prev *ScanReport for _, r := range rows { out = append(out, ReconHistoryEntry{ScanID: r.ScanID, Host: r.Host, Profile: r.Profile, Status: r.Status, ScannedAt: r.ScannedAt, Report: r, Diff: DiffReports(prev, r)}); prev = r } return out } func ReportToPDF(report *ScanReport) []byte { return []byte("AetherForge Recon\n") } var metaGeneratorRe = regexp.MustCompile(`(?is)]+name=["']generator["'][^>]+content=["']([^"']+)["']`) var bannerTitleRe = regexp.MustCompile(`(?is)