Compare commits

...

63 Commits

Author SHA1 Message Date
epi
9e08766c07 Merge pull request #104 from epi052/FEATURE-add-pause-resume-functionality
add pause|resume feature
2020-11-01 19:07:19 -06:00
epi
b1e4c3fd6f changed banner color from crossterm to console 2020-11-01 19:04:18 -06:00
epi
08abb044e3 cargo fmt on scanner.rs 2020-11-01 19:00:19 -06:00
epi
bc4893970d updated README with pause|resume 2020-11-01 18:56:07 -06:00
epi
fae6f96f3a updated tests 2020-11-01 14:48:30 -06:00
epi
a627841058 added tests for pause_scan 2020-11-01 10:10:27 -06:00
epi
b5c640cc4f added tests for pause_scan 2020-11-01 10:09:40 -06:00
epi
5285f22dae added test for get_single_spinner 2020-11-01 09:52:18 -06:00
epi
96a4fb1139 added message about how to pause to banner 2020-11-01 09:47:09 -06:00
epi
95aca72670 added default to terminal input polling 2020-11-01 07:45:18 -06:00
epi
39f8f38204 implemented pause|resume functionality 2020-11-01 07:35:16 -06:00
epi
db5509cb52 bumped version to 1.4.0 2020-10-31 09:11:43 -05:00
epi
231752194f Merge pull request #100 from epi052/FEATURE-response-code-blacklist
Feature response code blacklist
2020-10-31 06:59:11 -05:00
epi
f64f02135e moved dont_filter from scanner to WildcardFilter 2020-10-31 06:54:19 -05:00
epi
db5e1e2e2d gif was out of place 2020-10-30 07:18:48 -05:00
epi
f649da359f updated readme to reflect 1.3.0 changes 2020-10-30 07:17:10 -05:00
epi
6e981e6d3a added whitespace around response size; server port number can clash with size 2020-10-30 05:58:17 -05:00
epi
12b46a44e1 Merge pull request #102 from epi052/FEATURE-response-code-blacklist--implement-blacklist
implemented deny list
2020-10-30 05:46:32 -05:00
epi
e35f86876d fixed oddly failing tests /shrug 2020-10-30 05:26:16 -05:00
epi
6fe5ae0d0c added integration test for status code filter 2020-10-30 05:19:38 -05:00
epi
dc89f3b5aa implemented deny list 2020-10-29 20:58:44 -05:00
epi
5918554754 Merge pull request #101 from epi052/FEATURE-response-code-blacklist--add-option-to-parser
added --filter-status option
2020-10-29 20:35:08 -05:00
epi
39241594ae added status code filter option to banner and config 2020-10-29 20:22:39 -05:00
epi
665564bbfe refactored long option names 2020-10-29 16:17:50 -05:00
epi
ffed3820a5 Merge pull request #99 from epi052/FEATURE-response-code-blacklist--filter-trait
Added new filter system to allow for extending with different filter types
2020-10-29 06:34:13 -05:00
epi
254f502ed3 removed lint from scanner 2020-10-29 06:33:21 -05:00
epi
d3ddefa0b7 removed lint and dead code from scanner 2020-10-29 06:13:25 -05:00
epi
acf16c92cd removed lint from heuristics 2020-10-29 06:11:07 -05:00
epi
2d67336b86 new filter system appears to work 2020-10-29 06:05:25 -05:00
epi
9db0dc505b updated emoji font to 13.1 after building from github source 2020-10-25 11:35:00 -05:00
epi
702cc8f18e updated README 2020-10-25 10:57:27 -05:00
epi
737d347121 added terminal width FAQ to README 2020-10-25 10:44:21 -05:00
epi
a4b7a8a8e6 updated nix install script 2020-10-25 07:58:51 -05:00
epi
afacb13787 install script for nix now adds emoji font 2020-10-25 07:38:48 -05:00
epi
610379c6a9 renamed install script, updated README 2020-10-25 07:18:52 -05:00
epi
1bb132f157 added install script 2020-10-25 07:15:07 -05:00
epi
c9601d4fe9 updated README 2020-10-25 06:42:10 -05:00
epi
fae404ff9a fixed link in README 2020-10-25 06:40:11 -05:00
epi
ab5ff1b2e0 updated README with some gifs 2020-10-25 06:38:22 -05:00
epi
045719b25a Merge pull request #96 from epi052/FEATURE-limit-number-of-scans
Added ability to limit number of scans
2020-10-25 05:29:24 -05:00
epi
154d8ae408 updated README 2020-10-24 21:10:49 -05:00
epi
8bebc7b81d Merge pull request #97 from epi052/FEATURE-limit-number-of-scans--implement-scan-limiter
implemented scan limiting
2020-10-24 20:54:39 -05:00
epi
204b90e1fa implemented scan limiter 2020-10-24 20:44:27 -05:00
epi
6ceba1170f reverted last change 2020-10-24 18:45:19 -05:00
epi
6f7e4564e7 changed scan_limit type to atomic 2020-10-24 16:42:14 -05:00
epi
e8041df0cd Merge pull request #95 from epi052/FEATURE-limit-number-of-scans--add-cli-option
added --scan-limit option
2020-10-24 16:09:46 -05:00
epi
1c364b0a21 added --scan-limit option 2020-10-24 15:59:53 -05:00
epi
6caa6b864c bumped version to 1.1.2 2020-10-24 12:56:19 -05:00
epi
962e22010f Merge pull request #94 from epi052/93-fix-progress-bar-counting
fixed progress bar being incremented too little
2020-10-24 12:34:03 -05:00
epi
fcc27f6770 fixed progress bar being incremented too little 2020-10-24 12:32:51 -05:00
epi
404b231c67 added FAQ section to README 2020-10-24 09:26:54 -05:00
epi
43e5ad14c9 added FAQ section to README 2020-10-24 09:20:34 -05:00
epi
52d05e613c Update README.md 2020-10-24 09:19:42 -05:00
epi
b84ee91c2e added FAQ section to README 2020-10-24 09:14:46 -05:00
epi
81456c7074 Merge pull request #91 from epi052/84-add-strip-to-cd-pipeline
add strip to linux and macos binaries
2020-10-23 17:30:00 -05:00
epi
5d564c5f28 CD pipeline back to master only trigger 2020-10-23 17:28:56 -05:00
epi
21eb70bdfa added strip to linux and macos binaries; test 2 2020-10-23 17:10:21 -05:00
epi
48b58664c7 added strip to linux and macos binaries; test 1 2020-10-23 17:07:27 -05:00
epi
c85cf21d4f Merge pull request #90 from epi052/78-check-for-updates-on-startup
feroxbuster now checks for updates on startup
2020-10-23 07:04:36 -05:00
epi
27f649d164 simplified .text() call to retrieve body 2020-10-23 06:45:51 -05:00
epi
4f53bc7b49 removed lint & added debug statement for api rate-limiting 2020-10-23 06:35:35 -05:00
epi
9fa963bb8c updates checked for and reported on startup 2020-10-23 06:27:38 -05:00
epi
0d6ae79c46 initial PR commit 2020-10-22 06:18:40 -05:00
26 changed files with 1391 additions and 398 deletions

View File

@@ -41,6 +41,9 @@ jobs:
use-cross: true
command: build
args: --release --target=${{ matrix.target }}
- name: Strip symbols from binary
run: |
strip -s ${{ matrix.path }}
- name: Build tar.gz for homebrew installs
if: matrix.type == 'ubuntu-x64'
run: |
@@ -83,6 +86,9 @@ jobs:
use-cross: true
command: build
args: --release --target=x86_64-apple-darwin
- name: Strip symbols from binary
run: |
strip -u -r target/x86_64-apple-darwin/release/feroxbuster
- name: Build tar.gz for homebrew installs
run: |
tar czf x86_64-macos-feroxbuster.tar.gz -C target/x86_64-apple-darwin/release feroxbuster

View File

@@ -1,6 +1,6 @@
[package]
name = "feroxbuster"
version = "1.1.0"
version = "1.4.0"
authors = ["Ben 'epi' Risher <epibar052@gmail.com>"]
license = "MIT"
edition = "2018"
@@ -25,12 +25,14 @@ clap = "2"
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"
openssl = { version = "0.10", features = ["vendored"] }
dirs = "3.0"
regex = "1"
crossterm = "0.18"
[dev-dependencies]
tempfile = "3.1"

187
README.md
View File

