Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #1
223 lines
8.1 KiB
Rust
223 lines
8.1 KiB
Rust
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)
|
|
}
|
|
}
|