Compare commits

...

48 Commits

Author SHA1 Message Date
epi
185808b289 Merge pull request #71 from epi052/66-capture-logging-in-logfile
Log records can be captured in a log file
2020-10-12 06:56:41 -05:00
epi
f676f56d71 cleaned up a few things during PR review 2020-10-12 06:32:33 -05:00
epi
fbffb57db3 increased heuristics test coverage agian 2020-10-12 05:48:01 -05:00
epi
26e27c340b added test coverage for heuristics 2020-10-12 05:27:47 -05:00
epi
530672f45f version upped to 1.0.4 2020-10-11 20:50:46 -05:00
epi
2f26187f61 happy with this implementation; just needs cleanup/polish 2020-10-11 20:50:05 -05:00
epi
4515e6a516 working, more or less. thinking a channel is in order 2020-10-10 21:06:44 -05:00
epi
2e8f05883d updated grcov options 2020-10-10 06:21:58 -05:00
epi
aa7871cca8 updated grcov options 2020-10-10 05:59:30 -05:00
epi
40e803ef07 updated grcov options 2020-10-10 05:38:43 -05:00
epi
86199002c9 added parser initialize test 2020-10-09 20:06:31 -05:00
epi
29abef6386 added parser initialize test 2020-10-09 20:05:21 -05:00
epi
d9271f6fe7 updated rust flags for profiling test coverage 2020-10-09 19:16:52 -05:00
epi
9881d65cc3 add linux tar.gz build for homebrew installs 2020-10-09 19:07:39 -05:00
epi
11f7a7e6f7 add linux tar.gz build for homebrew installs 2020-10-09 19:06:32 -05:00
epi
f64c5a8fdb Merge pull request #59 from epi052/58-improve-test-coverage
improve test coverage
2020-10-09 16:53:07 -05:00
epi
3cf278a77a removed pre-commit metadata block 2020-10-09 16:38:52 -05:00
epi
5327f3931e add linux tar.gz build for homebrew installs 2020-10-09 16:35:50 -05:00
epi
4cf8f030de add linux tar.gz build for homebrew installs 2020-10-09 16:28:13 -05:00
epi
2a8ebd0e04 added more heuristics tests 2020-10-09 15:48:09 -05:00
epi
8d335d7e90 added two tests to cover static wildcards 2020-10-09 15:34:33 -05:00
epi
ec1458cdc3 added two tests to cover static wildcards 2020-10-09 15:34:19 -05:00
epi
109d38f2ea trying coveralls coverage reporting 2020-10-09 14:17:13 -05:00
epi
2751bb844a added dontfilter test and removed dead code 2020-10-09 13:07:38 -05:00
epi
74b0065ce2 removed pre-commit dependency 2020-10-09 12:49:37 -05:00
epi
caa3674bba fmt 2020-10-09 12:32:53 -05:00
epi
4f557511b4 added no recursion/sizefilter test 2020-10-09 11:43:31 -05:00
epi
238f071d0a cargo fmt ran 2020-10-09 07:38:31 -05:00
epi
d19c7bfe17 added more tests for scanner 2020-10-09 06:28:47 -05:00
epi
65c0138e1a Merge branch 'master' into 58-improve-test-coverage 2020-10-09 05:48:15 -05:00
epi
db0e56bee2 updated README with cli commands for grabbing releases 2020-10-09 05:44:35 -05:00
epi
71649d1296 Merge pull request #68 from epi052/67-duplicate-scans-occurring
fixed duplicate directory scans
2020-10-08 20:50:48 -05:00
epi
a89f2be37b fmt / clippy 2020-10-08 20:43:24 -05:00
epi
572e5b7a95 fixed duplicate directory scans 2020-10-08 20:39:13 -05:00
epi
2e71d91960 Merge pull request #64 from TGotwig/patch-1
Publish with Homebrew on MacOS & Linux 🍺
2020-10-08 13:04:46 -05:00
epi
f9cdd91da9 added tar.gz for homebrew installs 2020-10-08 07:15:29 -05:00
epi
003b7f39f7 added tar.gz for homebrew installs 2020-10-08 06:53:45 -05:00
epi
39dfe442e8 added tar.gz for homebrew installs 2020-10-08 06:35:13 -05:00
epi
7d75a2cfd4 added tar.gz for homebrew installs 2020-10-08 06:28:59 -05:00
Thomas Gotwig
9657385282 Publish with Homebrew on MacOS & Linux 🍺
closes #63
2020-10-07 14:50:22 +02:00
epi
4c1094b59c added unit tests for reached_max_depth 2020-10-07 07:20:47 -05:00
epi
63ce5787d7 added invalid file output test 2020-10-07 06:46:34 -05:00
epi
5af8812929 added another output file test 2020-10-07 06:37:31 -05:00
epi
d5c508bc28 added scan with output file test 2020-10-07 06:33:37 -05:00
epi
603004a5bd updated client test 2020-10-07 05:46:16 -05:00
epi
a906b9731e added client test; setup_tmp_directory accepts a filename now 2020-10-07 05:30:58 -05:00
epi
f173147352 added client unit test 2020-10-06 19:45:01 -05:00
epi
bb1532e459 added test for bad proxy; added panic logic instead of exit for tests 2020-10-06 07:13:34 -05:00
20 changed files with 1438 additions and 260 deletions

