Add DAST, graph modules, toast notifications, and dashboard enhancements

Add DAST scanning and code knowledge graph features across the stack:
- compliance-dast and compliance-graph workspace crates
- Agent API handlers and routes for DAST targets/scans and graph builds
- Core models and traits for DAST and graph domains
- Dashboard pages for DAST targets/findings/overview and graph explorer/impact
- Toast notification system with auto-dismiss for async action feedback
- Button click animations and disabled states for better UX

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-04 13:53:50 +01:00
parent 03ee69834d
commit cea8f59e10
69 changed files with 8745 additions and 54 deletions

View File

@@ -0,0 +1,307 @@
use compliance_core::error::CoreError;
use compliance_core::models::dast::{DastEvidence, DastFinding, DastTarget, DastVulnType};
use compliance_core::models::Severity;
use compliance_core::traits::dast_agent::{DastAgent, DastContext};
use tracing::info;
/// API fuzzing agent that tests for misconfigurations and information disclosure
pub struct ApiFuzzerAgent {
http: reqwest::Client,
}
impl ApiFuzzerAgent {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
/// Common API paths to probe
fn discovery_paths(&self) -> Vec<(&str, &str)> {
vec![
("/.env", "Environment file exposure"),
("/.git/config", "Git config exposure"),
("/api/swagger.json", "Swagger spec exposure"),
("/api/openapi.json", "OpenAPI spec exposure"),
("/api-docs", "API documentation exposure"),
("/graphql", "GraphQL endpoint"),
("/debug", "Debug endpoint"),
("/actuator/health", "Spring actuator"),
("/wp-config.php.bak", "WordPress config backup"),
("/.well-known/openid-configuration", "OIDC config"),
("/server-status", "Apache server status"),
("/phpinfo.php", "PHP info exposure"),
("/robots.txt", "Robots.txt"),
("/sitemap.xml", "Sitemap"),
("/.htaccess", "htaccess exposure"),
("/backup.sql", "SQL backup exposure"),
("/api/v1/users", "User enumeration endpoint"),
]
}
/// Patterns indicating sensitive information disclosure
fn sensitive_patterns(&self) -> Vec<&str> {
vec![
"password",
"api_key",
"apikey",
"secret",
"token",
"private_key",
"aws_access_key",
"jdbc:",
"mongodb://",
"redis://",
"postgresql://",
]
}
}
impl DastAgent for ApiFuzzerAgent {
fn name(&self) -> &str {
"api_fuzzer"
}
async fn run(
&self,
target: &DastTarget,
_context: &DastContext,
) -> Result<Vec<DastFinding>, CoreError> {
let mut findings = Vec::new();
let target_id = target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
let base = target.base_url.trim_end_matches('/');
// Phase 1: Path discovery
for (path, description) in self.discovery_paths() {
let url = format!("{base}{path}");
let response = match self.http.get(&url).send().await {
Ok(r) => r,
Err(_) => continue,
};
let status = response.status().as_u16();
if status == 200 {
let body = response.text().await.unwrap_or_default();
// Check if it's actually sensitive content (not just a 200 catch-all)
let is_sensitive = !body.is_empty()
&& body.len() > 10
&& !body.contains("404")
&& !body.contains("not found");
if is_sensitive {
let snippet = body.chars().take(500).collect::<String>();
// Check for information disclosure
let body_lower = body.to_lowercase();
let has_secrets = self
.sensitive_patterns()
.iter()
.any(|p| body_lower.contains(p));
let severity = if has_secrets {
Severity::Critical
} else if path.contains(".env")
|| path.contains(".git")
|| path.contains("backup")
{
Severity::High
} else {
Severity::Medium
};
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: url.clone(),
request_headers: None,
request_body: None,
response_status: status,
response_headers: None,
response_snippet: Some(snippet),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let vuln_type = if has_secrets {
DastVulnType::InformationDisclosure
} else {
DastVulnType::SecurityMisconfiguration
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
vuln_type,
format!("{description}: {path}"),
format!(
"Sensitive resource accessible at {url}. {}",
if has_secrets {
"Response contains potentially sensitive information."
} else {
"This resource should not be publicly accessible."
}
),
severity,
url,
"GET".to_string(),
);
finding.exploitable = has_secrets;
finding.evidence = vec![evidence];
finding.cwe = Some(if has_secrets {
"CWE-200".to_string()
} else {
"CWE-16".to_string()
});
findings.push(finding);
}
}
}
// Phase 2: CORS misconfiguration check
let cors_finding = self.check_cors(base, &target_id).await;
if let Some(f) = cors_finding {
findings.push(f);
}
// Phase 3: Check for verbose error responses
let error_url = format!("{base}/nonexistent-path-{}", uuid::Uuid::new_v4());
if let Ok(response) = self.http.get(&error_url).send().await {
let body = response.text().await.unwrap_or_default();
let body_lower = body.to_lowercase();
let has_stack_trace = body_lower.contains("traceback")
|| body_lower.contains("stack trace")
|| body_lower.contains("at line")
|| body_lower.contains("exception in")
|| body_lower.contains("error in")
|| (body_lower.contains(".py") && body_lower.contains("line"));
if has_stack_trace {
let snippet = body.chars().take(500).collect::<String>();
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: error_url.clone(),
request_headers: None,
request_body: None,
response_status: 404,
response_headers: None,
response_snippet: Some(snippet),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::InformationDisclosure,
"Verbose error messages expose stack traces".to_string(),
"The application exposes detailed error information including stack traces. \
This can reveal internal paths, framework versions, and code structure."
.to_string(),
Severity::Low,
error_url,
"GET".to_string(),
);
finding.evidence = vec![evidence];
finding.cwe = Some("CWE-209".to_string());
finding.remediation = Some(
"Configure the application to use generic error pages in production. \
Do not expose stack traces or internal error details to end users."
.to_string(),
);
findings.push(finding);
}
}
info!(findings = findings.len(), "API fuzzing scan complete");
Ok(findings)
}
}
impl ApiFuzzerAgent {
async fn check_cors(&self, base_url: &str, target_id: &str) -> Option<DastFinding> {
let response = self
.http
.get(base_url)
.header("Origin", "https://evil.com")
.send()
.await
.ok()?;
let headers = response.headers();
let acao = headers
.get("access-control-allow-origin")?
.to_str()
.ok()?;
if acao == "*" || acao == "https://evil.com" {
let acac = headers
.get("access-control-allow-credentials")
.and_then(|v| v.to_str().ok())
.unwrap_or("false");
// Wildcard CORS with credentials is the worst case
let severity = if acac == "true" {
Severity::High
} else if acao == "*" {
Severity::Medium
} else {
Severity::Low
};
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: base_url.to_string(),
request_headers: Some(
[("Origin".to_string(), "https://evil.com".to_string())]
.into_iter()
.collect(),
),
request_body: None,
response_status: response.status().as_u16(),
response_headers: Some(
[(
"Access-Control-Allow-Origin".to_string(),
acao.to_string(),
)]
.into_iter()
.collect(),
),
response_snippet: None,
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.to_string(),
DastVulnType::SecurityMisconfiguration,
"CORS misconfiguration allows arbitrary origins".to_string(),
format!(
"The server responds with Access-Control-Allow-Origin: {acao} \
which may allow cross-origin attacks."
),
severity,
base_url.to_string(),
"GET".to_string(),
);
finding.evidence = vec![evidence];
finding.cwe = Some("CWE-942".to_string());
finding.remediation = Some(
"Configure CORS to only allow trusted origins. \
Never use wildcard (*) with credentials."
.to_string(),
);
Some(finding)
} else {
None
}
}
}

