use serde::{Deserialize, Serialize};
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TechFingerprintRequest {
pub url: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
fn default_timeout() -> u64 { 10 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TechHit {
pub technology: String,
pub version: Option<String>,
pub category: String,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TechFingerprintResult {
pub url: String,
pub technologies: Vec<TechHit>,
pub error: Option<String>,
}
pub async fn check_tech_stack(req: &TechFingerprintRequest) -> Result<TechFingerprintResult> {
let url = req.url.clone();
let http_req = crate::api::HttpCheckRequest {
url: url.clone(),
follow_redirects: true,
timeout_secs: req.timeout_secs,
};
let http_result = match crate::api::check_http(&http_req).await {
Ok(result) => result,
Err(e) => {
return Ok(TechFingerprintResult {
url,
technologies: vec![],
error: Some(format!("HTTP check failed: {}", e)),
});
}
};
let mut technologies = Vec::new();
if let Some(server_val) = http_result.headers.get("server") {
let server_lower = server_val.to_lowercase();
if server_lower.contains("nginx") {
if let Some(version) = extract_version(&server_val, "nginx") {
technologies.push(TechHit {
technology: "nginx".to_string(),
version: Some(version),
category: "web_server".to_string(),
source: "Server".to_string(),
});
} else {
technologies.push(TechHit {
technology: "nginx".to_string(),
version: None,
category: "web_server".to_string(),
source: "Server".to_string(),
});
}
} else if server_lower.contains("apache") {
if let Some(version) = extract_version(&server_val, "Apache") {
technologies.push(TechHit {
technology: "Apache".to_string(),
version: Some(version),
category: "web_server".to_string(),
source: "Server".to_string(),
});
}
} else if server_lower.contains("microsoft-iis") {
if let Some(version) = extract_version(&server_val, "IIS") {
technologies.push(TechHit {
technology: "IIS".to_string(),
version: Some(version),
category: "web_server".to_string(),
source: "Server".to_string(),
});
}
} else if server_lower.contains("caddy") {
technologies.push(TechHit {
technology: "Caddy".to_string(),
version: extract_version(&server_val, "Caddy"),
category: "web_server".to_string(),
source: "Server".to_string(),
});
}
}
if let Some(powered_by) = http_result.headers.get("x-powered-by") {
let pb_lower = powered_by.to_lowercase();
if pb_lower.contains("php") {
technologies.push(TechHit {
technology: "PHP".to_string(),
version: extract_version(powered_by, "PHP"),
category: "language".to_string(),
source: "X-Powered-By".to_string(),
});
} else if pb_lower.contains("asp.net") {
technologies.push(TechHit {
technology: "ASP.NET".to_string(),
version: extract_version(powered_by, "ASP.NET"),
category: "framework".to_string(),
source: "X-Powered-By".to_string(),
});
} else if pb_lower.contains("express") {
technologies.push(TechHit {
technology: "Express".to_string(),
version: extract_version(powered_by, "Express"),
category: "framework".to_string(),
source: "X-Powered-By".to_string(),
});
}
}
if let Some(generator) = http_result.headers.get("x-generator") {
let gen_lower = generator.to_lowercase();
if gen_lower.contains("wordpress") {
technologies.push(TechHit {
technology: "WordPress".to_string(),
version: extract_version(generator, "WordPress"),
category: "cms".to_string(),
source: "X-Generator".to_string(),
});
} else if gen_lower.contains("drupal") {
technologies.push(TechHit {
technology: "Drupal".to_string(),
version: extract_version(generator, "Drupal"),
category: "cms".to_string(),
source: "X-Generator".to_string(),
});
}
}
if http_result.headers.contains_key("x-drupal-cache") {
technologies.push(TechHit {
technology: "Drupal".to_string(),
version: None,
category: "cms".to_string(),
source: "X-Drupal-Cache".to_string(),
});
}
if let Some(_shopify) = http_result.headers.iter().find(|(k, _)| k.to_lowercase().starts_with("x-shopify")) {
technologies.push(TechHit {
technology: "Shopify".to_string(),
version: None,
category: "cms".to_string(),
source: "X-Shopify-*".to_string(),
});
}
if http_result.headers.contains_key("x-wp-total-api-calls") {
technologies.push(TechHit {
technology: "WordPress".to_string(),
version: None,
category: "cms".to_string(),
source: "X-WP-*".to_string(),
});
}
if let Some(via) = http_result.headers.get("via") {
let via_lower = via.to_lowercase();
if via_lower.contains("varnish") {
technologies.push(TechHit {
technology: "Varnish".to_string(),
version: extract_version(via, "Varnish"),
category: "cache".to_string(),
source: "Via".to_string(),
});
} else if via_lower.contains("squid") {
technologies.push(TechHit {
technology: "Squid".to_string(),
version: extract_version(via, "Squid"),
category: "cache".to_string(),
source: "Via".to_string(),
});
}
}
Ok(TechFingerprintResult {
url,
technologies,
error: None,
})
}
fn extract_version(input: &str, prefix: &str) -> Option<String> {
let lower_input = input.to_lowercase();
let lower_prefix = prefix.to_lowercase();
let pos = lower_input.find(&lower_prefix)?;
let after_match = lower_input.get(pos + lower_prefix.len()..)?;
if after_match.starts_with('/') || after_match.starts_with(' ') {
let version = after_match[1..].split_whitespace().next().unwrap_or("");
if !version.is_empty() {
return Some(version.to_string());
}
}
None
}