@@ -71,6 +71,7 @@ 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)
@@ -79,7 +80,12 @@ This attack is also known as Predictable Resource Location, File Enumeration, Di
- [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)
- [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)
- [Comparison w/ Similar Tools](#-comparison-w-similar-tools)
- [Common Problems/Issues (FAQ)](#-common-problemsissues-faq)
- [No file descriptors available](#no-file-descriptors-available)
- [Progress bars print one line at a time](#progress-bars-print-one-line-at-a-time)
## 💿 Installation
@@ -87,28 +93,9 @@ This attack is also known as Predictable Resource Location, File Enumeration, Di
Releases for multiple architectures can be found in the [Releases](https://github.com/epi052/feroxbuster/releases) section. The latest release for each of the following systems can be downloaded and executed as shown below.
#### Linux x86
#### Linux (32 and 64-bit) & MacOS
```
curl -sLO https://github.com/epi052/feroxbuster/releases/latest/download/x86-linux-feroxbuster.zip
unzip x86-linux-feroxbuster.zip
chmod +x ./feroxbuster
./feroxbuster -V
```
#### Linux x86_64
```
curl -sLO https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-linux-feroxbuster.zip
unzip x86_64-linux-feroxbuster.zip
chmod +x ./feroxbuster
./feroxbuster -V
```
#### MacOS x86_64
```
curl -sLO https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-macos-feroxbuster.zip
unzip x86_64-macos-feroxbuster.zip
chmod +x ./feroxbuster
./feroxbuster -V
curl -sL https://raw.githubusercontent.com/epi052/feroxbuster/master/install-nix.sh | bash
```
#### Windows x86
@@ -235,8 +222,9 @@ Configuration begins with with the following built-in default values baked into
- wordlist: `/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt`
- threads: `50`
- verbosity: `0` (no logging enabled)
- statuscodes: `200 204 301 302 307 308 401 403 405`
- useragent: `feroxbuster/VERSION`
- scan_limit: `0` (no limit imposed on concurrent scans)
- status_codes: `200 204 301 302 307 308 401 403 405`
- user_agent: `feroxbuster/VERSION`
- recursion depth: `4`
- auto-filter wildcards - `true`
- output: `stdout`
@@ -286,24 +274,26 @@ A pre-made configuration file with examples of all available settings can be fou
# Any setting used here can be overridden by the corresponding command line option/argument
#
# wordlist = "/wordlists/jhaddix/all.txt"
# statuscodes = [200, 500]
# status_codes = [200, 500]
# filter_status = [301]
# threads = 1
# timeout = 5
# proxy = "http://127.0.0.1:8080"
# verbosity = 1
# scan_limit = 6
# quiet = true
# output = "/targets/ellingson_mineral_company/gibson.txt"
# useragent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"
# user_agent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"
# redirects = true
# insecure = true
# extensions = ["php", "html"]
# norecursion = true
# addslash = true
# no_recursion = true
# add_slash = true
# stdin = true
# dontfilter = true
# dont_filter = true
# extract_links = true
# depth = 1
# sizefilters = [5174]
# filter_size = [5174]
# queries = [["name","value"], ["rick", "astley"]]
# headers can be specified on multiple lines or as an inline table
@@ -328,13 +318,13 @@ USAGE:
feroxbuster [FLAGS] [OPTIONS] --url <URL>...
FLAGS:
-f, --addslash Append / to each request
-D, --dontfilter Don't auto-filter wildcard responses
-f, --add-slash Append / to each request
-D, --dont-filter Don't auto-filter wildcard responses
-e, --extract-links Extract links from response body (html, javascript, etc...); make new requests based on
findings (default: false)
-h, --help Prints help information
-k, --insecure Disables TLS certificate validation
-n, --norecursion Do not scan recursively
-n, --no-recursion Do not scan recursively
-q, --quiet Only print URLs; Don't print status codes, response size, running config, etc...
-r, --redirects Follow redirects
--stdin Read url(s) from STDIN
@@ -344,21 +334,30 @@ FLAGS:
OPTIONS:
-d, --depth <RECURSION_DEPTH> Maximum recursion depth, a depth of 0 is infinite recursion (default: 4)
-x, --extensions <FILE_EXTENSION>... File extension(s) to search for (ex: -x php -x pdf js)
-S, --filter-size <SIZE>... Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)
-C, --filter-status <STATUS_CODE>... Filter out status codes (deny list) (ex: -C 200 -S 401)
-H, --headers <HEADER>... Specify HTTP headers (ex: -H Header:val 'stuff: things')
-o, --output <FILE> Output file to write results to (default: stdout)
-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)
-S, --sizefilter <SIZE>... Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)
-s, --statuscodes <STATUS_CODE>... Status Codes of interest (default: 200 204 301 302 307 308 401 403 405)
-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)
-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, --useragent <USER_AGENT> Sets the User-Agent (default: feroxbuster/VERSION)
-a, --user-agent <USER_AGENT> Sets the User-Agent (default: feroxbuster/VERSION)
-w, --wordlist <FILE> Path to the wordlist
```
## 🧰 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:
@@ -397,10 +396,21 @@ Example request/response with `--extract-links` enabled:
./feroxbuster -u http://127.1 --extract-links
```
Here's a comparison of a wordlist-only scan vs `--extract-links` using [Feline](https://www.hackthebox.eu/home/machines/profile/274) from Hack the Box:
Wordlist only
![normal-scan-cmp-extract](img/normal-scan-cmp-extract.gif)
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] --norecursion -vv
./feroxbuster -u http://[::1] --no-recursion -vv
```
### Read urls from STDIN; pipe only resulting urls out to another tool
@@ -421,12 +431,33 @@ cat targets | ./feroxbuster --stdin --quiet -s 200 301 302 --redirects -x js | f
./feroxbuster -u http://127.1 --proxy socks5://127.0.0.1:9050
```
### Pass auth token via query parameter
### 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
discovered directories can only begin scanning when the total number of active scans drops below the value passed to
`--scan-limit`.
```
./feroxbuster -u http://127.1 --scan-limit 2
```
![limit-demo](img/limit-demo.gif)
### Filter Response by Status Code (new in `v1.3.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 first such filter is a Status Code Filter. As responses come back from the scanned server,
each one is checked against a list of known filters and either displayed or not according to which filters are set.
```
./feroxbuster -u http://127.1 --filter-status 301
```
## 🧐 Comparison w/ Similar Tools
@@ -450,7 +481,7 @@ a few of the use-cases in which feroxbuster may be a better fit:
|------------------------------------------------------------------|---|---|---|
| fast | ✔ | ✔ | ✔ |
| easy to use | ✔ | ✔ | |
| blacklist status codes (in addition to whitelist) | | ✔ | ✔ |
| filter out responses by status code (new in `v1.3.0`) | | ✔ | ✔ |
| allows recursion | ✔ | | ✔ |
| can specify query parameters | ✔ | | ✔ |
| SOCKS proxy support | ✔ | | |
@@ -470,3 +501,83 @@ came across rustbuster when I was naming my tool (😢). I don't have any experi
be able to do POST requests with an HTTP body, has SOCKS support, and has an 8.3 shortname scanner (in addition to vhost
dns, directory, etc...). In short, it definitely looks interesting and may be what you're looking for as it has some
capability I haven't seen in similar tools.
## 🤯 Common Problems/Issues (FAQ)
### No file descriptors available
Why do I get a bunch of `No file descriptors available (os error 24)` errors?
---
There are a few potential causes of this error. The simplest is that your operating system sets an open file limit that is aggressively low. Through personal testing, I've found that `4096` is a reasonable open file limit (this will vary based on your exact setup).
There are quite a few options to solve this particular problem, of which a handful are shown below.
#### Increase the Number of Open Files
We'll start by increasing the number of open files the OS allows. On my Kali install, the default was `1024`, and I know some MacOS installs use `256` 😕.
##### Edit `/etc/security/limits.conf`
One option to up the limit is to edit `/etc/security/limits.conf` so that it includes the two lines below.
- `*` represents all users
- `hard` and `soft` indicate the hard and soft limits for the OS
- `nofile` is the number of open files option.
```
/etc/security/limits.conf
-------------------------
...
* soft nofile 4096
* hard nofile 8192
...
```
##### Use `ulimit` directly
A faster option, that is **not** persistent, is to simply use the `ulimit` command to change the setting.
```
ulimit -n 4096
```
#### Additional Tweaks (may not be needed)
If you still find yourself hitting the file limit with the above changes, there are a few additional tweaks that may help.
> This section was shamelessly stolen from this [stackoverflow answer](https://stackoverflow.com/a/3923785). More information is included in that post and is recommended reading if you end up needing to use this section.
✨ Special thanks to HTB user [@sparkla](https://www.hackthebox.eu/home/users/profile/221599) for their help with identifying these additional tweaks ✨
##### Increase the ephemeral port range, and decrease the tcp_fin_timeout.
The ephermal port range defines the maximum number of outbound sockets a host can create from a particular I.P. address. The fin_timeout defines the minimum time these sockets will stay in TIME_WAIT state (unusable after being used once). Usual system defaults are
- `net.ipv4.ip_local_port_range = 32768 61000`
- `net.ipv4.tcp_fin_timeout = 60`
This basically means your system cannot consistently guarantee more than `(61000 - 32768) / 60 = 470` sockets per second.
```
sudo sysctl net.ipv4.ip_local_port_range="15000 61000"
sudo sysctl net.ipv4.tcp_fin_timeout=30
```
##### Allow socket reuse while in a `TIME_WAIT` status
This allows fast cycling of sockets in time_wait state and re-using them. Make sure to read post [Coping with the TCP TIME-WAIT](https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux) from Vincent Bernat to understand the implications.
```
sudo sysctl net.ipv4.tcp_tw_reuse=1
```
### Progress bars print one line at a time
`feroxbuster` needs a terminal width of at least the size of what's being printed in order to do progress bar printing correctly. If your width is too small, you may see output like what's shown below.
![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.

View File

@@ -8,24 +8,26 @@
# Any setting used here can be overridden by the corresponding command line option/argument
#
# wordlist = "/wordlists/seclists/Discovery/Web-Content/raft-medium-directories.txt"
# statuscodes = [200, 500]
# status_codes = [200, 500]
# filter_status = [301]
# threads = 1
# timeout = 5
# proxy = "http://127.0.0.1:8080"
# verbosity = 1
# scan_limit = 6
# quiet = true
# output = "/targets/ellingson_mineral_company/gibson.txt"
# useragent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"
# user_agent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0"
# redirects = true
# insecure = true
# extensions = ["php", "html"]
# norecursion = true
# addslash = true
# no_recursion = true
# add_slash = true
# stdin = true
# dontfilter = true
# dont_filter = true
# extract_links = true
# depth = 1
# sizefilters = [5174]
# filter_size = [5174]
# queries = [["name","value"], ["rick", "astley"]]
# headers can be specified on multiple lines or as an inline table

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

BIN
img/limit-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

BIN
img/pause-resume-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
img/small-term.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

56
install-nix.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
BASE_URL=https://github.com/epi052/feroxbuster/releases/latest/download
MAC_ZIP=x86_64-macos-feroxbuster.zip
MAC_URL="${BASE_URL}/${MAC_ZIP}"
LIN32_ZIP=x86-linux-feroxbuster.zip
LIN32_URL="${BASE_URL}/${LIN32_ZIP}"
LIN64_ZIP=x86_64-linux-feroxbuster.zip
LIN64_URL="${BASE_URL}/${LIN64_ZIP}"
EMOJI_URL=https://gist.github.com/epi052/8196b550ea51d0907ad4b93751b1b57d/raw/6112c9f32ae07922983fdc549c54fd3fb9a38e4c/NotoColorEmoji.ttf
echo "[+] Installing feroxbuster!"
if [[ "$(uname)" == "Darwin" ]]; then
echo "[=] Found MacOS, downloading from ${MAC_URL}"
curl -sLO "${MAC_URL}"
unzip -o "${MAC_ZIP}" > /dev/null
rm "${MAC_ZIP}"
elif [[ "$(expr substr $(uname -s) 1 5)" == "Linux" ]]; then
if [[ $(getconf LONG_BIT) == 32 ]]; then
echo "[=] Found 32-bit Linux, downloading from ${LIN32_URL}"
curl -sLO "${LIN32_URL}"
unzip -o "${LIN32_ZIP}" > /dev/null
rm "${LIN32_ZIP}"
else
echo "[=] Found 64-bit Linux, downloading from ${LIN64_URL}"
curl -sLO "${LIN64_URL}"
unzip -o "${LIN64_ZIP}" > /dev/null
rm "${LIN64_ZIP}"
fi
echo "[=] Installing Noto Emoji Font"
mkdir -p ~/.fonts
pushd ~/.fonts 2>&1 >/dev/null
curl -sLO "${EMOJI_URL}"
fc-cache -f -v >/dev/null
popd 2>&1 >/dev/null
echo "[+] Noto Emoji Font installed"
fi
chmod +x ./feroxbuster
echo "[+] Installed feroxbuster version $(./feroxbuster -V)"

View File

@@ -1,4 +1,9 @@
use crate::{config::Configuration, utils::status_colorizer, VERSION};
use crate::config::{Configuration, CONFIGURATION};
use crate::utils::{make_request, status_colorizer};
use console::style;
use reqwest::{Client, Url};
use serde_json::Value;
use std::io::Write;
/// macro helper to abstract away repetitive string formatting
macro_rules! format_banner_entry_helper {
@@ -40,250 +45,581 @@ macro_rules! format_banner_entry {
};
}
/// Url used to query github's api; specifically used to look for the latest tagged release name
const UPDATE_URL: &str = "https://api.github.com/repos/epi052/feroxbuster/releases/latest";
/// Simple enum to hold three different update states
#[derive(Debug)]
enum UpdateStatus {
/// this version and latest release are the same
UpToDate,
/// this version and latest release are not the same
OutOfDate,
/// some error occurred during version check
Unknown,
}
/// Makes a request to the given url, expecting to receive a JSON response that contains a field
/// named `tag_name` that holds a value representing the latest tagged release of this tool.
///
/// ex: v1.1.0
///
/// Returns `UpdateStatus`
async fn needs_update(client: &Client, url: &str, bin_version: &str) -> UpdateStatus {
log::trace!("enter: needs_update({:?}, {})", client, url);
let unknown = UpdateStatus::Unknown;
let api_url = match Url::parse(url) {
Ok(url) => url,
Err(e) => {
log::error!("{}", e);
log::trace!("exit: needs_update -> {:?}", unknown);
return unknown;
}
};
if let Ok(response) = make_request(&client, &api_url).await {
let body = response.text().await.unwrap_or_default();
let json_response: Value = serde_json::from_str(&body).unwrap_or_default();
if json_response.is_null() {
// unwrap_or_default above should result in a null value for the json_response variable
log::error!("Could not parse JSON from response body");
log::trace!("exit: needs_update -> {:?}", unknown);
return unknown;
}
let latest_version = match json_response["tag_name"].as_str() {
Some(tag) => tag.trim_start_matches('v'),
None => {
log::error!("Could not get version field from JSON response");
log::debug!("{}", json_response);
log::trace!("exit: needs_update -> {:?}", unknown);
return unknown;
}
};
// if we've gotten this far, we have a string in the form of X.X.X where X is a number
// all that's left is to compare the current version with the version found above
return if latest_version == bin_version {
// there's really only two possible outcomes if we accept that the tag conforms to
// the X.X.X pattern:
// 1. the version strings match, meaning we're up to date
// 2. the version strings do not match, meaning we're out of date
//
// except for developers working on this code, nobody should ever be in a situation
// where they have a version greater than the latest tagged release
log::trace!("exit: needs_update -> UpdateStatus::UpToDate");
UpdateStatus::UpToDate
} else {
log::trace!("exit: needs_update -> UpdateStatus::OutOfDate");
UpdateStatus::OutOfDate
};
}
log::trace!("exit: needs_update -> {:?}", unknown);
unknown
}
/// Prints the banner to stdout.
///
/// Only prints those settings which are either always present, or passed in by the user.
pub fn initialize(targets: &[String], config: &Configuration) {
pub async fn initialize<W>(targets: &[String], config: &Configuration, version: &str, mut writer: W)
where
W: Write,
{
let artwork = format!(
r#"
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher {} ver: {}"#,
'\u{1F913}', VERSION
'\u{1F913}', version
);
let status = needs_update(&CONFIGURATION.client, UPDATE_URL, version).await;
let top = "───────────────────────────┬──────────────────────";
let addl_section = "──────────────────────────────────────────────────";
let bottom = "───────────────────────────┴──────────────────────";
eprintln!("{}", artwork);
eprintln!("{}", top);
writeln!(&mut writer, "{}", artwork).unwrap_or_default();
writeln!(&mut writer, "{}", top).unwrap_or_default();
// begin with always printed items
for target in targets {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1F3af}", "Target Url", target)
); // 🎯
)
.unwrap_or_default(); // 🎯
}
let mut codes = vec![];
for code in &config.statuscodes {
for code in &config.status_codes {
codes.push(status_colorizer(&code.to_string()))
}
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1F680}", "Threads", config.threads)
); // 🚀
)
.unwrap_or_default(); // 🚀
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4d6}", "Wordlist", config.wordlist)
); // 📖
)
.unwrap_or_default(); // 📖
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!(
"\u{1F197}",
"Status Codes",
format!("[{}]", codes.join(", "))
)
); // 🆗
)
.unwrap_or_default(); // 🆗
eprintln!(
if !config.filter_status.is_empty() {
// exception here for optional print due to me wanting the allows and denys to be printed
// one after the other
let mut code_filters = vec![];
for code in &config.filter_status {
code_filters.push(status_colorizer(&code.to_string()))
}
writeln!(
&mut writer,
"{}",
format_banner_entry!(
"\u{1f5d1}",
"Status Code Filters",
format!("[{}]", code_filters.join(", "))
)
)
.unwrap_or_default(); // 🗑
}
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4a5}", "Timeout (secs)", config.timeout)
); // 💥
)
.unwrap_or_default(); // 💥
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1F9a1}", "User-Agent", config.useragent)
); // 🦡
format_banner_entry!("\u{1F9a1}", "User-Agent", config.user_agent)
)
.unwrap_or_default(); // 🦡
// followed by the maybe printed or variably displayed values
if !config.config.is_empty() {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f489}", "Config File", config.config)
); // 💉
)
.unwrap_or_default(); // 💉
}
if !config.proxy.is_empty() {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f48e}", "Proxy", config.proxy)
); // 💎
)
.unwrap_or_default(); // 💎
}
if !config.headers.is_empty() {
for (name, value) in &config.headers {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f92f}", "Header", name, value)
); // 🤯
)
.unwrap_or_default(); // 🤯
}
}
if !config.sizefilters.is_empty() {
for filter in &config.sizefilters {
eprintln!(
if !config.filter_size.is_empty() {
for filter in &config.filter_size {
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4a2}", "Size Filter", filter)
); // 💢
)
.unwrap_or_default(); // 💢
}
}
if config.extract_links {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1F50E}", "Extract Links", config.extract_links)
); // 🔎
)
.unwrap_or_default(); // 🔎
}
if !config.queries.is_empty() {
for query in &config.queries {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!(
"\u{1f914}",
"Query Parameter",
format!("{}={}", query.0, query.1)
)
); // 🤔
)
.unwrap_or_default(); // 🤔
}
}
if !config.output.is_empty() {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4be}", "Output File", config.output)
); // 💾
)
.unwrap_or_default(); // 💾
}
if !config.extensions.is_empty() {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!(
"\u{1f4b2}",
"Extensions",
format!("[{}]", config.extensions.join(", "))
)
); // 💲
)
.unwrap_or_default(); // 💲
}
if config.insecure {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f513}", "Insecure", config.insecure)
); // 🔓
)
.unwrap_or_default(); // 🔓
}
if config.redirects {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4cd}", "Follow Redirects", config.redirects)
); // 📍
)
.unwrap_or_default(); // 📍
}
if config.dontfilter {
eprintln!(
if config.dont_filter {
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f92a}", "Filter Wildcards", !config.dontfilter)
); // 🤪
format_banner_entry!("\u{1f92a}", "Filter Wildcards", !config.dont_filter)
)
.unwrap_or_default(); // 🤪
}
match config.verbosity {
//speaker medium volume (increasing with verbosity to loudspeaker)
1 => {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f508}", "Verbosity", config.verbosity)
); // 🔈
)
.unwrap_or_default(); // 🔈
}
2 => {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f509}", "Verbosity", config.verbosity)
); // 🔉
)
.unwrap_or_default(); // 🔉
}
3 => {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f50a}", "Verbosity", config.verbosity)
); // 🔊
)
.unwrap_or_default(); // 🔊
}
4 => {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4e2}", "Verbosity", config.verbosity)
); // 📢
)
.unwrap_or_default(); // 📢
}
_ => {}
}
if config.addslash {
eprintln!(
if config.add_slash {
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1fa93}", "Add Slash", config.addslash)
); // 🪓
format_banner_entry!("\u{1fa93}", "Add Slash", config.add_slash)
)
.unwrap_or_default(); // 🪓
}
if !config.norecursion {
if !config.no_recursion {
if config.depth == 0 {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f503}", "Recursion Depth", "INFINITE")
); // 🔃
)
.unwrap_or_default(); // 🔃
} else {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f503}", "Recursion Depth", config.depth)
); // 🔃
)
.unwrap_or_default(); // 🔃
}
} else {
eprintln!(
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f6ab}", "Do Not Recurse", config.norecursion)
); // 🚫
format_banner_entry!("\u{1f6ab}", "Do Not Recurse", config.no_recursion)
)
.unwrap_or_default(); // 🚫
}
eprintln!("{}", bottom);
if CONFIGURATION.scan_limit > 0 {
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f9a5}", "Concurrent Scan Limit", config.scan_limit)
)
.unwrap_or_default(); // 🦥
}
if matches!(status, UpdateStatus::OutOfDate) {
writeln!(
&mut writer,
"{}",
format_banner_entry!(
"\u{1f389}",
"New Version Available",
"https://github.com/epi052/feroxbuster/releases/latest"
)
)
.unwrap_or_default(); // 🎉
}
writeln!(&mut writer, "{}", bottom).unwrap_or_default();
// ⏯
writeln!(
&mut writer,
" \u{23ef} Press [{}] to {}|{} your scan",
style("ENTER").yellow(),
style("pause").red(),
style("resume").green()
)
.unwrap_or_default();
writeln!(&mut writer, "{}", addl_section).unwrap_or_default();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::VERSION;
use httpmock::Method::GET;
use httpmock::{Mock, MockServer};
use std::fs::read_to_string;
use std::io::stderr;
use std::time::Duration;
use tempfile::NamedTempFile;
#[test]
#[tokio::test(core_threads = 1)]
/// test to hit no execution of targets for loop in banner
fn banner_without_targets() {
async fn banner_intialize_without_targets() {
let config = Configuration::default();
initialize(&[], &config);
initialize(&[], &config, VERSION, stderr()).await;
}
#[test]
#[tokio::test(core_threads = 1)]
/// test to hit no execution of statuscode for loop in banner
fn banner_without_status_codes() {
async fn banner_intialize_without_status_codes() {
let mut config = Configuration::default();
config.statuscodes = vec![];
initialize(&[String::from("http://localhost")], &config);
config.status_codes = vec![];
initialize(
&[String::from("http://localhost")],
&config,
VERSION,
stderr(),
)
.await;
}
#[test]
#[tokio::test(core_threads = 1)]
/// test to hit an empty config file
fn banner_without_config_file() {
async fn banner_intialize_without_config_file() {
let mut config = Configuration::default();
config.config = String::new();
initialize(&[String::from("http://localhost")], &config);
initialize(
&[String::from("http://localhost")],
&config,
VERSION,
stderr(),
)
.await;
}
#[test]
#[tokio::test(core_threads = 1)]
/// test to hit an empty config file
fn banner_without_queries() {
async fn banner_intialize_without_queries() {
let mut config = Configuration::default();
config.queries = vec![(String::new(), String::new())];
initialize(&[String::from("http://localhost")], &config);
initialize(
&[String::from("http://localhost")],
&config,
VERSION,
stderr(),
)
.await;
}
#[tokio::test(core_threads = 1)]
/// test to show that a new version is available for download
async fn banner_intialize_with_mismatched_version() {
let config = Configuration::default();
let file = NamedTempFile::new().unwrap();
initialize(
&[String::from("http://localhost")],
&config,
"mismatched-version",
&file,
)
.await;
let contents = read_to_string(file.path()).unwrap();
println!("contents: {}", contents);
assert!(contents.contains("New Version Available"));
assert!(contents.contains("https://github.com/epi052/feroxbuster/releases/latest"));
}
#[tokio::test(core_threads = 1)]
/// test that
async fn banner_needs_update_returns_unknown_with_bad_url() {
let result = needs_update(&CONFIGURATION.client, &"", VERSION).await;
assert!(matches!(result, UpdateStatus::Unknown));
}
#[tokio::test(core_threads = 1)]
/// test return value of good url to needs_update
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 result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.1.0").await;
assert_eq!(mock.times_called(), 1);
assert!(matches!(result, UpdateStatus::UpToDate));
}
#[tokio::test(core_threads = 1)]
/// test return value of good url to needs_update that returns a newer version than current
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 result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
assert_eq!(mock.times_called(), 1);
assert!(matches!(result, UpdateStatus::OutOfDate));
}
#[tokio::test(core_threads = 1)]
/// test return value of good url that times out
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 result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
assert_eq!(mock.times_called(), 1);
assert!(matches!(result, UpdateStatus::Unknown));
}
#[tokio::test(core_threads = 1)]
/// test return value of good url with bad json response
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 result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
assert_eq!(mock.times_called(), 1);
assert!(matches!(result, UpdateStatus::Unknown));
}
#[tokio::test(core_threads = 1)]
/// test return value of good url with json response that lacks the tag_name field
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 result = needs_update(&CONFIGURATION.client, &srv.url("/latest"), "1.0.1").await;
assert_eq!(mock.times_called(), 1);
assert!(matches!(result, UpdateStatus::Unknown));
}
}