View File

@@ -0,0 +1,219 @@
use compliance_core::error::CoreError;
use compliance_core::models::dast::{DastEvidence, DastFinding, DastTarget, DastVulnType};
use compliance_core::models::Severity;
use compliance_core::traits::dast_agent::{DastAgent, DastContext};
use tracing::info;
/// Authentication bypass testing agent
pub struct AuthBypassAgent {
http: reqwest::Client,
}
impl AuthBypassAgent {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
}
impl DastAgent for AuthBypassAgent {
fn name(&self) -> &str {
"auth_bypass"
}
async fn run(
&self,
target: &DastTarget,
context: &DastContext,
) -> Result<Vec<DastFinding>, CoreError> {
let mut findings = Vec::new();
let target_id = target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
// Test 1: Access protected endpoints without authentication
for endpoint in &context.endpoints {
if !endpoint.requires_auth {
continue;
}
// Try accessing without auth
let response = match self.http.get(&endpoint.url).send().await {
Ok(r) => r,
Err(_) => continue,
};
let status = response.status().as_u16();
// If we get 200 on a supposedly auth-required endpoint
if status == 200 {
let body = response.text().await.unwrap_or_default();
let snippet = body.chars().take(500).collect::<String>();
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: endpoint.url.clone(),
request_headers: None,
request_body: None,
response_status: status,
response_headers: None,
response_snippet: Some(snippet),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::AuthBypass,
format!("Authentication bypass on {}", endpoint.url),
format!(
"Protected endpoint {} returned HTTP 200 without authentication credentials.",
endpoint.url
),
Severity::Critical,
endpoint.url.clone(),
"GET".to_string(),
);
finding.exploitable = true;
finding.evidence = vec![evidence];
finding.cwe = Some("CWE-287".to_string());
finding.remediation = Some(
"Ensure all protected endpoints validate authentication tokens. \
Implement server-side authentication checks that cannot be bypassed."
.to_string(),
);
findings.push(finding);
}
}
// Test 2: HTTP method tampering
let methods = ["PUT", "PATCH", "DELETE", "OPTIONS"];
for endpoint in &context.endpoints {
if endpoint.method != "GET" && endpoint.method != "POST" {
continue;
}
for method in &methods {
let response = match self
.http
.request(
reqwest::Method::from_bytes(method.as_bytes())
.unwrap_or(reqwest::Method::GET),
&endpoint.url,
)
.send()
.await
{
Ok(r) => r,
Err(_) => continue,
};
let status = response.status().as_u16();
// If a non-standard method returns 200 when it shouldn't
if status == 200 && *method == "DELETE" && !target.allow_destructive {
let evidence = DastEvidence {
request_method: method.to_string(),
request_url: endpoint.url.clone(),
request_headers: None,
request_body: None,
response_status: status,
response_headers: None,
response_snippet: None,
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::AuthBypass,
format!("HTTP method tampering: {} accepted on {}", method, endpoint.url),
format!(
"Endpoint {} accepts {} requests which may bypass access controls.",
endpoint.url, method
),
Severity::Medium,
endpoint.url.clone(),
method.to_string(),
);
finding.evidence = vec![evidence];
finding.cwe = Some("CWE-288".to_string());
findings.push(finding);
}
}
}
// Test 3: Path traversal for auth bypass
let traversal_paths = [
"/../admin",
"/..;/admin",
"/%2e%2e/admin",
"/admin%00",
"/ADMIN",
"/Admin",
];
for path in &traversal_paths {
let test_url = format!("{}{}", target.base_url.trim_end_matches('/'), path);
let response = match self.http.get(&test_url).send().await {
Ok(r) => r,
Err(_) => continue,
};
let status = response.status().as_u16();
if status == 200 {
let body = response.text().await.unwrap_or_default();
// Check if response looks like an admin page
let body_lower = body.to_lowercase();
if body_lower.contains("admin")
|| body_lower.contains("dashboard")
|| body_lower.contains("management")
{
let snippet = body.chars().take(500).collect::<String>();
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: test_url.clone(),
request_headers: None,
request_body: None,
response_status: status,
response_headers: None,
response_snippet: Some(snippet),
screenshot_path: None,
payload: Some(path.to_string()),
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::AuthBypass,
format!("Path traversal auth bypass: {path}"),
format!(
"Possible authentication bypass via path traversal. \
Accessing '{}' returned admin-like content.",
test_url
),
Severity::High,
test_url,
"GET".to_string(),
);
finding.evidence = vec![evidence];
finding.cwe = Some("CWE-22".to_string());
findings.push(finding);
break;
}
}
}
info!(findings = findings.len(), "Auth bypass scan complete");
Ok(findings)
}
}

