use anyhow::{Context, Result};
use reqwest::Client;
use rusqlite::Connection;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tracing::{debug, error, info, warn};
use crate::config::ScanConfig;
use crate::db;
use crate::logger;
use crate::resolver::DnsResolver;
use crate::rules::RuleSet;
use crate::utils;
pub fn create_http_client(timeout_secs: u64, connect_timeout_secs: u64) -> Result<Client> {
let timeout = Duration::from_secs(timeout_secs);
let connect_timeout = Duration::from_secs(connect_timeout_secs);
let client = Client::builder()
.timeout(timeout)
.connect_timeout(connect_timeout)
.tcp_keepalive(Some(Duration::from_secs(30)))
.tcp_nodelay(true)
.pool_idle_timeout(Some(Duration::from_secs(90)))
.pool_max_idle_per_host(10) .use_rustls_tls() .user_agent("FATT Security Scanner") .redirect(reqwest::redirect::Policy::limited(3)) .build()
.context("Failed to build HTTP client")?;
debug!("📡 Created optimized HTTP client");
Ok(client)
}
pub async fn run_scan(config: ScanConfig) -> Result<()> {
config.validate()?;
config.log_config();
let start_time = Instant::now();
let ruleset = crate::rules::load_rules(&config.rules_file).context("Failed to load rules")?;
if ruleset.rules.is_empty() {
warn!("⚠️ No rules loaded from {}", config.rules_file);
return Ok(());
}
let db_conn = Arc::new(Mutex::new(
db::init_db(&config.db_path).context("Failed to initialize database")?,
));
let resolver = Arc::new(
DnsResolver::new("cache", config.dns_cache_size)
.await
.context("Failed to initialize DNS resolver")?,
);
let domains = utils::read_domains(&config.input_file).context("Failed to read domains")?;
if domains.is_empty() {
warn!("⚠️ No domains loaded from {}", config.input_file);
return Ok(());
}
let client = create_http_client(config.http_timeout, config.connect_timeout)?;
let matches_found = Arc::new(AtomicUsize::new(0));
let domains_processed = Arc::new(AtomicUsize::new(0));
let tasks_completed = Arc::new(AtomicUsize::new(0));
let batch_size = 100; let domain_chunks = utils::chunk_vector(domains, batch_size);
let total_domains = domain_chunks.iter().map(|chunk| chunk.len()).sum::<usize>();
let total_tasks = total_domains * ruleset.rules.len();
info!(
"🚀 Starting scan of {} domains with {} rules ({} total checks)",
total_domains,
ruleset.rules.len(),
total_tasks
);
let status_interval = Duration::from_secs(3);
let domains_processed_clone = domains_processed.clone();
let tasks_completed_clone = tasks_completed.clone();
let total_domains_clone = total_domains;
let total_tasks_clone = total_tasks;
let status_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(status_interval);
loop {
interval.tick().await;
let domains_done = domains_processed_clone.load(Ordering::Relaxed);
let tasks_done = tasks_completed_clone.load(Ordering::Relaxed);
let domains_percent =
(domains_done as f64 / total_domains_clone as f64 * 100.0) as usize;
let tasks_percent = (tasks_done as f64 / total_tasks_clone as f64 * 100.0) as usize;
info!(
"📊 Status: {}/{} domains ({}%), {}/{} tasks ({}%)",
domains_done,
total_domains_clone,
domains_percent,
tasks_done,
total_tasks_clone,
tasks_percent
);
if domains_done >= total_domains_clone && tasks_done >= total_tasks_clone {
break;
}
}
});
for (i, chunk) in domain_chunks.iter().enumerate() {
info!(
"📦 Processing batch {}/{} ({} domains)",
i + 1,
domain_chunks.len(),
chunk.len()
);
let client_clone = client.clone();
let ruleset_clone = ruleset.clone();
let resolver_clone = resolver.clone();
let db_conn_clone = db_conn.clone();
let tasks_completed_clone = tasks_completed.clone();
let matches_found_clone = matches_found.clone();
let domains_processed_clone = domains_processed.clone();
let mut handles = Vec::with_capacity(chunk.len());
for domain in chunk {
let domain = domain.clone();
let client = client_clone.clone();
let ruleset = ruleset_clone.clone();
let resolver = resolver_clone.clone();
let db_conn = db_conn_clone.clone();
let tasks_completed = tasks_completed_clone.clone();
let matches_found = matches_found_clone.clone();
let domains_processed = domains_processed_clone.clone();
let handle = tokio::spawn(async move {
let result = scan_domain(
&domain,
&client,
&ruleset,
&resolver,
db_conn,
tasks_completed,
matches_found,
)
.await;
domains_processed.fetch_add(1, Ordering::Relaxed);
result
});
handles.push(handle);
}
let results = futures::future::join_all(handles).await;
let error_count = results
.iter()
.filter(|r| r.is_err() || r.as_ref().ok().is_none_or(|r| r.is_err()))
.count();
if error_count > 0 {
debug!("⚠️ Batch completed with {} errors", error_count);
}
}
status_handle.abort();
let elapsed = start_time.elapsed();
let elapsed_secs = elapsed.as_secs_f64();
let matches = matches_found.load(Ordering::Relaxed);
logger::log_scan_stats(total_domains, total_tasks, matches, elapsed_secs);
Ok(())
}
pub async fn scan_domain(
domain: &str,
client: &Client,
ruleset: &RuleSet,
resolver: &DnsResolver,
db_conn: Arc<Mutex<Connection>>,
tasks_completed: Arc<AtomicUsize>,
matches_found: Arc<AtomicUsize>,
) -> Result<()> {
match resolver.lookup(domain).await {
Ok(ip) => {
debug!(
"🔍 Scanning domain: {} ({})",
domain,
ip.unwrap_or_else(|| "unresolved".to_string())
);
let mut rule_futures = Vec::with_capacity(ruleset.rules.len());
for rule in &ruleset.rules {
let domain = domain.to_string();
let client = client.clone();
let rule = rule.clone();
let db_conn = db_conn.clone();
let matches_found = matches_found.clone();
let rule_future = async move {
let url = format!("http://{}{}", domain, rule.path);
match check_path(&client, &url).await {
Ok(true) => {
match check_signature(&client, &url, &rule.signature).await {
Ok(true) => {
info!(
"🔴 Match found: {} - {} ({})",
domain, rule.name, rule.path
);
logger::log_success(&domain, &rule.name, &rule.path);
let conn = db_conn.lock().await;
if let Err(e) = db::insert_finding(
&conn, &domain, &rule.name, &rule.path, true,
) {
error!("Failed to insert finding: {}", e);
}
matches_found.fetch_add(1, Ordering::Relaxed);
Ok(())
}
Ok(false) => {
let conn = db_conn.lock().await;
if let Err(e) = db::insert_finding(
&conn, &domain, &rule.name, &rule.path, false,
) {
error!("Failed to insert finding: {}", e);
}
Ok(())
}
Err(e) => {
debug!(
"🔶 Error checking signature for {} - {}: {}",
domain, rule.path, e
);
Err(e)
}
}
}
Ok(false) => {
debug!("❌ Path not found: {} - {}", domain, rule.path);
Ok(())
}
Err(e) => {
debug!("🔶 Error checking path: {} - {}: {}", domain, rule.path, e);
Err(e)
}
}
};
rule_futures.push(rule_future);
}
let results = futures::future::join_all(rule_futures).await;
tasks_completed.fetch_add(ruleset.rules.len(), Ordering::Relaxed);
let errors: Vec<_> = results.into_iter().filter_map(|r| r.err()).collect();
if !errors.is_empty() {
debug!(
"❌ Some rule checks failed for {}: {} errors",
domain,
errors.len()
);
}
Ok(())
}
Err(e) => {
debug!("❌ Failed to resolve domain: {}: {}", domain, e);
tasks_completed.fetch_add(ruleset.rules.len(), Ordering::Relaxed);
Err(anyhow::anyhow!("Failed to resolve domain: {}", domain))
}
}
}
pub async fn check_path(client: &Client, url: &str) -> Result<bool> {
match client.head(url).send().await {
Ok(response) => Ok(response.status().is_success()),
Err(e) => {
debug!("HEAD request failed for {}: {}", url, e);
match client.get(url).send().await {
Ok(response) => Ok(response.status().is_success()),
Err(e) => {
debug!("GET request also failed for {}: {}", url, e);
Err(anyhow::anyhow!("Failed to check path: {}", e))
}
}
}
}
}
pub async fn check_signature(client: &Client, url: &str, signature: &str) -> Result<bool> {
match client.get(url).send().await {
Ok(response) => {
if response.status().is_success() {
let body = response.text().await?;
Ok(body.contains(signature))
} else {
Ok(false)
}
}
Err(e) => {
debug!("Error checking signature: {}", e);
Err(anyhow::anyhow!("Failed to check signature: {}", e))
}
}
}