Skip to content

Commit 2af18eb

Browse files
committed
refactor: modularize Katana and Dalfox into explicit workflow phases to improve pipeline execution flow.
1 parent 0130a54 commit 2af18eb

3 files changed

Lines changed: 112 additions & 31 deletions

File tree

internal/scanner/reflection/reflection.go

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -198,27 +198,74 @@ func ScanReflectionWithOptions(opts Options) (*Result, error) {
198198
}
199199
}
200200

201-
// ── Dalfox: confirm XSS on kxss findings that had {<} or {>} unfiltered ──
202-
// These characters indicate the site doesn't filter angle brackets —
203-
// the strongest signal for exploitable XSS. Feed only those URLs to dalfox.
204-
xssCandidateURLs := extractAngleBracketURLs(findings)
205-
if len(xssCandidateURLs) > 0 {
206-
logger.GetLogger().Infof("[INFO] Running dalfox on %d kxss angle-bracket candidates", len(xssCandidateURLs))
207-
runDalfoxOnURLs(xssCandidateURLs, opts.Domain, opts.Threads)
208-
} else {
209-
logger.GetLogger().Infof("[INFO] No angle-bracket candidates from kxss — skipping dalfox")
210-
if scanID := utils.GetCurrentScanID(); scanID != "" {
211-
_ = utils.WriteNoFindingsJSON(scanID, opts.Domain, "xss-detection", "dalfox-xss-results.json")
212-
}
213-
}
214-
215201
return &Result{
216202
Domain: opts.Domain,
217203
Reflections: reflectionCount,
218204
OutputFile: outFile,
219205
}, nil
220206
}
221207

