Compare commits

...

123 Commits

Author SHA1 Message Date
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
epi
57d5ea1e01 version bumped to v1.0.2 2020-10-07 17:13:52 -05:00
epi
4b4af5a303 Merge pull request #62 from epi052/61-change-url-error-to-warning
timeouts logged as warnings, other errors remain the same
2020-10-07 17:13:07 -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
4279ac372c timeouts now logged as warnings, other errors remain the same 2020-10-06 17:13:28 -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
epi
1f66d17516 Update README.md 2020-10-06 06:07:11 -05:00
epi
bf2f9431c7 Update README.md 2020-10-06 06:06:32 -05:00
epi
859069359a Update README.md 2020-10-06 06:05:32 -05:00
epi
c370dcc172 Merge pull request #55 from epi052/jsav-docker-for-ferox
@jsav0 added Dockerfile based on alpine. Includes wordlists.
2020-10-05 20:50:04 -05:00
epi
30ce6a3171 added docker install section 2020-10-05 20:42:16 -05:00
epi
951bd87c0e updated url to point to latest instead of 1.0.0 2020-10-05 20:24:39 -05:00
epi
7c036e587e fixed possible thread panic due to multiple calls to join 2020-10-05 18:52:22 -05:00
epi
b733477a61 Update README.md 2020-10-05 16:18:40 -05:00
epi
58e367b5c3 Update README.md 2020-10-05 14:47:56 -05:00
epi
99021db091 Update README.md 2020-10-05 14:47:18 -05:00
epi
7f145f11df Update README.md 2020-10-05 14:46:13 -05:00
epi
68ee5883b8 Update README.md 2020-10-05 14:45:11 -05:00
jsavage
1a2c08393d added Dockerfile based on alpine. Includes wordlists 2020-10-05 08:53:43 -04:00
epi
9b929fdb15 Merge pull request #53 from joohoi/ffuf_corrections
[Documentation] README.md matrix fixes for ffuf
2020-10-05 06:23:48 -05:00
Joona Hoikkala
a87dc64e8e ffuf corrections for the README.md matrix 2020-10-05 10:51:08 +03:00
epi
70918582e5 version bump to 1.0.0 🥳 2020-10-04 10:10:00 -05:00
epi
b445198b67 added missing docstrings to a few tests 2020-10-04 09:57:35 -05:00
epi
97b5bcdde6 removed logger init from scanner tests 2020-10-04 09:53:12 -05:00
epi
e15f6e9bd2 added recursive scan test 2020-10-04 09:01:01 -05:00
epi
e74678edc3 increased test coverage for main 2020-10-04 07:04:46 -05:00
epi
40cce2ee37 added codecov to CI pipeline 2020-10-04 06:22:59 -05:00
epi
e980cee570 added codecov to CI pipeline 2020-10-04 06:18:51 -05:00
epi
73bd7c1514 added codecov to CI pipeline 2020-10-04 06:14:25 -05:00
epi
a2728e1df0 began integration tests on main 2020-10-03 21:09:37 -05:00
epi
95dec44766 added tests in lib.rs 2020-10-03 20:44:44 -05:00
epi
c31cfe8673 more banner tests for coverage 2020-10-03 20:36:25 -05:00
epi
aaa7412bb1 added multiple status codes/targets test for banner 2020-10-03 19:35:18 -05:00
epi
cdbd0030dd updated reame 2020-10-03 19:21:42 -05:00
epi
e144caddc0 updated reame 2020-10-03 19:19:44 -05:00
epi
61c4b6d523 updated reame 2020-10-03 19:18:49 -05:00
epi
a70c9d9413 updated reame 2020-10-03 19:16:45 -05:00
epi
098584c945 updated reame 2020-10-03 19:14:47 -05:00
epi
11f32ea8c6 integration tests for banner complete 2020-10-03 19:08:18 -05:00
epi
afcfa4849c fixed 2 heuristics tests 2020-10-03 16:25:49 -05:00
epi
28d6c7dd97 increased code coverage for utils 2020-10-03 16:03:23 -05:00
epi
b538aad7d5 clippy/lint/format 2020-10-03 13:12:18 -05:00
epi
d3561a5823 added unit tests for create_urls; closes #35 2020-10-03 12:04:31 -05:00
epi
f23e4a5ed1 removed dependency on ansi_term; closes #52 2020-10-03 11:34:56 -05:00
epi
dd305bfa65 readme update 2020-10-03 10:21:24 -05:00
28 changed files with 3690 additions and 528 deletions

8
.github/actions-rs/grcov.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
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

@@ -1,69 +1,8 @@
name: CI Pipeline
name: CD Pipeline
on: [push]
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- uses: actions-rs/cargo@v1
with:
command: check
test:
name: Test Suite
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- uses: actions-rs/cargo@v1
with:
command: test
fmt:
name: Rust fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- run: rustup component add rustfmt
- uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- run: rustup component add clippy
- uses: actions-rs/cargo@v1
with:
command: clippy
args: --all-targets --all-features -- -D warnings -A clippy::unnecessary_unwrap
build-nix:
runs-on: ${{ matrix.os }}
if: github.ref == 'refs/heads/master'
@@ -102,10 +41,19 @@ jobs:
use-cross: true
command: build
args: --release --target=${{ matrix.target }}
- 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]
@@ -120,18 +68,40 @@ 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: 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
@@ -158,3 +128,4 @@ jobs:
with:
name: ${{ matrix.name }}
path: ${{ matrix.path }}

64
.github/workflows/check.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: CI Pipeline
on: [push]
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- uses: actions-rs/cargo@v1
with:
command: check
test:
name: Test Suite
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- uses: actions-rs/cargo@v1
with:
command: test
fmt:
name: Rust fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- run: rustup component add rustfmt
- uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- run: rustup component add clippy
- uses: actions-rs/cargo@v1
with:
command: clippy
args: --all-targets --all-features -- -D warnings -A clippy::unnecessary_unwrap

36
.github/workflows/coverage.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
on: [push]
name: Code Coverage Pipeline
jobs:
upload-coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly
override: true
- uses: actions-rs/cargo@v1
with:
command: clean
- uses: actions-rs/cargo@v1
with:
command: test
args: --all-features --no-fail-fast
env:
CARGO_INCREMENTAL: '0'
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: |
curl -O https://raw.githubusercontent.com/eriwen/lcov-to-cobertura-xml/master/lcov_cobertura/lcov_cobertura.py
chmod +x lcov_cobertura.py
./lcov_cobertura.py ./lcov.info
- uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.xml
name: codecov-umbrella
fail_ci_if_error: true

3
.gitignore vendored
View File

