From 5e491e7635be8340ffe2f1730e330785dbfda62f Mon Sep 17 00:00:00 2001
From: Christian Mehlmauer <105281+firefart@users.noreply.github.com>
Date: Mon, 31 Aug 2026 00:26:53 +0200
Subject: [PATCH 1/5] recursion?
---
README.md | 574 ++++++++------------------------
cli/dir/dir.go | 12 +
gobusterdir/gobusterdir.go | 58 +++-
gobusterdir/result.go | 15 +-
libgobuster/interfaces.go | 15 +
libgobuster/libgobuster.go | 89 ++++-
libgobuster/libgobuster_test.go | 125 +++++++
libgobuster/options.go | 3 +
8 files changed, 444 insertions(+), 447 deletions(-)
diff --git a/README.md b/README.md
index fb22a14c..7b4afa69 100644
--- a/README.md
+++ b/README.md
@@ -1,509 +1,213 @@
# Gobuster
-[](https://goreportcard.com/report/github.com/OJ/gobuster/v3) [](https://github.com/OJ/gobuster/blob/master/LICENSE) [](https://opencollective.com/gobuster) [](https://opencollective.com/gobuster)
+[](https://goreportcard.com/report/github.com/OJ/gobuster/v3)
+[](LICENSE)
+[](https://opencollective.com/gobuster)
-## 💻 Introduction
+Gobuster is a fast, flexible enumeration tool written in Go. It uses wordlists to
+discover directories and files, DNS names, virtual hosts, cloud storage buckets,
+and TFTP files. It can also fuzz values in URLs, headers, and request bodies.
-> A fast and flexible brute-forcing tool written in Go
+> Use Gobuster only against systems you own or have explicit permission to test.
-**Gobuster** is a high-performance directory/file, DNS and virtual host brute-forcing tool written in Go. It's designed to be fast, reliable, and easy to use for security professionals and penetration testers.
+## Modes
-## ✨ Features
+| Mode | Purpose |
+| ------- | --------------------------------------------------- |
+| `dir` | Discover directories and files on web servers |
+| `dns` | Discover DNS subdomains |
+| `vhost` | Discover virtual hosts on a web server |
+| `fuzz` | Replace `FUZZ` in URLs, headers, and request bodies |
+| `s3` | Enumerate Amazon S3 buckets |
+| `gcs` | Enumerate Google Cloud Storage buckets |
+| `tftp` | Discover files on TFTP servers |
-- 🚀 **High Performance**: Multi-threaded scanning with configurable concurrency
-- 🔍 **Multiple Modes**: Directory, DNS, virtual host, S3, GCS, TFTP, and fuzzing modes
-- 🛡️ **Security Focused**: Built for penetration testing and security assessments
-- 🐳 **Docker Support**: Available as a Docker container
-- 🔧 **Extensible**: Pattern-based scanning and custom wordlists
+## Installation
-## 🎯 What Can Gobuster Do?
+### Go
-- **Web Directory/File Enumeration**: Discover hidden directories and files on web servers
-- **DNS Subdomain Discovery**: Find subdomains with wildcard support
-- **Virtual Host Detection**: Identify virtual hosts on target web servers
-- **Cloud Storage Enumeration**: Discover open Amazon S3 and Google Cloud Storage buckets
-- **TFTP File Discovery**: Find files on TFTP servers
-- **Custom Fuzzing**: Flexible fuzzing with customizable parameters
+Gobuster requires Go 1.27 or newer.
-## 🚀 Quick Start
-
-```bash
-# Install gobuster
+```console
go install github.com/OJ/gobuster/v3@latest
-
-# Basic directory enumeration
-gobuster dir -u https://example.com -w /path/to/wordlist.txt
-
-# DNS subdomain enumeration
-gobuster dns -do example.com -w /path/to/wordlist.txt
-
-# Virtual host discovery
-gobuster vhost -u https://example.com -w /path/to/wordlist.txt
-
-# S3 bucket enumeration
-gobuster s3 -w /path/to/bucket-names.txt
```
-## 📦 Installation
-
-### Quick Install (Recommended)
-
-```bash
-go install github.com/OJ/gobuster/v3@latest
-```
+Make sure the Go binary directory is in your `PATH`. You can find it with
+`go env GOBIN`; when that value is empty, Go uses `$(go env GOPATH)/bin`.
-**Requirements**: Go 1.24 or higher
+### Prebuilt binaries
-### Alternative Installation Methods
+Download an archive for your platform from the
+[GitHub releases page](https://github.com/OJ/gobuster/releases).
-#### Using Binary Releases
+### Docker
-Download pre-compiled binaries from the [releases page](https://github.com/OJ/gobuster/releases).
-
-#### Using Docker
-
-```bash
-# Pull the latest image
+```console
docker pull ghcr.io/oj/gobuster:latest
-
-# Run gobuster in Docker
-docker run --rm -it ghcr.io/oj/gobuster:latest dir -u https://example.com -w /usr/share/wordlists/dirb/common.txt
-```
-
-#### Building from Source
-
-```bash
-git clone https://github.com/OJ/gobuster.git
-cd gobuster
-go mod tidy
-go build
+docker run --rm -it \
+ -v "$PWD/wordlists:/wordlists:ro" \
+ ghcr.io/oj/gobuster:latest \
+ dir -u https://example.com -w /wordlists/common.txt
```
-### Troubleshooting Installation
+## Quick start
-If you encounter issues:
+Every mode has its own options. Start with the built-in help when exploring a
+new mode:
-- Ensure Go version 1.24+ is installed: `go version`
-- Check your `$GOPATH` and `$GOBIN` environment variables
-- Verify `$GOPATH/bin` is in your `$PATH`
-
-## 🎯 Usage
-
-Gobuster uses a mode-based approach. Each mode is designed for specific enumeration tasks:
-
-```bash
-gobuster [mode] [options]
+```console
+gobuster --help
+gobuster dir --help
```
-### Getting Help
-
-```bash
-gobuster help # Show general help
-gobuster help [mode] # Show help for specific mode
-gobuster [mode] --help # Alternative help syntax
-```
-
-### 📊 Available Modes
-
-#### 🌐 Directory Mode (`dir`)
-
-Enumerate directories and files on web servers.
-
-**Basic Usage:**
+### Directory and file discovery
-```bash
+```console
gobuster dir -u https://example.com -w wordlist.txt
```
-**Advanced Options:**
-
-```bash
-# With file extensions
-gobuster dir -u https://example.com -w wordlist.txt -x php,html,js,txt
-
-# With custom headers and cookies
-gobuster dir -u https://example.com -w wordlist.txt -H "Authorization: Bearer token" -c "session=value"
-
-# Show response length
-gobuster dir -u https://example.com -w wordlist.txt -l
-
-# Filter by status codes
-gobuster dir -u https://example.com -w wordlist.txt -s 200,301,302
-
-# Filter using a regex against the response body
-# This can be handy for websites that return status code 200 for everything, but the html contains an error message
-gobuster dir -u https://example.com -w wordlist.txt -re "error\shello"
-
-# Filter using a regex but inverted against the response body
-gobuster dir -u https://example.com -w wordlist.txt -rei "(?i)\berror\b"
-```
-
-#### 🔍 DNS Mode (`dns`)
+Add extensions, choose accepted status codes, and write results to a file:
-Discover subdomains through DNS resolution.
-
-**Basic Usage:**
-
-```bash
-gobuster dns -do example.com -w wordlist.txt
+```console
+gobuster dir \
+ -u https://example.com \
+ -w wordlist.txt \
+ -x php,html,js \
+ -s 200,204,301,302,307,401,403 \
+ -b "" \
+ -o results.txt
```
-**Advanced Options:**
-
-```bash
-# Use custom DNS server
-gobuster dns -do example.com -w wordlist.txt -r 8.8.8.8:53
-
-# Increase threads for faster scanning
-gobuster dns -do example.com -w wordlist.txt -t 50
-```
+The status-code blacklist defaults to `404` and overrides the positive list, so
+explicitly clear it with `-b ""` when using `-s`.
-#### 🏠 Virtual Host Mode (`vhost`)
+Useful directory-mode options include:
-Discover virtual hosts on web servers.
+- `-H 'Name: value'` to add a header; repeat it for multiple headers
+- `-c 'name=value'` to send cookies
+- `-U user -P password` for HTTP Basic authentication
+- `-x php,html` to try file extensions
+- `--exclude-length 123,456-500` to ignore response sizes
+- `--regex PATTERN` or `--regex-invert PATTERN` to filter response bodies
+- `--recursive` to scan discovered directories recursively
+- `--body-output-dir PATH` to save response bodies
+- `-k` to skip TLS certificate verification
-**Basic Usage:**
+### DNS discovery
-```bash
-gobuster vhost -u https://example.com --append-domain -w wordlist.txt
+```console
+gobuster dns --domain example.com -w subdomains.txt
```
-#### ☁️ S3 Mode (`s3`)
+Use a custom resolver or inspect CNAME records:
-Enumerate Amazon S3 buckets.
-
-**Basic Usage:**
-
-```bash
-gobuster s3 -w bucket-names.txt
+```console
+gobuster dns --domain example.com -w subdomains.txt --resolver 1.1.1.1
+gobuster dns --domain example.com -w subdomains.txt --check-cname
```
-**With Debug Output:**
+### Virtual-host discovery
-```bash
-gobuster s3 -w bucket-names.txt --debug
+```console
+gobuster vhost -u https://example.com -w hosts.txt --append-domain
```
-#### 🖥️ TFTP Mode (`tftp`)
+Point `-u` at the server you want to test. Use `--append-domain` when the
+wordlist contains prefixes such as `admin` rather than complete hostnames.
-Enumerate files on tftp servers.
+### Fuzzing
-**Basic Usage:**
+Put the literal marker `FUZZ` wherever Gobuster should substitute each wordlist
+entry:
-```bash
-gobuster tftp -s 10.0.0.1 -w wordlist.txt
+```console
+gobuster fuzz -u 'https://example.com/?page=FUZZ' -w values.txt
+gobuster fuzz -u https://example.com -H 'X-Api-Version: FUZZ' -w versions.txt
+gobuster fuzz -u https://example.com/login -m POST \
+ -H 'Content-Type: application/x-www-form-urlencoded' \
+ -B 'username=admin&password=FUZZ' \
+ -w passwords.txt
```
-#### ☁️ GCS Mode (`gcs`)
-
-Enumerate Google Cloud Storage Buckets.
+### Cloud storage and TFTP
-**Basic Usage:**
-
-```bash
+```console
+gobuster s3 -w bucket-names.txt
gobuster gcs -w bucket-names.txt
+gobuster tftp -s 192.0.2.10 -w filenames.txt
```
-**With Debug Output:**
-
-```bash
-gobuster gcs -w bucket-names.txt --debug
-```
-
-#### 🔧 Fuzz Mode (`fuzz`)
-
-Custom fuzzing with the `FUZZ` keyword.
+## Controlling a scan
-**Basic Usage:**
+The following options are shared by most modes:
-```bash
-gobuster fuzz -u https://example.com?FUZZ=test -w wordlist.txt
-```
+| Option | Description |
+| ---------------- | --------------------------------------------------------- |
+| `-w, --wordlist` | Wordlist path; use `-` to read from standard input |
+| `-t, --threads` | Number of concurrent workers (default: `10`) |
+| `-d, --delay` | Delay applied by each worker, such as `250ms` |
+| `--timeout` | Network timeout, such as `15s` |
+| `-o, --output` | Write discovered results to a file |
+| `-q, --quiet` | Print results without the banner and informational output |
+| `--no-progress` | Disable the progress display |
+| `--debug` | Enable diagnostic output |
-**Advanced Examples:**
+Start conservatively and increase concurrency only when the target can handle
+it. A delay is often more useful than a very high thread count when testing
+rate-limited services.
-```bash
-# Fuzz URL parameters
-gobuster fuzz -u https://example.com?param=FUZZ -w wordlist.txt
+## Patterns
-# Fuzz headers
-gobuster fuzz -u https://example.com -H "X-Custom-Header: FUZZ" -w wordlist.txt
+`--pattern` expands every wordlist entry through a pattern file. Each occurrence
+of `{GOBUSTER}` is replaced with the current word:
-# Fuzz POST data
-gobuster fuzz -u https://example.com -d "username=admin&password=FUZZ" -w passwords.txt
+```text
+{GOBUSTER}-dev
+{GOBUSTER}-staging
+api-{GOBUSTER}
```
-## 💰 Support
-
-[](https://opencollective.com/gobuster) [](https://opencollective.com/gobuster)
-
-### Love this tool? Back it!
-
-If you're backing us already, you rock. If you're not, that's cool too! Want to back us? [Become a backer](https://opencollective.com/gobuster#backer)!
-
-[](https://opencollective.com/gobuster#backers)
-
-All funds that are donated to this project will be donated to charity. A full log of charity donations will be available in this repository as they are processed.
-
-## 💡 Common Use Cases
-
-### Web Application Security Testing
-
-```bash
-# Comprehensive directory enumeration
-gobuster dir -u https://target.com -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x php,html,js,txt,asp,aspx,jsp
-
-# API endpoint discovery
-gobuster dir -u https://api.target.com -w /usr/share/wordlists/dirb/common.txt -x json
-
-# Admin panel discovery
-gobuster dir -u https://target.com -w admin-panels.txt -s 200,301,302,403
+```console
+gobuster dns --domain example.com -w words.txt --pattern patterns.txt
```
-### DNS Reconnaissance
+Patterns multiply the number of requests, so review the pattern file before a
+large scan. `--discover-pattern` applies a pattern file only to successful
+guesses.
-```bash
-# Comprehensive subdomain enumeration
-gobuster dns -do target.com -w /usr/share/wordlists/dnsrecon/subdomains-top1mil-5000.txt -t 50
-```
-
-### Cloud Storage Assessment
+## Building from source
-```bash
-# S3 bucket enumeration with patterns
-gobuster s3 -w company-names.txt -v
+Clone the repository and use [Task](https://taskfile.dev/) for the standard
+development workflow:
-# GCS bucket enumeration
-gobuster gcs -w company-names.txt -v
+```console
+git clone https://github.com/OJ/gobuster.git
+cd gobuster
+task build
```
-## 🔧 Troubleshooting
-
-### Common Issues
-
-#### "Permission Denied" or "Access Denied"
-
-- Try reducing thread count with `-t` flag
-- Add delays between requests with `--delay`
-- Use different user agent with `-a` flag
-
-#### "Connection Timeout"
+Common development commands:
-- Increase timeout with `--timeout` flag
-- Reduce thread count for slower targets
-- Check your internet connection
-
-#### "No Results Found"
-
-- Verify the target URL is accessible
-- Try different wordlists
-- Check status code filtering with `-s` flag
-
-### Performance Issues
-
-#### Slow Scanning
-
-- Increase thread count with `-t` flag (but be careful not to overwhelm the target)
-- Use smaller, more targeted wordlists
-
-## 🎯 Best Practices
-
-### Security Testing Guidelines
-
-1. **Always get proper authorization** before testing any target
-2. **Start with low thread counts** to avoid overwhelming servers
-3. **Use appropriate wordlists** for the target technology
-4. **Respect rate limits** and implement delays if needed
-5. **Monitor your network traffic** to avoid detection
-
-### Wordlist Selection
-
-- **For web applications**: Use technology-specific wordlists (PHP, ASP.NET, etc.)
-- **For APIs**: Focus on common API endpoints and versioning patterns
-- **For DNS**: Use subdomain-specific wordlists with common patterns
-- **For cloud storage**: Use company/brand-specific patterns
-
-### Output Management
-
-```bash
-# Save results to file
-gobuster dir -u https://example.com -w wordlist.txt -o results.txt
-
-# Use quiet mode for clean output
-gobuster dir -u https://example.com -w wordlist.txt -q
+```console
+task test # format, vet, and run tests with race detection and coverage
+task check # format, run gofumpt, vet, and apply Go fixes
+task lint # run golangci-lint and verify module files are tidy
+task linux # build a Linux AMD64 binary
+task windows # build a Windows AMD64 binary
```
-## 📚 Additional Resources
-
-### Recommended Wordlists
-
-- **SecLists**: [https://github.com/danielmiessler/SecLists](https://github.com/danielmiessler/SecLists)
-- **FuzzDB**: [https://github.com/fuzzdb-project/fuzzdb](https://github.com/fuzzdb-project/fuzzdb)
-- **Seclists DNS**: [https://github.com/danielmiessler/SecLists/tree/master/Discovery/DNS](https://github.com/danielmiessler/SecLists/tree/master/Discovery/DNS)
-
----
-
-**Happy hacking! 🚀**
-
-_Remember: Always test responsibly and with proper authorization._
-
-# Changes
-
-
-
-3.8.3
-
-## 3.8.3
-
-- Add option to filter body by regex
-- Add option to save response bodies
-- Allow comma in Header values passed via the CLI
-
-
-
-
-
-3.8.2
-
-## 3.8.2
-
-- Fix expanded mode to show the full url again
-
-
-
-
-
-3.8.1
-
-## 3.8.1
-
-- Fix expanded mode showing the entries twice
-
-
-
-
-
-3.8
-
-## 3.8
-
-- Add exclude-hostname-length flag to dynamically adjust exclude-length by @0xyy66
-- Fix Fuzzing query parameters
-- Add `--force` flag in `dir` mode to continue execution if precheck errors occur
-
-
-
-
-
-3.7
-
-## 3.7
-
-- use new cli library
-- a lot more short options due to the new cli library
-- more user friendly error messages
-- clean up DNS mode
-- renamed `show-cname` to `check-cname` in dns mode
-- got rid of `verbose` flag and introduced `debug` instead
-- the version command now also shows some build variables for more info
-- switched to another pkcs12 library to support p12s generated with openssl3 that use SHA256 HMAC
-- comments in wordlists (strings starting with #) are no longer ignored
-- warn in vhost mode if the --append-domain switch might have been forgotten
-- allow to exclude status code and length in vhost mode
-- added automaxprocs for use in docker with cpu limits
-- log http requests with debug enabled
-- allow fuzzing of Host header in fuzz mode
-- automatically disable progress output when output is redirected
-- fix extra special characters when run with `--no-progress`
-- warn when using vhost mode with a proxy and http based urls as this might not work as expected
-- add `interface` and `local-ip` parameters to specify the outgoing interface for http requests
-- add support for tls renegotiation
-- fix progress with patterns by @acammack
-- fix backup discovery by @acammack
-- support tcp protocol on dns servers
-- add support for URL query parameters
-
-
-
-
-3.6
-
-## 3.6
-
-- Wordlist offset parameter to skip x lines from the wordlist
-- prevent double slashes when building up an url in dir mode
-- allow for multiple values and ranges on `--exclude-length`
-- `no-fqdn` parameter on dns bruteforce to disable the use of the systems search domains. This should speed up the run if you have configured some search domains. [https://github.com/OJ/gobuster/pull/418](https://github.com/OJ/gobuster/pull/418)
-
-
-
-
-3.5
-
-## 3.5
-
-- Allow Ranges in status code and status code blacklist. Example: 200,300-305,404
-
-
-
-
-3.4
-
-## 3.4
-
-- Enable TLS1.0 and TLS1.1 support
-- Add TFTP mode to search for files on tftp servers
-
-
-
-
-3.3
-
-## 3.3
-
-- Support TLS client certificates / mtls
-- support loading extensions from file
-- support fuzzing POST body, HTTP headers and basic auth
-- new option to not canonicalize header names
-
-
-
-
-3.2
-
-## 3.2
-
-- Use go 1.19
-- use contexts in the correct way
-- get rid of the wildcard flag (except in DNS mode)
-- color output
-- retry on timeout
-- google cloud bucket enumeration
-- fix nil reference errors
-
-
-
-
-3.1
-
-## 3.1
-
-- enumerate public AWS S3 buckets
-- fuzzing mode
-- specify HTTP method
-- added support for patterns. You can now specify a file containing patterns that are applied to every word, one by line. Every occurrence of the term `{GOBUSTER}` in it will be replaced with the current wordlist item. Please use with caution as this can cause increase the number of requests issued a lot.
-- The shorthand `p` flag which was assigned to proxy is now used by the pattern flag
-
-
+Bug reports should include the Gobuster version, the mode and command used
+(with secrets removed), and relevant debug output. Open an issue before making
+a large behavioral change.
-
-3.0
+## Support
-## 3.0
+Gobuster is maintained by Christian Mehlmauer
+([@firefart](https://github.com/firefart)) and OJ Reeves
+([@TheColonial](https://github.com/TheColonial)). You can support development
+through [Open Collective](https://opencollective.com/gobuster). Project funds
+are donated to charity.
-- New CLI options so modes are strictly separated (`-m` is now gone!)
-- Performance Optimizations and better connection handling
-- Ability to enumerate vhost names
-- Option to supply custom HTTP headers
+## License
-
+Gobuster is available under the [Apache License 2.0](LICENSE).
diff --git a/cli/dir/dir.go b/cli/dir/dir.go
index c8861df9..0c0817c9 100644
--- a/cli/dir/dir.go
+++ b/cli/dir/dir.go
@@ -35,6 +35,9 @@ func getFlags() []cli.Flag {
&cli.BoolFlag{Name: "hide-length", Aliases: []string{"hl"}, Value: false, Usage: "Hide the length of the body in the output"},
&cli.BoolFlag{Name: "add-slash", Aliases: []string{"f"}, Value: false, Usage: "Append / to each request"},
&cli.BoolFlag{Name: "discover-backup", Aliases: []string{"db"}, Value: false, Usage: "Upon finding a file search for backup files by appending multiple backup extensions"},
+ &cli.BoolFlag{Name: "recursive", Usage: "Recursively scan discovered directories"},
+ &cli.IntFlag{Name: "recursion-depth", Value: 5, Usage: "Maximum recursion depth (0 for unlimited)"},
+ &cli.IntFlag{Name: "recursion-max-targets", Value: 1000, Usage: "Maximum number of discovered targets (0 for unlimited)"},
&cli.StringFlag{Name: "exclude-length", Aliases: []string{"xl"}, Usage: "exclude the following content lengths (completely ignores the status). You can separate multiple lengths by comma and it also supports ranges like 203-206"},
&cli.BoolFlag{Name: "force", Value: false, Usage: "Continue even if the prechecks fail. Please only use this if you know what you are doing, it can lead to unexpected results."},
&cli.StringFlag{Name: "regex", Aliases: []string{"re"}, Usage: "Use regex to filter the results, by inspecting the content of the response body. When using this option be sure to set the status-codes and status-codes-blacklist options accordingly. The regex check is done after the status code checks. Only responses matching the regex will be displayed."},
@@ -48,6 +51,15 @@ func run(c *cli.Context) error {
if err != nil {
return err
}
+ globalOpts.Recursion = c.Bool("recursive")
+ globalOpts.RecursionDepth = c.Int("recursion-depth")
+ globalOpts.RecursionMaxTargets = c.Int("recursion-max-targets")
+ if globalOpts.RecursionDepth < 0 {
+ return errors.New("recursion-depth must be bigger or equal to 0")
+ }
+ if globalOpts.RecursionMaxTargets < 0 {
+ return errors.New("recursion-max-targets must be bigger or equal to 0")
+ }
log := libgobuster.NewLogger(globalOpts.Debug)
pluginOpts := gobusterdir.NewOptions()
diff --git a/gobusterdir/gobusterdir.go b/gobusterdir/gobusterdir.go
index bd683711..13df4ede 100644
--- a/gobusterdir/gobusterdir.go
+++ b/gobusterdir/gobusterdir.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
"os"
"path/filepath"
"strings"
@@ -49,6 +50,7 @@ type GobusterDir struct {
options *OptionsDir
globalopts *libgobuster.Options
http *libgobuster.HTTPClient
+ rootURL *url.URL
}
// New creates a new initialized GobusterDir
@@ -65,6 +67,10 @@ func New(globalopts *libgobuster.Options, opts *OptionsDir, logger *libgobuster.
options: opts,
globalopts: globalopts,
}
+ if opts.URL != nil {
+ rootURL := *opts.URL
+ g.rootURL = &rootURL
+ }
basicOptions := libgobuster.BasicHTTPOptions{
Proxy: opts.Proxy,
@@ -99,6 +105,28 @@ func New(globalopts *libgobuster.Options, opts *OptionsDir, logger *libgobuster.
return &g, nil
}
+// SetTarget changes the base URL between recursive scans. The orchestrator
+// calls this only after all workers for the previous target have stopped.
+func (d *GobusterDir) SetTarget(target string) error {
+ u, err := url.Parse(target)
+ if err != nil {
+ return err
+ }
+ if d.rootURL == nil {
+ return errors.New("initial URL is not set")
+ }
+ if !strings.EqualFold(u.Scheme, d.rootURL.Scheme) || !strings.EqualFold(u.Host, d.rootURL.Host) {
+ return errors.New("recursive target must have the same scheme and host as the initial URL")
+ }
+ u.RawQuery = ""
+ u.Fragment = ""
+ if !strings.HasSuffix(u.Path, "/") {
+ u.Path += "/"
+ }
+ d.options.URL = u
+ return nil
+}
+
// Name should return the name of the plugin
func (d *GobusterDir) Name() string {
return "directory enumeration"
@@ -336,7 +364,11 @@ func (d *GobusterDir) ProcessWord(ctx context.Context, word string, progress *li
}
if resultStatus && !d.options.ExcludeLengthParsed.Contains(int(size)) {
- path := fmt.Sprintf("%-20s", entity)
+ displayPath := entity
+ if d.globalopts.Recursion {
+ displayPath = fmt.Sprintf("%s%s", d.options.URL.Path, entity)
+ }
+ path := fmt.Sprintf("%-20s", displayPath)
if d.options.Expanded {
// expanded mode should show the full url
path = url.String()
@@ -348,6 +380,15 @@ func (d *GobusterDir) ProcessWord(ctx context.Context, word string, progress *li
StatusCode: -1,
Size: -1,
}
+ if d.globalopts.Recursion && d.isDirectoryCandidate(word) {
+ recursionURL := url
+ if !strings.HasSuffix(recursionURL.Path, "/") {
+ recursionURL.Path += "/"
+ }
+ recursionURL.RawQuery = ""
+ recursionURL.Fragment = ""
+ r.recursionTarget = recursionURL.String()
+ }
if !d.options.NoStatus {
r.StatusCode = statusCode
}
@@ -361,6 +402,15 @@ func (d *GobusterDir) ProcessWord(ctx context.Context, word string, progress *li
return nil, nil // nolint:nilnil
}
+func (d *GobusterDir) isDirectoryCandidate(word string) bool {
+ for ext := range d.options.ExtensionsParsed.Set {
+ if strings.HasSuffix(word, "."+ext) {
+ return false
+ }
+ }
+ return true
+}
+
// GetConfigString returns the string representation of the current config
func (d *GobusterDir) GetConfigString() (string, error) {
var buffer bytes.Buffer
@@ -385,6 +435,12 @@ func (d *GobusterDir) GetConfigString() (string, error) {
}
}
+ if d.globalopts.Recursion {
+ if _, err := fmt.Fprintf(tw, "[+] Recursion:\tenabled (depth %d, max targets %d)\n", d.globalopts.RecursionDepth, d.globalopts.RecursionMaxTargets); err != nil {
+ return "", err
+ }
+ }
+
wordlist := "stdin (pipe)"
if d.globalopts.Wordlist != "-" {
wordlist = d.globalopts.Wordlist
diff --git a/gobusterdir/result.go b/gobusterdir/result.go
index a1b015e9..fbc8536c 100644
--- a/gobusterdir/result.go
+++ b/gobusterdir/result.go
@@ -19,10 +19,17 @@ var (
// Result represents a single result
type Result struct {
- Path string
- Header http.Header
- StatusCode int
- Size int64
+ Path string
+ Header http.Header
+ StatusCode int
+ Size int64
+ recursionTarget string
+}
+
+// RecursiveTarget returns the next URL to scan, or an empty string when this
+// result represents a file rather than a directory candidate.
+func (r Result) RecursiveTarget() string {
+ return r.recursionTarget
}
// ResultToString converts the Result to its textual representation
diff --git a/libgobuster/interfaces.go b/libgobuster/interfaces.go
index 46e21e76..d96ccf57 100644
--- a/libgobuster/interfaces.go
+++ b/libgobuster/interfaces.go
@@ -17,3 +17,18 @@ type GobusterPlugin interface {
type Result interface {
ResultToString() (string, error)
}
+
+// RecursiveResult is implemented by results which can seed another scan.
+// An empty target means that the result must not be recursed into.
+type RecursiveResult interface {
+ Result
+ RecursiveTarget() string
+}
+
+// RecursivePlugin is implemented by plugins which can change their target
+// between scans. SetTarget is only called after the previous scan has fully
+// stopped, so implementations do not need to synchronize target access.
+type RecursivePlugin interface {
+ GobusterPlugin
+ SetTarget(string) error
+}
diff --git a/libgobuster/libgobuster.go b/libgobuster/libgobuster.go
index 91f1cc1d..00b7ecaf 100644
--- a/libgobuster/libgobuster.go
+++ b/libgobuster/libgobuster.go
@@ -39,6 +39,11 @@ type Guess struct {
discoverOnSuccess bool
}
+type successfulGuess struct {
+ guess *Guess
+ result Result
+}
+
type Wordlist struct {
scanner *bufio.Scanner
guessesPerLine int
@@ -56,7 +61,7 @@ func NewGobuster(opts *Options, plugin GobusterPlugin, logger *Logger) (*Gobuste
return &g, nil
}
-func (g *Gobuster) worker(ctx context.Context, guessChan <-chan *Guess, successChan chan<- *Guess, wg *sync.WaitGroup) {
+func (g *Gobuster) worker(ctx context.Context, guessChan <-chan *Guess, successChan chan<- successfulGuess, wg *sync.WaitGroup) {
defer wg.Done()
for {
// Prioritize stopping when the context is done
@@ -69,6 +74,9 @@ func (g *Gobuster) worker(ctx context.Context, guessChan <-chan *Guess, successC
case <-ctx.Done():
return
case guess := <-guessChan:
+ if guess == nil {
+ return
+ }
// Mode-specific processing
res, err := g.plugin.ProcessWord(ctx, guess.word, g.Progress)
@@ -84,7 +92,7 @@ func (g *Gobuster) worker(ctx context.Context, guessChan <-chan *Guess, successC
case <-ctx.Done():
g.Progress.incrementRequests()
return
- case successChan <- guess:
+ case successChan <- successfulGuess{guess: guess, result: res}:
}
}
@@ -234,6 +242,70 @@ func (g *Gobuster) Run(ctx context.Context) error {
defer close(g.Progress.ErrorChan)
defer close(g.Progress.MessageChan)
+ if !g.Opts.Recursion {
+ return g.runTarget(ctx, nil)
+ }
+ plugin, ok := g.plugin.(RecursivePlugin)
+ if !ok {
+ return errors.New("the selected plugin does not support recursion")
+ }
+ if g.Opts.Wordlist == "-" {
+ return errors.New("recursion is not supported with a wordlist read from stdin")
+ }
+
+ type target struct {
+ url string
+ depth int
+ }
+ queue := []target{{}}
+ seen := make(map[string]struct{})
+ for len(queue) > 0 {
+ current := queue[0]
+ queue = queue[1:]
+ if current.url != "" {
+ if err := plugin.SetTarget(current.url); err != nil {
+ return fmt.Errorf("failed to set recursive target %q: %w", current.url, err)
+ }
+ }
+
+ var discovered []string
+ if err := g.runTarget(ctx, func(result Result) {
+ recursiveResult, ok := result.(RecursiveResult)
+ if !ok {
+ return
+ }
+ targetURL := recursiveResult.RecursiveTarget()
+ if targetURL != "" {
+ discovered = append(discovered, targetURL)
+ }
+ }); err != nil {
+ return err
+ }
+ if err := ctx.Err(); err != nil {
+ return nil
+ }
+ if g.Opts.RecursionDepth > 0 && current.depth >= g.Opts.RecursionDepth {
+ continue
+ }
+ for _, targetURL := range discovered {
+ if _, exists := seen[targetURL]; exists {
+ continue
+ }
+ seen[targetURL] = struct{}{}
+ if g.Opts.RecursionMaxTargets > 0 && len(seen) > g.Opts.RecursionMaxTargets {
+ return fmt.Errorf("recursive target limit of %d exceeded", g.Opts.RecursionMaxTargets)
+ }
+ queue = append(queue, target{url: targetURL, depth: current.depth + 1})
+ }
+ }
+ return nil
+}
+
+// runTarget runs one complete wordlist against the plugin's current target.
+// It does not close the public channels; Run owns their lifetime across all
+// recursive targets.
+func (g *Gobuster) runTarget(ctx context.Context, onResult func(Result)) error {
+
if err := g.plugin.PreRun(ctx, g.Progress); err != nil {
return err
}
@@ -247,7 +319,7 @@ func (g *Gobuster) Run(ctx context.Context) error {
workerGroup.Add(g.Opts.Threads)
guessChan := make(chan *Guess, g.Opts.Threads*3)
- successChan := make(chan *Guess)
+ successChan := make(chan successfulGuess)
scanDone := make(chan error, 1)
var f io.ReadSeekCloser
@@ -293,17 +365,20 @@ ListenForMore:
if scanErr != nil {
break ListenForMore
}
- case successGuess := <-successChan:
+ case success := <-successChan:
+ if onResult != nil && success.guess.discoverOnSuccess {
+ onResult(success.result)
+ }
// Add more guesses based on the results of previous attempts
- if successGuess.discoverOnSuccess {
- discoverWords := g.plugin.AdditionalSuccessWords(successGuess.word)
+ if success.guess.discoverOnSuccess {
+ discoverWords := g.plugin.AdditionalSuccessWords(success.guess.word)
if len(discoverWords) > 0 {
g.Progress.IncrementTotalRequests(len(discoverWords))
feederGroup.Add(1)
go g.feeder(feederCtx, guessChan, discoverWords, false, &feederGroup)
}
- patternDiscoverWords := g.processDiscoverPatterns(successGuess.word)
+ patternDiscoverWords := g.processDiscoverPatterns(success.guess.word)
if len(patternDiscoverWords) > 0 {
g.Progress.IncrementTotalRequests(len(patternDiscoverWords))
feederGroup.Add(1)
diff --git a/libgobuster/libgobuster_test.go b/libgobuster/libgobuster_test.go
index 8f65b9ca..f78f853d 100644
--- a/libgobuster/libgobuster_test.go
+++ b/libgobuster/libgobuster_test.go
@@ -4,6 +4,7 @@ import (
"context"
"os"
"strings"
+ "sync"
"testing"
"time"
)
@@ -15,6 +16,130 @@ func (testPlugin) PreRun(context.Context, *Progress) error { return nil }
func (testPlugin) ProcessWord(context.Context, string, *Progress) (Result, error) {
return nil, nil //nolint:nilnil // A test plugin intentionally produces no result.
}
+
+type recursiveTestResult string
+
+func (recursiveTestResult) ResultToString() (string, error) { return "", nil }
+func (r recursiveTestResult) RecursiveTarget() string { return string(r) }
+
+type recursiveTestPlugin struct {
+ mu sync.Mutex
+ target string
+ started []string
+ distinctTargets bool
+}
+
+func (*recursiveTestPlugin) Name() string { return "recursive test" }
+func (p *recursiveTestPlugin) PreRun(context.Context, *Progress) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.started = append(p.started, p.target)
+ return nil
+}
+func (p *recursiveTestPlugin) ProcessWord(_ context.Context, word string, _ *Progress) (Result, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ switch p.target {
+ case "root":
+ if p.distinctTargets {
+ return recursiveTestResult(word), nil
+ }
+ return recursiveTestResult("child"), nil
+ case "child":
+ return recursiveTestResult("grandchild"), nil
+ default:
+ return nil, nil //nolint:nilnil
+ }
+}
+
+func TestRunRecursionEnforcesTargetLimit(t *testing.T) {
+ wordlist := t.TempDir() + "/words.txt"
+ if err := os.WriteFile(wordlist, []byte("one\ntwo\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ plugin := &recursiveTestPlugin{target: "root", distinctTargets: true}
+ g, err := NewGobuster(&Options{
+ Threads: 1, Wordlist: wordlist, Recursion: true,
+ RecursionMaxTargets: 1,
+ }, plugin, NewLogger(false))
+ if err != nil {
+ t.Fatal(err)
+ }
+ drainProgress(g.Progress)
+ if err := g.Run(t.Context()); err == nil || !strings.Contains(err.Error(), "target limit") {
+ t.Fatalf("expected recursive target limit error, got %v", err)
+ }
+}
+func (*recursiveTestPlugin) AdditionalWords(string) []string { return nil }
+func (*recursiveTestPlugin) AdditionalWordsLen() int { return 0 }
+func (*recursiveTestPlugin) AdditionalSuccessWords(string) []string { return nil }
+func (*recursiveTestPlugin) GetConfigString() (string, error) { return "", nil }
+func (p *recursiveTestPlugin) SetTarget(target string) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.target = target
+ return nil
+}
+
+func TestRunRecursionIsSequentialDeduplicatedAndDepthLimited(t *testing.T) {
+ wordlist := t.TempDir() + "/words.txt"
+ if err := os.WriteFile(wordlist, []byte("one\ntwo\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ plugin := &recursiveTestPlugin{target: "root"}
+ g, err := NewGobuster(&Options{
+ Threads: 2, Wordlist: wordlist, Recursion: true,
+ RecursionDepth: 1, RecursionMaxTargets: 10,
+ }, plugin, NewLogger(false))
+ if err != nil {
+ t.Fatal(err)
+ }
+ drainProgress(g.Progress)
+ if err := g.Run(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ plugin.mu.Lock()
+ defer plugin.mu.Unlock()
+ if got, want := strings.Join(plugin.started, ","), "root,child"; got != want {
+ t.Fatalf("scanned targets %q, want %q", got, want)
+ }
+}
+
+func TestRunRejectsRecursionForUnsupportedPlugin(t *testing.T) {
+ wordlist := t.TempDir() + "/words.txt"
+ if err := os.WriteFile(wordlist, []byte("one\ntwo\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ plugin := &recursiveTestPlugin{target: "root"}
+ g, err := NewGobuster(&Options{
+ Threads: 1, Wordlist: wordlist, Recursion: true,
+ RecursionMaxTargets: 0,
+ }, plugin, NewLogger(false))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Use a non-recursive plugin to verify the capability check independently.
+ g.plugin = testPlugin{}
+ drainProgress(g.Progress)
+ if err := g.Run(t.Context()); err == nil || !strings.Contains(err.Error(), "does not support recursion") {
+ t.Fatalf("expected unsupported recursion error, got %v", err)
+ }
+}
+
+func drainProgress(progress *Progress) {
+ go func() {
+ for range progress.ResultChan {
+ }
+ }()
+ go func() {
+ for range progress.ErrorChan {
+ }
+ }()
+ go func() {
+ for range progress.MessageChan {
+ }
+ }()
+}
func (testPlugin) AdditionalWords(string) []string { return nil }
func (testPlugin) AdditionalWordsLen() int { return 0 }
func (testPlugin) AdditionalSuccessWords(string) []string { return nil }
diff --git a/libgobuster/options.go b/libgobuster/options.go
index 3054aebf..8f69a599 100644
--- a/libgobuster/options.go
+++ b/libgobuster/options.go
@@ -19,4 +19,7 @@ type Options struct {
NoError bool
Quiet bool
Delay time.Duration
+ Recursion bool
+ RecursionDepth int
+ RecursionMaxTargets int
}
From 283861259e429c7c2fc69a0908744d26cc8c10f3 Mon Sep 17 00:00:00 2001
From: Christian Mehlmauer <105281+firefart@users.noreply.github.com>
Date: Mon, 31 Aug 2026 00:29:45 +0200
Subject: [PATCH 2/5] lint
---
libgobuster/libgobuster.go | 1 -
libgobuster/libgobuster_test.go | 9 ++++++---
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/libgobuster/libgobuster.go b/libgobuster/libgobuster.go
index 00b7ecaf..2bbcecf0 100644
--- a/libgobuster/libgobuster.go
+++ b/libgobuster/libgobuster.go
@@ -305,7 +305,6 @@ func (g *Gobuster) Run(ctx context.Context) error {
// It does not close the public channels; Run owns their lifetime across all
// recursive targets.
func (g *Gobuster) runTarget(ctx context.Context, onResult func(Result)) error {
-
if err := g.plugin.PreRun(ctx, g.Progress); err != nil {
return err
}
diff --git a/libgobuster/libgobuster_test.go b/libgobuster/libgobuster_test.go
index f78f853d..eef85ee6 100644
--- a/libgobuster/libgobuster_test.go
+++ b/libgobuster/libgobuster_test.go
@@ -128,15 +128,18 @@ func TestRunRejectsRecursionForUnsupportedPlugin(t *testing.T) {
func drainProgress(progress *Progress) {
go func() {
- for range progress.ResultChan {
+ for result := range progress.ResultChan {
+ _ = result
}
}()
go func() {
- for range progress.ErrorChan {
+ for err := range progress.ErrorChan {
+ _ = err
}
}()
go func() {
- for range progress.MessageChan {
+ for message := range progress.MessageChan {
+ _ = message
}
}()
}
From e302f16d2f2edf3f436e4fc36c1657fbda820b58 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 30 Aug 2026 22:35:12 +0000
Subject: [PATCH 3/5] fix recursive callback and docs
Co-authored-by: firefart <105281+firefart@users.noreply.github.com>
---
README.md | 2 ++
cli/dir/dir.go | 4 +--
libgobuster/libgobuster.go | 2 +-
libgobuster/libgobuster_test.go | 45 +++++++++++++++++++++++++++++++++
4 files changed, 50 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 7b4afa69..172eaa73 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,8 @@ Useful directory-mode options include:
- `--exclude-length 123,456-500` to ignore response sizes
- `--regex PATTERN` or `--regex-invert PATTERN` to filter response bodies
- `--recursive` to scan discovered directories recursively
+- `--recursion-depth N` to limit recursion depth (`0` for unlimited)
+- `--recursion-max-targets N` to cap discovered recursive targets (`0` for unlimited)
- `--body-output-dir PATH` to save response bodies
- `-k` to skip TLS certificate verification
diff --git a/cli/dir/dir.go b/cli/dir/dir.go
index 0c0817c9..abe7fa22 100644
--- a/cli/dir/dir.go
+++ b/cli/dir/dir.go
@@ -55,10 +55,10 @@ func run(c *cli.Context) error {
globalOpts.RecursionDepth = c.Int("recursion-depth")
globalOpts.RecursionMaxTargets = c.Int("recursion-max-targets")
if globalOpts.RecursionDepth < 0 {
- return errors.New("recursion-depth must be bigger or equal to 0")
+ return errors.New("recursion-depth must be greater than or equal to 0")
}
if globalOpts.RecursionMaxTargets < 0 {
- return errors.New("recursion-max-targets must be bigger or equal to 0")
+ return errors.New("recursion-max-targets must be greater than or equal to 0")
}
log := libgobuster.NewLogger(globalOpts.Debug)
diff --git a/libgobuster/libgobuster.go b/libgobuster/libgobuster.go
index 2bbcecf0..ca5df2bb 100644
--- a/libgobuster/libgobuster.go
+++ b/libgobuster/libgobuster.go
@@ -365,7 +365,7 @@ ListenForMore:
break ListenForMore
}
case success := <-successChan:
- if onResult != nil && success.guess.discoverOnSuccess {
+ if onResult != nil {
onResult(success.result)
}
// Add more guesses based on the results of previous attempts
diff --git a/libgobuster/libgobuster_test.go b/libgobuster/libgobuster_test.go
index eef85ee6..df23ac0a 100644
--- a/libgobuster/libgobuster_test.go
+++ b/libgobuster/libgobuster_test.go
@@ -52,6 +52,51 @@ func (p *recursiveTestPlugin) ProcessWord(_ context.Context, word string, _ *Pro
}
}
+type recursiveDiscoveryTestPlugin struct{}
+
+func (*recursiveDiscoveryTestPlugin) Name() string { return "recursive discovery test" }
+func (*recursiveDiscoveryTestPlugin) PreRun(context.Context, *Progress) error { return nil }
+func (*recursiveDiscoveryTestPlugin) ProcessWord(_ context.Context, word string, _ *Progress) (Result, error) {
+ switch word {
+ case "one":
+ return recursiveTestResult("root"), nil
+ case "discovered":
+ return recursiveTestResult("child"), nil
+ default:
+ return nil, nil //nolint:nilnil
+ }
+}
+
+func (*recursiveDiscoveryTestPlugin) AdditionalWords(string) []string { return nil }
+func (*recursiveDiscoveryTestPlugin) AdditionalWordsLen() int { return 0 }
+func (*recursiveDiscoveryTestPlugin) AdditionalSuccessWords(string) []string { return []string{"discovered"} }
+func (*recursiveDiscoveryTestPlugin) GetConfigString() (string, error) { return "", nil }
+
+func TestRunTargetInvokesCallbackForDiscoveredResults(t *testing.T) {
+ wordlist := t.TempDir() + "/words.txt"
+ if err := os.WriteFile(wordlist, []byte("one\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ g, err := NewGobuster(&Options{Threads: 1, Wordlist: wordlist}, &recursiveDiscoveryTestPlugin{}, NewLogger(false))
+ if err != nil {
+ t.Fatal(err)
+ }
+ drainProgress(g.Progress)
+ var got []string
+ if err := g.runTarget(t.Context(), func(result Result) {
+ recursiveResult, ok := result.(RecursiveResult)
+ if !ok {
+ return
+ }
+ got = append(got, recursiveResult.RecursiveTarget())
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if want := "root,child"; strings.Join(got, ",") != want {
+ t.Fatalf("discovered targets %q, want %q", strings.Join(got, ","), want)
+ }
+}
+
func TestRunRecursionEnforcesTargetLimit(t *testing.T) {
wordlist := t.TempDir() + "/words.txt"
if err := os.WriteFile(wordlist, []byte("one\ntwo\n"), 0o600); err != nil {
From ccc9977268486b0dd29ba49826190acbcdff316b Mon Sep 17 00:00:00 2001
From: Christian Mehlmauer <105281+firefart@users.noreply.github.com>
Date: Mon, 31 Aug 2026 01:10:00 +0200
Subject: [PATCH 4/5] lint
---
libgobuster/libgobuster_test.go | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/libgobuster/libgobuster_test.go b/libgobuster/libgobuster_test.go
index df23ac0a..02e49339 100644
--- a/libgobuster/libgobuster_test.go
+++ b/libgobuster/libgobuster_test.go
@@ -54,7 +54,7 @@ func (p *recursiveTestPlugin) ProcessWord(_ context.Context, word string, _ *Pro
type recursiveDiscoveryTestPlugin struct{}
-func (*recursiveDiscoveryTestPlugin) Name() string { return "recursive discovery test" }
+func (*recursiveDiscoveryTestPlugin) Name() string { return "recursive discovery test" }
func (*recursiveDiscoveryTestPlugin) PreRun(context.Context, *Progress) error { return nil }
func (*recursiveDiscoveryTestPlugin) ProcessWord(_ context.Context, word string, _ *Progress) (Result, error) {
switch word {
@@ -67,10 +67,12 @@ func (*recursiveDiscoveryTestPlugin) ProcessWord(_ context.Context, word string,
}
}
-func (*recursiveDiscoveryTestPlugin) AdditionalWords(string) []string { return nil }
-func (*recursiveDiscoveryTestPlugin) AdditionalWordsLen() int { return 0 }
-func (*recursiveDiscoveryTestPlugin) AdditionalSuccessWords(string) []string { return []string{"discovered"} }
-func (*recursiveDiscoveryTestPlugin) GetConfigString() (string, error) { return "", nil }
+func (*recursiveDiscoveryTestPlugin) AdditionalWords(string) []string { return nil }
+func (*recursiveDiscoveryTestPlugin) AdditionalWordsLen() int { return 0 }
+func (*recursiveDiscoveryTestPlugin) AdditionalSuccessWords(string) []string {
+ return []string{"discovered"}
+}
+func (*recursiveDiscoveryTestPlugin) GetConfigString() (string, error) { return "", nil }
func TestRunTargetInvokesCallbackForDiscoveredResults(t *testing.T) {
wordlist := t.TempDir() + "/words.txt"
From c098dea3524389a3116574b33049b549b92c78b8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 30 Aug 2026 23:20:45 +0000
Subject: [PATCH 5/5] cache recursive wordlist metadata
Co-authored-by: firefart <105281+firefart@users.noreply.github.com>
---
libgobuster/libgobuster.go | 70 +++++++++++++++++++++++++++++++++-----
1 file changed, 61 insertions(+), 9 deletions(-)
diff --git a/libgobuster/libgobuster.go b/libgobuster/libgobuster.go
index ca5df2bb..91092fd8 100644
--- a/libgobuster/libgobuster.go
+++ b/libgobuster/libgobuster.go
@@ -28,10 +28,16 @@ type ResultToStringFunc func(*Gobuster, *Result) (*string, error)
// Gobuster is the main object when creating a new run
type Gobuster struct {
- Opts *Options
- Logger *Logger
- plugin GobusterPlugin
- Progress *Progress
+ Opts *Options
+ Logger *Logger
+ plugin GobusterPlugin
+ Progress *Progress
+ wordlistCache *wordlistCache
+}
+
+type wordlistCache struct {
+ guessesPerLine int
+ lineCount int
}
type Guess struct {
@@ -182,8 +188,7 @@ func (g *Gobuster) feedWordlist(ctx context.Context, guessChan chan<- *Guess, wo
}
}
-func (g *Gobuster) getWordlist(wordlist io.ReadSeeker) (*Wordlist, error) {
- // calculate expected requests
+func (g *Gobuster) getWordlistCache() (*wordlistCache, error) {
var guessesPerLine int
if len(g.Opts.Patterns) > 0 {
nPats := len(g.Opts.Patterns)
@@ -192,6 +197,40 @@ func (g *Gobuster) getWordlist(wordlist io.ReadSeeker) (*Wordlist, error) {
guessesPerLine = 1 + g.plugin.AdditionalWordsLen()
}
+ if g.Opts.Wordlist == "-" {
+ return &wordlistCache{guessesPerLine: guessesPerLine}, nil
+ }
+
+ f, err := os.Open(g.Opts.Wordlist)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open wordlist: %w", err)
+ }
+ defer f.Close()
+
+ lines, err := lineCounter(f)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get number of lines: %w", err)
+ }
+ if lines-g.Opts.WordlistOffset <= 0 {
+ return nil, errors.New("offset is greater than the number of lines in the wordlist")
+ }
+
+ return &wordlistCache{guessesPerLine: guessesPerLine, lineCount: lines}, nil
+}
+
+func (g *Gobuster) getWordlist(wordlist io.ReadSeeker) (*Wordlist, error) {
+ guessesPerLine := 0
+ if g.wordlistCache != nil {
+ guessesPerLine = g.wordlistCache.guessesPerLine
+ } else {
+ if len(g.Opts.Patterns) > 0 {
+ nPats := len(g.Opts.Patterns)
+ guessesPerLine = nPats + nPats*g.plugin.AdditionalWordsLen()
+ } else {
+ guessesPerLine = 1 + g.plugin.AdditionalWordsLen()
+ }
+ }
+
if g.Opts.Wordlist == "-" {
// Read directly from stdin
scanner := bufio.NewScanner(os.Stdin)
@@ -199,9 +238,15 @@ func (g *Gobuster) getWordlist(wordlist io.ReadSeeker) (*Wordlist, error) {
return &Wordlist{scanner: scanner, guessesPerLine: guessesPerLine, isStream: true}, nil
}
- lines, err := lineCounter(wordlist)
- if err != nil {
- return nil, fmt.Errorf("failed to get number of lines: %w", err)
+ lines := 0
+ var err error
+ if g.wordlistCache != nil {
+ lines = g.wordlistCache.lineCount
+ } else {
+ lines, err = lineCounter(wordlist)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get number of lines: %w", err)
+ }
}
if lines-g.Opts.WordlistOffset <= 0 {
@@ -241,6 +286,7 @@ func (g *Gobuster) Run(ctx context.Context) error {
defer close(g.Progress.ResultChan)
defer close(g.Progress.ErrorChan)
defer close(g.Progress.MessageChan)
+ g.wordlistCache = nil
if !g.Opts.Recursion {
return g.runTarget(ctx, nil)
@@ -253,6 +299,12 @@ func (g *Gobuster) Run(ctx context.Context) error {
return errors.New("recursion is not supported with a wordlist read from stdin")
}
+ cache, err := g.getWordlistCache()
+ if err != nil {
+ return err
+ }
+ g.wordlistCache = cache
+
type target struct {
url string
depth int