use gossan_core::Target;
use reqwest::Client;
use secfinding::{Evidence, Finding, Severity};
const DEFAULT_WORDLIST: &str = include_str!("directory_wordlist.txt");
const TIER_B_PATHS: &[&str] = &[
"data/tier_b_wordlist.txt",
"crates/hidden/data/tier_b_wordlist.txt",
];
const DEFAULT_EXTENSIONS: &[&str] = &[
"", ".php", ".js", ".json", ".bak", ".txt", ".zip", ".tar.gz", ".sql", ".xml",
".old", ".save", ".swp", ".~", ".orig", ".copy", ".rar", ".7z", ".gz", ".tgz",
".bz2", ".tar", ".log", ".config", ".yml", ".yaml", ".cfg", ".ini", ".db",
".sqlite", ".sqlite3", ".mdb", ".dbf", ".csv", ".xls", ".xlsx", ".pdf",
".doc", ".docx",
];
const DEFAULT_STATUSES: &[u16] = &[200, 204, 301, 302, 307, 308, 401, 403, 405, 500];
pub fn load_wordlist(custom_path: Option<&str>) -> Vec<String> {
let mut words: Vec<String> = Vec::new();
if let Some(path) = custom_path {
if let Ok(content) = std::fs::read_to_string(path) {
words.extend(parse_wordlist(&content));
if !words.is_empty() {
tracing::info!(count = words.len(), path = path, "loaded custom directory wordlist");
return words;
}
}
}
for path in TIER_B_PATHS {
if let Ok(content) = std::fs::read_to_string(path) {
words.extend(parse_wordlist(&content));
if !words.is_empty() {
tracing::info!(count = words.len(), path = path, "loaded Tier B directory wordlist");
return words;
}
}
}
words.extend(parse_wordlist(DEFAULT_WORDLIST));
tracing::info!(count = words.len(), "using built-in directory wordlist fallback");
words
}
fn parse_wordlist(content: &str) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
content
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| l.strip_prefix('/').unwrap_or(l).to_string())
.filter(|l| !l.is_empty())
.filter(|l| seen.insert(l.clone()))
.collect()
}
pub fn extensions(custom: &[String]) -> Vec<String> {
if custom.is_empty() {
DEFAULT_EXTENSIONS.iter().map(|s| s.to_string()).collect()
} else {
custom.to_vec()
}
}
pub fn status_codes(custom: &[u16]) -> Vec<u16> {
if custom.is_empty() {
DEFAULT_STATUSES.to_vec()
} else {
custom.to_vec()
}
}
pub async fn probe(
client: &Client,
target: &Target,
wordlist: &[String],
extensions: &[String],
status_codes: &[u16],
baseline: Option<&crate::soft404::BaselineFingerprint>,
) -> Vec<Finding> {
let Target::Web(asset) = target else { return vec![] };
let base = asset.url.as_str().trim_end_matches('/');
let mut findings = Vec::new();
for path in wordlist {
let path = if path.starts_with('/') { path.clone() } else { format!("/{}", path) };
for ext in extensions {
let url = format!("{}{}{}", base, path, ext);
let Ok(resp) = client.get(&url).send().await else { continue };
let status = resp.status().as_u16();
if !status_codes.contains(&status) {
continue;
}
let bytes = match crate::soft404::read_limited(resp, crate::MAX_BODY_BYTES).await {
Some(b) => b,
None => continue,
};
if crate::soft404::is_likely_404(status, &bytes, baseline, false) {
continue;
}
let body_preview = String::from_utf8_lossy(&bytes);
let excerpt = if body_preview.len() > 200 {
format!("{}...", &body_preview[..200])
} else {
body_preview.to_string()
};
let safe_path = crate::path_sanitize::sanitize_url_path(&path);
let safe_ext = crate::path_sanitize::sanitize_url_path(ext);
if let Some(f) = Finding::builder("hidden", target.domain().unwrap_or("?"), severity_for_status(status))
.title(format!("Hidden path discovered: {}{}", safe_path, safe_ext))
.detail(format!(
"The path {}{} returned HTTP {} ({} bytes). This may expose administrative interfaces, backups, or undocumented API endpoints.",
safe_path, safe_ext, status, bytes.len()
))
.evidence(Evidence::HttpResponse {
status,
headers: vec![],
body_excerpt: Some((excerpt).into()),
})
.tag("hidden")
.tag("directory-brute")
.tag(match status {
401 | 403 => "auth-required",
500 => "server-error",
_ => "exposure",
})
.kind(secfinding::FindingKind::FileDiscovery)
.build_or_log()
{
findings.push(f);
}
break;
}
}
findings
}
fn severity_for_status(status: u16) -> Severity {
match status {
200 | 204 => Severity::High,
401 | 403 => Severity::Medium,
500 => Severity::Low,
_ => Severity::Info,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_wordlist_filters_comments_and_empty() {
let input = "# comment\n\n/admin\n/api\n/api\n";
let words = parse_wordlist(input);
assert_eq!(words, vec!["admin", "api"]);
}
#[test]
fn extensions_default_is_nonempty() {
let exts = extensions(&[]);
assert!(exts.contains(&".php".to_string()));
assert!(exts.contains(&".bak".to_string()));
assert!(exts.contains(&".yaml".to_string()));
}
#[test]
fn status_codes_default_covers_common() {
let codes = status_codes(&[]);
assert!(codes.contains(&200));
assert!(codes.contains(&401));
assert!(codes.contains(&500));
}
#[test]
fn severity_for_status_matches_expectations() {
assert_eq!(severity_for_status(200), Severity::High);
assert_eq!(severity_for_status(401), Severity::Medium);
assert_eq!(severity_for_status(500), Severity::Low);
assert_eq!(severity_for_status(301), Severity::Info);
}
}