use crate::http_client::HttpClient;
use crate::types::{Confidence, ScanConfig, Severity, Vulnerability};
use anyhow::Result;
use std::sync::Arc;
use tracing::{debug, info};
pub struct SpringScanner {
http_client: Arc<HttpClient>,
}
impl SpringScanner {
pub fn new(http_client: Arc<HttpClient>) -> Self {
Self { http_client }
}
pub async fn scan(
&self,
target: &str,
_config: &ScanConfig,
) -> Result<(Vec<Vulnerability>, usize)> {
let mut vulnerabilities = Vec::new();
let mut tests = 0;
let is_spring = self.detect_spring(target).await?;
tests += 1;
if !is_spring {
debug!("Target does not appear to be a Spring application");
return Ok((vulnerabilities, tests));
}
info!("Detected Spring application at {}", target);
let (actuator_vulns, t) = self.check_actuator_exposure(target).await?;
vulnerabilities.extend(actuator_vulns);
tests += t;
let (h2_vulns, t) = self.check_h2_console(target).await?;
vulnerabilities.extend(h2_vulns);
tests += t;
let (swagger_vulns, t) = self.check_swagger_exposure(target).await?;
vulnerabilities.extend(swagger_vulns);
tests += t;
let (config_vulns, t) = self.check_config_exposure(target).await?;
vulnerabilities.extend(config_vulns);
tests += t;
let (jolokia_vulns, t) = self.check_jolokia_exposure(target).await?;
vulnerabilities.extend(jolokia_vulns);
tests += t;
Ok((vulnerabilities, tests))
}
async fn detect_spring(&self, target: &str) -> Result<bool> {
if let Ok(response) = self.http_client.get(target).await {
if response.body.contains("Whitelabel Error Page") {
return Ok(true);
}
if response.headers.get("x-application-context").is_some() {
return Ok(true);
}
}
let spring_paths = vec!["/actuator", "/actuator/health", "/health"];
for path in spring_paths {
let url = format!("{}{}", target, path);
if let Ok(response) = self.http_client.get(&url).await {
if response.status_code == 200 {
if response.body.contains("status") || response.body.contains("UP") {
return Ok(true);
}
}
}
}
let error_url = format!("{}/this-path-does-not-exist-12345", target);
if let Ok(response) = self.http_client.get(&error_url).await {
if response.body.contains("Whitelabel Error Page")
|| response.body.contains("springframework")
{
return Ok(true);
}
}
Ok(false)
}
async fn check_actuator_exposure(&self, target: &str) -> Result<(Vec<Vulnerability>, usize)> {
let mut vulnerabilities = Vec::new();
let mut tests = 0;
let actuator_endpoints = vec![
(
"/actuator/env",
"Environment Variables",
Severity::Critical,
"Exposes all environment variables including secrets",
),
(
"/actuator/heapdump",
"Heap Dump",
Severity::Critical,
"Allows downloading JVM heap dump - contains secrets",
),
(
"/actuator/mappings",
"URL Mappings",
Severity::Medium,
"Exposes all URL mappings",
),
(
"/actuator/loggers",
"Loggers",
Severity::High,
"Can modify log levels at runtime",
),
(
"/actuator/jolokia",
"Jolokia JMX",
Severity::Critical,
"JMX over HTTP - can lead to RCE",
),
(
"/actuator/shutdown",
"Application Shutdown",
Severity::Critical,
"Can shutdown the application",
),
(
"/actuator/health",
"Health",
Severity::Low,
"Exposes health status",
),
(
"/env",
"Environment (Legacy)",
Severity::Critical,
"Legacy environment endpoint",
),
(
"/heapdump",
"Heap Dump (Legacy)",
Severity::Critical,
"Legacy heap dump endpoint",
),
];
for (path, name, severity, description) in actuator_endpoints {
let url = format!("{}{}", target, path);
tests += 1;
if let Ok(response) = self.http_client.get(&url).await {
if response.status_code == 200 {
let is_actuator = path.contains("heapdump")
|| response.body.contains("{")
|| response.body.contains("status")
|| response.body.len() > 10;
if is_actuator {
let cvss = match severity {
Severity::Critical => 9.8,
Severity::High => 7.5,
Severity::Medium => 5.3,
_ => 3.7,
};
vulnerabilities.push(Vulnerability {
id: generate_vuln_id(),
vuln_type: "Actuator Exposure".to_string(),
severity,
confidence: Confidence::High,
category: "Framework Security".to_string(),
url: url.clone(),
parameter: None,
payload: path.to_string(),
description: format!("Spring Boot Actuator {} endpoint exposed: {}", name, description),
evidence: Some(format!("Endpoint accessible: {}", path)),
cwe: "CWE-200".to_string(),
cvss,
verified: true,
false_positive: false,
remediation: "Secure actuator endpoints with authentication or disable in production".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
}
}
}
}
Ok((vulnerabilities, tests))
}
async fn check_h2_console(&self, target: &str) -> Result<(Vec<Vulnerability>, usize)> {
let mut vulnerabilities = Vec::new();
let mut tests = 0;
let h2_paths = vec!["/h2-console", "/h2-console/", "/h2", "/console"];
for path in h2_paths {
let url = format!("{}{}", target, path);
tests += 1;
if let Ok(response) = self.http_client.get(&url).await {
if response.status_code == 200
&& (response.body.contains("H2 Console")
|| response.body.contains("h2-console"))
{
vulnerabilities.push(Vulnerability {
id: generate_vuln_id(),
vuln_type: "Remote Code Execution".to_string(),
severity: Severity::Critical,
confidence: Confidence::High,
category: "Framework Security".to_string(),
url: url.clone(),
parameter: None,
payload: path.to_string(),
description: "H2 Database Console exposed - allows arbitrary SQL execution and potential RCE".to_string(),
evidence: Some("H2 Console login page accessible".to_string()),
cwe: "CWE-284".to_string(),
cvss: 9.8,
verified: true,
false_positive: false,
remediation: "Disable H2 Console in production (spring.h2.console.enabled=false)".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
break;
}
}
}
Ok((vulnerabilities, tests))
}
async fn check_swagger_exposure(&self, target: &str) -> Result<(Vec<Vulnerability>, usize)> {
let mut vulnerabilities = Vec::new();
let mut tests = 0;
let swagger_paths = vec![
"/swagger-ui.html",
"/swagger-ui/",
"/v2/api-docs",
"/v3/api-docs",
"/openapi.json",
];
for path in swagger_paths {
let url = format!("{}{}", target, path);
tests += 1;
if let Ok(response) = self.http_client.get(&url).await {
if response.status_code == 200 {
let is_swagger = response.body.contains("swagger")
|| response.body.contains("openapi")
|| response.body.contains("\"paths\"");
if is_swagger {
vulnerabilities.push(Vulnerability {
id: generate_vuln_id(),
vuln_type: "Information Disclosure".to_string(),
severity: Severity::Medium,
confidence: Confidence::High,
category: "Framework Security".to_string(),
url: url.clone(),
parameter: None,
payload: path.to_string(),
description: format!("Swagger/OpenAPI documentation exposed: {}", path),
evidence: Some(
"API documentation accessible without authentication".to_string(),
),
cwe: "CWE-200".to_string(),
cvss: 5.3,
verified: true,
false_positive: false,
remediation:
"Secure Swagger UI with authentication or disable in production"
.to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
break;
}
}
}
}
Ok((vulnerabilities, tests))
}
async fn check_config_exposure(&self, target: &str) -> Result<(Vec<Vulnerability>, usize)> {
let mut vulnerabilities = Vec::new();
let mut tests = 0;
let config_paths = vec!["/env", "/application.properties", "/application.yml"];
for path in config_paths {
let url = format!("{}{}", target, path);
tests += 1;
if let Ok(response) = self.http_client.get(&url).await {
if response.status_code == 200 {
let sensitive_patterns =
vec!["spring.datasource", "jdbc:", "password", "secret"];
for pattern in &sensitive_patterns {
if response
.body
.to_lowercase()
.contains(&pattern.to_lowercase())
{
vulnerabilities.push(Vulnerability {
id: generate_vuln_id(),
vuln_type: "Information Disclosure".to_string(),
severity: Severity::Critical,
confidence: Confidence::High,
category: "Framework Security".to_string(),
url: url.clone(),
parameter: None,
payload: path.to_string(),
description: format!("Spring configuration file exposed: {}", path),
evidence: Some(format!("Sensitive pattern found: {}", pattern)),
cwe: "CWE-538".to_string(),
cvss: 9.1,
verified: true,
false_positive: false,
remediation: "Remove configuration files from web-accessible paths"
.to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
break;
}
}
}
}
}
Ok((vulnerabilities, tests))
}
async fn check_jolokia_exposure(&self, target: &str) -> Result<(Vec<Vulnerability>, usize)> {
let mut vulnerabilities = Vec::new();
let mut tests = 0;
let jolokia_paths = vec!["/jolokia", "/jolokia/list", "/actuator/jolokia"];
for path in jolokia_paths {
let url = format!("{}{}", target, path);
tests += 1;
if let Ok(response) = self.http_client.get(&url).await {
if response.status_code == 200
&& (response.body.contains("jolokia") || response.body.contains("MBeanServer"))
{
vulnerabilities.push(Vulnerability {
id: generate_vuln_id(),
vuln_type: "Remote Code Execution".to_string(),
severity: Severity::Critical,
confidence: Confidence::High,
category: "Framework Security".to_string(),
url: url.clone(),
parameter: None,
payload: path.to_string(),
description: "Jolokia JMX endpoint exposed - allows JMX operations over HTTP, potential RCE".to_string(),
evidence: Some("Jolokia MBean access available".to_string()),
cwe: "CWE-284".to_string(),
cvss: 9.8,
verified: true,
false_positive: false,
remediation: "Disable Jolokia or secure with authentication".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
});
break;
}
}
}
Ok((vulnerabilities, tests))
}
}
fn generate_vuln_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("SPRING-{:x}", timestamp)
}