use crate::{finding_builder, soft404, MAX_BODY_BYTES};
use futures::StreamExt;
use gossan_core::{try_push_finding, Target};
use secfinding::{Evidence, Finding, Severity};
const PARALLEL_REQUESTS: usize = 25;
struct BackupCheck {
path: &'static str,
title: &'static str,
severity: Severity,
content_probe: Option<&'static str>,
magic: &'static [&'static [u8]],
}
const BACKUP_CHECKS: &[BackupCheck] = &[
BackupCheck {
path: "/backup.zip",
title: "Backup archive (zip) exposed",
severity: Severity::Critical,
content_probe: None,
magic: &[b"PK\x03\x04"],
},
BackupCheck {
path: "/backup.tar",
title: "Backup archive (tar) exposed",
severity: Severity::Critical,
content_probe: None,
magic: &[b"\x1f\x8b", b"ustar"],
},
BackupCheck {
path: "/backup.tar.gz",
title: "Backup archive (tar.gz) exposed",
severity: Severity::Critical,
content_probe: None,
magic: &[b"\x1f\x8b"],
},
BackupCheck {
path: "/site.zip",
title: "Site snapshot exposed",
severity: Severity::Critical,
content_probe: None,
magic: &[b"PK\x03\x04"],
},
BackupCheck {
path: "/website.zip",
title: "Website snapshot exposed",
severity: Severity::Critical,
content_probe: None,
magic: &[b"PK\x03\x04"],
},
BackupCheck {
path: "/www.zip",
title: "wwwroot snapshot exposed",
severity: Severity::Critical,
content_probe: None,
magic: &[b"PK\x03\x04"],
},
BackupCheck {
path: "/htdocs.zip",
title: "htdocs snapshot exposed",
severity: Severity::Critical,
content_probe: None,
magic: &[b"PK\x03\x04"],
},
BackupCheck {
path: "/public_html.zip",
title: "public_html snapshot exposed",
severity: Severity::Critical,
content_probe: None,
magic: &[b"PK\x03\x04"],
},
BackupCheck {
path: "/admin.zip",
title: "/admin snapshot exposed",
severity: Severity::Critical,
content_probe: None,
magic: &[b"PK\x03\x04"],
},
BackupCheck {
path: "/db.sql",
title: "SQL dump exposed",
severity: Severity::Critical,
content_probe: Some("CREATE TABLE"),
magic: &[],
},
BackupCheck {
path: "/dump.sql",
title: "SQL dump exposed",
severity: Severity::Critical,
content_probe: Some("INSERT INTO"),
magic: &[],
},
BackupCheck {
path: "/dump.sql.gz",
title: "Gzipped SQL dump exposed",
severity: Severity::Critical,
content_probe: None,
magic: &[b"\x1f\x8b"],
},
BackupCheck {
path: "/data.sql",
title: "SQL dump exposed",
severity: Severity::Critical,
content_probe: Some("INSERT INTO"),
magic: &[],
},
BackupCheck {
path: "/database.sql",
title: "SQL dump exposed",
severity: Severity::Critical,
content_probe: Some("CREATE TABLE"),
magic: &[],
},
BackupCheck {
path: "/backup.sql",
title: "SQL dump exposed",
severity: Severity::Critical,
content_probe: Some("CREATE TABLE"),
magic: &[],
},
BackupCheck {
path: "/mysql.sql",
title: "MySQL dump exposed",
severity: Severity::Critical,
content_probe: Some("INSERT INTO"),
magic: &[],
},
BackupCheck {
path: "/postgres.sql",
title: "Postgres dump exposed",
severity: Severity::Critical,
content_probe: Some("CREATE TABLE"),
magic: &[],
},
BackupCheck {
path: "/.swp",
title: "Vim swap file exposed",
severity: Severity::High,
content_probe: None,
magic: &[b"b0VIM"],
},
BackupCheck {
path: "/index.php.swp",
title: "Vim swap (index.php) exposed",
severity: Severity::High,
content_probe: None,
magic: &[b"b0VIM"],
},
BackupCheck {
path: "/index.html.swp",
title: "Vim swap (index.html) exposed",
severity: Severity::High,
content_probe: None,
magic: &[b"b0VIM"],
},
BackupCheck {
path: "/wp-config.php.swp",
title: "Vim swap (wp-config.php) exposed",
severity: Severity::Critical,
content_probe: None,
magic: &[b"b0VIM"],
},
BackupCheck {
path: "/.DS_Store",
title: ".DS_Store exposed",
severity: Severity::Low,
content_probe: None,
magic: &[b"BUD1", b"bplist"],
},
BackupCheck {
path: "/index.php.bak",
title: "index.php backup exposed",
severity: Severity::High,
content_probe: Some("<?"),
magic: &[],
},
BackupCheck {
path: "/index.html.bak",
title: "index.html backup exposed",
severity: Severity::Medium,
content_probe: None,
magic: &[],
},
BackupCheck {
path: "/index.php~",
title: "index.php~ backup exposed",
severity: Severity::High,
content_probe: Some("<?"),
magic: &[],
},
BackupCheck {
path: "/index.php.old",
title: "index.php.old backup exposed",
severity: Severity::High,
content_probe: Some("<?"),
magic: &[],
},
BackupCheck {
path: "/index.php.orig",
title: "index.php.orig backup exposed",
severity: Severity::High,
content_probe: Some("<?"),
magic: &[],
},
BackupCheck {
path: "/web.config.bak",
title: "web.config backup exposed",
severity: Severity::High,
content_probe: Some("<configuration"),
magic: &[],
},
BackupCheck {
path: "/config.php.bak",
title: "config.php backup exposed",
severity: Severity::Critical,
content_probe: Some("<?"),
magic: &[],
},
BackupCheck {
path: "/settings.py.bak",
title: "settings.py backup exposed",
severity: Severity::Critical,
content_probe: Some("SECRET_KEY"),
magic: &[],
},
BackupCheck {
path: "/application.yml.bak",
title: "application.yml backup exposed",
severity: Severity::High,
content_probe: Some(":"),
magic: &[],
},
BackupCheck {
path: "/database.yml.bak",
title: "database.yml backup exposed",
severity: Severity::Critical,
content_probe: Some("password"),
magic: &[],
},
BackupCheck {
path: "/.idea/workspace.xml",
title: "JetBrains IDE workspace exposed",
severity: Severity::Medium,
content_probe: Some("<project"),
magic: &[],
},
BackupCheck {
path: "/.vscode/settings.json",
title: "VSCode settings exposed",
severity: Severity::Low,
content_probe: Some("{"),
magic: &[],
},
BackupCheck {
path: "/.project",
title: "Eclipse .project exposed",
severity: Severity::Low,
content_probe: Some("<projectDescription"),
magic: &[],
},
BackupCheck {
path: "/logs.zip",
title: "Logs archive exposed",
severity: Severity::High,
content_probe: None,
magic: &[b"PK\x03\x04"],
},
BackupCheck {
path: "/access.log.gz",
title: "Access-log archive exposed",
severity: Severity::Medium,
content_probe: None,
magic: &[b"\x1f\x8b"],
},
BackupCheck {
path: "/error.log.gz",
title: "Error-log archive exposed",
severity: Severity::Medium,
content_probe: None,
magic: &[b"\x1f\x8b"],
},
];
pub async fn probe(client: &reqwest::Client, target: &Target) -> anyhow::Result<Vec<Finding>> {
let Target::Web(asset) = target else {
return Ok(vec![]);
};
let base = asset.url.as_str().trim_end_matches('/').to_string();
let indices: Vec<usize> = (0..BACKUP_CHECKS.len()).collect();
let results: Vec<Vec<Finding>> = futures::stream::iter(indices)
.map(|idx| {
let client = client.clone();
let base = base.clone();
let target = target.clone();
async move { process_one(client, base, target, &BACKUP_CHECKS[idx]).await }
})
.buffer_unordered(PARALLEL_REQUESTS)
.collect()
.await;
Ok(results.into_iter().flatten().collect())
}
async fn process_one(
client: reqwest::Client,
base: String,
target: Target,
check: &BackupCheck,
) -> Vec<Finding> {
let mut findings = Vec::new();
let url = format!("{}{}", base, check.path);
let Ok(resp) = client.get(&url).send().await else {
return findings;
};
if resp.status().as_u16() != 200 {
return findings;
}
let bytes = match soft404::read_limited(resp, MAX_BODY_BYTES).await {
Some(b) => b,
None => return findings,
};
if !check.magic.is_empty() && !magic_matches(check.magic, &bytes) {
return findings;
}
if let Some(needle) = check.content_probe {
let body = String::from_utf8_lossy(&bytes);
if !body.contains(needle) {
return findings;
}
}
let body_excerpt: String = String::from_utf8_lossy(&bytes).chars().take(300).collect();
try_push_finding(
finding_builder(&target, check.severity, check.title, check.title)
.evidence(Evidence::HttpResponse {
status: 200,
headers: vec![],
body_excerpt: Some(body_excerpt.into()),
})
.tag("exposure")
.tag("backup"),
&mut findings,
);
findings
}
fn magic_matches(magics: &[&[u8]], data: &[u8]) -> bool {
magics.iter().any(|m| {
if m == &b"ustar".as_slice() {
data.len() >= 262 && data[257..].starts_with(m)
} else {
data.starts_with(m)
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_list_is_non_trivial() {
assert!(BACKUP_CHECKS.len() >= 30);
}
#[test]
fn check_paths_are_unique() {
use std::collections::HashSet;
let mut seen = HashSet::new();
for c in BACKUP_CHECKS {
assert!(
seen.insert(c.path),
"duplicate backup check path: {}",
c.path
);
}
}
#[test]
fn check_paths_start_with_slash() {
for c in BACKUP_CHECKS {
assert!(c.path.starts_with('/'), "{} must start with /", c.path);
}
}
#[test]
fn magic_matches_zip_at_offset_zero() {
assert!(magic_matches(&[b"PK\x03\x04"], b"PK\x03\x04somezipdata"));
assert!(!magic_matches(&[b"PK\x03\x04"], b"<html></html>"));
}
#[test]
fn magic_matches_tar_at_offset_257() {
let mut data = vec![0u8; 257];
data.extend_from_slice(b"ustar ");
data.extend_from_slice(&[0u8; 100]);
assert!(magic_matches(&[b"ustar"], &data));
assert!(!magic_matches(&[b"ustar"], b"too short"));
}
#[test]
fn probe_is_noop_on_non_web_target() {
let target = Target::Domain(gossan_core::DomainTarget {
domain: "example.com".into(),
source: gossan_core::DiscoverySource::Seed,
});
let client = reqwest::Client::new();
let findings = futures::executor::block_on(probe(&client, &target)).unwrap();
assert!(findings.is_empty());
}
}