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

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:
AetherForge
2026-06-07 12:04:04 -07:00
parent 6122c3ebfc
commit 11c15cdcfb
21 changed files with 3314 additions and 51 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }[];