View File

@@ -0,0 +1,195 @@
use compliance_core::error::CoreError;
use compliance_core::models::dast::{DastEvidence, DastFinding, DastTarget, DastVulnType};
use compliance_core::models::Severity;
use compliance_core::traits::dast_agent::{DastAgent, DastContext};
use tracing::{info, warn};
/// SQL Injection testing agent
pub struct SqlInjectionAgent {
http: reqwest::Client,
}
impl SqlInjectionAgent {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
/// Test payloads for SQL injection detection
fn payloads(&self) -> Vec<(&str, &str)> {
vec![
("' OR '1'='1", "boolean-based blind"),
("1' AND SLEEP(2)-- -", "time-based blind"),
("' UNION SELECT NULL--", "union-based"),
("1; DROP TABLE test--", "stacked queries"),
("' OR 1=1#", "mysql boolean"),
("1' ORDER BY 1--", "order by probe"),
("') OR ('1'='1", "parenthesis bypass"),
]
}
/// Error patterns that indicate SQL injection
fn error_patterns(&self) -> Vec<&str> {
vec![
"sql syntax",
"mysql_fetch",
"ORA-01756",
"SQLite3::query",
"pg_query",
"unclosed quotation mark",
"quoted string not properly terminated",
"you have an error in your sql",
"warning: mysql",
"microsoft sql native client error",
"postgresql query failed",
"unterminated string",
"syntax error at or near",
]
}
}
impl DastAgent for SqlInjectionAgent {
fn name(&self) -> &str {
"sql_injection"
}
async fn run(
&self,
target: &DastTarget,
context: &DastContext,
) -> Result<Vec<DastFinding>, CoreError> {
let mut findings = Vec::new();
let target_id = target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
for endpoint in &context.endpoints {
// Only test endpoints with parameters
if endpoint.parameters.is_empty() {
continue;
}
for param in &endpoint.parameters {
for (payload, technique) in self.payloads() {
// Build the request with the injection payload
let test_url = if endpoint.method == "GET" {
format!(
"{}?{}={}",
endpoint.url,
param.name,
urlencoding::encode(payload)
)
} else {
endpoint.url.clone()
};
let request = if endpoint.method == "POST" {
self.http
.post(&endpoint.url)
.form(&[(param.name.as_str(), payload)])
} else {
self.http.get(&test_url)
};
let response = match request.send().await {
Ok(r) => r,
Err(_) => continue,
};
let status = response.status().as_u16();
let headers: std::collections::HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body = response.text().await.unwrap_or_default();
// Check for SQL error patterns in response
let body_lower = body.to_lowercase();
let is_vulnerable = self
.error_patterns()
.iter()
.any(|pattern| body_lower.contains(pattern));
if is_vulnerable {
let snippet = body.chars().take(500).collect::<String>();
let evidence = DastEvidence {
request_method: endpoint.method.clone(),
request_url: test_url.clone(),
request_headers: None,
request_body: if endpoint.method == "POST" {
Some(format!("{}={}", param.name, payload))
} else {
None
},
response_status: status,
response_headers: Some(headers),
response_snippet: Some(snippet),
screenshot_path: None,
payload: Some(payload.to_string()),
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(), // scan_run_id set by orchestrator
target_id.clone(),
DastVulnType::SqlInjection,
format!("SQL Injection ({technique}) in parameter '{}'", param.name),
format!(
"SQL injection vulnerability detected in parameter '{}' at {} using {} technique. \
The server returned SQL error messages in response to the injected payload.",
param.name, endpoint.url, technique
),
Severity::Critical,
endpoint.url.clone(),
endpoint.method.clone(),
);
finding.parameter = Some(param.name.clone());
finding.exploitable = true;
finding.evidence = vec![evidence];
finding.cwe = Some("CWE-89".to_string());
finding.remediation = Some(
"Use parameterized queries or prepared statements. \
Never concatenate user input into SQL queries."
.to_string(),
);
findings.push(finding);
warn!(
endpoint = %endpoint.url,
param = %param.name,
technique,
"SQL injection found"
);
// Don't test more payloads for same param once confirmed
break;
}
}
}
}
info!(findings = findings.len(), "SQL injection scan complete");
Ok(findings)
}
}
/// URL-encode a string for query parameters
mod urlencoding {
pub fn encode(input: &str) -> String {
let mut encoded = String::new();
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char);
}
_ => {
encoded.push_str(&format!("%{:02X}", byte));
}
}
}
encoded
}
}

