fix(scan): bound crawl breadth with a page budget (#269)

Crawl only capped recursion depth, not breadth per level. A link-heavy page
(pagination, faceted search) drives fetch count toward branching^depth with no
ceiling: a depth-3 crawl of a page linking 40 fresh urls issued 1641 real
fetches.

Cap total fetches at a page budget via an atomic counter in an OnRequest hook
that aborts once exceeded, and add a Truncated flag on CrawlResult so callers
can tell a run was cut short.
This commit is contained in:
Tigah
2026-07-22 12:35:18 -07:00
committed by GitHub
parent ef4b690e77
commit 0d96838ee4
2 changed files with 101 additions and 2 deletions
+26 -2
View File
@@ -17,6 +17,7 @@ import (
"net/url"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/gocolly/colly/v2"
@@ -26,9 +27,15 @@ import (
"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"`
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" }
@@ -74,6 +81,11 @@ func Crawl(targetURL string, depth int, timeout time.Duration, logdir string) (*
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
@@ -94,6 +106,15 @@ func Crawl(targetURL string, depth int, timeout time.Duration, logdir string) (*
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"))
@@ -120,7 +141,10 @@ func Crawl(targetURL string, depth int, timeout time.Duration, logdir string) (*
}
collector.Wait()
result := &CrawlResult{URLs: sortedKeys(seen)}
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
+75
View File
@@ -0,0 +1,75 @@
package scan
import (
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// TestCrawlBudgetCapsUnboundedBreadth guards against the request count Crawl
// issues growing without bound. MaxDepth only caps recursion depth; without a
// breadth budget, a page that hands back many fresh links per level (ordinary
// pagination/faceted-search shapes, not just a deliberate trap) drives the
// fetch count to branching^depth. This pins the total at maxCrawlPages instead.
func TestCrawlBudgetCapsUnboundedBreadth(t *testing.T) {
const branching = 40 // links per page
var reqCount int64
mux := http.NewServeMux()
mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&reqCount, 1)
// every page hands back `branching` brand-new, never-before-seen paths
// keyed off the requesting path, so colly's exact-url dedupe can't
// collapse them - the pagination/calendar-trap shape.
prefix := r.URL.Path
if prefix == "/" {
prefix = ""
}
for i := 0; i < branching; i++ {
fmt.Fprintf(w, `<a href="%s/%d">x</a>`, prefix, i)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
// depth=3 would otherwise fetch 1 + branching + branching^2 = 1641 pages;
// the budget must cut that off at maxCrawlPages regardless of depth.
result, err := Crawl(srv.URL, 3, 5*time.Second, "")
if err != nil {
t.Fatalf("Crawl: %v", err)
}
got := atomic.LoadInt64(&reqCount)
t.Logf("depth=3 branching=%d -> %d requests (budget %d), truncated=%v",
branching, got, maxCrawlPages, result.Truncated)
if got > maxCrawlPages {
t.Fatalf("expected the page budget to cap requests at %d, got %d", maxCrawlPages, got)
}
if !result.Truncated {
t.Fatalf("expected Truncated=true once the budget is hit, got false")
}
}
// TestCrawlBudgetDoesNotTruncateSmallSites is the negative case: an ordinary
// small site well under the budget crawls to completion and reports
// Truncated=false, so the new cap doesn't regress normal runs.
func TestCrawlBudgetDoesNotTruncateSmallSites(t *testing.T) {
srv := crawlSite(t)
defer srv.Close()
result, err := Crawl(srv.URL, 2, 2*time.Second, "")
if err != nil {
t.Fatalf("Crawl: %v", err)
}
if result.Truncated {
t.Fatalf("small site should not hit the page budget, got Truncated=true (urls=%v)", result.URLs)
}
}