Skip to content

Commit

Permalink
Add option to measure request & response time
Browse files Browse the repository at this point in the history
This can be useful for simple detection of endpoints susceptible
to DOS  attack
  • Loading branch information
matusf committed Jun 27, 2023
1 parent 0092e2a commit 9ca96fd
Show file tree
Hide file tree
Showing 3 changed files with 109 additions and 22 deletions.
84 changes: 65 additions & 19 deletions src/fuzzer.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
use std::{
borrow::Cow,
cell::RefCell,
collections::HashMap,
fs::{self, File},
mem,
path::PathBuf,
rc::Rc,
time::Instant,
};

use anyhow::{Context, Result};
use anyhow::{Context, Error, Result};
use indexmap::IndexMap;
use openapi_utils::ReferenceOrExt;
use openapiv3::{OpenAPI, ReferenceOr, Response, StatusCode};
Expand All @@ -19,7 +21,10 @@ use serde::{Deserialize, Serialize};
use ureq::OrAnyStatus;
use url::Url;

use crate::arbitrary::{ArbitraryParameters, Payload};
use crate::{
arbitrary::{ArbitraryParameters, Payload},
stats::Stats,
};

#[derive(Debug, Deserialize, Serialize)]
pub struct FuzzResult<'a> {
Expand All @@ -37,6 +42,7 @@ pub struct Fuzzer {
max_test_case_count: u32,
results_dir: PathBuf,
stats_dir: PathBuf,
save_stats: bool,
}