View File

@@ -10,7 +10,7 @@ use std::time::Duration;
/// Create and return an instance of [reqwest::Client](https://docs.rs/reqwest/latest/reqwest/struct.Client.html)
pub fn initialize(
timeout: u64,
useragent: &str,
user_agent: &str,
redirects: bool,
insecure: bool,
headers: &HashMap<String, String>,
@@ -27,7 +27,7 @@ pub fn initialize(
let client = Client::builder()
.timeout(Duration::new(timeout, 0))
.user_agent(useragent)
.user_agent(user_agent)
.danger_accept_invalid_certs(insecure)
.default_headers(header_map)
.redirect(policy);

View File

@@ -51,9 +51,13 @@ pub struct Configuration {
#[serde(default)]
pub target_url: String,
/// Status Codes of interest (default: 200 204 301 302 307 308 401 403 405)
#[serde(default = "statuscodes")]
pub statuscodes: Vec<u16>,
/// Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)
#[serde(default = "status_codes")]
pub status_codes: Vec<u16>,
/// Status Codes to filter out (deny list)
#[serde(default)]
pub filter_status: Vec<u16>,
/// Instance of [reqwest::Client](https://docs.rs/reqwest/latest/reqwest/struct.Client.html)
#[serde(skip)]
@@ -80,8 +84,8 @@ pub struct Configuration {
pub output: String,
/// Sets the User-Agent (default: feroxbuster/VERSION)
#[serde(default = "useragent")]
pub useragent: String,
#[serde(default = "user_agent")]
pub user_agent: String,
/// Follow redirects
#[serde(default)]
@@ -105,7 +109,7 @@ pub struct Configuration {
/// Do not scan recursively
#[serde(default)]
pub norecursion: bool,
pub no_recursion: bool,
/// Extract links from html/javscript
#[serde(default)]
@@ -113,7 +117,7 @@ pub struct Configuration {
/// Append / to each request
#[serde(default)]
pub addslash: bool,
pub add_slash: bool,
/// Read url(s) from STDIN
#[serde(default)]
@@ -123,16 +127,20 @@ pub struct Configuration {
#[serde(default = "depth")]
pub depth: usize,
/// Number of concurrent scans permitted; a limit of 0 means no limit is imposed
#[serde(default)]
pub scan_limit: usize,
/// Filter out messages of a particular size
#[serde(default)]
pub sizefilters: Vec<u64>,
pub filter_size: Vec<u64>,
/// Don't auto-filter wildcard responses
#[serde(default)]
pub dontfilter: bool,
pub dont_filter: bool,
}
// functions timeout, threads, statuscodes, useragent, wordlist, and depth are used to provide
// functions timeout, threads, status_codes, user_agent, wordlist, 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
@@ -147,7 +155,7 @@ fn threads() -> usize {
}
/// default status codes
fn statuscodes() -> Vec<u16> {
fn status_codes() -> Vec<u16> {
DEFAULT_STATUS_CODES
.iter()
.map(|code| code.as_u16())
@@ -159,8 +167,8 @@ fn wordlist() -> String {
String::from(DEFAULT_WORDLIST)
}
/// default useragent
fn useragent() -> String {
/// default user-agent
fn user_agent() -> String {
format!("feroxbuster/{}", VERSION)
}
@@ -173,21 +181,22 @@ impl Default for Configuration {
/// Builds the default Configuration for feroxbuster
fn default() -> Self {
let timeout = timeout();
let useragent = useragent();
let client = client::initialize(timeout, &useragent, false, false, &HashMap::new(), None);
let user_agent = user_agent();
let client = client::initialize(timeout, &user_agent, false, false, &HashMap::new(), None);
Configuration {
client,
timeout,
useragent,
dontfilter: false,
user_agent,
dont_filter: false,
quiet: false,
stdin: false,
verbosity: 0,
addslash: false,
scan_limit: 0,
add_slash: false,
insecure: false,
redirects: false,
norecursion: false,
no_recursion: false,
extract_links: false,
proxy: String::new(),
config: String::new(),
@@ -195,12 +204,13 @@ impl Default for Configuration {
target_url: String::new(),
queries: Vec::new(),
extensions: Vec::new(),
sizefilters: Vec::new(),
filter_size: Vec::new(),
filter_status: Vec::new(),
headers: HashMap::new(),
threads: threads(),
depth: depth(),
wordlist: wordlist(),
statuscodes: statuscodes(),
status_codes: status_codes(),
}
}
}
@@ -218,20 +228,22 @@ impl Configuration {
/// - **timeout**: `7` seconds
/// - **verbosity**: `0` (no logging enabled)
/// - **proxy**: `None`
/// - **statuscodes**: [`DEFAULT_RESPONSE_CODES`](constant.DEFAULT_RESPONSE_CODES.html)
/// - **status_codes**: [`DEFAULT_RESPONSE_CODES`](constant.DEFAULT_RESPONSE_CODES.html)
/// - **filter_status**: `None`
/// - **output**: `None` (print to stdout)
/// - **quiet**: `false`
/// - **useragent**: `feroxer/VERSION`
/// - **user_agent**: `feroxer/VERSION`
/// - **insecure**: `false` (don't be insecure, i.e. don't allow invalid certs)
/// - **extensions**: `None`
/// - **sizefilters**: `None`
/// - **filter_size**: `None`
/// - **headers**: `None`
/// - **queries**: `None`
/// - **norecursion**: `false` (recursively scan enumerated sub-directories)
/// - **addslash**: `false`
/// - **no_recursion**: `false` (recursively scan enumerated sub-directories)
/// - **add_slash**: `false`
/// - **stdin**: `false`
/// - **dontfilter**: `false` (auto filter wildcard responses)
/// - **dont_filter**: `false` (auto filter wildcard responses)
/// - **depth**: `4` (maximum recursion depth)
/// - **scan_limit**: `0` (no limit on concurrent scans imposed)
///
/// After which, any values defined in a
/// [ferox-config.toml](constant.DEFAULT_CONFIG_NAME.html) config file will override the
@@ -316,6 +328,12 @@ impl Configuration {
config.depth = depth;
}
if args.value_of("scan_limit").is_some() {
let scan_limit =
value_t!(args.value_of("scan_limit"), usize).unwrap_or_else(|e| e.exit());
config.scan_limit = scan_limit;
}
if args.value_of("wordlist").is_some() {
config.wordlist = String::from(args.value_of("wordlist").unwrap());
}
@@ -324,9 +342,29 @@ impl Configuration {
config.output = String::from(args.value_of("output").unwrap());
}
if args.values_of("statuscodes").is_some() {
config.statuscodes = args
.values_of("statuscodes")
if args.values_of("status_codes").is_some() {
config.status_codes = args
.values_of("status_codes")
.unwrap() // already known good
.map(|code| {
StatusCode::from_bytes(code.as_bytes())
.unwrap_or_else(|e| {
eprintln!(
"{} {}: {}",
status_colorizer("ERROR"),
module_colorizer("Configuration::new"),
e
);
exit(1)
})
.as_u16()
})
.collect();
}
if args.values_of("filter_status").is_some() {
config.filter_status = args
.values_of("filter_status")
.unwrap() // already known good
.map(|code| {
StatusCode::from_bytes(code.as_bytes())
@@ -352,9 +390,9 @@ impl Configuration {
.collect();
}
if args.values_of("sizefilters").is_some() {
config.sizefilters = args
.values_of("sizefilters")
if args.values_of("filter_size").is_some() {
config.filter_size = args
.values_of("filter_size")
.unwrap() // already known good
.map(|size| {
size.parse::<u64>().unwrap_or_else(|e| {
@@ -378,8 +416,8 @@ impl Configuration {
config.quiet = args.is_present("quiet");
}
if args.is_present("dontfilter") {
config.dontfilter = args.is_present("dontfilter");
if args.is_present("dont_filter") {
config.dont_filter = args.is_present("dont_filter");
}
if args.occurrences_of("verbosity") > 0 {
@@ -388,12 +426,12 @@ impl Configuration {
config.verbosity = args.occurrences_of("verbosity") as u8;
}
if args.is_present("norecursion") {
config.norecursion = args.is_present("norecursion");
if args.is_present("no_recursion") {
config.no_recursion = args.is_present("no_recursion");
}
if args.is_present("addslash") {
config.addslash = args.is_present("addslash");
if args.is_present("add_slash") {
config.add_slash = args.is_present("add_slash");
}
if args.is_present("extract_links") {
@@ -413,8 +451,8 @@ impl Configuration {
config.proxy = String::from(args.value_of("proxy").unwrap());
}
if args.value_of("useragent").is_some() {
config.useragent = String::from(args.value_of("useragent").unwrap());
if args.value_of("user_agent").is_some() {
config.user_agent = String::from(args.value_of("user_agent").unwrap());
}
if args.value_of("timeout").is_some() {
@@ -462,7 +500,7 @@ impl Configuration {
// the client and store it in the config struct
if !config.proxy.is_empty()
|| config.timeout != timeout()
|| config.useragent != useragent()
|| config.user_agent != user_agent()
|| config.redirects
|| config.insecure
|| !config.headers.is_empty()
@@ -470,7 +508,7 @@ impl Configuration {
if config.proxy.is_empty() {
config.client = client::initialize(
config.timeout,
&config.useragent,
&config.user_agent,
config.redirects,
config.insecure,
&config.headers,
@@ -479,7 +517,7 @@ impl Configuration {
} else {
config.client = client::initialize(
config.timeout,
&config.useragent,
&config.user_agent,
config.redirects,
config.insecure,
&config.headers,
@@ -515,25 +553,27 @@ impl Configuration {
fn merge_config(settings: &mut Self, settings_to_merge: Self) {
settings.threads = settings_to_merge.threads;
settings.wordlist = settings_to_merge.wordlist;
settings.statuscodes = settings_to_merge.statuscodes;
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.useragent = settings_to_merge.useragent;
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.norecursion = settings_to_merge.norecursion;
settings.addslash = settings_to_merge.addslash;
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.sizefilters = settings_to_merge.sizefilters;
settings.dontfilter = settings_to_merge.dontfilter;
settings.filter_size = settings_to_merge.filter_size;
settings.filter_status = settings_to_merge.filter_status;
settings.dont_filter = settings_to_merge.dont_filter;
settings.scan_limit = settings_to_merge.scan_limit;
}
/// If present, read in `DEFAULT_CONFIG_NAME` and deserialize the specified values
@@ -569,25 +609,27 @@ mod tests {
fn setup_config_test() -> Configuration {
let data = r#"
wordlist = "/some/path"
statuscodes = [201, 301, 401]
status_codes = [201, 301, 401]
threads = 40
timeout = 5
proxy = "http://127.0.0.1:8080"
quiet = true
verbosity = 1
scan_limit = 6
output = "/some/otherpath"
redirects = true
insecure = true
extensions = ["html", "php", "js"]
headers = {stuff = "things", mostuff = "mothings"}
queries = [["name","value"], ["rick", "astley"]]
norecursion = true
addslash = true
no_recursion = true
add_slash = true
stdin = true
dontfilter = true
dont_filter = true
extract_links = true
depth = 1
sizefilters = [4120]
filter_size = [4120]
filter_status = [201]
"#;
let tmp_dir = TempDir::new().unwrap();
let file = tmp_dir.path().join(DEFAULT_CONFIG_NAME);
@@ -603,22 +645,24 @@ mod tests {
assert_eq!(config.proxy, String::new());
assert_eq!(config.target_url, String::new());
assert_eq!(config.config, String::new());
assert_eq!(config.statuscodes, statuscodes());
assert_eq!(config.status_codes, status_codes());
assert_eq!(config.threads, threads());
assert_eq!(config.depth, depth());
assert_eq!(config.timeout, timeout());
assert_eq!(config.verbosity, 0);
assert_eq!(config.scan_limit, 0);
assert_eq!(config.quiet, false);
assert_eq!(config.dontfilter, false);
assert_eq!(config.norecursion, false);
assert_eq!(config.dont_filter, false);
assert_eq!(config.no_recursion, false);
assert_eq!(config.stdin, false);
assert_eq!(config.addslash, false);
assert_eq!(config.add_slash, false);
assert_eq!(config.redirects, false);
assert_eq!(config.extract_links, false);
assert_eq!(config.insecure, false);
assert_eq!(config.queries, Vec::new());
assert_eq!(config.extensions, Vec::<String>::new());
assert_eq!(config.sizefilters, Vec::<u64>::new());
assert_eq!(config.filter_size, Vec::<u64>::new());
assert_eq!(config.filter_status, Vec::<u16>::new());
assert_eq!(config.headers, HashMap::new());
}
@@ -631,9 +675,9 @@ mod tests {
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_statuscodes() {
fn config_reads_status_codes() {
let config = setup_config_test();
assert_eq!(config.statuscodes, vec![201, 301, 401]);
assert_eq!(config.status_codes, vec![201, 301, 401]);
}
#[test]
@@ -650,6 +694,13 @@ mod tests {
assert_eq!(config.depth, 1);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_scan_limit() {
let config = setup_config_test();
assert_eq!(config.scan_limit, 6);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_timeout() {
@@ -701,9 +752,9 @@ mod tests {
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_norecursion() {
fn config_reads_no_recursion() {
let config = setup_config_test();
assert_eq!(config.norecursion, true);
assert_eq!(config.no_recursion, true);
}
#[test]
@@ -715,16 +766,16 @@ mod tests {
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_dontfilter() {
fn config_reads_dont_filter() {
let config = setup_config_test();
assert_eq!(config.dontfilter, true);
assert_eq!(config.dont_filter, true);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_addslash() {
fn config_reads_add_slash() {
let config = setup_config_test();
assert_eq!(config.addslash, true);
assert_eq!(config.add_slash, true);
}
#[test]
@@ -743,9 +794,16 @@ mod tests {
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_sizefilters() {
fn config_reads_filter_size() {
let config = setup_config_test();
assert_eq!(config.sizefilters, vec![4120]);
assert_eq!(config.filter_size, vec![4120]);
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_filter_status() {
let config = setup_config_test();
assert_eq!(config.filter_status, vec![201]);
}
#[test]

142
src/filters.rs Normal file
View File

@@ -0,0 +1,142 @@
use crate::config::CONFIGURATION;
use crate::utils::get_url_path_length;
use crate::FeroxResponse;
use std::any::Any;
use std::fmt::Debug;
// references:
// https://dev.to/magnusstrale/rust-trait-objects-in-a-vector-non-trivial-4co5
// https://stackoverflow.com/questions/25339603/how-to-test-for-equality-between-trait-objects
/// FeroxFilter trait; represents different types of possible filters that can be applied to
/// responses
pub trait FeroxFilter: Debug + Send + Sync {
/// Determine whether or not this particular filter should be applied or not
fn should_filter_response(&self, response: &FeroxResponse) -> bool;
/// delegates to the FeroxFilter-implementing type which gives us the actual type of self
fn box_eq(&self, other: &dyn Any) -> bool;
/// gives us `other` as Any in box_eq
fn as_any(&self) -> &dyn Any;
}
/// implementation of PartialEq, necessary long-form due to "trait cannot be made into an object"
/// error when attempting to derive PartialEq on the trait itself
impl PartialEq for Box<dyn FeroxFilter> {
/// Perform a comparison of two implementors of the FeroxFilter trait
fn eq(&self, other: &Box<dyn FeroxFilter>) -> bool {
self.box_eq(other.as_any())
}
}
/// Data holder for two pieces of data needed when auto-filtering out wildcard responses
///
/// `dynamic` is the size of the response that will later be combined with the length
/// of the path of the url requested and used to determine interesting pages from custom
/// 404s where the requested url is reflected back in the response
///
/// `size` is size of the response that should be included with filters passed via runtime
/// configuration and any static wildcard lengths.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct WildcardFilter {
/// size of the response that will later be combined with the length of the path of the url
/// requested
pub dynamic: u64,
/// size of the response that should be included with filters passed via runtime configuration
pub size: u64,
}
/// implementation of FeroxFilter for WildcardFilter
impl FeroxFilter for WildcardFilter {
/// Examine size, dynamic, and content_len to determine whether or not the response received
/// is a wildcard response and therefore should be filtered out
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {:?})", self, response);
// quick return if dont_filter is set
if CONFIGURATION.dont_filter {
// --dont-filter applies specifically to wildcard filters, it is not a 100% catch all
// for not filtering anything. As such, it should live in the implementation of
// a wildcard filter
return false;
}
if self.size > 0 && self.size == response.content_length() {
// static wildcard size found during testing
// size isn't default, size equals response length, and auto-filter is on
log::debug!("static wildcard: filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
if self.dynamic > 0 {
// dynamic wildcard offset found during testing
// I'm about to manually split this url path instead of using reqwest::Url's
// builtin parsing. The reason is that they call .split() on the url path
// except that I don't want an empty string taking up the last index in the
// event that the url ends with a forward slash. It's ugly enough to be split
// into its own function for readability.
let url_len = get_url_path_length(&response.url());
if url_len + self.dynamic == response.content_length() {
log::debug!("dynamic wildcard: filtered out {}", response.url());
log::trace!("exit: should_filter_response -> true");
return true;
}
}
log::trace!("exit: should_filter_response -> false");
false
}
/// Compare one WildcardFilter to another
fn box_eq(&self, other: &dyn Any) -> bool {
other.downcast_ref::<Self>().map_or(false, |a| self == a)
}
/// Return self as Any for dynamic dispatch purposes
fn as_any(&self) -> &dyn Any {
self
}
}
/// Simple implementor of FeroxFilter; used to filter out status codes specified using
/// -C|--filter-status
#[derive(Default, Debug, PartialEq)]
pub struct StatusCodeFilter {
/// Status code that should not be displayed to the user
pub filter_code: u16,
}
/// implementation of FeroxFilter for StatusCodeFilter
impl FeroxFilter for StatusCodeFilter {
/// Check `filter_code` against what was passed in via -C|--filter-status
fn should_filter_response(&self, response: &FeroxResponse) -> bool {
log::trace!("enter: should_filter_response({:?} {:?})", self, response);
if response.status().as_u16() == self.filter_code {
log::debug!(
"filtered out {} based on --filter-status of {}",
response.url(),
self.filter_code
);
log::trace!("exit: should_filter_response -> true");
return true;
}
log::trace!("exit: should_filter_response -> false");
false
}
/// Compare one StatusCodeFilter to another
fn box_eq(&self, other: &dyn Any) -> bool {
other.downcast_ref::<Self>().map_or(false, |a| self == a)
}
/// Return self as Any for dynamic dispatch purposes
fn as_any(&self) -> &dyn Any {
self
}
}

View File

@@ -1,11 +1,15 @@
use crate::config::{CONFIGURATION, PROGRESS_PRINTER};
use crate::scanner::should_filter_response;
use crate::utils::{
ferox_print, format_url, get_url_path_length, make_request, module_colorizer, status_colorizer,
use crate::{
config::{CONFIGURATION, PROGRESS_PRINTER},
filters::WildcardFilter,
scanner::should_filter_response,
utils::{
ferox_print, format_url, get_url_path_length, make_request, module_colorizer,
status_colorizer,
},
FeroxResponse,
};
use console::style;
use indicatif::ProgressBar;
use reqwest::Response;
use std::process;
use tokio::sync::mpsc::UnboundedSender;
use uuid::Uuid;
@@ -13,24 +17,6 @@ use uuid::Uuid;
/// length of a standard UUID, used when determining wildcard responses
const UUID_LENGTH: u64 = 32;
/// Data holder for two pieces of data needed when auto-filtering out wildcard responses
///
/// `dynamic` is the size of the response that will later be combined with the length
/// of the path of the url requested and used to determine interesting pages from custom
/// 404s where the requested url is reflected back in the response
///
/// `size` is size of the response that should be included with filters passed via runtime
/// configuration and any static wildcard lengths.
#[derive(Default, Debug, PartialEq, Copy, Clone)]
pub struct WildcardFilter {
/// size of the response that will later be combined with the length of the path of the url
/// requested
pub dynamic: u64,
/// size of the response that should be included with filters passed via runtime configuration
pub size: u64,
}
/// Simple helper to return a uuid, formatted as lowercase without hyphens
///
/// `length` determines the number of uuids to string together. Each uuid
@@ -66,8 +52,8 @@ pub async fn wildcard_test(
tx_file
);
if CONFIGURATION.dontfilter {
// early return, dontfilter scans don't need tested
if CONFIGURATION.dont_filter {
// early return, dont_filter scans don't need tested
log::trace!("exit: wildcard_test -> None");
return None;
}
@@ -75,13 +61,13 @@ pub async fn wildcard_test(
let clone_req_one = tx_file.clone();
let clone_req_two = tx_file.clone();
if let Some(resp_one) = make_wildcard_request(&target_url, 1, clone_req_one).await {
if let Some(ferox_response) = make_wildcard_request(&target_url, 1, clone_req_one).await {
bar.inc(1);
// found a wildcard response
let mut wildcard = WildcardFilter::default();
let wc_length = resp_one.content_length().unwrap_or(0);
let wc_length = ferox_response.content_length();
if wc_length == 0 {
log::trace!("exit: wildcard_test -> Some({:?})", wildcard);
@@ -93,25 +79,23 @@ pub async fn wildcard_test(
if let Some(resp_two) = make_wildcard_request(&target_url, 3, clone_req_two).await {
bar.inc(1);
let wc2_length = resp_two.content_length().unwrap_or(0);
let wc2_length = resp_two.content_length();
if wc2_length == wc_length + (UUID_LENGTH * 2) {
// second length is what we'd expect to see if the requested url is
// reflected in the response along with some static content; aka custom 404
let url_len = get_url_path_length(&resp_one.url());
let url_len = get_url_path_length(&ferox_response.url());
wildcard.dynamic = wc_length - url_len;
if !CONFIGURATION.quiet
&& !should_filter_response(&wildcard.dynamic, &resp_one.url())
{
if !CONFIGURATION.quiet {
let msg = format!(
"{} {:>10} Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}\n",
status_colorizer("WLD"),
wildcard.dynamic,
style("auto-filtering").yellow(),
style(wc_length - url_len).cyan(),
style("--dontfilter").yellow()
style("--dont-filter").yellow()
);
ferox_print(&msg, &PROGRESS_PRINTER);
@@ -125,15 +109,14 @@ pub async fn wildcard_test(
} else if wc_length == wc2_length {
wildcard.size = wc_length;
if !CONFIGURATION.quiet && !should_filter_response(&wildcard.size, &resp_one.url())
{
if !CONFIGURATION.quiet {
let msg = format!(
"{} {:>10} Wildcard response is static; {} {} responses; toggle this behavior by using {}\n",
status_colorizer("WLD"),
wc_length,
style("auto-filtering").yellow(),
style(wc_length).cyan(),
style("--dontfilter").yellow()
style("--dont-filter").yellow()
);
ferox_print(&msg, &PROGRESS_PRINTER);
@@ -167,7 +150,7 @@ async fn make_wildcard_request(
target_url: &str,
length: usize,
tx_file: UnboundedSender<String>,
) -> Option<Response> {
) -> Option<FeroxResponse> {
log::trace!(
"enter: make_wildcard_request({}, {}, {:?})",
target_url,
@@ -180,7 +163,7 @@ async fn make_wildcard_request(
let nonexistent = match format_url(
target_url,
&unique_str,
CONFIGURATION.addslash,
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
None,
) {
@@ -197,20 +180,21 @@ async fn make_wildcard_request(
match make_request(&CONFIGURATION.client, &nonexistent.to_owned()).await {
Ok(response) => {
if CONFIGURATION
.statuscodes
.status_codes
.contains(&response.status().as_u16())
{
// found a wildcard response
let url_len = get_url_path_length(&response.url());
let content_len = response.content_length().unwrap_or(0);
let ferox_response = FeroxResponse::from(response, false).await;
let url_len = get_url_path_length(&ferox_response.url());
let content_len = ferox_response.content_length();
if !CONFIGURATION.quiet && !should_filter_response(&content_len, &response.url()) {
if !CONFIGURATION.quiet && !should_filter_response(&ferox_response) {
let msg = format!(
"{} {:>10} Got {} for {} (url length: {})\n",
wildcard,
content_len,
status_colorizer(&response.status().as_str()),
response.url(),
status_colorizer(&ferox_response.status().as_str()),
ferox_response.url(),
url_len
);
@@ -223,18 +207,16 @@ async fn make_wildcard_request(
);
}
if response.status().is_redirection() {
if ferox_response.status().is_redirection() {
// show where it goes, if possible
if let Some(next_loc) = response.headers().get("Location") {
if let Some(next_loc) = ferox_response.headers().get("Location") {
let next_loc_str = next_loc.to_str().unwrap_or("Unknown");
if !CONFIGURATION.quiet
&& !should_filter_response(&content_len, &response.url())
{
if !CONFIGURATION.quiet && !should_filter_response(&ferox_response) {
let msg = format!(
"{} {:>10} {} redirects to => {}\n",
wildcard,
content_len,
response.url(),
ferox_response.url(),
next_loc_str
);
@@ -248,8 +230,8 @@ async fn make_wildcard_request(
}
}
}
log::trace!("exit: make_wildcard_request -> {:?}", response);
return Some(response);
log::trace!("exit: make_wildcard_request -> {:?}", ferox_response);
return Some(ferox_response);
}
}
Err(e) => {
@@ -276,7 +258,7 @@ pub async fn connectivity_test(target_urls: &[String]) -> Vec<String> {
let request = match format_url(
target_url,
"",
CONFIGURATION.addslash,
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
None,
) {

View File

@@ -2,6 +2,7 @@ pub mod banner;
pub mod client;
pub mod config;
pub mod extractor;
pub mod filters;
pub mod heuristics;
pub mod logger;
pub mod parser;
@@ -10,8 +11,10 @@ pub mod reporter;
pub mod scanner;
pub mod utils;
use reqwest::header::HeaderMap;
use reqwest::{Response, StatusCode, Url};
use reqwest::{
header::HeaderMap,
{Response, StatusCode, Url},
};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
/// Generic Result type to ease error handling in async contexts
@@ -32,6 +35,9 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const DEFAULT_WORDLIST: &str =
"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt";
/// Number of milliseconds to wait between polls of `PAUSE_SCAN` when user pauses a scan
pub static SLEEP_DURATION: u64 = 500;
/// Default list of status codes to report
///
/// * 200 Ok
@@ -61,7 +67,7 @@ pub const DEFAULT_STATUS_CODES: [StatusCode; 9] = [
pub const DEFAULT_CONFIG_NAME: &str = "ferox-config.toml";
/// A `FeroxResponse`, derived from a `Response` to a submitted `Request`
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct FeroxResponse {
/// The final `Url` of this `FeroxResponse`
url: Url,

View File

@@ -1,17 +1,59 @@
use feroxbuster::config::{CONFIGURATION, PROGRESS_PRINTER};
use feroxbuster::scanner::scan_url;
use feroxbuster::utils::{ferox_print, get_current_depth, module_colorizer, status_colorizer};
use feroxbuster::{banner, heuristics, logger, reporter, FeroxResponse, FeroxResult};
use crossterm::event::{self, Event, KeyCode};
use feroxbuster::{
banner,
config::{CONFIGURATION, PROGRESS_PRINTER},
heuristics, logger, reporter,
scanner::{scan_url, PAUSE_SCAN},
utils::{ferox_print, get_current_depth, module_colorizer, status_colorizer},
FeroxResponse, FeroxResult, SLEEP_DURATION, VERSION,
};
use futures::StreamExt;
use std::collections::HashSet;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::process;
use std::sync::Arc;
use tokio::io;
use tokio::sync::mpsc::UnboundedSender;
use std::{
collections::HashSet,
fs::File,
io::{stderr, BufRead, BufReader},
process,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
use tokio::{io, sync::mpsc::UnboundedSender};
use tokio_util::codec::{FramedRead, LinesCodec};
/// Atomic boolean flag, used to determine whether or not the terminal input handler should exit
pub static SCAN_COMPLETE: AtomicBool = AtomicBool::new(false);
/// Handles specific key events triggered by the user over stdin
fn terminal_input_handler() {
log::trace!("enter: terminal_input_handler");
loop {
if event::poll(Duration::from_millis(SLEEP_DURATION)).unwrap_or(false) {
// It's guaranteed that the `read()` won't block when the `poll()`
// function returns `true`
if let Ok(key_pressed) = event::read() {
if key_pressed == Event::Key(KeyCode::Enter.into()) {
// if the user presses Enter, toggle the value stored in PAUSE_SCAN
// ignore any other keys
let current = PAUSE_SCAN.load(Ordering::Acquire);
PAUSE_SCAN.store(!current, Ordering::Release);
}
}
} else {
// Timeout expired and no `Event` is available; use the timeout to check SCAN_COMPLETE
if SCAN_COMPLETE.load(Ordering::Relaxed) {
// scan has been marked complete by main, time to exit the loop
break;
}
}
}
log::trace!("exit: terminal_input_handler");
}
/// Create a HashSet of Strings from the given wordlist then stores it inside an Arc
fn get_unique_words_from_wordlist(path: &str) -> FeroxResult<Arc<HashSet<String>>> {
log::trace!("enter: get_unique_words_from_wordlist({})", path);
@@ -132,6 +174,11 @@ async fn main() {
log::trace!("enter: main");
log::debug!("{:#?}", *CONFIGURATION);
// spawn a thread that listens for keyboard input on stdin, when a user presses enter
// the input handler will toggle PAUSE_SCAN, which in turn is used to pause and resume
// scans that are already running
tokio::task::spawn_blocking(terminal_input_handler);
let save_output = !CONFIGURATION.output.is_empty(); // was -o used?
let (tx_term, tx_file, term_handle, file_handle) =
@@ -158,7 +205,8 @@ async fn main() {
if !CONFIGURATION.quiet {
// only print banner if -q isn't used
banner::initialize(&targets, &CONFIGURATION);
let std_stderr = stderr(); // std::io::stderr
banner::initialize(&targets, &CONFIGURATION, &VERSION, std_stderr).await;
}
// discard non-responsive targets
@@ -204,6 +252,9 @@ async fn main() {
log::trace!("done awaiting file output handler's receiver");
}
// mark all scans complete so the terminal input handler will exit cleanly
SCAN_COMPLETE.store(true, Ordering::Relaxed);
log::trace!("exit: main");
// clean-up function for the MultiProgress bar; must be called last in order to still see

View File

@@ -68,15 +68,15 @@ pub fn initialize() -> App<'static, 'static> {
),
)
.arg(
Arg::with_name("statuscodes")
Arg::with_name("status_codes")
.short("s")
.long("statuscodes")
.long("status-codes")
.value_name("STATUS_CODE")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.help(
"Status Codes of interest (default: 200 204 301 302 307 308 401 403 405)",
"Status Codes to include (allow list) (default: 200 204 301 302 307 308 401 403 405)",
),
)
.arg(
@@ -87,9 +87,9 @@ pub fn initialize() -> App<'static, 'static> {
.help("Only print URLs; Don't print status codes, response size, running config, etc...")
)
.arg(
Arg::with_name("dontfilter")
Arg::with_name("dont_filter")
.short("D")
.long("dontfilter")
.long("dont-filter")
.takes_value(false)
.help("Don't auto-filter wildcard responses")
)
@@ -102,9 +102,9 @@ pub fn initialize() -> App<'static, 'static> {
.takes_value(true),
)
.arg(
Arg::with_name("useragent")
Arg::with_name("user_agent")
.short("a")
.long("useragent")
.long("user-agent")
.value_name("USER_AGENT")
.takes_value(true)
.help(
@@ -162,16 +162,16 @@ pub fn initialize() -> App<'static, 'static> {
),
)
.arg(
Arg::with_name("norecursion")
Arg::with_name("no_recursion")
.short("n")
.long("norecursion")
.long("no-recursion")
.takes_value(false)
.help("Do not scan recursively")
)
.arg(
Arg::with_name("addslash")
Arg::with_name("add_slash")
.short("f")
.long("addslash")
.long("add-slash")
.takes_value(false)
.conflicts_with("extensions")
.help("Append / to each request")
@@ -184,9 +184,9 @@ pub fn initialize() -> App<'static, 'static> {
.conflicts_with("url")
)
.arg(
Arg::with_name("sizefilters")
Arg::with_name("filter_size")
.short("S")
.long("sizefilter")
.long("filter-size")
.value_name("SIZE")
.takes_value(true)
.multiple(true)
@@ -195,6 +195,18 @@ pub fn initialize() -> App<'static, 'static> {
"Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)",
),
)
.arg(
Arg::with_name("filter_status")
.short("C")
.long("filter-status")
.value_name("STATUS_CODE")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.help(
"Filter out status codes (deny list) (ex: -C 200 -S 401)",
),
)
.arg(
Arg::with_name("extract_links")
.short("e")
@@ -202,7 +214,14 @@ pub fn initialize() -> App<'static, 'static> {
.takes_value(false)
.help("Extract links from response body (html, javascript, etc...); make new requests based on findings (default: false)")
)
.arg(
Arg::with_name("scan_limit")
.short("L")
.long("scan-limit")
.value_name("SCAN_LIMIT")
.takes_value(true)
.help("Limit total number of concurrent scans (default: 0, i.e. no limit)")
)
.after_help(r#"NOTE:
Options that take multiple values are very flexible. Consider the following ways of specifying
extensions:
@@ -218,7 +237,7 @@ EXAMPLES:
./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] --norecursion -vv
./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

View File

@@ -94,7 +94,7 @@ async fn spawn_terminal_reporter(
while let Some(resp) = resp_chan.recv().await {
log::debug!("received {} on reporting channel", resp.url());
if CONFIGURATION.statuscodes.contains(&resp.status().as_u16()) {
if CONFIGURATION.status_codes.contains(&resp.status().as_u16()) {
let report = if CONFIGURATION.quiet {
// -q used, just need the url
format!("{}\n", resp.url())

View File

@@ -1,29 +1,120 @@
use crate::config::{CONFIGURATION, PROGRESS_BAR};
use crate::extractor::get_links;
use crate::heuristics::WildcardFilter;
use crate::utils::{format_url, get_current_depth, get_url_path_length, make_request};
use crate::{heuristics, progress, FeroxChannel, FeroxResponse};
use futures::future::{BoxFuture, FutureExt};
use futures::{stream, StreamExt};
use crate::{
config::{CONFIGURATION, PROGRESS_BAR},
extractor::get_links,
filters::{FeroxFilter, StatusCodeFilter, WildcardFilter},
heuristics, progress,
utils::{format_url, get_current_depth, make_request},
FeroxChannel, FeroxResponse, SLEEP_DURATION,
};
use console::style;
use futures::{
future::{BoxFuture, FutureExt},
stream, StreamExt,
};
use indicatif::{ProgressBar, ProgressStyle};
use lazy_static::lazy_static;
use reqwest::Url;
use std::collections::HashSet;
use std::convert::TryInto;
use std::ops::Deref;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, RwLock};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
use std::{
collections::HashSet,
convert::TryInto,
io::{stderr, Write},
ops::Deref,
sync::atomic::{AtomicBool, AtomicUsize, Ordering},
sync::{Arc, RwLock},
};
use tokio::{
sync::{
mpsc::{self, UnboundedReceiver, UnboundedSender},
Semaphore,
},
task::JoinHandle,
time,
};
/// Single atomic number that gets incremented once, used to track first scan vs. all others
static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
/// Atomic boolean flag, used to determine whether or not a scan should pause or resume
pub static PAUSE_SCAN: AtomicBool = AtomicBool::new(false);
lazy_static! {
/// Set of urls that have been sent to [scan_url](fn.scan_url.html), used for deduplication
static ref SCANNED_URLS: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
/// Vector of WildcardFilters that have been ID'd through heuristics
static ref WILDCARD_FILTERS: Arc<RwLock<Vec<Arc<WildcardFilter>>>> = Arc::new(RwLock::new(Vec::<Arc<WildcardFilter>>::new()));
/// A clock spinner protected with a RwLock to allow for a single thread to use at a time
static ref SINGLE_SPINNER: RwLock<ProgressBar> = RwLock::new(get_single_spinner());
/// 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()));
/// Bounded semaphore used as a barrier to limit concurrent scans
static ref SCAN_LIMITER: Semaphore = Semaphore::new(CONFIGURATION.scan_limit);
}
/// Return a clock spinner, used when scans are paused
fn get_single_spinner() -> ProgressBar {
log::trace!("enter: get_single_spinner");
let spinner = ProgressBar::new_spinner().with_style(
ProgressStyle::default_spinner()
.tick_strings(&[
"🕛", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚",
])
.template(&format!(
"\t-= All Scans {{spinner}} {} =-",
style("Paused").red()
)),
);
log::trace!("exit: get_single_spinner -> {:?}", spinner);
spinner
}
/// Forced the calling thread into a busy loop
///
/// Every `SLEEP_DURATION` milliseconds, the function examines the result stored in `PAUSE_SCAN`
///
/// When the value stored in `PAUSE_SCAN` becomes `false`, the function returns, exiting the busy
/// loop
async fn pause_scan() {
log::trace!("enter: pause_scan");
// function uses tokio::time, not std
// local testing showed a pretty slow increase (less than linear) in CPU usage as # of
// concurrent scans rose when SLEEP_DURATION was set to 500, using that as the default for now
let mut interval = time::interval(time::Duration::from_millis(SLEEP_DURATION));
// ignore any error returned
let _ = stderr().flush();
if SINGLE_SPINNER.read().unwrap().is_finished() {
// in order to not leave draw artifacts laying around in the terminal, we call
// finish_and_clear on the progress bar when resuming scans. For this reason, we need to
// check if the spinner is finished, and repopulate the RwLock with a new spinner if
// necessary
if let Ok(mut guard) = SINGLE_SPINNER.write() {
*guard = get_single_spinner();
}
}
if let Ok(spinner) = SINGLE_SPINNER.write() {
spinner.enable_steady_tick(120);
}
loop {
// first tick happens immediately, all others wait the specified duration
interval.tick().await;
if !PAUSE_SCAN.load(Ordering::Acquire) {
// PAUSE_SCAN is false, so we can exit the busy loop
if let Ok(spinner) = SINGLE_SPINNER.write() {
spinner.finish_and_clear();
}
let _ = stderr().flush();
log::trace!("exit: pause_scan");
return;
}
}
}
/// Adds the given url to `SCANNED_URLS`
@@ -63,37 +154,37 @@ fn add_url_to_list_of_scanned_urls(resp: &str, scanned_urls: &RwLock<HashSet<Str
}
}
/// Adds the given WildcardFilter to `WILDCARD_FILTERS`
/// Adds the given FeroxFilter to the given list of FeroxFilter implementors
///
/// If `WILDCARD_FILTERS` did not already contain the filter, return true; otherwise return false
fn add_filter_to_list_of_wildcard_filters(
filter: Arc<WildcardFilter>,
wildcard_filters: Arc<RwLock<Vec<Arc<WildcardFilter>>>>,
/// If the given list did not already contain the filter, return true; otherwise return false
fn add_filter_to_list_of_ferox_filters(
filter: Box<dyn FeroxFilter>,
ferox_filters: Arc<RwLock<Vec<Box<dyn FeroxFilter>>>>,
) -> bool {
log::trace!(
"enter: add_filter_to_list_of_wildcard_filters({:?}, {:?})",
"enter: add_filter_to_list_of_ferox_filters({:?}, {:?})",
filter,
wildcard_filters
ferox_filters
);
match wildcard_filters.write() {
match ferox_filters.write() {
Ok(mut filters) => {
// If the set did not contain the assigned filter, true is returned.
// If the set did contain the assigned filter, false is returned.
if filters.contains(&filter) {
log::trace!("exit: add_filter_to_list_of_wildcard_filters -> false");
log::trace!("exit: add_filter_to_list_of_ferox_filters -> false");
return false;
}
filters.push(filter);
log::trace!("exit: add_filter_to_list_of_wildcard_filters -> true");
log::trace!("exit: add_filter_to_list_of_ferox_filters -> true");
true
}
Err(e) => {
// poisoned lock
log::error!("Set of wildcard filters poisoned: {}", e);
log::trace!("exit: add_filter_to_list_of_wildcard_filters -> false");
log::trace!("exit: add_filter_to_list_of_ferox_filters -> false");
false
}
}
@@ -120,6 +211,7 @@ fn spawn_recursion_handler(
let boxed_future = async move {
let mut scans = vec![];
while let Some(resp) = recursion_channel.recv().await {
let unknown = add_url_to_list_of_scanned_urls(&resp, &SCANNED_URLS);
@@ -173,7 +265,7 @@ fn create_urls(target_url: &str, word: &str, extensions: &[String]) -> Vec<Url>
if let Ok(url) = format_url(
&target_url,
&word,
CONFIGURATION.addslash,
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
None,
) {
@@ -184,7 +276,7 @@ fn create_urls(target_url: &str, word: &str, extensions: &[String]) -> Vec<Url>
if let Ok(url) = format_url(
&target_url,
&word,
CONFIGURATION.addslash,
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
Some(ext),
) {
@@ -237,6 +329,7 @@ fn response_is_directory(response: &FeroxResponse) -> bool {
}
} else if response.status().is_success() {
// status code is 2xx, need to check if it ends in /
if response.url().as_str().ends_with('/') {
log::debug!("{} is directory suitable for recursion", response.url());
log::trace!("exit: is_directory -> true");
@@ -332,43 +425,24 @@ async fn try_recursion(
/// Simple helper to stay DRY; determines whether or not a given `FeroxResponse` should be reported
/// to the user or not.
pub fn should_filter_response(content_len: &u64, url: &Url) -> bool {
if CONFIGURATION.sizefilters.contains(content_len) {
// filtered value from --sizefilters, move on to the next url
log::debug!("size filter: filtered out {}", url);
pub fn should_filter_response(response: &FeroxResponse) -> bool {
if CONFIGURATION
.filter_size
.contains(&response.content_length())
{
// filtered value from --filter-size, size filters and wildcards are two separate filters
// and are applied independently
log::debug!("size filter: filtered out {}", response.url());
return true;
}
match WILDCARD_FILTERS.read() {
match FILTERS.read() {
Ok(filters) => {
for filter in filters.iter() {
if CONFIGURATION.dontfilter {
// quick return if dontfilter is set
return false;
}
if filter.size > 0 && filter.size == *content_len {
// static wildcard size found during testing
// size isn't default, size equals response length, and auto-filter is on
log::debug!("static wildcard: filtered out {}", url);
// wildcard.should_filter goes here
if filter.should_filter_response(&response) {
return true;
}
if filter.dynamic > 0 {
// dynamic wildcard offset found during testing
// I'm about to manually split this url path instead of using reqwest::Url's
// builtin parsing. The reason is that they call .split() on the url path
// except that I don't want an empty string taking up the last index in the
// event that the url ends with a forward slash. It's ugly enough to be split
// into its own function for readability.
let url_len = get_url_path_length(&url);
if url_len + filter.dynamic == *content_len {
log::debug!("dynamic wildcard: filtered out {}", url);
return true;
}
}
}
}
Err(e) => {
@@ -407,16 +481,14 @@ async fn make_requests(
let ferox_response = FeroxResponse::from(response, CONFIGURATION.extract_links).await;
// do recursion if appropriate
if !CONFIGURATION.norecursion {
if !CONFIGURATION.no_recursion {
try_recursion(&ferox_response, base_depth, dir_chan.clone()).await;
}
// purposefully doing recursion before filtering. the thought process is that
// even though this particular url is filtered, subsequent urls may not
let content_len = &ferox_response.content_length();
if should_filter_response(content_len, &ferox_response.url()) {
if should_filter_response(&ferox_response) {
continue;
}
@@ -435,7 +507,7 @@ async fn make_requests(
let new_url = match format_url(
&new_link,
&"",
CONFIGURATION.addslash,
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
None,
) {
@@ -453,8 +525,7 @@ async fn make_requests(
FeroxResponse::from(new_response, CONFIGURATION.extract_links).await;
// filter if necessary
let new_content_len = &new_ferox_response.content_length();
if should_filter_response(new_content_len, &new_ferox_response.url()) {
if should_filter_response(&new_ferox_response) {
continue;
}
@@ -471,7 +542,7 @@ async fn make_requests(
continue;
}
if !CONFIGURATION.norecursion {
if !CONFIGURATION.no_recursion {
log::debug!(
"Recursive extraction: {} ({})",
new_ferox_response.url(),
@@ -555,8 +626,21 @@ pub async fn scan_url(
// this protection around join also allows us to add the first scanned url to SCANNED_URLS
// from within the scan_url function instead of the recursion handler
add_url_to_list_of_scanned_urls(&target_url, &SCANNED_URLS);
if CONFIGURATION.scan_limit == 0 {
// scan_limit == 0 means no limit should be imposed... however, scoping the Semaphore
// permit is tricky, so as a workaround, we'll add a ridiculous number of permits to
// the semaphore (1,152,921,504,606,846,975 to be exact) and call that 'unlimited'
SCAN_LIMITER.add_permits(usize::MAX >> 4);
}
}
// When acquire is called and the semaphore has remaining permits, the function immediately
// returns a permit. However, if no remaining permits are available, acquire (asynchronously)
// waits until an outstanding permit is dropped. At this point, the freed permit is assigned
// to the caller.
let permit = SCAN_LIMITER.acquire().await;
// Arc clones to be passed around to the various scans
let wildcard_bar = progress_bar.clone();
let heuristics_file_clone = tx_file.clone();
@@ -576,13 +660,23 @@ pub async fn scan_url(
.await
});
// add any wildcard filters to `FILTERS`
let filter =
match heuristics::wildcard_test(&target_url, wildcard_bar, heuristics_file_clone).await {
Some(f) => Arc::new(f),
None => Arc::new(WildcardFilter::default()),
Some(f) => Box::new(f),
None => Box::new(WildcardFilter::default()),
};
add_filter_to_list_of_wildcard_filters(filter.clone(), WILDCARD_FILTERS.clone());
add_filter_to_list_of_ferox_filters(filter, FILTERS.clone());
// add any status code filters to `FILTERS`
for code_filter in &CONFIGURATION.filter_status {
let filter = StatusCodeFilter {
filter_code: *code_filter,
};
let boxed_filter = Box::new(filter);
add_filter_to_list_of_ferox_filters(boxed_filter, FILTERS.clone());
}
// producer tasks (mp of mpsc); responsible for making requests
let producers = stream::iter(looping_words.deref().to_owned())
@@ -592,14 +686,22 @@ pub async fn scan_url(
let pb = progress_bar.clone(); // progress bar is an Arc around internal state
let tgt = target_url.to_string(); // done to satisfy 'static lifetime below
(
tokio::spawn(async move { make_requests(&tgt, &word, base_depth, txd, txr).await }),
tokio::spawn(async move {
if PAUSE_SCAN.load(Ordering::Acquire) {
// for every word in the wordlist, check to see if PAUSE_SCAN is set to true
// when true; enter a busy loop that only exits by setting PAUSE_SCAN back
// to false
pause_scan().await;
}
make_requests(&tgt, &word, base_depth, txd, txr).await
}),
pb,
)
})
.for_each_concurrent(CONFIGURATION.threads, |(resp, bar)| async move {
match resp.await {
Ok(_) => {
bar.inc(1);
bar.inc((CONFIGURATION.extensions.len() + 1) as u64);
}
Err(e) => {
log::error!("error awaiting a response: {}", e);
@@ -612,6 +714,9 @@ pub async fn scan_url(
producers.await;
log::trace!("done awaiting scan producers");
// drop the current permit so the semaphore will allow another scan to proceed
drop(permit);
progress_bar.finish();
// manually drop tx in order for the rx task's while loops to eval to false
@@ -760,28 +865,31 @@ mod tests {
}
#[test]
/// add a wildcard filter with the `size` attribute set to WILDCARD_FILTERS and ensure that
/// should_filter_response correctly returns true
fn should_filter_response_filters_wildcard_size() {
let mut filter = WildcardFilter::default();
let url = Url::parse("http://localhost").unwrap();
filter.size = 18;
let filter = Arc::new(filter);
add_filter_to_list_of_wildcard_filters(filter, WILDCARD_FILTERS.clone());
let result = should_filter_response(&18, &url);
assert!(result);
/// test that get_single_spinner returns the correct spinner
fn scanner_get_single_spinner_returns_spinner() {
let spinner = get_single_spinner();
assert!(!spinner.is_finished());
}
#[test]
/// add a wildcard filter with the `dynamic` attribute set to WILDCARD_FILTERS and ensure that
/// should_filter_response correctly returns true
fn should_filter_response_filters_wildcard_dynamic() {
let mut filter = WildcardFilter::default();
let url = Url::parse("http://localhost/some-path").unwrap();
filter.dynamic = 9;
let filter = Arc::new(filter);
add_filter_to_list_of_wildcard_filters(filter, WILDCARD_FILTERS.clone());
let result = should_filter_response(&18, &url);
assert!(result);
#[tokio::test(core_threads = 1)]
/// tests that pause_scan pauses execution and releases execution when PAUSE_SCAN is toggled
/// the spinner used during the test has had .finish_and_clear called on it, meaning that
/// a new one will be created, taking the if branch within the function
async fn scanner_pause_scan_with_finished_spinner() {
let now = time::Instant::now();
PAUSE_SCAN.store(true, Ordering::Relaxed);
SINGLE_SPINNER.write().unwrap().finish_and_clear();
let expected = time::Duration::from_secs(2);
tokio::spawn(async move {
time::delay_for(expected).await;
PAUSE_SCAN.store(false, Ordering::Relaxed);
});
pause_scan().await;
assert!(now.elapsed() > expected);
}
}

View File

@@ -140,7 +140,7 @@ pub fn ferox_print(msg: &str, bar: &ProgressBar) {
pub fn format_url(
url: &str,
word: &str,
addslash: bool,
add_slash: bool,
queries: &[(String, String)],
extension: Option<&str>,
) -> FeroxResult<Url> {
@@ -148,7 +148,7 @@ pub fn format_url(
"enter: format_url({}, {}, {}, {:?} {:?})",
url,
word,
addslash,
add_slash,
queries,
extension
);
@@ -175,7 +175,7 @@ pub fn format_url(
// extensions and slashes are mutually exclusive cases
let word = if extension.is_some() {
format!("{}.{}", word, extension.unwrap())
} else if addslash && !word.ends_with('/') {
} else if add_slash && !word.ends_with('/') {
// -f used, and word doesn't already end with a /
format!("{}/", word)
} else {

View File

@@ -77,14 +77,14 @@ fn banner_prints_headers() -> Result<(), Box<dyn std::error::Error>> {
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + multiple size filters
fn banner_prints_size_filters() -> Result<(), Box<dyn std::error::Error>> {
fn banner_prints_filter_sizes() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-S")
.arg("789456123")
.arg("--sizefilter")
.arg("--filter-size")
.arg("44444444")
.assert()
.failure()
@@ -277,13 +277,13 @@ fn banner_prints_extensions() -> Result<(), Box<dyn std::error::Error>> {
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + dontfilter
fn banner_prints_dontfilter() -> Result<(), Box<dyn std::error::Error>> {
/// expect to see all mandatory prints + dont_filter
fn banner_prints_dont_filter() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--dontfilter")
.arg("--dont-filter")
.assert()
.failure()
.stderr(
@@ -563,3 +563,59 @@ fn banner_prints_extract_links() -> Result<(), Box<dyn std::error::Error>> {
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + scan-limit
fn banner_prints_scan_limit() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-L")
.arg("4")
.assert()
.failure()
.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("Concurrent Scan Limit"))
.and(predicate::str::contains("│ 4"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + filter-status
fn banner_prints_filter_status() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-C")
.arg("200")
.assert()
.failure()
.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("Status Code Filters"))
.and(predicate::str::contains("│ [200]"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}

View File

@@ -181,7 +181,7 @@ fn extractor_finds_same_relative_url_twice() -> Result<(), Box<dyn std::error::E
}
#[test]
/// send a request to a page that contains an absolute link that leads to a page with a sizefilter
/// send a request to a page that contains an absolute link that leads to a page with a filter_size
/// that should filter it out, expect not to see the second response reported
fn extractor_finds_filtered_content() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
@@ -209,7 +209,7 @@ fn extractor_finds_filtered_content() -> Result<(), Box<dyn std::error::Error>>
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--extract-links")
.arg("--sizefilter")
.arg("--filter-size")
.arg("18")
.unwrap();

57
tests/test_filters.rs Normal file
View File

@@ -0,0 +1,57 @@
mod utils;
use assert_cmd::prelude::*;
use httpmock::Method::GET;
use httpmock::{Mock, MockServer};
use predicates::prelude::*;
use std::process::Command;
use utils::{setup_tmp_directory, teardown_tmp_directory};
#[test]
/// create a FeroxResponse that should elicit a true from
/// StatusCodeFilter::should_filter_response
fn filters_status_code_should_filter_response() {
let srv = MockServer::start();
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_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 cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("--filter-status")
.arg("302")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.not()
.and(predicate::str::contains("302"))
.not()
.and(predicate::str::contains("14"))
.not()
.and(predicate::str::contains("/file.js"))
.and(predicate::str::contains("200"))
.and(predicate::str::contains("34")),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
teardown_tmp_directory(tmp_dir);
}

View File

@@ -115,7 +115,7 @@ fn test_static_wildcard_request_found() -> Result<(), Box<dyn std::error::Error>
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--addslash")
.arg("--add-slash")
.unwrap();
teardown_tmp_directory(tmp_dir);
@@ -158,7 +158,7 @@ fn test_dynamic_wildcard_request_found() -> Result<(), Box<dyn std::error::Error
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--addslash")
.arg("--add-slash")
.arg("--output")
.arg(outfile.as_os_str())
.unwrap();
@@ -198,8 +198,8 @@ fn test_dynamic_wildcard_request_found() -> Result<(), Box<dyn std::error::Error
}
#[test]
/// uses dontfilter, so the normal wildcard test should never happen
fn heuristics_static_wildcard_request_with_dontfilter() -> Result<(), Box<dyn std::error::Error>> {
/// uses dont_filter, so the normal wildcard test should never happen
fn heuristics_static_wildcard_request_with_dont_filter() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
@@ -216,7 +216,7 @@ fn heuristics_static_wildcard_request_with_dontfilter() -> Result<(), Box<dyn st
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--dontfilter")
.arg("--dont-filter")
.unwrap();
teardown_tmp_directory(tmp_dir);
@@ -251,7 +251,7 @@ fn heuristics_wildcard_test_with_two_static_wildcards() -> Result<(), Box<dyn st
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--addslash")
.arg("--add-slash")
.unwrap();
teardown_tmp_directory(tmp_dir);
@@ -299,7 +299,7 @@ fn heuristics_wildcard_test_with_two_static_wildcards_with_quiet_enabled(
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--addslash")
.arg("--add-slash")
.arg("-q")
.unwrap();
@@ -340,7 +340,7 @@ fn heuristics_wildcard_test_with_two_static_wildcards_and_output_to_file(
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--addslash")
.arg("--add-slash")
.arg("--output")
.arg(outfile.as_os_str())
.unwrap();
@@ -406,7 +406,7 @@ fn heuristics_wildcard_test_with_redirect_as_response_code(
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--addslash")
.arg("--add-slash")
.arg("--output")
.arg(outfile.as_os_str())
.unwrap();

View File

@@ -327,7 +327,8 @@ fn scanner_single_request_quiet_scan() -> Result<(), Box<dyn std::error::Error>>
}
#[test]
/// send single valid request, get back a 301 without a Location header, expect false
/// send single valid request, get back a 301 without a Location header
/// expect response_is_directory to return false when called
fn scanner_single_request_returns_301_without_location_header(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
@@ -336,6 +337,7 @@ fn scanner_single_request_returns_301_without_location_header(
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_body("this is a test")
.return_status(301)
.create_on(&srv);
@@ -345,17 +347,16 @@ fn scanner_single_request_returns_301_without_location_header(
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-T")
.arg("--timeout")
.arg("5")
.arg("-a")
.arg("--user-agent")
.arg("some-user-agent-string")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains(srv.url("/LICENSE"))
.and(predicate::str::contains("301"))
.and(predicate::str::contains("14"))
.not(),
.and(predicate::str::contains("14")),
);
assert_eq!(mock.times_called(), 1);
@@ -401,7 +402,7 @@ fn scanner_single_request_scan_with_filtered_result() -> Result<(), Box<dyn std:
.and(predicate::str::contains("20"))
.and(predicate::str::contains("ignored"))
.not()
.and(predicate::str::contains("14"))
.and(predicate::str::contains(" 14 "))
.not(),
);