Compare commits

...

57 Commits

Author SHA1 Message Date
epi
e5934cef1f fixed response code in test_scanner 2020-12-12 18:01:46 -06:00
epi
1b49c5dfe9 Merge pull request #162 from epi052/emoji-fallback
added emoji fallback when terminals dont support; updated httpmock
2020-12-12 17:22:50 -06:00
epi
47c384e2ec added emoji fallback when terminals dont support; updated httpmock 2020-12-12 17:20:24 -06:00
epi
8d5a0c590e Merge pull request #158 from epi052/dependabot/cargo/console-0.13
Update console requirement from 0.12 to 0.13
2020-12-12 10:25:56 -06:00
epi
6b04bc6757 Merge pull request #159 from epi052/dependabot/cargo/httpmock-0.5.2
Update httpmock requirement from 0.4.5 to 0.5.2
2020-12-12 10:20:46 -06:00
dependabot-preview[bot]
baa996356c Update httpmock requirement from 0.4.5 to 0.5.2
Updates the requirements on [httpmock](https://github.com/alexliesenfeld/httpmock) to permit the latest version.
- [Release notes](https://github.com/alexliesenfeld/httpmock/releases)
- [Changelog](https://github.com/alexliesenfeld/httpmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/alexliesenfeld/httpmock/compare/v0.4.5...v0.5.2)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-12-12 12:55:10 +00:00
epi
ae5f7e5435 Merge pull request #157 from epi052/dependabot/add-v2-config-file
Create Dependabot config file
2020-12-12 06:55:09 -06:00
dependabot-preview[bot]
9241b3c748 Update console requirement from 0.12 to 0.13
Updates the requirements on [console](https://github.com/mitsuhiko/console) to permit the latest version.
- [Release notes](https://github.com/mitsuhiko/console/releases)
- [Changelog](https://github.com/mitsuhiko/console/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mitsuhiko/console/compare/v0.12.0...v0.13.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-12-12 12:54:54 +00:00
dependabot-preview[bot]
48b341db39 Create Dependabot config file 2020-12-12 12:54:28 +00:00
epi
b759e016bb added probot-stale config 2020-12-12 06:30:00 -06:00
epi
8dc7a86b2b Merge pull request #152 from epi052/138-max-local-runtime
add maximum runtime for scans, i.e. time limit
2020-12-12 06:21:17 -06:00
epi
0db0273513 added documentation for time-limit 2020-12-11 21:08:48 -06:00
epi
21254ad871 added extra-words for longer scans 2020-12-11 16:38:03 -06:00
epi
5bbf29859f added tests for time-limit 2020-12-11 16:28:09 -06:00
epi
730566fd05 added time limit banner test 2020-12-11 14:48:08 -06:00
epi
f05c5eca03 fixed failing test 2020-12-11 13:11:48 -06:00
epi
8c50d94f8e cleaned up todo; reduced memory usage; polished time limit code; updated example config; added banner entry 2020-12-11 11:40:35 -06:00
epi
91c42e137d poc for max time works 2020-12-09 19:43:46 -06:00
epi
a2a9ba289c bumped version to 1.10.0 2020-12-09 15:51:35 -06:00
epi
0ea798e70e Merge pull request #150 from epi052/allow-cli-override-resume-from
allow CLI args to work w/ --resume-from
2020-12-09 08:40:49 -06:00
epi
3caa8d2ceb comment lint and corrections 2020-12-09 07:52:33 -06:00
epi
bd836c8b55 fixed test with hardcoded ferox version in expected msg 2020-12-08 20:49:38 -06:00
epi
f3bf05ab9b fixed replay proxy issue 2020-12-08 20:37:07 -06:00
epi
ef0b5d3780 Update pull_request_template.md 2020-12-08 15:37:20 -06:00
epi
ab5fbeb6ed feature appears to be working; tests passing 2020-12-08 15:35:11 -06:00
epi
6c779bd4c1 added shell completion scripts and build.rs; closes #146 2020-12-04 07:08:49 -06:00
epi
fbb964a893 added emoji font to Dockerfile 2020-12-04 06:21:07 -06:00
epi
4f1f63671e Merge pull request #147 from epi052/144-resume-scan
added 1.9 images to repo
2020-12-03 19:42:06 -06:00
epi
5578e8db5c added 1.9 images to repo 2020-12-03 19:41:17 -06:00
epi
5a93907d74 Merge pull request #145 from epi052/144-resume-scan
add ability to resume scans
2020-12-03 19:37:47 -06:00
epi
1d4403b497 CI still doesnt like the new addr_of stuff, reverted 2020-12-03 07:09:40 -06:00
epi
6939884a95 removed addr_of suppression from clippy 2020-12-02 20:30:30 -06:00
epi
509f09165a added documentation for 1.9.0; added save_state to example config 2020-12-02 20:10:34 -06:00
epi
40d8e1b76a added integration test for --resume-from 2020-12-01 17:00:09 -06:00
epi
da1c085f4a added integration test for --resume-from 2020-12-01 16:59:30 -06:00
epi
53281c0921 added more tests for scan_manager 2020-12-01 07:54:31 -06:00
epi
b9cf9b5558 added more tests for scan_manager 2020-12-01 07:31:57 -06:00
epi
295500a746 added more tests for scan_manager 2020-12-01 07:04:17 -06:00
epi
b1f77d202d added test for progress 2020-12-01 06:07:38 -06:00
epi
5a29f5fbb1 added progress test 2020-11-30 20:44:57 -06:00
epi
1d6e4374c0 simplified test, removed possible fail condition 2020-11-30 18:47:33 -06:00
epi
eaa7d1c790 added test for ferox response; fixed bug found in status code deserialization 2020-11-30 18:45:04 -06:00
epi
f29cd16616 added a few more tests 2020-11-29 20:14:42 -06:00
epi
1279ad6e68 updated json test 2020-11-29 18:24:44 -06:00
epi
8d4ba43cbe added deserialize test for FeroxScan 2020-11-29 17:40:34 -06:00
epi
d2562a5e0a resume appears to be fully implemented, just need tests 2020-11-29 10:12:53 -06:00
epi
a1d67afb72 resume appears to be fully implemented, just need tests 2020-11-29 10:12:38 -06:00
epi
fd61b8506b json can be used with both output files at the same time 2020-11-28 12:14:28 -06:00
epi
75babad426 made resume-from mutually exclusive with all other settings; json now requires one of the output files 2020-11-28 12:11:30 -06:00
epi
2b64030c0c all three types can be deserialized from state file 2020-11-28 09:29:09 -06:00
epi
26fcf457e6 added serialization/deserialization of a few different types 2020-11-28 07:27:58 -06:00
epi
26bf1e482d added logic for tracking responses 2020-11-28 07:25:40 -06:00
epi
107eac7e25 added --resume-from option to the parser 2020-11-28 07:15:47 -06:00
epi
e2b442ab0b added logic to kickoff ctrlc handler in main 2020-11-28 07:11:57 -06:00
epi
b822a5d862 added client config logic to resume_scan call branch 2020-11-28 07:11:02 -06:00
epi
dc4e41305e added ctrlc crate 2020-11-28 07:09:24 -06:00
epi
fdfb4cff64 bumped version to 1.9.0 2020-11-27 06:42:19 -06:00
36 changed files with 105157 additions and 715 deletions

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: cargo
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10

View File

@@ -4,7 +4,7 @@ Long form explanations of most of the items below can be found in the [CONTRIBUT
## Branching checklist
- [ ] There is an issue associated with your PR (bug, feature, etc.. if not, create one)
- [ ] Your PR description references the associated issue (i.e. fixes #123)
- [ ] Your PR description references the associated issue (i.e. fixes #123456)
- [ ] Code is in its own branch
- [ ] Branch name is related to the PR contents
- [ ] PR targets master

17
.github/stale.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 21
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

View File

@@ -61,4 +61,4 @@ jobs:
- uses: actions-rs/cargo@v1
with:
command: clippy
args: --all-targets --all-features -- -D warnings -A clippy::unnecessary_unwrap -A clippy::deref_addrof
args: --all-targets --all-features -- -D warnings -A clippy::deref_addrof

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ img/**
# scripts to check code coverage using nightly compiler
check-coverage.sh
lcov_cobertura.py
# dockerignore file that makes it so i can work on the docker config without copying a 4GB manifest or w/e it is
.dockerignore

View File

@@ -1,6 +1,6 @@
[package]
name = "feroxbuster"
version = "1.8.0"
version = "1.10.0"
authors = ["Ben 'epi' Risher <epibar052@gmail.com>"]
license = "MIT"
edition = "2018"
@@ -10,10 +10,16 @@ description = "A fast, simple, recursive content discovery tool."
categories = ["command-line-utilities"]
keywords = ["pentest", "enumeration", "url-bruteforce", "content-discovery", "web"]
exclude = [".github/*", "img/*", "check-coverage.sh"]
build = "build.rs"
[badges]
maintenance = { status = "actively-developed" }
[build-dependencies]
clap = "2.33"
regex = "1"
lazy_static = "1.4"
[dependencies]
futures = { version = "0.3"}
tokio = { version = "0.2", features = ["full"] }
@@ -21,23 +27,24 @@ tokio-util = {version = "0.3", features = ["codec"]}
log = "0.4"
env_logger = "0.8"
reqwest = { version = "0.10", features = ["socks"] }
clap = "2"
clap = "2.33"
lazy_static = "1.4"
toml = "0.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "0.8", features = ["v4"] }
indicatif = "0.15"
console = "0.12"
console = "0.13"
openssl = { version = "0.10", features = ["vendored"] }
dirs = "3.0"
regex = "1"
crossterm = "0.18"
rlimit = "0.5"
ctrlc = "3.1"
[dev-dependencies]
tempfile = "3.1"
httpmock = "0.4.5"
httpmock = "0.5.2"
assert_cmd = "1.0.1"
predicates = "1.0.5"

View File

@@ -1,8 +1,10 @@
FROM alpine:latest
LABEL maintainer="wfnintr@null.net"
RUN sed -i -e 's/v[[:digit:]]\..*\//edge\//g' /etc/apk/repositories && apk upgrade --update-cache --available
# download default wordlists
RUN apk add --no-cache --virtual .depends subversion && \
RUN apk add --no-cache --virtual .depends subversion font-noto-emoji && \
svn export https://github.com/danielmiessler/SecLists/trunk/Discovery/Web-Content /usr/share/seclists/Discovery/Web-Content && \
apk del .depends

253
README.md
View File

@@ -73,19 +73,22 @@ This attack is also known as Predictable Resource Location, File Enumeration, Di
- [ferox-config.toml](#ferox-configtoml)
- [Command Line Parsing](#command-line-parsing)
- [Example Usage](#-example-usage)
- [Pause and Resume Scans (new in `v1.4.0`)](#pause-and-resume-scans-new-in-v140)
- [Multiple Values](#multiple-values)
- [Extract Links from Response Body (new in `v1.1.0`)](#extract-links-from-response-body-new-in-v110)
- [Include Headers](#include-headers)
- [IPv6, Non-recursive scan with INFO logging enabled](#ipv6-non-recursive-scan-with-info-level-logging-enabled)
- [Read urls from STDIN; pipe only resulting urls out to another tool](#read-urls-from-stdin-pipe-only-resulting-urls-out-to-another-tool)
- [Proxy traffic through Burp](#proxy-traffic-through-burp)
- [Proxy traffic through a SOCKS proxy](#proxy-traffic-through-a-socks-proxy)
- [Pass auth token via query parameter](#pass-auth-token-via-query-parameter)
- [Extract Links from Response Body (new in `v1.1.0`)](#extract-links-from-response-body-new-in-v110)
- [Limit Total Number of Concurrent Scans (new in `v1.2.0`)](#limit-total-number-of-concurrent-scans-new-in-v120)
- [Filter Response by Status Code (new in `v1.3.0`)](#filter-response-by-status-code--new-in-v130)
- [Filter Response Using a Regular Expression (new in `v1.8.0`)](#filter-response-using-a-regular-expression-new-in-v180)
- [Pause an Active Scan (new in `v1.4.0`)](#pause-an-active-scan-new-in-v140)
- [Replay Responses to a Proxy based on Status Code (new in `v1.5.0`)](#replay-responses-to-a-proxy-based-on-status-code-new-in-v150)
- [Filter Response by Word Count & Line Count (new in `v1.6.0`)](#filter-response-by-word-count--line-count--new-in-v160)
- [Filter Response Using a Regular Expression (new in `v1.8.0`)](#filter-response-using-a-regular-expression-new-in-v180)
- [Stop and Resume Scans (save scan's state to disk) (new in `v1.9.0`)](#stop-and-resume-scans---resume-from-file-new-in-v190)
- [Enforce a Time Limit on Your Scan (new in `v1.10.0`)](#enforce-a-time-limit-on-your-scan-new-in-v1100)
- [Comparison w/ Similar Tools](#-comparison-w-similar-tools)
- [Common Problems/Issues (FAQ)](#-common-problemsissues-faq)
- [No file descriptors available](#no-file-descriptors-available)
@@ -257,6 +260,7 @@ Configuration begins with with the following built-in default values baked into
- recursion depth: `4`
- auto-filter wildcards - `true`
- output: `stdout`
- save_state: `true` (create a state file in cwd when `Ctrl+C` is received)
### Threads and Connection Limits At A High-Level
@@ -348,6 +352,8 @@ A pre-made configuration file with examples of all available settings can be fou
# filter_word_count = [993]
# filter_line_count = [35, 36]
# queries = [["name","value"], ["rick", "astley"]]
# save_state = false
# time_limit = 10m
# headers can be specified on multiple lines or as an inline table
#
@@ -400,14 +406,17 @@ OPTIONS:
-o, --output <FILE> Output file to write results to (use w/ --json for JSON entries)
-p, --proxy <PROXY> Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)
-Q, --query <QUERY>... Specify URL query parameters (ex: -Q token=stuff -Q secret=key)
-R, --replay-codes <REPLAY_CODE>... Status Codes to send through a Replay Proxy when found (default: --status
-codes value)
-R, --replay-codes <REPLAY_CODE>... Status Codes to send through a Replay Proxy when found (default: --status-
codes value)
-P, --replay-proxy <REPLAY_PROXY> Send only unfiltered requests through a Replay Proxy, instead of all
requests
--resume-from <STATE_FILE> State file from which to resume a partially complete scan (ex. --resume-from
ferox-1606586780.state)
-L, --scan-limit <SCAN_LIMIT> Limit total number of concurrent scans (default: 0, i.e. no limit)
-s, --status-codes <STATUS_CODE>... Status Codes to include (allow list) (default: 200 204 301 302 307 308 401
403 405)
-t, --threads <THREADS> Number of concurrent threads (default: 50)
--time-limit <TIME_SPEC> Limit total run time of all scans (ex: --time-limit 10m)
-T, --timeout <SECONDS> Number of seconds before a request times out (default: 7)
-u, --url <URL>... The target URL(s) (required, unless --stdin used)
-a, --user-agent <USER_AGENT> Sets the User-Agent (default: feroxbuster/VERSION)
@@ -416,12 +425,6 @@ OPTIONS:
## 🧰 Example Usage
### Pause and Resume Scans (new in `v1.4.0`)
Scans can be paused and resumed by pressing the ENTER key (shown below)
![pause-resume-demo](img/pause-resume-demo.gif)
### Multiple Values
Options that take multiple values are very flexible. Consider the following ways of specifying extensions:
@@ -440,6 +443,36 @@ All of the methods above (multiple flags, space separated, comma separated, etc.
./feroxbuster -u http://127.1 -H Accept:application/json "Authorization: Bearer {token}"
```
### IPv6, non-recursive scan with INFO-level logging enabled
```
./feroxbuster -u http://[::1] --no-recursion -vv
```
### Read urls from STDIN; pipe only resulting urls out to another tool
```
cat targets | ./feroxbuster --stdin --quiet -s 200 301 302 --redirects -x js | fff -s 200 -o js-files
```
### Proxy traffic through Burp
```
./feroxbuster -u http://127.1 --insecure --proxy http://127.0.0.1:8080
```
### Proxy traffic through a SOCKS proxy
```
./feroxbuster -u http://127.1 --proxy socks5://127.0.0.1:9050
```
### Pass auth token via query parameter
```
./feroxbuster -u http://127.1 --query token=0123456789ABCDEF
```
### Extract Links from Response Body (New in `v1.1.0`)
Search through the body of valid responses (html, javascript, etc...) for additional endpoints to scan. This turns
@@ -470,37 +503,6 @@ With `--extract-links`
![extract-scan-cmp-normal](img/extract-scan-cmp-normal.gif)
### IPv6, non-recursive scan with INFO-level logging enabled
```
./feroxbuster -u http://[::1] --no-recursion -vv
```
### Read urls from STDIN; pipe only resulting urls out to another tool
```
cat targets | ./feroxbuster --stdin --quiet -s 200 301 302 --redirects -x js | fff -s 200 -o js-files
```
### Proxy traffic through Burp
```
./feroxbuster -u http://127.1 --insecure --proxy http://127.0.0.1:8080
```
### Proxy traffic through a SOCKS proxy
```
./feroxbuster -u http://127.1 --proxy socks5://127.0.0.1:9050
```
### Pass auth token via query parameter
```
./feroxbuster -u http://127.1 --query token=0123456789ABCDEF
```
### Limit Total Number of Concurrent Scans (new in `v1.2.0`)
Limit the number of scans permitted to run at any given time. Recursion will still identify new directories, but newly
@@ -523,18 +525,11 @@ each one is checked against a list of known filters and either displayed or not
./feroxbuster -u http://127.1 --filter-status 301
```
### Filter Response Using a Regular Expression (new in `v1.8.0`)
### Pause an Active Scan (new in `v1.4.0`)
Version 1.3.0 included an overhaul to the filtering system which will allow for a wide array of filters to be added
with minimal effort. The latest addition is a Regular Expression Filter. As responses come back from the scanned server,
the **body** of the response is checked against the filter's regular expression. If the expression is found in the
body, then that response is filtered out.
Scans can be paused and resumed by pressing the ENTER key (shown below)
**NOTE: Using regular expressions to filter large responses or many regular expressions may negatively impact performance.**
```
./feroxbuster -u http://127.1 --filter-regex '[aA]ccess [dD]enied.?' --output results.txt --json
```
![pause-resume-demo](img/pause-resume-demo.gif)
### Replay Responses to a Proxy based on Status Code (new in `v1.5.0`)
@@ -550,6 +545,110 @@ Of note: this means that for every response that matches your replay criteria, y
![replay-proxy-demo](img/replay-proxy-demo.gif)
### Filter Response by Word Count & Line Count (new in `v1.6.0`)
In addition to filtering on the size of a response, version 1.6.0 added the ability to filter out responses based on the number of lines and/or words contained within the response body. This change drove a change to the information displayed to the user as well. This section will detail the new information and how to make use of it with the new filters provided.
Example output:
```
200 10l 212w 38437c https://example-site.com/index.html
```
There are five columns of output above:
- column 1: status code - can be filtered with `-C|--filter-status`
- column 2: number of lines - can be filtered with `-N|--filter-lines`
- column 3: number of words - can be filtered with `-W|--filter-words`
- column 4: number of bytes (overall size) - can be filtered with `-S|--filter-size`
- column 5: url to discovered resource
### Filter Response Using a Regular Expression (new in `v1.8.0`)
Version 1.3.0 included an overhaul to the filtering system which will allow for a wide array of filters to be added
with minimal effort. The latest addition is a Regular Expression Filter. As responses come back from the scanned server,
the **body** of the response is checked against the filter's regular expression. If the expression is found in the
body, then that response is filtered out.
**NOTE: Using regular expressions to filter large responses or many regular expressions may negatively impact performance.**
```
./feroxbuster -u http://127.1 --filter-regex '[aA]ccess [dD]enied.?' --output results.txt --json
```
### Stop and Resume Scans (`--resume-from FILE`) (new in `v1.9.0`)
Version 1.9.0 adds a few features that allow for completely stopping a scan, and resuming that same scan from a file on disk.
A simple `Ctrl+C` during a scan will create a file that contains information about the scan that was cancelled.
![save-state](img/save-state.png)
```json
// example snippet of state file
{
"scans":[
{
"id":"057016a14769414aac9a7a62707598cb",
"url":"https://localhost.com",
"scan_type":"Directory",
"complete":true
},
{
"id":"400b2323a16f43468a04ffcbbeba34c6",
"url":"https://localhost.com/css",
"scan_type":"Directory",
"complete":false
}
],
"config":{
"wordlist":"/wordlists/seclists/Discovery/Web-Content/common.txt",
"...":"..."
},
"responses":[
{
"type":"response",
"url":"https://localhost.com/Login",
"path":"/Login",
"wildcard":false,
"status":302,
"content_length":0,
"line_count":0,
"word_count":0,
"headers":{
"content-length":"0",
"server":"nginx/1.16.1"
}
}
]
},
```
Based on the example image above, the same scan can be resumed by using `feroxbuster --resume-from ferox-http_localhost-1606947491.state`. Directories that were already complete are not rescanned, however partially complete scans are started from the beginning.
![resumed-scan](img/resumed-scan.gif)
In order to prevent state file creation when `Ctrl+C` is pressed, you can simply add the entry below to your `ferox-config.toml`.
```toml
# ferox-config.toml
save_state = false
```
### Enforce a Time Limit on Your Scan (new in `v1.10.0`)
Version 1.10.0 adds the ability to set a maximum runtime, or time limit, on your scan. The usage is pretty simple: a number followed directly by a single character representing seconds, minutes, hours, or days. `feroxbuster` refers to this combination as a time_spec.
Examples of possible time_specs:
- `30s` - 30 seconds
- `20m` - 20 minutes
- `1h` - 1 hour
- `1d` - 1 day (why??)
A valid time_spec can be passed to `--time-limit` in order to force a shutdown after the given time has elapsed.
![time-limit](img/time-limit.gif)
## 🧐 Comparison w/ Similar Tools
There are quite a few similar tools for forced browsing/content discovery. Burp Suite Pro, Dirb, Dirbuster, etc...
@@ -568,24 +667,32 @@ a few of the use-cases in which feroxbuster may be a better fit:
- You want **recursion** along with some other thing mentioned above (ffuf also does recursion)
- You want a **configuration file** option for overriding built-in default values for your scans
| | feroxbuster | gobuster | ffuf |
|------------------------------------------------------------------|---|---|---|
| fast | ✔ | ✔ | ✔ |
| easy to use | ✔ | ✔ | |
| filter out responses by status code (new in `v1.3.0`) | ✔ | | ✔ |
| allows recursion | ✔ | | ✔ |
| can specify query parameters | ✔ | | |
| SOCKS proxy support | ✔ | | |
| extracts links from response body to increase scan coverage | ✔ | | |
| multiple target scan (via stdin or multiple -u) | ✔ | | ✔ |
| configuration file for default value override | ✔ | | ✔ |
| can accept urls via STDIN as part of a pipeline | ✔ | | ✔ |
| can accept wordlists via STDIN | | | ✔ |
| filter based on response size, wordcount, and linecount | ✔ | | ✔ |
| auto-filter wildcard responses | | | ✔ |
| performs other scans (vhost, dns, etc) | | ✔ | ✔ |
| time delay / rate limiting | | | |
| **huge** number of other options | | | ✔ |
| | feroxbuster | gobuster | ffuf |
|------------------------------------------------------------------------------|---|---|---|
| fast | ✔ | ✔ | ✔ |
| easy to use | ✔ | ✔ | |
| allows recursion | ✔ | | ✔ |
| can specify query parameters | ✔ | | ✔ |
| SOCKS proxy support | ✔ | | |
| multiple target scan (via stdin or multiple -u) | ✔ | | |
| configuration file for default value override | ✔ | | |
| can accept urls via STDIN as part of a pipeline | ✔ | | ✔ |
| can accept wordlists via STDIN | | ✔ | ✔ |
| filter based on response size, wordcount, and linecount | ✔ | | ✔ |
| auto-filter wildcard responses | | | ✔ |
| performs other scans (vhost, dns, etc) | | ✔ | ✔ |
| time delay / rate limiting | | | ✔ |
| extracts links from response body to increase scan coverage (`v1.1.0`) | ✔ | | |
| limit number of concurrent recursive scans (`v1.2.0`) | | | |
| filter out responses by status code (`v1.3.0`) | | | ✔ |
| interactive pause and resume of active scan (`v1.4.0`) | ✔ | | |
| replay only matched requests to a proxy (`v1.5.0`) | ✔ | | ✔ |
| filter out responses by line & word count (`v1.6.0`) | ✔ | | ✔ |
| json output (ffuf supports other formats as well) (`v1.7.0`) | ✔ | | ✔ |
| filter out responses by regular expression (`v1.8.0`) | ✔ | | ✔ |
| save scan's state to disk (can pick up where it left off) (`v1.9.0`) | ✔ | | |
| maximum run time limit (`v1.10.0`) | ✔ | | ✔ |
| **huge** number of other options | | | ✔ |
Of note, there's another written-in-rust content discovery tool, [rustbuster](https://github.com/phra/rustbuster). I
came across rustbuster when I was naming my tool (😢). I don't have any experience using it, but it appears to
@@ -671,4 +778,8 @@ sudo sysctl net.ipv4.tcp_tw_reuse=1
![small-term](img/small-term.png)
If you can, simply make the terminal wider and rerun. If you're unable to make your terminal wider
consider using `-q` to suppress the progress bars.
consider using `-q` to suppress the progress bars.
### What do each of the numbers beside the URL mean?
Please refer to [this section](#filter-response-by-word-count--line-count--new-in-v160) where each number's meaning and how to use it to filter responses is discussed.

17
build.rs Normal file
View File

@@ -0,0 +1,17 @@
extern crate clap;
use clap::Shell;
include!("src/parser.rs");
fn main() {
let outdir = "shell_completions";
let mut app = initialize();
let shells: [Shell; 4] = [Shell::Bash, Shell::Fish, Shell::Zsh, Shell::PowerShell];
for shell in &shells {
app.gen_completions("feroxbuster", *shell, outdir);
}
}

View File

@@ -36,6 +36,8 @@
# filter_word_count = [993]
# filter_line_count = [35, 36]
# queries = [["name","value"], ["rick", "astley"]]
# save_state = false
# time_limit = "10m"
# headers can be specified on multiple lines or as an inline table
#

BIN
img/resumed-scan.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

BIN
img/save-state.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
img/time-limit.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

View File

@@ -0,0 +1,95 @@
#compdef feroxbuster
autoload -U is-at-least
_feroxbuster() {
typeset -A opt_args
typeset -a _arguments_options
local ret=1
if is-at-least 5.2; then
_arguments_options=(-s -S -C)
else
_arguments_options=(-s -C)
fi
local context curcontext="$curcontext" state line
_arguments "${_arguments_options[@]}" \
'-w+[Path to the wordlist]' \
'--wordlist=[Path to the wordlist]' \
'*-u+[The target URL(s) (required, unless --stdin used)]' \
'*--url=[The target URL(s) (required, unless --stdin used)]' \
'-t+[Number of concurrent threads (default: 50)]' \
'--threads=[Number of concurrent threads (default: 50)]' \
'-d+[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]' \
'--depth=[Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)]' \
'-T+[Number of seconds before a request times out (default: 7)]' \
'--timeout=[Number of seconds before a request times out (default: 7)]' \
'-p+[Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)]' \
'--proxy=[Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)]' \
'-P+[Send only unfiltered requests through a Replay Proxy, instead of all requests]' \
'--replay-proxy=[Send only unfiltered requests through a Replay Proxy, instead of all requests]' \
'*-R+[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]' \
'*--replay-codes=[Status Codes to send through a Replay Proxy when found (default: --status-codes value)]' \
'*-s+[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]' \
'*--status-codes=[Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)]' \
'-o+[Output file to write results to (use w/ --json for JSON entries)]' \
'--output=[Output file to write results to (use w/ --json for JSON entries)]' \
'(-u --url)--resume-from=[State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)]' \
'--debug-log=[Output file to write log entries (use w/ --json for JSON entries)]' \
'-a+[Sets the User-Agent (default: feroxbuster/VERSION)]' \
'--user-agent=[Sets the User-Agent (default: feroxbuster/VERSION)]' \
'*-x+[File extension(s) to search for (ex: -x php -x pdf js)]' \
'*--extensions=[File extension(s) to search for (ex: -x php -x pdf js)]' \
'*-H+[Specify HTTP headers (ex: -H Header:val '\''stuff: things'\'')]' \
'*--headers=[Specify HTTP headers (ex: -H Header:val '\''stuff: things'\'')]' \
'*-Q+[Specify URL query parameters (ex: -Q token=stuff -Q secret=key)]' \
'*--query=[Specify URL query parameters (ex: -Q token=stuff -Q secret=key)]' \
'*-S+[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]' \
'*--filter-size=[Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)]' \
'*-X+[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]' \
'*--filter-regex=[Filter out messages via regular expression matching on the response'\''s body (ex: -X '\''^ignore me$'\'')]' \
'*-W+[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]' \
'*--filter-words=[Filter out messages of a particular word count (ex: -W 312 -W 91,82)]' \
'*-N+[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]' \
'*--filter-lines=[Filter out messages of a particular line count (ex: -N 20 -N 31,30)]' \
'*-C+[Filter out status codes (deny list) (ex: -C 200 -C 401)]' \
'*--filter-status=[Filter out status codes (deny list) (ex: -C 200 -C 401)]' \
'-L+[Limit total number of concurrent scans (default: 0, i.e. no limit)]' \
'--scan-limit=[Limit total number of concurrent scans (default: 0, i.e. no limit)]' \
'--time-limit=[Limit total run time of all scans (ex: --time-limit 10m)]' \
'*-v[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
'*--verbosity[Increase verbosity level (use -vv or more for greater effect. \[CAUTION\] 4 -v'\''s is probably too much)]' \
'-q[Only print URLs; Don'\''t print status codes, response size, running config, etc...]' \
'--quiet[Only print URLs; Don'\''t print status codes, response size, running config, etc...]' \
'--json[Emit JSON logs to --output and --debug-log instead of normal text]' \
'-D[Don'\''t auto-filter wildcard responses]' \
'--dont-filter[Don'\''t auto-filter wildcard responses]' \
'-r[Follow redirects]' \
'--redirects[Follow redirects]' \
'-k[Disables TLS certificate validation]' \
'--insecure[Disables TLS certificate validation]' \
'-n[Do not scan recursively]' \
'--no-recursion[Do not scan recursively]' \
'(-x --extensions)-f[Append / to each request]' \
'(-x --extensions)--add-slash[Append / to each request]' \
'(-u --url)--stdin[Read url(s) from STDIN]' \
'-e[Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)]' \
'--extract-links[Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)]' \
'-h[Prints help information]' \
'--help[Prints help information]' \
'-V[Prints version information]' \
'--version[Prints version information]' \
&& ret=0
}
(( $+functions[_feroxbuster_commands] )) ||
_feroxbuster_commands() {
local commands; commands=(
)
_describe -t commands 'feroxbuster commands' commands "$@"
}
_feroxbuster "$@"

View File

@@ -0,0 +1,94 @@
using namespace System.Management.Automation
using namespace System.Management.Automation.Language
Register-ArgumentCompleter -Native -CommandName 'feroxbuster' -ScriptBlock {
param($wordToComplete, $commandAst, $cursorPosition)
$commandElements = $commandAst.CommandElements
$command = @(
'feroxbuster'
for ($i = 1; $i -lt $commandElements.Count; $i++) {
$element = $commandElements[$i]
if ($element -isnot [StringConstantExpressionAst] -or
$element.StringConstantType -ne [StringConstantType]::BareWord -or
$element.Value.StartsWith('-')) {
break
}
$element.Value
}) -join ';'
$completions = @(switch ($command) {
'feroxbuster' {
[CompletionResult]::new('-w', 'w', [CompletionResultType]::ParameterName, 'Path to the wordlist')
[CompletionResult]::new('--wordlist', 'wordlist', [CompletionResultType]::ParameterName, 'Path to the wordlist')
[CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The target URL(s) (required, unless --stdin used)')
[CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The target URL(s) (required, unless --stdin used)')
[CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'Number of concurrent threads (default: 50)')
[CompletionResult]::new('--threads', 'threads', [CompletionResultType]::ParameterName, 'Number of concurrent threads (default: 50)')
[CompletionResult]::new('-d', 'd', [CompletionResultType]::ParameterName, 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)')
[CompletionResult]::new('--depth', 'depth', [CompletionResultType]::ParameterName, 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)')
[CompletionResult]::new('-T', 'T', [CompletionResultType]::ParameterName, 'Number of seconds before a request times out (default: 7)')
[CompletionResult]::new('--timeout', 'timeout', [CompletionResultType]::ParameterName, 'Number of seconds before a request times out (default: 7)')
[CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)')
[CompletionResult]::new('--proxy', 'proxy', [CompletionResultType]::ParameterName, 'Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)')
[CompletionResult]::new('-P', 'P', [CompletionResultType]::ParameterName, 'Send only unfiltered requests through a Replay Proxy, instead of all requests')
[CompletionResult]::new('--replay-proxy', 'replay-proxy', [CompletionResultType]::ParameterName, 'Send only unfiltered requests through a Replay Proxy, instead of all requests')
[CompletionResult]::new('-R', 'R', [CompletionResultType]::ParameterName, 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)')
[CompletionResult]::new('--replay-codes', 'replay-codes', [CompletionResultType]::ParameterName, 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)')
[CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)')
[CompletionResult]::new('--status-codes', 'status-codes', [CompletionResultType]::ParameterName, 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)')
[CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'Output file to write results to (use w/ --json for JSON entries)')
[CompletionResult]::new('--output', 'output', [CompletionResultType]::ParameterName, 'Output file to write results to (use w/ --json for JSON entries)')
[CompletionResult]::new('--resume-from', 'resume-from', [CompletionResultType]::ParameterName, 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)')
[CompletionResult]::new('--debug-log', 'debug-log', [CompletionResultType]::ParameterName, 'Output file to write log entries (use w/ --json for JSON entries)')
[CompletionResult]::new('-a', 'a', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/VERSION)')
[CompletionResult]::new('--user-agent', 'user-agent', [CompletionResultType]::ParameterName, 'Sets the User-Agent (default: feroxbuster/VERSION)')
[CompletionResult]::new('-x', 'x', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js)')
[CompletionResult]::new('--extensions', 'extensions', [CompletionResultType]::ParameterName, 'File extension(s) to search for (ex: -x php -x pdf js)')
[CompletionResult]::new('-H', 'H', [CompletionResultType]::ParameterName, 'Specify HTTP headers (ex: -H Header:val ''stuff: things'')')
[CompletionResult]::new('--headers', 'headers', [CompletionResultType]::ParameterName, 'Specify HTTP headers (ex: -H Header:val ''stuff: things'')')
[CompletionResult]::new('-Q', 'Q', [CompletionResultType]::ParameterName, 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)')
[CompletionResult]::new('--query', 'query', [CompletionResultType]::ParameterName, 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)')
[CompletionResult]::new('-S', 'S', [CompletionResultType]::ParameterName, 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)')
[CompletionResult]::new('--filter-size', 'filter-size', [CompletionResultType]::ParameterName, 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)')
[CompletionResult]::new('-X', 'X', [CompletionResultType]::ParameterName, 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')')
[CompletionResult]::new('--filter-regex', 'filter-regex', [CompletionResultType]::ParameterName, 'Filter out messages via regular expression matching on the response''s body (ex: -X ''^ignore me$'')')
[CompletionResult]::new('-W', 'W', [CompletionResultType]::ParameterName, 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)')
[CompletionResult]::new('--filter-words', 'filter-words', [CompletionResultType]::ParameterName, 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)')
[CompletionResult]::new('-N', 'N', [CompletionResultType]::ParameterName, 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)')
[CompletionResult]::new('--filter-lines', 'filter-lines', [CompletionResultType]::ParameterName, 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)')
[CompletionResult]::new('-C', 'C', [CompletionResultType]::ParameterName, 'Filter out status codes (deny list) (ex: -C 200 -C 401)')
[CompletionResult]::new('--filter-status', 'filter-status', [CompletionResultType]::ParameterName, 'Filter out status codes (deny list) (ex: -C 200 -C 401)')
[CompletionResult]::new('-L', 'L', [CompletionResultType]::ParameterName, 'Limit total number of concurrent scans (default: 0, i.e. no limit)')
[CompletionResult]::new('--scan-limit', 'scan-limit', [CompletionResultType]::ParameterName, 'Limit total number of concurrent scans (default: 0, i.e. no limit)')
[CompletionResult]::new('--time-limit', 'time-limit', [CompletionResultType]::ParameterName, 'Limit total run time of all scans (ex: --time-limit 10m)')
[CompletionResult]::new('-v', 'v', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)')
[CompletionResult]::new('--verbosity', 'verbosity', [CompletionResultType]::ParameterName, 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v''s is probably too much)')
[CompletionResult]::new('-q', 'q', [CompletionResultType]::ParameterName, 'Only print URLs; Don''t print status codes, response size, running config, etc...')
[CompletionResult]::new('--quiet', 'quiet', [CompletionResultType]::ParameterName, 'Only print URLs; Don''t print status codes, response size, running config, etc...')
[CompletionResult]::new('--json', 'json', [CompletionResultType]::ParameterName, 'Emit JSON logs to --output and --debug-log instead of normal text')
[CompletionResult]::new('-D', 'D', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses')
[CompletionResult]::new('--dont-filter', 'dont-filter', [CompletionResultType]::ParameterName, 'Don''t auto-filter wildcard responses')
[CompletionResult]::new('-r', 'r', [CompletionResultType]::ParameterName, 'Follow redirects')
[CompletionResult]::new('--redirects', 'redirects', [CompletionResultType]::ParameterName, 'Follow redirects')
[CompletionResult]::new('-k', 'k', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation')
[CompletionResult]::new('--insecure', 'insecure', [CompletionResultType]::ParameterName, 'Disables TLS certificate validation')
[CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'Do not scan recursively')
[CompletionResult]::new('--no-recursion', 'no-recursion', [CompletionResultType]::ParameterName, 'Do not scan recursively')
[CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Append / to each request')
[CompletionResult]::new('--add-slash', 'add-slash', [CompletionResultType]::ParameterName, 'Append / to each request')
[CompletionResult]::new('--stdin', 'stdin', [CompletionResultType]::ParameterName, 'Read url(s) from STDIN')
[CompletionResult]::new('-e', 'e', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)')
[CompletionResult]::new('--extract-links', 'extract-links', [CompletionResultType]::ParameterName, 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)')
[CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Prints help information')
[CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Prints help information')
[CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Prints version information')
[CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Prints version information')
break
}
})
$completions.Where{ $_.CompletionText -like "$wordToComplete*" } |
Sort-Object -Property ListItemText
}

View File

@@ -0,0 +1,213 @@
_feroxbuster() {
local i cur prev opts cmds
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
cmd=""
opts=""
for i in ${COMP_WORDS[@]}
do
case "${i}" in
feroxbuster)
cmd="feroxbuster"
;;
*)
;;
esac
done
case "${cmd}" in
feroxbuster)
opts=" -v -q -D -r -k -n -f -e -h -V -w -u -t -d -T -p -P -R -s -o -a -x -H -Q -S -X -W -N -C -L --verbosity --quiet --json --dont-filter --redirects --insecure --no-recursion --add-slash --stdin --extract-links --help --version --wordlist --url --threads --depth --timeout --proxy --replay-proxy --replay-codes --status-codes --output --resume-from --debug-log --user-agent --extensions --headers --query --filter-size --filter-regex --filter-words --filter-lines --filter-status --scan-limit --time-limit "
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
--wordlist)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-w)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--url)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-u)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--threads)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-t)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--depth)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-d)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--timeout)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-T)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--proxy)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-p)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--replay-proxy)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-P)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--replay-codes)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-R)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--status-codes)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-s)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--output)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-o)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--resume-from)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--debug-log)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--user-agent)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-a)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--extensions)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-x)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--headers)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-H)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--query)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-Q)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--filter-size)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-S)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--filter-regex)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-X)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--filter-words)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-W)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--filter-lines)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-N)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--filter-status)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-C)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--scan-limit)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-L)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--time-limit)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
esac
}
complete -F _feroxbuster -o bashdefault -o default feroxbuster

View File

@@ -0,0 +1,35 @@
complete -c feroxbuster -n "__fish_use_subcommand" -s w -l wordlist -d 'Path to the wordlist'
complete -c feroxbuster -n "__fish_use_subcommand" -s u -l url -d 'The target URL(s) (required, unless --stdin used)'
complete -c feroxbuster -n "__fish_use_subcommand" -s t -l threads -d 'Number of concurrent threads (default: 50)'
complete -c feroxbuster -n "__fish_use_subcommand" -s d -l depth -d 'Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)'
complete -c feroxbuster -n "__fish_use_subcommand" -s T -l timeout -d 'Number of seconds before a request times out (default: 7)'
complete -c feroxbuster -n "__fish_use_subcommand" -s p -l proxy -d 'Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)'
complete -c feroxbuster -n "__fish_use_subcommand" -s P -l replay-proxy -d 'Send only unfiltered requests through a Replay Proxy, instead of all requests'
complete -c feroxbuster -n "__fish_use_subcommand" -s R -l replay-codes -d 'Status Codes to send through a Replay Proxy when found (default: --status-codes value)'
complete -c feroxbuster -n "__fish_use_subcommand" -s s -l status-codes -d 'Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)'
complete -c feroxbuster -n "__fish_use_subcommand" -s o -l output -d 'Output file to write results to (use w/ --json for JSON entries)'
complete -c feroxbuster -n "__fish_use_subcommand" -l resume-from -d 'State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)'
complete -c feroxbuster -n "__fish_use_subcommand" -l debug-log -d 'Output file to write log entries (use w/ --json for JSON entries)'
complete -c feroxbuster -n "__fish_use_subcommand" -s a -l user-agent -d 'Sets the User-Agent (default: feroxbuster/VERSION)'
complete -c feroxbuster -n "__fish_use_subcommand" -s x -l extensions -d 'File extension(s) to search for (ex: -x php -x pdf js)'
complete -c feroxbuster -n "__fish_use_subcommand" -s H -l headers -d 'Specify HTTP headers (ex: -H Header:val \'stuff: things\')'
complete -c feroxbuster -n "__fish_use_subcommand" -s Q -l query -d 'Specify URL query parameters (ex: -Q token=stuff -Q secret=key)'
complete -c feroxbuster -n "__fish_use_subcommand" -s S -l filter-size -d 'Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)'
complete -c feroxbuster -n "__fish_use_subcommand" -s X -l filter-regex -d 'Filter out messages via regular expression matching on the response\'s body (ex: -X \'^ignore me$\')'
complete -c feroxbuster -n "__fish_use_subcommand" -s W -l filter-words -d 'Filter out messages of a particular word count (ex: -W 312 -W 91,82)'
complete -c feroxbuster -n "__fish_use_subcommand" -s N -l filter-lines -d 'Filter out messages of a particular line count (ex: -N 20 -N 31,30)'
complete -c feroxbuster -n "__fish_use_subcommand" -s C -l filter-status -d 'Filter out status codes (deny list) (ex: -C 200 -C 401)'
complete -c feroxbuster -n "__fish_use_subcommand" -s L -l scan-limit -d 'Limit total number of concurrent scans (default: 0, i.e. no limit)'
complete -c feroxbuster -n "__fish_use_subcommand" -l time-limit -d 'Limit total run time of all scans (ex: --time-limit 10m)'
complete -c feroxbuster -n "__fish_use_subcommand" -s v -l verbosity -d 'Increase verbosity level (use -vv or more for greater effect. [CAUTION] 4 -v\'s is probably too much)'
complete -c feroxbuster -n "__fish_use_subcommand" -s q -l quiet -d 'Only print URLs; Don\'t print status codes, response size, running config, etc...'
complete -c feroxbuster -n "__fish_use_subcommand" -l json -d 'Emit JSON logs to --output and --debug-log instead of normal text'
complete -c feroxbuster -n "__fish_use_subcommand" -s D -l dont-filter -d 'Don\'t auto-filter wildcard responses'
complete -c feroxbuster -n "__fish_use_subcommand" -s r -l redirects -d 'Follow redirects'
complete -c feroxbuster -n "__fish_use_subcommand" -s k -l insecure -d 'Disables TLS certificate validation'
complete -c feroxbuster -n "__fish_use_subcommand" -s n -l no-recursion -d 'Do not scan recursively'
complete -c feroxbuster -n "__fish_use_subcommand" -s f -l add-slash -d 'Append / to each request'
complete -c feroxbuster -n "__fish_use_subcommand" -l stdin -d 'Read url(s) from STDIN'
complete -c feroxbuster -n "__fish_use_subcommand" -s e -l extract-links -d 'Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)'
complete -c feroxbuster -n "__fish_use_subcommand" -s h -l help -d 'Prints help information'
complete -c feroxbuster -n "__fish_use_subcommand" -s V -l version -d 'Prints version information'

View File

@@ -1,6 +1,6 @@
use crate::config::{Configuration, CONFIGURATION};
use crate::utils::{make_request, status_colorizer};
use console::style;
use console::{style, Emoji};
use reqwest::{Client, Url};
use serde_json::Value;
use std::io::Write;
@@ -126,6 +126,14 @@ async fn needs_update(client: &Client, url: &str, bin_version: &str) -> UpdateSt
unknown
}
/// Simple wrapper for emoji or fallback when terminal doesn't support emoji
fn format_emoji(emoji: &str) -> String {
let width = console::measure_text_width(emoji);
let pad_len = width * width;
let pad = format!("{:<pad_len$}", "\u{0020}", pad_len = pad_len);
Emoji(emoji, &pad).to_string()
}
/// Prints the banner to stdout.
///
/// Only prints those settings which are either always present, or passed in by the user.
@@ -138,10 +146,10 @@ where
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher {} ver: {}"#,
'\u{1F913}', version
by Ben "epi" Risher {} ver: {}"#,
Emoji("🤓", &format!("{:<2}", "\u{0020}")),
version
);
let status = needs_update(&CONFIGURATION.client, UPDATE_URL, version).await;
let top = "───────────────────────────┬──────────────────────";
@@ -156,7 +164,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1F3af}", "Target Url", target)
format_banner_entry!(format_emoji("🎯"), "Target Url", target)
)
.unwrap_or_default(); // 🎯
}
@@ -170,14 +178,14 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1F680}", "Threads", config.threads)
format_banner_entry!(format_emoji("🚀"), "Threads", config.threads)
)
.unwrap_or_default(); // 🚀
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4d6}", "Wordlist", config.wordlist)
format_banner_entry!(format_emoji("📖"), "Wordlist", config.wordlist)
)
.unwrap_or_default(); // 📖
@@ -185,7 +193,7 @@ by Ben "epi" Risher {} ver: {}"#,
&mut writer,
"{}",
format_banner_entry!(
"\u{1F197}",
format_emoji("🆗"),
"Status Codes",
format!("[{}]", codes.join(", "))
)
@@ -205,7 +213,7 @@ by Ben "epi" Risher {} ver: {}"#,
&mut writer,
"{}",
format_banner_entry!(
"\u{1f5d1}",
format_emoji("🗑"),
"Status Code Filters",
format!("[{}]", code_filters.join(", "))
)
@@ -216,14 +224,14 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4a5}", "Timeout (secs)", config.timeout)
format_banner_entry!(format_emoji("💥"), "Timeout (secs)", config.timeout)
)
.unwrap_or_default(); // 💥
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1F9a1}", "User-Agent", config.user_agent)
format_banner_entry!(format_emoji("🦡"), "User-Agent", config.user_agent)
)
.unwrap_or_default(); // 🦡
@@ -232,7 +240,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f489}", "Config File", config.config)
format_banner_entry!(format_emoji("💉"), "Config File", config.config)
)
.unwrap_or_default(); // 💉
}
@@ -241,7 +249,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f48e}", "Proxy", config.proxy)
format_banner_entry!(format_emoji("💎"), "Proxy", config.proxy)
)
.unwrap_or_default(); // 💎
}
@@ -255,7 +263,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f3a5}", "Replay Proxy", config.replay_proxy)
format_banner_entry!(format_emoji("🎥"), "Replay Proxy", config.replay_proxy)
)
.unwrap_or_default(); // 🎥
@@ -267,7 +275,7 @@ by Ben "epi" Risher {} ver: {}"#,
&mut writer,
"{}",
format_banner_entry!(
"\u{1f4fc}",
format_emoji("📼"),
"Replay Proxy Codes",
format!("[{}]", replay_codes.join(", "))
)
@@ -280,7 +288,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f92f}", "Header", name, value)
format_banner_entry!(format_emoji("🤯"), "Header", name, value)
)
.unwrap_or_default(); // 🤯
}
@@ -291,7 +299,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4a2}", "Size Filter", filter)
format_banner_entry!(format_emoji("💢"), "Size Filter", filter)
)
.unwrap_or_default(); // 💢
}
@@ -301,7 +309,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4a2}", "Word Count Filter", filter)
format_banner_entry!(format_emoji("💢"), "Word Count Filter", filter)
)
.unwrap_or_default(); // 💢
}
@@ -310,7 +318,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4a2}", "Line Count Filter", filter)
format_banner_entry!(format_emoji("💢"), "Line Count Filter", filter)
)
.unwrap_or_default(); // 💢
}
@@ -319,7 +327,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4a2}", "Regex Filter", filter)
format_banner_entry!(format_emoji("💢"), "Regex Filter", filter)
)
.unwrap_or_default(); // 💢
}
@@ -328,7 +336,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1F50E}", "Extract Links", config.extract_links)
format_banner_entry!(format_emoji("🔎"), "Extract Links", config.extract_links)
)
.unwrap_or_default(); // 🔎
}
@@ -337,7 +345,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1F9d4}", "JSON Output", config.json)
format_banner_entry!(format_emoji("🧔"), "JSON Output", config.json)
)
.unwrap_or_default(); // 🧔
}
@@ -348,7 +356,7 @@ by Ben "epi" Risher {} ver: {}"#,
&mut writer,
"{}",
format_banner_entry!(
"\u{1f914}",
format_emoji("🤔"),
"Query Parameter",
format!("{}={}", query.0, query.1)
)
@@ -361,7 +369,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4be}", "Output File", config.output)
format_banner_entry!(format_emoji("💾"), "Output File", config.output)
)
.unwrap_or_default(); // 💾
}
@@ -370,7 +378,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1fab2}", "Debugging Log", config.debug_log)
format_banner_entry!(format_emoji("🪲"), "Debugging Log", config.debug_log)
)
.unwrap_or_default(); // 🪲
}
@@ -380,7 +388,7 @@ by Ben "epi" Risher {} ver: {}"#,
&mut writer,
"{}",
format_banner_entry!(
"\u{1f4b2}",
format_emoji("💲"),
"Extensions",
format!("[{}]", config.extensions.join(", "))
)
@@ -392,7 +400,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f513}", "Insecure", config.insecure)
format_banner_entry!(format_emoji("🔓"), "Insecure", config.insecure)
)
.unwrap_or_default(); // 🔓
}
@@ -401,7 +409,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4cd}", "Follow Redirects", config.redirects)
format_banner_entry!(format_emoji("📍"), "Follow Redirects", config.redirects)
)
.unwrap_or_default(); // 📍
}
@@ -410,7 +418,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f92a}", "Filter Wildcards", !config.dont_filter)
format_banner_entry!(format_emoji("🤪"), "Filter Wildcards", !config.dont_filter)
)
.unwrap_or_default(); // 🤪
}
@@ -421,7 +429,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f508}", "Verbosity", config.verbosity)
format_banner_entry!(format_emoji("🔈"), "Verbosity", config.verbosity)
)
.unwrap_or_default(); // 🔈
}
@@ -429,7 +437,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f509}", "Verbosity", config.verbosity)
format_banner_entry!(format_emoji("🔉"), "Verbosity", config.verbosity)
)
.unwrap_or_default(); // 🔉
}
@@ -437,7 +445,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f50a}", "Verbosity", config.verbosity)
format_banner_entry!(format_emoji("🔊"), "Verbosity", config.verbosity)
)
.unwrap_or_default(); // 🔊
}
@@ -445,7 +453,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4e2}", "Verbosity", config.verbosity)
format_banner_entry!(format_emoji("📢"), "Verbosity", config.verbosity)
)
.unwrap_or_default(); // 📢
}
@@ -456,7 +464,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1fa93}", "Add Slash", config.add_slash)
format_banner_entry!(format_emoji("🪓"), "Add Slash", config.add_slash)
)
.unwrap_or_default(); // 🪓
}
@@ -466,14 +474,14 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f503}", "Recursion Depth", "INFINITE")
format_banner_entry!(format_emoji("🔃"), "Recursion Depth", "INFINITE")
)
.unwrap_or_default(); // 🔃
} else {
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f503}", "Recursion Depth", config.depth)
format_banner_entry!(format_emoji("🔃"), "Recursion Depth", config.depth)
)
.unwrap_or_default(); // 🔃
}
@@ -481,7 +489,7 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f6ab}", "Do Not Recurse", config.no_recursion)
format_banner_entry!(format_emoji("🚫"), "Do Not Recurse", config.no_recursion)
)
.unwrap_or_default(); // 🚫
}
@@ -490,17 +498,30 @@ by Ben "epi" Risher {} ver: {}"#,
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f9a5}", "Concurrent Scan Limit", config.scan_limit)
format_banner_entry!(
format_emoji("🦥"),
"Concurrent Scan Limit",
config.scan_limit
)
)
.unwrap_or_default(); // 🦥
}
if !CONFIGURATION.time_limit.is_empty() {
writeln!(
&mut writer,
"{}",
format_banner_entry!(format_emoji("🕖"), "Time Limit", config.time_limit)
)
.unwrap_or_default(); // 🕖
}
if matches!(status, UpdateStatus::OutOfDate) {
writeln!(
&mut writer,
"{}",
format_banner_entry!(
"\u{1f389}",
format_emoji("🎉"),
"New Version Available",
"https://github.com/epi052/feroxbuster/releases/latest"
)
@@ -512,12 +533,14 @@ by Ben "epi" Risher {} ver: {}"#,
// ⏯
writeln!(
&mut writer,
" \u{23ef} Press [{}] to {}|{} your scan",
" {} Press [{}] to {}|{} your scan",
format_emoji(""),
style("ENTER").yellow(),
style("pause").red(),
style("resume").green()
)
.unwrap_or_default();
writeln!(&mut writer, "{}", addl_section).unwrap_or_default();
}
@@ -526,7 +549,7 @@ mod tests {
use super::*;
use crate::VERSION;
use httpmock::Method::GET;
use httpmock::{Mock, MockServer};
use httpmock::MockServer;
use std::fs::read_to_string;
use std::io::stderr;
use std::time::Duration;
@@ -611,16 +634,14 @@ mod tests {
async fn banner_needs_update_returns_up_to_date() {
let srv = MockServer::start();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/latest")
.return_status(200)
.return_body("{\"tag_name\":\"v1.1.0\"}")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200).body("{\"tag_name\":\"v1.1.0\"}");
});
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.1.0").await;
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert!(matches!(result, UpdateStatus::UpToDate));
}
@@ -629,16 +650,14 @@ mod tests {
async fn banner_needs_update_returns_out_of_date() {
let srv = MockServer::start();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/latest")
.return_status(200)
.return_body("{\"tag_name\":\"v1.1.0\"}")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200).body("{\"tag_name\":\"v1.1.0\"}");
});
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert!(matches!(result, UpdateStatus::OutOfDate));
}
@@ -647,17 +666,16 @@ mod tests {
async fn banner_needs_update_returns_unknown_on_timeout() {
let srv = MockServer::start();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/latest")
.return_status(200)
.return_body("{\"tag_name\":\"v1.1.0\"}")
.return_with_delay(Duration::from_secs(8))
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200)
.body("{\"tag_name\":\"v1.1.0\"}")
.delay(Duration::from_secs(8));
});
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert!(matches!(result, UpdateStatus::Unknown));
}
@@ -666,16 +684,14 @@ mod tests {
async fn banner_needs_update_returns_unknown_on_bad_json_response() {
let srv = MockServer::start();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/latest")
.return_status(200)
.return_body("not json")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200).body("not json");
});
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert!(matches!(result, UpdateStatus::Unknown));
}
@@ -684,16 +700,15 @@ mod tests {
async fn banner_needs_update_returns_unknown_on_json_without_correct_tag() {
let srv = MockServer::start();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/latest")
.return_status(200)
.return_body("{\"no tag_name\": \"doesn't exist\"}")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/latest");
then.status(200)
.body("{\"no tag_name\": \"doesn't exist\"}");
});
let result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert!(matches!(result, UpdateStatus::Unknown));
}
}

View File

@@ -1,7 +1,8 @@
use crate::scan_manager::resume_scan;
use crate::utils::{module_colorizer, status_colorizer};
use crate::{client, parser, progress};
use crate::{FeroxSerialize, DEFAULT_CONFIG_NAME, DEFAULT_STATUS_CODES, DEFAULT_WORDLIST, VERSION};
use clap::value_t;
use clap::{value_t, ArgMatches};
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget};
use lazy_static::lazy_static;
use reqwest::{Client, StatusCode};
@@ -21,7 +22,33 @@ lazy_static! {
pub static ref PROGRESS_BAR: MultiProgress = MultiProgress::with_draw_target(ProgressDrawTarget::stdout());
/// Global progress bar that is only used for printing messages that don't jack up other bars
pub static ref PROGRESS_PRINTER: ProgressBar = progress::add_bar("", 0, true);
pub static ref PROGRESS_PRINTER: ProgressBar = progress::add_bar("", 0, true, false);
}
/// macro helper to abstract away repetitive configuration updates
macro_rules! update_config_if_present {
($c:expr, $m:ident, $v:expr, $t:ty) => {
match value_t!($m, $v, $t) {
Ok(value) => *$c = value, // Update value
Err(clap::Error {
kind: clap::ErrorKind::ArgumentNotFound,
message: _,
info: _,
}) => {
// Do nothing if argument not found
}
Err(e) => e.exit(), // Exit with error on parse error
}
};
}
/// macro helper to abstract away repetitive if not default: update checks
macro_rules! update_if_not_default {
($old:expr, $new:expr, $default:expr) => {
if $new != $default {
*$old = $new;
}
};
}
/// simple helper to clean up some code reuse below; panics under test / exits in prod
@@ -80,7 +107,7 @@ pub struct Configuration {
pub status_codes: Vec<u16>,
/// Status Codes to replay to the Replay Proxy (default: whatever is passed to --status-code)
#[serde(default)]
#[serde(default = "status_codes")]
pub replay_codes: Vec<u16>,
/// Status Codes to filter out (deny list)
@@ -191,9 +218,24 @@ pub struct Configuration {
/// Don't auto-filter wildcard responses
#[serde(default)]
pub dont_filter: bool,
/// Scan started from a state file, not from CLI args
#[serde(default)]
pub resumed: bool,
/// Whether or not a scan's current state should be saved when user presses Ctrl+C
///
/// Not configurable from CLI; can only be set from a config file
#[serde(default = "save_state")]
pub save_state: bool,
/// The maximum runtime for a scan, expressed as N[smdh] where N can be parsed into a
/// non-negative integer and the next character is either s, m, h, or d (case insensitive)
#[serde(default)]
pub time_limit: String,
}
// functions timeout, threads, status_codes, user_agent, wordlist, and depth are used to provide
// functions timeout, threads, status_codes, user_agent, wordlist, save_state, and depth are used to provide
// defaults in the event that a ferox-config.toml is found but one or more of the values below
// aren't listed in the config. This way, we get the correct defaults upon Deserialization
@@ -207,6 +249,11 @@ fn timeout() -> u64 {
7
}
/// default save_state value
fn save_state() -> bool {
true
}
/// default threads value
fn threads() -> usize {
50
@@ -256,6 +303,7 @@ impl Default for Configuration {
replay_client,
dont_filter: false,
quiet: false,
resumed: false,
stdin: false,
json: false,
verbosity: 0,
@@ -265,11 +313,13 @@ impl Default for Configuration {
redirects: false,
no_recursion: false,
extract_links: false,
save_state: true,
proxy: String::new(),
config: String::new(),
output: String::new(),
debug_log: String::new(),
target_url: String::new(),
time_limit: String::new(),
replay_proxy: String::new(),
queries: Vec::new(),
extensions: Vec::new(),
@@ -304,6 +354,7 @@ impl Configuration {
/// - **output**: `None` (print to stdout)
/// - **debug_log**: `None`
/// - **quiet**: `false`
/// - **save_state**: `true`
/// - **user_agent**: `feroxbuster/VERSION`
/// - **insecure**: `false` (don't be insecure, i.e. don't allow invalid certs)
/// - **extensions**: `None`
@@ -320,6 +371,7 @@ impl Configuration {
/// - **dont_filter**: `false` (auto filter wildcard responses)
/// - **depth**: `4` (maximum recursion depth)
/// - **scan_limit**: `0` (no limit on concurrent scans imposed)
/// - **time_limit**: `None` (no limit on length of scan imposed)
/// - **replay_proxy**: `None` (no limit on concurrent scans imposed)
/// - **replay_codes**: [`DEFAULT_RESPONSE_CODES`](constant.DEFAULT_RESPONSE_CODES.html)
///
@@ -343,13 +395,65 @@ impl Configuration {
pub fn new() -> Self {
// when compiling for test, we want to eliminate the runtime dependency of the parser
if cfg!(test) {
return Configuration::default();
let mut test_config = Configuration::default();
test_config.save_state = false; // don't clutter up junk when testing
return test_config;
}
let args = parser::initialize().get_matches();
// Get the default configuration, this is what will apply if nothing
// else is specified.
let mut config = Configuration::default();
// read in all config files
Self::parse_config_files(&mut config);
// read in the user provided options, this produces a separate instance of Configuration
// in order to allow for potentially merging into a --resume-from Configuration
let cli_config = Self::parse_cli_args(&args);
// --resume-from used, need to first read the Configuration from disk, and then
// merge the cli_config into the resumed config
if let Some(filename) = args.value_of("resume_from") {
// when resuming a scan, instead of normal configuration loading, we just
// load the config from disk by calling resume_scan
let mut previous_config = resume_scan(filename);
// if any other arguments were passed on the command line, the theory is that the
// user meant to modify the previously cancelled/saved scan in some way that we
// should take into account
Self::merge_config(&mut previous_config, cli_config);
// the resumed flag isn't printed in the banner and really has no business being
// serialized or included in much of the usual config logic; simply setting it to true
// here and being done with it
previous_config.resumed = true;
// if the user used --stdin, we already have all the scans started (or complete), we
// need to flip stdin to false so that the 'read from stdin' logic doesn't fire (if
// not flipped to false, the program hangs waiting for input from stdin again)
previous_config.stdin = false;
// clients aren't serialized, have to remake them from the previous config
Self::try_rebuild_clients(&mut previous_config);
return previous_config;
}
// if we've gotten to this point in the code, --resume-from was not used, so we need to
// merge the cli options into the config file options and return the result
Self::merge_config(&mut config, cli_config);
// rebuild clients is the last step in either code branch
Self::try_rebuild_clients(&mut config);
config
}
/// Parse all possible versions of the ferox-config.toml file, adhering to the order of
/// precedence outlined above
fn parse_config_files(mut config: &mut Self) {
// Next, we parse the ferox-config.toml file, if present and set the values
// therein to overwrite our default values. Deserialized defaults are specified
// in the Configuration struct so that we don't change anything that isn't
@@ -391,24 +495,12 @@ impl Configuration {
let config_file = cwd.join(DEFAULT_CONFIG_NAME);
Self::parse_and_merge_config(config_file, &mut config);
}
}
let args = parser::initialize().get_matches();
macro_rules! update_config_if_present {
($c:expr, $m:ident, $v:expr, $t:ty) => {
match value_t!($m, $v, $t) {
Ok(value) => *$c = value, // Update value
Err(clap::Error {
kind: clap::ErrorKind::ArgumentNotFound,
message: _,
info: _,
}) => {
// Do nothing if argument not found
}
Err(e) => e.exit(), // Exit with error on parse error
}
};
}
/// Given a set of ArgMatches read from the CLI, update and return the default Configuration
/// settings
fn parse_cli_args(args: &ArgMatches) -> Self {
let mut config = Configuration::default();
update_config_if_present!(&mut config.threads, args, "threads", usize);
update_config_if_present!(&mut config.depth, args, "depth", usize);
@@ -416,6 +508,7 @@ impl Configuration {
update_config_if_present!(&mut config.wordlist, args, "wordlist", String);
update_config_if_present!(&mut config.output, args, "output", String);
update_config_if_present!(&mut config.debug_log, args, "debug_log", String);
update_config_if_present!(&mut config.time_limit, args, "time_limit", String);
if let Some(arg) = args.values_of("status_codes") {
config.status_codes = arg
@@ -522,8 +615,8 @@ impl Configuration {
if args.is_present("stdin") {
config.stdin = true;
} else {
config.target_url = String::from(args.value_of("url").unwrap());
} else if let Some(url) = args.value_of("url") {
config.target_url = String::from(url);
}
////
@@ -569,50 +662,53 @@ impl Configuration {
}
}
// this if statement determines if we've gotten a Client configuration change from
// either the config file or command line arguments; if we have, we need to rebuild
// the client and store it in the config struct
if !config.proxy.is_empty()
|| config.timeout != timeout()
|| config.user_agent != user_agent()
|| config.redirects
|| config.insecure
|| !config.headers.is_empty()
config
}
/// this function determines if we've gotten a Client configuration change from
/// either the config file or command line arguments; if we have, we need to rebuild
/// the client and store it in the config struct
fn try_rebuild_clients(configuration: &mut Configuration) {
if !configuration.proxy.is_empty()
|| configuration.timeout != timeout()
|| configuration.user_agent != user_agent()
|| configuration.redirects
|| configuration.insecure
|| !configuration.headers.is_empty()
|| configuration.resumed
{
if config.proxy.is_empty() {
config.client = client::initialize(
config.timeout,
&config.user_agent,
config.redirects,
config.insecure,
&config.headers,
if configuration.proxy.is_empty() {
configuration.client = client::initialize(
configuration.timeout,
&configuration.user_agent,
configuration.redirects,
configuration.insecure,
&configuration.headers,
None,
)
} else {
config.client = client::initialize(
config.timeout,
&config.user_agent,
config.redirects,
config.insecure,
&config.headers,
Some(&config.proxy),
configuration.client = client::initialize(
configuration.timeout,
&configuration.user_agent,
configuration.redirects,
configuration.insecure,
&configuration.headers,
Some(&configuration.proxy),
)
}
}
if !config.replay_proxy.is_empty() {
if !configuration.replay_proxy.is_empty() {
// only set replay_client when replay_proxy is set
config.replay_client = Some(client::initialize(
config.timeout,
&config.user_agent,
config.redirects,
config.insecure,
&config.headers,
Some(&config.replay_proxy),
configuration.replay_client = Some(client::initialize(
configuration.timeout,
&configuration.user_agent,
configuration.redirects,
configuration.insecure,
&configuration.headers,
Some(&configuration.replay_proxy),
));
}
config
}
/// Given a configuration file's location and an instance of `Configuration`, read in
@@ -636,37 +732,64 @@ impl Configuration {
}
/// Given two Configurations, overwrite `settings` with the fields found in `settings_to_merge`
fn merge_config(settings: &mut Self, settings_to_merge: Self) {
settings.threads = settings_to_merge.threads;
settings.wordlist = settings_to_merge.wordlist;
settings.status_codes = settings_to_merge.status_codes;
settings.proxy = settings_to_merge.proxy;
settings.timeout = settings_to_merge.timeout;
settings.verbosity = settings_to_merge.verbosity;
settings.quiet = settings_to_merge.quiet;
settings.output = settings_to_merge.output;
settings.user_agent = settings_to_merge.user_agent;
settings.redirects = settings_to_merge.redirects;
settings.insecure = settings_to_merge.insecure;
settings.extract_links = settings_to_merge.extract_links;
settings.extensions = settings_to_merge.extensions;
settings.headers = settings_to_merge.headers;
settings.queries = settings_to_merge.queries;
settings.no_recursion = settings_to_merge.no_recursion;
settings.add_slash = settings_to_merge.add_slash;
settings.stdin = settings_to_merge.stdin;
settings.depth = settings_to_merge.depth;
settings.filter_size = settings_to_merge.filter_size;
settings.filter_regex = settings_to_merge.filter_regex;
settings.filter_word_count = settings_to_merge.filter_word_count;
settings.filter_line_count = settings_to_merge.filter_line_count;
settings.filter_status = settings_to_merge.filter_status;
settings.dont_filter = settings_to_merge.dont_filter;
settings.scan_limit = settings_to_merge.scan_limit;
settings.replay_proxy = settings_to_merge.replay_proxy;
settings.replay_codes = settings_to_merge.replay_codes;
settings.debug_log = settings_to_merge.debug_log;
settings.json = settings_to_merge.json;
fn merge_config(conf: &mut Self, new: Self) {
// does not include the following Configuration fields, as they don't make sense here
// - kind
// - client
// - replay_client
// - resumed
// - config
update_if_not_default!(&mut conf.target_url, new.target_url, "");
update_if_not_default!(&mut conf.time_limit, new.time_limit, "");
update_if_not_default!(&mut conf.proxy, new.proxy, "");
update_if_not_default!(&mut conf.verbosity, new.verbosity, 0);
update_if_not_default!(&mut conf.quiet, new.quiet, false);
update_if_not_default!(&mut conf.output, new.output, "");
update_if_not_default!(&mut conf.redirects, new.redirects, false);
update_if_not_default!(&mut conf.insecure, new.insecure, false);
update_if_not_default!(&mut conf.extract_links, new.extract_links, false);
update_if_not_default!(&mut conf.extensions, new.extensions, Vec::<String>::new());
update_if_not_default!(&mut conf.headers, new.headers, HashMap::new());
update_if_not_default!(&mut conf.queries, new.queries, Vec::new());
update_if_not_default!(&mut conf.no_recursion, new.no_recursion, false);
update_if_not_default!(&mut conf.add_slash, new.add_slash, false);
update_if_not_default!(&mut conf.stdin, new.stdin, false);
update_if_not_default!(&mut conf.filter_size, new.filter_size, Vec::<u64>::new());
update_if_not_default!(
&mut conf.filter_regex,
new.filter_regex,
Vec::<String>::new()
);
update_if_not_default!(
&mut conf.filter_word_count,
new.filter_word_count,
Vec::<usize>::new()
);
update_if_not_default!(
&mut conf.filter_line_count,
new.filter_line_count,
Vec::<usize>::new()
);
update_if_not_default!(
&mut conf.filter_status,
new.filter_status,
Vec::<u16>::new()
);
update_if_not_default!(&mut conf.dont_filter, new.dont_filter, false);
update_if_not_default!(&mut conf.scan_limit, new.scan_limit, 0);
update_if_not_default!(&mut conf.replay_proxy, new.replay_proxy, "");
update_if_not_default!(&mut conf.debug_log, new.debug_log, "");
update_if_not_default!(&mut conf.json, new.json, false);
update_if_not_default!(&mut conf.timeout, new.timeout, timeout());
update_if_not_default!(&mut conf.user_agent, new.user_agent, user_agent());
update_if_not_default!(&mut conf.threads, new.threads, threads());
update_if_not_default!(&mut conf.depth, new.depth, depth());
update_if_not_default!(&mut conf.wordlist, new.wordlist, wordlist());
update_if_not_default!(&mut conf.status_codes, new.status_codes, status_codes());
// status_codes() is the default for replay_codes, if they're not provided
update_if_not_default!(&mut conf.replay_codes, new.replay_codes, status_codes());
update_if_not_default!(&mut conf.save_state, new.save_state, save_state());
}
/// If present, read in `DEFAULT_CONFIG_NAME` and deserialize the specified values
@@ -752,6 +875,7 @@ mod tests {
quiet = true
verbosity = 1
scan_limit = 6
time_limit = "10m"
output = "/some/otherpath"
debug_log = "/yet/anotherpath"
redirects = true
@@ -765,6 +889,7 @@ mod tests {
dont_filter = true
extract_links = true
json = true
save_state = false
depth = 1
filter_size = [4120]
filter_regex = ["^ignore me$"]
@@ -785,6 +910,7 @@ mod tests {
assert_eq!(config.wordlist, wordlist());
assert_eq!(config.proxy, String::new());
assert_eq!(config.target_url, String::new());
assert_eq!(config.time_limit, String::new());
assert_eq!(config.debug_log, String::new());
assert_eq!(config.config, String::new());
assert_eq!(config.replay_proxy, String::new());
@@ -800,6 +926,7 @@ mod tests {
assert_eq!(config.dont_filter, false);
assert_eq!(config.no_recursion, false);
assert_eq!(config.json, false);
assert_eq!(config.save_state, true);
assert_eq!(config.stdin, false);
assert_eq!(config.add_slash, false);
assert_eq!(config.redirects, false);
@@ -1004,6 +1131,20 @@ mod tests {
assert_eq!(config.filter_status, vec![201]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_save_state() {
let config = setup_config_test();
assert_eq!(config.save_state, false);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_time_limit() {
let config = setup_config_test();
assert_eq!(config.time_limit, "10m");
}
#[test]
/// parse the test config and see that the values parsed are correct
fn config_reads_headers() {

View File

@@ -143,7 +143,7 @@ mod tests {
use super::*;
use crate::utils::make_request;
use httpmock::Method::GET;
use httpmock::{Mock, MockServer};
use httpmock::MockServer;
use reqwest::Client;
#[test]
@@ -245,12 +245,12 @@ mod tests {
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/some-path")
.return_status(200)
.return_body("\"http://defintely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"")
.create_on(&srv);
let mock = srv.mock(|when, then|{
when.method(GET)
.path("/some-path");
then.status(200)
.body("\"http://defintely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"");
});
let client = Client::new();
let url = Url::parse(&srv.url("/some-path")).unwrap();
@@ -263,7 +263,7 @@ mod tests {
assert!(links.is_empty());
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
Ok(())
}
}

View File

@@ -360,6 +360,8 @@ mod tests {
wildcard: true,
url: Url::parse("http://localhost").unwrap(),
content_length: 100,
word_count: 50,
line_count: 25,
headers: reqwest::header::HeaderMap::new(),
status: reqwest::StatusCode::OK,
};
@@ -380,6 +382,8 @@ mod tests {
wildcard: true,
url: Url::parse("http://localhost/stuff").unwrap(),
content_length: 100,
word_count: 50,
line_count: 25,
headers: reqwest::header::HeaderMap::new(),
status: reqwest::StatusCode::OK,
};
@@ -400,6 +404,8 @@ mod tests {
wildcard: false,
url: Url::parse("http://localhost/stuff").unwrap(),
content_length: 100,
word_count: 50,
line_count: 25,
headers: reqwest::header::HeaderMap::new(),
status: reqwest::StatusCode::OK,
};

View File

@@ -14,9 +14,13 @@ pub mod utils;
use crate::utils::{get_url_path_length, status_colorizer};
use console::{style, Color};
use reqwest::header::{HeaderName, HeaderValue};
use reqwest::{header::HeaderMap, Response, StatusCode, Url};
use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer};
use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use std::collections::HashMap;
use std::convert::{TryFrom, TryInto};
use std::str::FromStr;
use std::{error, fmt};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
@@ -112,6 +116,12 @@ pub struct FeroxResponse {
/// The content-length of this response, if known
content_length: u64,
/// The number of lines contained in the body of this response, if known
line_count: usize,
/// The number of words contained in the body of this response, if known
word_count: usize,
/// The `Headers` of this `FeroxResponse`
headers: HeaderMap,
@@ -194,15 +204,12 @@ impl FeroxResponse {
/// Returns line count of the response text.
pub fn line_count(&self) -> usize {
self.text().lines().count()
self.line_count
}
/// Returns word count of the response text.
pub fn word_count(&self) -> usize {
self.text()
.lines()
.map(|s| s.split_whitespace().count())
.sum()
self.word_count
}
/// Create a new `FeroxResponse` from the given `Response`
@@ -228,18 +235,23 @@ impl FeroxResponse {
String::new()
};
let line_count = text.lines().count();
let word_count = text.lines().map(|s| s.split_whitespace().count()).sum();
FeroxResponse {
url,
status,
content_length,
text,
headers,
line_count,
word_count,
wildcard: false,
}
}
}
/// Implement FeroxSerialize for FeroxResponse
/// Implement FeroxSerialusize::from(ize for FeroxRespons)e
impl FeroxSerialize for FeroxResponse {
/// Simple wrapper around create_report_string
fn as_str(&self) -> String {
@@ -359,18 +371,99 @@ impl Serialize for FeroxResponse {
state.serialize_field("wildcard", &self.wildcard)?;
state.serialize_field("status", &self.status.as_u16())?;
state.serialize_field("content_length", &self.content_length)?;
state.serialize_field("line_count", &self.line_count())?;
state.serialize_field("word_count", &self.word_count())?;
state.serialize_field("line_count", &self.line_count)?;
state.serialize_field("word_count", &self.word_count)?;
state.serialize_field("headers", &headers)?;
state.end()
}
}
/// Deserialize implementation for FeroxResponse
impl<'de> Deserialize<'de> for FeroxResponse {
/// Deserialize a FeroxResponse from a serde_json::Value
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let mut response = Self {
url: Url::parse("http://localhost").unwrap(),
status: StatusCode::OK,
text: String::new(),
content_length: 0,
headers: HeaderMap::new(),
wildcard: false,
line_count: 0,
word_count: 0,
};
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
for (key, value) in &map {
match key.as_str() {
"url" => {
if let Some(url) = value.as_str() {
if let Ok(parsed) = Url::parse(url) {
response.url = parsed;
}
}
}
"status" => {
if let Some(num) = value.as_u64() {
if let Ok(smaller) = u16::try_from(num) {
if let Ok(status) = StatusCode::from_u16(smaller) {
response.status = status;
}
}
}
}
"content_length" => {
if let Some(num) = value.as_u64() {
response.content_length = num;
}
}
"line_count" => {
if let Some(num) = value.as_u64() {
response.line_count = num.try_into().unwrap_or_default();
}
}
"word_count" => {
if let Some(num) = value.as_u64() {
response.word_count = num.try_into().unwrap_or_default();
}
}
"headers" => {
let mut headers = HeaderMap::<HeaderValue>::default();
if let Some(map_headers) = value.as_object() {
for (h_key, h_value) in map_headers {
let h_value_str = h_value.as_str().unwrap_or("");
let h_name = HeaderName::from_str(h_key)
.unwrap_or_else(|_| HeaderName::from_str("Unknown").unwrap());
let h_value_parsed = HeaderValue::from_str(h_value_str)
.unwrap_or_else(|_| HeaderValue::from_str("Unknown").unwrap());
headers.insert(h_name, h_value_parsed);
}
}
response.headers = headers;
}
"wildcard" => {
if let Some(result) = value.as_bool() {
response.wildcard = result;
}
}
_ => {}
}
}
Ok(response)
}
}
#[derive(Serialize, Deserialize, Default)]
/// Representation of a log entry, can be represented as a human readable string or JSON
pub struct FeroxMessage {
// todo probably move to lib
#[serde(rename = "type")]
/// Name of this type of struct, used for serialization, i.e. `{"type":"log"}`
kind: String,

View File

@@ -1,16 +1,18 @@
use crossterm::event::{self, Event, KeyCode};
use feroxbuster::progress::add_bar;
use feroxbuster::{
banner,
config::{CONFIGURATION, PROGRESS_BAR, PROGRESS_PRINTER},
heuristics, logger, reporter,
scan_manager::PAUSE_SCAN,
scanner::{self, scan_url},
scan_manager::{self, PAUSE_SCAN},
scanner::{self, scan_url, RESPONSES, SCANNED_URLS},
utils::{ferox_print, get_current_depth, module_colorizer, status_colorizer},
FeroxError, FeroxResponse, FeroxResult, SLEEP_DURATION, VERSION,
FeroxError, FeroxResponse, FeroxResult, FeroxSerialize, SLEEP_DURATION, VERSION,
};
#[cfg(not(target_os = "windows"))]
use feroxbuster::{utils::set_open_file_limit, DEFAULT_OPEN_FILE_LIMIT};
use futures::StreamExt;
use std::convert::TryInto;
use std::{
collections::HashSet,
fs::File,
@@ -115,6 +117,31 @@ async fn scan(
scanner::initialize(words.len(), &CONFIGURATION);
if CONFIGURATION.resumed {
if let Ok(scans) = SCANNED_URLS.scans.lock() {
for scan in scans.iter() {
if let Ok(locked_scan) = scan.lock() {
if locked_scan.complete {
// these scans are complete, and just need to be shown to the user
let pb = add_bar(
&locked_scan.url,
words.len().try_into().unwrap_or_default(),
false,
true,
);
pb.finish();
}
}
}
}
if let Ok(responses) = RESPONSES.responses.read() {
for response in responses.iter() {
PROGRESS_PRINTER.println(response.as_str());
}
}
}
let mut tasks = vec![];
for target in targets {
@@ -152,6 +179,22 @@ async fn get_targets() -> FeroxResult<Vec<String>> {
while let Some(line) = reader.next().await {
targets.push(line?);
}
} else if CONFIGURATION.resumed {
// resume-from can't be used with --url, and --stdin is marked false for every resumed
// scan, making it mutually exclusive from either of the other two options
if let Ok(scans) = SCANNED_URLS.scans.lock() {
for scan in scans.iter() {
// SCANNED_URLS gets deserialized scans added to it at program start if --resume-from
// is used, so scans that aren't marked complete still need to be scanned
if let Ok(locked_scan) = scan.lock() {
if locked_scan.complete {
// this one's already done, ignore it
continue;
}
targets.push(locked_scan.url.to_owned());
}
}
}
} else {
targets.push(CONFIGURATION.target_url.clone());
}
@@ -177,6 +220,14 @@ async fn wrapped_main() {
PROGRESS_BAR.join().unwrap();
});
if !CONFIGURATION.time_limit.is_empty() {
// --time-limit value not an empty string, need to kick off the thread that enforces
// the limit
tokio::spawn(async move {
scan_manager::start_max_time_thread(&CONFIGURATION.time_limit).await
});
}
// can't trace main until after logger is initialized and the above task is started
log::trace!("enter: main");
@@ -298,6 +349,11 @@ fn main() {
// setup logging based on the number of -v's used
logger::initialize(CONFIGURATION.verbosity);
if CONFIGURATION.save_state {
// start the ctrl+c handler
scan_manager::initialize();
}
// this function uses rlimit, which is not supported on windows
#[cfg(not(target_os = "windows"))]
set_open_file_limit(DEFAULT_OPEN_FILE_LIMIT);

View File

@@ -1,10 +1,23 @@
use crate::VERSION;
use clap::{App, Arg};
use clap::{App, Arg, ArgGroup};
use lazy_static::lazy_static;
use regex::Regex;
lazy_static! {
/// Regex used to validate values passed to --time-limit
///
/// Examples of expected values that will this regex will match:
/// - 30s
/// - 20m
/// - 1h
/// - 1d
pub static ref TIMESPEC_REGEX: Regex =
Regex::new(r"^(?i)(?P<n>\d+)(?P<m>[smdh])$").expect("Could not compile regex");
}
/// Create and return an instance of [clap::App](https://docs.rs/clap/latest/clap/struct.App.html), i.e. the Command Line Interface's configuration
pub fn initialize() -> App<'static, 'static> {
App::new("feroxbuster")
.version(VERSION)
.version(env!("CARGO_PKG_VERSION"))
.author("Ben 'epi' Risher (@epi052)")
.about("A fast, simple, recursive content discovery tool written in Rust")
.arg(
@@ -19,7 +32,7 @@ pub fn initialize() -> App<'static, 'static> {
Arg::with_name("url")
.short("u")
.long("url")
.required_unless("stdin")
.required_unless_one(&["stdin", "resume_from"])
.value_name("URL")
.multiple(true)
.use_delimiter(true)
@@ -113,6 +126,7 @@ pub fn initialize() -> App<'static, 'static> {
Arg::with_name("json")
.long("json")
.takes_value(false)
.requires("output_files")
.help("Emit JSON logs to --output and --debug-log instead of normal text")
)
.arg(
@@ -130,6 +144,14 @@ pub fn initialize() -> App<'static, 'static> {
.help("Output file to write results to (use w/ --json for JSON entries)")
.takes_value(true),
)
.arg(
Arg::with_name("resume_from")
.long("resume-from")
.value_name("STATE_FILE")
.help("State file from which to resume a partially complete scan (ex. --resume-from ferox-1606586780.state)")
.conflicts_with("url")
.takes_value(true),
)
.arg(
Arg::with_name("debug_log")
.long("debug-log")
@@ -294,6 +316,18 @@ pub fn initialize() -> App<'static, 'static> {
.takes_value(true)
.help("Limit total number of concurrent scans (default: 0, i.e. no limit)")
)
.arg(
Arg::with_name("time_limit")
.long("time-limit")
.value_name("TIME_SPEC")
.takes_value(true)
.validator(valid_time_spec)
.help("Limit total run time of all scans (ex: --time-limit 10m)")
)
.group(ArgGroup::with_name("output_files")
.args(&["debug_log", "output"])
.multiple(true)
)
.after_help(r#"NOTE:
Options that take multiple values are very flexible. Consider the following ways of specifying
extensions:
@@ -331,6 +365,20 @@ EXAMPLES:
"#)
}
/// Validate that a string is formatted as a number followed by s, m, h, or d (10d, 30s, etc...)
fn valid_time_spec(time_spec: String) -> Result<(), String> {
match TIMESPEC_REGEX.is_match(&time_spec) {
true => Ok(()),
false => {
let msg = format!(
"Expected a non-negative, whole number followed by s, m, h, or d (case insensitive); received {}",
time_spec
);
Err(msg)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -341,4 +389,37 @@ mod tests {
let app = initialize();
assert_eq!(app.get_name(), "feroxbuster");
}
#[test]
/// sanity checks that valid_time_spec correctly checks and rejects a given string
///
/// instead of having a bunch of single tests here, they're all quick and are mostly checking
/// that i didn't hose up the regex. Going to consolidate them into a single test
fn validate_valid_time_spec_validation() {
let float_rejected = "1.4m";
assert!(valid_time_spec(float_rejected.into()).is_err());
let negative_rejected = "-1m";
assert!(valid_time_spec(negative_rejected.into()).is_err());
let only_number_rejected = "1";
assert!(valid_time_spec(only_number_rejected.into()).is_err());
let only_measurement_rejected = "m";
assert!(valid_time_spec(only_measurement_rejected.into()).is_err());
for accepted_measurement in &["s", "m", "h", "d", "S", "M", "H", "D"] {
// all upper/lowercase should be good
assert!(valid_time_spec(format!("1{}", *accepted_measurement)).is_ok());
}
let leading_space_rejected = " 14m";
assert!(valid_time_spec(leading_space_rejected.into()).is_err());
let trailing_space_rejected = "14m ";
assert!(valid_time_spec(trailing_space_rejected.into()).is_err());
let space_between_rejected = "1 4m";
assert!(valid_time_spec(space_between_rejected.into()).is_err());
}
}

View File

@@ -3,9 +3,16 @@ use indicatif::{ProgressBar, ProgressStyle};
/// Add an [indicatif::ProgressBar](https://docs.rs/indicatif/latest/indicatif/struct.ProgressBar.html)
/// to the global [PROGRESS_BAR](../config/struct.PROGRESS_BAR.html)
pub fn add_bar(prefix: &str, length: u64, hidden: bool) -> ProgressBar {
pub fn add_bar(prefix: &str, length: u64, hidden: bool, hide_per_sec: bool) -> ProgressBar {
let style = if hidden || CONFIGURATION.quiet {
ProgressStyle::default_bar().template("")
} else if hide_per_sec {
ProgressStyle::default_bar()
.template(&format!(
"[{{bar:.cyan/blue}}] - {{elapsed:<4}} {{pos:>7}}/{{len:7}} {:7} {{prefix}}",
"-"
))
.progress_chars("#>-")
} else {
ProgressStyle::default_bar()
.template("[{bar:.cyan/blue}] - {elapsed:<4} {pos:>7}/{len:7} {per_sec:7} {prefix}")
@@ -20,3 +27,24 @@ pub fn add_bar(prefix: &str, length: u64, hidden: bool) -> ProgressBar {
progress_bar
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// hit all code branches for add_bar
fn add_bar_with_all_configurations() {
let p1 = add_bar("prefix", 2, true, false); // hidden
let p2 = add_bar("prefix", 2, false, true); // no per second field
let p3 = add_bar("prefix", 2, false, false); // normal bar
p1.finish();
p2.finish();
p3.finish();
assert!(p1.is_finished());
assert!(p2.is_finished());
assert!(p3.is_finished());
}
}

View File

@@ -1,5 +1,6 @@
use crate::{
config::{CONFIGURATION, PROGRESS_PRINTER},
scanner::RESPONSES,
utils::{ferox_print, make_request, open_file},
FeroxChannel, FeroxResponse, FeroxSerialize,
};
@@ -93,10 +94,14 @@ async fn spawn_terminal_reporter(
save_output
);
while let Some(resp) = resp_chan.recv().await {
while let Some(mut resp) = resp_chan.recv().await {
log::trace!("received {} on reporting channel", resp.url());
if CONFIGURATION.status_codes.contains(&resp.status().as_u16()) {
let contains_sentry = CONFIGURATION.status_codes.contains(&resp.status().as_u16());
let unknown_sentry = !RESPONSES.contains(&resp); // !contains == unknown
let should_process_response = contains_sentry && unknown_sentry;
if should_process_response {
// print to stdout
ferox_print(&resp.as_str(), &PROGRESS_PRINTER);
@@ -114,9 +119,7 @@ async fn spawn_terminal_reporter(
}
log::trace!("report complete: {}", resp.url());
if CONFIGURATION.replay_client.is_some()
&& CONFIGURATION.replay_codes.contains(&resp.status().as_u16())
{
if CONFIGURATION.replay_client.is_some() && should_process_response {
// replay proxy specified/client created and this response's status code is one that
// should be replayed
match make_request(CONFIGURATION.replay_client.as_ref().unwrap(), &resp.url()).await {
@@ -126,6 +129,18 @@ async fn spawn_terminal_reporter(
}
}
}
if should_process_response {
// add response to RESPONSES for serialization in case of ctrl+c
// placed all by its lonesome like this so that RESPONSES can take ownership
// of the FeroxResponse
// before ownership is transferred, there's no real reason to keep the body anymore
// so we can free that piece of data, reducing memory usage
resp.text = String::new();
RESPONSES.insert(resp);
}
}
log::trace!("exit: spawn_terminal_reporter");
}

View File

@@ -1,11 +1,29 @@
use crate::{config::PROGRESS_PRINTER, progress, scanner::NUMBER_OF_REQUESTS, SLEEP_DURATION};
use crate::config::Configuration;
use crate::reporter::safe_file_write;
use crate::utils::open_file;
use crate::{
config::{CONFIGURATION, PROGRESS_PRINTER},
parser::TIMESPEC_REGEX,
progress,
scanner::{NUMBER_OF_REQUESTS, RESPONSES, SCANNED_URLS},
FeroxResponse, FeroxSerialize, SLEEP_DURATION,
};
use console::style;
use indicatif::{ProgressBar, ProgressStyle};
use lazy_static::lazy_static;
use serde::{
ser::{SerializeSeq, SerializeStruct},
Deserialize, Deserializer, Serialize, Serializer,
};
use serde_json::Value;
use std::collections::HashMap;
use std::{
cmp::PartialEq,
fmt,
fs::File,
io::BufReader,
sync::{Arc, Mutex, RwLock},
time::{SystemTime, UNIX_EPOCH},
};
use std::{
io::{stderr, Write},
@@ -28,12 +46,20 @@ static INTERACTIVE_BARRIER: AtomicUsize = AtomicUsize::new(0);
pub static PAUSE_SCAN: AtomicBool = AtomicBool::new(false);
/// Simple enum used to flag a `FeroxScan` as likely a directory or file
#[derive(Debug)]
#[derive(Debug, Serialize, Deserialize)]
pub enum ScanType {
File,
Directory,
}
/// Default implementation for ScanType
impl Default for ScanType {
/// Return ScanType::File as default
fn default() -> Self {
Self::File
}
}
/// Struct to hold scan-related state
///
/// The purpose of this container is to open up the pathway to aborting currently running tasks and
@@ -59,17 +85,8 @@ pub struct FeroxScan {
pub progress_bar: Option<ProgressBar>,
}
/// Implementation of FeroxScan
impl FeroxScan {
/// Stop a currently running scan
pub fn abort(&self) {
self.stop_progress_bar();
if let Some(_task) = &self.task {
// task.abort(); todo uncomment once upgraded to tokio 0.3 (issue #107)
}
}
/// Default implementation for FeroxScan
impl Default for FeroxScan {
/// Create a default FeroxScan, populates ID with a new UUID
fn default() -> Self {
let new_id = Uuid::new_v4().to_simple().to_string();
@@ -83,6 +100,18 @@ impl FeroxScan {
scan_type: ScanType::File,
}
}
}
/// Implementation of FeroxScan
impl FeroxScan {
/// Stop a currently running scan
pub fn abort(&self) {
self.stop_progress_bar();
if let Some(_task) = &self.task {
// task.abort(); todo uncomment once upgraded to tokio 0.3 (issue #107)
}
}
/// Simple helper to call .finish on the scan's progress bar
fn stop_progress_bar(&self) {
@@ -97,7 +126,7 @@ impl FeroxScan {
pb.clone()
} else {
let num_requests = NUMBER_OF_REQUESTS.load(Ordering::Relaxed);
let pb = progress::add_bar(&self.url, num_requests, false);
let pb = progress::add_bar(&self.url, num_requests, false, false);
pb.reset_elapsed();
@@ -145,6 +174,69 @@ impl PartialEq for FeroxScan {
}
}
/// Serialize implementation for FeroxScan
impl Serialize for FeroxScan {
/// Function that handles serialization of a FeroxScan
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut state = serializer.serialize_struct("FeroxScan", 4)?;
state.serialize_field("id", &self.id)?;
state.serialize_field("url", &self.url)?;
state.serialize_field("scan_type", &self.scan_type)?;
state.serialize_field("complete", &self.complete)?;
state.end()
}
}
/// Deserialize implementation for FeroxScan
impl<'de> Deserialize<'de> for FeroxScan {
/// Deserialize a FeroxScan from a serde_json::Value
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let mut scan = Self::default();
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
for (key, value) in &map {
match key.as_str() {
"id" => {
if let Some(id) = value.as_str() {
scan.id = id.to_string();
}
}
"scan_type" => {
if let Some(scan_type) = value.as_str() {
scan.scan_type = match scan_type {
"File" => ScanType::File,
"Directory" => ScanType::Directory,
_ => ScanType::File,
}
}
}
"complete" => {
if let Some(complete) = value.as_bool() {
scan.complete = complete;
}
}
"url" => {
if let Some(url) = value.as_str() {
scan.url = url.to_string();
}
}
_ => {}
}
}
Ok(scan)
}
}
/// Container around a locked hashset of `FeroxScan`s, adds wrappers for insertion and searching
#[derive(Debug, Default)]
pub struct FeroxScans {
@@ -152,6 +244,31 @@ pub struct FeroxScans {
pub scans: Mutex<Vec<Arc<Mutex<FeroxScan>>>>,
}
/// Serialize implementation for FeroxScans
impl Serialize for FeroxScans {
/// Function that handles serialization of FeroxScans
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Ok(scans) = self.scans.lock() {
let mut seq = serializer.serialize_seq(Some(scans.len()))?;
for scan in scans.iter() {
if let Ok(unlocked) = scan.lock() {
seq.serialize_element(&*unlocked)?;
}
}
seq.end()
} else {
// if for some reason we can't unlock the mutex, just write an empty list
let seq = serializer.serialize_seq(Some(0))?;
seq.end()
}
}
}
/// Implementation of `FeroxScans`
impl FeroxScans {
/// Add a `FeroxScan` to the internal container
@@ -324,8 +441,12 @@ impl FeroxScans {
fn add_scan(&self, url: &str, scan_type: ScanType) -> (bool, Arc<Mutex<FeroxScan>>) {
let bar = match scan_type {
ScanType::Directory => {
let progress_bar =
progress::add_bar(&url, NUMBER_OF_REQUESTS.load(Ordering::Relaxed), false);
let progress_bar = progress::add_bar(
&url,
NUMBER_OF_REQUESTS.load(Ordering::Relaxed),
false,
false,
);
progress_bar.reset_elapsed();
@@ -382,9 +503,263 @@ fn get_single_spinner() -> ProgressBar {
spinner
}
/// Container around a locked vector of `FeroxResponse`s, adds wrappers for insertion and search
#[derive(Debug, Default)]
pub struct FeroxResponses {
/// Internal structure: locked hashset of `FeroxScan`s
pub responses: Arc<RwLock<Vec<FeroxResponse>>>,
}
/// Serialize implementation for FeroxResponses
impl Serialize for FeroxResponses {
/// Function that handles serialization of FeroxResponses
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Ok(responses) = self.responses.read() {
let mut seq = serializer.serialize_seq(Some(responses.len()))?;
for response in responses.iter() {
seq.serialize_element(response)?;
}
seq.end()
} else {
// if for some reason we can't unlock the mutex, just write an empty list
let seq = serializer.serialize_seq(Some(0))?;
seq.end()
}
}
}
/// Implementation of `FeroxResponses`
impl FeroxResponses {
/// Add a `FeroxResponse` to the internal container
pub fn insert(&self, response: FeroxResponse) {
match self.responses.write() {
Ok(mut responses) => {
responses.push(response);
}
Err(e) => {
log::error!("FeroxResponses' container's mutex is poisoned: {}", e);
}
}
}
/// Simple check for whether or not a FeroxResponse is contained within the inner container
pub fn contains(&self, other: &FeroxResponse) -> bool {
match self.responses.read() {
Ok(responses) => {
for response in responses.iter() {
if response.url == other.url {
return true;
}
}
}
Err(e) => {
log::error!("FeroxResponses' container's mutex is poisoned: {}", e);
}
}
false
}
}
/// Data container for (de)?serialization of multiple items
#[derive(Serialize, Debug)]
pub struct FeroxState {
/// Known scans
scans: &'static FeroxScans,
/// Current running config
config: &'static Configuration,
/// Known responses
responses: &'static FeroxResponses,
}
/// FeroxSerialize implementation for FeroxState
impl FeroxSerialize for FeroxState {
/// Simply return debug format of FeroxState to satisfy as_str
fn as_str(&self) -> String {
format!("{:?}", self)
}
/// Simple call to produce a JSON string using the given FeroxState
fn as_json(&self) -> String {
serde_json::to_string(&self).unwrap_or_default()
}
}
/// Given a string representing some number of seconds, minutes, hours, or days, convert
/// that representation to seconds and then wait for those seconds to elapse. Once that period
/// of time has elapsed, kill all currently running scans and dump a state file to disk that can
/// be used to resume any unfinished scan.
pub async fn start_max_time_thread(time_spec: &str) {
log::trace!("enter: start_max_time_thread({})", time_spec);
// as this function has already made it through the parser, which calls is_match on
// the value passed to --time-limit using TIMESPEC_REGEX; we can safely assume that
// the capture groups are populated; can expect something like 10m, 30s, 1h, etc...
let captures = TIMESPEC_REGEX.captures(&time_spec).unwrap();
let length_match = captures.get(1).unwrap();
let measurement_match = captures.get(2).unwrap();
if let Ok(length) = length_match.as_str().parse::<u64>() {
let length_in_secs = match measurement_match.as_str().to_ascii_lowercase().as_str() {
"s" => length,
"m" => length * 60, // minutes
"h" => length * 60 * 60, // hours
"d" => length * 60 * 60 * 24, // days
_ => length,
};
log::debug!(
"max time limit as string: {} and as seconds: {}",
time_spec,
length_in_secs
);
time::delay_for(time::Duration::new(length_in_secs, 0)).await;
log::trace!("exit: start_max_time_thread");
#[cfg(test)]
panic!();
#[cfg(not(test))]
sigint_handler();
}
log::error!(
"Could not parse the value provided ({}), can't enforce time limit",
length_match.as_str()
);
}
/// Writes the current state of the program to disk (if save_state is true) and then exits
fn sigint_handler() {
log::trace!("enter: sigint_handler");
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let slug = if !CONFIGURATION.target_url.is_empty() {
// target url populated
CONFIGURATION
.target_url
.replace("://", "_")
.replace("/", "_")
.replace(".", "_")
} else {
// stdin used
"stdin".to_string()
};
let filename = format!("ferox-{}-{}.state", slug, ts);
let warning = format!(
"🚨 Caught {} 🚨 saving scan state to {} ...",
style("ctrl+c").yellow(),
filename
);
PROGRESS_PRINTER.println(warning);
let state = FeroxState {
config: &CONFIGURATION,
scans: &SCANNED_URLS,
responses: &RESPONSES,
};
let state_file = open_file(&filename);
if let Some(buffered_file) = state_file {
safe_file_write(&state, buffered_file, true);
}
log::trace!("exit: sigint_handler (end of program)");
std::process::exit(1);
}
/// Initialize the ctrl+c handler that saves scan state to disk
pub fn initialize() {
log::trace!("enter: initialize");
let result = ctrlc::set_handler(sigint_handler);
if result.is_err() {
log::error!("Could not set Ctrl+c handler");
std::process::exit(1);
}
log::trace!("exit: initialize");
}
/// Primary logic used to load a Configuration from disk and populate the appropriate data
/// structures
pub fn resume_scan(filename: &str) -> Configuration {
log::trace!("enter: resume_scan({})", filename);
let file = File::open(filename).unwrap_or_else(|e| {
log::error!("{}", e);
log::error!("Could not open state file, exiting");
std::process::exit(1);
});
let reader = BufReader::new(file);
let state: serde_json::Value = serde_json::from_reader(reader).unwrap();
let conf = state.get("config").unwrap_or_else(|| {
log::error!("Could not load configuration from state file, exiting");
std::process::exit(1);
});
let config = serde_json::from_value(conf.clone()).unwrap_or_else(|e| {
log::error!("{}", e);
log::error!("Could not deserialize configuration found in state file, exiting");
std::process::exit(1);
});
if let Some(responses) = state.get("responses") {
if let Some(arr_responses) = responses.as_array() {
for response in arr_responses {
if let Ok(deser_resp) = serde_json::from_value(response.clone()) {
RESPONSES.insert(deser_resp);
}
}
}
}
if let Some(scans) = state.get("scans") {
if let Some(arr_scans) = scans.as_array() {
for scan in arr_scans {
let deser_scan: FeroxScan =
serde_json::from_value(scan.clone()).unwrap_or_default();
// need to determine if it's complete and based on that create a progress bar
// populate it accordingly based on completion
SCANNED_URLS.insert(Arc::new(Mutex::new(deser_scan)));
}
}
}
log::trace!("exit: resume_scan -> {:?}", config);
config
}
#[cfg(test)]
mod tests {
use super::*;
use crate::VERSION;
use predicates::prelude::*;
#[test]
/// test that ScanType's default is File
fn default_scantype_is_file() {
match ScanType::default() {
ScanType::File => {}
ScanType::Directory => panic!(),
}
}
#[test]
/// test that get_single_spinner returns the correct spinner
@@ -528,4 +903,168 @@ mod tests {
assert!(scan.progress_bar.is_some()); // new pb created
assert!(!pb.is_finished()) // not finished
}
#[test]
/// given a JSON entry representing a FeroxScan, test that it deserializes into the proper type
/// with the right attributes
fn ferox_scan_deserialize() {
let fs_json = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Directory","complete":true}"#;
let fs_json_two = r#"{"id":"057016a14769414aac9a7a62707598cb","url":"https://spiritanimal.com","scan_type":"Not Correct","complete":true}"#;
let fs: FeroxScan = serde_json::from_str(fs_json).unwrap();
let fs_two: FeroxScan = serde_json::from_str(fs_json_two).unwrap();
assert_eq!(fs.url, "https://spiritanimal.com");
match fs.scan_type {
ScanType::Directory => {}
ScanType::File => {
panic!();
}
}
match fs_two.scan_type {
ScanType::Directory => {
panic!();
}
ScanType::File => {}
}
match fs.progress_bar {
None => {}
Some(_) => {
panic!();
}
}
assert_eq!(fs.complete, true);
assert_eq!(fs.id, "057016a14769414aac9a7a62707598cb");
}
#[test]
/// given a FeroxScan, test that it serializes into the proper JSON entry
fn ferox_scan_serialize() {
let fs = FeroxScan::new("https://spiritanimal.com", ScanType::Directory, None);
let fs_json = format!(
r#"{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","complete":false}}"#,
fs.lock().unwrap().id
);
assert_eq!(
fs_json,
serde_json::to_string(&*fs.lock().unwrap()).unwrap()
);
}
#[test]
/// given a FeroxScans, test that it serializes into the proper JSON entry
fn ferox_scans_serialize() {
let ferox_scan = FeroxScan::new("https://spiritanimal.com", ScanType::Directory, None);
let ferox_scans = FeroxScans::default();
let ferox_scans_json = format!(
r#"[{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","complete":false}}]"#,
ferox_scan.lock().unwrap().id
);
ferox_scans.scans.lock().unwrap().push(ferox_scan);
assert_eq!(
ferox_scans_json,
serde_json::to_string(&ferox_scans).unwrap()
);
}
#[test]
/// given a FeroxResponses, test that it serializes into the proper JSON entry
fn ferox_responses_serialize() {
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"}}"#;
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
let responses = FeroxResponses::default();
responses.insert(response);
// responses has a response now
// serialized should be a list of responses
let expected = format!("[{}]", json_response);
let serialized = serde_json::to_string(&responses).unwrap();
assert_eq!(expected, serialized);
}
#[test]
/// given a FeroxResponse, test that it serializes into the proper JSON entry
fn ferox_response_serialize_and_deserialize() {
// deserialize
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"}}"#;
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
assert_eq!(response.url.as_str(), "https://nerdcore.com/css");
assert_eq!(response.url.path(), "/css");
assert_eq!(response.wildcard, true);
assert_eq!(response.status.as_u16(), 301);
assert_eq!(response.content_length, 173);
assert_eq!(response.line_count, 10);
assert_eq!(response.word_count, 16);
assert_eq!(response.headers.get("server").unwrap(), "nginx/1.16.1");
// serialize, however, this can fail when headers are out of order
let new_json = serde_json::to_string(&response).unwrap();
assert_eq!(json_response, new_json);
}
#[test]
/// test FeroxSerialize implementation of FeroxState
fn feroxstates_feroxserialize_implementation() {
let ferox_scan = FeroxScan::new("https://spiritanimal.com", ScanType::Directory, None);
let saved_id = ferox_scan.lock().unwrap().id.clone();
SCANNED_URLS.insert(ferox_scan);
let json_response = r#"{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{"server":"nginx/1.16.1"}}"#;
let response: FeroxResponse = serde_json::from_str(json_response).unwrap();
RESPONSES.insert(response);
let ferox_state = FeroxState {
scans: &SCANNED_URLS,
responses: &RESPONSES,
config: &CONFIGURATION,
};
let expected_strs = predicates::str::contains("scans: FeroxScans").and(
predicate::str::contains("config: Configuration")
.and(predicate::str::contains("responses: FeroxResponses"))
.and(predicate::str::contains("nerdcore.com"))
.and(predicate::str::contains("/css"))
.and(predicate::str::contains("https://spiritanimal.com")),
);
assert!(expected_strs.eval(&ferox_state.as_str()));
let json_state = ferox_state.as_json();
let expected = format!(
r#"{{"scans":[{{"id":"{}","url":"https://spiritanimal.com","scan_type":"Directory","complete":false}}],"config":{{"type":"configuration","wordlist":"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt","config":"","proxy":"","replay_proxy":"","target_url":"","status_codes":[200,204,301,302,307,308,401,403,405],"replay_codes":[200,204,301,302,307,308,401,403,405],"filter_status":[],"threads":50,"timeout":7,"verbosity":0,"quiet":false,"json":false,"output":"","debug_log":"","user_agent":"feroxbuster/{}","redirects":false,"insecure":false,"extensions":[],"headers":{{}},"queries":[],"no_recursion":false,"extract_links":false,"add_slash":false,"stdin":false,"depth":4,"scan_limit":0,"filter_size":[],"filter_line_count":[],"filter_word_count":[],"filter_regex":[],"dont_filter":false,"resumed":false,"save_state":false,"time_limit":""}},"responses":[{{"type":"response","url":"https://nerdcore.com/css","path":"/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{{"server":"nginx/1.16.1"}}}}]}}"#,
saved_id, VERSION
);
assert!(predicates::str::similar(expected).eval(&json_state));
}
#[should_panic]
#[tokio::test(core_threads = 1)]
/// call start_max_time_thread with a valid timespec, expect a panic, but only after a certain
/// number of seconds
async fn start_max_time_thread_panics_after_delay() {
let now = time::Instant::now();
let delay = time::Duration::new(3, 0);
start_max_time_thread("3s").await;
assert!(now.elapsed() > delay);
}
#[tokio::test(core_threads = 1)]
/// call start_max_time_thread with a timespec that's too large to be parsed correctly, expect
/// immediate return and no panic, as the sigint handler is never called
async fn start_max_time_thread_returns_immediately_with_too_large_input() {
let now = time::Instant::now();
let delay = time::Duration::new(1, 0);
// pub const MAX: usize = usize::MAX; // 18_446_744_073_709_551_615usize
start_max_time_thread("18446744073709551616m").await; // can't fit in dest u64
assert!(now.elapsed() < delay); // assuming function call will take less than 1second
}
}

View File

@@ -6,7 +6,7 @@ use crate::{
WordsFilter,
},
heuristics,
scan_manager::{FeroxScans, PAUSE_SCAN},
scan_manager::{FeroxResponses, FeroxScans, PAUSE_SCAN},
utils::{format_url, get_current_depth, make_request},
FeroxChannel, FeroxResponse,
};
@@ -47,6 +47,9 @@ lazy_static! {
/// Vector of implementors of the FeroxFilter trait
static ref FILTERS: Arc<RwLock<Vec<Box<dyn FeroxFilter>>>> = Arc::new(RwLock::new(Vec::<Box<dyn FeroxFilter>>::new()));
/// Vector of FeroxResponse objects
pub static ref RESPONSES: FeroxResponses = FeroxResponses::default();
/// Bounded semaphore used as a barrier to limit concurrent scans
static ref SCAN_LIMITER: Semaphore = Semaphore::new(CONFIGURATION.scan_limit);
}

102774
tests/extra-words Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -711,6 +711,8 @@ fn banner_prints_json() {
.arg("--url")
.arg("http://localhost")
.arg("--json")
.arg("--output")
.arg("/dev/null")
.assert()
.success()
.stderr(
@@ -781,3 +783,30 @@ fn banner_prints_filter_regex() {
.and(predicate::str::contains("─┴─")),
);
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + time limit
fn banner_prints_time_limit() {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--time-limit")
.arg("10m")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Time Limit"))
.and(predicate::str::contains("│ 10m"))
.and(predicate::str::contains("─┴─")),
);
}

View File

@@ -1,7 +1,7 @@
mod utils;
use assert_cmd::prelude::*;
use httpmock::Method::GET;
use httpmock::{Mock, MockServer};
use httpmock::MockServer;
use predicates::prelude::*;
use std::process::Command;
use utils::{setup_tmp_directory, teardown_tmp_directory};
@@ -13,18 +13,17 @@ fn extractor_finds_absolute_url() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body(&srv.url("'/homepage/assets/img/icons/handshake.svg'"))
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200)
.body(&srv.url("'/homepage/assets/img/icons/handshake.svg'"));
});
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/homepage/assets/img/icons/handshake.svg")
.return_status(200)
.create_on(&srv);
let mock_two = srv.mock(|when, then| {
when.method(GET)
.path("/homepage/assets/img/icons/handshake.svg");
then.status(200);
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -43,8 +42,8 @@ fn extractor_finds_absolute_url() -> Result<(), Box<dyn std::error::Error>> {
)),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
@@ -56,12 +55,11 @@ fn extractor_finds_absolute_url_to_different_domain() -> Result<(), Box<dyn std:
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("\"http://localhost/homepage/assets/img/icons/handshake.svg\"")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200)
.body("\"http://localhost/homepage/assets/img/icons/handshake.svg\"");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -81,7 +79,7 @@ fn extractor_finds_absolute_url_to_different_domain() -> Result<(), Box<dyn std:
.not(),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
@@ -92,18 +90,17 @@ fn extractor_finds_relative_url() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("\"/homepage/assets/img/icons/handshake.svg\"")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200)
.body("\"/homepage/assets/img/icons/handshake.svg\"");
});
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/homepage/assets/img/icons/handshake.svg")
.return_status(200)
.create_on(&srv);
let mock_two = srv.mock(|when, then| {
when.method(GET)
.path("/homepage/assets/img/icons/handshake.svg");
then.status(200);
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -122,8 +119,8 @@ fn extractor_finds_relative_url() -> Result<(), Box<dyn std::error::Error>> {
)),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
@@ -136,25 +133,23 @@ fn extractor_finds_same_relative_url_twice() {
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "README".to_string()], "wordlist").unwrap();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body(&srv.url("\"/homepage/assets/img/icons/handshake.svg\""))
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200)
.body(&srv.url("\"/homepage/assets/img/icons/handshake.svg\""));
});
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/README")
.return_body(&srv.url("\"/homepage/assets/img/icons/handshake.svg\""))
.return_status(200)
.create_on(&srv);
let mock_two = srv.mock(|when, then| {
when.method(GET).path("/README");
then.status(200)
.body(&srv.url("\"/homepage/assets/img/icons/handshake.svg\""));
});
let mock_three = Mock::new()
.expect_method(GET)
.expect_path("/homepage/assets/img/icons/handshake.svg")
.return_status(200)
.create_on(&srv);
let mock_three = srv.mock(|when, then| {
when.method(GET)
.path("/homepage/assets/img/icons/handshake.svg");
then.status(200);
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -173,10 +168,10 @@ fn extractor_finds_same_relative_url_twice() {
)),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
assert!(mock_three.times_called() <= 2); // todo: sometimes this is 2 instead of 1
// the expectation is one, suggesting a race condition... investigate and fix
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 1);
assert!(mock_three.hits() <= 2); // todo: sometimes this is 2 instead of 1
// the expectation is one, suggesting a race condition... investigate and fix
teardown_tmp_directory(tmp_dir);
}
@@ -188,19 +183,17 @@ fn extractor_finds_filtered_content() -> Result<(), Box<dyn std::error::Error>>
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "README".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body(&srv.url("\"/homepage/assets/img/icons/handshake.svg\""))
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200)
.body(&srv.url("\"/homepage/assets/img/icons/handshake.svg\""));
});
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/homepage/assets/img/icons/handshake.svg")
.return_body("im a little teapot")
.return_status(200)
.create_on(&srv);
let mock_two = srv.mock(|when, then| {
when.method(GET)
.path("/homepage/assets/img/icons/handshake.svg");
then.status(200).body("im a little teapot");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -222,8 +215,8 @@ fn extractor_finds_filtered_content() -> Result<(), Box<dyn std::error::Error>>
.not(),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}

View File

@@ -1,7 +1,7 @@
mod utils;
use assert_cmd::prelude::*;
use httpmock::Method::GET;
use httpmock::{Mock, MockServer};
use httpmock::MockServer;
use predicates::prelude::*;
use std::process::Command;
use utils::{setup_tmp_directory, teardown_tmp_directory};
@@ -14,19 +14,15 @@ fn filters_status_code_should_filter_response() {
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "file.js".to_string()], "wordlist").unwrap();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(302)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(302).body("this is a test");
});
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/file.js")
.return_status(200)
.return_body("this is also a test of some import")
.create_on(&srv);
let mock_two = srv.mock(|when, then| {
when.method(GET).path("/file.js");
then.status(200).body("this is also a test of some import");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -51,8 +47,8 @@ fn filters_status_code_should_filter_response() {
.and(predicate::str::contains("34c")),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 1);
teardown_tmp_directory(tmp_dir);
}
@@ -64,19 +60,16 @@ fn filters_lines_should_filter_response() {
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "file.js".to_string()], "wordlist").unwrap();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(302)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(302).body("this is a test");
});
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/file.js")
.return_status(200)
.return_body("this is also a test of some import\nwith 2 lines, no less")
.create_on(&srv);
let mock_two = srv.mock(|when, then| {
when.method(GET).path("/file.js");
then.status(200)
.body("this is also a test of some import\nwith 2 lines, no less");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -100,8 +93,8 @@ fn filters_lines_should_filter_response() {
.not(),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 1);
teardown_tmp_directory(tmp_dir);
}
@@ -113,19 +106,16 @@ fn filters_words_should_filter_response() {
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "file.js".to_string()], "wordlist").unwrap();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(302)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(302).body("this is a test");
});
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/file.js")
.return_status(200)
.return_body("this is also a test of some import\nwith 2 lines, no less")
.create_on(&srv);
let mock_two = srv.mock(|when, then| {
when.method(GET).path("/file.js");
then.status(200)
.body("this is also a test of some import\nwith 2 lines, no less");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -149,8 +139,8 @@ fn filters_words_should_filter_response() {
.not(),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 1);
teardown_tmp_directory(tmp_dir);
}
@@ -162,19 +152,16 @@ fn filters_size_should_filter_response() {
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "file.js".to_string()], "wordlist").unwrap();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(302)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(302).body("this is a test");
});
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/file.js")
.return_status(200)
.return_body("this is also a test of some import\nwith 2 lines, no less")
.create_on(&srv);
let mock_two = srv.mock(|when, then| {
when.method(GET).path("/file.js");
then.status(200)
.body("this is also a test of some import\nwith 2 lines, no less");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -198,7 +185,7 @@ fn filters_size_should_filter_response() {
.not(),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 1);
teardown_tmp_directory(tmp_dir);
}

View File

@@ -2,7 +2,7 @@ mod utils;
use assert_cmd::prelude::*;
use assert_cmd::Command;
use httpmock::Method::GET;
use httpmock::{Mock, MockServer, Regex};
use httpmock::{MockServer, Regex};
use predicates::prelude::*;
use utils::{setup_tmp_directory, teardown_tmp_directory};
@@ -65,12 +65,10 @@ fn test_one_good_and_one_bad_target_scan_succeeds() -> Result<(), Box<dyn std::e
let urls = vec![not_real, srv.url("/"), String::from("LICENSE")];
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let mut cmd = Command::cargo_bin("feroxbuster").unwrap();
@@ -86,7 +84,7 @@ fn test_one_good_and_one_bad_target_scan_succeeds() -> Result<(), Box<dyn std::e
.and(predicate::str::contains("200"))
.and(predicate::str::contains("14")),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
@@ -98,12 +96,11 @@ fn test_static_wildcard_request_found() -> Result<(), Box<dyn std::error::Error>
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -123,7 +120,7 @@ fn test_static_wildcard_request_found() -> Result<(), Box<dyn std::error::Error>
.and(predicate::str::contains("(url length: 32)")),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
Ok(())
}
@@ -134,19 +131,17 @@ fn test_dynamic_wildcard_request_found() {
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let outfile = tmp_dir.path().join("outfile");
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
then.status(200)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
let mock2 = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock2 = srv.mock(|when, then| {
when.method(GET).path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap());
then.status(200).body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -177,8 +172,8 @@ fn test_dynamic_wildcard_request_found() {
.and(predicate::str::contains("(url length: 96)")),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock2.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock2.hits(), 1);
}
#[test]
@@ -187,12 +182,11 @@ fn heuristics_static_wildcard_request_with_dont_filter() -> Result<(), Box<dyn s
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
then.status(200).body("this is a test");
});
Command::cargo_bin("feroxbuster")
.unwrap()
@@ -205,7 +199,7 @@ fn heuristics_static_wildcard_request_with_dont_filter() -> Result<(), Box<dyn s
teardown_tmp_directory(tmp_dir);
assert_eq!(mock.times_called(), 0);
assert_eq!(mock.hits(), 0);
Ok(())
}
@@ -215,19 +209,19 @@ fn heuristics_wildcard_test_with_two_static_wildcards() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
then.status(200)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
let mock2 = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock2 = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap());
then.status(200)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -251,8 +245,8 @@ fn heuristics_wildcard_test_with_two_static_wildcards() {
)),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock2.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock2.hits(), 1);
}
#[test]
@@ -262,19 +256,19 @@ fn heuristics_wildcard_test_with_two_static_wildcards_with_quiet_enabled(
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
then.status(200)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
let mock2 = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock2 = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap());
then.status(200)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -290,8 +284,8 @@ fn heuristics_wildcard_test_with_two_static_wildcards_with_quiet_enabled(
cmd.assert().success().stdout(predicate::str::is_empty());
assert_eq!(mock.times_called(), 1);
assert_eq!(mock2.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock2.hits(), 1);
Ok(())
}
@@ -302,19 +296,19 @@ fn heuristics_wildcard_test_with_two_static_wildcards_and_output_to_file() {
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let outfile = tmp_dir.path().join("outfile");
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
then.status(200)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
let mock2 = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock2 = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap());
then.status(200)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -348,8 +342,8 @@ fn heuristics_wildcard_test_with_two_static_wildcards_and_output_to_file() {
)),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock2.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock2.hits(), 1);
}
#[test]
@@ -361,20 +355,20 @@ fn heuristics_wildcard_test_with_redirect_as_response_code(
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let outfile = tmp_dir.path().join("outfile");
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(301)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap());
then.status(301)
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
let mock2 = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap())
.return_status(301)
.return_header("Location", &srv.url("/some-redirect"))
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock2 = srv.mock(|when, then| {
when.method(GET)
.path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap());
then.status(301)
.header("Location", &srv.url("/some-redirect"))
.body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -407,7 +401,7 @@ fn heuristics_wildcard_test_with_redirect_as_response_code(
.and(predicate::str::contains("WLD")),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock2.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(mock2.hits(), 1);
Ok(())
}

View File

@@ -1,7 +1,7 @@
mod utils;
use assert_cmd::Command;
use httpmock::Method::GET;
use httpmock::{Mock, MockServer};
use httpmock::MockServer;
use predicates::prelude::*;
use utils::{setup_tmp_directory, teardown_tmp_directory};
@@ -10,12 +10,10 @@ use utils::{setup_tmp_directory, teardown_tmp_directory};
fn main_use_root_owned_file_as_wordlist() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/");
then.status(200).body("this is a test");
});
Command::cargo_bin("feroxbuster")
.unwrap()
@@ -29,7 +27,7 @@ fn main_use_root_owned_file_as_wordlist() -> Result<(), Box<dyn std::error::Erro
.stdout(predicate::str::contains("Permission denied (os error 13)"));
// connectivity test hits it once
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
Ok(())
}
@@ -39,12 +37,10 @@ fn main_use_empty_wordlist() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&[], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/");
then.status(200).body("this is a test");
});
Command::cargo_bin("feroxbuster")
.unwrap()
@@ -57,7 +53,7 @@ fn main_use_empty_wordlist() -> Result<(), Box<dyn std::error::Error>> {
.failure()
.stdout(predicate::str::contains("Did not find any words in"));
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())

130
tests/test_scan_manager.rs Normal file
View File

@@ -0,0 +1,130 @@
mod utils;
use assert_cmd::Command;
use httpmock::Method::GET;
use httpmock::MockServer;
use predicates::prelude::*;
use std::fs::{read_to_string, write};
use std::path::Path;
use std::time;
use utils::{setup_tmp_directory, teardown_tmp_directory};
#[test]
/// pass a known serialized scan with 1 scan complete and 1 not. expect the incomplete scan to
/// start and the complete to not start. expect the responses, scans, and configuration structures
/// to be populated based off the contents of the given state file
fn resume_scan_works() {
let srv = MockServer::start();
let (tmp_dir, file) =
setup_tmp_directory(&["css".to_string(), "stuff".to_string()], "wordlist").unwrap();
// localhost:PORT/ <- complete
// localhost:PORT/js <- will get scanned with /css and /stuff
let complete_scan = format!(
r#"{{"id":"057016a14769414aac9a7a62707598cb","url":"{}","scan_type":"Directory","complete":true}}"#,
srv.url("/")
);
let incomplete_scan = format!(
r#"{{"id":"400b2323a16f43468a04ffcbbeba34c6","url":"{}","scan_type":"Directory","complete":false}}"#,
srv.url("/js")
);
let scans = format!(r#""scans":[{},{}]"#, complete_scan, incomplete_scan);
let config = format!(
r#""config": {{"type":"configuration","wordlist":"{}","config":"","proxy":"","replay_proxy":"","target_url":"{}","status_codes":[200,204,301,302,307,308,401,403,405],"replay_codes":[200,204,301,302,307,308,401,403,405],"filter_status":[],"threads":50,"timeout":7,"verbosity":0,"quiet":false,"json":false,"output":"","debug_log":"","user_agent":"feroxbuster/1.9.0","redirects":false,"insecure":false,"extensions":[],"headers":{{}},"queries":[],"no_recursion":false,"extract_links":false,"add_slash":false,"stdin":false,"depth":2,"scan_limit":1,"filter_size":[],"filter_line_count":[],"filter_word_count":[],"filter_regex":[],"dont_filter":false}}"#,
file.to_string_lossy(),
srv.url("/")
);
// // localhost:PORT/js/css has already been seen, expect not to be scanned
let response = format!(
r#"{{"type":"response","url":"{}","path":"/js/css","wildcard":true,"status":301,"content_length":173,"line_count":10,"word_count":16,"headers":{{"server":"nginx/1.16.1"}}}}"#,
srv.url("/js/css")
);
let responses = format!(r#""responses":[{}]"#, response);
// not scanned because /js is not complete, and /js/stuff response is not known
let not_scanned_yet = srv.mock(|when, then| {
when.method(GET).path("/js/stuff");
then.status(200).body("i expect to be scanned");
});
// will get scanned because /js is not complete, but because response of /js/css is known, the
// response will not be in stdout
let already_scanned = srv.mock(|when, then| {
when.method(GET).path("/js/css");
then.status(200);
});
// already scanned because scan on / is complete
let also_already_scanned = srv.mock(|when, then| {
when.method(GET).path("/css");
then.status(200).body("two words");
});
let state_file_contents = format!("{{{},{},{}}}", scans, config, responses);
let (tmp_dir2, state_file) = setup_tmp_directory(&[state_file_contents], "state-file").unwrap();
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--resume-from")
.arg(state_file.as_os_str())
.assert()
.success()
.stdout(
predicate::str::contains("/js/stuff")
.and(predicate::str::contains("22c"))
.and(predicate::str::contains("5w"))
.and(predicate::str::contains("/js/css"))
.not()
.and(predicate::str::contains("2w"))
.not()
.and(predicate::str::contains("9c"))
.not(),
);
teardown_tmp_directory(tmp_dir);
teardown_tmp_directory(tmp_dir2);
assert_eq!(already_scanned.hits(), 1);
assert_eq!(also_already_scanned.hits(), 0);
assert_eq!(not_scanned_yet.hits(), 1);
}
#[test]
/// kick off scan with a time limit;
fn time_limit_enforced_when_specified() {
let srv = MockServer::start();
let (tmp_dir, file) =
setup_tmp_directory(&["css".to_string(), "stuff".to_string()], "wordlist").unwrap();
// ensure the command will run long enough by adding crap to the wordlist
let more_words = read_to_string(Path::new("tests/extra-words")).unwrap();
write(&file, more_words).unwrap();
assert!(file.metadata().unwrap().len() > 100); // sanity check on wordlist size
let now = time::Instant::now();
let lower_bound = time::Duration::new(5, 0);
let upper_bound = time::Duration::new(6, 0);
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--time-limit")
.arg("5s")
.assert()
.failure();
// expected run time is somewhere in the 30 seconds ballpark (real 0m37.376s)
// so if the cmd returns in a significantly shorter amount of time, the test will have
// succeeded
// --time-limit is 5 seconds, so elapsed should be in a window that is greater than 5
// but significantly less than 30ish
assert!(now.elapsed() > lower_bound && now.elapsed() < upper_bound);
teardown_tmp_directory(tmp_dir);
}

View File

@@ -1,7 +1,7 @@
mod utils;
use assert_cmd::prelude::*;
use httpmock::Method::GET;
use httpmock::{Mock, MockServer};
use httpmock::MockServer;
use predicates::prelude::*;
use std::process::Command;
use utils::{setup_tmp_directory, teardown_tmp_directory};
@@ -12,12 +12,10 @@ fn scanner_single_request_scan() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -34,7 +32,7 @@ fn scanner_single_request_scan() -> Result<(), Box<dyn std::error::Error>> {
.and(predicate::str::contains("14")),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
@@ -51,33 +49,26 @@ fn scanner_recursive_request_scan() -> Result<(), Box<dyn std::error::Error>> {
];
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
let js_mock = Mock::new()
.expect_method(GET)
.expect_path("/js")
.return_status(301)
.return_header("Location", &srv.url("/js/"))
.create_on(&srv);
let js_mock = srv.mock(|when, then| {
when.method(GET).path("/js");
then.status(301).header("Location", &srv.url("/js/"));
});
let js_prod_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/prod")
.return_status(301)
.return_header("Location", &srv.url("/js/prod/"))
.create_on(&srv);
let js_prod_mock = srv.mock(|when, then| {
when.method(GET).path("/js/prod");
then.status(301).header("Location", &srv.url("/js/prod/"));
});
let js_dev_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/dev")
.return_status(301)
.return_header("Location", &srv.url("/js/dev/"))
.create_on(&srv);
let js_dev_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev");
then.status(301).header("Location", &srv.url("/js/dev/"));
});
let js_dev_file_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/dev/file.js")
.return_status(200)
.return_body("this is a test and is more bytes than other ones")
.create_on(&srv);
let js_dev_file_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev/file.js");
then.status(200)
.body("this is a test and is more bytes than other ones");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -98,10 +89,10 @@ fn scanner_recursive_request_scan() -> Result<(), Box<dyn std::error::Error>> {
.and(predicate::str::is_match("200.*js/dev/file.js").unwrap()),
);
assert_eq!(js_mock.times_called(), 1);
assert_eq!(js_prod_mock.times_called(), 1);
assert_eq!(js_dev_mock.times_called(), 1);
assert_eq!(js_dev_file_mock.times_called(), 1);
assert_eq!(js_mock.hits(), 1);
assert_eq!(js_prod_mock.hits(), 1);
assert_eq!(js_dev_mock.hits(), 1);
assert_eq!(js_dev_file_mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
@@ -121,33 +112,26 @@ fn scanner_recursive_request_scan_using_only_success_responses(
];
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
let js_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/")
.return_status(200)
.return_header("Location", &srv.url("/js/"))
.create_on(&srv);
let js_mock = srv.mock(|when, then| {
when.method(GET).path("/js/");
then.status(200).header("Location", &srv.url("/js/"));
});
let js_prod_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/prod/")
.return_status(200)
.return_header("Location", &srv.url("/js/prod/"))
.create_on(&srv);
let js_prod_mock = srv.mock(|when, then| {
when.method(GET).path("/js/prod/");
then.status(200).header("Location", &srv.url("/js/prod/"));
});
let js_dev_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/dev/")
.return_status(200)
.return_header("Location", &srv.url("/js/dev/"))
.create_on(&srv);
let js_dev_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev/");
then.status(200).header("Location", &srv.url("/js/dev/"));
});
let js_dev_file_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/dev/file.js")
.return_status(200)
.return_body("this is a test and is more bytes than other ones")
.create_on(&srv);
let js_dev_file_mock = srv.mock(|when, then| {
when.method(GET).path("/js/dev/file.js");
then.status(200)
.body("this is a test and is more bytes than other ones");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -169,10 +153,10 @@ fn scanner_recursive_request_scan_using_only_success_responses(
.and(predicate::str::is_match("200.*js/dev/file.js").unwrap()),
);
assert_eq!(js_mock.times_called(), 1);
assert_eq!(js_prod_mock.times_called(), 1);
assert_eq!(js_dev_mock.times_called(), 1);
assert_eq!(js_dev_file_mock.times_called(), 1);
assert_eq!(js_mock.hits(), 1);
assert_eq!(js_prod_mock.hits(), 1);
assert_eq!(js_dev_mock.hits(), 1);
assert_eq!(js_dev_file_mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
@@ -185,12 +169,10 @@ fn scanner_single_request_scan_with_file_output() -> Result<(), Box<dyn std::err
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let outfile = tmp_dir.path().join("output");
@@ -211,7 +193,7 @@ fn scanner_single_request_scan_with_file_output() -> Result<(), Box<dyn std::err
assert!(contents.contains("200"));
assert!(contents.contains("14"));
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
@@ -223,12 +205,10 @@ fn scanner_single_request_scan_with_file_output_and_tack_q(
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let outfile = tmp_dir.path().join("output");
@@ -249,7 +229,7 @@ fn scanner_single_request_scan_with_file_output_and_tack_q(
let url = srv.url("/LICENSE");
assert!(contents.contains(&url));
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
@@ -261,12 +241,10 @@ fn scanner_single_request_scan_with_invalid_file_output() -> Result<(), Box<dyn
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let outfile = tmp_dir.path(); // outfile is a directory
@@ -285,7 +263,7 @@ fn scanner_single_request_scan_with_invalid_file_output() -> Result<(), Box<dyn
let contents = std::fs::read_to_string(outfile);
assert!(contents.is_err());
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
@@ -296,12 +274,10 @@ fn scanner_single_request_quiet_scan() -> Result<(), Box<dyn std::error::Error>>
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -321,7 +297,7 @@ fn scanner_single_request_quiet_scan() -> Result<(), Box<dyn std::error::Error>>
.not(),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
@@ -334,12 +310,10 @@ fn scanner_single_request_returns_301_without_location_header(
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_body("this is a test")
.return_status(301)
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(301).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -359,7 +333,52 @@ fn scanner_single_request_returns_301_without_location_header(
.and(predicate::str::contains("14")),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send a single valid request, expect a 200 response that then gets routed to the replay
/// proxy
fn scanner_single_request_replayed_to_proxy() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let proxy = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let mock_two = proxy.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--replay-proxy")
.arg(format!("http://{}", proxy.address().to_string()))
.arg("--replay-codes")
.arg("200")
.unwrap();
cmd.assert()
.success()
.stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains("14c")),
)
.stderr(predicate::str::contains("Replay Proxy Codes"));
assert_eq!(mock.hits(), 1);
assert_eq!(mock_two.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
@@ -371,19 +390,15 @@ fn scanner_single_request_scan_with_filtered_result() -> Result<(), Box<dyn std:
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "ignored".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a not a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a not a test");
});
let filtered_mock = Mock::new()
.expect_method(GET)
.expect_path("/ignored")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let filtered_mock = srv.mock(|when, then| {
when.method(GET).path("/ignored");
then.status(200).body("this is a test");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -406,57 +421,8 @@ fn scanner_single_request_scan_with_filtered_result() -> Result<(), Box<dyn std:
.not(),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(filtered_mock.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send a single valid request, expect a 200 response that then gets routed to the replay
/// proxy
fn scanner_single_request_replayed_to_proxy() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let proxy = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&proxy);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--replay-proxy")
.arg(format!("http://{}", proxy.address().to_string()))
.arg("--replay-codes")
.arg("200")
.unwrap();
cmd.assert()
.success()
.stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains("14")),
)
.stderr(predicate::str::contains("Replay Proxy Codes"));
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(filtered_mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
@@ -467,12 +433,10 @@ fn scanner_single_request_scan_with_debug_logging() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let outfile = tmp_dir.path().join("debug.log");
@@ -496,7 +460,7 @@ fn scanner_single_request_scan_with_debug_logging() {
assert!(contents.contains("feroxbuster All scans complete!"));
assert!(contents.contains("feroxbuster exit: terminal_input_handler"));
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
}
@@ -506,12 +470,10 @@ fn scanner_single_request_scan_with_debug_logging_as_json() {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist").unwrap();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let outfile = tmp_dir.path().join("debug.log");
@@ -538,7 +500,7 @@ fn scanner_single_request_scan_with_debug_logging_as_json() {
assert!(contents.contains("All scans complete!"));
assert!(contents.contains("exit: terminal_input_handler"));
assert_eq!(mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
}
@@ -549,19 +511,16 @@ fn scanner_single_request_scan_with_regex_filtered_result() {
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "ignored".to_string()], "wordlist").unwrap();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a not a test")
.create_on(&srv);
let mock = srv.mock(|when, then| {
when.method(GET).path("/LICENSE");
then.status(200).body("this is a test");
});
let filtered_mock = Mock::new()
.expect_method(GET)
.expect_path("/ignored")
.return_status(200)
.return_body("this is a test\nThat rug really tied the room together")
.create_on(&srv);
let filtered_mock = srv.mock(|when, then| {
when.method(GET).path("/ignored");
then.status(200)
.body("this is a test\nThat rug really tied the room together");
});
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
@@ -583,7 +542,7 @@ fn scanner_single_request_scan_with_regex_filtered_result() {
.not(),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(filtered_mock.times_called(), 1);
assert_eq!(mock.hits(), 1);
assert_eq!(filtered_mock.hits(), 1);
teardown_tmp_directory(tmp_dir);
}