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:
co-authored by
Claude Opus 4.6
parent
03ee69834d
commit
cea8f59e10
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user