mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 22:40:54 -07:00
* docs(scan): correct crawl's false robots.txt claim the doc comment said robots.txt is respected because colly honors it by default, but colly's default is IgnoreRobotsTxt=true and Crawl never flips it, so Disallow rules were never enforced. state the actual, intentional behavior instead: a recon crawler has no reason to treat Disallow as a scope boundary. pin the behavior with a regression test so it isn't "fixed" into a partial robots.txt implementation by accident later. * fix(scan): cap crawl request fanout MaxDepth only bounds recursion depth; colly's own MaxRequests budget was left at 0 (unlimited) and Crawl set no Limit rule, so a hostile page fanning out to an attacker-controlled number of links got every one of them fetched at that depth (b^d). cap the total number of requests a single Crawl call will make with a fixed internal budget. TestCrawl_BoundsRequestFanout proves a 2500-link fanout page no longer drives the server past maxCrawlRequests+1 actual fetches. * fix(scan): stop redirects from escaping crawl scope Crawl called SetClient(httpx.Client(timeout)), which replaces colly's whole *http.Client and discards the CheckRedirect colly installs to re-check AllowedDomains on every redirect hop. with it gone, redirects fell back to net/http's default: follow up to 10 hops to any host. an in-scope page could 302 the crawler to an arbitrary off-scope host (e.g. a cloud metadata endpoint) and it would be fetched. colly's own AllowedDomains only matches on hostname anyway, which would still let a same-host, different-port redirect through (an internal service reachable on the same IP). build our own http.Client on top of httpx's shared transport instead, with a CheckRedirect that requires the redirect target to share the exact host:port of the request that produced it, and caps the hop count ourselves since a custom CheckRedirect drops net/http's own 10-redirect default. TestCrawl_RedirectRejectsOffScopeHost proves the off-scope host is no longer fetched; TestCrawl_RedirectFollowsSameHost proves ordinary same-host redirects still are.
189 lines
6.8 KiB
Go
189 lines
6.8 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package scan
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gocolly/colly/v2"
|
|
|
|
"github.com/vmfunc/sif/internal/httpx"
|
|
"github.com/vmfunc/sif/internal/logger"
|
|
"github.com/vmfunc/sif/internal/output"
|
|
)
|
|
|
|
// maxCrawlPages bounds total fetches independent of depth: MaxDepth caps
|
|
// recursion depth but not breadth, so a link-heavy page (pagination, faceted
|
|
// search) otherwise grows request count as branching^depth with no ceiling.
|
|
const maxCrawlPages = 500
|
|
|
|
// CrawlResult holds the deduped set of urls discovered by the spider.
|
|
type CrawlResult struct {
|
|
URLs []string `json:"urls"`
|
|
Truncated bool `json:"truncated,omitempty"` // hit maxCrawlPages before the spider ran out of links
|
|
}
|
|
|
|
func (r *CrawlResult) ResultType() string { return "crawl" }
|
|
|
|
// compile-time check so a result-type drift fails the build, not a run.
|
|
var _ ScanResult = (*CrawlResult)(nil)
|
|
|
|
// maxCrawlRequests caps the total pages a single Crawl call will fetch, so a
|
|
// hostile page fanning out to an attacker-controlled number of links can't
|
|
// turn a bounded-depth crawl into an unbounded one (b^d).
|
|
const maxCrawlRequests = 2000
|
|
|
|
// maxRedirectHops mirrors net/http's own default redirect cap, which we lose
|
|
// once we install a custom CheckRedirect below.
|
|
const maxRedirectHops = 10
|
|
|
|
// Crawl spiders the target up to depth, following same-host links/scripts/forms.
|
|
// all traffic flows through the shared httpx client so proxy/headers/rate-limit
|
|
// apply. robots.txt is intentionally NOT honored: this is a recon/pentest
|
|
// crawler and Disallow rules are not a scope boundary we want to respect.
|
|
func Crawl(targetURL string, depth int, timeout time.Duration, logdir string) (*CrawlResult, error) {
|
|
log := output.Module("CRAWL")
|
|
log.Start()
|
|
|
|
sanitizedURL := stripScheme(targetURL)
|
|
|
|
if logdir != "" {
|
|
if err := logger.WriteHeader(sanitizedURL, logdir, "web crawl"); err != nil {
|
|
log.Error("error creating log file: %v", err)
|
|
return nil, fmt.Errorf("create crawl log: %w", err)
|
|
}
|
|
}
|
|
|
|
// the host bounds the crawl; without it colly would wander the whole web.
|
|
parsed, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse target url %q: %w", targetURL, err)
|
|
}
|
|
host := parsed.Hostname()
|
|
if host == "" {
|
|
return nil, fmt.Errorf("target url %q has no host", targetURL)
|
|
}
|
|
|
|
collector := colly.NewCollector(
|
|
colly.MaxDepth(depth),
|
|
colly.AllowedDomains(host),
|
|
colly.MaxRequests(maxCrawlRequests),
|
|
)
|
|
// reuse the shared transport so proxy/-H/rate-limit still apply, but scope
|
|
// redirects ourselves: colly's CheckRedirect only re-checks AllowedDomains,
|
|
// which matches on hostname alone, so a same-host redirect to a different
|
|
// port would still be followed. also re-cap the hop count, since installing
|
|
// a custom CheckRedirect drops net/http's own 10-redirect default.
|
|
collector.SetClient(&http.Client{
|
|
Timeout: timeout,
|
|
Transport: httpx.Client(timeout).Transport,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= maxRedirectHops {
|
|
return fmt.Errorf("stopped after %d redirects", maxRedirectHops)
|
|
}
|
|
if req.URL.Host != via[0].URL.Host {
|
|
return fmt.Errorf("redirect to %q leaves crawl scope %q", req.URL, via[0].URL.Host)
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
|
|
// dedupe across the concurrent callbacks colly may fire.
|
|
var mu sync.Mutex
|
|
seen := make(map[string]struct{})
|
|
|
|
// visited caps the total pages fetched (see maxCrawlPages); atomic since
|
|
// colly's callbacks can fire from multiple goroutines.
|
|
var visited int64
|
|
var truncated int32
|
|
|
|
record := func(raw string) {
|
|
if raw == "" {
|
|
return
|
|
}
|
|
// keep the result set scoped to the target host; off-host assets
|
|
// (cdns, third-party links) are noise for an in-scope crawl.
|
|
if u, err := url.Parse(raw); err != nil || u.Hostname() != host {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
if _, ok := seen[raw]; !ok {
|
|
seen[raw] = struct{}{}
|
|
log.Success("found: %s", output.Highlight.Render(raw))
|
|
if logdir != "" {
|
|
_ = logger.Write(sanitizedURL, logdir, raw+"\n")
|
|
}
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
|
|
// count every request toward the budget (the seed included) and abort once
|
|
// it is exceeded, before the dial, so a link-heavy target can't run away.
|
|
collector.OnRequest(func(r *colly.Request) {
|
|
if atomic.AddInt64(&visited, 1) > maxCrawlPages {
|
|
atomic.StoreInt32(&truncated, 1)
|
|
r.Abort()
|
|
}
|
|
})
|
|
|
|
// links drive recursion; scripts/forms are recorded but not followed.
|
|
collector.OnHTML("a[href]", func(e *colly.HTMLElement) {
|
|
link := e.Request.AbsoluteURL(e.Attr("href"))
|
|
record(link)
|
|
// Visit enforces AllowedDomains/MaxDepth itself, so off-host or
|
|
// too-deep links are dropped without us re-checking.
|
|
_ = e.Request.Visit(link)
|
|
})
|
|
collector.OnHTML("script[src]", func(e *colly.HTMLElement) {
|
|
record(e.Request.AbsoluteURL(e.Attr("src")))
|
|
})
|
|
collector.OnHTML("form[action]", func(e *colly.HTMLElement) {
|
|
record(e.Request.AbsoluteURL(e.Attr("action")))
|
|
})
|
|
|
|
collector.OnError(func(_ *colly.Response, e error) {
|
|
// a single bad page shouldn't abort the crawl; note it and move on.
|
|
log.Warn("crawl error: %v", e)
|
|
})
|
|
|
|
if err := collector.Visit(targetURL); err != nil {
|
|
log.Error("crawl failed: %v", err)
|
|
return nil, fmt.Errorf("visit %q: %w", targetURL, err)
|
|
}
|
|
collector.Wait()
|
|
|
|
result := &CrawlResult{URLs: sortedKeys(seen), Truncated: atomic.LoadInt32(&truncated) != 0}
|
|
if result.Truncated {
|
|
log.Warn("crawl hit the %d-page budget and stopped early; results are partial", maxCrawlPages)
|
|
}
|
|
|
|
log.Complete(len(result.URLs), "urls")
|
|
return result, nil
|
|
}
|
|
|
|
// sortedKeys returns the map keys in a stable order so output is deterministic.
|
|
func sortedKeys(set map[string]struct{}) []string {
|
|
keys := make([]string, 0, len(set))
|
|
for k := range set {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|