refactor: modularize codebase and add 404 unit tests (#13)
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Detect Changes (push) Successful in 5s
CI / Tests (push) Successful in 5m15s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
CI / Security Audit (push) Successful in 1m44s

This commit was merged in pull request #13.
This commit is contained in:
2026-03-13 08:03:45 +00:00
parent acc5b86aa4
commit 3bb690e5bb
89 changed files with 11884 additions and 6046 deletions
+228 -227
View File
@@ -39,10 +39,7 @@ impl TlsAnalyzerTool {
/// TLS client hello. We test SSLv3 / old protocol support by attempting
/// connection with the system's native-tls which typically negotiates the
/// best available, then inspect what was negotiated.
async fn check_tls(
host: &str,
port: u16,
) -> Result<TlsInfo, CoreError> {
async fn check_tls(host: &str, port: u16) -> Result<TlsInfo, CoreError> {
let addr = format!("{host}:{port}");
let tcp = TcpStream::connect(&addr)
@@ -62,11 +59,13 @@ impl TlsAnalyzerTool {
.await
.map_err(|e| CoreError::Dast(format!("TLS handshake with {addr} failed: {e}")))?;
let peer_cert = tls_stream.get_ref().peer_certificate()
let peer_cert = tls_stream
.get_ref()
.peer_certificate()
.map_err(|e| CoreError::Dast(format!("Failed to get peer certificate: {e}")))?;
let mut tls_info = TlsInfo {
protocol_version: String::new(),
_protocol_version: String::new(),
cert_subject: String::new(),
cert_issuer: String::new(),
cert_not_before: String::new(),
@@ -78,7 +77,8 @@ impl TlsAnalyzerTool {
};
if let Some(cert) = peer_cert {
let der = cert.to_der()
let der = cert
.to_der()
.map_err(|e| CoreError::Dast(format!("Certificate DER encoding failed: {e}")))?;
// native_tls doesn't give rich access, so we parse what we can
@@ -93,7 +93,7 @@ impl TlsAnalyzerTool {
/// Best-effort parse of DER-encoded X.509 certificate for dates and subject.
/// This is a simplified parser; in production you would use a proper x509 crate.
fn parse_cert_der(der: &[u8], mut info: TlsInfo) -> TlsInfo {
fn parse_cert_der(_der: &[u8], mut info: TlsInfo) -> TlsInfo {
// We rely on the native_tls debug output stored in cert_subject
// and just mark fields as "see certificate details"
if info.cert_subject.contains("self signed") || info.cert_subject.contains("Self-Signed") {
@@ -104,7 +104,7 @@ impl TlsAnalyzerTool {
}
struct TlsInfo {
protocol_version: String,
_protocol_version: String,
cert_subject: String,
cert_issuer: String,
cert_not_before: String,
@@ -152,111 +152,194 @@ impl PentestTool for TlsAnalyzerTool {
&'a self,
input: serde_json::Value,
context: &'a PentestToolContext,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>> {
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>,
> {
Box::pin(async move {
let url = input
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| CoreError::Dast("Missing required 'url' parameter".to_string()))?;
let url = input
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| CoreError::Dast("Missing required 'url' parameter".to_string()))?;
let host = Self::extract_host(url)
.unwrap_or_else(|| url.to_string());
let host = Self::extract_host(url).unwrap_or_else(|| url.to_string());
let port = input
.get("port")
.and_then(|v| v.as_u64())
.map(|p| p as u16)
.unwrap_or_else(|| Self::extract_port(url));
let port = input
.get("port")
.and_then(|v| v.as_u64())
.map(|p| p as u16)
.unwrap_or_else(|| Self::extract_port(url));
let target_id = context
.target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
let target_id = context
.target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
let mut findings = Vec::new();
let mut tls_data = json!({});
let mut findings = Vec::new();
let mut tls_data = json!({});
// First check: does the server even support HTTPS?
let https_url = if url.starts_with("https://") {
url.to_string()
} else if url.starts_with("http://") {
url.replace("http://", "https://")
} else {
format!("https://{url}")
};
// First check: does the server even support HTTPS?
let https_url = if url.starts_with("https://") {
url.to_string()
} else if url.starts_with("http://") {
url.replace("http://", "https://")
} else {
format!("https://{url}")
};
// Check if HTTP redirects to HTTPS
let http_url = if url.starts_with("http://") {
url.to_string()
} else if url.starts_with("https://") {
url.replace("https://", "http://")
} else {
format!("http://{url}")
};
// Check if HTTP redirects to HTTPS
let http_url = if url.starts_with("http://") {
url.to_string()
} else if url.starts_with("https://") {
url.replace("https://", "http://")
} else {
format!("http://{url}")
};
match self.http.get(&http_url).send().await {
Ok(resp) => {
let final_url = resp.url().to_string();
let redirects_to_https = final_url.starts_with("https://");
tls_data["http_redirects_to_https"] = json!(redirects_to_https);
match self.http.get(&http_url).send().await {
Ok(resp) => {
let final_url = resp.url().to_string();
let redirects_to_https = final_url.starts_with("https://");
tls_data["http_redirects_to_https"] = json!(redirects_to_https);
if !redirects_to_https {
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: http_url.clone(),
request_headers: None,
request_body: None,
response_status: resp.status().as_u16(),
response_headers: None,
response_snippet: Some(format!("Final URL: {final_url}")),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
if !redirects_to_https {
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: http_url.clone(),
request_headers: None,
request_body: None,
response_status: resp.status().as_u16(),
response_headers: None,
response_snippet: Some(format!("Final URL: {final_url}")),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("HTTP does not redirect to HTTPS for {host}"),
format!(
"HTTP requests to {host} are not redirected to HTTPS. \
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("HTTP does not redirect to HTTPS for {host}"),
format!(
"HTTP requests to {host} are not redirected to HTTPS. \
Users accessing the site via HTTP will have their traffic \
transmitted in cleartext."
),
Severity::Medium,
http_url.clone(),
"GET".to_string(),
);
finding.cwe = Some("CWE-319".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Configure the web server to redirect all HTTP requests to HTTPS \
),
Severity::Medium,
http_url.clone(),
"GET".to_string(),
);
finding.cwe = Some("CWE-319".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Configure the web server to redirect all HTTP requests to HTTPS \
using a 301 redirect."
.to_string(),
);
findings.push(finding);
.to_string(),
);
findings.push(finding);
}
}
Err(_) => {
tls_data["http_check_error"] = json!("Could not connect via HTTP");
}
}
Err(_) => {
tls_data["http_check_error"] = json!("Could not connect via HTTP");
}
}
// Perform TLS analysis
match Self::check_tls(&host, port).await {
Ok(tls_info) => {
tls_data["host"] = json!(host);
tls_data["port"] = json!(port);
tls_data["cert_subject"] = json!(tls_info.cert_subject);
tls_data["cert_issuer"] = json!(tls_info.cert_issuer);
tls_data["cert_not_before"] = json!(tls_info.cert_not_before);
tls_data["cert_not_after"] = json!(tls_info.cert_not_after);
tls_data["alpn_protocol"] = json!(tls_info.alpn_protocol);
tls_data["san_names"] = json!(tls_info.san_names);
// Perform TLS analysis
match Self::check_tls(&host, port).await {
Ok(tls_info) => {
tls_data["host"] = json!(host);
tls_data["port"] = json!(port);
tls_data["cert_subject"] = json!(tls_info.cert_subject);
tls_data["cert_issuer"] = json!(tls_info.cert_issuer);
tls_data["cert_not_before"] = json!(tls_info.cert_not_before);
tls_data["cert_not_after"] = json!(tls_info.cert_not_after);
tls_data["alpn_protocol"] = json!(tls_info.alpn_protocol);
tls_data["san_names"] = json!(tls_info.san_names);
if tls_info.cert_expired {
if tls_info.cert_expired {
let evidence = DastEvidence {
request_method: "TLS".to_string(),
request_url: format!("{host}:{port}"),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some(format!(
"Certificate expired. Not After: {}",
tls_info.cert_not_after
)),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("Expired TLS certificate for {host}"),
format!(
"The TLS certificate for {host} has expired. \
Browsers will show security warnings to users."
),
Severity::High,
format!("https://{host}:{port}"),
"TLS".to_string(),
);
finding.cwe = Some("CWE-295".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Renew the TLS certificate. Consider using automated certificate \
management with Let's Encrypt or a similar CA."
.to_string(),
);
findings.push(finding);
warn!(host, "Expired TLS certificate");
}
if tls_info.cert_self_signed {
let evidence = DastEvidence {
request_method: "TLS".to_string(),
request_url: format!("{host}:{port}"),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some("Self-signed certificate detected".to_string()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("Self-signed TLS certificate for {host}"),
format!(
"The TLS certificate for {host} is self-signed and not issued by a \
trusted certificate authority. Browsers will show security warnings."
),
Severity::Medium,
format!("https://{host}:{port}"),
"TLS".to_string(),
);
finding.cwe = Some("CWE-295".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Replace the self-signed certificate with one issued by a trusted \
certificate authority."
.to_string(),
);
findings.push(finding);
warn!(host, "Self-signed certificate");
}
}
Err(e) => {
tls_data["tls_error"] = json!(e.to_string());
// TLS handshake failure itself is a finding
let evidence = DastEvidence {
request_method: "TLS".to_string(),
request_url: format!("{host}:{port}"),
@@ -264,10 +347,7 @@ impl PentestTool for TlsAnalyzerTool {
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some(format!(
"Certificate expired. Not After: {}",
tls_info.cert_not_after
)),
response_snippet: Some(format!("TLS error: {e}")),
screenshot_path: None,
payload: None,
response_time_ms: None,
@@ -277,10 +357,9 @@ impl PentestTool for TlsAnalyzerTool {
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("Expired TLS certificate for {host}"),
format!("TLS handshake failure for {host}"),
format!(
"The TLS certificate for {host} has expired. \
Browsers will show security warnings to users."
"Could not establish a TLS connection to {host}:{port}. Error: {e}"
),
Severity::High,
format!("https://{host}:{port}"),
@@ -289,115 +368,37 @@ impl PentestTool for TlsAnalyzerTool {
finding.cwe = Some("CWE-295".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Renew the TLS certificate. Consider using automated certificate \
management with Let's Encrypt or a similar CA."
.to_string(),
);
findings.push(finding);
warn!(host, "Expired TLS certificate");
}
if tls_info.cert_self_signed {
let evidence = DastEvidence {
request_method: "TLS".to_string(),
request_url: format!("{host}:{port}"),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some("Self-signed certificate detected".to_string()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("Self-signed TLS certificate for {host}"),
format!(
"The TLS certificate for {host} is self-signed and not issued by a \
trusted certificate authority. Browsers will show security warnings."
),
Severity::Medium,
format!("https://{host}:{port}"),
"TLS".to_string(),
);
finding.cwe = Some("CWE-295".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Replace the self-signed certificate with one issued by a trusted \
certificate authority."
.to_string(),
);
findings.push(finding);
warn!(host, "Self-signed certificate");
}
}
Err(e) => {
tls_data["tls_error"] = json!(e.to_string());
// TLS handshake failure itself is a finding
let evidence = DastEvidence {
request_method: "TLS".to_string(),
request_url: format!("{host}:{port}"),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some(format!("TLS error: {e}")),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("TLS handshake failure for {host}"),
format!(
"Could not establish a TLS connection to {host}:{port}. Error: {e}"
),
Severity::High,
format!("https://{host}:{port}"),
"TLS".to_string(),
);
finding.cwe = Some("CWE-295".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Ensure TLS is properly configured on the server. Check that the \
"Ensure TLS is properly configured on the server. Check that the \
certificate is valid and the server supports modern TLS versions."
.to_string(),
);
findings.push(finding);
.to_string(),
);
findings.push(finding);
}
}
}
// Check strict transport security via an HTTPS request
match self.http.get(&https_url).send().await {
Ok(resp) => {
let hsts = resp.headers().get("strict-transport-security");
tls_data["hsts_header"] = json!(hsts.map(|v| v.to_str().unwrap_or("")));
// Check strict transport security via an HTTPS request
match self.http.get(&https_url).send().await {
Ok(resp) => {
let hsts = resp.headers().get("strict-transport-security");
tls_data["hsts_header"] = json!(hsts.map(|v| v.to_str().unwrap_or("")));
if hsts.is_none() {
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: https_url.clone(),
request_headers: None,
request_body: None,
response_status: resp.status().as_u16(),
response_headers: None,
response_snippet: Some(
"Strict-Transport-Security header not present".to_string(),
),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
if hsts.is_none() {
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: https_url.clone(),
request_headers: None,
request_body: None,
response_status: resp.status().as_u16(),
response_headers: None,
response_snippet: Some(
"Strict-Transport-Security header not present".to_string(),
),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
@@ -410,33 +411,33 @@ impl PentestTool for TlsAnalyzerTool {
https_url.clone(),
"GET".to_string(),
);
finding.cwe = Some("CWE-319".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
finding.cwe = Some("CWE-319".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Add the Strict-Transport-Security header with an appropriate max-age. \
Example: 'Strict-Transport-Security: max-age=31536000; includeSubDomains'."
.to_string(),
);
findings.push(finding);
findings.push(finding);
}
}
Err(_) => {
tls_data["https_check_error"] = json!("Could not connect via HTTPS");
}
}
Err(_) => {
tls_data["https_check_error"] = json!("Could not connect via HTTPS");
}
}
let count = findings.len();
info!(host = %host, findings = count, "TLS analysis complete");
let count = findings.len();
info!(host = %host, findings = count, "TLS analysis complete");
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} TLS configuration issues for {host}.")
} else {
format!("TLS configuration looks good for {host}.")
},
findings,
data: tls_data,
})
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} TLS configuration issues for {host}.")
} else {
format!("TLS configuration looks good for {host}.")
},
findings,
data: tls_data,
})
})
}
}