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:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user