View File

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

View File

@@ -41,10 +41,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]
@@ -59,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
@@ -97,3 +128,4 @@ jobs:
with:
name: ${{ matrix.name }}
path: ${{ matrix.path }}

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "feroxbuster"
version = "1.0.2"
version = "1.0.4"
authors = ["Ben 'epi' Risher <epibar052@gmail.com>"]
license = "MIT"
edition = "2018"

View File

@@ -62,6 +62,7 @@ This attack is also known as Predictable Resource Location, File Enumeration, Di
- [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)
- [Docker Install](#docker-install)
@@ -83,13 +84,62 @@ This attack is also known as Predictable Resource Location, File Enumeration, Di
### 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
```
wget -sLO https://github.com/epi052/feroxbuster/releases/latest/download/x86-linux-feroxbuster.zip
unzip x86-linux-feroxbuster.zip
./feroxbuster -V
```
#### Linux x86_64
```
wget -sLO https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-linux-feroxbuster.zip
unzip x86_64-linux-feroxbuster.zip
./feroxbuster -V
```
#### MacOS x86_64
```
wget -sLO https://github.com/epi052/feroxbuster/releases/latest/download/x86_64-macos-feroxbuster.zip
unzip x86_64-macos-feroxbuster.zip
./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
@@ -101,9 +151,11 @@ 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
```

View File

@@ -1,9 +1,9 @@
use crate::utils::{module_colorizer, status_colorizer};
use console::style;
use reqwest::header::HeaderMap;
use reqwest::{redirect::Policy, Client, Proxy};
use std::collections::HashMap;
use std::convert::TryInto;
#[cfg(not(test))]
use std::process::exit;
use std::time::Duration;
@@ -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"),
module_colorizer("Client::initialize"),
e
);
exit(1);
}
};
// try_into returns infallible as its error, unwrap is safe here
let header_map: HeaderMap = headers.try_into().unwrap();
let client = Client::builder()
.timeout(Duration::new(timeout, 0))
@@ -55,9 +45,13 @@ pub fn initialize(
eprintln!(
"{} {} {}",
status_colorizer("ERROR"),
style("Client::initialize").cyan(),
module_colorizer("Client::initialize"),
e
);
#[cfg(test)]
panic!();
#[cfg(not(test))]
exit(1);
}
}
@@ -79,7 +73,32 @@ pub fn initialize(
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

@@ -325,7 +325,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()
@@ -347,7 +352,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)
})
})

View File