@@ -19,5 +19,6 @@ ferox-config.toml
# images for the README on github
img/**
# personal script to check code coverage using nightly compiler
# scripts to check code coverage using nightly compiler
check-coverage.sh
lcov_cobertura.py

View File

@@ -1,6 +1,6 @@
[package]
name = "feroxbuster"
version = "0.2.1"
version = "1.1.0"
authors = ["Ben 'epi' Risher <epibar052@gmail.com>"]
license = "MIT"
edition = "2018"
@@ -26,11 +26,11 @@ lazy_static = "1.4"
toml = "0.5"
serde = { version = "1.0", features = ["derive"] }
uuid = { version = "0.8", features = ["v4"] }
ansi_term = "0.12"
indicatif = "0.15"
console = "0.12"
openssl = { version = "0.10", features = ["vendored"] }
dirs = "3.0"
regex = "1"
[dev-dependencies]
tempfile = "3.1"
@@ -50,4 +50,4 @@ conf-files = ["/etc/feroxbuster/ferox-config.toml"]
assets = [
["target/release/feroxbuster", "/usr/bin/", "755"],
["ferox-config.toml.example", "/etc/feroxbuster/ferox-config.toml", "644"],
]
]

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM alpine:latest
LABEL maintainer="wfnintr@null.net"
# download default wordlists
RUN apk add --no-cache --virtual .depends subversion && \
svn export https://github.com/danielmiessler/SecLists/trunk/Discovery/Web-Content /usr/share/seclists/Discovery/Web-Content && \
apk del .depends
# install latest release
RUN wget https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-linux-feroxbuster.zip -qO feroxbuster.zip && unzip -d /usr/local/bin/ feroxbuster.zip feroxbuster && rm feroxbuster.zip && chmod +x /usr/local/bin/feroxbuster
ENTRYPOINT ["feroxbuster"]

277
README.md
View File

@@ -7,40 +7,72 @@
<h4 align="center">A simple, fast, recursive content discovery tool written in Rust</h4>
<p align="center">
<img src="https://img.shields.io/github/workflow/status/epi052/feroxbuster/CI%20Pipeline/master?logo=github">
<img src="https://img.shields.io/github/downloads/epi052/feroxbuster/total?label=downloads&logo=github&color=inactive" alt="github downloads">
<img src="https://img.shields.io/github/issues-closed-raw/s0md3v/Photon.svg">
<img src="https://img.shields.io/github/last-commit/epi052/feroxbuster?logo=github">
<img src="https://img.shields.io/crates/v/feroxbuster?color=blue&label=version&logo=rust">
<img src="https://img.shields.io/crates/d/feroxbuster?label=downloads&logo=rust&color=inactive">
<a href="https://github.com/epi052/feroxbuster/actions?query=workflow%3A%22CI+Pipeline%22">
<img src="https://img.shields.io/github/workflow/status/epi052/feroxbuster/CI%20Pipeline/master?logo=github">
</a>
<a href="https://github.com/epi052/feroxbuster/releases">
<img src="https://img.shields.io/github/downloads/epi052/feroxbuster/total?label=downloads&logo=github&color=inactive" alt="github downloads">
</a>
<a href="https://github.com/epi052/feroxbuster/commits/master">
<img src="https://img.shields.io/github/last-commit/epi052/feroxbuster?logo=github">
</a>
<a href="https://crates.io/crates/feroxbuster">
<img src="https://img.shields.io/crates/v/feroxbuster?color=blue&label=version&logo=rust">
</a>
<a href="https://crates.io/crates/feroxbuster">
<img src="https://img.shields.io/crates/d/feroxbuster?label=downloads&logo=rust&color=inactive">
</a>
<a href="https://codecov.io/gh/epi052/feroxbuster">
<img src="https://codecov.io/gh/epi052/feroxbuster/branch/master/graph/badge.svg" />
</a>
</p>
![demo](img/demo.gif)
<p align="center">
<a href="https://github.com/epi052/feroxbuster/releases">Releases</a> •
<a href="#-example-usage">Example Usage</a>
<a href="https://github.com/epi052/feroxbuster/blob/master/CONTRIBUTING.md">Contributing</a>
🦀
<a href="https://github.com/epi052/feroxbuster/releases">Releases</a>
<a href="#-example-usage">Example Usage</a>
<a href="https://github.com/epi052/feroxbuster/blob/master/CONTRIBUTING.md">Contributing</a> ✨
<a href="https://docs.rs/feroxbuster/latest/feroxbuster/">Documentation</a>
🦀
</p>
## 😕 What the heck is a ferox anyway?
Ferox is short for Ferric Oxide. Ferric Oxide, simply put, is rust. The name rustbuster was taken, so I decided on a variation. 🤷
## 🤔 What's it do tho?
`feroxbuster` is a tool designed to perform [Forced Browsing](https://owasp.org/www-community/attacks/Forced_browsing).
Forced browsing is an attack where the aim is to enumerate and access resources that are not referenced by the web application, but are still accessible by an attacker.
`feroxbuster` uses brute force combined with a wordlist to search for unlinked content in target directories. These resources may store sensitive information about web applications and operational systems, such as source code, credentials, internal network addressing, etc...
This attack is also known as Predictable Resource Location, File Enumeration, Directory Enumeration, and Resource Enumeration.
📖 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)
- [Configuration](#-configuration)
- [AUR Install](#aur-install)
- [Docker Install](#docker-install)
- [Configuration](#%EF%B8%8F-configuration)
- [Default Values](#default-values)
- [ferox-config.toml](#ferox-configtoml)
- [Command Line Parsing](#command-line-parsing)
- [Example Usage](#-example-usage)
- [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)
@@ -53,13 +85,65 @@ Ferox is short for Ferric Oxide. Ferric Oxide, simply put, is rust. The name ru
### 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 x86
```
curl -sLO https://github.com/epi052/feroxbuster/releases/latest/download/x86-linux-feroxbuster.zip
unzip x86-linux-feroxbuster.zip
chmod +x ./feroxbuster
./feroxbuster -V
```
#### Linux x86_64
```
curl -sLO https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-linux-feroxbuster.zip
unzip x86_64-linux-feroxbuster.zip
chmod +x ./feroxbuster
./feroxbuster -V
```
#### MacOS x86_64
```
curl -sLO https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-macos-feroxbuster.zip
unzip x86_64-macos-feroxbuster.zip
chmod +x ./feroxbuster
./feroxbuster -V
```
#### 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
@@ -71,12 +155,77 @@ 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
First, clone the repository.
```
git clone https://github.com/epi052/feroxbuster.git
cd feroxbuster
```
Next, build the image.
```
sudo docker build -t feroxbuster .
```
After that, you should be able to use `docker run` to perform scans with `feroxbuster`.
#### Basic usage
```
sudo docker run --init -it feroxbuster -u http://example.com -x js,html
```
#### Piping from stdin and proxying all requests through socks5 proxy
```
cat targets.txt | sudo docker run --net=host --init -i feroxbuster --stdin -x js,html --proxy socks5://127.0.0.1:9050
```
#### Mount a volume to pass in `ferox-config.toml`
You've got some options available if you want to pass in a config file. [`ferox-buster.toml`](#ferox-configtoml) can live in multiple locations and still be valid, so it's up to you how you'd like to pass it in. Below are a few valid examples:
```
sudo docker run --init -v $(pwd)/ferox-config.toml:/etc/feroxbuster/ferox-config.toml -it feroxbuster -u http://example.com
```
```
sudo docker run --init -v ~/.config/feroxbuster:/root/.config/feroxbuster -it feroxbuster -u http://example.com
```
Note: If you are on a SELinux enforced system, you will need to pass the `:Z` attribute also.
```
docker run --init -v (pwd)/ferox-config.toml:/etc/feroxbuster/ferox-config.toml:Z -it feroxbuster -u http://example.com
```
#### Define an alias for simplicity
```
alias feroxbuster="sudo docker run --init -v ~/.config/feroxbuster:/root/.config/feroxbuster -i feroxbuster"
```
## ⚙️ Configuration
### Default Values
Configuration begins with with the following built-in default values baked into the binary:
@@ -97,10 +246,15 @@ After setting built-in default values, any values defined in a `ferox-config.tom
built-in defaults.
`feroxbuster` searches for `ferox-config.toml` in the following locations (in the order shown):
- `/etc/feroxbuster/`
- `CONFIG_DIR/ferxobuster/`
- The same directory as the `feroxbuster` executable
- The user's current working directory
- `/etc/feroxbuster/` (global)
- `CONFIG_DIR/ferxobuster/` (per-user)
- 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.
@@ -147,6 +301,7 @@ A pre-made configuration file with examples of all available settings can be fou
# addslash = true
# stdin = true
# dontfilter = true
# extract_links = true
# depth = 1
# sizefilters = [5174]
# queries = [["name","value"], ["rick", "astley"]]
@@ -173,16 +328,18 @@ 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, --addslash Append / to each request
-D, --dontfilter Don't auto-filter wildcard responses
-e, --extract-links Extract links from response body (html, javascript, etc...); make new requests based on
findings (default: false)
-h, --help Prints help information
-k, --insecure Disables TLS certificate validation
-n, --norecursion Do not scan recursively
-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)
@@ -220,6 +377,26 @@ 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
```
### IPv6, non-recursive scan with INFO-level logging enabled
```
@@ -265,26 +442,28 @@ 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 | ✔ | ✔ | |
| blacklist status codes (in addition to whitelist) | | ✔ | ✔ |
| 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

View File

@@ -23,6 +23,7 @@
# addslash = true
# stdin = true
# dontfilter = true
# extract_links = true
# depth = 1
# sizefilters = [5174]
# queries = [["name","value"], ["rick", "astley"]]

View File

@@ -1,4 +1,4 @@
use crate::{config::CONFIGURATION, utils::status_colorizer, VERSION};
use crate::{config::Configuration, utils::status_colorizer, VERSION};
/// macro helper to abstract away repetitive string formatting
macro_rules! format_banner_entry_helper {
@@ -43,7 +43,7 @@ macro_rules! format_banner_entry {
/// 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]) {
pub fn initialize(targets: &[String], config: &Configuration) {
let artwork = format!(
r#"
___ ___ __ __ __ __ __ ___
@@ -69,18 +69,20 @@ by Ben "epi" Risher {} ver: {}"#,
let mut codes = vec![];
for code in &CONFIGURATION.statuscodes {
for code in &config.statuscodes {
codes.push(status_colorizer(&code.to_string()))
}
eprintln!(
"{}",
format_banner_entry!("\u{1F680}", "Threads", CONFIGURATION.threads)
format_banner_entry!("\u{1F680}", "Threads", config.threads)
); // 🚀
eprintln!(
"{}",
format_banner_entry!("\u{1f4d6}", "Wordlist", CONFIGURATION.wordlist)
format_banner_entry!("\u{1f4d6}", "Wordlist", config.wordlist)
); // 📖
eprintln!(
"{}",
format_banner_entry!(
@@ -89,32 +91,34 @@ by Ben "epi" Risher {} ver: {}"#,
format!("[{}]", codes.join(", "))
)
); // 🆗
eprintln!(
"{}",
format_banner_entry!("\u{1f4a5}", "Timeout (secs)", CONFIGURATION.timeout)
format_banner_entry!("\u{1f4a5}", "Timeout (secs)", config.timeout)
); // 💥
eprintln!(
"{}",
format_banner_entry!("\u{1F9a1}", "User-Agent", CONFIGURATION.useragent)
format_banner_entry!("\u{1F9a1}", "User-Agent", config.useragent)
); // 🦡
// followed by the maybe printed or variably displayed values
if !CONFIGURATION.config.is_empty() {
if !config.config.is_empty() {
eprintln!(
"{}",
format_banner_entry!("\u{1f489}", "Config File", CONFIGURATION.config)
format_banner_entry!("\u{1f489}", "Config File", config.config)
); // 💉
}
if !CONFIGURATION.proxy.is_empty() {
if !config.proxy.is_empty() {
eprintln!(
"{}",
format_banner_entry!("\u{1f48e}", "Proxy", CONFIGURATION.proxy)
format_banner_entry!("\u{1f48e}", "Proxy", config.proxy)
); // 💎
}
if !CONFIGURATION.headers.is_empty() {
for (name, value) in &CONFIGURATION.headers {
if !config.headers.is_empty() {
for (name, value) in &config.headers {
eprintln!(
"{}",
format_banner_entry!("\u{1f92f}", "Header", name, value)
@@ -122,8 +126,8 @@ by Ben "epi" Risher {} ver: {}"#,
}
}
if !CONFIGURATION.sizefilters.is_empty() {
for filter in &CONFIGURATION.sizefilters {
if !config.sizefilters.is_empty() {
for filter in &config.sizefilters {
eprintln!(
"{}",
format_banner_entry!("\u{1f4a2}", "Size Filter", filter)
@@ -131,8 +135,15 @@ by Ben "epi" Risher {} ver: {}"#,
}
}
if !CONFIGURATION.queries.is_empty() {
for query in &CONFIGURATION.queries {
if config.extract_links {
eprintln!(
"{}",
format_banner_entry!("\u{1F50E}", "Extract Links", config.extract_links)
); // 🔎
}
if !config.queries.is_empty() {
for query in &config.queries {
eprintln!(
"{}",
format_banner_entry!(
@@ -144,83 +155,83 @@ by Ben "epi" Risher {} ver: {}"#,
}
}
if !CONFIGURATION.output.is_empty() {
if !config.output.is_empty() {
eprintln!(
"{}",
format_banner_entry!("\u{1f4be}", "Output File", CONFIGURATION.output)
format_banner_entry!("\u{1f4be}", "Output File", config.output)
); // 💾
}
if !CONFIGURATION.extensions.is_empty() {
if !config.extensions.is_empty() {
eprintln!(
"{}",
format_banner_entry!(
"\u{1f4b2}",
"Extensions",
format!("[{}]", CONFIGURATION.extensions.join(", "))
format!("[{}]", config.extensions.join(", "))
)
); // 💲
}
if CONFIGURATION.insecure {
if config.insecure {
eprintln!(
"{}",
format_banner_entry!("\u{1f513}", "Insecure", CONFIGURATION.insecure)
format_banner_entry!("\u{1f513}", "Insecure", config.insecure)
); // 🔓
}
if CONFIGURATION.redirects {
if config.redirects {
eprintln!(
"{}",
format_banner_entry!("\u{1f4cd}", "Follow Redirects", CONFIGURATION.redirects)
format_banner_entry!("\u{1f4cd}", "Follow Redirects", config.redirects)
); // 📍
}
if CONFIGURATION.dontfilter {
if config.dontfilter {
eprintln!(
"{}",
format_banner_entry!("\u{1f92a}", "Filter Wildcards", !CONFIGURATION.dontfilter)
format_banner_entry!("\u{1f92a}", "Filter Wildcards", !config.dontfilter)
); // 🤪
}
match CONFIGURATION.verbosity {
match config.verbosity {
//speaker medium volume (increasing with verbosity to loudspeaker)
1 => {
eprintln!(
"{}",
format_banner_entry!("\u{1f508}", "Verbosity", CONFIGURATION.verbosity)
format_banner_entry!("\u{1f508}", "Verbosity", config.verbosity)
); // 🔈
}
2 => {
eprintln!(
"{}",
format_banner_entry!("\u{1f509}", "Verbosity", CONFIGURATION.verbosity)
format_banner_entry!("\u{1f509}", "Verbosity", config.verbosity)
); // 🔉
}
3 => {
eprintln!(
"{}",
format_banner_entry!("\u{1f50a}", "Verbosity", CONFIGURATION.verbosity)
format_banner_entry!("\u{1f50a}", "Verbosity", config.verbosity)
); // 🔊
}
4 => {
eprintln!(
"{}",
format_banner_entry!("\u{1f4e2}", "Verbosity", CONFIGURATION.verbosity)
format_banner_entry!("\u{1f4e2}", "Verbosity", config.verbosity)
); // 📢
}
_ => {}
}
if CONFIGURATION.addslash {
if config.addslash {
eprintln!(
"{}",
format_banner_entry!("\u{1fa93}", "Add Slash", CONFIGURATION.addslash)
format_banner_entry!("\u{1fa93}", "Add Slash", config.addslash)
); // 🪓
}
if !CONFIGURATION.norecursion {
if CONFIGURATION.depth == 0 {
if !config.norecursion {
if config.depth == 0 {
eprintln!(
"{}",
format_banner_entry!("\u{1f503}", "Recursion Depth", "INFINITE")
@@ -228,15 +239,51 @@ by Ben "epi" Risher {} ver: {}"#,
} else {
eprintln!(
"{}",
format_banner_entry!("\u{1f503}", "Recursion Depth", CONFIGURATION.depth)
format_banner_entry!("\u{1f503}", "Recursion Depth", config.depth)
); // 🔃
}
} else {
eprintln!(
"{}",
format_banner_entry!("\u{1f6ab}", "Do Not Recurse", CONFIGURATION.norecursion)
format_banner_entry!("\u{1f6ab}", "Do Not Recurse", config.norecursion)
); // 🚫
}
eprintln!("{}", bottom);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// test to hit no execution of targets for loop in banner
fn banner_without_targets() {
let config = Configuration::default();
initialize(&[], &config);
}
#[test]
/// test to hit no execution of statuscode for loop in banner
fn banner_without_status_codes() {
let mut config = Configuration::default();
config.statuscodes = vec![];
initialize(&[String::from("http://localhost")], &config);
}
#[test]
/// test to hit an empty config file
fn banner_without_config_file() {
let mut config = Configuration::default();
config.config = String::new();
initialize(&[String::from("http://localhost")], &config);
}
#[test]
/// test to hit an empty config file
fn banner_without_queries() {
let mut config = Configuration::default();
config.queries = vec![(String::new(), String::new())];
initialize(&[String::from("http://localhost")], &config);
}
}

View File

@@ -1,9 +1,9 @@
use crate::utils::status_colorizer;
use ansi_term::Color::Cyan;
use crate::utils::{module_colorizer, status_colorizer};
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;
@@ -22,18 +22,8 @@ pub fn initialize(
Policy::none()
};
let header_map: HeaderMap = match headers.try_into() {
Ok(map) => map,
Err(e) => {
eprintln!(
"{} {} {}",
status_colorizer("ERROR"),
Cyan.paint("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))
@@ -49,15 +39,19 @@ pub fn initialize(
eprintln!(
"{} {} Could not add proxy ({:?}) to Client configuration",
status_colorizer("ERROR"),
Cyan.paint("Client::initialize"),
module_colorizer("Client::initialize"),
proxy
);
eprintln!(
"{} {} {}",
status_colorizer("ERROR"),
Cyan.paint("Client::initialize"),
module_colorizer("Client::initialize"),
e
);
#[cfg(test)]
panic!();
#[cfg(not(test))]
exit(1);
}
}
@@ -71,15 +65,40 @@ pub fn initialize(
eprintln!(
"{} {} Could not create a Client with the given configuration, exiting.",
status_colorizer("ERROR"),
Cyan.paint("Client::build")
module_colorizer("Client::build")
);
eprintln!(
"{} {} {}",
status_colorizer("ERROR"),
Cyan.paint("Client::build"),
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

@@ -1,7 +1,6 @@
use crate::utils::status_colorizer;
use crate::utils::{module_colorizer, status_colorizer};
use crate::{client, parser, progress};
use crate::{DEFAULT_CONFIG_NAME, DEFAULT_STATUS_CODES, DEFAULT_WORDLIST, VERSION};
use ansi_term::Color::Cyan;
use clap::value_t;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget};
use lazy_static::lazy_static;
@@ -108,6 +107,10 @@ pub struct Configuration {
#[serde(default)]
pub norecursion: bool,
/// Extract links from html/javscript
#[serde(default)]
pub extract_links: bool,
/// Append / to each request
#[serde(default)]
pub addslash: bool,
@@ -183,8 +186,9 @@ impl Default for Configuration {
verbosity: 0,
addslash: false,
insecure: false,
norecursion: false,
redirects: false,
norecursion: false,
extract_links: false,
proxy: String::new(),
config: String::new(),
output: String::new(),
@@ -207,6 +211,7 @@ impl Configuration {
///
/// - **timeout**: `5` seconds
/// - **redirects**: `false`
/// - **extract-links**: `false`
/// - **wordlist**: [`DEFAULT_WORDLIST`](constant.DEFAULT_WORDLIST.html)
/// - **config**: `None`
/// - **threads**: `50`
@@ -246,6 +251,11 @@ impl Configuration {
/// The resulting [Configuration](struct.Configuration.html) is a singleton with a `static`
/// lifetime.
pub fn new() -> Self {
// when compiling for test, we want to eliminate the runtime dependency of the parser
if cfg!(test) {
return Configuration::default();
}
// Get the default configuration, this is what will apply if nothing
// else is specified.
let mut config = Configuration::default();
@@ -321,7 +331,12 @@ impl Configuration {
.map(|code| {
StatusCode::from_bytes(code.as_bytes())
.unwrap_or_else(|e| {
eprintln!("[!] Error encountered: {}", e);
eprintln!(
"{} {}: {}",
status_colorizer("ERROR"),
module_colorizer("Configuration::new"),
e
);
exit(1)
})
.as_u16()
@@ -343,7 +358,12 @@ impl Configuration {
.unwrap() // already known good
.map(|size| {
size.parse::<u64>().unwrap_or_else(|e| {
eprintln!("[!] Error encountered: {}", e);
eprintln!(
"{} {}: {}",
status_colorizer("ERROR"),
module_colorizer("Configuration::new"),
e
);
exit(1)
})
})
@@ -376,6 +396,10 @@ impl Configuration {
config.addslash = args.is_present("addslash");
}
if args.is_present("extract_links") {
config.extract_links = args.is_present("extract_links");
}
if args.is_present("stdin") {
config.stdin = args.is_present("stdin");
} else {
@@ -500,6 +524,7 @@ impl Configuration {
settings.useragent = settings_to_merge.useragent;
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;
@@ -524,7 +549,7 @@ impl Configuration {
println!(
"{} {} {}",
status_colorizer("ERROR"),
Cyan.paint("config::parse_config"),
module_colorizer("config::parse_config"),
e
);
}
@@ -560,6 +585,7 @@ mod tests {
addslash = true
stdin = true
dontfilter = true
extract_links = true
depth = 1
sizefilters = [4120]
"#;
@@ -588,6 +614,7 @@ mod tests {
assert_eq!(config.stdin, false);
assert_eq!(config.addslash, 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());
@@ -700,6 +727,13 @@ mod tests {
assert_eq!(config.addslash, 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]
/// parse the test config and see that the value parsed is correct
fn config_reads_extensions() {

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(())
}
}

View File

@@ -1,9 +1,13 @@
use crate::config::{CONFIGURATION, PROGRESS_PRINTER};
use crate::utils::{ferox_print, format_url, get_url_path_length, make_request, status_colorizer};
use ansi_term::Color::{Cyan, Yellow};
use crate::scanner::should_filter_response;
use crate::utils::{
ferox_print, format_url, get_url_path_length, make_request, module_colorizer, status_colorizer,
};
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
@@ -17,9 +21,13 @@ const UUID_LENGTH: u64 = 32;
///
/// `size` is size of the response that should be included with filters passed via runtime
/// configuration and any static wildcard lengths.
#[derive(Default, Debug)]
#[derive(Default, Debug, PartialEq, Copy, Clone)]
pub struct WildcardFilter {
/// size of the response that will later be combined with the length of the path of the url
/// requested
pub dynamic: u64,
/// size of the response that should be included with filters passed via runtime configuration
pub size: u64,
}
@@ -46,8 +54,17 @@ 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
@@ -55,7 +72,10 @@ pub async fn wildcard_test(target_url: &str, bar: ProgressBar) -> Option<Wildcar
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(resp_one) = make_wildcard_request(&target_url, 1, clone_req_one).await {
bar.inc(1);
// found a wildcard response
@@ -70,7 +90,7 @@ 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);
@@ -80,32 +100,50 @@ pub async fn wildcard_test(target_url: &str, bar: ProgressBar) -> Option<Wildcar
// 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 {}",
wildcard.dynamic = wc_length - url_len;
if !CONFIGURATION.quiet
&& !should_filter_response(&wildcard.dynamic, &resp_one.url())
{
let msg = format!(
"{} {:>10} Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}\n",
status_colorizer("WLD"),
wc_length - url_len,
Yellow.paint("auto-filtering"),
Cyan.paint(format!("{}", wc_length - url_len)),
Yellow.paint("--dontfilter")
), &PROGRESS_PRINTER
wildcard.dynamic,
style("auto-filtering").yellow(),
style(wc_length - url_len).cyan(),
style("--dontfilter").yellow()
);
ferox_print(&msg, &PROGRESS_PRINTER);
try_send_message_to_file(
&msg,
tx_file.clone(),
!CONFIGURATION.output.is_empty(),
);
}
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 {}",
wildcard.size = wc_length;
if !CONFIGURATION.quiet && !should_filter_response(&wildcard.size, &resp_one.url())
{
let msg = format!(
"{} {:>10} Wildcard response is static; {} {} responses; toggle this behavior by using {}\n",
status_colorizer("WLD"),
wc_length,
Yellow.paint("auto-filtering"),
Cyan.paint(format!("{}", wc_length)),
Yellow.paint("--dontfilter")
), &PROGRESS_PRINTER);
style("auto-filtering").yellow(),
style(wc_length).cyan(),
style("--dontfilter").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);
@@ -125,8 +163,17 @@ 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<Response> {
log::trace!(
"enter: make_wildcard_request({}, {}, {:?})",
target_url,
length,
tx_file
);
let unique_str = unique_string(length);
@@ -157,45 +204,46 @@ async fn make_wildcard_request(target_url: &str, length: usize) -> Option<Respon
let url_len = get_url_path_length(&response.url());
let content_len = response.content_length().unwrap_or(0);
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(&content_len, &response.url()) {
let msg = format!(
"{} {:>10} Got {} for {} (url length: {})\n",
wildcard,
content_len,
status_colorizer(&response.status().as_str()),
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() {
// 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,
let next_loc_str = next_loc.to_str().unwrap_or("Unknown");
if !CONFIGURATION.quiet
&& !should_filter_response(&content_len, &response.url())
{
let msg = format!(
"{} {:>10} {} redirects to => {}\n",
wildcard,
content_len,
response.url(),
next_loc_str
);
ferox_print(&msg, &PROGRESS_PRINTER);
try_send_message_to_file(
&msg,
tx_file.clone(),
!CONFIGURATION.output.is_empty(),
);
}
}
@@ -261,7 +309,7 @@ pub async fn connectivity_test(target_urls: &[String]) -> Vec<String> {
eprintln!(
"{} {} Could not connect to any target provided",
status_colorizer("ERROR"),
Cyan.paint("heuristics::connectivity_test"),
module_colorizer("heuristics::connectivity_test"),
);
process::exit(1);
@@ -272,14 +320,87 @@ 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!(
"{} {} {}",
status_colorizer("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]
fn unique_string_returns_correct_length() {
/// request a unique string of 32bytes * a value returns correct result
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,19 +1,26 @@
pub mod banner;
pub mod client;
pub mod config;
pub mod extractor;
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;
use reqwest::{Response, StatusCode, Url};
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>>;
/// 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");
@@ -52,3 +59,141 @@ 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)]
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::*;
#[test]
/// asserts default config name is correct
fn default_config_name() {
assert_eq!(DEFAULT_CONFIG_NAME, "ferox-config.toml");
}
#[test]
/// asserts default wordlist is correct
fn default_wordlist() {
assert_eq!(
DEFAULT_WORDLIST,
"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt"
);
}
#[test]
/// asserts default version is correct
fn default_version() {
assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
}
}

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,8 +1,7 @@
use ansi_term::Color::Cyan;
use feroxbuster::config::{CONFIGURATION, PROGRESS_PRINTER};
use feroxbuster::scanner::scan_url;
use feroxbuster::utils::{get_current_depth, status_colorizer};
use feroxbuster::{banner, heuristics, logger, FeroxResult};
use feroxbuster::utils::{ferox_print, get_current_depth, module_colorizer, status_colorizer};
use feroxbuster::{banner, heuristics, logger, reporter, FeroxResponse, FeroxResult};
use futures::StreamExt;
use std::collections::HashSet;
use std::fs::File;
@@ -10,6 +9,7 @@ use std::io::{BufRead, BufReader};
use std::process;
use std::sync::Arc;
use tokio::io;
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::codec::{FramedRead, LinesCodec};
/// Create a HashSet of Strings from the given wordlist then stores it inside an Arc
@@ -22,7 +22,7 @@ fn get_unique_words_from_wordlist(path: &str) -> FeroxResult<Arc<HashSet<String>
eprintln!(
"{} {} {}",
status_colorizer("ERROR"),
Cyan.paint("main::get_unique_words_from_wordlist"),
module_colorizer("main::get_unique_words_from_wordlist"),
e
);
log::error!("Could not open wordlist: {}", e);
@@ -37,26 +37,30 @@ fn get_unique_words_from_wordlist(path: &str) -> FeroxResult<Arc<HashSet<String>
let mut words = HashSet::new();
for line in reader.lines() {
match line {
Ok(word) => {
words.insert(word);
}
Err(e) => {
log::warn!("Could not parse current line from wordlist : {}", e);
}
let result = line?;
if result.starts_with('#') || result.is_empty() {
continue;
}
words.insert(result);
}
log::trace!(
"exit: get_unique_words_from_wordlist -> Arc<wordlist[{} words...]>",
words.len()
);
Ok(Arc::new(words))
}
/// 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
@@ -68,7 +72,7 @@ async fn scan(targets: Vec<String>) -> FeroxResult<()> {
eprintln!(
"{} {} Did not find any words in {}",
status_colorizer("ERROR"),
Cyan.paint("main::scan"),
module_colorizer("main::scan"),
CONFIGURATION.wordlist
);
process::exit(1);
@@ -77,11 +81,13 @@ async fn scan(targets: Vec<String>) -> FeroxResult<()> {
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);
@@ -94,7 +100,7 @@ async fn scan(targets: Vec<String>) -> FeroxResult<()> {
Ok(())
}
async fn get_targets() -> Vec<String> {
async fn get_targets() -> FeroxResult<Vec<String>> {
log::trace!("enter: get_targets");
let mut targets = vec![];
@@ -106,14 +112,7 @@ async fn get_targets() -> Vec<String> {
let mut reader = FramedRead::new(stdin, LinesCodec::new());
while let Some(line) = reader.next().await {
match line {
Ok(target) => {
targets.push(target);
}
Err(e) => {
log::error!("{}", e);
}
}
targets.push(line?);
}
} else {
targets.push(CONFIGURATION.target_url.clone());
@@ -121,35 +120,93 @@ async fn get_targets() -> Vec<String> {
log::trace!("exit: get_targets -> {:?}", targets);
targets
Ok(targets)
}
#[tokio::main]
async fn main() {
// setup logging based on the number of -v's used
logger::initialize(CONFIGURATION.verbosity);
// can't trace main until after logger is initialized
log::trace!("enter: main");
log::debug!("{:#?}", *CONFIGURATION);
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 = get_targets().await;
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);
}
};
if !CONFIGURATION.quiet {
// only print banner if -q isn't used
banner::initialize(&targets);
banner::initialize(&targets, &CONFIGURATION);
}
// discard non-responsive targets
let live_targets = heuristics::connectivity_test(&targets).await;
match scan(live_targets).await {
// 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) => log::error!("An error occurred: {}", e),
};
PROGRESS_PRINTER.finish();
// manually drop tx in order for the rx task's while loops to eval to false
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");
}
log::trace!("exit: main");
// clean-up function for the MultiProgress bar; must be called last in order to still see
// the final trace message above
PROGRESS_PRINTER.finish();
}

View File

@@ -195,6 +195,13 @@ pub fn initialize() -> App<'static, 'static> {
"Filter out messages of a particular size (ex: -S 5120 -S 4927,1970)",
),
)
.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)")
)
.after_help(r#"NOTE:
Options that take multiple values are very flexible. Consider the following ways of specifying
@@ -225,7 +232,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");
}
}

229
src/reporter.rs Normal file
View File

@@ -0,0 +1,229 @@
use crate::config::{CONFIGURATION, PROGRESS_PRINTER};
use crate::utils::{ferox_print, 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::debug!("received {} on reporting channel", resp.url());
if CONFIGURATION.statuscodes.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::debug!("report complete: {}", resp.url());
}
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,119 +1,102 @@
use crate::config::{CONFIGURATION, PROGRESS_BAR, PROGRESS_PRINTER};
use crate::config::{CONFIGURATION, PROGRESS_BAR};
use crate::extractor::get_links;
use crate::heuristics::WildcardFilter;
use crate::utils::{
ferox_print, format_url, get_current_depth, get_url_path_length, make_request, status_colorizer,
};
use crate::{heuristics, progress};
use crate::utils::{format_url, get_current_depth, get_url_path_length, make_request};
use crate::{heuristics, progress, FeroxChannel, FeroxResponse};
use futures::future::{BoxFuture, FutureExt};
use futures::{stream, StreamExt};
use reqwest::{Response, Url};
use lazy_static::lazy_static;
use reqwest::Url;
use std::collections::HashSet;
use std::convert::TryInto;
use std::ops::Deref;
use std::sync::Arc;
use tokio::fs;
use tokio::io::{self, AsyncWriteExt};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, RwLock};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
/// 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);
/// Single atomic number that gets incremented once, used to track first scan vs. all others
static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
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);
let mut writer = io::BufWriter::new(outfile); // tokio BufWriter
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");
/// Vector of WildcardFilters that have been ID'd through heuristics
static ref WILDCARD_FILTERS: Arc<RwLock<Vec<Arc<WildcardFilter>>>> = Arc::new(RwLock::new(Vec::<Arc<WildcardFilter>>::new()));
}
/// Spawn a single consumer task (sc side of mpsc)
/// Adds the given url to `SCANNED_URLS`
///
/// 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);
/// 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
);
while let Some(resp) = report_channel.recv().await {
log::debug!("received {} on reporting channel", resp.url());
if CONFIGURATION.statuscodes.contains(&resp.status().as_u16()) {
if CONFIGURATION.quiet {
ferox_print(&format!("{}", resp.url()), &PROGRESS_PRINTER);
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 {
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,
);
}
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 WildcardFilter to `WILDCARD_FILTERS`
///
/// If `WILDCARD_FILTERS` did not already contain the filter, return true; otherwise return false
fn add_filter_to_list_of_wildcard_filters(
filter: Arc<WildcardFilter>,
wildcard_filters: Arc<RwLock<Vec<Arc<WildcardFilter>>>>,
) -> bool {
log::trace!(
"enter: add_filter_to_list_of_wildcard_filters({:?}, {:?})",
filter,
wildcard_filters
);
match wildcard_filters.write() {
Ok(mut filters) => {
// If the set did not contain the assigned filter, true is returned.
// If the set did contain the assigned filter, false is returned.
if filters.contains(&filter) {
log::trace!("exit: add_filter_to_list_of_wildcard_filters -> false");
return false;
}
filters.push(filter);
log::trace!("exit: add_filter_to_list_of_wildcard_filters -> true");
true
}
Err(e) => {
// poisoned lock
log::error!("Set of wildcard filters poisoned: {}", e);
log::trace!("exit: add_filter_to_list_of_wildcard_filters -> false");
false
}
log::debug!("report complete: {}", resp.url());
}
log::trace!("exit: spawn_terminal_reporter");
}
/// Spawn a single consumer task (sc side of mpsc)
@@ -123,22 +106,44 @@ 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
@@ -195,7 +200,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() {
@@ -247,10 +252,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;
@@ -258,7 +268,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;
}
@@ -270,7 +280,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>,
) {
@@ -281,7 +291,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());
@@ -292,9 +304,8 @@ async fn try_recursion(
}
Err(e) => {
log::error!(
"could not send {} across {:?}: {}",
"Could not send {} to recursion handler: {}",
response.url(),
transmitter,
e
);
}
@@ -308,9 +319,8 @@ async fn try_recursion(
Ok(_) => {}
Err(e) => {
log::error!(
"could not send {}/ across {:?}: {}",
"Could not send {}/ to recursion handler: {}",
response.url(),
transmitter,
e
);
}
@@ -320,6 +330,54 @@ 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(content_len: &u64, url: &Url) -> bool {
if CONFIGURATION.sizefilters.contains(content_len) {
// filtered value from --sizefilters, move on to the next url
log::debug!("size filter: filtered out {}", url);
return true;
}
match WILDCARD_FILTERS.read() {
Ok(filters) => {
for filter in filters.iter() {
if CONFIGURATION.dontfilter {
// quick return if dontfilter is set
return false;
}
if filter.size > 0 && filter.size == *content_len {
// static wildcard size found during testing
// size isn't default, size equals response length, and auto-filter is on
log::debug!("static wildcard: filtered out {}", url);
return true;
}
if filter.dynamic > 0 {
// dynamic wildcard offset found during testing
// I'm about to manually split this url path instead of using reqwest::Url's
// builtin parsing. The reason is that they call .split() on the url path
// except that I don't want an empty string taking up the last index in the
// event that the url ends with a forward slash. It's ugly enough to be split
// into its own function for readability.
let url_len = get_url_path_length(&url);
if url_len + filter.dynamic == *content_len {
log::debug!("dynamic wildcard: filtered out {}", url);
return true;
}
}
}
}
Err(e) => {
log::error!("{}", e);
}
}
false
}
/// Wrapper for [make_request](fn.make_request.html)
///
/// Handles making multiple requests based on the presence of extensions
@@ -329,9 +387,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({}, {}, {}, {:?}, {:?})",
@@ -346,79 +403,139 @@ 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.norecursion {
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);
let content_len = &ferox_response.content_length();
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(content_len, &ferox_response.url()) {
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.addslash,
&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
let new_content_len = &new_ferox_response.content_length();
if should_filter_response(new_content_len, &new_ferox_response.url()) {
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.norecursion {
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()
@@ -430,54 +547,52 @@ pub async fn scan_url(target_url: &str, wordlist: Arc<HashSet<String>>, base_dep
let progress_bar = progress::add_bar(&target_url, num_reqs_expected, false);
progress_bar.reset_elapsed();
if get_current_depth(&target_url) - base_depth == 0 {
if CALL_COUNT.load(Ordering::Relaxed) == 0 {
// join can only be called once, otherwise it causes the thread to panic
// when current depth - base depth equals zero, we're in the first call to scan_url
tokio::task::spawn_blocking(move || PROGRESS_BAR.join().unwrap());
CALL_COUNT.fetch_add(1, Ordering::Relaxed);
// this protection around join also allows us to add the first scanned url to SCANNED_URLS
// from within the scan_url function instead of the recursion handler
add_url_to_list_of_scanned_urls(&target_url, &SCANNED_URLS);
}
// 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()),
};
let filter =
match heuristics::wildcard_test(&target_url, wildcard_bar, heuristics_file_clone).await {
Some(f) => Arc::new(f),
None => Arc::new(WildcardFilter::default()),
};
add_filter_to_list_of_wildcard_filters(filter.clone(), WILDCARD_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
}),
tokio::spawn(async move { make_requests(&tgt, &word, base_depth, txd, txr).await }),
pb,
)
})
@@ -508,17 +623,165 @@ 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");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// sending url + word without any extensions should get back one url with the joined word
fn create_urls_no_extension_returns_base_url_with_word() {
let urls = create_urls("http://localhost", "turbo", &[]);
assert_eq!(urls, [Url::parse("http://localhost/turbo").unwrap()])
}
#[test]
/// sending url + word + 1 extension should get back two urls, one base and one with extension
fn create_urls_one_extension_returns_two_urls() {
let urls = create_urls("http://localhost", "turbo", &[String::from("js")]);
assert_eq!(
urls,
[
Url::parse("http://localhost/turbo").unwrap(),
Url::parse("http://localhost/turbo.js").unwrap()
]
)
}
#[test]
/// sending url + word + multiple extensions should get back n+1 urls
fn create_urls_multiple_extensions_returns_n_plus_one_urls() {
let ext_vec = vec![
vec![String::from("js")],
vec![String::from("js"), String::from("php")],
vec![String::from("js"), String::from("php"), String::from("pdf")],
vec![
String::from("js"),
String::from("php"),
String::from("pdf"),
String::from("tar.gz"),
],
];
let base = Url::parse("http://localhost/turbo").unwrap();
let js = Url::parse("http://localhost/turbo.js").unwrap();
let php = Url::parse("http://localhost/turbo.php").unwrap();
let pdf = Url::parse("http://localhost/turbo.pdf").unwrap();
let tar = Url::parse("http://localhost/turbo.tar.gz").unwrap();
let expected = vec![
vec![base.clone(), js.clone()],
vec![base.clone(), js.clone(), php.clone()],
vec![base.clone(), js.clone(), php.clone(), pdf.clone()],
vec![base, js, php, pdf, tar],
];
for (i, ext_set) in ext_vec.into_iter().enumerate() {
let urls = create_urls("http://localhost", "turbo", &ext_set);
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]
/// add a wildcard filter with the `size` attribute set to WILDCARD_FILTERS and ensure that
/// should_filter_response correctly returns true
fn should_filter_response_filters_wildcard_size() {
let mut filter = WildcardFilter::default();
let url = Url::parse("http://localhost").unwrap();
filter.size = 18;
let filter = Arc::new(filter);
add_filter_to_list_of_wildcard_filters(filter, WILDCARD_FILTERS.clone());
let result = should_filter_response(&18, &url);
assert!(result);
}
#[test]
/// add a wildcard filter with the `dynamic` attribute set to WILDCARD_FILTERS and ensure that
/// should_filter_response correctly returns true
fn should_filter_response_filters_wildcard_dynamic() {
let mut filter = WildcardFilter::default();
let url = Url::parse("http://localhost/some-path").unwrap();
filter.dynamic = 9;
let filter = Arc::new(filter);
add_filter_to_list_of_wildcard_filters(filter, WILDCARD_FILTERS.clone());
let result = should_filter_response(&18, &url);
assert!(result);
}
}

View File

@@ -1,6 +1,5 @@
use crate::FeroxResult;
use ansi_term::Color::{Blue, Cyan, Green, Red, Yellow};
use console::{strip_ansi_codes, user_attended};
use console::{strip_ansi_codes, style, user_attended};
use indicatif::ProgressBar;
use reqwest::Url;
use reqwest::{Client, Response};
@@ -63,17 +62,24 @@ pub fn get_current_depth(target: &str) -> usize {
/// Takes in a string and examines the first character to return a color version of the same string
pub fn status_colorizer(status: &str) -> String {
match status.chars().next() {
Some('1') => Blue.paint(status).to_string(), // informational
Some('2') => Green.bold().paint(status).to_string(), // success
Some('3') => Yellow.paint(status).to_string(), // redirects
Some('4') => Red.paint(status).to_string(), // client error
Some('5') => Red.paint(status).to_string(), // server error
Some('W') => Cyan.paint(status).to_string(), // wildcard
Some('E') => Red.paint(status).to_string(), // error
_ => status.to_string(), // ¯\_(ツ)_/¯
Some('1') => style(status).blue().to_string(), // informational
Some('2') => style(status).green().to_string(), // success
Some('3') => style(status).yellow().to_string(), // redirects
Some('4') => style(status).red().to_string(), // client error
Some('5') => style(status).red().to_string(), // server error
Some('W') => style(status).cyan().to_string(), // wildcard
Some('E') => style(status).red().to_string(), // error
_ => status.to_string(), // ¯\_(ツ)_/¯
}
}
/// Takes in a string and colors it using console::style
///
/// mainly putting this here in case i want to change the color later, making any changes easy
pub fn module_colorizer(modname: &str) -> String {
style(modname).cyan().to_string()
}
/// Gets the length of a url's path
///
/// example: http://localhost/stuff -> 5
@@ -154,7 +160,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()
@@ -217,7 +227,12 @@ pub async fn make_request(client: &Client, url: &Url) -> FeroxResult<Response> {
}
Err(e) => {
log::trace!("exit: make_request -> {}", e);
log::error!("Error while making request: {}", e);
if e.to_string().contains("operation timed out") {
// only warn for timeouts, while actual errors are still left as errors
log::warn!("Error while making request: {}", e);
} else {
log::error!("Error while making request: {}", e);
}
Err(Box::new(e))
}
}
@@ -228,30 +243,42 @@ mod tests {
use super::*;
#[test]
/// base url returns 1
fn get_current_depth_base_url_returns_1() {
let depth = get_current_depth("http://localhost");
assert_eq!(depth, 1);
}
#[test]
/// base url with slash returns 1
fn get_current_depth_base_url_with_slash_returns_1() {
let depth = get_current_depth("http://localhost/");
assert_eq!(depth, 1);
}
#[test]
/// base url + 1 dir returns 2
fn get_current_depth_one_dir_returns_2() {
let depth = get_current_depth("http://localhost/src");
assert_eq!(depth, 2);
}
#[test]
/// base url + 1 dir and slash returns 2
fn get_current_depth_one_dir_with_slash_returns_2() {
let depth = get_current_depth("http://localhost/src/");
assert_eq!(depth, 2);
}
#[test]
/// base url + 1 dir and slash returns 2
fn get_current_depth_single_forward_slash_is_zero() {
let depth = get_current_depth("");
assert_eq!(depth, 0);
}
#[test]
/// base url + 1 word + no slash + no extension
fn format_url_normal() {
assert_eq!(
format_url("http://localhost", "stuff", false, &Vec::new(), None).unwrap(),
@@ -260,6 +287,7 @@ mod tests {
}
#[test]
/// base url + no word + no slash + no extension
fn format_url_no_word() {
assert_eq!(
format_url("http://localhost", "", false, &Vec::new(), None).unwrap(),
@@ -267,13 +295,47 @@ mod tests {
);
}
#[test]
/// base url + word + no slash + no extension + queries
fn format_url_joins_queries() {
assert_eq!(
format_url(
"http://localhost",
"lazer",
false,
&[(String::from("stuff"), String::from("things"))],
None
)
.unwrap(),
reqwest::Url::parse("http://localhost/lazer?stuff=things").unwrap()
);
}
#[test]
/// base url + no word + no slash + no extension + queries
fn format_url_without_word_joins_queries() {
assert_eq!(
format_url(
"http://localhost",
"",
false,
&[(String::from("stuff"), String::from("things"))],
None
)
.unwrap(),
reqwest::Url::parse("http://localhost/?stuff=things").unwrap()
);
}
#[test]
#[should_panic]
/// no base url is an error
fn format_url_no_url() {
format_url("", "stuff", false, &Vec::new(), None).unwrap();
}
#[test]
/// word prepended with slash is adjusted for correctness
fn format_url_word_with_preslash() {
assert_eq!(
format_url("http://localhost", "/stuff", false, &Vec::new(), None).unwrap(),
@@ -282,10 +344,59 @@ mod tests {
}
#[test]
/// word with appended slash allows the slash to persist
fn format_url_word_with_postslash() {
assert_eq!(
format_url("http://localhost", "stuff/", false, &Vec::new(), None).unwrap(),
reqwest::Url::parse("http://localhost/stuff/").unwrap()
);
}
#[test]
/// status colorizer uses red for 500s
fn status_colorizer_uses_red_for_500s() {
assert_eq!(status_colorizer("500"), style("500").red().to_string());
}
#[test]
/// status colorizer uses red for 400s
fn status_colorizer_uses_red_for_400s() {
assert_eq!(status_colorizer("400"), style("400").red().to_string());
}
#[test]
/// status colorizer uses red for errors
fn status_colorizer_uses_red_for_errors() {
assert_eq!(status_colorizer("ERROR"), style("ERROR").red().to_string());
}
#[test]
/// status colorizer uses cyan for wildcards
fn status_colorizer_uses_cyan_for_wildcards() {
assert_eq!(status_colorizer("WLD"), style("WLD").cyan().to_string());
}
#[test]
/// status colorizer uses blue for 100s
fn status_colorizer_uses_blue_for_100s() {
assert_eq!(status_colorizer("100"), style("100").blue().to_string());
}
#[test]
/// status colorizer uses green for 200s
fn status_colorizer_uses_green_for_200s() {
assert_eq!(status_colorizer("200"), style("200").green().to_string());
}
#[test]
/// status colorizer uses yellow for 300s
fn status_colorizer_uses_yellow_for_300s() {
assert_eq!(status_colorizer("300"), style("300").yellow().to_string());
}
#[test]
/// status colorizer doesnt color anything else
fn status_colorizer_returns_as_is() {
assert_eq!(status_colorizer("farfignewton"), "farfignewton".to_string());
}
}

565
tests/test_banner.rs Normal file
View File

@@ -0,0 +1,565 @@
mod utils;
use assert_cmd::Command;
use predicates::prelude::*;
use utils::{setup_tmp_directory, teardown_tmp_directory};
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + proxy
fn banner_prints_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("--proxy")
.arg("http://127.0.0.1:8080")
.pipe_stdin(file)
.unwrap()
.assert()
.failure()
.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("Proxy"))
.and(predicate::str::contains("http://127.0.0.1:8080"))
.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
fn banner_prints_headers() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--headers")
.arg("stuff:things")
.arg("-H")
.arg("mostuff:mothings")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Header"))
.and(predicate::str::contains("stuff: things"))
.and(predicate::str::contains("mostuff: mothings"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[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>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-S")
.arg("789456123")
.arg("--sizefilter")
.arg("44444444")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Size Filter"))
.and(predicate::str::contains("789456123"))
.and(predicate::str::contains("44444444"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + queries
fn banner_prints_queries() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-Q")
.arg("token=supersecret")
.arg("--query")
.arg("stuff=things")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Query Parameter"))
.and(predicate::str::contains("token=supersecret"))
.and(predicate::str::contains("stuff=things"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + status codes
fn banner_prints_status_codes() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-s")
.arg("201,301,401")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("[201, 301, 401]"))
.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
fn banner_prints_output_file() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--output")
.arg("/super/cool/path")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Output File"))
.and(predicate::str::contains("/super/cool/path"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + insecure
fn banner_prints_insecure() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-k")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Insecure"))
.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 + follow redirects
fn banner_prints_redirects() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-r")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Follow Redirects"))
.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 + extensions
fn banner_prints_extensions() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-x")
.arg("js")
.arg("--extensions")
.arg("pdf")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Extensions"))
.and(predicate::str::contains("[js, pdf]"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[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>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--dontfilter")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Filter Wildcards"))
.and(predicate::str::contains("false"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + verbosity=1
fn banner_prints_verbosity_one() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-v")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Verbosity"))
.and(predicate::str::contains("│ 1"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + verbosity=2
fn banner_prints_verbosity_two() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-vv")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Verbosity"))
.and(predicate::str::contains("│ 2"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + verbosity=3
fn banner_prints_verbosity_three() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-vvv")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Verbosity"))
.and(predicate::str::contains("│ 3"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + verbosity=4
fn banner_prints_verbosity_four() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-vvvv")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Verbosity"))
.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 + add slash
fn banner_prints_add_slash() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-f")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Add Slash"))
.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 + INFINITE recursion
fn banner_prints_infinite_depth() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--depth")
.arg("0")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Recursion Depth"))
.and(predicate::str::contains("INFINITE"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + recursion depth
fn banner_prints_recursion_depth() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("--depth")
.arg("343214")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Recursion Depth"))
.and(predicate::str::contains("343214"))
.and(predicate::str::contains("─┴─")),
);
Ok(())
}
#[test]
/// test allows non-existent wordlist to trigger the banner printing to stderr
/// expect to see all mandatory prints + no recursion
fn banner_prints_no_recursion() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-n")
.assert()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Do Not Recurse"))
.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 only the error of could not connect
fn banner_doesnt_print() -> Result<(), Box<dyn std::error::Error>> {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://localhost")
.arg("-q")
.assert()
.failure()
.stderr(predicate::str::contains(
"ERROR heuristics::connectivity_test Could not connect to any target provided",
));
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()
.failure()
.stderr(
predicate::str::contains("─┬─")
.and(predicate::str::contains("Target Url"))
.and(predicate::str::contains("http://localhost"))
.and(predicate::str::contains("Threads"))
.and(predicate::str::contains("Wordlist"))
.and(predicate::str::contains("Status Codes"))
.and(predicate::str::contains("Timeout (secs)"))
.and(predicate::str::contains("User-Agent"))
.and(predicate::str::contains("Extract Links"))
.and(predicate::str::contains("true"))
.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()
.failure()
.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 sizefilter
/// 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("--sizefilter")
.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(())
}

View File

@@ -10,19 +10,21 @@ 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")?;
let cmd = std::panic::catch_unwind(|| {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://fjdksafjkdsajfkdsajkfdsajkfsdjkdsfdsafdsafdsajkr3l2ajfdskafdsjk")
.arg("--wordlist")
.arg(file.as_os_str())
.unwrap()
});
assert!(cmd.is_err());
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg("http://fjdksafjkdsajfkdsajkfdsajkfsdjkdsfdsafdsafdsajkr3l2ajfdskafdsjk")
.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")),
);
teardown_tmp_directory(tmp_dir);
Ok(())
@@ -35,20 +37,22 @@ 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")?;
let cmd = std::panic::catch_unwind(|| {
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--stdin")
.arg("--wordlist")
.arg(file.as_os_str())
.pipe_stdin(file)
.unwrap()
.unwrap()
});
assert!(cmd.is_err());
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--stdin")
.arg("--wordlist")
.arg(file.as_os_str())
.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")),
);
teardown_tmp_directory(tmp_dir);
Ok(())
@@ -63,7 +67,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)
@@ -96,7 +100,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)
@@ -128,10 +132,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)
@@ -154,10 +159,26 @@ fn test_dynamic_wildcard_request_found() -> Result<(), Box<dyn std::error::Error
.arg("--wordlist")
.arg(file.as_os_str())
.arg("--addslash")
.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"))
@@ -175,3 +196,242 @@ fn test_dynamic_wildcard_request_found() -> Result<(), Box<dyn std::error::Error
assert_eq!(mock2.times_called(), 1);
Ok(())
}
#[test]
/// uses dontfilter, so the normal wildcard test should never happen
fn heuristics_static_wildcard_request_with_dontfilter() -> 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("--dontfilter")
.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("--addslash")
.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("--addslash")
.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("--addslash")
.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("--addslash")
.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(())
}

98
tests/test_main.rs Normal file
View File

@@ -0,0 +1,98 @@
mod utils;
use assert_cmd::Command;
use httpmock::Method::GET;
use httpmock::{Mock, MockServer};
use predicates::prelude::*;
use utils::{setup_tmp_directory, teardown_tmp_directory};
#[test]
/// send the function a file to which we dont have permission in order to execute error branch
fn main_use_root_owned_file_as_wordlist() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let mock = Mock::new()
.expect_method(GET)
.expect_path("/")
.return_status(200)
.return_body("this is a test")
.create_on(&srv);
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--url")
.arg(srv.url("/"))
.arg("--wordlist")
.arg("/etc/shadow")
.arg("-vvvv")
.assert()
.success()
.stderr(predicate::str::contains(
"ERROR main::get_unique_words_from_wordlist Permission denied (os error 13)",
));
// connectivity test hits it once
assert_eq!(mock.times_called(), 1);
Ok(())
}
#[test]
/// 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(&[], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
.expect_path("/")
.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("-vvvv")
.assert()
.failure()
.stderr(predicate::str::contains(
"ERROR main::scan Did not find any words in",
));
assert_eq!(mock.times_called(), 1);
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[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(&[], "wordlist")?;
// get_targets is called before scan, so the empty wordlist shouldn't trigger
// the 'Did not find any words' error
Command::cargo_bin("feroxbuster")
.unwrap()
.arg("--stdin")
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvv")
.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"))
.and(predicate::str::contains("Target Url"))
.not(), // no target url found
);
teardown_tmp_directory(tmp_dir);
Ok(())
}

View File

@@ -7,9 +7,10 @@ use std::process::Command;
use utils::{setup_tmp_directory, teardown_tmp_directory};
#[test]
fn test_single_request_scan() -> Result<(), Box<dyn std::error::Error>> {
/// send a single valid request, expect a 200 response
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)
@@ -24,6 +25,7 @@ fn test_single_request_scan() -> Result<(), Box<dyn std::error::Error>> {
.arg(srv.url("/"))
.arg("--wordlist")
.arg(file.as_os_str())
.arg("-vvvv")
.unwrap();
cmd.assert().success().stdout(
@@ -36,3 +38,375 @@ fn test_single_request_scan() -> Result<(), Box<dyn std::error::Error>> {
teardown_tmp_directory(tmp_dir);
Ok(())
}
#[test]
/// send a valid request, follow redirects into new directories, expect 301/200 responses
fn scanner_recursive_request_scan() -> 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(301)
.return_header("Location", &srv.url("/js/"))
.create_on(&srv);
let js_prod_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/prod")
.return_status(301)
.return_header("Location", &srv.url("/js/prod/"))
.create_on(&srv);
let js_dev_mock = Mock::new()
.expect_method(GET)
.expect_path("/js/dev")
.return_status(301)
.return_header("Location", &srv.url("/js/dev/"))
.create_on(&srv);
let js_dev_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")
.unwrap();
cmd.assert().success().stdout(
predicate::str::is_match("301.*js")
.unwrap()
.and(predicate::str::is_match("301.*js/prod").unwrap())
.and(predicate::str::is_match("301.*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 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 false
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_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("-T")
.arg("5")
.arg("-a")
.arg("some-user-agent-string")
.unwrap();
cmd.assert().success().stdout(
predicate::str::contains(srv.url("/LICENSE"))
.and(predicate::str::contains("301"))
.and(predicate::str::contains("14"))
.not(),
);
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(())
}

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))
}