View File

@@ -0,0 +1,5 @@
pub mod api_fuzzer;
pub mod auth_bypass;
pub mod injection;
pub mod ssrf;
pub mod xss;

View File

@@ -0,0 +1,169 @@
use compliance_core::error::CoreError;
use compliance_core::models::dast::{DastEvidence, DastFinding, DastTarget, DastVulnType};
use compliance_core::models::Severity;
use compliance_core::traits::dast_agent::{DastAgent, DastContext};
use tracing::info;
/// Server-Side Request Forgery (SSRF) testing agent
pub struct SsrfAgent {
http: reqwest::Client,
}
impl SsrfAgent {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
fn payloads(&self) -> Vec<(&str, &str)> {
vec![
("http://127.0.0.1", "localhost IPv4"),
("http://[::1]", "localhost IPv6"),
("http://0.0.0.0", "zero address"),
("http://169.254.169.254/latest/meta-data/", "AWS metadata"),
(
"http://metadata.google.internal/",
"GCP metadata",
),
("http://127.0.0.1:22", "SSH port probe"),
("http://127.0.0.1:3306", "MySQL port probe"),
("http://localhost/admin", "localhost admin"),
]
}
fn internal_indicators(&self) -> Vec<&str> {
vec![
"ami-id",
"instance-id",
"local-hostname",
"public-hostname",
"iam/security-credentials",
"computeMetadata",
"OpenSSH",
"mysql_native_password",
"root:x:0:",
"<!DOCTYPE html>",
]
}
}
impl DastAgent for SsrfAgent {
fn name(&self) -> &str {
"ssrf"
}
async fn run(
&self,
target: &DastTarget,
context: &DastContext,
) -> Result<Vec<DastFinding>, CoreError> {
let mut findings = Vec::new();
let target_id = target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
// Find endpoints with URL-like parameters
for endpoint in &context.endpoints {
let url_params: Vec<_> = endpoint
.parameters
.iter()
.filter(|p| {
let name_lower = p.name.to_lowercase();
name_lower.contains("url")
|| name_lower.contains("uri")
|| name_lower.contains("link")
|| name_lower.contains("src")
|| name_lower.contains("redirect")
|| name_lower.contains("callback")
|| name_lower.contains("fetch")
|| name_lower.contains("load")
})
.collect();
if url_params.is_empty() {
continue;
}
for param in &url_params {
for (payload, technique) in self.payloads() {
let request = if endpoint.method == "POST" {
self.http
.post(&endpoint.url)
.form(&[(param.name.as_str(), payload)])
} else {
let test_url = format!(
"{}?{}={}",
endpoint.url, param.name, payload
);
self.http.get(&test_url)
};
let response = match request.send().await {
Ok(r) => r,
Err(_) => continue,
};
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
// Check for SSRF indicators
let body_lower = body.to_lowercase();
let is_vulnerable = self
.internal_indicators()
.iter()
.any(|indicator| body_lower.contains(&indicator.to_lowercase()));
if is_vulnerable {
let snippet = body.chars().take(500).collect::<String>();
let evidence = DastEvidence {
request_method: endpoint.method.clone(),
request_url: endpoint.url.clone(),
request_headers: None,
request_body: Some(format!("{}={}", param.name, payload)),
response_status: status,
response_headers: None,
response_snippet: Some(snippet),
screenshot_path: None,
payload: Some(payload.to_string()),
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::Ssrf,
format!(
"SSRF ({technique}) via parameter '{}'",
param.name
),
format!(
"Server-side request forgery detected in parameter '{}' at {}. \
The application made a request to an internal resource ({}).",
param.name, endpoint.url, payload
),
Severity::High,
endpoint.url.clone(),
endpoint.method.clone(),
);
finding.parameter = Some(param.name.clone());
finding.exploitable = true;
finding.evidence = vec![evidence];
finding.cwe = Some("CWE-918".to_string());
finding.remediation = Some(
"Validate and sanitize all user-supplied URLs. \
Use allowlists for permitted domains and block internal IP ranges."
.to_string(),
);
findings.push(finding);
break;
}
}
}
}
info!(findings = findings.len(), "SSRF scan complete");
Ok(findings)
}
}