impl Fuzzer {
Expand All @@ -47,6 +53,7 @@ impl Fuzzer {
extra_headers: HashMap<String, String>,
max_test_case_count: u32,
output_dir: PathBuf,
save_stats: bool,
) -> Fuzzer {
Fuzzer {
schema,
Expand All @@ -56,6 +63,7 @@ impl Fuzzer {
max_test_case_count,
results_dir: output_dir.join("results"),
stats_dir: output_dir.join("stats"),
save_stats,
}
}

Expand All @@ -78,6 +86,9 @@ impl Fuzzer {
let paths = mem::take(&mut self.schema.paths);
let max_path_length = paths.iter().map(|(path, _)| path.len()).max().unwrap_or(0);

println!("\x1B[1mMETHOD {path:max_path_length$} STATUS MEAN (μs) STD.DEV. MIN (μs) MAX (μs)\x1B[0m",
path = "PATH"
);
for (path_with_params, mut ref_or_item) in paths {
let path_with_params = path_with_params.trim_start_matches('/');
let item = ref_or_item.to_item_mut();
Expand All @@ -98,9 +109,12 @@ impl Fuzzer {
{
let responses = mem::take(&mut operation.responses.responses);

let times = RefCell::new(vec![]);

let result = TestRunner::new(config.clone()).run(
&any_with::<Payload>(Rc::new(ArbitraryParameters::new(operation))),
|payload| {
let now = Instant::now();
let response = Fuzzer::send_request(
&self.url,
path_with_params.to_owned(),
Expand All @@ -112,30 +126,19 @@ impl Fuzzer {
TestCaseError::Fail(format!("unable to send request: {e}").into())
})?;

times.borrow_mut().push(now.elapsed().as_micros());

match self.is_expected_response(&response, &responses) {
true => Ok(()),
false => Err(TestCaseError::Fail(response.status().to_string().into())),
}
},
);

match result {
Err(TestError::Fail(reason, payload)) => {
let reason: Cow<str> = reason.message().into();
let status_code = reason
.parse::<u16>()
.map_err(|_| anyhow::Error::msg(reason.into_owned()))?;

println!("{method:7} {path_with_params:max_path_length$} failed ");
self.save_finding(path_with_params, method, payload, &status_code)?;
}
Ok(()) => {
println!("{method:7} {path_with_params:max_path_length$} ok ")
}
Err(TestError::Abort(_)) => {
println!("{method:7} {path_with_params:max_path_length$} aborted")
}
let times = times.into_inner();
if self.save_stats {
self.save_stats(path_with_params, method, &times)?;
}
self.report_run(method, path_with_params, result, max_path_length, &times)?
}
}

Expand Down Expand Up @@ -212,4 +215,47 @@ impl Fuzzer {
)
.map_err(|e| e.into())
}

fn save_stats(&self, path: &str, method: &str, times: &[u128]) -> Result<()> {
let file = format!("{}-{method}.json", path.trim_matches('/').replace('/', "-"));

serde_json::to_writer(
&File::create(self.stats_dir.join(&file))
.context(format!("Unable to create file: {file:?}"))?,
times,
)
.map_err(|e| e.into())
}

fn report_run(
&self,
method: &str,
path_with_params: &str,
result: Result<(), TestError<Payload>>,
max_path_length: usize,
times: &[u128],
) -> Result<()> {
let status = match result {
Err(TestError::Fail(reason, payload)) => {
let reason: Cow<str> = reason.message().into();
let status_code = reason
.parse::<u16>()
.map_err(|_| Error::msg(reason.into_owned()))?;

self.save_finding(path_with_params, method, payload, &status_code)?;
"failed"
}
Ok(()) => "ok",
Err(TestError::Abort(_)) => "aborted",
};

let Stats {
min,
max,
mean,
std_dev,
} = Stats::compute(times).ok_or(Error::msg("no requests sent"))?;
println!("{method:7} {path_with_params:max_path_length$} {status:^7} {mean:10.0} {std_dev:8.0} {min:8} {max:10}");
Ok(())
}
}
15 changes: 12 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
mod arbitrary;
mod fuzzer;
mod stats;

use std::fs;
use std::path::PathBuf;
use std::str::FromStr;
use std::{fs, time::Instant};

use anyhow::{Context, Result};
use argh::FromArgs;
Expand Down Expand Up @@ -53,10 +54,15 @@ struct RunArgs {
#[argh(option, default = "256")]
max_test_case_count: u32,

/// directory for results with minimal generated payload used for resending requests
/// (default: output)
/// directory for results with minimal generated payload used for resending requests.
/// findings from fuzzer will be located in results folder in this folder (default: output)
#[argh(option, short = 'o', default = "String::from(\"output\").into()")]
output: PathBuf,

/// save statistics for graph generation. the statistics will be located in folder
/// stats in output folder (default: false)
#[argh(switch)]
save_stats: bool,
}

#[derive(FromArgs, Debug, PartialEq)]
Expand Down Expand Up @@ -131,15 +137,18 @@ fn main() -> Result<()> {
serde_yaml::from_str(&specfile).context("Failed to parse schema")?;
let openapi_schema = openapi_schema.deref_all();

let now = Instant::now();
Fuzzer::new(
openapi_schema,
args.url.into(),
args.ignore_status_code,
args.header.into_iter().map(Into::into).collect(),
args.max_test_case_count,
args.output,
args.save_stats,
)
.run()?;
println!("Elapsed time: {}s", now.elapsed().as_secs());
}
Subcommands::Resend(args) => {
let json = fs::read_to_string(&args.file)
Expand Down
32 changes: 32 additions & 0 deletions src/stats.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#[derive(Debug)]
pub struct Stats {
pub min: u128,
pub max: u128,
pub mean: f64,
pub std_dev: f64,
}

impl Stats {
pub fn compute(data: &[u128]) -> Option<Stats> {
if data.is_empty() {
return None;
}
let min = *data.iter().min().expect("data length is nonzero");
let max = *data.iter().max().expect("data length is nonzero");
let sum: u128 = data.iter().sum();
let mean = sum as f64 / data.len() as f64;

let variance = data
.iter()
.map(|value| (mean - (*value as f64)).powf(2.))
.sum::<f64>()
/ (data.len() as f64);

Some(Stats {
min,
max,
mean,
std_dev: variance.sqrt(),
})
}
}

0 comments on commit 9ca96fd

Please sign in to comment.