Compare commits

...

173 Commits

Author SHA1 Message Date
epi
c8775e3c8c excluded rlimit usage from windows build 2020-11-07 16:11:39 -06:00
epi
427efdef3b excluded rlimit usage from windows build 2020-11-07 15:29:05 -06:00
epi
45815ff796 Merge pull request #118 from epi052/85-automatically-adjust-nofile-limit
added auto-adjustment of open file limit
2020-11-07 15:17:07 -06:00
epi
0dbc3bee23 added auto-adjustment of open file limit 2020-11-07 15:05:07 -06:00
epi
9e143d9f19 bumped version to 1.5.1 2020-11-07 11:35:06 -06:00
epi
bd2bd2035c Merge pull request #117 from epi052/114-fix-extract-links-reporting
Fix handling of urls found in wordlists
2020-11-07 11:33:59 -06:00
epi
6e71f4e039 fixed issue with 2 urls being joined 2020-11-07 11:24:49 -06:00
epi
f5229a1ddd fixed issue with 2 urls being joined 2020-11-07 11:24:11 -06:00
epi
d4eae2af8b Merge pull request #110 from epi052/FEATURE-105-add-replay-proxy
added replay-proxy option
2020-11-07 05:48:24 -06:00
epi
ae3b837e81 updated emoji comment in banner 2020-11-06 05:49:23 -06:00
epi
20fbb2f68d removed cruft 2020-11-06 05:44:51 -06:00
epi
2ddcf4249f nitpickery in the banner 2020-11-06 05:41:33 -06:00
epi
c975a7b82f updated readme with gif 2020-11-06 05:14:42 -06:00
epi
43c1eb58ad updated readme with replay proxy info 2020-11-05 20:53:21 -06:00
epi
2b94205f2a Merge pull request #116 from epi052/FEATURE-105-add-replay-proxy--implement-feature
implemented replay proxy
2020-11-05 20:08:04 -06:00
epi
15942e7a06 implemented replay proxy 2020-11-05 19:59:39 -06:00
epi
39f82816d8 Merge pull request #113 from epi052/FEATURE-105-add-replay-proxy--update-banner
added replay options to banner and parser
2020-11-05 06:33:01 -06:00
epi
d39a2ab0f7 added comma to help 2020-11-05 06:31:08 -06:00
epi
095edc0804 combined replay logic in banner 2020-11-05 06:29:10 -06:00
epi
7d70126eea combined replay logic in banner 2020-11-05 06:28:33 -06:00
epi
b09e8d078a added replay options to banner and parser 2020-11-05 06:05:33 -06:00
epi
47d4221ada Merge pull request #111 from epi052/FEATURE-105-add-replay-proxy--update-config
added replay_[codes,proxy,client] to config.rs; added examples to fer…
2020-11-04 14:49:00 -06:00
epi
4578630b13 broke out reused code into helper function 2020-11-04 12:56:59 -06:00
epi
c4f018a757 added replay_[codes,proxy,client] to config.rs; added examples to ferox-config.toml.example 2020-11-04 07:36:20 -06:00
epi
49462df2fa bumped version to 1.5.0 2020-11-04 07:01:24 -06:00
epi
0898914d19 Merge pull request #109 from epi052/106-notify-users-of-bad-certs
logging initialized early enough to display all intended log messages
2020-11-03 12:54:28 -06:00
epi
d97d2714ce fixed comments from review 2020-11-03 12:46:21 -06:00
epi
c1bbd10f51 fixed failing test 2020-11-03 11:26:30 -06:00
epi
cda1628aa6 logging initialized early enough to display all intended log messages 2020-11-03 10:44:55 -06:00
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
epi
952f44e798 Merge pull request #74 from epi052/FEATURE-add-link-extraction
New feature: added link extraction
2020-10-22 06:12:11 -05:00
epi
6534040992 Merge branch 'FEATURE-add-link-extraction' of github.com:epi052/feroxbuster into FEATURE-add-link-extraction 2020-10-22 05:56:12 -05:00
epi
5db47bf85d updated readme and exmaple config 2020-10-22 05:55:54 -05:00
epi
ba279079b6 Merge pull request #87 from epi052/FEATURE-add-link-extraction--integrate-get-links-into-scanner-v2
Integrate extractor::get_links into scanner v2
2020-10-21 20:19:28 -05:00
epi
61648394cc simplified heuristics redirection printing 2020-10-21 06:39:32 -05:00
epi
6a0e27f67c increased code coverage for scanner 2020-10-21 06:22:44 -05:00
epi
7e518b2921 increased code coverage for scanner 2020-10-21 06:22:25 -05:00
epi
62d4e794da wildcard filters now shared across recursive scans 2020-10-21 05:39:10 -05:00
epi
280177e7e4 added a test for get_links 2020-10-20 06:38:14 -05:00
epi
090a556212 added integration tests for extractor 2020-10-19 20:46:41 -05:00
epi
e8c76e89ee added integration tests for extractor 2020-10-19 20:46:24 -05:00
epi
74aa5e8047 even more cleanup; extraction looking mostly complete 2020-10-19 19:47:03 -05:00
epi
6fa542ecc5 lots of post-implementation cleanup done 2020-10-18 21:02:09 -05:00
epi
0ec4f90a09 Merge pull request #86 from spikecodes/patch-1
Update AUR Package Name
2020-10-18 15:21:05 -05:00
Spike
6c5337f6af Update AUR Package Name 2020-10-18 11:39:15 -07:00
epi
bb57a148ff added FeroxResponse, old Response channels replaced with FeroxResponse 2020-10-18 12:19:49 -05:00
epi
98619c1c3b Merge branch 'master' into FEATURE-add-link-extraction 2020-10-18 09:56:25 -05:00
epi
eea5276c5f Merge pull request #83 from spikecodes/patch-1
Publish to Arch User Repository
2020-10-17 20:22:23 -05:00
Spike
6272699370 Publish to AUR 2020-10-17 16:41:01 -07:00
epi
e0db5d17e9 bumped version to 1.0.5 2020-10-17 12:44:11 -05:00
epi
934c08d285 comments and empty lines are skipped in wordlist 2020-10-17 12:42:28 -05:00
epi
96ab0381e8 Merge pull request #75 from epi052/FEATURE-add-link-extraction--add-extractor-for-html
Added extractor module, exposes `get_links` function
2020-10-16 06:00:20 -05:00
epi
5dff0ab571 removed unwrap from get_links 2020-10-16 05:48:50 -05:00
epi
2d076564b9 added unit tests for add_link_to_set_of_links 2020-10-16 05:17:08 -05:00
epi
f9da98be34 lint in tests 2020-10-15 20:50:53 -05:00
epi
7345d706ff added unit tests for get_sub_paths_from_path 2020-10-15 20:50:08 -05:00
epi
6921ac03a9 extractor logic complete 2020-10-15 07:34:23 -05:00
epi
273689b134 Update README.md 2020-10-15 06:52:10 -05:00
epi
f537139f1d Update README.md 2020-10-14 17:23:26 -05:00
epi
3c940b8e03 Merge pull request #72 from epi052/FEATURE-add-link-extraction--add-cli-option
added -e|--extract-links to parser/banner/config 🕵
2020-10-12 19:44:23 -05:00
epi
1dbe99ea19 added banner integration test for extract-links 2020-10-12 17:23:08 -05:00
epi
8845a40510 added -e|--extract-links to parser/banner/config 🕵 2020-10-12 16:48:51 -05:00
epi
42a1a94062 Update README.md 2020-10-12 15:28:39 -05:00
epi
185808b289 Merge pull request #71 from epi052/66-capture-logging-in-logfile
Log records can be captured in a log file
2020-10-12 06:56:41 -05:00
epi
f676f56d71 cleaned up a few things during PR review 2020-10-12 06:32:33 -05:00
epi
fbffb57db3 increased heuristics test coverage agian 2020-10-12 05:48:01 -05:00
epi
26e27c340b added test coverage for heuristics 2020-10-12 05:27:47 -05:00
epi
530672f45f version upped to 1.0.4 2020-10-11 20:50:46 -05:00
epi
2f26187f61 happy with this implementation; just needs cleanup/polish 2020-10-11 20:50:05 -05:00
epi
4515e6a516 working, more or less. thinking a channel is in order 2020-10-10 21:06:44 -05:00
epi
2e8f05883d updated grcov options 2020-10-10 06:21:58 -05:00
epi
aa7871cca8 updated grcov options 2020-10-10 05:59:30 -05:00
epi
40e803ef07 updated grcov options 2020-10-10 05:38:43 -05:00
epi
86199002c9 added parser initialize test 2020-10-09 20:06:31 -05:00
epi
29abef6386 added parser initialize test 2020-10-09 20:05:21 -05:00
epi
d9271f6fe7 updated rust flags for profiling test coverage 2020-10-09 19:16:52 -05:00
epi
9881d65cc3 add linux tar.gz build for homebrew installs 2020-10-09 19:07:39 -05:00
epi
11f7a7e6f7 add linux tar.gz build for homebrew installs 2020-10-09 19:06:32 -05:00
epi
f64c5a8fdb Merge pull request #59 from epi052/58-improve-test-coverage
improve test coverage
2020-10-09 16:53:07 -05:00
epi
3cf278a77a removed pre-commit metadata block 2020-10-09 16:38:52 -05:00
epi
5327f3931e add linux tar.gz build for homebrew installs 2020-10-09 16:35:50 -05:00
epi
4cf8f030de add linux tar.gz build for homebrew installs 2020-10-09 16:28:13 -05:00
epi
2a8ebd0e04 added more heuristics tests 2020-10-09 15:48:09 -05:00
epi
8d335d7e90 added two tests to cover static wildcards 2020-10-09 15:34:33 -05:00
epi
ec1458cdc3 added two tests to cover static wildcards 2020-10-09 15:34:19 -05:00
epi
109d38f2ea trying coveralls coverage reporting 2020-10-09 14:17:13 -05:00
epi
2751bb844a added dontfilter test and removed dead code 2020-10-09 13:07:38 -05:00
epi
74b0065ce2 removed pre-commit dependency 2020-10-09 12:49:37 -05:00
epi
caa3674bba fmt 2020-10-09 12:32:53 -05:00
epi
4f557511b4 added no recursion/sizefilter test 2020-10-09 11:43:31 -05:00
epi
238f071d0a cargo fmt ran 2020-10-09 07:38:31 -05:00
epi
d19c7bfe17 added more tests for scanner 2020-10-09 06:28:47 -05:00
epi
65c0138e1a Merge branch 'master' into 58-improve-test-coverage 2020-10-09 05:48:15 -05:00
epi
db0e56bee2 updated README with cli commands for grabbing releases 2020-10-09 05:44:35 -05:00
epi
71649d1296 Merge pull request #68 from epi052/67-duplicate-scans-occurring
fixed duplicate directory scans
2020-10-08 20:50:48 -05:00
epi
a89f2be37b fmt / clippy 2020-10-08 20:43:24 -05:00
epi
572e5b7a95 fixed duplicate directory scans 2020-10-08 20:39:13 -05:00
epi
2e71d91960 Merge pull request #64 from TGotwig/patch-1
Publish with Homebrew on MacOS & Linux 🍺
2020-10-08 13:04:46 -05:00
epi
f9cdd91da9 added tar.gz for homebrew installs 2020-10-08 07:15:29 -05:00
epi
003b7f39f7 added tar.gz for homebrew installs 2020-10-08 06:53:45 -05:00
epi
39dfe442e8 added tar.gz for homebrew installs 2020-10-08 06:35:13 -05:00
epi
7d75a2cfd4 added tar.gz for homebrew installs 2020-10-08 06:28:59 -05:00
Thomas Gotwig
9657385282 Publish with Homebrew on MacOS & Linux 🍺
closes #63
2020-10-07 14:50:22 +02:00
epi
4c1094b59c added unit tests for reached_max_depth 2020-10-07 07:20:47 -05:00
epi
63ce5787d7 added invalid file output test 2020-10-07 06:46:34 -05:00
epi
5af8812929 added another output file test 2020-10-07 06:37:31 -05:00
epi
d5c508bc28 added scan with output file test 2020-10-07 06:33:37 -05:00
epi
603004a5bd updated client test 2020-10-07 05:46:16 -05:00
epi
a906b9731e added client test; setup_tmp_directory accepts a filename now 2020-10-07 05:30:58 -05:00
epi
f173147352 added client unit test 2020-10-06 19:45:01 -05:00
epi
bb1532e459 added test for bad proxy; added panic logic instead of exit for tests 2020-10-06 07:13:34 -05:00
34 changed files with 4181 additions and 690 deletions

View File

@@ -1,7 +1,8 @@
branch: true
branch: false
ignore-not-existing: true
llvm: true
output-type: lcov
output-path: ./lcov.info
# excl-br-line: "^\\s*((debug_)?assert(_eq|_ne)?!|#\\[derive\\(|log::)"
ignore:
- "../*"

View File

