mirror of
https://github.com/epi052/feroxbuster.git
synced 2026-06-04 15:51:12 -03:00
implemented/tested logic for collecting backups
This commit is contained in:
@@ -2,11 +2,13 @@ use super::Command::AddToUsizeField;
|
||||
use super::*;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use futures::future::{BoxFuture, FutureExt};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::{
|
||||
config::Configuration,
|
||||
progress::PROGRESS_PRINTER,
|
||||
response::FeroxResponse,
|
||||
scanner::RESPONSES,
|
||||
send_command, skip_fail,
|
||||
statistics::StatField::ResourcesDiscovered,
|
||||
@@ -15,6 +17,7 @@ use crate::{
|
||||
CommandReceiver, CommandSender, Joiner,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Container for terminal output transmitter
|
||||
@@ -185,56 +188,9 @@ impl TermOutHandler {
|
||||
|
||||
while let Some(command) = self.receiver.recv().await {
|
||||
match command {
|
||||
Command::Report(mut resp) => {
|
||||
let contains_sentry =
|
||||
self.config.status_codes.contains(&resp.status().as_u16());
|
||||
let unknown_sentry = !RESPONSES.contains(&resp); // !contains == unknown
|
||||
let should_process_response = contains_sentry && unknown_sentry;
|
||||
|
||||
if should_process_response {
|
||||
// print to stdout
|
||||
ferox_print(&resp.as_str(), &PROGRESS_PRINTER);
|
||||
|
||||
send_command!(tx_stats, AddToUsizeField(ResourcesDiscovered, 1));
|
||||
|
||||
if self.file_task.is_some() {
|
||||
// -o used, need to send the report to be written out to disk
|
||||
self.tx_file
|
||||
.send(Command::Report(resp.clone()))
|
||||
.with_context(|| {
|
||||
fmt_err(&format!("Could not send {} to file handler", resp))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
log::trace!("report complete: {}", resp.url());
|
||||
|
||||
if self.config.replay_client.is_some() && should_process_response {
|
||||
// replay proxy specified/client created and this response's status code is one that
|
||||
// should be replayed; not using logged_request due to replay proxy client
|
||||
make_request(
|
||||
self.config.replay_client.as_ref().unwrap(),
|
||||
resp.url(),
|
||||
resp.method().as_str(),
|
||||
None,
|
||||
self.config.output_level,
|
||||
&self.config,
|
||||
tx_stats.clone(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| "Could not replay request through replay proxy")?;
|
||||
}
|
||||
|
||||
if should_process_response {
|
||||
// add response to RESPONSES for serialization in case of ctrl+c
|
||||
// placed all by its lonesome like this so that RESPONSES can take ownership
|
||||
// of the FeroxResponse
|
||||
|
||||
// before ownership is transferred, there's no real reason to keep the body anymore
|
||||
// so we can free that piece of data, reducing memory usage
|
||||
resp.drop_text();
|
||||
|
||||
RESPONSES.insert(*resp);
|
||||
}
|
||||
Command::Report(resp) => {
|
||||
// todo add enum to replace bool
|
||||
self.process_response(tx_stats.clone(), resp, false).await?;
|
||||
}
|
||||
Command::Sync(sender) => {
|
||||
sender.send(true).unwrap_or_default();
|
||||
@@ -251,6 +207,139 @@ impl TermOutHandler {
|
||||
log::trace!("exit: start");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// todo
|
||||
fn process_response(
|
||||
&self,
|
||||
tx_stats: CommandSender,
|
||||
mut resp: Box<FeroxResponse>,
|
||||
recursive_call: bool,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
async move {
|
||||
let contains_sentry = self.config.status_codes.contains(&resp.status().as_u16());
|
||||
let unknown_sentry = !RESPONSES.contains(&resp); // !contains == unknown
|
||||
let should_process_response = contains_sentry && unknown_sentry;
|
||||
|
||||
if should_process_response {
|
||||
// print to stdout
|
||||
ferox_print(&resp.as_str(), &PROGRESS_PRINTER);
|
||||
|
||||
send_command!(tx_stats, AddToUsizeField(ResourcesDiscovered, 1));
|
||||
|
||||
if self.file_task.is_some() {
|
||||
// -o used, need to send the report to be written out to disk
|
||||
self.tx_file
|
||||
.send(Command::Report(resp.clone()))
|
||||
.with_context(|| {
|
||||
fmt_err(&format!("Could not send {} to file handler", resp))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
log::trace!("report complete: {}", resp.url());
|
||||
|
||||
if self.config.replay_client.is_some() && should_process_response {
|
||||
// replay proxy specified/client created and this response's status code is one that
|
||||
// should be replayed; not using logged_request due to replay proxy client
|
||||
make_request(
|
||||
self.config.replay_client.as_ref().unwrap(),
|
||||
resp.url(),
|
||||
resp.method().as_str(),
|
||||
None,
|
||||
self.config.output_level,
|
||||
&self.config,
|
||||
tx_stats.clone(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| "Could not replay request through replay proxy")?;
|
||||
}
|
||||
|
||||
// todo update if statement to include --collect-backups
|
||||
if should_process_response && !recursive_call {
|
||||
let backup_urls = self.generate_backup_urls(&resp).await;
|
||||
for backup_url in &backup_urls {
|
||||
let backup_response = make_request(
|
||||
&self.config.client,
|
||||
backup_url,
|
||||
resp.method().as_str(),
|
||||
None,
|
||||
self.config.output_level,
|
||||
&self.config,
|
||||
tx_stats.clone(),
|
||||
)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Could not request backup of {}", resp.url().as_str())
|
||||
})?;
|
||||
let mut ferox_response = FeroxResponse::from(
|
||||
backup_response,
|
||||
resp.url().as_str(),
|
||||
resp.method().as_str(),
|
||||
resp.output_level,
|
||||
)
|
||||
.await;
|
||||
self.process_response(tx_stats.clone(), Box::new(ferox_response), true)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if should_process_response {
|
||||
// add response to RESPONSES for serialization in case of ctrl+c
|
||||
// placed all by its lonesome like this so that RESPONSES can take ownership
|
||||
// of the FeroxResponse
|
||||
|
||||
// before ownership is transferred, there's no real reason to keep the body anymore
|
||||
// so we can free that piece of data, reducing memory usage
|
||||
resp.drop_text();
|
||||
|
||||
RESPONSES.insert(*resp);
|
||||
}
|
||||
log::trace!("exit: process_response");
|
||||
Ok(())
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
/// internal helper to stay DRY
|
||||
fn add_new_url_to_vec(&self, url: &Url, new_name: &str, urls: &mut Vec<Url>) {
|
||||
let mut new_url = url.clone();
|
||||
new_url.set_path(&new_name);
|
||||
urls.push(new_url);
|
||||
}
|
||||
|
||||
/// todo
|
||||
async fn generate_backup_urls(&self, response: &FeroxResponse) -> Vec<Url> {
|
||||
// todo
|
||||
let mut urls = vec![];
|
||||
let url = response.url();
|
||||
|
||||
// confirmed safe: see src/response.rs for comments
|
||||
let filename = url.path_segments().unwrap().last().unwrap();
|
||||
|
||||
if !filename.is_empty() {
|
||||
// append rules
|
||||
for suffix in ["~", ".bak", ".bak2", ".old", ".1"] {
|
||||
self.add_new_url_to_vec(&url, &format!("{}{}", filename, suffix), &mut urls);
|
||||
}
|
||||
|
||||
// vim swap rule
|
||||
self.add_new_url_to_vec(&url, &format!(".{}.swp", filename), &mut urls);
|
||||
|
||||
// replace original extension rule
|
||||
let parts: Vec<_> = filename
|
||||
.split('.')
|
||||
// keep things like /.bash_history out of results
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect();
|
||||
|
||||
if parts.len() > 1 {
|
||||
// filename + at least one extension, i.e. whatever.js becomes ["whatever", "js"]
|
||||
self.add_new_url_to_vec(url, &format!("{}.bak", parts.first().unwrap()), &mut urls);
|
||||
}
|
||||
}
|
||||
|
||||
// todo
|
||||
urls
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -286,4 +375,89 @@ mod tests {
|
||||
println!("{:?}", toh);
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// when the feroxresponse's url contains an extension, there should be 7 urls returned
|
||||
async fn generate_backup_urls_creates_correct_urls_when_extension_present() {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
|
||||
let toh = TermOutHandler {
|
||||
config,
|
||||
file_task: None,
|
||||
receiver: rx,
|
||||
tx_file,
|
||||
};
|
||||
|
||||
let expected: Vec<_> = vec![
|
||||
"derp.php~",
|
||||
"derp.php.bak",
|
||||
"derp.php.bak2",
|
||||
"derp.php.old",
|
||||
"derp.php.1",
|
||||
".derp.php.swp",
|
||||
"derp.bak",
|
||||
];
|
||||
|
||||
let mut fr = FeroxResponse::default();
|
||||
fr.set_url("http://localhost/derp.php");
|
||||
|
||||
let urls = toh.generate_backup_urls(&fr).await;
|
||||
|
||||
let paths: Vec<_> = urls
|
||||
.iter()
|
||||
.map(|url| url.path_segments().unwrap().last().unwrap())
|
||||
.collect();
|
||||
|
||||
assert_eq!(urls.len(), 7);
|
||||
|
||||
for path in paths {
|
||||
assert!(expected.contains(&path));
|
||||
}
|
||||
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
|
||||
/// when the feroxresponse's url doesn't contain an extension, there should be 6 urls returned
|
||||
async fn generate_backup_urls_creates_correct_urls_when_extension_not_present() {
|
||||
let (tx, rx) = mpsc::unbounded_channel::<Command>();
|
||||
let (tx_file, _) = mpsc::unbounded_channel::<Command>();
|
||||
let config = Arc::new(Configuration::new().unwrap());
|
||||
|
||||
let toh = TermOutHandler {
|
||||
config,
|
||||
file_task: None,
|
||||
receiver: rx,
|
||||
tx_file,
|
||||
};
|
||||
|
||||
let expected: Vec<_> = vec![
|
||||
"derp~",
|
||||
"derp.bak",
|
||||
"derp.bak2",
|
||||
"derp.old",
|
||||
"derp.1",
|
||||
".derp.swp",
|
||||
];
|
||||
|
||||
let mut fr = FeroxResponse::default();
|
||||
fr.set_url("http://localhost/derp");
|
||||
|
||||
let urls = toh.generate_backup_urls(&fr).await;
|
||||
|
||||
let paths: Vec<_> = urls
|
||||
.iter()
|
||||
.map(|url| url.path_segments().unwrap().last().unwrap())
|
||||
.collect();
|
||||
|
||||
assert_eq!(urls.len(), 6);
|
||||
|
||||
for path in paths {
|
||||
assert!(expected.contains(&path));
|
||||
}
|
||||
|
||||
tx.send(Command::Exit).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,3 +679,91 @@ fn add_discovered_extension_updates_bars_and_stats() {
|
||||
assert!(contents.contains("extensions_collected: 1"));
|
||||
assert!(contents.contains("expected_per_scan: 6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// send a request to a 200 file, expect pre-configured backup collection rules to be applied
|
||||
/// and then requested
|
||||
fn collect_backups_makes_appropriate_requests() {
|
||||
let srv = MockServer::start();
|
||||
let (tmp_dir, file) = setup_tmp_directory(&["LICENSE.txt".to_string()], "wordlist").unwrap();
|
||||
|
||||
let mock = srv.mock(|when, then| {
|
||||
when.method(GET).path("/LICENSE.txt");
|
||||
then.status(200).body("this is a test");
|
||||
});
|
||||
|
||||
let tilde_backup = srv.mock(|when, then| {
|
||||
when.method(GET).path("/LICENSE.txt~");
|
||||
then.status(200);
|
||||
});
|
||||
|
||||
let bak_backup = srv.mock(|when, then| {
|
||||
when.method(GET).path("/LICENSE.txt.bak");
|
||||
then.status(200);
|
||||
});
|
||||
|
||||
let bak2_backup = srv.mock(|when, then| {
|
||||
when.method(GET).path("/LICENSE.txt.bak2");
|
||||
then.status(200);
|
||||
});
|
||||
|
||||
let old_backup = srv.mock(|when, then| {
|
||||
when.method(GET).path("/LICENSE.txt.old");
|
||||
then.status(200);
|
||||
});
|
||||
|
||||
let dot1_backup = srv.mock(|when, then| {
|
||||
when.method(GET).path("/LICENSE.txt.1");
|
||||
then.status(200);
|
||||
});
|
||||
|
||||
let replaced_bak_backup = srv.mock(|when, then| {
|
||||
when.method(GET).path("/LICENSE.bak");
|
||||
then.status(200);
|
||||
});
|
||||
|
||||
let vim_swap_backup = srv.mock(|when, then| {
|
||||
when.method(GET).path("/.LICENSE.txt.swp");
|
||||
then.status(200);
|
||||
});
|
||||
|
||||
// todo add double backup style tests for all variants
|
||||
let tilde_double_backup = srv.mock(|when, then| {
|
||||
when.method(GET).path("/LICENSE.txt~~");
|
||||
then.status(404);
|
||||
});
|
||||
|
||||
// todo add --collect-backups flag when available
|
||||
let cmd = Command::cargo_bin("feroxbuster")
|
||||
.unwrap()
|
||||
.arg("--url")
|
||||
.arg(srv.url("/"))
|
||||
.arg("--wordlist")
|
||||
.arg(file.as_os_str())
|
||||
.unwrap();
|
||||
|
||||
// todo maybe add in some stdout checks
|
||||
cmd.assert().success().stdout(
|
||||
predicate::str::contains("/LICENSE.txt").and(predicate::str::contains("/LICENSE.txt~")),
|
||||
);
|
||||
// .and(predicate::str::contains("403"))
|
||||
// .and(predicate::str::contains("53c"))
|
||||
// .and(predicate::str::contains("14c"))
|
||||
// .and(predicate::str::contains("0c"))
|
||||
// .and(predicate::str::contains("ignored").count(2))
|
||||
// .and(predicate::str::contains("/ignored/LICENSE")),
|
||||
// );
|
||||
|
||||
assert_eq!(mock.hits(), 1);
|
||||
assert_eq!(tilde_backup.hits(), 1);
|
||||
assert_eq!(tilde_double_backup.hits(), 0); // shouldn't request backups of backups
|
||||
|
||||
assert_eq!(bak_backup.hits(), 1);
|
||||
assert_eq!(bak2_backup.hits(), 1);
|
||||
assert_eq!(old_backup.hits(), 1);
|
||||
assert_eq!(dot1_backup.hits(), 1);
|
||||
assert_eq!(replaced_bak_backup.hits(), 1);
|
||||
assert_eq!(vim_swap_backup.hits(), 1);
|
||||
|
||||
teardown_tmp_directory(tmp_dir);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user