pub mod banner;
pub mod client;
pub mod config;
pub mod extractor;
pub mod filters;
pub mod heuristics;
pub mod logger;
pub mod parser;
pub mod progress;
pub mod reporter;
pub mod scan_manager;
pub mod scanner;
pub mod utils;
use crate::utils::{get_url_path_length, status_colorizer};
use console::{style, Color};
use reqwest::header::{HeaderName, HeaderValue};
use reqwest::{header::HeaderMap, Response, StatusCode, Url};
use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use std::collections::HashMap;
use std::convert::{TryFrom, TryInto};
use std::str::FromStr;
use std::{error, fmt};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
pub type FeroxResult<T> = std::result::Result<T, Box<dyn error::Error + Send + Sync + 'static>>;
#[derive(Debug, Default)]
pub struct FeroxError {
pub message: String,
}
impl error::Error for FeroxError {}
impl fmt::Display for FeroxError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", &self.message)
}
}
pub type FeroxChannel<T> = (UnboundedSender<T>, UnboundedReceiver<T>);
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const DEFAULT_OPEN_FILE_LIMIT: usize = 8192;
pub const DEFAULT_WORDLIST: &str =
"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt";
pub static SLEEP_DURATION: u64 = 500;
pub const DEFAULT_STATUS_CODES: [StatusCode; 9] = [
StatusCode::OK,
StatusCode::NO_CONTENT,
StatusCode::MOVED_PERMANENTLY,
StatusCode::FOUND,
StatusCode::TEMPORARY_REDIRECT,
StatusCode::PERMANENT_REDIRECT,
StatusCode::UNAUTHORIZED,
StatusCode::FORBIDDEN,
StatusCode::METHOD_NOT_ALLOWED,
];
pub const DEFAULT_CONFIG_NAME: &str = "ferox-config.toml";
pub trait FeroxSerialize: Serialize {
fn as_str(&self) -> String;
fn as_json(&self) -> String;
}
#[derive(Debug, Clone)]
pub struct FeroxResponse {
url: Url,
status: StatusCode,
text: String,
content_length: u64,
line_count: usize,
word_count: usize,
headers: HeaderMap,
wildcard: bool,
}
impl fmt::Display for FeroxResponse {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"FeroxResponse {{ url: {}, status: {}, content-length: {} }}",
self.url(),
self.status(),
self.content_length()
)
}
}
impl FeroxResponse {
pub fn status(&self) -> &StatusCode {
&self.status
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn text(&self) -> &str {
&self.text
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn content_length(&self) -> u64 {
self.content_length
}
pub fn set_url(&mut self, url: &str) {
match Url::parse(&url) {
Ok(url) => {
self.url = url;
}
Err(e) => {
log::error!("Could not parse {} into a Url: {}", url, e);
}
};
}
pub fn is_file(&self) -> bool {
let has_extension = match self.url.path_segments() {
Some(path) => {
if let Some(last) = path.last() {
last.contains('.')
} else {
false
}
}
None => false,
};
self.url.query_pairs().count() > 0 || has_extension
}
pub fn line_count(&self) -> usize {
self.line_count
}
pub fn word_count(&self) -> usize {
self.word_count
}
pub async fn from(response: Response, read_body: bool) -> Self {
let url = response.url().clone();
let status = response.status();
let headers = response.headers().clone();
let content_length = response.content_length().unwrap_or(0);
let text = if read_body {
match response.text().await {
Ok(text) => text,
Err(e) => {
log::error!("Could not parse body from response: {}", e);
String::new()
}
}
} else {
String::new()
};
let line_count = text.lines().count();
let word_count = text.lines().map(|s| s.split_whitespace().count()).sum();
FeroxResponse {
url,
status,
content_length,
text,
headers,
line_count,
word_count,
wildcard: false,
}
}
}
impl FeroxSerialize for FeroxResponse {
fn as_str(&self) -> String {
let lines = self.line_count().to_string();
let words = self.word_count().to_string();
let chars = self.content_length().to_string();
let status = self.status().as_str();
let wild_status = status_colorizer("WLD");
if self.wildcard {
let mut message = format!(
"{} {:>8}l {:>8}w {:>8}c Got {} for {} (url length: {})\n",
wild_status,
lines,
words,
chars,
status_colorizer(&status),
self.url(),
get_url_path_length(&self.url())
);
if self.status().is_redirection() {
if let Some(next_loc) = self.headers().get("Location") {
let next_loc_str = next_loc.to_str().unwrap_or("Unknown");
let redirect_msg = format!(
"{} {:>9} {:>9} {:>9} {} redirects to => {}\n",
wild_status,
"-",
"-",
"-",
self.url(),
next_loc_str
);
message.push_str(&redirect_msg);
}
}
message
} else {
utils::create_report_string(
self.status.as_str(),
&lines,
&words,
&chars,
self.url().as_str(),
)
}
}
fn as_json(&self) -> String {
if let Ok(mut json) = serde_json::to_string(&self) {
json.push('\n');
json
} else {
format!("{{\"error\":\"could not convert {} to json\"}}", self.url())
}
}
}
impl Serialize for FeroxResponse {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut headers = HashMap::new();
let mut state = serializer.serialize_struct("FeroxResponse", 7)?;
for (key, value) in &self.headers {
let k = key.as_str().to_owned();
let v = String::from_utf8_lossy(value.as_bytes());
headers.insert(k, v);
}
state.serialize_field("type", "response")?;
state.serialize_field("url", self.url.as_str())?;
state.serialize_field("path", self.url.path())?;
state.serialize_field("wildcard", &self.wildcard)?;
state.serialize_field("status", &self.status.as_u16())?;
state.serialize_field("content_length", &self.content_length)?;
state.serialize_field("line_count", &self.line_count)?;
state.serialize_field("word_count", &self.word_count)?;
state.serialize_field("headers", &headers)?;
state.end()
}
}
impl<'de> Deserialize<'de> for FeroxResponse {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let mut response = Self {
url: Url::parse("http://localhost").unwrap(),
status: StatusCode::OK,
text: String::new(),
content_length: 0,
headers: HeaderMap::new(),
wildcard: false,
line_count: 0,
word_count: 0,
};
let map: HashMap<String, Value> = HashMap::deserialize(deserializer)?;
for (key, value) in &map {
match key.as_str() {
"url" => {
if let Some(url) = value.as_str() {
if let Ok(parsed) = Url::parse(url) {
response.url = parsed;
}
}
}
"status" => {
if let Some(num) = value.as_u64() {
if let Ok(smaller) = u16::try_from(num) {
if let Ok(status) = StatusCode::from_u16(smaller) {
response.status = status;
}
}
}
}
"content_length" => {
if let Some(num) = value.as_u64() {
response.content_length = num;
}
}
"line_count" => {
if let Some(num) = value.as_u64() {
response.line_count = num.try_into().unwrap_or_default();
}
}
"word_count" => {
if let Some(num) = value.as_u64() {
response.word_count = num.try_into().unwrap_or_default();
}
}
"headers" => {
let mut headers = HeaderMap::<HeaderValue>::default();
if let Some(map_headers) = value.as_object() {
for (h_key, h_value) in map_headers {
let h_value_str = h_value.as_str().unwrap_or("");
let h_name = HeaderName::from_str(h_key)
.unwrap_or_else(|_| HeaderName::from_str("Unknown").unwrap());
let h_value_parsed = HeaderValue::from_str(h_value_str)
.unwrap_or_else(|_| HeaderValue::from_str("Unknown").unwrap());
headers.insert(h_name, h_value_parsed);
}
}
response.headers = headers;
}
"wildcard" => {
if let Some(result) = value.as_bool() {
response.wildcard = result;
}
}
_ => {}
}
}
Ok(response)
}
}
#[derive(Serialize, Deserialize, Default)]
pub struct FeroxMessage {
#[serde(rename = "type")]
kind: String,
pub message: String,
pub level: String,
pub time_offset: f32,
pub module: String,
}
impl FeroxSerialize for FeroxMessage {
fn as_json(&self) -> String {
if let Ok(mut json) = serde_json::to_string(&self) {
json.push('\n');
json
} else {
String::from("{\"error\":\"could not convert to json\"}")
}
}
fn as_str(&self) -> String {
let (level_name, level_color) = match self.level.as_str() {
"ERROR" => ("ERR", Color::Red),
"WARN" => ("WRN", Color::Red),
"INFO" => ("INF", Color::Cyan),
"DEBUG" => ("DBG", Color::Yellow),
"TRACE" => ("TRC", Color::Magenta),
"WILDCARD" => ("WLD", Color::Cyan),
_ => ("UNK", Color::White),
};
format!(
"{} {:10.03} {} {}\n",
style(level_name).bg(level_color).black(),
style(self.time_offset).dim(),
self.module,
style(&self.message).dim(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_name() {
assert_eq!(DEFAULT_CONFIG_NAME, "ferox-config.toml");
}
#[test]
fn default_wordlist() {
assert_eq!(
DEFAULT_WORDLIST,
"/usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt"
);
}
#[test]
fn default_version() {
assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
}
#[test]
fn ferox_message_as_str_returns_string_with_newline() {
let message = FeroxMessage {
message: "message".to_string(),
module: "utils".to_string(),
time_offset: 1.0,
level: "INFO".to_string(),
kind: "log".to_string(),
};
let message_str = message.as_str();
assert!(message_str.contains("INF"));
assert!(message_str.contains("1.000"));
assert!(message_str.contains("utils"));
assert!(message_str.contains("message"));
assert!(message_str.ends_with('\n'));
}
#[test]
fn ferox_message_as_json_returns_json_representation_of_ferox_message_with_newline() {
let message = FeroxMessage {
message: "message".to_string(),
module: "utils".to_string(),
time_offset: 1.0,
level: "INFO".to_string(),
kind: "log".to_string(),
};
let message_str = message.as_json();
let error_margin = f32::EPSILON;
let json: FeroxMessage = serde_json::from_str(&message_str).unwrap();
assert_eq!(json.module, message.module);
assert_eq!(json.message, message.message);
assert!((json.time_offset - message.time_offset).abs() < error_margin);
assert_eq!(json.level, message.level);
assert_eq!(json.kind, message.kind);
}
}