Skip to content

Commit d13622d

Browse files
authored
feat(scan): add -concurrency flag to scan targets in parallel (#367)
* refactor(cli): extract per-target scan into scanTarget pull the target loop body into scanTarget, which returns a targetScan holding that target's findings, report rows, scan labels and log files instead of appending onto run-wide slices. the run loop merges those in target order and finishRun handles the post-loop notify/silent/report/ summary steps. behavior is identical for the sequential run today; the isolation is the seam a bounded worker pool needs next. * feat(cli): scan targets in parallel with -concurrency add -concurrency N (default 1) to run the per-target scan pool over several targets at once. scanTarget already returns isolated accumulators, so workers share only the console: output.SetConcurrent serializes sink writes and de-animates spinners/progress so parallel lines stay whole. results merge in input order, and concurrency 1 keeps the sequential path unchanged.
1 parent 24f3481 commit d13622d

7 files changed

Lines changed: 638 additions & 325 deletions

File tree

internal/config/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ type Settings struct {
4040
Git bool
4141
Whois bool
4242
Threads int
43+
Concurrency int
4344
Nuclei bool
4445
JavaScript bool
4546
Timeout time.Duration
@@ -171,6 +172,7 @@ func registerFlags(settings *Settings) *goflags.FlagSet {
171172
flagSet.DurationVarP(&settings.Timeout, "timeout", "t", 10*time.Second, "HTTP request timeout"),
172173
flagSet.StringVarP(&settings.LogDir, "log", "l", "", "Directory to store logs in"),
173174
flagSet.IntVar(&settings.Threads, "threads", 10, "Number of threads to run scans on"),
175+
flagSet.IntVar(&settings.Concurrency, "concurrency", 1, "Number of targets to scan in parallel (>1 interleaves console output)"),
174176
flagSet.StringVar(&settings.Template, "template", "", "Load scan settings from a template (preset minimal/recon/full, or a local yaml file)"),
175177
)
176178

@@ -241,5 +243,11 @@ func Parse() *Settings {
241243
settings.Threads = minThreads
242244
}
243245

246+
// concurrency bounds the target worker pool; floor it so 0/negative means
247+
// sequential rather than a stalled or panicking pool.
248+
if settings.Concurrency < 1 {
249+
settings.Concurrency = 1
250+
}
251+
244252
return settings
245253
}

internal/output/output.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"io"
1818
"os"
1919
"strings"
20+
"sync"
2021

2122
"github.com/charmbracelet/lipgloss"
2223
)
@@ -154,6 +155,40 @@ func Silent() bool {
154155
return silent
155156
}
156157

158+
// concurrent is set when multiple targets are scanned in parallel. it serializes
159+
// sink writes so lines from different targets never garble, and disables live
160+
// widgets, which cannot share one terminal across goroutines.
161+
var concurrent bool
162+
163+
// SetConcurrent switches the sink into parallel-safe mode: writes go through a
164+
// mutex and interactive widgets (spinners, live progress) are gated off. call it
165+
// once before launching target workers; it is not meant to be toggled back.
166+
func SetConcurrent(enabled bool) {
167+
concurrent = enabled
168+
if enabled {
169+
sink = &lockingWriter{w: sink}
170+
}
171+
}
172+
173+
// Concurrent reports whether parallel-target mode is active; widget code gates on
174+
// it so spinners and progress bars stay silent when targets interleave.
175+
func Concurrent() bool {
176+
return concurrent
177+
}
178+
179+
// lockingWriter serializes concurrent writes to the wrapped sink so one target's
180+
// line is never interleaved mid-write with another's.
181+
type lockingWriter struct {
182+
mu sync.Mutex
183+
w io.Writer
184+
}
185+
186+
func (l *lockingWriter) Write(p []byte) (int, error) {
187+
l.mu.Lock()
188+
defer l.mu.Unlock()
189+
return l.w.Write(p)
190+
}
191+
157192
// Writer is the current chrome sink (stdout normally, stderr under -silent).
158193
// callers that render their own chrome (the startup banner) write here so it
159194
// follows the same routing as everything else.
@@ -286,7 +321,9 @@ func (m *ModuleLogger) Complete(resultCount int, resultType string) {
286321
// ClearLine clears the current line (for progress bar updates). silent mode is
287322
// non-interactive, so there's no live line to clear and stdout stays untouched.
288323
func ClearLine() {
289-
if !IsTTY || silent {
324+
// under -concurrency there is no single live line to clear, and emitting the
325+
// escape would wipe whatever another target just wrote.
326+
if !IsTTY || silent || concurrent {
290327
return
291328
}
292329
fmt.Fprint(sink, "\033[2K\r")

internal/output/progress.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ func (p *Progress) Resume() {
9191

9292
// Done clears the progress bar line
9393
func (p *Progress) Done() {
94-
if apiMode || !IsTTY {
94+
// under -concurrency there is no live line to clear (render printed milestones),
95+
// and clearing would wipe another target's output.
96+
if apiMode || !IsTTY || concurrent {
9597
return
9698
}
9799
ClearLine()
@@ -102,8 +104,9 @@ func (p *Progress) render() {
102104
return
103105
}
104106

105-
// In non-TTY mode, print progress at milestones only
106-
if !IsTTY {
107+
// In non-TTY mode, or under -concurrency, print progress at milestones only
108+
// rather than redrawing a live line that parallel targets would corrupt.
109+
if !IsTTY || concurrent {
107110
current := atomic.LoadInt64(&p.current)
108111
total := p.total
109112
if total <= 0 {

internal/output/spinner.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ func (s *Spinner) Start() {
5454
s.done = make(chan struct{})
5555
s.mu.Unlock()
5656

57-
// In non-TTY mode, just print the message once
58-
if !IsTTY {
57+
// In non-TTY mode, or when targets scan in parallel, just print the message
58+
// once: a shared animated line can't be driven from concurrent goroutines.
59+
if !IsTTY || concurrent {
5960
fmt.Fprintf(sink, " %s...\n", s.message)
6061
return
6162
}

0 commit comments

Comments
 (0)