Skip to content

Commit a3b8cc2

Browse files
h0tak88rclaude
andcommitted
feat(scans): reuse stored httpx URLs to skip re-probing in nuclei scans
Live subdomains are already persisted with their resolved scheme (http_url/ https_url columns, set by the httpx step). Expose that so nuclei scans can target the scheme-prefixed URL directly and skip httpx: - db: SubdomainStatus.BestURL() picks the responding scheme (https first); db.ListLiveSubdomainURLs(domain) returns every live subdomain as https://host. - pipeline: fast-path — if a root already has stored live URLs, reuse them and skip enumeration + httpx entirely; otherwise enumerate → httpx (which persists the scheme URLs) → feed those stored URLs to nuclei. - global nuclei scan: dump the stored scheme-prefixed URL for probed-live subs (bare host fallback for unprobed) so it no longer re-probes. - nuclei's target sanitizer already preserves http/https schemes, so the URLs reach the engine intact. - test: BestURL scheme-preference table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 00836e0 commit a3b8cc2

5 files changed

Lines changed: 120 additions & 13 deletions

File tree

internal/api/pipeline_api.go

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,19 @@ func runRootPipeline(scanID, template string, newOnly bool, threads, maxRoots in
114114
go func(root string) {
115115
defer wg.Done()
116116
defer func() { <-sem }()
117+
118+
// Fast path: if this root already has probed live URLs stored, reuse them
119+
// and skip enumeration + httpx entirely — the schemes were resolved when
120+
// the subdomains were first probed, so nuclei can target them directly.
121+
if stored, serr := db.ListLiveSubdomainURLs(root); serr == nil && len(stored) > 0 {
122+
mu.Lock()
123+
done++
124+
allSubs = append(allSubs, stored...)
125+
stdLog(scanID, "[OK] %s → %d stored live URLs, skipped httpx (%d/%d roots)", root, len(stored), done, len(targetRoots))
126+
mu.Unlock()
127+
return
128+
}
129+
117130
subs, err := subdomains.EnumerateSubdomains(root, threads)
118131
if err != nil {
119132
mu.Lock()
@@ -127,21 +140,20 @@ func runRootPipeline(scanID, template string, newOnly bool, threads, maxRoots in
127140
stdLog(scanID, "[WARN] store %s subs failed: %v", root, ierr)
128141
}
129142
}
130-
// httpx: probe for live hosts (marks live in the DB) and scan only those.
131-
// Falls back to the raw subdomains if the probe finds nothing (nuclei does
132-
// its own probing anyway, so no host is lost).
133-
targets := subs
134-
liveN := 0
135-
if res, herr := livehosts.FilterLiveHosts(root, threads, true); herr == nil && res != nil && res.LiveSubsFile != "" {
136-
if live, rerr := utils.ReadLines(res.LiveSubsFile); rerr == nil && len(live) > 0 {
137-
targets = live
138-
liveN = len(live)
139-
}
143+
// httpx: probe for live hosts — marks them live AND stores the resolved
144+
// scheme-prefixed URL per host in the DB, so future runs skip httpx.
145+
_, _ = livehosts.FilterLiveHosts(root, threads, true)
146+
// Use the stored scheme-prefixed live URLs as nuclei targets (nuclei then
147+
// skips its own probing). Fall back to raw subs if nothing came back live.
148+
targets, _ := db.ListLiveSubdomainURLs(root)
149+
liveN := len(targets)
150+
if liveN == 0 {
151+
targets = subs // fallback: nuclei probes these itself
140152
}
141153
mu.Lock()
142154
done++
143155
allSubs = append(allSubs, targets...)
144-
stdLog(scanID, "[OK] %s → %d subs, %d live (%d/%d roots)", root, len(subs), liveN, done, len(targetRoots))
156+
stdLog(scanID, "[OK] %s → %d subs, %d live URLs (%d/%d roots)", root, len(subs), liveN, done, len(targetRoots))
145157
mu.Unlock()
146158
}(r)
147159
}

internal/api/ui_api.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -676,8 +676,17 @@ func apiRunGlobalNuclei(c *gin.Context) {
676676
break
677677
}
678678
for _, s := range subs {
679-
if s.Subdomain != "" {
680-
tmpFile.WriteString(s.Subdomain + "\n")
679+
// For subdomains already probed live, use the stored scheme-prefixed
680+
// URL (https://host) so nuclei skips its own httpx probing. Unprobed
681+
// hosts fall back to the bare name (nuclei resolves the scheme itself).
682+
target := s.Subdomain
683+
if s.IsLive {
684+
if u := s.BestURL(); u != "" {
685+
target = u
686+
}
687+
}
688+
if target != "" {
689+
tmpFile.WriteString(target + "\n")
681690
totalSubs++
682691
}
683692
}

internal/db/db.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,28 @@ func ListSubdomainsWithStatus(domain string) ([]SubdomainStatus, error) {
312312
return dbInstance.ListSubdomainsWithStatus(domain)
313313
}
314314

315+
// ListLiveSubdomainURLs returns the stored, scheme-prefixed URL for every live
316+
// subdomain of a domain (e.g. https://api.example.com). Using these as nuclei
317+
// targets lets a scan skip the httpx probing step, since the scheme was already
318+
// resolved when the subdomain was probed. Returns nothing if the domain has no
319+
// live subdomains stored yet (caller should then run httpx).
320+
func ListLiveSubdomainURLs(domain string) ([]string, error) {
321+
subs, err := ListSubdomainsWithStatus(domain)
322+
if err != nil {
323+
return nil, err
324+
}
325+
out := make([]string, 0, len(subs))
326+
for _, s := range subs {
327+
if !s.IsLive {
328+
continue
329+
}
330+
if u := s.BestURL(); u != "" {
331+
out = append(out, u)
332+
}
333+
}
334+
return out, nil
335+
}
336+
315337
// ListAllSubdomainsPaginated returns a paginated global list of subdomains matching a search.
316338
func ListAllSubdomainsPaginated(search, techFilter, cnameFilter string, statusFilter int, liveOnly bool, limit, offset int) ([]GlobalSubdomain, int, error) {
317339
if dbInstance == nil {

internal/db/types.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,26 @@ type SubdomainStatus struct {
384384
CNAMEs string `json:"cnames,omitempty"`
385385
}
386386

387+
// BestURL returns the scheme-prefixed URL to use for this subdomain, preferring
388+
// the scheme that actually responded (https first). Used so downstream nuclei
389+
// scans can target the already-probed URL and skip re-running httpx.
390+
func (s SubdomainStatus) BestURL() string {
391+
switch {
392+
case s.HTTPSStatus > 0 && s.HTTPSURL != "":
393+
return s.HTTPSURL
394+
case s.HTTPStatus > 0 && s.HTTPURL != "":
395+
return s.HTTPURL
396+
case s.HTTPSURL != "":
397+
return s.HTTPSURL
398+
case s.HTTPURL != "":
399+
return s.HTTPURL
400+
case s.Subdomain != "":
401+
return "https://" + s.Subdomain
402+
default:
403+
return ""
404+
}
405+
}
406+
387407
// GlobalSubdomain extends SubdomainStatus with the root domain
388408
type GlobalSubdomain struct {
389409
SubdomainStatus

internal/db/types_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package db
2+
3+
import "testing"
4+
5+
func TestSubdomainStatusBestURL(t *testing.T) {
6+
cases := []struct {
7+
name string
8+
in SubdomainStatus
9+
want string
10+
}{
11+
{
12+
name: "https responded → prefer https",
13+
in: SubdomainStatus{Subdomain: "a.example.com", HTTPURL: "http://a.example.com", HTTPSURL: "https://a.example.com", HTTPStatus: 200, HTTPSStatus: 200},
14+
want: "https://a.example.com",
15+
},
16+
{
17+
name: "only http responded → use http",
18+
in: SubdomainStatus{Subdomain: "b.example.com", HTTPURL: "http://b.example.com", HTTPSURL: "https://b.example.com", HTTPStatus: 200, HTTPSStatus: 0},
19+
want: "http://b.example.com",
20+
},
21+
{
22+
name: "no status but https url present → https",
23+
in: SubdomainStatus{Subdomain: "c.example.com", HTTPSURL: "https://c.example.com"},
24+
want: "https://c.example.com",
25+
},
26+
{
27+
name: "bare host only → construct https",
28+
in: SubdomainStatus{Subdomain: "d.example.com"},
29+
want: "https://d.example.com",
30+
},
31+
{
32+
name: "empty → empty",
33+
in: SubdomainStatus{},
34+
want: "",
35+
},
36+
}
37+
for _, tc := range cases {
38+
t.Run(tc.name, func(t *testing.T) {
39+
if got := tc.in.BestURL(); got != tc.want {
40+
t.Errorf("BestURL() = %q, want %q", got, tc.want)
41+
}
42+
})
43+
}
44+
}

0 commit comments

Comments
 (0)