Add recon network batch 1: stack banners and smart port profiles.
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
Parse technology stack from crawl headers, grab SSH/HTTP/WinRM banners, merge smart port bundles with FleetPorts, and suggest deploy-kit lane plus SSM for EC2 metadata targets.
This commit is contained in:
@@ -25,6 +25,8 @@ func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlRepo
|
||||
}
|
||||
|
||||
report := &CrawlReport{}
|
||||
var headerSnaps []HTTPHeaderSnap
|
||||
var htmlBodies []string
|
||||
visited := map[string]bool{}
|
||||
queue := []queuedURL{}
|
||||
for _, p := range seeds {
|
||||
@@ -44,7 +46,7 @@ func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlRepo
|
||||
}
|
||||
visited[key] = true
|
||||
|
||||
status, body, err := fetchPage(item.url)
|
||||
status, body, headers, err := fetchPage(item.url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -54,6 +56,8 @@ func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlRepo
|
||||
title = root
|
||||
}
|
||||
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...)
|
||||
@@ -84,6 +88,7 @@ func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlRepo
|
||||
report.SSRFScore = 100
|
||||
}
|
||||
report.CMSFingerprints = mergeCMS(nil, report.CMSFingerprints)
|
||||
report.Stack = BuildStack(headerSnaps, htmlBodies)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
@@ -92,26 +97,28 @@ type queuedURL struct {
|
||||
depth int
|
||||
}
|
||||
|
||||
func fetchPage(rawURL string) (int, string, error) {
|
||||
func fetchPage(rawURL string) (int, string, map[string]string, error) {
|
||||
if fetchPageFn != nil {
|
||||
return fetchPageFn(rawURL)
|
||||
s,b,e:=fetchPageFn(rawURL); return s,b,nil,e
|
||||
}
|
||||
client := &http.Client{Timeout: DefaultPortDialTimeout}
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
return 0, "", nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "AetherForge-Recon/1.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
return 0, "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := readBodyLimited(resp.Body, maxHTMLBytes)
|
||||
if err != nil {
|
||||
return resp.StatusCode, "", err
|
||||
return resp.StatusCode, "", nil, err
|
||||
}
|
||||
return resp.StatusCode, body, nil
|
||||
headers:=map[string]string{}
|
||||
for k,v:=range resp.Header { if len(v)>0 { headers[k]=v[0] } }
|
||||
return resp.StatusCode, body, headers, nil
|
||||
}
|
||||
|
||||
func normalizeScheme(scheme string, port int) string {
|
||||
|
||||
@@ -2,17 +2,33 @@ package recon
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dialPortFn overrides TCP probes in tests (nil = live dial from server host).
|
||||
const (
|
||||
PortProfileWeb = "web"
|
||||
PortProfileWindows = "windows"
|
||||
PortProfileLinux = "linux"
|
||||
PortProfileCloudMetadata = "cloud_metadata"
|
||||
)
|
||||
|
||||
var portProfilePorts = map[string][]int{
|
||||
PortProfileWeb: {80, 443, 6262},
|
||||
PortProfileWindows: {445, 5985, 3389},
|
||||
PortProfileLinux: {22},
|
||||
}
|
||||
|
||||
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 {
|
||||
func ScanPorts(host string, ports []int) []PortResult {
|
||||
if len(ports) == 0 {
|
||||
ports = FleetPorts
|
||||
}
|
||||
out := make([]PortResult, 0, len(ports))
|
||||
for _, port := range ports {
|
||||
out = append(out, PortResult{Port: port, Open: dialPort(host, port, DefaultPortDialTimeout)})
|
||||
}
|
||||
return out
|
||||
@@ -22,11 +38,76 @@ 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)
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func ResolveScanPortsFromRequest(req ScanRequest) ([]int, []string) {
|
||||
return ResolveScanPorts(req.Profiles)
|
||||
}
|
||||
|
||||
func ResolveScanPorts(profiles []string) ([]int, []string) {
|
||||
seen := map[int]bool{}
|
||||
var ports []int
|
||||
for _, p := range FleetPorts {
|
||||
if !seen[p] {
|
||||
seen[p] = true
|
||||
ports = append(ports, p)
|
||||
}
|
||||
}
|
||||
var used []string
|
||||
for _, raw := range profiles {
|
||||
name := strings.ToLower(strings.TrimSpace(raw))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if name == PortProfileCloudMetadata {
|
||||
if !containsPortProfile(used, name) {
|
||||
used = append(used, name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
bundle, ok := portProfilePorts[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !containsPortProfile(used, name) {
|
||||
used = append(used, name)
|
||||
}
|
||||
for _, p := range bundle {
|
||||
if !seen[p] {
|
||||
seen[p] = true
|
||||
ports = append(ports, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Ints(ports)
|
||||
return ports, used
|
||||
}
|
||||
|
||||
func containsPortProfile(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TargetLooksEC2(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if host == "169.254.169.254" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(host, ".compute.amazonaws.com") ||
|
||||
strings.Contains(host, ".compute.internal") ||
|
||||
strings.HasPrefix(host, "ip-10-") ||
|
||||
strings.HasPrefix(host, "ec2-")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestScanPortsWithInject(t *testing.T) {
|
||||
return port == 22 || port == 443
|
||||
})
|
||||
t.Cleanup(func() { SetPortDialHook(nil) })
|
||||
results := ScanPorts("10.0.0.5")
|
||||
results := ScanPorts("10.0.0.5", nil)
|
||||
open := map[int]bool{}
|
||||
for _, r := range results {
|
||||
if r.Open {
|
||||
@@ -119,7 +119,7 @@ func TestScanReportRecommendations(t *testing.T) {
|
||||
MultipartForms: []FormFinding{{PageURL: "http://x/", Multipart: true}},
|
||||
SSRFScore: 40,
|
||||
}
|
||||
recs := BuildRecommendations(ports, crawl)
|
||||
recs := BuildRecommendations(ports, crawl, nil, "10.0.0.1", false)
|
||||
if len(recs) < 5 {
|
||||
t.Fatalf("recs=%+v", recs)
|
||||
}
|
||||
@@ -184,3 +184,24 @@ func atoi(s string) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
|
||||
func TestResolveScanPortsMergesProfiles(t *testing.T) {
|
||||
ports, used := ResolveScanPorts([]string{"web", "linux", "cloud_metadata"})
|
||||
if !containsInt(ports, 6262) || len(used) != 3 { t.Fatalf("%v %v", ports, used) }
|
||||
}
|
||||
func TestBuildStackFromHeaders(t *testing.T) {
|
||||
stack := BuildStack([]HTTPHeaderSnap{{Headers: map[string]string{"X-Powered-By":"PHP/8.1"}}}, nil)
|
||||
if SuggestDeployKitLane(stack) != "php" { t.Fatal(stack) }
|
||||
}
|
||||
func TestGrabBannersWithHooks(t *testing.T) {
|
||||
SetBannerHooks(func(_ string,p int) string { if p==22 {return "SSH"}; return "" }, func(_ string,p int)(string,string){ if p==80 {return "t","s"}; return "","" }, func(_ string,p int) string { if p==5985 {return "w"}; return "" }, nil)
|
||||
t.Cleanup(func(){SetBannerHooks(nil,nil,nil,nil)})
|
||||
if len(GrabBanners("h", []PortResult{{22,true},{80,true},{5985,true}})) != 3 { t.Fatal() }
|
||||
}
|
||||
func TestCloudMetadataProfileSuggestsSSM(t *testing.T) {
|
||||
SetBannerHooks(nil,nil,nil,func()bool{return true}); t.Cleanup(func(){SetBannerHooks(nil,nil,nil,nil)})
|
||||
for _,r := range BuildRecommendations(nil,nil,nil,"ec2.compute.amazonaws.com",true) { if r.Lane=="ssm_document" { return } }
|
||||
t.Fatal()
|
||||
}
|
||||
func containsInt(a []int,w int) bool { for _,n:=range a { if n==w {return true} }; return false }
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package recon
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHostReachableHook(t *testing.T) {
|
||||
SetHostReachableHook(func(host string) bool { return host == "10.0.0.5" })
|
||||
t.Cleanup(func() { SetHostReachableHook(nil) })
|
||||
if !HostReachable("10.0.0.5") || HostReachable("10.0.0.6") {
|
||||
t.Fatal("reachability hook mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAgentRelayScanWireguardHint(t *testing.T) {
|
||||
payload := []byte(`{"open_ports":[22],"udp_hints":[{"port":51820,"open":true,"service":"wireguard"}]}`)
|
||||
merged, err := MergeAgentRelayScan(RelayScanReport{Host: "10.1.2.50", ScannedVia: "relay"}, payload, false)
|
||||
if err != nil || len(merged.PathTracerHints) != 1 || merged.PathTracerHints[0] != "wireguard" {
|
||||
t.Fatalf("merged=%+v err=%v", merged, err)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,28 @@
|
||||
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)
|
||||
ports := ScanPorts(host, nil)
|
||||
report := &ScanReport{
|
||||
Host: host,
|
||||
ScannedAt: time.Now().UTC(),
|
||||
@@ -26,7 +35,7 @@ func Scan(req ScanRequest) (*ScanReport, error) {
|
||||
report.Crawl = crawl
|
||||
}
|
||||
}
|
||||
report.Recommendations = BuildRecommendations(ports, report.Crawl)
|
||||
report.Recommendations = BuildRecommendations(ports, report.Crawl, nil, host, false)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
@@ -62,7 +71,7 @@ func shouldCrawl(req ScanRequest, ports []PortResult) bool {
|
||||
}
|
||||
for _, p := range ports {
|
||||
switch p.Port {
|
||||
case 80, 443, 8080, 8443:
|
||||
case 80, 443, 6262, 8080, 8443:
|
||||
if p.Open {
|
||||
return true
|
||||
}
|
||||
@@ -72,7 +81,7 @@ func shouldCrawl(req ScanRequest, ports []PortResult) bool {
|
||||
}
|
||||
|
||||
// BuildRecommendations maps port and crawl findings to existing deploy lanes/templates.
|
||||
func BuildRecommendations(ports []PortResult, crawl *CrawlReport) []DeployRecommendation {
|
||||
func BuildRecommendations(ports []PortResult, crawl *CrawlReport, stack []StackEntry, host string, cloudMetaProfile bool) []DeployRecommendation {
|
||||
var recs []DeployRecommendation
|
||||
open := map[int]bool{}
|
||||
for _, p := range ports {
|
||||
@@ -85,14 +94,14 @@ func BuildRecommendations(ports []PortResult, crawl *CrawlReport) []DeployRecomm
|
||||
recs = append(recs, DeployRecommendation{
|
||||
Lane: "linux_lotl",
|
||||
Template: "linux-lotl",
|
||||
Reason: "TCP 22 open — SSH LOTL bootstrap",
|
||||
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",
|
||||
Reason: "TCP 445 open — SMB UNC spread",
|
||||
Priority: 50,
|
||||
})
|
||||
}
|
||||
@@ -100,19 +109,19 @@ func BuildRecommendations(ports []PortResult, crawl *CrawlReport) []DeployRecomm
|
||||
recs = append(recs, DeployRecommendation{
|
||||
Lane: "winrm",
|
||||
Template: "winrm",
|
||||
Reason: "TCP 5985/5986 open — WinRM bootstrap",
|
||||
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",
|
||||
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",
|
||||
Reason: "HTTP surface open — copy /spread/ public waterhole landing",
|
||||
Priority: 15,
|
||||
})
|
||||
}
|
||||
@@ -121,19 +130,25 @@ func BuildRecommendations(ports []PortResult, crawl *CrawlReport) []DeployRecomm
|
||||
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",
|
||||
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),
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -150,3 +165,203 @@ func dedupeRecommendations(in []DeployRecommendation) []DeployRecommendation {
|
||||
}
|
||||
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)<meta[^>]+name=["']generator["'][^>]+content=["']([^"']+)["']`)
|
||||
var bannerTitleRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
|
||||
var probeSSHBannerFn func(host string, port int) string
|
||||
var probeHTTPTitleFn func(host string, port int) (string, string)
|
||||
var probeWinRMHintFn func(host string, port int) string
|
||||
var probeEC2MetaFn func() bool
|
||||
|
||||
func SetBannerHooks(ssh func(host string, port int) string, httpTitle func(host string, port int) (string, string), winrm func(host string, port int) string, ec2Meta func() bool) {
|
||||
probeSSHBannerFn, probeHTTPTitleFn, probeWinRMHintFn, probeEC2MetaFn = ssh, httpTitle, winrm, ec2Meta
|
||||
}
|
||||
func BuildStack(snaps []HTTPHeaderSnap, htmlBodies []string) []StackEntry {
|
||||
seen := map[string]bool{}
|
||||
var out []StackEntry
|
||||
add := func(name, source, detail string) { key := strings.ToLower(name) + "|" + source; if !seen[key] { seen[key] = true; out = append(out, StackEntry{Name: name, Source: source, Detail: detail}) } }
|
||||
for _, snap := range snaps { for k, v := range snap.Headers { switch strings.ToLower(k) { case "server": add(v, "header:Server", snap.URL); case "x-powered-by": add(v, "header:X-Powered-By", snap.URL) } } }
|
||||
for _, body := range htmlBodies {
|
||||
if m := metaGeneratorRe.FindStringSubmatch(body); len(m) > 1 { add(strings.TrimSpace(m[1]), "meta:generator", "") }
|
||||
if strings.Contains(strings.ToLower(body), "react") { add("React", "html:js", "") }
|
||||
}
|
||||
return out
|
||||
}
|
||||
func MergeStack(parts ...[]StackEntry) []StackEntry { seen := map[string]bool{}; var out []StackEntry; for _, part := range parts { for _, e := range part { key := strings.ToLower(e.Name) + "|" + e.Source; if !seen[key] { seen[key] = true; out = append(out, e) } } }; return out }
|
||||
func SuggestDeployKitLane(stack []StackEntry) string {
|
||||
for _, e := range stack { n := strings.ToLower(e.Name); if strings.Contains(n, "php") { return "php" }; if strings.Contains(n, "node") || strings.Contains(n, "react") { return "node" }; if strings.Contains(n, "nginx") { return "nginx" } }
|
||||
if len(stack) > 0 { return "static" }
|
||||
return ""
|
||||
}
|
||||
func GrabBanners(host string, ports []PortResult) []PortBanner {
|
||||
open := map[int]bool{}; for _, p := range ports { if p.Open { open[p.Port] = true } }
|
||||
var out []PortBanner
|
||||
if open[22] { if b := probeSSHBanner(host, 22); b != "" { out = append(out, PortBanner{Port: 22, Service: "ssh", Banner: b}) } }
|
||||
for _, port := range []int{80, 443, 6262, 8080} {
|
||||
if !open[port] { continue }
|
||||
title, server := probeHTTPBanner(host, port)
|
||||
if title != "" || server != "" { b := PortBanner{Port: port, Service: "http", Title: title, Banner: server, Hint: "origin_host"}; if looksLikeCDN(server) { b.Hint = "cdn_fronted" }; out = append(out, b) }
|
||||
}
|
||||
if open[5985] { if h := probeWinRMHint(host, 5985); h != "" { out = append(out, PortBanner{Port: 5985, Service: "winrm", Hint: h}) } }
|
||||
return out
|
||||
}
|
||||
func looksLikeCDN(s string) bool { s = strings.ToLower(s); return strings.Contains(s, "cloudflare") || strings.Contains(s, "cloudfront") }
|
||||
func probeSSHBanner(host string, port int) string {
|
||||
if probeSSHBannerFn != nil { return probeSSHBannerFn(host, port) }
|
||||
c, e := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), DefaultPortDialTimeout)
|
||||
if e != nil { return "" }; defer c.Close(); _ = c.SetReadDeadline(time.Now().Add(DefaultPortDialTimeout))
|
||||
line, e := bufio.NewReader(c).ReadString('\n'); if e != nil { return "" }; return strings.TrimSpace(line)
|
||||
}
|
||||
func probeHTTPBanner(host string, port int) (string, string) {
|
||||
if probeHTTPTitleFn != nil { return probeHTTPTitleFn(host, port) }
|
||||
scheme := "http"; if port == 443 { scheme = "https" }
|
||||
resp, err := http.Get(fmt.Sprintf("%s://%s/", scheme, joinHostPort(host, port)))
|
||||
if err != nil { return "", "" }; defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
title := ""; if m := bannerTitleRe.FindStringSubmatch(string(body)); len(m) > 1 { title = strings.TrimSpace(m[1]) }
|
||||
return title, strings.TrimSpace(resp.Header.Get("Server"))
|
||||
}
|
||||
func probeWinRMHint(host string, port int) string {
|
||||
if probeWinRMHintFn != nil { return probeWinRMHintFn(host, port) }
|
||||
c, e := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), DefaultPortDialTimeout)
|
||||
if e != nil { return "" }; _ = c.Close(); return "winrm_listening"
|
||||
}
|
||||
func ProbeEC2Metadata() bool {
|
||||
if probeEC2MetaFn != nil { return probeEC2MetaFn() }
|
||||
c := &http.Client{Timeout: 800 * time.Millisecond}
|
||||
r, e := c.Get("http://169.254.169.254/latest/meta-data/")
|
||||
if e != nil { return false }; defer r.Body.Close()
|
||||
return r.StatusCode == http.StatusOK || r.StatusCode == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ const (
|
||||
)
|
||||
|
||||
type ScanRequest struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Profiles []string `json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
type PortResult struct {
|
||||
@@ -22,6 +24,25 @@ type PortResult struct {
|
||||
Open bool `json:"open"`
|
||||
}
|
||||
|
||||
type PortBanner struct {
|
||||
Port int `json:"port"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Banner string `json:"banner,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
type StackEntry struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPHeaderSnap struct {
|
||||
URL string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type FormFinding struct {
|
||||
PageURL string `json:"page_url"`
|
||||
Action string `json:"action,omitempty"`
|
||||
@@ -53,6 +74,7 @@ type CrawlReport struct {
|
||||
URLFields []URLFieldFinding `json:"url_fields,omitempty"`
|
||||
SSRFScore int `json:"ssrf_score"`
|
||||
CMSFingerprints []string `json:"cms_fingerprints,omitempty"`
|
||||
Stack []StackEntry `json:"stack,omitempty"`
|
||||
}
|
||||
|
||||
type DeployRecommendation struct {
|
||||
@@ -62,10 +84,33 @@ type DeployRecommendation struct {
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
type ReconScanDiff struct {
|
||||
NewPorts []int `json:"new_ports,omitempty"`
|
||||
NewForms []FormFinding `json:"new_forms,omitempty"`
|
||||
}
|
||||
|
||||
type ReconHistoryEntry struct {
|
||||
ScanID string `json:"scan_id"`
|
||||
Host string `json:"host"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ScannedAt time.Time `json:"scanned_at"`
|
||||
Report *ScanReport `json:"report"`
|
||||
Diff *ReconScanDiff `json:"diff,omitempty"`
|
||||
}
|
||||
|
||||
type ScanReport struct {
|
||||
ScanID string `json:"scan_id,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
ProfilesUsed []string `json:"profiles_used,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ScannedAt time.Time `json:"scanned_at"`
|
||||
Ports []PortResult `json:"ports"`
|
||||
Banners []PortBanner `json:"banners,omitempty"`
|
||||
Stack []StackEntry `json:"stack,omitempty"`
|
||||
DeployKitLane string `json:"deploy_kit_lane,omitempty"`
|
||||
Crawl *CrawlReport `json:"crawl,omitempty"`
|
||||
RelayVia string `json:"relay_via,omitempty"`
|
||||
Recommendations []DeployRecommendation `json:"recommendations,omitempty"`
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface ReconScanRequest {
|
||||
port?: number;
|
||||
scheme?: string;
|
||||
paths?: string[];
|
||||
profile?: string;
|
||||
profiles?: string[];
|
||||
}
|
||||
|
||||
export interface ReconPortResult {
|
||||
@@ -121,6 +123,20 @@ export interface ReconPortResult {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface ReconPortBanner {
|
||||
port: number;
|
||||
service?: string;
|
||||
banner?: string;
|
||||
title?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface ReconStackEntry {
|
||||
name: string;
|
||||
source: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ReconFormFinding {
|
||||
page_url: string;
|
||||
action?: string;
|
||||
@@ -146,6 +162,7 @@ export interface ReconCrawlReport {
|
||||
url_fields?: ReconURLFieldFinding[];
|
||||
ssrf_score: number;
|
||||
cms_fingerprints?: string[];
|
||||
stack?: ReconStackEntry[];
|
||||
}
|
||||
|
||||
export interface ReconDeployRecommendation {
|
||||
@@ -156,9 +173,16 @@ export interface ReconDeployRecommendation {
|
||||
}
|
||||
|
||||
export interface ReconScanReport {
|
||||
scan_id?: string;
|
||||
host: string;
|
||||
profile?: string;
|
||||
profiles_used?: string[];
|
||||
status?: string;
|
||||
scanned_at: string;
|
||||
ports: ReconPortResult[];
|
||||
banners?: ReconPortBanner[];
|
||||
stack?: ReconStackEntry[];
|
||||
deploy_kit_lane?: string;
|
||||
crawl?: ReconCrawlReport;
|
||||
relay_via?: string;
|
||||
udp_hints?: { port: number; open: boolean; service?: string }[];
|
||||
|
||||
Reference in New Issue
Block a user