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:
32
compliance-dast/Cargo.toml
Normal file
32
compliance-dast/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "compliance-dast"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
compliance-core = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
mongodb = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# HTML parsing
|
||||
scraper = "0.22"
|
||||
|
||||
# Browser automation
|
||||
chromiumoxide = { version = "0.7", features = ["tokio-runtime"], default-features = false }
|
||||
|
||||
# Docker sandboxing
|
||||
bollard = "0.18"
|
||||
|
||||
# Serialization
|
||||
bson = "2"
|
||||
url = "2"
|
||||
307
compliance-dast/src/agents/api_fuzzer.rs
Normal file
307
compliance-dast/src/agents/api_fuzzer.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
219
compliance-dast/src/agents/auth_bypass.rs
Normal file
219
compliance-dast/src/agents/auth_bypass.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
195
compliance-dast/src/agents/injection.rs
Normal file
195
compliance-dast/src/agents/injection.rs
Normal 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
|
||||
}
|
||||
}
|
||||
5
compliance-dast/src/agents/mod.rs
Normal file
5
compliance-dast/src/agents/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod api_fuzzer;
|
||||
pub mod auth_bypass;
|
||||
pub mod injection;
|
||||
pub mod ssrf;
|
||||
pub mod xss;
|
||||
169
compliance-dast/src/agents/ssrf.rs
Normal file
169
compliance-dast/src/agents/ssrf.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
147
compliance-dast/src/agents/xss.rs
Normal file
147
compliance-dast/src/agents/xss.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
200
compliance-dast/src/crawler/mod.rs
Normal file
200
compliance-dast/src/crawler/mod.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::traits::dast_agent::{DiscoveredEndpoint, EndpointParameter};
|
||||
use scraper::{Html, Selector};
|
||||
use tracing::info;
|
||||
use url::Url;
|
||||
|
||||
/// Web crawler that discovers endpoints and forms
|
||||
pub struct WebCrawler {
|
||||
http: reqwest::Client,
|
||||
max_depth: u32,
|
||||
rate_limit_ms: u64,
|
||||
}
|
||||
|
||||
impl WebCrawler {
|
||||
pub fn new(http: reqwest::Client, max_depth: u32, rate_limit_ms: u64) -> Self {
|
||||
Self {
|
||||
http,
|
||||
max_depth,
|
||||
rate_limit_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Crawl a target starting from the base URL
|
||||
pub async fn crawl(
|
||||
&self,
|
||||
base_url: &str,
|
||||
excluded_paths: &[String],
|
||||
) -> Result<Vec<DiscoveredEndpoint>, CoreError> {
|
||||
let base = Url::parse(base_url)
|
||||
.map_err(|e| CoreError::Dast(format!("Invalid base URL: {e}")))?;
|
||||
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
let mut endpoints: Vec<DiscoveredEndpoint> = Vec::new();
|
||||
let mut queue: Vec<(String, u32)> = vec![(base_url.to_string(), 0)];
|
||||
|
||||
while let Some((url, depth)) = queue.pop() {
|
||||
if depth > self.max_depth {
|
||||
continue;
|
||||
}
|
||||
|
||||
if visited.contains(&url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check exclusions
|
||||
if excluded_paths
|
||||
.iter()
|
||||
.any(|excl| url.contains(excl.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.insert(url.clone());
|
||||
|
||||
// Rate limiting
|
||||
if self.rate_limit_ms > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(self.rate_limit_ms)).await;
|
||||
}
|
||||
|
||||
// Fetch the page
|
||||
let response = match self.http.get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Record this endpoint
|
||||
endpoints.push(DiscoveredEndpoint {
|
||||
url: url.clone(),
|
||||
method: "GET".to_string(),
|
||||
parameters: Vec::new(),
|
||||
content_type: Some(content_type.clone()),
|
||||
requires_auth: status.as_u16() == 401 || status.as_u16() == 403,
|
||||
});
|
||||
|
||||
if !content_type.contains("text/html") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = match response.text().await {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Parse HTML for links and forms
|
||||
let document = Html::parse_document(&body);
|
||||
|
||||
// Extract links
|
||||
let link_selector =
|
||||
Selector::parse("a[href]").unwrap_or_else(|_| Selector::parse("a").expect("valid selector"));
|
||||
for element in document.select(&link_selector) {
|
||||
if let Some(href) = element.value().attr("href") {
|
||||
if let Some(absolute_url) = self.resolve_url(&base, &url, href) {
|
||||
if self.is_same_origin(&base, &absolute_url) && !visited.contains(&absolute_url)
|
||||
{
|
||||
queue.push((absolute_url, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract forms
|
||||
let form_selector = Selector::parse("form")
|
||||
.unwrap_or_else(|_| Selector::parse("form").expect("valid selector"));
|
||||
let input_selector = Selector::parse("input, select, textarea")
|
||||
.unwrap_or_else(|_| Selector::parse("input").expect("valid selector"));
|
||||
|
||||
for form in document.select(&form_selector) {
|
||||
let action = form.value().attr("action").unwrap_or("");
|
||||
let method = form
|
||||
.value()
|
||||
.attr("method")
|
||||
.unwrap_or("GET")
|
||||
.to_uppercase();
|
||||
|
||||
let form_url = self
|
||||
.resolve_url(&base, &url, action)
|
||||
.unwrap_or_else(|| url.clone());
|
||||
|
||||
let mut params = Vec::new();
|
||||
for input in form.select(&input_selector) {
|
||||
let name = input
|
||||
.value()
|
||||
.attr("name")
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let input_type = input
|
||||
.value()
|
||||
.attr("type")
|
||||
.unwrap_or("text")
|
||||
.to_string();
|
||||
|
||||
let location = if method == "GET" {
|
||||
"query".to_string()
|
||||
} else {
|
||||
"body".to_string()
|
||||
};
|
||||
|
||||
params.push(EndpointParameter {
|
||||
name,
|
||||
location,
|
||||
param_type: Some(input_type),
|
||||
example_value: input.value().attr("value").map(|v| v.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
endpoints.push(DiscoveredEndpoint {
|
||||
url: form_url,
|
||||
method,
|
||||
parameters: params,
|
||||
content_type: Some("application/x-www-form-urlencoded".to_string()),
|
||||
requires_auth: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
info!(endpoints = endpoints.len(), "Crawling complete");
|
||||
Ok(endpoints)
|
||||
}
|
||||
|
||||
fn resolve_url(&self, _base: &Url, current_page: &str, href: &str) -> Option<String> {
|
||||
// Skip anchors, javascript:, mailto:, etc.
|
||||
if href.starts_with('#')
|
||||
|| href.starts_with("javascript:")
|
||||
|| href.starts_with("mailto:")
|
||||
|| href.starts_with("tel:")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(absolute) = Url::parse(href) {
|
||||
return Some(absolute.to_string());
|
||||
}
|
||||
|
||||
// Relative URL
|
||||
let current = Url::parse(current_page).ok()?;
|
||||
current.join(href).ok().map(|u| u.to_string())
|
||||
}
|
||||
|
||||
fn is_same_origin(&self, base: &Url, url: &str) -> bool {
|
||||
if let Ok(parsed) = Url::parse(url) {
|
||||
parsed.host() == base.host() && parsed.scheme() == base.scheme()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
6
compliance-dast/src/lib.rs
Normal file
6
compliance-dast/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod agents;
|
||||
pub mod crawler;
|
||||
pub mod orchestrator;
|
||||
pub mod recon;
|
||||
|
||||
pub use orchestrator::DastOrchestrator;
|
||||
3
compliance-dast/src/orchestrator/mod.rs
Normal file
3
compliance-dast/src/orchestrator/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod state_machine;
|
||||
|
||||
pub use state_machine::DastOrchestrator;
|
||||
203
compliance-dast/src/orchestrator/state_machine.rs
Normal file
203
compliance-dast/src/orchestrator/state_machine.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use chrono::Utc;
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::dast::{
|
||||
DastFinding, DastScanPhase, DastScanRun, DastScanStatus, DastTarget,
|
||||
};
|
||||
use compliance_core::traits::dast_agent::DastContext;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::crawler::WebCrawler;
|
||||
use crate::recon::ReconAgent;
|
||||
|
||||
/// State machine orchestrator for DAST scanning
|
||||
pub struct DastOrchestrator {
|
||||
http: reqwest::Client,
|
||||
rate_limit_ms: u64,
|
||||
}
|
||||
|
||||
impl DastOrchestrator {
|
||||
pub fn new(rate_limit_ms: u64) -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
rate_limit_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a complete DAST scan against a target
|
||||
pub async fn run_scan(
|
||||
&self,
|
||||
target: &DastTarget,
|
||||
sast_hints: Vec<String>,
|
||||
) -> Result<(DastScanRun, Vec<DastFinding>), CoreError> {
|
||||
let target_id = target
|
||||
.id
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let mut scan_run = DastScanRun::new(target_id);
|
||||
let mut all_findings = Vec::new();
|
||||
|
||||
info!(target = %target.base_url, "Starting DAST scan");
|
||||
|
||||
// Phase 1: Reconnaissance
|
||||
scan_run.current_phase = DastScanPhase::Reconnaissance;
|
||||
let recon = ReconAgent::new(self.http.clone());
|
||||
let recon_result = match recon.scan(&target.base_url).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!(error = %e, "Reconnaissance failed");
|
||||
scan_run.status = DastScanStatus::Failed;
|
||||
scan_run.error_message = Some(format!("Reconnaissance failed: {e}"));
|
||||
scan_run.completed_at = Some(Utc::now());
|
||||
return Ok((scan_run, all_findings));
|
||||
}
|
||||
};
|
||||
scan_run
|
||||
.phases_completed
|
||||
.push(DastScanPhase::Reconnaissance);
|
||||
|
||||
info!(
|
||||
technologies = ?recon_result.technologies,
|
||||
headers = recon_result.interesting_headers.len(),
|
||||
"Reconnaissance complete"
|
||||
);
|
||||
|
||||
// Phase 2: Crawling
|
||||
scan_run.current_phase = DastScanPhase::Crawling;
|
||||
let crawler = WebCrawler::new(
|
||||
self.http.clone(),
|
||||
target.max_crawl_depth,
|
||||
self.rate_limit_ms,
|
||||
);
|
||||
let endpoints = match crawler
|
||||
.crawl(&target.base_url, &target.excluded_paths)
|
||||
.await
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
error!(error = %e, "Crawling failed");
|
||||
scan_run.status = DastScanStatus::Failed;
|
||||
scan_run.error_message = Some(format!("Crawling failed: {e}"));
|
||||
scan_run.completed_at = Some(Utc::now());
|
||||
return Ok((scan_run, all_findings));
|
||||
}
|
||||
};
|
||||
scan_run.endpoints_discovered = endpoints.len() as u32;
|
||||
scan_run.phases_completed.push(DastScanPhase::Crawling);
|
||||
|
||||
info!(endpoints = endpoints.len(), "Crawling complete");
|
||||
|
||||
// Build context for vulnerability agents
|
||||
let context = DastContext {
|
||||
endpoints,
|
||||
technologies: recon_result.technologies,
|
||||
sast_hints,
|
||||
};
|
||||
|
||||
// Phase 3: Vulnerability Analysis
|
||||
scan_run.current_phase = DastScanPhase::VulnerabilityAnalysis;
|
||||
let vuln_findings = self.run_vulnerability_agents(target, &context).await?;
|
||||
all_findings.extend(vuln_findings);
|
||||
scan_run
|
||||
.phases_completed
|
||||
.push(DastScanPhase::VulnerabilityAnalysis);
|
||||
|
||||
// Phase 4: Exploitation (verify findings)
|
||||
scan_run.current_phase = DastScanPhase::Exploitation;
|
||||
// Exploitation is handled within each agent's evidence collection
|
||||
scan_run.phases_completed.push(DastScanPhase::Exploitation);
|
||||
|
||||
// Phase 5: Reporting
|
||||
scan_run.current_phase = DastScanPhase::Reporting;
|
||||
scan_run.findings_count = all_findings.len() as u32;
|
||||
scan_run.exploitable_count = all_findings.iter().filter(|f| f.exploitable).count() as u32;
|
||||
scan_run.phases_completed.push(DastScanPhase::Reporting);
|
||||
|
||||
scan_run.status = DastScanStatus::Completed;
|
||||
scan_run.current_phase = DastScanPhase::Completed;
|
||||
scan_run.completed_at = Some(Utc::now());
|
||||
|
||||
info!(
|
||||
findings = scan_run.findings_count,
|
||||
exploitable = scan_run.exploitable_count,
|
||||
"DAST scan complete"
|
||||
);
|
||||
|
||||
Ok((scan_run, all_findings))
|
||||
}
|
||||
|
||||
/// Run all vulnerability testing agents in parallel
|
||||
async fn run_vulnerability_agents(
|
||||
&self,
|
||||
target: &DastTarget,
|
||||
context: &DastContext,
|
||||
) -> Result<Vec<DastFinding>, CoreError> {
|
||||
use compliance_core::traits::DastAgent;
|
||||
|
||||
let http = self.http.clone();
|
||||
|
||||
// Spawn each agent as a separate tokio task
|
||||
let t1 = target.clone();
|
||||
let c1 = context.clone();
|
||||
let h1 = http.clone();
|
||||
let sqli_handle = tokio::spawn(async move {
|
||||
crate::agents::injection::SqlInjectionAgent::new(h1)
|
||||
.run(&t1, &c1)
|
||||
.await
|
||||
});
|
||||
|
||||
let t2 = target.clone();
|
||||
let c2 = context.clone();
|
||||
let h2 = http.clone();
|
||||
let xss_handle = tokio::spawn(async move {
|
||||
crate::agents::xss::XssAgent::new(h2)
|
||||
.run(&t2, &c2)
|
||||
.await
|
||||
});
|
||||
|
||||
let t3 = target.clone();
|
||||
let c3 = context.clone();
|
||||
let h3 = http.clone();
|
||||
let auth_handle = tokio::spawn(async move {
|
||||
crate::agents::auth_bypass::AuthBypassAgent::new(h3)
|
||||
.run(&t3, &c3)
|
||||
.await
|
||||
});
|
||||
|
||||
let t4 = target.clone();
|
||||
let c4 = context.clone();
|
||||
let h4 = http.clone();
|
||||
let ssrf_handle = tokio::spawn(async move {
|
||||
crate::agents::ssrf::SsrfAgent::new(h4)
|
||||
.run(&t4, &c4)
|
||||
.await
|
||||
});
|
||||
|
||||
let t5 = target.clone();
|
||||
let c5 = context.clone();
|
||||
let h5 = http;
|
||||
let api_handle = tokio::spawn(async move {
|
||||
crate::agents::api_fuzzer::ApiFuzzerAgent::new(h5)
|
||||
.run(&t5, &c5)
|
||||
.await
|
||||
});
|
||||
|
||||
let handles: Vec<tokio::task::JoinHandle<Result<Vec<DastFinding>, CoreError>>> =
|
||||
vec![sqli_handle, xss_handle, auth_handle, ssrf_handle, api_handle];
|
||||
|
||||
let mut all_findings = Vec::new();
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok(Ok(findings)) => all_findings.extend(findings),
|
||||
Ok(Err(e)) => {
|
||||
error!(error = %e, "Agent failed");
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "Agent task panicked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_findings)
|
||||
}
|
||||
}
|
||||
132
compliance-dast/src/recon/mod.rs
Normal file
132
compliance-dast/src/recon/mod.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use compliance_core::error::CoreError;
|
||||
use tracing::info;
|
||||
|
||||
/// Result of reconnaissance scanning
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReconResult {
|
||||
pub technologies: Vec<String>,
|
||||
pub interesting_headers: HashMap<String, String>,
|
||||
pub server: Option<String>,
|
||||
pub open_ports: Vec<u16>,
|
||||
}
|
||||
|
||||
/// Agent that performs reconnaissance on a target
|
||||
pub struct ReconAgent {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ReconAgent {
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
Self { http }
|
||||
}
|
||||
|
||||
/// Perform reconnaissance on a target URL
|
||||
pub async fn scan(&self, base_url: &str) -> Result<ReconResult, CoreError> {
|
||||
let mut result = ReconResult {
|
||||
technologies: Vec::new(),
|
||||
interesting_headers: HashMap::new(),
|
||||
server: None,
|
||||
open_ports: Vec::new(),
|
||||
};
|
||||
|
||||
// HTTP header fingerprinting
|
||||
let response = self
|
||||
.http
|
||||
.get(base_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::Dast(format!("Failed to connect to target: {e}")))?;
|
||||
|
||||
let headers = response.headers();
|
||||
|
||||
// Extract server info
|
||||
if let Some(server) = headers.get("server") {
|
||||
let server_str = server.to_str().unwrap_or("unknown").to_string();
|
||||
result.server = Some(server_str.clone());
|
||||
result.technologies.push(server_str);
|
||||
}
|
||||
|
||||
// Detect technologies from headers
|
||||
let security_headers = [
|
||||
"x-powered-by",
|
||||
"x-aspnet-version",
|
||||
"x-frame-options",
|
||||
"x-xss-protection",
|
||||
"x-content-type-options",
|
||||
"strict-transport-security",
|
||||
"content-security-policy",
|
||||
"x-generator",
|
||||
];
|
||||
|
||||
for header_name in &security_headers {
|
||||
if let Some(value) = headers.get(*header_name) {
|
||||
let value_str = value.to_str().unwrap_or("").to_string();
|
||||
result
|
||||
.interesting_headers
|
||||
.insert(header_name.to_string(), value_str.clone());
|
||||
|
||||
if *header_name == "x-powered-by" || *header_name == "x-generator" {
|
||||
result.technologies.push(value_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing security headers
|
||||
let missing_security = [
|
||||
"strict-transport-security",
|
||||
"x-content-type-options",
|
||||
"x-frame-options",
|
||||
];
|
||||
for header in &missing_security {
|
||||
if !headers.contains_key(*header) {
|
||||
result.interesting_headers.insert(
|
||||
format!("missing:{header}"),
|
||||
"Not present".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect technology from response body
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| CoreError::Dast(format!("Failed to read response: {e}")))?;
|
||||
|
||||
self.detect_technologies_from_body(&body, &mut result);
|
||||
|
||||
info!(
|
||||
url = base_url,
|
||||
technologies = ?result.technologies,
|
||||
"Reconnaissance complete"
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn detect_technologies_from_body(&self, body: &str, result: &mut ReconResult) {
|
||||
let patterns = [
|
||||
("React", r#"react"#),
|
||||
("Angular", r#"ng-version"#),
|
||||
("Vue.js", r#"vue"#),
|
||||
("jQuery", r#"jquery"#),
|
||||
("WordPress", r#"wp-content"#),
|
||||
("Django", r#"csrfmiddlewaretoken"#),
|
||||
("Rails", r#"csrf-token"#),
|
||||
("Laravel", r#"laravel"#),
|
||||
("Express", r#"express"#),
|
||||
("Next.js", r#"__NEXT_DATA__"#),
|
||||
("Nuxt.js", r#"__NUXT__"#),
|
||||
];
|
||||
|
||||
let body_lower = body.to_lowercase();
|
||||
for (tech, pattern) in &patterns {
|
||||
if body_lower.contains(&pattern.to_lowercase()) {
|
||||
if !result.technologies.contains(&tech.to_string()) {
|
||||
result.technologies.push(tech.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user