@@ -6,6 +6,7 @@ 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
@@ -21,7 +22,11 @@ const UUID_LENGTH: u64 = 32;
/// configuration and any static wildcard lengths.
#[derive(Default, Debug)]
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,
}
@@ -48,8 +53,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
@@ -57,7 +71,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
@@ -72,7 +89,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);
@@ -83,29 +100,43 @@ pub async fn wildcard_test(target_url: &str, bar: ProgressBar) -> Option<Wildcar
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 {}",
let msg = format!(
"{} {:>10} Wildcard response is dynamic; {} ({} + url length) responses; toggle this behavior by using {}\n",
status_colorizer("WLD"),
wc_length - url_len,
style("auto-filtering").yellow(),
style(wc_length - url_len).cyan(),
style("--dontfilter").yellow()
), &PROGRESS_PRINTER
);
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 {}",
let msg = format!(
"{} {:>10} Wildcard response is static; {} {} responses; toggle this behavior by using {}\n",
status_colorizer("WLD"),
wc_length,
style("auto-filtering").yellow(),
style(wc_length).cyan(),
style("--dontfilter").yellow()
), &PROGRESS_PRINTER);
);
ferox_print(&msg, &PROGRESS_PRINTER);
try_send_message_to_file(
&msg,
tx_file.clone(),
!CONFIGURATION.output.is_empty(),
);
}
wildcard.size = wc_length;
}
@@ -127,8 +158,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);
@@ -160,44 +200,60 @@ async fn make_wildcard_request(target_url: &str, length: usize) -> Option<Respon
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,
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 => {:?}",
let msg = format!(
"{} {:>10} {} redirects to => {}\n",
wildcard,
content_len,
response.url(),
next_loc
),
&PROGRESS_PRINTER,
next_loc_str
);
ferox_print(&msg, &PROGRESS_PRINTER);
try_send_message_to_file(
&msg,
tx_file.clone(),
!CONFIGURATION.output.is_empty(),
);
}
} else if !CONFIGURATION.quiet {
let msg = format!(
"{} {:>10} {} redirects to => {:?}\n",
wildcard,
content_len,
response.url(),
next_loc
);
ferox_print(&msg, &PROGRESS_PRINTER);
try_send_message_to_file(
&msg,
tx_file.clone(),
!CONFIGURATION.output.is_empty(),
);
}
}
@@ -274,15 +330,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]
/// request a unique string of 32bytes * a value returns correct result
fn unique_string_returns_correct_length() {
fn heuristics_unique_string_returns_correct_length() {
for i in 0..10 {
assert_eq!(unique_string(i).len(), i * 32);
}
}
#[test]
/// simply test the default values for wildcardfilter, expect 0, 0
fn heuristics_wildcardfilter_dafaults() {
let wcf = WildcardFilter::default();
assert_eq!(wcf.size, 0);
assert_eq!(wcf.dynamic, 0);
}
#[tokio::test(core_threads = 1)]
/// tests that given a message and transmitter, the function sends the message across the
/// channel
async fn heuristics_try_send_message_to_file_sends_when_true() {
let (tx, mut rx): FeroxChannel<String> = mpsc::unbounded_channel();
let msg = "It really tied the room together.";
let should_save = true;
try_send_message_to_file(&msg, tx, should_save);
assert_eq!(rx.recv().await.unwrap(), msg);
}
#[tokio::test(core_threads = 1)]
#[should_panic]
/// tests that when save_output is false, nothing is sent to the receiver
async fn heuristics_try_send_message_to_file_sends_when_false() {
let (tx, mut rx): FeroxChannel<String> = mpsc::unbounded_channel();
let msg = "I'm the Dude, so that's what you call me.";
let should_save = false;
try_send_message_to_file(&msg, tx, should_save);
assert_ne!(rx.recv().await.unwrap(), msg);
}
#[tokio::test(core_threads = 1)]
/// tests that when save_output is true, but the receiver is closed, nothing is sent to the receiver
/// this test doesn't assert anything, but reaches the error block of the given function and
/// can be verified with --nocapture and RUST_LOG being set
async fn heuristics_try_send_message_to_file_sends_with_closed_receiver() {
env_logger::init();
let (tx, mut rx): FeroxChannel<String> = mpsc::unbounded_channel();
let msg = "Hey, nice marmot.";
let should_save = true;
rx.close();
try_send_message_to_file(&msg, tx, should_save);
}
}