View File

@@ -0,0 +1,147 @@
use compliance_core::error::CoreError;
use compliance_core::models::dast::{DastEvidence, DastFinding, DastTarget, DastVulnType};
use compliance_core::models::Severity;
use compliance_core::traits::dast_agent::{DastAgent, DastContext};
use tracing::info;
/// Cross-Site Scripting (XSS) testing agent
pub struct XssAgent {
http: reqwest::Client,
}
impl XssAgent {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
fn payloads(&self) -> Vec<(&str, &str)> {
vec![
("<script>alert(1)</script>", "basic script injection"),
(
"<img src=x onerror=alert(1)>",
"event handler injection",
),
(
"<svg/onload=alert(1)>",
"svg event handler",
),
(
"javascript:alert(1)",
"javascript protocol",
),
(
"'\"><script>alert(1)</script>",
"attribute breakout",
),
(
"<body onload=alert(1)>",
"body event handler",
),
]
}
}
impl DastAgent for XssAgent {
fn name(&self) -> &str {
"xss"
}
async fn run(
&self,
target: &DastTarget,
context: &DastContext,
) -> Result<Vec<DastFinding>, CoreError> {
let mut findings = Vec::new();
let target_id = target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
for endpoint in &context.endpoints {
if endpoint.parameters.is_empty() {
continue;
}
for param in &endpoint.parameters {
for (payload, technique) in self.payloads() {
let test_url = if endpoint.method == "GET" {
format!(
"{}?{}={}",
endpoint.url, param.name, payload
)
} else {
endpoint.url.clone()
};
let request = if endpoint.method == "POST" {
self.http
.post(&endpoint.url)
.form(&[(param.name.as_str(), payload)])
} else {
self.http.get(&test_url)
};
let response = match request.send().await {
Ok(r) => r,
Err(_) => continue,
};
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
// Check if payload is reflected in response without encoding
if body.contains(payload) {
let snippet = body.chars().take(500).collect::<String>();
let evidence = DastEvidence {
request_method: endpoint.method.clone(),
request_url: test_url.clone(),
request_headers: None,
request_body: if endpoint.method == "POST" {
Some(format!("{}={}", param.name, payload))
} else {
None
},
response_status: status,
response_headers: None,
response_snippet: Some(snippet),
screenshot_path: None,
payload: Some(payload.to_string()),
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::Xss,
format!("Reflected XSS ({technique}) in parameter '{}'", param.name),
format!(
"Cross-site scripting vulnerability detected in parameter '{}' at {}. \
The injected payload was reflected in the response without proper encoding.",
param.name, endpoint.url
),
Severity::High,
endpoint.url.clone(),
endpoint.method.clone(),
);
finding.parameter = Some(param.name.clone());
finding.exploitable = true;
finding.evidence = vec![evidence];
finding.cwe = Some("CWE-79".to_string());
finding.remediation = Some(
"Encode all user input before rendering in HTML context. \
Use Content-Security-Policy headers to mitigate impact."
.to_string(),
);
findings.push(finding);
break;
}
}
}
}
info!(findings = findings.len(), "XSS scan complete");
Ok(findings)
}
}