use crate::http_client::HttpClient;
use crate::types::{Confidence, Severity, Vulnerability};
use anyhow::Result;
use scraper::{Html, Selector};
use std::collections::HashSet;
use std::sync::Arc;
pub struct DifferentialFuzzer {
http_client: Arc<HttpClient>,
}
impl DifferentialFuzzer {
pub fn new(http_client: Arc<HttpClient>) -> Self {
Self { http_client }
}
pub async fn scan(&self, url: &str) -> Result<(Vec<Vulnerability>, usize)> {
let mut vulnerabilities = Vec::new();
let params = extract_url_parameters(url);
if params.is_empty() {
return Ok((vulnerabilities, 0));
}
let baseline = self.http_client.get(url).await?;
let baseline_dom = parse_dom(&baseline.body);
let mut tests = 0;
for (param_name, _original_value) in params {
let payloads = generate_xss_payloads();
let test_urls: Vec<String> = payloads
.iter()
.map(|payload| inject_payload(url, ¶m_name, payload))
.collect();
let futures: Vec<_> = test_urls
.iter()
.map(|test_url| self.http_client.get(test_url))
.collect();
let responses = futures::future::join_all(futures).await;
tests += responses.len();
for (payload, response) in payloads.iter().zip(responses.iter()) {
if let Ok(resp) = response {
let test_dom = parse_dom(&resp.body);
if let Some(vuln) =
detect_xss_by_diff(&baseline_dom, &test_dom, url, ¶m_name, payload)
{
vulnerabilities.push(vuln);
break; }
}
}
}
Ok((vulnerabilities, tests))
}
}
fn extract_url_parameters(url: &str) -> Vec<(String, String)> {
url::Url::parse(url)
.ok()
.map(|parsed| {
parsed
.query_pairs()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
})
.unwrap_or_default()
}
fn inject_payload(url: &str, param: &str, payload: &str) -> String {
if let Ok(mut parsed) = url::Url::parse(url) {
let pairs: Vec<(String, String)> = parsed
.query_pairs()
.map(|(k, v)| {
if k == param {
(k.to_string(), payload.to_string())
} else {
(k.to_string(), v.to_string())
}
})
.collect();
parsed.set_query(None);
for (k, v) in pairs {
parsed.query_pairs_mut().append_pair(&k, &v);
}
parsed.to_string()
} else {
url.to_string()
}
}
fn generate_xss_payloads() -> Vec<String> {
vec![
"<script>alert(1)</script>".to_string(),
"<img src=x onerror=alert(1)>".to_string(),
"<svg onload=alert(1)>".to_string(),
"<body onload=alert(1)>".to_string(),
"<input autofocus onfocus=alert(1)>".to_string(),
"\" onmouseover=\"alert(1)".to_string(),
"' onmouseover='alert(1)".to_string(),
"';alert(1);//".to_string(),
"\";alert(1);//".to_string(),
"<ScRiPt>alert(1)</sCrIpT>".to_string(),
"<scr<script>ipt>alert(1)</scr</script>ipt>".to_string(),
"<img src=x onerror=\"alert(String.fromCharCode(88,83,83))\">".to_string(),
"{{constructor.constructor('alert(1)')()}}".to_string(),
"${alert(1)}".to_string(),
"<iframe src=\"javascript:alert(1)\">".to_string(),
"<a href=\"javascript:alert(1)\">click</a>".to_string(),
]
}
#[derive(Debug)]
struct DomStructure {
script_tags: HashSet<String>,
event_handlers: HashSet<String>,
iframe_srcs: HashSet<String>,
link_hrefs: HashSet<String>,
}
fn parse_dom(html: &str) -> DomStructure {
let document = Html::parse_document(html);
let mut structure = DomStructure {
script_tags: HashSet::new(),
event_handlers: HashSet::new(),
iframe_srcs: HashSet::new(),
link_hrefs: HashSet::new(),
};
if let Ok(selector) = Selector::parse("script") {
for element in document.select(&selector) {
structure
.script_tags
.insert(element.inner_html().to_lowercase());
}
}
let event_attrs = [
"onclick",
"onerror",
"onload",
"onmouseover",
"onfocus",
"onblur",
];
for tag in document.tree.nodes() {
if let Some(element) = tag.value().as_element() {
for attr in &event_attrs {
if let Some(value) = element.attr(attr) {
structure
.event_handlers
.insert(format!("{}={}", attr, value.to_lowercase()));
}
}
}
}
if let Ok(selector) = Selector::parse("iframe") {
for element in document.select(&selector) {
if let Some(src) = element.value().attr("src") {
structure.iframe_srcs.insert(src.to_lowercase());
}
}
}
if let Ok(selector) = Selector::parse("a") {
for element in document.select(&selector) {
if let Some(href) = element.value().attr("href") {
structure.link_hrefs.insert(href.to_lowercase());
}
}
}
structure
}
fn detect_xss_by_diff(
baseline: &DomStructure,
test: &DomStructure,
url: &str,
param: &str,
payload: &str,
) -> Option<Vulnerability> {
let new_scripts: Vec<_> = test
.script_tags
.difference(&baseline.script_tags)
.collect();
if !new_scripts.is_empty() {
return Some(create_vulnerability(
url,
param,
payload,
"New script tag detected",
Confidence::High,
));
}
let new_handlers: Vec<_> = test
.event_handlers
.difference(&baseline.event_handlers)
.collect();
if !new_handlers.is_empty() {
return Some(create_vulnerability(
url,
param,
payload,
"New event handler detected",
Confidence::High,
));
}
let new_iframes: Vec<_> = test.iframe_srcs.difference(&baseline.iframe_srcs).collect();
if !new_iframes.is_empty() && new_iframes.iter().any(|s| s.starts_with("javascript:")) {
return Some(create_vulnerability(
url,
param,
payload,
"JavaScript iframe src detected",
Confidence::High,
));
}
let new_links: Vec<_> = test.link_hrefs.difference(&baseline.link_hrefs).collect();
if !new_links.is_empty() && new_links.iter().any(|s| s.starts_with("javascript:")) {
return Some(create_vulnerability(
url,
param,
payload,
"JavaScript link href detected",
Confidence::Medium,
));
}
None
}
fn create_vulnerability(
url: &str,
param: &str,
payload: &str,
description: &str,
confidence: Confidence,
) -> Vulnerability {
Vulnerability {
id: uuid::Uuid::new_v4().to_string(),
vuln_type: format!("XSS in '{}' parameter (Differential Fuzzing)", param),
category: "XSS".to_string(),
description: format!(
"DOM differential analysis detected XSS: {}. \
The parameter '{}' allows injection of malicious content that alters the page structure.",
description, param
),
severity: Severity::High,
confidence,
url: url.to_string(),
parameter: Some(param.to_string()),
payload: payload.to_string(),
evidence: Some(format!("Payload caused: {}", description)),
remediation: "Implement proper output encoding:\n\
- HTML context: Use htmlspecialchars() or equivalent\n\
- JavaScript context: Use JSON.stringify()\n\
- Apply Content-Security-Policy header\n\
- Use a template engine with auto-escaping"
.to_string(),
cwe: "CWE-79".to_string(),
cvss: 6.1,
verified: true, false_positive: false,
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
}
}