208+
// RunDalfoxPhase reads the kxss results for a domain, filters URLs where {<} or {>}
209+
// was unfiltered, and runs dalfox on those candidates as a separate pipeline phase.
210+
// Returns a non-nil error only on configuration failures (not on "no findings").
211+
func RunDalfoxPhase(domain string) error {
212+
resultsDir := utils.GetResultsDir()
213+
domainDir := filepath.Join(resultsDir, domain)
214+
kxssFile := filepath.Join(domainDir, "vulnerabilities", "kxss-results.txt")
215+
216+
// Parse kxss text output: "URL: <url> Param: <p> Unfiltered: [{<} {>} ...]"
217+
data, err := os.ReadFile(kxssFile)
218+
if err != nil || len(strings.TrimSpace(string(data))) == 0 {
219+
scanID := utils.GetCurrentScanID()
220+
if scanID != "" {
221+
_ = utils.WriteNoFindingsJSON(scanID, domain, "xss-detection", "dalfox-xss-results.json")
222+
}
223+
return nil
224+
}
225+
226+
// Collect URLs where angle brackets were unfiltered
227+
seen := make(map[string]struct{})
228+
var candidates []string
229+
for _, line := range strings.Split(string(data), "\n") {
230+
line = strings.TrimSpace(line)
231+
if line == "" {
232+
continue
233+
}
234+
hasAngle := strings.Contains(line, "{<}") || strings.Contains(line, "{>}")
235+
if !hasAngle {
236+
continue
237+
}
238+
// Extract the URL part: "URL: <url> Param: ..."
239+
urlPart := ""
240+
if idx := strings.Index(line, "URL: "); idx >= 0 {
241+
rest := line[idx+5:]
242+
if end := strings.Index(rest, " Param:"); end >= 0 {
243+
urlPart = strings.TrimSpace(rest[:end])
244+
} else {
245+
urlPart = strings.TrimSpace(rest)
246+
}
247+
}
248+
if urlPart != "" {
249+
if _, ok := seen[urlPart]; !ok {
250+
seen[urlPart] = struct{}{}
251+
candidates = append(candidates, urlPart)
252+
}
253+
}
254+
}
255+
256+
if len(candidates) == 0 {
257+
logger.GetLogger().Infof("[INFO] Dalfox: no angle-bracket candidates from kxss for %s", domain)
258+
if scanID := utils.GetCurrentScanID(); scanID != "" {
259+
_ = utils.WriteNoFindingsJSON(scanID, domain, "xss-detection", "dalfox-xss-results.json")
260+
}
261+
return nil
262+
}
263+
264+
logger.GetLogger().Infof("[INFO] Dalfox: running on %d kxss angle-bracket candidates for %s", len(candidates), domain)
265+
runDalfoxOnURLs(candidates, domain, 50)
266+
return nil
267+
}
268+
222269
// xssFinding is one structured kxss result persisted to the dashboard.
223270
type xssFinding struct {
224271
TemplateID string `json:"template-id"`

internal/scanner/subdomain/subdomain.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ func RunSubdomainWithOptions(subdomain string, opts RunOptions) (*Result, error)
176176
{"tech", "[Stage 2] Technology detection", func() error { _, err := tech.DetectTech(subdomainClean, 150); return err }, 0},
177177
{"ports", "[Stage 2] Port scan", func() error { _, err := ports.ScanPorts(subdomainClean, 150); return err }, 0},
178178
{"urls", "[Stage 2] URL collection", func() error { _, err := urls.CollectURLs(subdomainClean, 150, true); return err }, 0},
179-
{"jsscan", "[Stage 2] JS scan", func() error {
179+
{"js-analysis", "[Stage 2] JS secrets scan", func() error {
180180
_, err := jsscan.Run(jsscan.Options{Domain: subdomainClean, Subdomain: subdomainClean, Threads: 150})
181181
return err
182182
}, 0},
@@ -262,6 +262,15 @@ func RunSubdomainWithOptions(subdomain string, opts RunOptions) (*Result, error)
262262

263263
wgPhase2.Wait()
264264

265+
// Phase 2.5: Katana (sequential — after URL collection, before deep scanning)
266+
// Needs live hosts + initial URL corpus from Phase 2. Results merged into all-urls.txt
267+
// so GF, reflection, and other Phase 3 tools see the complete URL set.
268+
if err := utils.RunWorkflowPhase("katana", getNextStep(), totalSteps, "[Stage 2.5] Katana crawler", subdomainClean, 10*60, func() error {
269+
return urls.RunKatanaPhase(subdomainClean)
270+
}); err != nil {
271+
logger.GetLogger().Infof("[WARN] Katana phase failed: %v", err)
272+
}
273+
265274
// Phase 3: Deep Scan Group (Parallel, requires Stage 2 - specifically URLs)
266275
var wgPhase3 sync.WaitGroup
267276

@@ -271,7 +280,7 @@ func RunSubdomainWithOptions(subdomain string, opts RunOptions) (*Result, error)
271280
return err
272281
})
273282

274-
runParallelPhase(&wgPhase3, "jsendpoints", "[Stage 3] JS endpoint extraction", 0, func() error {
283+
runParallelPhase(&wgPhase3, "js-endpoints", "[Stage 3] JS endpoint extraction", 0, func() error {
275284
_, err := jsendpoints.Run(jsendpoints.Options{Domain: subdomainClean, Threads: 30})
276285
return err
277286
})
@@ -320,6 +329,14 @@ func RunSubdomainWithOptions(subdomain string, opts RunOptions) (*Result, error)
320329

321330
wgPhase3.Wait()
322331

332+
// Phase 4: Dalfox XSS confirmation (sequential, needs reflection/kxss results from Phase 3)
333+
// Reads kxss-results.txt produced by the reflection phase above.
334+
if err := utils.RunWorkflowPhase("xss-detection", getNextStep(), totalSteps, "[Stage 4] Dalfox XSS confirmation", subdomainClean, 20*60, func() error {
335+
return reflection.RunDalfoxPhase(subdomainClean)
336+
}); err != nil {
337+
logger.GetLogger().Infof("[WARN] Dalfox XSS phase failed: %v", err)
338+
}
339+
323340
logger.GetLogger().Infof("[OK] Full subdomain scan completed for %s", subdomain)
324341

325342
// Get subdomain directory path (resultsDir already declared earlier)

internal/scanner/urls/urls.go

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,38 @@ type Result struct {
3333
InterestingFile string
3434
}
3535

36+
// RunKatanaPhase runs Katana crawling as a standalone pipeline phase for a domain.
37+
// Called explicitly by the subdomain workflow after URL collection completes.
38+
// Results are merged into all-urls.txt and persisted as katana-urls.json.
39+
func RunKatanaPhase(domain string) error {
40+
resultsDir := utils.GetResultsDir()
41+
dirDomain := extractRootDomain(domain)
42+
domainDir := filepath.Join(resultsDir, dirDomain)
43+
liveFile := filepath.Join(domainDir, "subs", "live-subs.txt")
44+
allFile := filepath.Join(filepath.Join(domainDir, "urls"), "all-urls.txt")
45+
46+
fi, err := os.Stat(liveFile)
47+
if err != nil || fi.Size() == 0 {
48+
return fmt.Errorf("no live hosts file found for %s", domain)
49+
}
50+
51+
kataURLs := runKatana(liveFile, domain)
52+
if len(kataURLs) == 0 {
53+
logger.GetLogger().Infof("[INFO] Katana: no URLs found for %s", domain)
54+
return nil
55+
}
56+
logger.GetLogger().Infof("[OK] Katana: Found %d URLs for %s", len(kataURLs), domain)
57+
58+
existing, _ := readLines(allFile)
59+
merged := uniqueStrings(append(existing, kataURLs...))
60+
_ = utils.WriteLines(allFile, merged)
61+
62+
if scanID := utils.GetCurrentScanID(); scanID != "" {
63+
_ = utils.WriteLinesAsJSON(scanID, dirDomain, "katana-crawler", "katana-urls.json", kataURLs)
64+
}
65+
return nil
66+
}
67+
3668
// CollectURLs ensures live hosts exist for a domain and then collects URLs and JS URLs
3769
// using external tools (urlfinder and jsfinder), mirroring modules/urls.sh behaviour.
3870
// If skipSubdomainEnum is true, it treats the input as a single subdomain and skips
@@ -143,21 +175,6 @@ func CollectURLs(domain string, threads int, skipSubdomainEnum bool) (*Result, e
143175
_ = utils.WriteLines(allFile, allURLs)
144176
}
145177

146-
// 2b) Katana crawling — fast JS-aware crawler for deeper endpoint discovery
147-
if fi, err2 := os.Stat(liveFile); err2 == nil && fi.Size() > 0 {
148-
kataURLs := runKatana(liveFile, domain)
149-
if len(kataURLs) > 0 {
150-
logger.GetLogger().Infof("[OK] Katana: Found %d URLs for %s", len(kataURLs), domain)
151-
existingURLs3, _ := readLines(allFile)
152-
merged := uniqueStrings(append(existingURLs3, kataURLs...))
153-
_ = utils.WriteLines(allFile, merged)
154-
// Persist Katana results as their own dashboard module
155-
if scanID := utils.GetCurrentScanID(); scanID != "" {
156-
_ = utils.WriteLinesAsJSON(scanID, dirDomain, "katana-crawler", "katana-urls.json", kataURLs)
157-
}
158-
}
159-
}
160-
161178
// 3) Collect JS URLs with embedded jsfinder over live hosts
162179
if fi, err := os.Stat(liveFile); err == nil && fi.Size() > 0 {
163180
logger.GetLogger().Infof("[INFO] Running embedded jsfinder on live hosts for %s", domain)

0 commit comments

Comments
 (0)