View File

@@ -5,15 +5,20 @@ 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 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");

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,14 +1,16 @@
use feroxbuster::config::{CONFIGURATION, PROGRESS_PRINTER};
use feroxbuster::scanner::scan_url;
use feroxbuster::utils::{ferox_print, get_current_depth, module_colorizer, status_colorizer};
use feroxbuster::{banner, heuristics, logger, FeroxResult};
use feroxbuster::{banner, heuristics, logger, reporter, FeroxResult};
use futures::StreamExt;
use reqwest::Response;
use std::collections::HashSet;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::process;
use std::sync::Arc;
use tokio::io;
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::codec::{FramedRead, LinesCodec};
/// Create a HashSet of Strings from the given wordlist then stores it inside an Arc
@@ -48,8 +50,12 @@ fn get_unique_words_from_wordlist(path: &str) -> FeroxResult<Arc<HashSet<String>
}
/// Determine whether it's a single url scan or urls are coming from stdin, then scan as needed
async fn scan(targets: Vec<String>) -> FeroxResult<()> {
log::trace!("enter: scan");
async fn scan(
targets: Vec<String>,
tx_term: UnboundedSender<Response>,
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
@@ -70,11 +76,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);
@@ -112,11 +120,18 @@ async fn get_targets() -> FeroxResult<Vec<String>> {
#[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 = match get_targets().await {
Ok(t) => t,
@@ -144,14 +159,49 @@ async fn main() {
// 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

@@ -229,3 +229,15 @@ EXAMPLES:
./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");
}
}

230
src/reporter.rs Normal file
View File

@@ -0,0 +1,230 @@
use crate::config::{CONFIGURATION, PROGRESS_PRINTER};
use crate::utils::{ferox_print, status_colorizer};
use crate::FeroxChannel;
use console::strip_ansi_codes;
use reqwest::Response;
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<Response>,
UnboundedSender<String>,
JoinHandle<()>,
Option<JoinHandle<()>>,
) {
log::trace!("enter: initialize({}, {})", output_file, save_output);
let (tx_rpt, rx_rpt): FeroxChannel<Response> = 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<Response>,
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().unwrap_or(0),
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,122 +1,62 @@
use crate::config::{CONFIGURATION, PROGRESS_BAR, PROGRESS_PRINTER};
use crate::config::{CONFIGURATION, PROGRESS_BAR};
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};
use futures::future::{BoxFuture, FutureExt};
use futures::{stream, StreamExt};
use lazy_static::lazy_static;
use reqwest::{Response, Url};
use std::collections::HashSet;
use std::convert::TryInto;
use std::ops::Deref;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::fs;
use tokio::io::{self, AsyncWriteExt};
use std::sync::{Arc, RwLock};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
/// Single atomic number that gets incremented once, used to track first scan vs. all others
static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
/// Spawn a single consumer task (sc side of mpsc)
///
/// The consumer simply receives responses and writes them to the given output file if they meet
/// the given reporting criteria
async fn spawn_file_reporter(mut report_channel: UnboundedReceiver<Response>) {
log::trace!("enter: spawn_file_reporter({:?}", report_channel);
log::info!("Writing scan results to {}", CONFIGURATION.output);
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");
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());
}
/// 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
}
log::debug!("report complete: {}", resp.url());
}
log::trace!("exit: spawn_terminal_reporter");
}
/// Spawn a single consumer task (sc side of mpsc)
@@ -126,22 +66,44 @@ fn spawn_recursion_handler(
mut recursion_channel: UnboundedReceiver<String>,
wordlist: Arc<HashSet<String>>,
base_depth: usize,
tx_term: UnboundedSender<Response>,
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
@@ -250,10 +212,15 @@ fn response_is_directory(response: &Response) -> bool {
///
/// Essentially looks at the Url path and determines how many directories are present in the
/// given Url
fn reached_max_depth(url: &Url, base_depth: usize) -> bool {
log::trace!("enter: reached_max_depth({}, {})", url, base_depth);
fn reached_max_depth(url: &Url, base_depth: usize, max_depth: usize) -> bool {
log::trace!(
"enter: reached_max_depth({}, {}, {})",
url,
base_depth,
max_depth
);
if CONFIGURATION.depth == 0 {
if max_depth == 0 {
// early return, as 0 means recurse forever; no additional processing needed
log::trace!("exit: reached_max_depth -> false");
return false;
@@ -261,7 +228,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;
}
@@ -284,7 +251,9 @@ async fn try_recursion(
transmitter
);
if !reached_max_depth(response.url(), base_depth) && response_is_directory(&response) {
if !reached_max_depth(response.url(), base_depth, CONFIGURATION.depth)
&& response_is_directory(&response)
{
if CONFIGURATION.redirects {
// response is 2xx can simply send it because we're following redirects
log::info!("Added new directory to recursive scan: {}", response.url());
@@ -295,9 +264,8 @@ async fn try_recursion(
}
Err(e) => {
log::error!(
"could not send {} across {:?}: {}",
"Could not send {} to recursion handler: {}",
response.url(),
transmitter,
e
);
}
@@ -311,9 +279,8 @@ async fn try_recursion(
Ok(_) => {}
Err(e) => {
log::error!(
"could not send {}/ across {:?}: {}",
"Could not send {}/ to recursion handler: {}",
response.url(),
transmitter,
e
);
}
@@ -407,21 +374,25 @@ async fn make_requests(
/// 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<Response>,
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()
@@ -437,44 +408,43 @@ pub async fn scan_url(target_url: &str, wordlist: Arc<HashSet<String>>, base_dep
// join can only be called once, otherwise it causes the thread to panic
tokio::task::spawn_blocking(move || PROGRESS_BAR.join().unwrap());
CALL_COUNT.fetch_add(1, Ordering::Relaxed);
// this protection 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()),
};
// 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
(
@@ -511,18 +481,6 @@ pub async fn scan_url(target_url: &str, wordlist: Arc<HashSet<String>>, base_dep
futures::future::join_all(recurser.await.unwrap()).await;
log::trace!("done awaiting recursive scan receiver/scans");
// same thing here, drop report tx so the rx can finish up
log::trace!("dropped report handler's transmitter");
drop(tx_rpt);
log::trace!("awaiting report receiver");
match reporter.await {
Ok(_) => {}
Err(e) => {
log::error!("error awaiting report receiver: {}", e);
}
}
log::trace!("done awaiting report receiver");
log::trace!("exit: scan_url");
}
@@ -583,4 +541,79 @@ mod tests {
assert_eq!(urls, expected[i]);
}
}
#[test]
/// call reached_max_depth with max depth of zero, which is infinite recursion, expect false
fn reached_max_depth_returns_early_on_zero() {
let url = Url::parse("http://localhost").unwrap();
let result = reached_max_depth(&url, 0, 0);
assert!(!result);
}
#[test]
/// call reached_max_depth with url depth equal to max depth, expect true
fn reached_max_depth_current_depth_equals_max() {
let url = Url::parse("http://localhost/one/two").unwrap();
let result = reached_max_depth(&url, 0, 2);
assert!(result);
}
#[test]
/// call reached_max_depth with url dpeth less than max depth, expect false
fn reached_max_depth_current_depth_less_than_max() {
let url = Url::parse("http://localhost").unwrap();
let result = reached_max_depth(&url, 0, 2);
assert!(!result);
}
#[test]
/// call reached_max_depth with url of 2, base depth of 2, and max depth of 2, expect false
fn reached_max_depth_base_depth_equals_max_depth() {
let url = Url::parse("http://localhost/one/two").unwrap();
let result = reached_max_depth(&url, 2, 2);
assert!(!result);
}
#[test]
/// call reached_max_depth with url depth greater than max depth, expect true
fn reached_max_depth_current_greater_than_max() {
let url = Url::parse("http://localhost/one/two/three").unwrap();
let result = reached_max_depth(&url, 0, 2);
assert!(result);
}
#[test]
/// add an unknown url to the hashset, expect true
fn add_url_to_list_of_scanned_urls_with_unknown_url() {
let urls = RwLock::new(HashSet::<String>::new());
let url = "http://unknown_url";
assert_eq!(add_url_to_list_of_scanned_urls(url, &urls), true);
}
#[test]
/// add a known url to the hashset, with a trailing slash, expect false
fn add_url_to_list_of_scanned_urls_with_known_url() {
let urls = RwLock::new(HashSet::<String>::new());
let url = "http://unknown_url/";
assert_eq!(urls.write().unwrap().insert(url.to_string()), true);
assert_eq!(add_url_to_list_of_scanned_urls(url, &urls), false);
}
#[test]
/// add a known url to the hashset, without a trailing slash, expect false
fn add_url_to_list_of_scanned_urls_with_known_url_without_slash() {
let urls = RwLock::new(HashSet::<String>::new());
let url = "http://unknown_url";
assert_eq!(
urls.write()
.unwrap()
.insert("http://unknown_url/".to_string()),
true
);
assert_eq!(add_url_to_list_of_scanned_urls(url, &urls), false);
}
}

View File

@@ -11,7 +11,7 @@ fn banner_prints_proxy() -> Result<(), Box<dyn std::error::Error>> {
String::from("http://localhost"),
String::from("http://schmocalhost"),
];
let (tmp_dir, file) = setup_tmp_directory(&urls)?;
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
Command::cargo_bin("feroxbuster")
.unwrap()

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

View File

@@ -10,7 +10,7 @@ use utils::{setup_tmp_directory, teardown_tmp_directory};
/// test passes one bad target via -u to the scanner, expected result is that the
/// scanner dies
fn test_single_target_cannot_connect() -> Result<(), Box<dyn std::error::Error>> {
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()])?;
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE".to_string()], "wordlist")?;
Command::cargo_bin("feroxbuster")
.unwrap()
@@ -37,7 +37,7 @@ fn test_two_targets_cannot_connect() -> Result<(), Box<dyn std::error::Error>> {
let not_real =
String::from("http://fjdksafjkdsajfkdsajkfdsajkfsdjkdsfdsafdsafdsajkr3l2ajfdskafdsjk");
let urls = vec![not_real.clone(), not_real];
let (tmp_dir, file) = setup_tmp_directory(&urls)?;
let (tmp_dir, file) = setup_tmp_directory(&urls, "wordlist")?;
Command::cargo_bin("feroxbuster")
.unwrap()
@@ -67,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)
@@ -100,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)
@@ -132,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)
@@ -158,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"))
@@ -179,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(())
}

View File

@@ -39,7 +39,7 @@ fn main_use_root_owned_file_as_wordlist() -> Result<(), Box<dyn std::error::Erro
/// send the function an empty file
fn main_use_empty_wordlist() -> Result<(), Box<dyn std::error::Error>> {
let srv = MockServer::start();
let (tmp_dir, file) = setup_tmp_directory(&[])?;
let (tmp_dir, file) = setup_tmp_directory(&[], "wordlist")?;
let mock = Mock::new()
.expect_method(GET)
@@ -70,7 +70,7 @@ fn main_use_empty_wordlist() -> Result<(), Box<dyn std::error::Error>> {
#[test]
/// send nothing over stdin, expect heuristics to be upset during connectivity test
fn main_use_empty_stdin_targets() -> Result<(), Box<dyn std::error::Error>> {
let (tmp_dir, file) = setup_tmp_directory(&[])?;
let (tmp_dir, file) = setup_tmp_directory(&[], "wordlist")?;
// get_targets is called before scan, so the empty wordlist shouldn't trigger
// the 'Did not find any words' error

View File

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