@@ -41,10 +41,22 @@ 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: |
tar czf ${{ matrix.name }}.tar.gz -C target/x86_64-unknown-linux-musl/release feroxbuster
- uses: actions/upload-artifact@v2
with:
name: ${{ matrix.name }}
path: ${{ matrix.path }}
- uses: actions/upload-artifact@v2
if: matrix.type == 'ubuntu-x64'
with:
name: ${{ matrix.name }}.tar.gz
path: ${{ matrix.name }}.tar.gz
build-deb:
needs: [build-nix]
@@ -59,18 +71,43 @@ jobs:
name: feroxbuster_amd64.deb
path: ./target/x86_64-unknown-linux-musl/debian/*
build-rest:
build-macos:
runs-on: macos-latest
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: x86_64-apple-darwin
override: true
- uses: actions-rs/cargo@v1
with:
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
- uses: actions/upload-artifact@v2
with:
name: x86_64-macos-feroxbuster
path: target/x86_64-apple-darwin/release/feroxbuster
- uses: actions/upload-artifact@v2
with:
name: x86_64-macos-feroxbuster.tar.gz
path: x86_64-macos-feroxbuster.tar.gz
build-windows:
runs-on: ${{ matrix.os }}
if: github.ref == 'refs/heads/master'
strategy:
matrix:
type: [windows-x64, windows-x86, macos]
type: [windows-x64, windows-x86]
include:
- type: macos
os: macos-latest
target: x86_64-apple-darwin
name: x86_64-macos-feroxbuster
path: target/x86_64-apple-darwin/release/feroxbuster
- type: windows-x64
os: windows-latest
target: x86_64-pc-windows-msvc
@@ -97,3 +134,4 @@ jobs:
with:
name: ${{ matrix.name }}
path: ${{ matrix.path }}

View File

@@ -20,8 +20,8 @@ jobs:
args: --all-features --no-fail-fast
env:
CARGO_INCREMENTAL: '0'
RUSTFLAGS: '-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests'
RUSTDOCFLAGS: '-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests'
RUSTFLAGS: '-Zprofile -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort'
RUSTDOCFLAGS: '-Cpanic=abort'
- uses: actions-rs/grcov@v0.1
- name: Convert lcov to xml
run: |

View File

@@ -1,6 +1,6 @@
[package]
name = "feroxbuster"
version = "1.0.2"
version = "1.5.2"
authors = ["Ben 'epi' Risher <epibar052@gmail.com>"]
license = "MIT"
edition = "2018"
@@ -19,17 +19,21 @@ futures = { version = "0.3"}
tokio = { version = "0.2", features = ["full"] }
tokio-util = {version = "0.3", features = ["codec"]}
log = "0.4"
env_logger = "0.7"
env_logger = "0.8"
reqwest = { version = "0.10", features = ["socks"] }
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"
rlimit = "0.5"
[dev-dependencies]
tempfile = "3.1"

324
README.md
View File

@@ -59,37 +59,79 @@ This attack is also known as Predictable Resource Location, File Enumeration, Di
📖 Table of Contents
-----------------
- [Downloads](#-downloads)
- [Installation](#-installation)
- [Download a Release](#download-a-release)
- [Homebrew on MacOS and Linux](#homebrew-on-macos-and-linux)
- [Cargo Install](#cargo-install)
- [apt Install](#apt-install)
- [AUR Install](#aur-install)
- [Docker Install](#docker-install)
- [Configuration](#-configuration)
- [Configuration](#%EF%B8%8F-configuration)
- [Default Values](#default-values)
- [ferox-config.toml](#ferox-configtoml)
- [Command Line Parsing](#command-line-parsing)
- [Example Usage](#-example-usage)
- [Pause and Resume Scans (new in `v1.4.0`)](#pause-and-resume-scans-new-in-v140)
- [Multiple Values](#multiple-values)
- [Extract Links from Response Body (new in `v1.1.0`)](#extract-links-from-response-body-new-in-v110)
- [Include Headers](#include-headers)
- [IPv6, Non-recursive scan with INFO logging enabled](#ipv6-non-recursive-scan-with-info-level-logging-enabled)
- [Read urls from STDIN; pipe only resulting urls out to another tool](#read-urls-from-stdin-pipe-only-resulting-urls-out-to-another-tool)
- [Proxy traffic through Burp](#proxy-traffic-through-burp)
- [Proxy traffic through a SOCKS proxy](#proxy-traffic-through-a-socks-proxy)
- [Pass auth token via query parameter](#pass-auth-token-via-query-parameter)
- [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)
- [Replay Responses to a Proxy based on Status Code (new in `v1.5.0`)](#replay-responses-to-a-proxy-based-on-status-code-new-in-v150)
- [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
### Download a Release
Releases for multiple architectures can be found in the [Releases](https://github.com/epi052/feroxbuster/releases) section. Builds for the following systems are currently supported:
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 x86_64
- MacOS x86_64
- Windows x86
- Windows x86_64
#### Linux (32 and 64-bit) & MacOS
```
curl -sL https://raw.githubusercontent.com/epi052/feroxbuster/master/install-nix.sh | bash
```
#### Windows x86
```
https://github.com/epi052/feroxbuster/releases/latest/download/x86-windows-feroxbuster.exe.zip
Expand-Archive .\feroxbuster.zip
.\feroxbuster\feroxbuster.exe -V
```
#### Windows x86_64
```
Invoke-WebRequest https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-windows-feroxbuster.exe.zip -OutFile feroxbuster.zip
Expand-Archive .\feroxbuster.zip
.\feroxbuster\feroxbuster.exe -V
```
### Homebrew on MacOS and Linux
Installable by Homebrew throughout own formulas:
🍏 [MacOS](https://github.com/TGotwig/homebrew-feroxbuster/blob/main/feroxbuster.rb)
```shell
brew tap tgotwig/feroxbuster
brew install feroxbuster
```
🐧 [Linux](https://github.com/TGotwig/homebrew-linux-feroxbuster/blob/main/feroxbuster.rb)
```shell
brew tap tgotwig/linux-feroxbuster
brew install feroxbuster
```
### Cargo Install
@@ -101,12 +143,22 @@ cargo install feroxbuster
### apt Install
Head to the [Releases](https://github.com/epi052/feroxbuster/releases) section and download `feroxbuster_amd64.deb`. After that, use your favorite package manager to install the .deb.
Download `feroxbuster_amd64.deb` from the [Releases](https://github.com/epi052/feroxbuster/releases) section. After that, use your favorite package manager to install the `.deb`.
```
wget -sLO https://github.com/epi052/feroxbuster/releases/latest/download/feroxbuster_amd64.deb.zip
unzip feroxbuster_amd64.deb.zip
sudo apt install ./feroxbuster_amd64.deb
```
### AUR Install
Install `feroxbuster-git` on Arch Linux with your AUR helper of choice:
```
yay -S feroxbuster-git
```
### Docker Install
> The following steps assume you have docker installed / setup
@@ -171,8 +223,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`
@@ -187,6 +240,11 @@ built-in defaults.
- The same directory as the `feroxbuster` executable (per-user)
- The user's current working directory (per-target)
> `CONFIG_DIR` is defined as the following:
> - Linux: `$XDG_CONFIG_HOME` or `$HOME/.config` i.e. `/home/bob/.config`
> - MacOs: `$HOME/Library/Application Support` i.e. `/Users/bob/Library/Application Support`
> - Windows: `{FOLDERID_RoamingAppData}` i.e. `C:\Users\Bob\AppData\Roaming`
If more than one valid configuration file is found, each one overwrites the values found previously.
If no configuration file is found, nothing happens at this stage.
@@ -217,23 +275,28 @@ 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]
# replay_codes = [301]
# threads = 1
# timeout = 5
# proxy = "http://127.0.0.1:8080"
# replay_proxy = "http://127.0.0.1:8081"
# 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
@@ -258,35 +321,50 @@ USAGE:
feroxbuster [FLAGS] [OPTIONS] --url <URL>...
FLAGS:
-f, --addslash Append / to each request
-D, --dontfilter Don't auto-filter wildcard responses
-h, --help Prints help information
-k, --insecure Disables TLS certificate validation
-n, --norecursion 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
-V, --version Prints version information
-v, --verbosity Increase verbosity level (use -vv or more for greater effect)
-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, --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
-V, --version Prints version information
-v, --verbosity Increase verbosity level (use -vv or more for greater effect)
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 -C 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)
-R, --replay-codes <REPLAY_CODE>... Status Codes to send through a Replay Proxy when found (default: --status
-codes value)
-P, --replay-proxy <REPLAY_PROXY> Send only unfiltered requests through a Replay Proxy, instead of all
requests
-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:
@@ -305,10 +383,41 @@ All of the methods above (multiple flags, space separated, comma separated, etc.
./feroxbuster -u http://127.1 -H Accept:application/json "Authorization: Bearer {token}"
```
### Extract Links from Response Body (New in `v1.1.0`)
Search through the body of valid responses (html, javascript, etc...) for additional endpoints to scan. This turns
`feroxbuster` into a hybrid that looks for both linked and unlinked content.
Example request/response with `--extract-links` enabled:
- Make request to `http://example.com/index.html`
- Receive, and read in, the `body` of the response
- Search the `body` for absolute and relative links (i.e. `homepage/assets/img/icons/handshake.svg`)
- Add the following directories for recursive scanning:
- `http://example.com/homepage`
- `http://example.com/homepage/assets`
- `http://example.com/homepage/assets/img`
- `http://example.com/homepage/assets/img/icons`
- Make a single request to `http://example.com/homepage/assets/img/icons/handshake.svg`
```
./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
@@ -329,12 +438,47 @@ 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
```
### Replay Responses to a Proxy based on Status Code (new in `v1.5.0`)
The `--replay-proxy` and `--replay-codes` options were added as a way to only send a select few responses to a proxy. This is in stark contrast to `--proxy` which proxies EVERY request.
Imagine you only care about proxying responses that have either the status code `200` or `302` (or you just don't want to clutter up your Burp history). These two options will allow you to fine-tune what gets proxied and what doesn't.
```
./feroxbuster -u http://127.1 --replay-proxy http://localhost:8080 --replay-codes 200 302 --insecure
```
Of note: this means that for every response that matches your replay criteria, you'll end up sending the request that generated that response a second time. Depending on the target and your engagement terms (if any), it may not make sense from a traffic generated perspective.
![replay-proxy-demo](img/replay-proxy-demo.gif)
## 🧐 Comparison w/ Similar Tools
@@ -350,29 +494,111 @@ a few of the use-cases in which feroxbuster may be a better fit:
- You want to be able to run your content discovery as part of some crazy 12 command unix **pipeline extravaganza**
- You want to scan through a **SOCKS** proxy
- You want **auto-filtering** of Wildcard responses by default
- You want an integrated **link extractor** to increase discovered endpoints
- You want **recursion** along with some other thing mentioned above (ffuf also does recursion)
- You want a **configuration file** option for overriding built-in default values for your scans
| | feroxbuster | gobuster | ffuf |
|-----------------------------------------------------|---|---|---|
| fast | ✔ | ✔ | ✔ |
| easy to use | ✔ | ✔ | |
| blacklist status codes (in addition to whitelist) | | ✔ | ✔ |
| allows recursion | ✔ | | ✔ |
| can specify query parameters | ✔ | | ✔ |
| SOCKS proxy support | ✔ | | |
| multiple target scan (via stdin or multiple -u) | ✔ | | |
| configuration file for default value override | ✔ | | ✔ |
| can accept urls via STDIN as part of a pipeline | ✔ | | ✔ |
| can accept wordlists via STDIN | | | ✔ |
| filter by response size | | | ✔ |
| auto-filter wildcard responses | ✔ | | ✔ |
| performs other scans (vhost, dns, etc) | | | ✔ |
| time delay / rate limiting | | ✔ | ✔ |
| **huge** number of other options | | | ✔ |
| | feroxbuster | gobuster | ffuf |
|------------------------------------------------------------------|---|---|---|
| fast | ✔ | ✔ | ✔ |
| easy to use | ✔ | ✔ | |
| filter out responses by status code (new in `v1.3.0`) | | ✔ | ✔ |
| allows recursion | ✔ | | ✔ |
| can specify query parameters | ✔ | | ✔ |
| SOCKS proxy support | ✔ | | |
| extracts links from response body to increase scan coverage | ✔ | | |
| multiple target scan (via stdin or multiple -u) | ✔ | | ✔ |
| configuration file for default value override | ✔ | | ✔ |
| can accept urls via STDIN as part of a pipeline | | | ✔ |
| can accept wordlists via STDIN | | | ✔ |
| filter by response size | ✔ | | ✔ |
| auto-filter wildcard responses | | | ✔ |
| performs other scans (vhost, dns, etc) | | ✔ | ✔ |
| time delay / rate limiting | | ✔ | ✔ |
| **huge** number of other options | | | ✔ |
Of note, there's another written-in-rust content discovery tool, [rustbuster](https://github.com/phra/rustbuster). I
came across rustbuster when I was naming my tool (😢). I don't have any experience using it, but it appears to
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,23 +8,28 @@
# 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"
# replay_proxy = "http://127.0.0.1:8081"
# replay_codes = [200, 302]
# 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/replay-proxy-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 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,239 +45,610 @@ 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)
); // 🚀
eprintln!(
)
.unwrap_or_default(); // 🚀
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f4d6}", "Wordlist", config.wordlist)
); // 📖
eprintln!(
)
.unwrap_or_default(); // 📖
writeln!(
&mut writer,
"{}",
format_banner_entry!(
"\u{1F197}",
"Status Codes",
format!("[{}]", codes.join(", "))
)
); // 🆗
eprintln!(
)
.unwrap_or_default(); // 🆗
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)
); // 💥
eprintln!(
)
.unwrap_or_default(); // 💥
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.replay_proxy.is_empty() {
// i include replay codes logic here because in config.rs, replay codes are set to the
// value in status codes, meaning it's never empty
let mut replay_codes = vec![];
writeln!(
&mut writer,
"{}",
format_banner_entry!("\u{1f3a5}", "Replay Proxy", config.replay_proxy)
)
.unwrap_or_default(); // 🎥
for code in &config.replay_codes {
replay_codes.push(status_colorizer(&code.to_string()))
}
writeln!(
&mut writer,
"{}",
format_banner_entry!(
"\u{1f4fc}",
"Replay Proxy Codes",
format!("[{}]", replay_codes.join(", "))
)
)
.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 {
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

@@ -1,16 +1,16 @@
use crate::utils::{module_colorizer, status_colorizer};
use console::style;
use reqwest::header::HeaderMap;
use reqwest::{redirect::Policy, Client, Proxy};
use std::collections::HashMap;
use std::convert::TryInto;
#[cfg(not(test))]
use std::process::exit;
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>,
@@ -22,22 +22,12 @@ pub fn initialize(
Policy::none()
};
let header_map: HeaderMap = match headers.try_into() {
Ok(map) => map,
Err(e) => {
eprintln!(
"{} {} {}",
status_colorizer("ERROR"),
module_colorizer("Client::initialize"),
e
);
exit(1);
}
};
// try_into returns infallible as its error, unwrap is safe here
let header_map: HeaderMap = headers.try_into().unwrap();
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);
@@ -55,9 +45,13 @@ pub fn initialize(
eprintln!(
"{} {} {}",
status_colorizer("ERROR"),
style("Client::initialize").cyan(),
module_colorizer("Client::initialize"),
e
);
#[cfg(test)]
panic!();
#[cfg(not(test))]
exit(1);
}
}
@@ -68,18 +62,38 @@ pub fn initialize(
match client.build() {
Ok(client) => client,
Err(e) => {
eprintln!(
"{} {} Could not create a Client with the given configuration, exiting.",
status_colorizer("ERROR"),
module_colorizer("Client::build")
);
eprintln!(
"{} {} {}",
status_colorizer("ERROR"),
module_colorizer("Client::build"),
e
);
#[cfg(test)]
panic!();
#[cfg(not(test))]
exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
/// create client with a bad proxy, expect panic
fn client_with_bad_proxy() {
let headers = HashMap::new();
initialize(0, "stuff", true, false, &headers, Some("not a valid proxy"));
}
#[test]
/// create client with a proxy, expect no error
fn client_with_good_proxy() {
let headers = HashMap::new();
let proxy = "http://127.0.0.1:8080";
initialize(0, "stuff", true, true, &headers, Some(proxy));
}
}

View File

@@ -10,6 +10,7 @@ use std::collections::HashMap;
use std::env::{current_dir, current_exe};
use std::fs::read_to_string;
use std::path::PathBuf;
#[cfg(not(test))]
use std::process::exit;
lazy_static! {
@@ -23,6 +24,21 @@ lazy_static! {
pub static ref PROGRESS_PRINTER: ProgressBar = progress::add_bar("", 0, true);
}
/// simple helper to clean up some code reuse below; panics under test / exits in prod
fn report_and_exit(err: &str) -> ! {
eprintln!(
"{} {}: {}",
status_colorizer("ERROR"),
module_colorizer("Configuration::new"),
err
);
#[cfg(test)]
panic!();
#[cfg(not(test))]
exit(1);
}
/// Represents the final, global configuration of the program.
///
/// This struct is the combination of the following:
@@ -47,18 +63,34 @@ pub struct Configuration {
#[serde(default)]
pub proxy: String,
/// Replay Proxy to use for requests (ex: http(s)://host:port, socks5://host:port)
#[serde(default)]
pub replay_proxy: String,
/// The target URL
#[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 replay to the Replay Proxy (default: whatever is passed to --status-code)
#[serde(default)]
pub replay_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)]
pub client: Client,
/// Instance of [reqwest::Client](https://docs.rs/reqwest/latest/reqwest/struct.Client.html)
#[serde(skip)]
pub replay_client: Option<Client>,
/// Number of concurrent threads (default: 50)
#[serde(default = "threads")]
pub threads: usize,
@@ -80,8 +112,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,11 +137,15 @@ pub struct Configuration {
/// Do not scan recursively
#[serde(default)]
pub norecursion: bool,
pub no_recursion: bool,
/// Extract links from html/javscript
#[serde(default)]
pub extract_links: bool,
/// Append / to each request
#[serde(default)]
pub addslash: bool,
pub add_slash: bool,
/// Read url(s) from STDIN
#[serde(default)]
@@ -119,16 +155,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
@@ -143,7 +183,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())
@@ -155,8 +195,8 @@ fn wordlist() -> String {
String::from(DEFAULT_WORDLIST)
}
/// default useragent
fn useragent() -> String {
/// default user-agent
fn user_agent() -> String {
format!("feroxbuster/{}", VERSION)
}
@@ -169,33 +209,42 @@ 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);
let replay_client = None;
let status_codes = status_codes();
let replay_codes = status_codes.clone();
Configuration {
client,
timeout,
useragent,
dontfilter: false,
user_agent,
replay_codes,
status_codes,
replay_client,
dont_filter: false,
quiet: false,
stdin: false,
verbosity: 0,
addslash: false,
scan_limit: 0,
add_slash: false,
insecure: false,
norecursion: false,
redirects: false,
no_recursion: false,
extract_links: false,
proxy: String::new(),
config: String::new(),
output: String::new(),
target_url: String::new(),
replay_proxy: 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(),
threads: threads(),
wordlist: wordlist(),
statuscodes: statuscodes(),
}
}
}
@@ -206,26 +255,31 @@ impl Configuration {
///
/// - **timeout**: `5` seconds
/// - **redirects**: `false`
/// - **extract-links**: `false`
/// - **wordlist**: [`DEFAULT_WORDLIST`](constant.DEFAULT_WORDLIST.html)
/// - **config**: `None`
/// - **threads**: `50`
/// - **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)
/// - **replay_proxy**: `None` (no limit on concurrent scans imposed)
/// - **replay_codes**: [`DEFAULT_RESPONSE_CODES`](constant.DEFAULT_RESPONSE_CODES.html)
///
/// After which, any values defined in a
/// [ferox-config.toml](constant.DEFAULT_CONFIG_NAME.html) config file will override the
@@ -310,6 +364,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());
}
@@ -318,16 +378,41 @@ 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!("[!] Error encountered: {}", e);
exit(1)
})
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
.as_u16()
})
.collect();
}
if args.values_of("replay_codes").is_some() {
// replay codes passed in by the user
config.replay_codes = args
.values_of("replay_codes")
.unwrap() // already known good
.map(|code| {
StatusCode::from_bytes(code.as_bytes())
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
.as_u16()
})
.collect();
} else {
// not passed in by the user, use whatever value is held in status_codes
config.replay_codes = config.status_codes.clone();
}
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())
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
.as_u16()
})
.collect();
@@ -341,15 +426,13 @@ 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| {
eprintln!("[!] Error encountered: {}", e);
exit(1)
})
size.parse::<u64>()
.unwrap_or_else(|e| report_and_exit(&e.to_string()))
})
.collect();
}
@@ -362,8 +445,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 {
@@ -372,12 +455,16 @@ 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") {
config.extract_links = args.is_present("extract_links");
}
if args.is_present("stdin") {
@@ -393,8 +480,12 @@ 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("replay_proxy").is_some() {
config.replay_proxy = String::from(args.value_of("replay_proxy").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() {
@@ -442,7 +533,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()
@@ -450,7 +541,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,
@@ -459,7 +550,7 @@ impl Configuration {
} else {
config.client = client::initialize(
config.timeout,
&config.useragent,
&config.user_agent,
config.redirects,
config.insecure,
&config.headers,
@@ -468,6 +559,18 @@ impl Configuration {
}
}
if !config.replay_proxy.is_empty() {
// only set replay_client when replay_proxy is set
config.replay_client = Some(client::initialize(
config.timeout,
&config.user_agent,
config.redirects,
config.insecure,
&config.headers,
Some(&config.replay_proxy),
));
}
config
}
@@ -495,24 +598,29 @@ 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;
settings.replay_proxy = settings_to_merge.replay_proxy;
settings.replay_codes = settings_to_merge.replay_codes;
}
/// If present, read in `DEFAULT_CONFIG_NAME` and deserialize the specified values
@@ -548,24 +656,29 @@ mod tests {
fn setup_config_test() -> Configuration {
let data = r#"
wordlist = "/some/path"
statuscodes = [201, 301, 401]
status_codes = [201, 301, 401]
replay_codes = [201, 301]
threads = 40
timeout = 5
proxy = "http://127.0.0.1:8080"
replay_proxy = "http://127.0.0.1:8081"
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);
@@ -581,21 +694,27 @@ 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.replay_proxy, String::new());
assert_eq!(config.status_codes, status_codes());
assert_eq!(config.replay_codes, config.status_codes);
assert!(config.replay_client.is_none());
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());
}
@@ -608,9 +727,16 @@ 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]
/// parse the test config and see that the value parsed is correct
fn config_reads_replay_codes() {
let config = setup_config_test();
assert_eq!(config.replay_codes, vec![201, 301]);
}
#[test]
@@ -627,6 +753,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() {
@@ -641,6 +774,13 @@ mod tests {
assert_eq!(config.proxy, "http://127.0.0.1:8080");
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_replay_proxy() {
let config = setup_config_test();
assert_eq!(config.replay_proxy, "http://127.0.0.1:8081");
}
#[test]
/// parse the test config and see that the value parsed is correct
fn config_reads_quiet() {
@@ -678,9 +818,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]
@@ -692,16 +832,23 @@ 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]
/// parse the test config and see that the value parsed is correct
fn config_reads_extract_links() {
let config = setup_config_test();
assert_eq!(config.extract_links, true);
}
#[test]
@@ -713,9 +860,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]
@@ -737,4 +891,11 @@ mod tests {
queries.push(("rick".to_string(), "astley".to_string()));
assert_eq!(config.queries, queries);
}
#[test]
#[should_panic]
/// test that an error message is printed and panic is called when report_and_exit is called
fn config_report_and_exit_works() {
report_and_exit("some message");
}
}

269
src/extractor.rs Normal file
View File

@@ -0,0 +1,269 @@
use crate::FeroxResponse;
use lazy_static::lazy_static;
use regex::Regex;
use reqwest::Url;
use std::collections::HashSet;
/// Regular expression used in [LinkFinder](https://github.com/GerbenJavado/LinkFinder)
///
/// Incorporates change from this [Pull Request](https://github.com/GerbenJavado/LinkFinder/pull/66/files)
const LINKFINDER_REGEX: &str = r#"(?:"|')(((?:[a-zA-Z]{1,10}://|//)[^"'/]{1,}\.[a-zA-Z]{2,}[^"']{0,})|((?:/|\.\./|\./)[^"'><,;| *()(%%$^/\\\[\]][^"'><,;|()]{1,})|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{1,}\.(?:[a-zA-Z]{1,4}|action)(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-/]{1,}/[a-zA-Z0-9_\-/]{3,}(?:[\?|#][^"|']{0,}|))|([a-zA-Z0-9_\-.]{1,}\.(?:php|asp|aspx|jsp|json|action|html|js|txt|xml)(?:[\?|#][^"|']{0,}|)))(?:"|')"#;
lazy_static! {
/// `LINKFINDER_REGEX` as a regex::Regex type
static ref REGEX: Regex = Regex::new(LINKFINDER_REGEX).unwrap();
}
/// Iterate over a given path, return a list of every sub-path found
///
/// example: `path` contains a link fragment `homepage/assets/img/icons/handshake.svg`
/// the following fragments would be returned:
/// - homepage/assets/img/icons/handshake.svg
/// - homepage/assets/img/icons/
/// - homepage/assets/img/
/// - homepage/assets/
/// - homepage/
fn get_sub_paths_from_path(path: &str) -> Vec<String> {
log::trace!("enter: get_sub_paths_from_path({})", path);
let mut paths = vec![];
// filter out any empty strings caused by .split
let mut parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let length = parts.len();
for _ in 0..length {
// iterate over all parts of the path
if parts.is_empty() {
// pop left us with an empty vector, we're done
break;
}
let possible_path = parts.join("/");
if possible_path.is_empty() {
// .join can result in an empty string, which we don't need, ignore
continue;
}
paths.push(possible_path); // good sub-path found
parts.pop(); // use .pop() to remove the last part of the path and continue iteration
}
log::trace!("exit: get_sub_paths_from_path -> {:?}", paths);
paths
}
/// simple helper to stay DRY, trys to join a url + fragment and add it to the `links` HashSet
fn add_link_to_set_of_links(link: &str, url: &Url, links: &mut HashSet<String>) {
log::trace!(
"enter: add_link_to_set_of_links({}, {}, {:?})",
link,
url.to_string(),
links
);
match url.join(&link) {
Ok(new_url) => {
links.insert(new_url.to_string());
}
Err(e) => {
log::error!("Could not join given url to the base url: {}", e);
}
}
log::trace!("exit: add_link_to_set_of_links");
}
/// Given a `reqwest::Response`, perform the following actions
/// - parse the response's text for links using the linkfinder regex
/// - for every link found take its url path and parse each sub-path
/// - example: Response contains a link fragment `homepage/assets/img/icons/handshake.svg`
/// with a base url of http://localhost, the following urls would be returned:
/// - homepage/assets/img/icons/handshake.svg
/// - homepage/assets/img/icons/
/// - homepage/assets/img/
/// - homepage/assets/
/// - homepage/
pub async fn get_links(response: &FeroxResponse) -> HashSet<String> {
log::trace!("enter: get_links({})", response.url().as_str());
let mut links = HashSet::<String>::new();
let body = response.text();
for capture in REGEX.captures_iter(&body) {
// remove single & double quotes from both ends of the capture
// capture[0] is the entire match, additional capture groups start at [1]
let link = capture[0].trim_matches(|c| c == '\'' || c == '"');
match Url::parse(link) {
Ok(absolute) => {
if absolute.domain() != response.url().domain()
|| absolute.host() != response.url().host()
{
// domains/ips are not the same, don't scan things that aren't part of the original
// target url
continue;
}
for sub_path in get_sub_paths_from_path(absolute.path()) {
// take a url fragment like homepage/assets/img/icons/handshake.svg and
// incrementally add
// - homepage/assets/img/icons/
// - homepage/assets/img/
// - homepage/assets/
// - homepage/
log::debug!("Adding {} to {:?}", sub_path, links);
add_link_to_set_of_links(&sub_path, &response.url(), &mut links);
}
}
Err(e) => {
// this is the expected error that happens when we try to parse a url fragment
// ex: Url::parse("/login") -> Err("relative URL without a base")
// while this is technically an error, these are good results for us
if e.to_string().contains("relative URL without a base") {
for sub_path in get_sub_paths_from_path(link) {
// incrementally save all sub-paths that led to the relative url's resource
log::debug!("Adding {} to {:?}", sub_path, links);
add_link_to_set_of_links(&sub_path, &response.url(), &mut links);
}
} else {
// unexpected error has occurred
log::error!("Could not parse given url: {}", e);
}
}
}
}
log::trace!("exit: get_links -> {:?}", links);
links
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::make_request;
use httpmock::Method::GET;
use httpmock::{Mock, MockServer};
use reqwest::Client;
#[test]
/// extract sub paths from the given url fragment; expect 4 sub paths and that all are
/// in the expected array
fn extractor_get_sub_paths_from_path_with_multiple_paths() {
let path = "homepage/assets/img/icons/handshake.svg";
let paths = get_sub_paths_from_path(&path);
let expected = vec![
"homepage",
"homepage/assets",
"homepage/assets/img",
"homepage/assets/img/icons",
"homepage/assets/img/icons/handshake.svg",
];
assert_eq!(paths.len(), expected.len());
for expected_path in expected {
assert_eq!(paths.contains(&expected_path.to_string()), true);
}
}
#[test]
/// extract sub paths from the given url fragment; expect 2 sub paths and that all are
/// in the expected array. the fragment is wrapped in slashes to ensure no empty strings are
/// returned
fn extractor_get_sub_paths_from_path_with_enclosing_slashes() {
let path = "/homepage/assets/";
let paths = get_sub_paths_from_path(&path);
let expected = vec!["homepage", "homepage/assets"];
assert_eq!(paths.len(), expected.len());
for expected_path in expected {
assert_eq!(paths.contains(&expected_path.to_string()), true);
}
}
#[test]
/// extract sub paths from the given url fragment; expect 1 sub path, no forward slashes are
/// included
fn extractor_get_sub_paths_from_path_with_only_a_word() {
let path = "homepage";
let paths = get_sub_paths_from_path(&path);
let expected = vec!["homepage"];
assert_eq!(paths.len(), expected.len());
for expected_path in expected {
assert_eq!(paths.contains(&expected_path.to_string()), true);
}
}
#[test]
/// extract sub paths from the given url fragment; expect 1 sub path, forward slash removed
fn extractor_get_sub_paths_from_path_with_an_absolute_word() {
let path = "/homepage";
let paths = get_sub_paths_from_path(&path);
let expected = vec!["homepage"];
assert_eq!(paths.len(), expected.len());
for expected_path in expected {
assert_eq!(paths.contains(&expected_path.to_string()), true);
}
}
#[test]
/// test that a full url and fragment are joined correctly, then added to the given list
/// i.e. the happy path
fn extractor_add_link_to_set_of_links_happy_path() {
let url = Url::parse("https://localhost").unwrap();
let mut links = HashSet::<String>::new();
let link = "admin";
assert_eq!(links.len(), 0);
add_link_to_set_of_links(link, &url, &mut links);
assert_eq!(links.len(), 1);
assert!(links.contains("https://localhost/admin"));
}
#[test]
/// test that an invalid path fragment doesn't add anything to the set of links
fn extractor_add_link_to_set_of_links_with_non_base_url() {
let url = Url::parse("https://localhost").unwrap();
let mut links = HashSet::<String>::new();
let link = "\\\\\\\\";
assert_eq!(links.len(), 0);
add_link_to_set_of_links(link, &url, &mut links);
assert_eq!(links.len(), 0);
assert!(links.is_empty());
}
#[tokio::test(core_threads = 1)]
/// use make_request to generate a Response, and use the Response to test get_links;
/// the response will contain an absolute path to a domain that is not part of the scanned
/// domain; expect an empty set returned
async fn extractor_get_links_with_absolute_url_that_differs_from_target_domain(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/some-path")
.return_status(200)
.return_body("\"http://defintely.not.a.thing.probably.com/homepage/assets/img/icons/handshake.svg\"")
.create_on(&srv);
let client = Client::new();
let url = Url::parse(&srv.url("/some-path")).unwrap();
let response = make_request(&client, &url).await.unwrap();
let ferox_response = FeroxResponse::from(response, true).await;
let links = get_links(&ferox_response).await;
assert!(links.is_empty());
assert_eq!(mock.times_called(), 1);
Ok(())
}
}

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,30 +1,21 @@
use crate::config::{CONFIGURATION, PROGRESS_PRINTER};
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;
/// 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)]
pub struct WildcardFilter {
pub dynamic: u64,
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
@@ -48,22 +39,34 @@ fn unique_string(length: usize) -> String {
///
/// In the event that url returns a wildcard response, a
/// [WildcardFilter](struct.WildcardFilter.html) is created and returned to the caller.
pub async fn wildcard_test(target_url: &str, bar: ProgressBar) -> Option<WildcardFilter> {
log::trace!("enter: wildcard_test({:?})", target_url);
pub async fn wildcard_test(
target_url: &str,
bar: ProgressBar,
tx_file: UnboundedSender<String>,
) -> Option<WildcardFilter> {
log::trace!(
"enter: wildcard_test({:?}, {:?}, {:?})",
target_url,
bar,
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;
}
if let Some(resp_one) = make_wildcard_request(&target_url, 1).await {
let clone_req_one = tx_file.clone();
let clone_req_two = tx_file.clone();
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);
@@ -72,42 +75,57 @@ pub async fn wildcard_test(target_url: &str, bar: ProgressBar) -> Option<Wildcar
// content length of wildcard is non-zero, perform additional tests:
// make a second request, with a known-sized (64) longer request
if let Some(resp_two) = make_wildcard_request(&target_url, 3).await {
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());
if !CONFIGURATION.quiet {
ferox_print(
&format!(
"{} {:>10} Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}",
status_colorizer("WLD"),
wc_length - url_len,
style("auto-filtering").yellow(),
style(wc_length - url_len).cyan(),
style("--dontfilter").yellow()
), &PROGRESS_PRINTER
);
}
let url_len = get_url_path_length(&ferox_response.url());
wildcard.dynamic = wc_length - url_len;
} else if wc_length == wc2_length {
if !CONFIGURATION.quiet {
ferox_print(&format!(
"{} {:>10} Wildcard response is static; {} {} responses; toggle this behavior by using {}",
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("--dont-filter").yellow()
);
ferox_print(&msg, &PROGRESS_PRINTER);
try_send_message_to_file(
&msg,
tx_file.clone(),
!CONFIGURATION.output.is_empty(),
);
}
} else if wc_length == wc2_length {
wildcard.size = wc_length;
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()
), &PROGRESS_PRINTER);
style("--dont-filter").yellow()
);
ferox_print(&msg, &PROGRESS_PRINTER);
try_send_message_to_file(
&msg,
tx_file.clone(),
!CONFIGURATION.output.is_empty(),
);
}
wildcard.size = wc_length;
}
} else {
bar.inc(2);
@@ -127,15 +145,24 @@ pub async fn wildcard_test(target_url: &str, bar: ProgressBar) -> Option<Wildcar
/// Once the unique url is created, the request is sent to the server. If the server responds
/// back with a valid status code, the response is considered to be a wildcard response. If that
/// wildcard response has a 3xx status code, that redirection location is displayed to the user.
async fn make_wildcard_request(target_url: &str, length: usize) -> Option<Response> {
log::trace!("enter: make_wildcard_request({}, {})", target_url, length);
async fn make_wildcard_request(
target_url: &str,
length: usize,
tx_file: UnboundedSender<String>,
) -> Option<FeroxResponse> {
log::trace!(
"enter: make_wildcard_request({}, {}, {:?})",
target_url,
length,
tx_file
);
let unique_str = unique_string(length);
let nonexistent = match format_url(
target_url,
&unique_str,
CONFIGURATION.addslash,
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
None,
) {
@@ -152,58 +179,58 @@ async fn make_wildcard_request(target_url: &str, length: usize) -> Option<Respon
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 {
ferox_print(
&format!(
"{} {:>10} Got {} for {} (url length: {})",
wildcard,
content_len,
status_colorizer(&response.status().as_str()),
response.url(),
url_len
),
&PROGRESS_PRINTER,
if !CONFIGURATION.quiet && !should_filter_response(&ferox_response) {
let msg = format!(
"{} {:>10} Got {} for {} (url length: {})\n",
wildcard,
content_len,
status_colorizer(&ferox_response.status().as_str()),
ferox_response.url(),
url_len
);
ferox_print(&msg, &PROGRESS_PRINTER);
try_send_message_to_file(
&msg,
tx_file.clone(),
!CONFIGURATION.output.is_empty(),
);
}
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 Ok(next_loc_str) = next_loc.to_str() {
if !CONFIGURATION.quiet {
ferox_print(
&format!(
"{} {:>10} {} redirects to => {}",
wildcard,
content_len,
response.url(),
next_loc_str
),
&PROGRESS_PRINTER,
);
}
} else if !CONFIGURATION.quiet {
ferox_print(
&format!(
"{} {:>10} {} redirects to => {:?}",
wildcard,
content_len,
response.url(),
next_loc
),
&PROGRESS_PRINTER,
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(&ferox_response) {
let msg = format!(
"{} {:>10} {} redirects to => {}\n",
wildcard,
content_len,
ferox_response.url(),
next_loc_str
);
ferox_print(&msg, &PROGRESS_PRINTER);
try_send_message_to_file(
&msg,
tx_file.clone(),
!CONFIGURATION.output.is_empty(),
);
}
}
}
log::trace!("exit: make_wildcard_request -> {:?}", response);
return Some(response);
log::trace!("exit: make_wildcard_request -> {:?}", ferox_response);
return Some(ferox_response);
}
}
Err(e) => {
@@ -230,7 +257,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,
) {
@@ -259,14 +286,6 @@ pub async fn connectivity_test(target_urls: &[String]) -> Vec<String> {
if good_urls.is_empty() {
log::error!("Could not connect to any target provided, exiting.");
log::trace!("exit: connectivity_test");
eprintln!(
"{} {} Could not connect to any target provided",
status_colorizer("ERROR"),
module_colorizer("heuristics::connectivity_test"),
);
process::exit(1);
}
log::trace!("exit: connectivity_test -> {:?}", good_urls);
@@ -274,15 +293,86 @@ pub async fn connectivity_test(target_urls: &[String]) -> Vec<String> {
good_urls
}
/// simple helper to keep DRY; sends a message using the transmitter side of the given mpsc channel
/// the receiver is expected to be the side that saves the message to CONFIGURATION.output.
fn try_send_message_to_file(msg: &str, tx_file: UnboundedSender<String>, save_output: bool) {
log::trace!("enter: try_send_message_to_file({}, {:?})", msg, tx_file);
if save_output {
match tx_file.send(msg.to_string()) {
Ok(_) => {
log::trace!(
"sent message from heuristics::try_send_message_to_file to file handler"
);
}
Err(e) => {
log::error!(
"{} {}",
module_colorizer("heuristics::try_send_message_to_file"),
e
);
}
}
}
log::trace!("exit: try_send_message_to_file");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::FeroxChannel;
use tokio::sync::mpsc;
#[test]
/// request a unique string of 32bytes * a value returns correct result
fn unique_string_returns_correct_length() {
fn heuristics_unique_string_returns_correct_length() {
for i in 0..10 {
assert_eq!(unique_string(i).len(), i * 32);
}
}
#[test]
/// simply test the default values for wildcardfilter, expect 0, 0
fn heuristics_wildcardfilter_dafaults() {
let wcf = WildcardFilter::default();
assert_eq!(wcf.size, 0);
assert_eq!(wcf.dynamic, 0);
}
#[tokio::test(core_threads = 1)]
/// tests that given a message and transmitter, the function sends the message across the
/// channel
async fn heuristics_try_send_message_to_file_sends_when_true() {
let (tx, mut rx): FeroxChannel<String> = mpsc::unbounded_channel();
let msg = "It really tied the room together.";
let should_save = true;
try_send_message_to_file(&msg, tx, should_save);
assert_eq!(rx.recv().await.unwrap(), msg);
}
#[tokio::test(core_threads = 1)]
#[should_panic]
/// tests that when save_output is false, nothing is sent to the receiver
async fn heuristics_try_send_message_to_file_sends_when_false() {
let (tx, mut rx): FeroxChannel<String> = mpsc::unbounded_channel();
let msg = "I'm the Dude, so that's what you call me.";
let should_save = false;
try_send_message_to_file(&msg, tx, should_save);
assert_ne!(rx.recv().await.unwrap(), msg);
}
#[tokio::test(core_threads = 1)]
/// tests that when save_output is true, but the receiver is closed, nothing is sent to the receiver
/// this test doesn't assert anything, but reaches the error block of the given function and
/// can be verified with --nocapture and RUST_LOG being set
async fn heuristics_try_send_message_to_file_sends_with_closed_receiver() {
env_logger::init();
let (tx, mut rx): FeroxChannel<String> = mpsc::unbounded_channel();
let msg = "Hey, nice marmot.";
let should_save = true;
rx.close();
try_send_message_to_file(&msg, tx, should_save);
}
}

View File

@@ -1,22 +1,50 @@
pub mod banner;
pub mod client;
pub mod config;
pub mod extractor;
pub mod filters;
pub mod heuristics;
pub mod logger;
pub mod parser;
pub mod progress;
pub mod reporter;
pub mod scanner;
pub mod utils;
use reqwest::StatusCode;
use reqwest::{
header::HeaderMap,
{Response, StatusCode, Url},
};
use std::{error, fmt};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
/// Generic Result type to ease error handling in async contexts
pub type FeroxResult<T> =
std::result::Result<T, Box<dyn std::error::Error + Send + Sync + 'static>>;
pub type FeroxResult<T> = std::result::Result<T, Box<dyn error::Error + Send + Sync + 'static>>;
/// Simple Error implementation to allow for custom error returns
#[derive(Debug, Default)]
pub struct FeroxError {
/// fancy string that can be printed via Display
pub message: String,
}
impl error::Error for FeroxError {}
impl fmt::Display for FeroxError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", &self.message)
}
}
/// Generic mpsc::unbounded_channel type to tidy up some code
pub type FeroxChannel<T> = (UnboundedSender<T>, UnboundedReceiver<T>);
/// Version pulled from Cargo.toml at compile time
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Maximum number of file descriptors that can be opened during a scan
pub const DEFAULT_OPEN_FILE_LIMIT: usize = 8192;
/// Default wordlist to use when `-w|--wordlist` isn't specified and not `wordlist` isn't set
/// in a [ferox-config.toml](constant.DEFAULT_CONFIG_NAME.html) config file.
///
@@ -25,6 +53,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
@@ -53,6 +84,118 @@ pub const DEFAULT_STATUS_CODES: [StatusCode; 9] = [
/// Expected location is in the same directory as the feroxbuster binary.
pub const DEFAULT_CONFIG_NAME: &str = "ferox-config.toml";
/// A `FeroxResponse`, derived from a `Response` to a submitted `Request`
#[derive(Debug, Clone)]
pub struct FeroxResponse {
/// The final `Url` of this `FeroxResponse`
url: Url,
/// The `StatusCode` of this `FeroxResponse`
status: StatusCode,
/// The full response text
text: String,
/// The content-length of this response, if known
content_length: u64,
/// The `Headers` of this `FeroxResponse`
headers: HeaderMap,
}
/// `FeroxResponse` implementation
impl FeroxResponse {
/// Get the `StatusCode` of this `FeroxResponse`
pub fn status(&self) -> &StatusCode {
&self.status
}
/// Get the final `Url` of this `FeroxResponse`.
pub fn url(&self) -> &Url {
&self.url
}
/// Get the full response text
pub fn text(&self) -> &str {
&self.text
}
/// Get the `Headers` of this `FeroxResponse`
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
/// Get the content-length of this response, if known
pub fn content_length(&self) -> u64 {
self.content_length
}
/// Set `FeroxResponse`'s `url` attribute, has no affect if an error occurs
pub fn set_url(&mut self, url: &str) {
match Url::parse(&url) {
Ok(url) => {
self.url = url;
}
Err(e) => {
log::error!("Could not parse {} into a Url: {}", url, e);
}
};
}
/// Make a reasonable guess at whether the response is a file or not
///
/// Examines the last part of a path to determine if it has an obvious extension
/// i.e. http://localhost/some/path/stuff.js where stuff.js indicates a file
///
/// Additionally, inspects query parameters, as they're also often indicative of a file
pub fn is_file(&self) -> bool {
let has_extension = match self.url.path_segments() {
Some(path) => {
if let Some(last) = path.last() {
last.contains('.') // last segment has some sort of extension, probably
} else {
false
}
}
None => false,
};
self.url.query_pairs().count() > 0 || has_extension
}
/// Create a new `FeroxResponse` from the given `Response`
pub async fn from(response: Response, read_body: bool) -> Self {
let url = response.url().clone();
let status = response.status();
let headers = response.headers().clone();
let content_length = response.content_length().unwrap_or(0);
let text = if read_body {
// .text() consumes the response, must be called last
// additionally, --extract-links is currently the only place we use the body of the
// response, so we forego the processing if not performing extraction
match response.text().await {
// await the response's body
Ok(text) => text,
Err(e) => {
log::error!("Could not parse body from response: {}", e);
String::new()
}
}
} else {
String::new()
};
FeroxResponse {
url,
status,
content_length,
text,
headers,
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,4 +1,5 @@
use crate::config::PROGRESS_PRINTER;
use crate::config::{CONFIGURATION, PROGRESS_PRINTER};
use crate::reporter::{get_cached_file_handle, safe_file_write};
use console::{style, Color};
use env_logger::Builder;
use std::env;
@@ -27,6 +28,19 @@ pub fn initialize(verbosity: u8) {
let start = Instant::now();
let mut builder = Builder::from_default_env();
// I REALLY wanted the logger to also use the reporting channels found in the `reporter`
// module. However, in order to properly clean up the channels, all references to the
// transmitter side of a channel need to go out of scope, then you can await the future into
// which the receiver was moved.
//
// The problem was that putting a transmitter reference in this closure, which gets initialized
// as part of the global logger, made it so that I couldn't destroy/leak/take/swap the last
// reference to allow the channels to gracefully close.
//
// The workaround was to have a RwLock around the file and allow both the logger and the
// file handler to both write independent of each other.
let locked_file = get_cached_file_handle(&CONFIGURATION.output);
builder
.format(move |_, record| {
let t = start.elapsed().as_secs_f32();
@@ -41,13 +55,18 @@ pub fn initialize(verbosity: u8) {
};
let msg = format!(
"{} {:10.03} {}",
"{} {:10.03} {}\n",
style(level_name).bg(level_color).black(),
style(t).dim(),
style(record.args()).dim(),
);
PROGRESS_PRINTER.println(msg);
PROGRESS_PRINTER.println(&msg);
if let Some(buffered_file) = locked_file.clone() {
safe_file_write(&msg, buffered_file);
}
Ok(())
})
.init();

View File

@@ -1,16 +1,61 @@
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, FeroxResult};
use crossterm::event::{self, Event, KeyCode};
use feroxbuster::{
banner,
config::{CONFIGURATION, PROGRESS_BAR, PROGRESS_PRINTER},
heuristics, logger, reporter,
scanner::{scan_url, PAUSE_SCAN},
utils::{ferox_print, get_current_depth, module_colorizer, status_colorizer},
FeroxError, FeroxResponse, FeroxResult, SLEEP_DURATION, VERSION,
};
#[cfg(not(target_os = "windows"))]
use feroxbuster::{utils::set_open_file_limit, DEFAULT_OPEN_FILE_LIMIT};
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 std::{
collections::HashSet,
fs::File,
io::{stderr, BufRead, BufReader},
process,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
use tokio::{io, sync::mpsc::UnboundedSender, task::JoinHandle};
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);
@@ -18,12 +63,6 @@ fn get_unique_words_from_wordlist(path: &str) -> FeroxResult<Arc<HashSet<String>
let file = match File::open(&path) {
Ok(f) => f,
Err(e) => {
eprintln!(
"{} {} {}",
status_colorizer("ERROR"),
module_colorizer("main::get_unique_words_from_wordlist"),
e
);
log::error!("Could not open wordlist: {}", e);
log::trace!("exit: get_unique_words_from_wordlist -> {}", e);
@@ -36,7 +75,13 @@ fn get_unique_words_from_wordlist(path: &str) -> FeroxResult<Arc<HashSet<String>
let mut words = HashSet::new();
for line in reader.lines() {
words.insert(line?);
let result = line?;
if result.starts_with('#') || result.is_empty() {
continue;
}
words.insert(result);
}
log::trace!(
@@ -48,8 +93,12 @@ fn get_unique_words_from_wordlist(path: &str) -> FeroxResult<Arc<HashSet<String>
}
/// Determine whether it's a single url scan or urls are coming from stdin, then scan as needed
async fn scan(targets: Vec<String>) -> FeroxResult<()> {
log::trace!("enter: scan");
async fn scan(
targets: Vec<String>,
tx_term: UnboundedSender<FeroxResponse>,
tx_file: UnboundedSender<String>,
) -> FeroxResult<()> {
log::trace!("enter: scan({:?}, {:?}, {:?})", targets, tx_term, tx_file);
// cloning an Arc is cheap (it's basically a pointer into the heap)
// so that will allow for cheap/safe sharing of a single wordlist across multi-target scans
// as well as additional directories found as part of recursion
@@ -58,23 +107,21 @@ async fn scan(targets: Vec<String>) -> FeroxResult<()> {
.await??;
if words.len() == 0 {
eprintln!(
"{} {} Did not find any words in {}",
status_colorizer("ERROR"),
module_colorizer("main::scan"),
CONFIGURATION.wordlist
);
process::exit(1);
let mut err = FeroxError::default();
err.message = format!("Did not find any words in {}", CONFIGURATION.wordlist);
return Err(Box::new(err));
}
let mut tasks = vec![];
for target in targets {
let wordclone = words.clone();
let word_clone = words.clone();
let term_clone = tx_term.clone();
let file_clone = tx_file.clone();
let task = tokio::spawn(async move {
let base_depth = get_current_depth(&target);
scan_url(&target, wordclone, base_depth).await;
scan_url(&target, word_clone, base_depth, term_clone, file_clone).await;
});
tasks.push(task);
@@ -87,6 +134,7 @@ async fn scan(targets: Vec<String>) -> FeroxResult<()> {
Ok(())
}
/// Get targets from either commandline or stdin, pass them back to the caller as a Result<Vec>
async fn get_targets() -> FeroxResult<Vec<String>> {
log::trace!("enter: get_targets");
@@ -110,48 +158,150 @@ async fn get_targets() -> FeroxResult<Vec<String>> {
Ok(targets)
}
#[tokio::main]
async fn main() {
logger::initialize(CONFIGURATION.verbosity);
/// async main called from real main, broken out in this way to allow for some synchronous code
/// to be executed before bringing the tokio runtime online
async fn wrapped_main() {
// join can only be called once, otherwise it causes the thread to panic
tokio::task::spawn_blocking(move || {
// ok, lazy_static! uses (unsurprisingly in retrospect) a lazy loading model where the
// thing obtained through deref isn't actually created until it's used. This created a
// problem when initializing the logger as it relied on PROGRESS_PRINTER which may or may
// not have been created by the time it was needed for logging (really only occurred in
// heuristics / banner / main). In order to initialize logging properly, we need to ensure
// PROGRESS_PRINTER and PROGRESS_BAR have been used at least once. This call satisfies
// that constraint
PROGRESS_PRINTER.println("");
PROGRESS_BAR.join().unwrap();
});
// can't trace main until after logger is initialized and the above task is started
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) =
reporter::initialize(&CONFIGURATION.output, save_output);
// get targets from command line or stdin
let targets = match get_targets().await {
Ok(t) => t,
Err(e) => {
// should only happen in the event that there was an error reading from stdin
log::error!("{}", e);
ferox_print(
&format!(
"{} {} {}",
status_colorizer("ERROR"),
module_colorizer("main::get_targets"),
e
),
&PROGRESS_PRINTER,
);
process::exit(1);
log::error!("{} {}", module_colorizer("main::get_targets"), e);
clean_up(tx_term, term_handle, tx_file, file_handle, save_output).await;
return;
}
};
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
let live_targets = heuristics::connectivity_test(&targets).await;
match scan(live_targets).await {
if live_targets.is_empty() {
clean_up(tx_term, term_handle, tx_file, file_handle, save_output).await;
return;
}
// kick off a scan against any targets determined to be responsive
match scan(live_targets, tx_term.clone(), tx_file.clone()).await {
Ok(_) => {
log::info!("Done");
log::info!("All scans complete!");
}
Err(e) => {
ferox_print(
&format!("{} while scanning: {}", status_colorizer("Error"), e),
&PROGRESS_PRINTER,
);
clean_up(tx_term, term_handle, tx_file, file_handle, save_output).await;
process::exit(1);
}
Err(e) => log::error!("An error occurred: {}", e),
};
PROGRESS_PRINTER.finish();
clean_up(tx_term, term_handle, tx_file, file_handle, save_output).await;
log::trace!("exit: main");
}
/// Single cleanup function that handles all the necessary drops/finishes etc required to gracefully
/// shutdown the program
async fn clean_up(
tx_term: UnboundedSender<FeroxResponse>,
term_handle: JoinHandle<()>,
tx_file: UnboundedSender<String>,
file_handle: Option<JoinHandle<()>>,
save_output: bool,
) {
log::trace!(
"enter: clean_up({:?}, {:?}, {:?}, {:?}, {}",
tx_term,
term_handle,
tx_file,
file_handle,
save_output
);
drop(tx_term);
log::trace!("dropped terminal output handler's transmitter");
log::trace!("awaiting terminal output handler's receiver");
// after dropping tx, we can await the future where rx lived
match term_handle.await {
Ok(_) => {}
Err(e) => {
log::error!("error awaiting terminal output handler's receiver: {}", e);
}
}
log::trace!("done awaiting terminal output handler's receiver");
log::trace!("tx_file: {:?}", tx_file);
// the same drop/await process used on the terminal handler is repeated for the file handler
// we drop the file transmitter every time, because it's created no matter what
drop(tx_file);
log::trace!("dropped file output handler's transmitter");
if save_output {
// but we only await if -o was specified
log::trace!("awaiting file output handler's receiver");
match file_handle.unwrap().await {
Ok(_) => {}
Err(e) => {
log::error!("error awaiting file output handler's receiver: {}", e);
}
}
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);
// clean-up function for the MultiProgress bar; must be called last in order to still see
// the final trace messages above
PROGRESS_PRINTER.finish();
log::trace!("exit: clean_up");
}
fn main() {
// setup logging based on the number of -v's used
logger::initialize(CONFIGURATION.verbosity);
// this function uses rlimit, which is not supported on windows
#[cfg(not(target_os = "windows"))]
set_open_file_limit(DEFAULT_OPEN_FILE_LIMIT);
if let Ok(mut runtime) = tokio::runtime::Runtime::new() {
let future = wrapped_main();
runtime.block_on(future);
}
}

View File

@@ -68,15 +68,38 @@ pub fn initialize() -> App<'static, 'static> {
),
)
.arg(
Arg::with_name("statuscodes")
Arg::with_name("replay_proxy")
.short("P")
.long("replay-proxy")
.takes_value(true)
.value_name("REPLAY_PROXY")
.help(
"Send only unfiltered requests through a Replay Proxy, instead of all requests",
),
)
.arg(
Arg::with_name("replay_codes")
.short("R")
.long("replay-codes")
.value_name("REPLAY_CODE")
.takes_value(true)
.multiple(true)
.use_delimiter(true)
.requires("replay_proxy")
.help(
"Status Codes to send through a Replay Proxy when found (default: --status-codes value)",
),
)
.arg(
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 +110,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 +125,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 +185,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 +207,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,7 +218,33 @@ 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 -C 401)",
),
)
.arg(
Arg::with_name("extract_links")
.short("e")
.long("extract-links")
.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:
@@ -211,7 +260,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
@@ -225,7 +274,22 @@ EXAMPLES:
Pass auth token via query parameter
./feroxbuster -u http://127.1 --query token=0123456789ABCDEF
Find links in javascript/html and make additional requests based on results
./feroxbuster -u http://127.1 --extract-links
Ludicrous speed... go!
./feroxbuster -u http://127.1 -t 200
"#)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// initalize parser, expect a clap::App returned
fn parser_initialize_gives_defaults() {
let app = initialize();
assert_eq!(app.get_name(), "feroxbuster");
}
}

242
src/reporter.rs Normal file
View File

@@ -0,0 +1,242 @@
use crate::config::{CONFIGURATION, PROGRESS_PRINTER};
use crate::utils::{ferox_print, make_request, status_colorizer};
use crate::{FeroxChannel, FeroxResponse};
use console::strip_ansi_codes;
use std::io::Write;
use std::sync::{Arc, Once, RwLock};
use std::{fs, io};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
/// Singleton buffered file behind an Arc/RwLock; used for file writes from two locations:
/// - [logger::initialize](../logger/fn.initialize.html) (specifically a closure on the global logger instance)
/// - `reporter::spawn_file_handler`
pub static mut LOCKED_FILE: Option<Arc<RwLock<io::BufWriter<fs::File>>>> = None;
/// An initializer Once variable used to create `LOCKED_FILE`
static INIT: Once = Once::new();
// Accessing a `static mut` is unsafe much of the time, but if we do so
// in a synchronized fashion (e.g., write once or read all) then we're
// good to go!
//
// This function will only call `open_file` once, and will
// otherwise always return the value returned from the first invocation.
pub fn get_cached_file_handle(filename: &str) -> Option<Arc<RwLock<io::BufWriter<fs::File>>>> {
unsafe {
INIT.call_once(|| {
LOCKED_FILE = open_file(&filename);
});
LOCKED_FILE.clone()
}
}
/// Creates all required output handlers (terminal, file) and returns
/// the transmitter sides of each mpsc along with each receiver's future's JoinHandle to be awaited
///
/// Any other module that needs to write a Response to stdout or output results to a file should
/// be passed a clone of the appropriate returned transmitter
pub fn initialize(
output_file: &str,
save_output: bool,
) -> (
UnboundedSender<FeroxResponse>,
UnboundedSender<String>,
JoinHandle<()>,
Option<JoinHandle<()>>,
) {
log::trace!("enter: initialize({}, {})", output_file, save_output);
let (tx_rpt, rx_rpt): FeroxChannel<FeroxResponse> = mpsc::unbounded_channel();
let (tx_file, rx_file): FeroxChannel<String> = mpsc::unbounded_channel();
let file_clone = tx_file.clone();
let term_reporter =
tokio::spawn(async move { spawn_terminal_reporter(rx_rpt, file_clone, save_output).await });
let file_reporter = if save_output {
// -o used, need to spawn the thread for writing to disk
let file_clone = output_file.to_string();
Some(tokio::spawn(async move {
spawn_file_reporter(rx_file, &file_clone).await
}))
} else {
None
};
log::trace!(
"exit: initialize -> ({:?}, {:?}, {:?}, {:?})",
tx_rpt,
tx_file,
term_reporter,
file_reporter
);
(tx_rpt, tx_file, term_reporter, file_reporter)
}
/// Spawn a single consumer task (sc side of mpsc)
///
/// The consumer simply receives responses and prints them if they meet the given
/// reporting criteria
async fn spawn_terminal_reporter(
mut resp_chan: UnboundedReceiver<FeroxResponse>,
file_chan: UnboundedSender<String>,
save_output: bool,
) {
log::trace!(
"enter: spawn_terminal_reporter({:?}, {:?}, {})",
resp_chan,
file_chan,
save_output
);
while let Some(resp) = resp_chan.recv().await {
log::trace!("received {} on reporting channel", resp.url());
if CONFIGURATION.status_codes.contains(&resp.status().as_u16()) {
let report = if CONFIGURATION.quiet {
// -q used, just need the url
format!("{}\n", resp.url())
} else {
// normal printing with status and size
let status = status_colorizer(&resp.status().as_str());
format!(
// example output
// 200 3280 https://localhost.com/FAQ
"{} {:>10} {}\n",
status,
resp.content_length(),
resp.url()
)
};
// print to stdout
ferox_print(&report, &PROGRESS_PRINTER);
if save_output {
// -o used, need to send the report to be written out to disk
match file_chan.send(report.to_string()) {
Ok(_) => {
log::debug!("Sent {} to file handler", resp.url());
}
Err(e) => {
log::error!("Could not send {} to file handler: {}", resp.url(), e);
}
}
}
}
log::trace!("report complete: {}", resp.url());
if CONFIGURATION.replay_client.is_some()
&& CONFIGURATION.replay_codes.contains(&resp.status().as_u16())
{
// replay proxy specified/client created and this response's status code is one that
// should be replayed
match make_request(CONFIGURATION.replay_client.as_ref().unwrap(), &resp.url()).await {
Ok(_) => {}
Err(e) => {
log::error!("{}", e);
}
}
}
}
log::trace!("exit: spawn_terminal_reporter");
}
/// Spawn a single consumer task (sc side of mpsc)
///
/// The consumer simply receives responses and writes them to the given output file if they meet
/// the given reporting criteria
async fn spawn_file_reporter(mut report_channel: UnboundedReceiver<String>, output_file: &str) {
let buffered_file = match get_cached_file_handle(&CONFIGURATION.output) {
Some(file) => file,
None => {
log::trace!("exit: spawn_file_reporter");
return;
}
};
log::trace!(
"enter: spawn_file_reporter({:?}, {})",
report_channel,
output_file
);
log::info!("Writing scan results to {}", output_file);
while let Some(report) = report_channel.recv().await {
safe_file_write(&report, buffered_file.clone());
}
log::trace!("exit: spawn_file_reporter");
}
/// Given the path to a file, open the file in append mode (create it if it doesn't exist) and
/// return a reference to the file that is buffered and locked
fn open_file(filename: &str) -> Option<Arc<RwLock<io::BufWriter<fs::File>>>> {
log::trace!("enter: open_file({})", filename);
match fs::OpenOptions::new() // std fs
.create(true)
.append(true)
.open(filename)
{
Ok(file) => {
let writer = io::BufWriter::new(file); // std io
let locked_file = Some(Arc::new(RwLock::new(writer)));
log::trace!("exit: open_file -> {:?}", locked_file);
locked_file
}
Err(e) => {
log::error!("{}", e);
log::trace!("exit: open_file -> None");
None
}
}
}
/// Given a string and a reference to a locked buffered file, write the contents and flush
/// the buffer to disk.
pub fn safe_file_write(contents: &str, locked_file: Arc<RwLock<io::BufWriter<fs::File>>>) {
// note to future self: adding logging of anything other than error to this function
// is a bad idea. we call this function while processing records generated by the logger.
// If we then call log::... while already processing some logging output, it results in
// the second log entry being injected into the first.
let contents = strip_ansi_codes(&contents);
if let Ok(mut handle) = locked_file.write() {
// write lock acquired
match handle.write(contents.as_bytes()) {
Ok(_) => {}
Err(e) => {
log::error!("could not write report to disk: {}", e);
}
}
match handle.flush() {
// this function is used within async functions/loops, so i'm flushing so that in
// the event of a ctrl+c or w/e results seen so far are saved instead of left lying
// around in the buffer
Ok(_) => {}
Err(e) => {
log::error!("error writing to file: {}", e);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
/// asserts that an empty string for a filename returns None
fn reporter_get_cached_file_handle_without_filename_returns_none() {
let _used = get_cached_file_handle(&"").unwrap();
}
}

View File

@@ -1,122 +1,193 @@
use crate::config::{CONFIGURATION, PROGRESS_BAR, PROGRESS_PRINTER};
use crate::heuristics::WildcardFilter;
use crate::utils::{
ferox_print, format_url, get_current_depth, get_url_path_length, make_request, status_colorizer,
use crate::{
config::CONFIGURATION,
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,
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,
};
use crate::{heuristics, progress};
use futures::future::{BoxFuture, FutureExt};
use futures::{stream, StreamExt};
use reqwest::{Response, Url};
use std::collections::HashSet;
use std::convert::TryInto;
use std::ops::Deref;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::fs;
use tokio::io::{self, AsyncWriteExt};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
/// Single atomic number that gets incremented once, used to track first scan vs. all others
static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
/// Spawn a single consumer task (sc side of mpsc)
///
/// The consumer simply receives responses and writes them to the given output file if they meet
/// the given reporting criteria
async fn spawn_file_reporter(mut report_channel: UnboundedReceiver<Response>) {
log::trace!("enter: spawn_file_reporter({:?}", report_channel);
/// Atomic boolean flag, used to determine whether or not a scan should pause or resume
pub static PAUSE_SCAN: AtomicBool = AtomicBool::new(false);
log::info!("Writing scan results to {}", CONFIGURATION.output);
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());
match fs::OpenOptions::new() // tokio fs
.create(true)
.append(true)
.open(&CONFIGURATION.output)
.await
{
Ok(outfile) => {
log::debug!("{:?} opened in append mode", outfile);
/// 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());
let mut writer = io::BufWriter::new(outfile); // tokio BufWriter
/// 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()));
while let Some(resp) = report_channel.recv().await {
log::debug!("received {} on reporting channel", resp.url());
if CONFIGURATION.statuscodes.contains(&resp.status().as_u16()) {
let report = if CONFIGURATION.quiet {
format!("{}\n", resp.url())
} else {
// example output
// 200 3280 https://localhost.com/FAQ
format!(
"{} {:>10} {}\n",
resp.status().as_str(),
resp.content_length().unwrap_or(0),
resp.url()
)
};
match writer.write(report.as_bytes()).await {
Ok(written) => {
log::trace!("wrote {} bytes to {}", written, CONFIGURATION.output);
}
Err(e) => {
log::error!("could not write report to disk: {}", e);
}
}
}
match writer.flush().await {
// i'm flushing inside the while loop so in the event of a ctrl+c or w/e
// results seen so far are saved instead of left lying around in the buffer
Ok(_) => {}
Err(e) => {
log::error!("error writing to file: {}", e);
}
}
log::debug!("report complete: {}", resp.url());
}
}
Err(e) => {
log::error!("error opening file: {}", e);
}
}
log::trace!("exit: spawn_file_reporter");
/// Bounded semaphore used as a barrier to limit concurrent scans
static ref SCAN_LIMITER: Semaphore = Semaphore::new(CONFIGURATION.scan_limit);
}
/// Spawn a single consumer task (sc side of mpsc)
/// 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
///
/// The consumer simply receives responses and prints them if they meet the given
/// reporting criteria
async fn spawn_terminal_reporter(mut report_channel: UnboundedReceiver<Response>) {
log::trace!("enter: spawn_terminal_reporter({:?})", report_channel);
/// 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
while let Some(resp) = report_channel.recv().await {
log::debug!("received {} on reporting channel", resp.url());
// 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));
if CONFIGURATION.statuscodes.contains(&resp.status().as_u16()) {
if CONFIGURATION.quiet {
ferox_print(&format!("{}", resp.url()), &PROGRESS_PRINTER);
} else {
let status = status_colorizer(&resp.status().as_str());
ferox_print(
&format!(
// example output
// 200 3280 https://localhost.com/FAQ
"{} {:>10} {}",
status,
resp.content_length().unwrap_or(0),
resp.url()
),
&PROGRESS_PRINTER,
);
}
// 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`
///
/// If `SCANNED_URLS` did not already contain the url, return true; otherwise return false
fn add_url_to_list_of_scanned_urls(resp: &str, scanned_urls: &RwLock<HashSet<String>>) -> bool {
log::trace!(
"enter: add_url_to_list_of_scanned_urls({}, {:?})",
resp,
scanned_urls
);
match scanned_urls.write() {
// check new url against what's already been scanned
Ok(mut urls) => {
let normalized_url = if resp.ends_with('/') {
// append a / to the list of 'seen' urls, this is to prevent the case where
// 3xx and 2xx duplicate eachother
resp.to_string()
} else {
format!("{}/", resp)
};
// If the set did not contain resp, true is returned.
// If the set did contain resp, false is returned.
let response = urls.insert(normalized_url);
log::trace!("exit: add_url_to_list_of_scanned_urls -> {}", response);
response
}
Err(e) => {
// poisoned lock
log::error!("Set of scanned urls poisoned: {}", e);
log::trace!("exit: add_url_to_list_of_scanned_urls -> false");
false
}
}
}
/// Adds the given FeroxFilter to the given list of FeroxFilter implementors
///
/// 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_ferox_filters({:?}, {:?})",
filter,
ferox_filters
);
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_ferox_filters -> false");
return false;
}
filters.push(filter);
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_ferox_filters -> false");
false
}
log::debug!("report complete: {}", resp.url());
}
log::trace!("exit: spawn_terminal_reporter");
}
/// Spawn a single consumer task (sc side of mpsc)
@@ -126,22 +197,45 @@ fn spawn_recursion_handler(
mut recursion_channel: UnboundedReceiver<String>,
wordlist: Arc<HashSet<String>>,
base_depth: usize,
tx_term: UnboundedSender<FeroxResponse>,
tx_file: UnboundedSender<String>,
) -> BoxFuture<'static, Vec<JoinHandle<()>>> {
log::trace!(
"enter: spawn_recursion_handler({:?}, wordlist[{} words...], {})",
"enter: spawn_recursion_handler({:?}, wordlist[{} words...], {}, {:?}, {:?})",
recursion_channel,
wordlist.len(),
base_depth
base_depth,
tx_term,
tx_file
);
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);
if !unknown {
// not unknown, i.e. we've seen the url before and don't need to scan again
continue;
}
log::info!("received {} on recursion channel", resp);
let clonedresp = resp.clone();
let clonedlist = wordlist.clone();
let term_clone = tx_term.clone();
let file_clone = tx_file.clone();
let resp_clone = resp.clone();
let list_clone = wordlist.clone();
scans.push(tokio::spawn(async move {
scan_url(clonedresp.to_owned().as_str(), clonedlist, base_depth).await
scan_url(
resp_clone.to_owned().as_str(),
list_clone,
base_depth,
term_clone,
file_clone,
)
.await
}));
}
scans
@@ -171,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,
) {
@@ -182,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),
) {
@@ -198,7 +292,7 @@ fn create_urls(target_url: &str, word: &str, extensions: &[String]) -> Vec<Url>
///
/// handles 2xx and 3xx responses by either checking if the url ends with a / (2xx)
/// or if the Location header is present and matches the base url + / (3xx)
fn response_is_directory(response: &Response) -> bool {
fn response_is_directory(response: &FeroxResponse) -> bool {
log::trace!("enter: is_directory({:?})", response);
if response.status().is_redirection() {
@@ -235,6 +329,7 @@ fn response_is_directory(response: &Response) -> 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");
@@ -250,10 +345,15 @@ fn response_is_directory(response: &Response) -> bool {
///
/// Essentially looks at the Url path and determines how many directories are present in the
/// given Url
fn reached_max_depth(url: &Url, base_depth: usize) -> bool {
log::trace!("enter: reached_max_depth({}, {})", url, base_depth);
fn reached_max_depth(url: &Url, base_depth: usize, max_depth: usize) -> bool {
log::trace!(
"enter: reached_max_depth({}, {}, {})",
url,
base_depth,
max_depth
);
if CONFIGURATION.depth == 0 {
if max_depth == 0 {
// early return, as 0 means recurse forever; no additional processing needed
log::trace!("exit: reached_max_depth -> false");
return false;
@@ -261,7 +361,7 @@ fn reached_max_depth(url: &Url, base_depth: usize) -> bool {
let depth = get_current_depth(url.as_str());
if depth - base_depth >= CONFIGURATION.depth {
if depth - base_depth >= max_depth {
return true;
}
@@ -273,7 +373,7 @@ fn reached_max_depth(url: &Url, base_depth: usize) -> bool {
///
/// When a recursion opportunity is found, the new url is sent across the recursion channel
async fn try_recursion(
response: &Response,
response: &FeroxResponse,
base_depth: usize,
transmitter: UnboundedSender<String>,
) {
@@ -284,7 +384,9 @@ async fn try_recursion(
transmitter
);
if !reached_max_depth(response.url(), base_depth) && response_is_directory(&response) {
if !reached_max_depth(response.url(), base_depth, CONFIGURATION.depth)
&& response_is_directory(&response)
{
if CONFIGURATION.redirects {
// response is 2xx can simply send it because we're following redirects
log::info!("Added new directory to recursive scan: {}", response.url());
@@ -295,9 +397,8 @@ async fn try_recursion(
}
Err(e) => {
log::error!(
"could not send {} across {:?}: {}",
"Could not send {} to recursion handler: {}",
response.url(),
transmitter,
e
);
}
@@ -311,9 +412,8 @@ async fn try_recursion(
Ok(_) => {}
Err(e) => {
log::error!(
"could not send {}/ across {:?}: {}",
"Could not send {}/ to recursion handler: {}",
response.url(),
transmitter,
e
);
}
@@ -323,6 +423,35 @@ async fn try_recursion(
log::trace!("exit: 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(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 FILTERS.read() {
Ok(filters) => {
for filter in filters.iter() {
// wildcard.should_filter goes here
if filter.should_filter_response(&response) {
return true;
}
}
}
Err(e) => {
log::error!("{}", e);
}
}
false
}
/// Wrapper for [make_request](fn.make_request.html)
///
/// Handles making multiple requests based on the presence of extensions
@@ -332,9 +461,8 @@ async fn make_requests(
target_url: &str,
word: &str,
base_depth: usize,
filter: Arc<WildcardFilter>,
dir_chan: UnboundedSender<String>,
report_chan: UnboundedSender<Response>,
report_chan: UnboundedSender<FeroxResponse>,
) {
log::trace!(
"enter: make_requests({}, {}, {}, {:?}, {:?})",
@@ -349,79 +477,136 @@ async fn make_requests(
for url in urls {
if let Ok(response) = make_request(&CONFIGURATION.client, &url).await {
// response came back without error
// response came back without error, convert it to FeroxResponse
let ferox_response = FeroxResponse::from(response, CONFIGURATION.extract_links).await;
// do recursion if appropriate
if !CONFIGURATION.norecursion && response_is_directory(&response) {
try_recursion(&response, base_depth, dir_chan.clone()).await;
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 = &response.content_length().unwrap_or(0);
if CONFIGURATION.sizefilters.contains(content_len) {
// filtered value from --sizefilters, move on to the next url
log::debug!("size filter: filtered out {}", response.url());
if should_filter_response(&ferox_response) {
continue;
}
if filter.size > 0 && filter.size == *content_len && !CONFIGURATION.dontfilter {
// 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());
continue;
}
if CONFIGURATION.extract_links && !ferox_response.status().is_redirection() {
let new_links = get_links(&ferox_response).await;
if filter.dynamic > 0 && !CONFIGURATION.dontfilter {
// dynamic wildcard offset found during testing
for new_link in new_links {
let unknown = add_url_to_list_of_scanned_urls(&new_link, &SCANNED_URLS);
// 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 !unknown {
// not unknown, i.e. we've seen the url before and don't need to scan again
continue;
}
if url_len + filter.dynamic == *content_len {
log::debug!("dynamic wildcard: filtered out {}", response.url());
continue;
// create a url based on the given command line options, continue on error
let new_url = match format_url(
&new_link,
&"",
CONFIGURATION.add_slash,
&CONFIGURATION.queries,
None,
) {
Ok(url) => url,
Err(_) => continue,
};
// make the request and store the response
let new_response = match make_request(&CONFIGURATION.client, &new_url).await {
Ok(resp) => resp,
Err(_) => continue,
};
let mut new_ferox_response =
FeroxResponse::from(new_response, CONFIGURATION.extract_links).await;
// filter if necessary
if should_filter_response(&new_ferox_response) {
continue;
}
if new_ferox_response.is_file() {
// very likely a file, simply request and report
log::debug!(
"Singular extraction: {} ({})",
new_ferox_response.url(),
new_ferox_response.status().as_str(),
);
send_report(report_chan.clone(), new_ferox_response);
continue;
}
if !CONFIGURATION.no_recursion {
log::debug!(
"Recursive extraction: {} ({})",
new_ferox_response.url(),
new_ferox_response.status().as_str()
);
if new_ferox_response.status().is_success()
&& !new_ferox_response.url().as_str().ends_with('/')
{
// since all of these are 2xx, recursion is only attempted if the
// url ends in a /. I am actually ok with adding the slash and not
// adding it, as both have merit. Leaving it in for now to see how
// things turn out (current as of: v1.1.0)
new_ferox_response.set_url(&format!("{}/", new_ferox_response.url()));
}
try_recursion(&new_ferox_response, base_depth, dir_chan.clone()).await;
}
}
}
// everything else should be reported
match report_chan.send(response) {
Ok(_) => {
log::debug!("sent {}/{} over reporting channel", &target_url, &word);
}
Err(e) => {
log::error!("wtf: {}", e);
}
}
send_report(report_chan.clone(), ferox_response);
}
}
log::trace!("exit: make_requests");
}
/// Simple helper to send a `FeroxResponse` over the tx side of an `mpsc::unbounded_channel`
fn send_report(report_sender: UnboundedSender<FeroxResponse>, response: FeroxResponse) {
log::trace!("enter: send_report({:?}, {:?}", report_sender, response);
match report_sender.send(response) {
Ok(_) => {}
Err(e) => {
log::error!("{}", e);
}
}
log::trace!("exit: send_report");
}
/// Scan a given url using a given wordlist
///
/// This is the primary entrypoint for the scanner
pub async fn scan_url(target_url: &str, wordlist: Arc<HashSet<String>>, base_depth: usize) {
pub async fn scan_url(
target_url: &str,
wordlist: Arc<HashSet<String>>,
base_depth: usize,
tx_term: UnboundedSender<FeroxResponse>,
tx_file: UnboundedSender<String>,
) {
log::trace!(
"enter: scan_url({:?}, wordlist[{} words...], {})",
"enter: scan_url({:?}, wordlist[{} words...], {}, {:?}, {:?})",
target_url,
wordlist.len(),
base_depth
base_depth,
tx_term,
tx_file
);
log::info!("Starting scan against: {}", target_url);
let (tx_rpt, rx_rpt): (UnboundedSender<Response>, UnboundedReceiver<Response>) =
mpsc::unbounded_channel();
let (tx_dir, rx_dir): (UnboundedSender<String>, UnboundedReceiver<String>) =
mpsc::unbounded_channel();
let (tx_dir, rx_dir): FeroxChannel<String> = mpsc::unbounded_channel();
let num_reqs_expected: u64 = if CONFIGURATION.extensions.is_empty() {
wordlist.len().try_into().unwrap()
@@ -434,52 +619,79 @@ pub async fn scan_url(target_url: &str, wordlist: Arc<HashSet<String>>, base_dep
progress_bar.reset_elapsed();
if CALL_COUNT.load(Ordering::Relaxed) == 0 {
// join can only be called once, otherwise it causes the thread to panic
tokio::task::spawn_blocking(move || PROGRESS_BAR.join().unwrap());
CALL_COUNT.fetch_add(1, Ordering::Relaxed);
// this protection 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 reporter = if !CONFIGURATION.output.is_empty() {
// output file defined
tokio::spawn(async move { spawn_file_reporter(rx_rpt).await })
} else {
tokio::spawn(async move { spawn_terminal_reporter(rx_rpt).await })
};
// lifetime satisfiers, as it's an Arc, clones are cheap anyway
let looping_words = wordlist.clone();
let heuristics_file_clone = tx_file.clone();
let recurser_term_clone = tx_term.clone();
let recurser_file_clone = tx_file.clone();
let recurser_words = wordlist.clone();
let looping_words = wordlist.clone();
let recurser =
tokio::spawn(
async move { spawn_recursion_handler(rx_dir, recurser_words, base_depth).await },
);
let recurser = tokio::spawn(async move {
spawn_recursion_handler(
rx_dir,
recurser_words,
base_depth,
recurser_term_clone,
recurser_file_clone,
)
.await
});
let filter = match heuristics::wildcard_test(&target_url, wildcard_bar).await {
Some(f) => {
if CONFIGURATION.dontfilter {
// don't auto filter, i.e. use the defaults
Arc::new(WildcardFilter::default())
} else {
Arc::new(f)
}
}
None => Arc::new(WildcardFilter::default()),
};
// add any wildcard filters to `FILTERS`
let filter =
match heuristics::wildcard_test(&target_url, wildcard_bar, heuristics_file_clone).await {
Some(f) => Box::new(f),
None => Box::new(WildcardFilter::default()),
};
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())
.map(|word| {
let wc_filter = filter.clone();
let txd = tx_dir.clone();
let txr = tx_rpt.clone();
let txr = tx_term.clone();
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, wc_filter, txd, txr).await
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,
)
@@ -487,7 +699,7 @@ pub async fn scan_url(target_url: &str, wordlist: Arc<HashSet<String>>, base_dep
.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);
@@ -500,6 +712,9 @@ pub async fn scan_url(target_url: &str, wordlist: Arc<HashSet<String>>, base_dep
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
@@ -511,18 +726,6 @@ pub async fn scan_url(target_url: &str, wordlist: Arc<HashSet<String>>, base_dep
futures::future::join_all(recurser.await.unwrap()).await;
log::trace!("done awaiting recursive scan receiver/scans");
// same thing here, drop report tx so the rx can finish up
log::trace!("dropped report handler's transmitter");
drop(tx_rpt);
log::trace!("awaiting report receiver");
match reporter.await {
Ok(_) => {}
Err(e) => {
log::error!("error awaiting report receiver: {}", e);
}
}
log::trace!("done awaiting report receiver");
log::trace!("exit: scan_url");
}
@@ -583,4 +786,108 @@ mod tests {
assert_eq!(urls, expected[i]);
}
}
#[test]
/// call reached_max_depth with max depth of zero, which is infinite recursion, expect false
fn reached_max_depth_returns_early_on_zero() {
let url = Url::parse("http://localhost").unwrap();
let result = reached_max_depth(&url, 0, 0);
assert!(!result);
}
#[test]
/// call reached_max_depth with url depth equal to max depth, expect true
fn reached_max_depth_current_depth_equals_max() {
let url = Url::parse("http://localhost/one/two").unwrap();
let result = reached_max_depth(&url, 0, 2);
assert!(result);
}
#[test]
/// call reached_max_depth with url dpeth less than max depth, expect false
fn reached_max_depth_current_depth_less_than_max() {
let url = Url::parse("http://localhost").unwrap();
let result = reached_max_depth(&url, 0, 2);
assert!(!result);
}
#[test]
/// call reached_max_depth with url of 2, base depth of 2, and max depth of 2, expect false
fn reached_max_depth_base_depth_equals_max_depth() {
let url = Url::parse("http://localhost/one/two").unwrap();
let result = reached_max_depth(&url, 2, 2);
assert!(!result);
}
#[test]
/// call reached_max_depth with url depth greater than max depth, expect true
fn reached_max_depth_current_greater_than_max() {
let url = Url::parse("http://localhost/one/two/three").unwrap();
let result = reached_max_depth(&url, 0, 2);
assert!(result);
}
#[test]
/// add an unknown url to the hashset, expect true
fn add_url_to_list_of_scanned_urls_with_unknown_url() {
let urls = RwLock::new(HashSet::<String>::new());
let url = "http://unknown_url";
assert_eq!(add_url_to_list_of_scanned_urls(url, &urls), true);
}
#[test]
/// add a known url to the hashset, with a trailing slash, expect false
fn add_url_to_list_of_scanned_urls_with_known_url() {
let urls = RwLock::new(HashSet::<String>::new());
let url = "http://unknown_url/";
assert_eq!(urls.write().unwrap().insert(url.to_string()), true);
assert_eq!(add_url_to_list_of_scanned_urls(url, &urls), false);
}
#[test]
/// add a known url to the hashset, without a trailing slash, expect false
fn add_url_to_list_of_scanned_urls_with_known_url_without_slash() {
let urls = RwLock::new(HashSet::<String>::new());
let url = "http://unknown_url";
assert_eq!(
urls.write()
.unwrap()
.insert("http://unknown_url/".to_string()),
true
);
assert_eq!(add_url_to_list_of_scanned_urls(url, &urls), false);
}
#[test]
/// 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());
}
#[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

@@ -1,8 +1,10 @@
use crate::FeroxResult;
use crate::{FeroxError, FeroxResult};
use console::{strip_ansi_codes, style, user_attended};
use indicatif::ProgressBar;
use reqwest::Url;
use reqwest::{Client, Response};
#[cfg(not(target_os = "windows"))]
use rlimit::{getrlimit, setrlimit, Resource, Rlim};
use std::convert::TryInto;
/// Helper function that determines the current depth of a given url
@@ -140,7 +142,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,11 +150,32 @@ pub fn format_url(
"enter: format_url({}, {}, {}, {:?} {:?})",
url,
word,
addslash,
add_slash,
queries,
extension
);
if Url::parse(&word).is_ok() {
// when a full url is passed in as a word to be joined to a base url using
// reqwest::Url::join, the result is that the word (url) completely overwrites the base
// url, potentially resulting in requests to places that aren't actually the target
// specified.
//
// in order to resolve the issue, we check if the word from the wordlist is a parsable URL
// and if so, don't do any further processing
let message = format!(
"word ({}) from the wordlist is actually a URL, skipping...",
word
);
log::warn!("{}", message);
let mut err = FeroxError::default();
err.message = message;
log::trace!("exit: format_url -> {}", err);
return Err(Box::new(err));
}
// from reqwest::Url::join
// Note: a trailing slash is significant. Without it, the last path component
// is considered to be a “file” name to be removed to get at the “directory”
@@ -160,7 +183,11 @@ pub fn format_url(
//
// the transforms that occur here will need to keep this in mind, i.e. add a slash to preserve
// the current directory sent as part of the url
let url = if !url.ends_with('/') {
let url = if word.is_empty() {
// v1.0.6: added during --extract-links feature inplementation to support creating urls
// that were extracted from response bodies, i.e. http://localhost/some/path/js/main.js
url.to_string()
} else if !url.ends_with('/') {
format!("{}/", url)
} else {
url.to_string()
@@ -171,7 +198,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 {
@@ -234,10 +261,89 @@ pub async fn make_request(client: &Client, url: &Url) -> FeroxResult<Response> {
}
}
/// Attempts to set the soft limit for the RLIMIT_NOFILE resource
///
/// RLIMIT_NOFILE is the maximum number of file descriptors that can be opened by this process
///
/// The soft limit is the value that the kernel enforces for the corresponding resource.
/// The hard limit acts as a ceiling for the soft limit: an unprivileged process may set only its
/// soft limit to a value in the range from 0 up to the hard limit, and (irreversibly) lower its
/// hard limit.
///
/// A child process created via fork(2) inherits its parent's resource limits. Resource limits are
/// per-process attributes that are shared by all of the threads in a process.
///
/// Based on the above information, no attempt is made to restore the limit to its pre-scan value
/// as the adjustment made here is only valid for the scan itself (and any child processes, of which
/// there are none).
#[cfg(not(target_os = "windows"))]
pub fn set_open_file_limit(limit: usize) -> bool {
log::trace!("enter: set_open_file_limit");
if let Ok((soft, hard)) = getrlimit(Resource::NOFILE) {
if hard.as_usize() > limit {
// our default open file limit is less than the current hard limit, this means we can
// set the soft limit to our default
let new_soft_limit = Rlim::from_usize(limit);
if setrlimit(Resource::NOFILE, new_soft_limit, hard).is_ok() {
log::debug!("set open file descriptor limit to {}", limit);
log::trace!("exit: set_open_file_limit -> {}", true);
return true;
}
} else if soft != hard {
// hard limit is lower than our default, the next best option is to set the soft limit as
// high as the hard limit will allow
if setrlimit(Resource::NOFILE, hard, hard).is_ok() {
log::debug!("set open file descriptor limit to {}", limit);
log::trace!("exit: set_open_file_limit -> {}", true);
return true;
}
}
}
// failed to set a new limit, as limit adjustments are a 'nice to have', we'll just log
// and move along
log::warn!("could not set open file descriptor limit to {}", limit);
log::trace!("exit: set_open_file_limit -> {}", false);
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// set_open_file_limit with a low requested limit succeeds
fn utils_set_open_file_limit_with_low_requested_limit() {
let (_, hard) = getrlimit(Resource::NOFILE).unwrap();
let lower_limit = hard.as_usize() - 1;
assert!(set_open_file_limit(lower_limit));
}
#[test]
/// set_open_file_limit with a high requested limit succeeds
fn utils_set_open_file_limit_with_high_requested_limit() {
let (_, hard) = getrlimit(Resource::NOFILE).unwrap();
let higher_limit = hard.as_usize() + 1;
// calculate a new soft to ensure soft != hard and hit that logic branch
let new_soft = Rlim::from_usize(hard.as_usize() - 1);
setrlimit(Resource::NOFILE, new_soft, hard).unwrap();
assert!(set_open_file_limit(higher_limit));
}
#[test]
/// set_open_file_limit should fail when hard == soft
fn utils_set_open_file_limit_with_fails_when_both_limits_are_equal() {
let (_, hard) = getrlimit(Resource::NOFILE).unwrap();
// calculate a new soft to ensure soft == hard and hit the failure logic branch
setrlimit(Resource::NOFILE, hard, hard).unwrap();
assert!(!set_open_file_limit(hard.as_usize())); // returns false
}
#[test]
/// base url returns 1
fn get_current_depth_base_url_returns_1() {
@@ -348,6 +454,19 @@ mod tests {
);
}
#[test]
/// word that is a fully formed url, should return an error
fn format_url_word_that_is_a_url() {
let url = format_url(
"http://localhost",
"http://schmocalhost",
false,
&Vec::new(),
None,
);
assert!(url.is_err());
}
#[test]
/// status colorizer uses red for 500s
fn status_colorizer_uses_red_for_500s() {

View File

@@ -11,7 +11,7 @@ fn banner_prints_proxy() -> Result<(), Box<dyn std::error::Error>> {
String::from("http://localhost"),
String::from("http://schmocalhost"),
];
let (tmp_dir, file) = setup_tmp_directory(&urls)?;
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
Command::cargo_bin("feroxbuster")
.unwrap()
@@ -23,7 +23,7 @@ fn banner_prints_proxy() -> Result<(), Box<dyn std::error::Error>> {
.pipe_stdin(file)
.unwrap()
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -43,6 +43,46 @@ fn banner_prints_proxy() -> 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 + replay proxy
fn banner_prints_replay_proxy() -> Result<(), Box<dyn std::error::Error>> {
let urls = vec![
String::from("http://localhost"),
String::from("http://schmocalhost"),
];
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--stdin")
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--replay-proxy")
.arg("http://127.0.0.1:8081")
.pipe_stdin(file)
.unwrap()
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("http://schmocalhost"))
.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("Replay Proxy"))
.and(predicate::str::contains("http://127.0.0.1:8081"))
.and(predicate::str::contains("─┴─")),
);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + multiple headers
@@ -56,7 +96,7 @@ fn banner_prints_headers() -> Result<(), Box<dyn std::error::Error>> {
.arg("-H")
.arg("mostuff:mothings")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -77,17 +117,17 @@ 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()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -118,7 +158,7 @@ fn banner_prints_queries() -> Result<(), Box<dyn std::error::Error>> {
.arg("--query")
.arg("stuff=things")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -147,7 +187,7 @@ fn banner_prints_status_codes() -> Result<(), Box<dyn std::error::Error>> {
.arg("-s")
.arg("201,301,401")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -163,6 +203,37 @@ fn banner_prints_status_codes() -> 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 + replay codes
fn banner_prints_replay_codes() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--replay-codes")
.arg("200,302")
.arg("--replay-proxy")
.arg("http://localhost:8081")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Replay Proxy"))
.and(predicate::str::contains("http://localhost:8081"))
.and(predicate::str::contains("Replay Proxy Codes"))
.and(predicate::str::contains("[200, 302]"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + output file
@@ -174,7 +245,7 @@ fn banner_prints_output_file() -> Result<(), Box<dyn std::error::Error>> {
.arg("--output")
.arg("/super/cool/path")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -201,7 +272,7 @@ fn banner_prints_insecure() -> Result<(), Box<dyn std::error::Error>> {
.arg("http://localhost")
.arg("-k")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -228,7 +299,7 @@ fn banner_prints_redirects() -> Result<(), Box<dyn std::error::Error>> {
.arg("http://localhost")
.arg("-r")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -258,7 +329,7 @@ fn banner_prints_extensions() -> Result<(), Box<dyn std::error::Error>> {
.arg("--extensions")
.arg("pdf")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -277,15 +348,15 @@ 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()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -312,7 +383,7 @@ fn banner_prints_verbosity_one() -> Result<(), Box<dyn std::error::Error>> {
.arg("http://localhost")
.arg("-v")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -339,7 +410,7 @@ fn banner_prints_verbosity_two() -> Result<(), Box<dyn std::error::Error>> {
.arg("http://localhost")
.arg("-vv")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -366,7 +437,7 @@ fn banner_prints_verbosity_three() -> Result<(), Box<dyn std::error::Error>> {
.arg("http://localhost")
.arg("-vvv")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -393,7 +464,7 @@ fn banner_prints_verbosity_four() -> Result<(), Box<dyn std::error::Error>> {
.arg("http://localhost")
.arg("-vvvv")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -420,7 +491,7 @@ fn banner_prints_add_slash() -> Result<(), Box<dyn std::error::Error>> {
.arg("http://localhost")
.arg("-f")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -448,7 +519,7 @@ fn banner_prints_infinite_depth() -> Result<(), Box<dyn std::error::Error>> {
.arg("--depth")
.arg("0")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -476,7 +547,7 @@ fn banner_prints_recursion_depth() -> Result<(), Box<dyn std::error::Error>> {
.arg("--depth")
.arg("343214")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -503,7 +574,7 @@ fn banner_prints_no_recursion() -> Result<(), Box<dyn std::error::Error>> {
.arg("http://localhost")
.arg("-n")
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
@@ -522,7 +593,7 @@ fn banner_prints_no_recursion() -> Result<(), Box<dyn std::error::Error>> {
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see only the error of could not connect
/// expect to see nothing
fn banner_doesnt_print() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
@@ -530,9 +601,90 @@ fn banner_doesnt_print() -> Result<(), Box<dyn std::error::Error>> {
.arg("http://localhost")
.arg("-q")
.assert()
.failure()
.stderr(predicate::str::contains(
"ERROR heuristics::connectivity_test Could not connect to any target provided",
));
.success()
.stderr(predicate::str::is_empty());
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + extract-links
fn banner_prints_extract_links() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-e")
.assert()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Extract Links"))
.and(predicate::str::contains("true"))
.and(predicate::str::contains("─┴─")),
);
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()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("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()
.success()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Status Code Filters"))
.and(predicate::str::contains("│ [200]"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}

27
tests/test_config.rs Normal file
View File

@@ -0,0 +1,27 @@
mod utils;
use assert_cmd::prelude::*;
use predicates::prelude::*;
use std::process::Command;
use utils::{setup_tmp_directory, teardown_tmp_directory};
#[test]
/// send a single valid request, expect a 200 response
fn read_in_config_file_for_settings() -> Result<(), Box<dyn std::error::Error>> {
let (tmp_dir, file) = setup_tmp_directory(&["threads = 37".to_string()], "ferox-config.toml")?;
Command::cargo_bin("feroxbuster")
.unwrap()
.current_dir(&tmp_dir)
.arg("--url")
.arg("http://localhost")
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.assert()
.success()
.stderr(predicate::str::contains("│ 37"));
teardown_tmp_directory(tmp_dir);
Ok(())
}

229
tests/test_extractor.rs Normal file
View File

@@ -0,0 +1,229 @@
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]
/// send a request to a page that contains a relative link, --extract-links should find the link
/// and make a request to the new link
fn extractor_finds_absolute_url() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body(&srv.url("'/homepage/assets/img/icons/handshake.svg'"))
.create_on(&srv);
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/homepage/assets/img/icons/handshake.svg")
.return_status(200)
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--extract-links")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains(
"/homepage/assets/img/icons/handshake.svg",
)),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send a request to a page that contains an absolute link to another domain, scanner should not
/// follow
fn extractor_finds_absolute_url_to_different_domain() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("\"http://localhost/homepage/assets/img/icons/handshake.svg\"")
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--extract-links")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains(
"/homepage/assets/img/icons/handshake.svg",
))
.not(),
);
assert_eq!(mock.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send a request to a page that contains a relative link, should follow
fn extractor_finds_relative_url() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("\"/homepage/assets/img/icons/handshake.svg\"")
.create_on(&srv);
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/homepage/assets/img/icons/handshake.svg")
.return_status(200)
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--extract-links")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains(
"/homepage/assets/img/icons/handshake.svg",
)),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send a request to a page that contains an relative link, follow it, and find the same link again
/// should follow then filter
fn extractor_finds_same_relative_url_twice() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "README".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body(&srv.url("\"/homepage/assets/img/icons/handshake.svg\""))
.create_on(&srv);
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/README")
.return_body(&srv.url("\"/homepage/assets/img/icons/handshake.svg\""))
.return_status(200)
.create_on(&srv);
let mock_three = Mock::new()
.expect_method(GET)
.expect_path("/homepage/assets/img/icons/handshake.svg")
.return_status(200)
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--extract-links")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains(
"/homepage/assets/img/icons/handshake.svg",
)),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
assert_eq!(mock_three.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// 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();
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "README".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body(&srv.url("\"/homepage/assets/img/icons/handshake.svg\""))
.create_on(&srv);
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/homepage/assets/img/icons/handshake.svg")
.return_body("im a little teapot")
.return_status(200)
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--extract-links")
.arg("--filter-size")
.arg("18")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains(
"/homepage/assets/img/icons/handshake.svg",
))
.not(),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}

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

@@ -10,7 +10,7 @@ use utils::{setup_tmp_directory, teardown_tmp_directory};
/// test passes one bad target via -u to the scanner, expected result is that the
/// scanner dies
fn test_single_target_cannot_connect() -> Result<(), Box<dyn std::error::Error>> {
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()])?;
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
Command::cargo_bin("feroxbuster")
.unwrap()
@@ -19,11 +19,9 @@ fn test_single_target_cannot_connect() -> Result<(), Box<dyn std::error::Error>>
.arg("--wordlist")
.arg(file.as_os_str())
.assert()
.failure()
.stderr(
predicate::str::contains("Could not connect to any target provided")
.and(predicate::str::contains("ERROR"))
.and(predicate::str::contains("heuristics::connectivity_test")),
.success()
.stdout(
predicate::str::contains("Could not connect to http://fjdksafjkdsajfkdsajkfdsajkfsdjkdsfdsafdsafdsajkr3l2ajfdskafdsjk, skipping...", )
);
teardown_tmp_directory(tmp_dir);
@@ -37,7 +35,7 @@ fn test_two_targets_cannot_connect() -> Result<(), Box<dyn std::error::Error>> {
let not_real =
String::from("http://fjdksafjkdsajfkdsajkfdsajkfsdjkdsfdsafdsafdsajkr3l2ajfdskafdsjk");
let urls = vec![not_real.clone(), not_real];
let (tmp_dir, file) = setup_tmp_directory(&urls)?;
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
Command::cargo_bin("feroxbuster")
.unwrap()
@@ -47,11 +45,9 @@ fn test_two_targets_cannot_connect() -> Result<(), Box<dyn std::error::Error>> {
.pipe_stdin(file)
.unwrap()
.assert()
.failure()
.stderr(
predicate::str::contains("Could not connect to any target provided")
.and(predicate::str::contains("ERROR"))
.and(predicate::str::contains("heuristics::connectivity_test")),
.success()
.stdout(
predicate::str::contains("Could not connect to http://fjdksafjkdsajfkdsajkfdsajkfsdjkdsfdsafdsafdsajkr3l2ajfdskafdsjk, skipping...", )
);
teardown_tmp_directory(tmp_dir);
@@ -67,7 +63,7 @@ fn test_one_good_and_one_bad_target_scan_succeeds() -> Result<(), Box<dyn std::e
let not_real =
String::from("http://fjdksafjkdsajfkdsajkfdsajkfsdjkdsfdsafdsafdsajkr3l2ajfdskafdsjk");
let urls = vec![not_real, srv.url("/"), String::from("LICENSE")];
let (tmp_dir, file) = setup_tmp_directory(&urls)?;
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
@@ -100,7 +96,7 @@ fn test_one_good_and_one_bad_target_scan_succeeds() -> Result<(), Box<dyn std::e
/// test finds a static wildcard and reports as much to stdout
fn test_static_wildcard_request_found() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()])?;
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
@@ -115,7 +111,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);
@@ -132,10 +128,11 @@ fn test_static_wildcard_request_found() -> Result<(), Box<dyn std::error::Error>
}
#[test]
/// test finds a dynamic wildcard and reports as much to stdout
/// test finds a dynamic wildcard and reports as much to stdout and a file
fn test_dynamic_wildcard_request_found() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()])?;
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let outfile = tmp_dir.path().join("outfile");
let mock = Mock::new()
.expect_method(GET)
@@ -157,11 +154,27 @@ 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();
let contents = std::fs::read_to_string(outfile).unwrap();
teardown_tmp_directory(tmp_dir);
assert_eq!(contents.contains("WLD"), true);
assert_eq!(contents.contains("Got"), true);
assert_eq!(contents.contains("200"), true);
assert_eq!(contents.contains("auto-filtering"), true);
assert_eq!(contents.contains("(url length: 32)"), true);
assert_eq!(contents.contains("(url length: 96)"), true);
assert_eq!(contents.contains("Wildcard response is dynamic"), true);
assert_eq!(
contents.contains("(14 + url length) responses; toggle this behavior by using"),
true
);
cmd.assert().success().stdout(
predicate::str::contains("WLD")
.and(predicate::str::contains("Got"))
@@ -179,3 +192,242 @@ fn test_dynamic_wildcard_request_found() -> Result<(), Box<dyn std::error::Error
assert_eq!(mock2.times_called(), 1);
Ok(())
}
#[test]
/// 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")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--dont-filter")
.unwrap();
teardown_tmp_directory(tmp_dir);
assert_eq!(mock.times_called(), 0);
Ok(())
}
#[test]
/// test finds a static wildcard and reports as much to stdout
fn heuristics_wildcard_test_with_two_static_wildcards() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock2 = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--add-slash")
.unwrap();
teardown_tmp_directory(tmp_dir);
cmd.assert().success().stdout(
predicate::str::contains("WLD")
.and(predicate::str::contains("Got"))
.and(predicate::str::contains("200"))
.and(predicate::str::contains("(url length: 32)"))
.and(predicate::str::contains("(url length: 96)"))
.and(predicate::str::contains(
"Wildcard response is static; auto-filtering 46",
)),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock2.times_called(), 1);
Ok(())
}
#[test]
/// test finds a static wildcard and reports nothing to stdout
fn heuristics_wildcard_test_with_two_static_wildcards_with_quiet_enabled(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock2 = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--add-slash")
.arg("-q")
.unwrap();
teardown_tmp_directory(tmp_dir);
cmd.assert().success().stdout(predicate::str::is_empty());
assert_eq!(mock.times_called(), 1);
assert_eq!(mock2.times_called(), 1);
Ok(())
}
#[test]
/// test finds a static wildcard and reports as much to stdout and a file
fn heuristics_wildcard_test_with_two_static_wildcards_and_output_to_file(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let outfile = tmp_dir.path().join("outfile");
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock2 = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap())
.return_status(200)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--add-slash")
.arg("--output")
.arg(outfile.as_os_str())
.unwrap();
let contents = std::fs::read_to_string(outfile).unwrap();
teardown_tmp_directory(tmp_dir);
assert_eq!(contents.contains("WLD"), true);
assert_eq!(contents.contains("Got"), true);
assert_eq!(contents.contains("200"), true);
assert_eq!(contents.contains("(url length: 32)"), true);
assert_eq!(contents.contains("(url length: 96)"), true);
assert_eq!(
contents.contains("Wildcard response is static; auto-filtering 46"),
true
);
cmd.assert().success().stdout(
predicate::str::contains("WLD")
.and(predicate::str::contains("Got"))
.and(predicate::str::contains("200"))
.and(predicate::str::contains("(url length: 32)"))
.and(predicate::str::contains("(url length: 96)"))
.and(predicate::str::contains(
"Wildcard response is static; auto-filtering 46",
)),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock2.times_called(), 1);
Ok(())
}
#[test]
/// test finds a static wildcard that returns 3xx, expect redirects to => in response as well as
/// in the output file
fn heuristics_wildcard_test_with_redirect_as_response_code(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let outfile = tmp_dir.path().join("outfile");
let mock = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{32}/").unwrap())
.return_status(301)
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let mock2 = Mock::new()
.expect_method(GET)
.expect_path_matches(Regex::new("/[a-zA-Z0-9]{96}/").unwrap())
.return_status(301)
.return_header("Location", &srv.url("/some-redirect"))
.return_body("this is a testAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--add-slash")
.arg("--output")
.arg(outfile.as_os_str())
.unwrap();
let contents = std::fs::read_to_string(outfile).unwrap();
teardown_tmp_directory(tmp_dir);
assert_eq!(contents.contains("WLD"), true);
assert_eq!(contents.contains("301"), true);
assert_eq!(contents.contains("/some-redirect"), true);
assert_eq!(contents.contains("redirects to => "), true);
assert_eq!(contents.contains(&srv.url("/")), true);
assert_eq!(contents.contains("(url length: 32)"), true);
cmd.assert().success().stdout(
predicate::str::contains("redirects to => ")
.and(predicate::str::contains("/some-redirect"))
.and(predicate::str::contains("301"))
.and(predicate::str::contains(srv.url("/")))
.and(predicate::str::contains("(url length: 32)"))
.and(predicate::str::contains("WLD")),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(mock2.times_called(), 1);
Ok(())
}

View File

@@ -25,10 +25,8 @@ fn main_use_root_owned_file_as_wordlist() -> Result<(), Box<dyn std::error::Erro
.arg("/etc/shadow")
.arg("-vvvv")
.assert()
.success()
.stderr(predicate::str::contains(
"ERROR main::get_unique_words_from_wordlist Permission denied (os error 13)",
));
.failure()
.stdout(predicate::str::contains("Permission denied (os error 13)"));
// connectivity test hits it once
assert_eq!(mock.times_called(), 1);
@@ -39,7 +37,7 @@ fn main_use_root_owned_file_as_wordlist() -> Result<(), Box<dyn std::error::Erro
/// send the function an empty file
fn main_use_empty_wordlist() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&[])?;
let (tmp_dir, file) = setup_tmp_directory(&[], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
@@ -57,9 +55,7 @@ fn main_use_empty_wordlist() -> Result<(), Box<dyn std::error::Error>> {
.arg("-vvvv")
.assert()
.failure()
.stderr(predicate::str::contains(
"ERROR main::scan Did not find any words in",
));
.stdout(predicate::str::contains("Did not find any words in"));
assert_eq!(mock.times_called(), 1);
@@ -70,7 +66,7 @@ fn main_use_empty_wordlist() -> Result<(), Box<dyn std::error::Error>> {
#[test]
/// send nothing over stdin, expect heuristics to be upset during connectivity test
fn main_use_empty_stdin_targets() -> Result<(), Box<dyn std::error::Error>> {
let (tmp_dir, file) = setup_tmp_directory(&[])?;
let (tmp_dir, file) = setup_tmp_directory(&[], "wordlist")?;
// get_targets is called before scan, so the empty wordlist shouldn't trigger
// the 'Did not find any words' error
@@ -83,11 +79,9 @@ fn main_use_empty_stdin_targets() -> Result<(), Box<dyn std::error::Error>> {
.pipe_stdin(file)
.unwrap()
.assert()
.failure()
.success()
.stderr(
predicate::str::contains("Could not connect to any target provided")
.and(predicate::str::contains("ERROR"))
.and(predicate::str::contains("heuristics::connectivity_test"))
.and(predicate::str::contains("Target Url"))
.not(), // no target url found
);

View File

@@ -8,9 +8,9 @@ use utils::{setup_tmp_directory, teardown_tmp_directory};
#[test]
/// send a single valid request, expect a 200 response
fn test_single_request_scan() -> Result<(), Box<dyn std::error::Error>> {
fn scanner_single_request_scan() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()])?;
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
@@ -49,7 +49,7 @@ fn scanner_recursive_request_scan() -> Result<(), Box<dyn std::error::Error>> {
"dev".to_string(),
"file.js".to_string(),
];
let (tmp_dir, file) = setup_tmp_directory(&urls)?;
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
let js_mock = Mock::new()
.expect_method(GET)
@@ -107,3 +107,356 @@ fn scanner_recursive_request_scan() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
#[test]
/// send a valid request, follow 200s into new directories, expect 200 responses
fn scanner_recursive_request_scan_using_only_success_responses(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let urls = [
"js/".to_string(),
"prod/".to_string(),
"dev/".to_string(),
"file.js".to_string(),
];
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
let js_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/")
.return_status(200)
.return_header("Location", &srv.url("/js/"))
.create_on(&srv);
let js_prod_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/prod/")
.return_status(200)
.return_header("Location", &srv.url("/js/prod/"))
.create_on(&srv);
let js_dev_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/dev/")
.return_status(200)
.return_header("Location", &srv.url("/js/dev/"))
.create_on(&srv);
let js_dev_file_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/dev/file.js")
.return_status(200)
.return_body("this is a test and is more bytes than other ones")
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("-t")
.arg("1")
.arg("--redirects")
.unwrap();
cmd.assert().success().stdout(
predicate::str::is_match("200.*js")
.unwrap()
.and(predicate::str::is_match("200.*js/prod").unwrap())
.and(predicate::str::is_match("200.*js/dev").unwrap())
.and(predicate::str::is_match("200.*js/dev/file.js").unwrap()),
);
assert_eq!(js_mock.times_called(), 1);
assert_eq!(js_prod_mock.times_called(), 1);
assert_eq!(js_dev_mock.times_called(), 1);
assert_eq!(js_dev_file_mock.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send a single valid request, get a response, and write it to disk
fn scanner_single_request_scan_with_file_output() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let outfile = tmp_dir.path().join("output");
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("-o")
.arg(outfile.as_os_str())
.unwrap();
let contents = std::fs::read_to_string(outfile)?;
assert!(contents.contains("/LICENSE"));
assert!(contents.contains("200"));
assert!(contents.contains("14"));
assert_eq!(mock.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send a single valid request with -q, get a response, and write only the url to disk
fn scanner_single_request_scan_with_file_output_and_tack_q(
) -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let outfile = tmp_dir.path().join("output");
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("-q")
.arg("-o")
.arg(outfile.as_os_str())
.unwrap();
let contents = std::fs::read_to_string(outfile)?;
let url = srv.url("/LICENSE");
assert!(contents.contains(&url));
assert_eq!(mock.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send an invalid output file, expect nothing to be written to disk
fn scanner_single_request_scan_with_invalid_file_output() -> Result<(), Box<dyn std::error::Error>>
{
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let outfile = tmp_dir.path(); // outfile is a directory
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.arg("-q")
.arg("-o")
.arg(outfile.as_os_str())
.unwrap();
let contents = std::fs::read_to_string(outfile);
assert!(contents.is_err());
assert_eq!(mock.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send a single valid request using -q, expect only the url on stdout
fn scanner_single_request_quiet_scan() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-x")
.arg("js,html")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains(srv.url("/LICENSE"))
.and(predicate::str::contains("200"))
.not()
.and(predicate::str::contains("14"))
.not(),
);
assert_eq!(mock.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// 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();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_body("this is a test")
.return_status(301)
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--timeout")
.arg("5")
.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")),
);
assert_eq!(mock.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send a single valid request, filter the size of the response, expect one out of 2 urls
fn scanner_single_request_scan_with_filtered_result() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) =
setup_tmp_directory(&["LICENSE".to_string(), "ignored".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a not a test")
.create_on(&srv);
let filtered_mock = Mock::new()
.expect_method(GET)
.expect_path("/ignored")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-n")
.arg("-S")
.arg("14")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains("20"))
.and(predicate::str::contains("ignored"))
.not()
.and(predicate::str::contains(" 14 "))
.not(),
);
assert_eq!(mock.times_called(), 1);
assert_eq!(filtered_mock.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send a single valid request, expect a 200 response that then gets routed to the replay
/// proxy
fn scanner_single_request_replayed_to_proxy() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let proxy = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
let mock_two = Mock::new()
.expect_method(GET)
.expect_path("/LICENSE")
.return_status(200)
.return_body("this is a test")
.create_on(&proxy);
let cmd = Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--replay-proxy")
.arg(format!("http://{}", proxy.address().to_string()))
.arg("--replay-codes")
.arg("200")
.unwrap();
cmd.assert()
.success()
.stdout(
predicate::str::contains("/LICENSE")
.and(predicate::str::contains("200"))
.and(predicate::str::contains("14")),
)
.stderr(predicate::str::contains("Replay Proxy Codes"));
assert_eq!(mock.times_called(), 1);
assert_eq!(mock_two.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}

View File

@@ -3,12 +3,13 @@ use std::path::PathBuf;
use tempfile::TempDir;
/// integration test helper: creates a temp directory, and writes `words` to
/// a file named `wordlist` in the temp directory
/// a file named `filename` in the temp directory
pub fn setup_tmp_directory(
words: &[String],
filename: &str,
) -> Result<(TempDir, PathBuf), Box<dyn std::error::Error>> {
let tmp_dir = TempDir::new()?;
let file = tmp_dir.path().join("wordlist");
let file = tmp_dir.path().join(&filename);
write(&file, words.join("\n"))?;
Ok((tmp_dir, file))
}