mirror of
https://github.com/epi052/feroxbuster.git
synced 2026-04-19 06:31:13 -03:00
376 lines
13 KiB
Rust
376 lines
13 KiB
Rust
mod utils;
|
|
use assert_cmd::cargo_bin;
|
|
use assert_cmd::prelude::*;
|
|
use httpmock::Method::GET;
|
|
use httpmock::MockServer;
|
|
use regex::Regex;
|
|
use std::fs::{read_to_string, write};
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
use std::time::Instant;
|
|
use tokio::time::Duration;
|
|
use utils::{setup_tmp_directory, teardown_tmp_directory};
|
|
|
|
// tests/policy-test-error-words is a wordlist with the following attributes:
|
|
// - 60 errors per error category (error, 403, 429)
|
|
// - 1000 words tagged as normal for noise/padding
|
|
// - each error string is 6_RANDOM_ASCII{error,status403,status429,normal}6_RANDOM_ASCII
|
|
// examples:
|
|
// - BKPMiherrortBPKcw
|
|
// - lTjbLpstatus403fZQaFD
|
|
// - ZhGBHGstatus429SIUZvI
|
|
// - ufzEXWnormalOLhbLM
|
|
// these words will be used along with pattern matching to trigger different policies
|
|
|
|
#[test]
|
|
/// --auto-bail should cancel a scan with spurious 403s
|
|
fn auto_bail_cancels_scan_with_403s() {
|
|
let srv = MockServer::start();
|
|
let (tmp_dir, file) = setup_tmp_directory(&["ignored".to_string()], "wordlist").unwrap();
|
|
let (log_dir, logfile) = setup_tmp_directory(&[], "debug-log").unwrap();
|
|
|
|
let policy_words = read_to_string(Path::new("tests/policy-test-words.shuffled")).unwrap();
|
|
|
|
write(&file, policy_words).unwrap();
|
|
|
|
assert_eq!(file.metadata().unwrap().len(), 117720); // sanity check on wordlist size
|
|
|
|
let error_mock = srv.mock(|when, then| {
|
|
when.method(GET).path_matches(
|
|
Regex::new("/[a-zA-Z]{6}(error|status429|status403)[a-zA-Z]{6}").unwrap(),
|
|
);
|
|
then.status(200).body("other errors are still a 200");
|
|
});
|
|
|
|
let normal_reqs_mock = srv.mock(|when, then| {
|
|
when.method(GET)
|
|
.path_matches(Regex::new("/[a-zA-Z]{6}normal[a-zA-Z]{6}").unwrap());
|
|
then.status(403)
|
|
.body("these guys need to be 403 in order to trigger 90% threshold");
|
|
});
|
|
|
|
Command::new(cargo_bin!("feroxbuster"))
|
|
.arg("--url")
|
|
.arg(srv.url("/"))
|
|
.arg("--wordlist")
|
|
.arg(file.as_os_str())
|
|
.arg("--auto-bail")
|
|
.arg("--dont-filter")
|
|
.arg("--threads")
|
|
.arg("4")
|
|
.arg("--debug-log")
|
|
.arg(logfile.as_os_str())
|
|
.arg("-vv")
|
|
.arg("--json")
|
|
.assert()
|
|
.success();
|
|
|
|
println!("log filesize: {}", logfile.metadata().unwrap().len());
|
|
let debug_log = read_to_string(logfile).unwrap();
|
|
let re = Regex::new("total_expected: ([0-9]+),").unwrap();
|
|
|
|
// read debug log to get the number of errors enforced
|
|
for line in debug_log.lines() {
|
|
let log: serde_json::Value = serde_json::from_str(line).unwrap_or_default();
|
|
if let Some(message) = log.get("message") {
|
|
let str_msg = message.as_str().unwrap_or_default().to_string();
|
|
|
|
if str_msg.starts_with("Stats") {
|
|
println!("{str_msg}");
|
|
assert!(re.is_match(&str_msg));
|
|
let total_expected = re
|
|
.captures(&str_msg)
|
|
.unwrap()
|
|
.get(1)
|
|
.map_or("", |m| m.as_str())
|
|
.parse::<usize>()
|
|
.unwrap();
|
|
println!("total_expected: {total_expected}");
|
|
assert!(total_expected < 5000);
|
|
}
|
|
}
|
|
}
|
|
|
|
teardown_tmp_directory(tmp_dir);
|
|
teardown_tmp_directory(log_dir);
|
|
|
|
assert!(normal_reqs_mock.hits() + error_mock.hits() > 25); // must have at least 50 reqs fly
|
|
|
|
// expect much less in the way of requests for this one, 90% is measured against requests made,
|
|
// not requests expected, so 90% can be reached very quickly. for the same reason, the
|
|
// num_enforced can be less than 50
|
|
assert!(normal_reqs_mock.hits() < 500);
|
|
assert!(error_mock.hits() <= 180); // may or may not see all other error requests
|
|
}
|
|
|
|
#[test]
|
|
/// --auto-bail should cancel a scan with spurious 429s
|
|
fn auto_bail_cancels_scan_with_429s() {
|
|
let srv = MockServer::start();
|
|
let (tmp_dir, file) = setup_tmp_directory(&["ignored".to_string()], "wordlist").unwrap();
|
|
let (log_dir, logfile) = setup_tmp_directory(&[], "debug-log").unwrap();
|
|
|
|
let policy_words = read_to_string(Path::new("tests/policy-test-words.shuffled")).unwrap();
|
|
|
|
write(&file, policy_words).unwrap();
|
|
|
|
assert_eq!(file.metadata().unwrap().len(), 117720); // sanity check on wordlist size
|
|
|
|
let error_mock = srv.mock(|when, then| {
|
|
when.method(GET).path_matches(
|
|
Regex::new("/[a-zA-Z]{6}(error|status429|status403)[a-zA-Z]{6}").unwrap(),
|
|
);
|
|
then.status(200).body("other errors are still a 200");
|
|
});
|
|
|
|
let normal_reqs_mock = srv.mock(|when, then| {
|
|
when.method(GET)
|
|
.path_matches(Regex::new("/[a-zA-Z]{6}normal[a-zA-Z]{6}").unwrap());
|
|
then.status(429)
|
|
.body("these guys need to be 403 in order to trigger 90% threshold");
|
|
});
|
|
|
|
Command::new(cargo_bin!("feroxbuster"))
|
|
.arg("--url")
|
|
.arg(srv.url("/"))
|
|
.arg("--wordlist")
|
|
.arg(file.as_os_str())
|
|
.arg("--auto-bail")
|
|
.arg("--dont-filter")
|
|
.arg("--threads")
|
|
.arg("4")
|
|
.arg("--debug-log")
|
|
.arg(logfile.as_os_str())
|
|
.arg("-vvv")
|
|
.arg("--json")
|
|
.assert()
|
|
.success();
|
|
|
|
println!("log filesize: {}", logfile.metadata().unwrap().len());
|
|
let debug_log = read_to_string(logfile).unwrap();
|
|
let re = Regex::new("total_expected: ([0-9]+),").unwrap();
|
|
|
|
// read debug log to get the number of errors enforced
|
|
for line in debug_log.lines() {
|
|
let log: serde_json::Value = serde_json::from_str(line).unwrap_or_default();
|
|
if let Some(message) = log.get("message") {
|
|
let str_msg = message.as_str().unwrap_or_default().to_string();
|
|
|
|
if str_msg.starts_with("Stats") {
|
|
println!("{str_msg}");
|
|
assert!(re.is_match(&str_msg));
|
|
let total_expected = re
|
|
.captures(&str_msg)
|
|
.unwrap()
|
|
.get(1)
|
|
.map_or("", |m| m.as_str())
|
|
.parse::<usize>()
|
|
.unwrap();
|
|
println!("total_expected: {total_expected}");
|
|
assert!(total_expected < 5000);
|
|
}
|
|
}
|
|
}
|
|
|
|
teardown_tmp_directory(tmp_dir);
|
|
teardown_tmp_directory(log_dir);
|
|
|
|
assert!(normal_reqs_mock.hits() + error_mock.hits() > 25); // must have at least 50 reqs fly
|
|
|
|
// expect much less in the way of requests for this one, 90% is measured against requests made,
|
|
// not requests expected, so 90% can be reached very quickly. for the same reason, the
|
|
// num_enforced can be less than 50
|
|
assert!(normal_reqs_mock.hits() < 500);
|
|
assert!(error_mock.hits() <= 180); // may or may not see all other error requests
|
|
}
|
|
|
|
#[test]
|
|
/// --auto-tune should slow a scan with spurious 429s
|
|
fn auto_tune_slows_scan_with_429s() {
|
|
let srv = MockServer::start();
|
|
let (tmp_dir, file) = setup_tmp_directory(&["ignored".to_string()], "wordlist").unwrap();
|
|
|
|
let policy_words = read_to_string(Path::new("tests/policy-test-words.shuffled")).unwrap();
|
|
|
|
write(&file, policy_words).unwrap();
|
|
|
|
assert_eq!(file.metadata().unwrap().len(), 117720); // sanity check on wordlist size
|
|
|
|
let error_mock = srv.mock(|when, then| {
|
|
when.method(GET).path_matches(
|
|
Regex::new("/[a-zA-Z]{6}(error|status429|status403)[a-zA-Z]{6}").unwrap(),
|
|
);
|
|
then.status(200).body("other errors are still a 200");
|
|
});
|
|
|
|
let normal_reqs_mock = srv.mock(|when, then| {
|
|
when.method(GET)
|
|
.path_matches(Regex::new("/[a-zA-Z]{6}normal[a-zA-Z]{6}").unwrap());
|
|
then.status(429)
|
|
.body("these guys need to be 429 in order to trigger 30% threshold");
|
|
});
|
|
|
|
let start = Instant::now();
|
|
|
|
Command::new(cargo_bin!("feroxbuster"))
|
|
.arg("--url")
|
|
.arg(srv.url("/"))
|
|
.arg("--wordlist")
|
|
.arg(file.as_os_str())
|
|
.arg("--auto-tune")
|
|
.arg("--dont-filter")
|
|
.arg("--time-limit")
|
|
.arg("7s")
|
|
.arg("--threads")
|
|
.arg("4")
|
|
.assert()
|
|
.failure();
|
|
|
|
teardown_tmp_directory(tmp_dir);
|
|
|
|
let normal_hits = normal_reqs_mock.hits();
|
|
let error_hits = error_mock.hits();
|
|
|
|
println!("normal_reqs_mock.hits(): {}", normal_hits);
|
|
println!("error_mock.hits(): {}", error_hits);
|
|
|
|
assert!(normal_hits + error_hits > 25); // must have at least 50 reqs fly
|
|
|
|
println!("elapsed: {}", start.elapsed().as_millis());
|
|
// With auto-tune and 429s, the scan should be slowed down but may still process
|
|
// ~1800-2000 requests in 7 seconds. The key is that it hits the time limit.
|
|
assert!(
|
|
normal_hits < 3000,
|
|
"Should process fewer than 3000 requests due to rate limiting"
|
|
);
|
|
assert!(error_hits <= 180); // may or may not see all other error requests
|
|
assert!(start.elapsed().as_millis() >= 7000); // scan should hit time limit due to limiting
|
|
}
|
|
|
|
#[test]
|
|
/// --auto-tune should slow a scan with spurious 403s
|
|
fn auto_tune_slows_scan_with_403s() {
|
|
let srv = MockServer::start();
|
|
let (tmp_dir, file) = setup_tmp_directory(&["ignored".to_string()], "wordlist").unwrap();
|
|
|
|
let policy_words = read_to_string(Path::new("tests/policy-test-words.shuffled")).unwrap();
|
|
|
|
write(&file, policy_words).unwrap();
|
|
|
|
assert_eq!(file.metadata().unwrap().len(), 117720); // sanity check on wordlist size
|
|
|
|
let error_mock = srv.mock(|when, then| {
|
|
when.method(GET).path_matches(
|
|
Regex::new("/[a-zA-Z]{6}(error|status429|status403)[a-zA-Z]{6}").unwrap(),
|
|
);
|
|
then.status(200).body("other errors are still a 200");
|
|
});
|
|
|
|
let normal_reqs_mock = srv.mock(|when, then| {
|
|
when.method(GET)
|
|
.path_matches(Regex::new("/[a-zA-Z]{6}normal[a-zA-Z]{6}").unwrap());
|
|
then.status(403)
|
|
.body("these guys need to be 403 in order to trigger 90% threshold");
|
|
});
|
|
|
|
let start = Instant::now();
|
|
|
|
Command::new(cargo_bin!("feroxbuster"))
|
|
.arg("--url")
|
|
.arg(srv.url("/"))
|
|
.arg("--wordlist")
|
|
.arg(file.as_os_str())
|
|
.arg("--auto-tune")
|
|
.arg("--dont-filter")
|
|
.arg("--time-limit")
|
|
.arg("7s")
|
|
.arg("--threads")
|
|
.arg("4")
|
|
.assert()
|
|
.failure();
|
|
|
|
teardown_tmp_directory(tmp_dir);
|
|
|
|
let normal_hits = normal_reqs_mock.hits();
|
|
let error_hits = error_mock.hits();
|
|
|
|
println!("normal_reqs_mock.hits(): {}", normal_hits);
|
|
println!("error_mock.hits(): {}", error_hits);
|
|
|
|
assert!(normal_hits + error_hits > 25); // must have at least 50 reqs fly
|
|
|
|
println!("elapsed: {}", start.elapsed().as_millis());
|
|
// With auto-tune and 403s, the scan should be slowed down but may still process
|
|
// ~1800-2000 requests in 7 seconds. The key is that it hits the time limit.
|
|
assert!(
|
|
normal_hits < 3000,
|
|
"Should process fewer than 3000 requests due to rate limiting"
|
|
);
|
|
assert!(error_hits <= 180); // may or may not see all other error requests
|
|
assert!(start.elapsed().as_millis() >= 7000); // scan should hit time limit due to limiting
|
|
}
|
|
|
|
#[test]
|
|
/// --auto-tune should slow a scan with spurious errors
|
|
fn auto_tune_slows_scan_with_general_errors() {
|
|
let srv = MockServer::start();
|
|
let (tmp_dir, file) = setup_tmp_directory(&["ignored".to_string()], "wordlist").unwrap();
|
|
|
|
let policy_words = read_to_string(Path::new("tests/policy-test-words.shuffled")).unwrap();
|
|
|
|
write(&file, policy_words).unwrap();
|
|
|
|
assert_eq!(file.metadata().unwrap().len(), 117720); // sanity check on wordlist size
|
|
|
|
let error_mock = srv.mock(|when, then| {
|
|
when.method(GET).path_matches(
|
|
Regex::new("/[a-zA-Z]{6}(error|status429|status403)[a-zA-Z]{6}").unwrap(),
|
|
);
|
|
then.status(200).body("other errors are still a 200");
|
|
});
|
|
|
|
let normal_reqs_mock = srv.mock(|when, then| {
|
|
when.method(GET)
|
|
.path_matches(Regex::new("/[a-zA-Z]{6}normal[a-zA-Z]{6}").unwrap());
|
|
then.status(200)
|
|
.body("these guys need to be 429 in order to trigger 30% threshold")
|
|
.delay(Duration::new(3, 0));
|
|
});
|
|
|
|
let start = Instant::now();
|
|
|
|
Command::new(cargo_bin!("feroxbuster"))
|
|
.arg("--url")
|
|
.arg(srv.url("/"))
|
|
.arg("--wordlist")
|
|
.arg(file.as_os_str())
|
|
.arg("--auto-tune")
|
|
.arg("--dont-filter")
|
|
.arg("--time-limit")
|
|
.arg("7s")
|
|
.arg("--threads")
|
|
.arg("4")
|
|
.arg("--timeout")
|
|
.arg("2")
|
|
.assert()
|
|
.failure();
|
|
|
|
teardown_tmp_directory(tmp_dir);
|
|
|
|
let normal_hits = normal_reqs_mock.hits();
|
|
let error_hits = error_mock.hits();
|
|
|
|
println!("normal_reqs_mock.hits(): {}", normal_hits);
|
|
println!("error_mock.hits(): {}", error_hits);
|
|
println!("elapsed: {}", start.elapsed().as_millis());
|
|
|
|
// Normal requests timeout (3s delay with 2s timeout), triggering error policy
|
|
// The scan should be rate-limited and hit the time limit
|
|
assert!(
|
|
normal_hits < 3000,
|
|
"Should process fewer requests due to rate limiting and timeouts"
|
|
);
|
|
assert!(error_hits <= 180); // may or may not see all other error requests
|
|
assert!(start.elapsed().as_millis() >= 7000); // scan should hit time limit due to limiting
|
|
}
|