use reqwest::header::HeaderMap;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::error::{AuditError, Result};
use crate::taxonomy::Severity;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityAnalysis {
pub score: u32,
pub grade: String,
pub headers: SecurityHeaders,
pub ssl: SslInfo,
pub issues: Vec<SecurityIssue>,
pub recommendations: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecurityHeaders {
pub content_security_policy: Option<String>,
pub x_content_type_options: Option<String>,
pub x_frame_options: Option<String>,
pub x_xss_protection: Option<String>,
pub referrer_policy: Option<String>,
pub permissions_policy: Option<String>,
pub strict_transport_security: Option<String>,
pub cross_origin_opener_policy: Option<String>,
pub cross_origin_resource_policy: Option<String>,
}
impl SecurityHeaders {
pub fn count(&self) -> usize {
[
self.content_security_policy.is_some(),
self.x_content_type_options.is_some(),
self.x_frame_options.is_some(),
self.x_xss_protection.is_some(),
self.referrer_policy.is_some(),
self.permissions_policy.is_some(),
self.strict_transport_security.is_some(),
self.cross_origin_opener_policy.is_some(),
self.cross_origin_resource_policy.is_some(),
]
.iter()
.filter(|&&x| x)
.count()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SslInfo {
pub https: bool,
pub valid_certificate: bool,
pub has_hsts: bool,
pub hsts_max_age: Option<u64>,
pub hsts_include_subdomains: bool,
pub hsts_preload: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityIssue {
pub header: String,
pub issue_type: String,
pub message: String,
pub severity: Severity,
}
pub async fn analyze_security(url: &str) -> Result<SecurityAnalysis> {
info!("Analyzing security headers for {}...", url);
let https = url.starts_with("https://");
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(false)
.build()
.map_err(AuditError::HttpError)?;
let response = match client.head(url).send().await {
Ok(response) => Ok(response),
Err(_) => client.get(url).send().await,
};
let headers = match response {
Ok(response) => extract_security_headers(response.headers()),
Err(err) => {
info!(
"Security header request failed for {}; continuing with URL-only security analysis: {}",
url, err
);
SecurityHeaders::default()
}
};
let ssl = analyze_ssl(https, &headers);
let issues = generate_security_issues(&headers, https);
let recommendations = generate_recommendations(&headers, https);
let score = calculate_security_score(&headers, &ssl, &issues);
let grade = calculate_grade(score);
info!(
"Security analysis: score={}, grade={}, headers={}",
score,
grade,
headers.count()
);
Ok(SecurityAnalysis {
score,
grade,
headers,
ssl,
issues,
recommendations,
})
}
fn extract_security_headers(headers: &HeaderMap) -> SecurityHeaders {
SecurityHeaders {
content_security_policy: headers
.get("content-security-policy")
.and_then(|v| v.to_str().ok())
.map(String::from),
x_content_type_options: headers
.get("x-content-type-options")
.and_then(|v| v.to_str().ok())
.map(String::from),
x_frame_options: headers
.get("x-frame-options")
.and_then(|v| v.to_str().ok())
.map(String::from),
x_xss_protection: headers
.get("x-xss-protection")
.and_then(|v| v.to_str().ok())
.map(String::from),
referrer_policy: headers
.get("referrer-policy")
.and_then(|v| v.to_str().ok())
.map(String::from),
permissions_policy: headers
.get("permissions-policy")
.and_then(|v| v.to_str().ok())
.map(String::from),
strict_transport_security: headers
.get("strict-transport-security")
.and_then(|v| v.to_str().ok())
.map(String::from),
cross_origin_opener_policy: headers
.get("cross-origin-opener-policy")
.and_then(|v| v.to_str().ok())
.map(String::from),
cross_origin_resource_policy: headers
.get("cross-origin-resource-policy")
.and_then(|v| v.to_str().ok())
.map(String::from),
}
}
fn analyze_ssl(https: bool, headers: &SecurityHeaders) -> SslInfo {
let hsts = headers.strict_transport_security.as_ref();
let (hsts_max_age, hsts_include_subdomains, hsts_preload) = if let Some(hsts_value) = hsts {
let max_age = hsts_value
.split(';')
.find(|s| s.trim().starts_with("max-age"))
.and_then(|s| s.split('=').nth(1))
.and_then(|s| s.trim().parse().ok());
let include_subdomains = hsts_value.to_lowercase().contains("includesubdomains");
let preload = hsts_value.to_lowercase().contains("preload");
(max_age, include_subdomains, preload)
} else {
(None, false, false)
};
SslInfo {
https,
valid_certificate: https, has_hsts: hsts.is_some(),
hsts_max_age,
hsts_include_subdomains,
hsts_preload,
}
}
fn generate_security_issues(headers: &SecurityHeaders, https: bool) -> Vec<SecurityIssue> {
let mut issues = Vec::new();
if !https {
issues.push(SecurityIssue {
header: "HTTPS".to_string(),
issue_type: "missing_https".to_string(),
message: "Site is not served over HTTPS".to_string(),
severity: Severity::Critical,
});
}
if headers.content_security_policy.is_none() {
issues.push(SecurityIssue {
header: "Content-Security-Policy".to_string(),
issue_type: "missing_header".to_string(),
message: "Missing Content-Security-Policy header".to_string(),
severity: Severity::High,
});
}
if headers.x_content_type_options.is_none() {
issues.push(SecurityIssue {
header: "X-Content-Type-Options".to_string(),
issue_type: "missing_header".to_string(),
message: "Missing X-Content-Type-Options header".to_string(),
severity: Severity::Medium,
});
}
if headers.x_frame_options.is_none() {
issues.push(SecurityIssue {
header: "X-Frame-Options".to_string(),
issue_type: "missing_header".to_string(),
message: "Missing X-Frame-Options header (clickjacking protection)".to_string(),
severity: Severity::Medium,
});
}
if https && headers.strict_transport_security.is_none() {
issues.push(SecurityIssue {
header: "Strict-Transport-Security".to_string(),
issue_type: "missing_header".to_string(),
message: "Missing HSTS header".to_string(),
severity: Severity::High,
});
}
if headers.referrer_policy.is_none() {
issues.push(SecurityIssue {
header: "Referrer-Policy".to_string(),
issue_type: "missing_header".to_string(),
message: "Missing Referrer-Policy header".to_string(),
severity: Severity::Low,
});
}
issues
}
fn generate_recommendations(headers: &SecurityHeaders, https: bool) -> Vec<String> {
let mut recommendations = Vec::new();
if !https {
recommendations.push("Enable HTTPS with a valid SSL certificate".to_string());
}
if headers.content_security_policy.is_none() {
recommendations.push(
"Add Content-Security-Policy header to prevent XSS and data injection".to_string(),
);
}
if headers.x_content_type_options.is_none() {
recommendations
.push("Add X-Content-Type-Options: nosniff to prevent MIME-type sniffing".to_string());
}
if headers.x_frame_options.is_none() {
recommendations
.push("Add X-Frame-Options: DENY or SAMEORIGIN to prevent clickjacking".to_string());
}
if https && headers.strict_transport_security.is_none() {
recommendations.push(
"Add Strict-Transport-Security header with max-age of at least 1 year".to_string(),
);
}
if headers.permissions_policy.is_none() {
recommendations
.push("Add Permissions-Policy header to control browser features".to_string());
}
recommendations
}
fn calculate_security_score(
_headers: &SecurityHeaders,
ssl: &SslInfo,
issues: &[SecurityIssue],
) -> u32 {
let mut score = 100u32;
for issue in issues {
score = score.saturating_sub(match issue.severity {
Severity::Critical => 25,
Severity::High => 15,
Severity::Medium => 10,
Severity::Low => 5,
});
}
if ssl.has_hsts {
score = score.saturating_add(5).min(100);
if ssl.hsts_include_subdomains {
score = score.saturating_add(3).min(100);
}
if ssl.hsts_preload {
score = score.saturating_add(2).min(100);
}
}
score
}
fn calculate_grade(score: u32) -> String {
match score {
90..=100 => "A+".to_string(),
80..=89 => "A".to_string(),
70..=79 => "B".to_string(),
60..=69 => "C".to_string(),
50..=59 => "D".to_string(),
_ => "F".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_security_headers_count() {
let headers = SecurityHeaders {
content_security_policy: Some("default-src 'self'".to_string()),
x_content_type_options: Some("nosniff".to_string()),
x_frame_options: Some("DENY".to_string()),
..Default::default()
};
assert_eq!(headers.count(), 3);
}
#[test]
fn test_calculate_grade() {
assert_eq!(calculate_grade(95), "A+");
assert_eq!(calculate_grade(85), "A");
assert_eq!(calculate_grade(75), "B");
assert_eq!(calculate_grade(65), "C");
assert_eq!(calculate_grade(55), "D");
assert_eq!(calculate_grade(40), "F");
}
#[test]
fn test_analyze_ssl_with_hsts() {
let headers = SecurityHeaders {
strict_transport_security: Some(
"max-age=31536000; includeSubDomains; preload".to_string(),
),
..Default::default()
};
let ssl = analyze_ssl(true, &headers);
assert!(ssl.https);
assert!(ssl.has_hsts);
assert_eq!(ssl.hsts_max_age, Some(31536000));
assert!(ssl.hsts_include_subdomains);
assert!(ssl.hsts_preload);
}
#[test]
fn test_analyze_ssl_without_hsts() {
let headers = SecurityHeaders::default();
let ssl = analyze_ssl(false, &headers);
assert!(!ssl.https);
assert!(!ssl.has_hsts);
assert_eq!(ssl.hsts_max_age, None);
}
#[test]
fn test_generate_security_issues_no_https() {
let headers = SecurityHeaders::default();
let issues = generate_security_issues(&headers, false);
assert!(issues.iter().any(|i| i.header == "HTTPS"));
assert!(issues.iter().any(|i| i.header == "Content-Security-Policy"));
assert!(issues.iter().any(|i| i.header == "X-Content-Type-Options"));
assert!(!issues
.iter()
.any(|i| i.header == "Strict-Transport-Security"));
}
#[test]
fn test_generate_security_issues_https_no_hsts() {
let headers = SecurityHeaders::default();
let issues = generate_security_issues(&headers, true);
assert!(!issues.iter().any(|i| i.header == "HTTPS"));
assert!(issues
.iter()
.any(|i| i.header == "Strict-Transport-Security"));
}
#[test]
fn test_generate_security_issues_all_headers_present() {
let headers = SecurityHeaders {
content_security_policy: Some("default-src 'self'".to_string()),
x_content_type_options: Some("nosniff".to_string()),
x_frame_options: Some("DENY".to_string()),
strict_transport_security: Some("max-age=31536000".to_string()),
referrer_policy: Some("strict-origin".to_string()),
..Default::default()
};
let issues = generate_security_issues(&headers, true);
assert!(issues.is_empty());
}
#[test]
fn test_generate_recommendations() {
let headers = SecurityHeaders::default();
let recs = generate_recommendations(&headers, true);
assert!(recs.iter().any(|r| r.contains("Content-Security-Policy")));
assert!(recs.iter().any(|r| r.contains("X-Content-Type-Options")));
assert!(recs.iter().any(|r| r.contains("Strict-Transport-Security")));
}
#[test]
fn test_calculate_security_score_perfect() {
let headers = SecurityHeaders::default();
let ssl = SslInfo {
https: true,
valid_certificate: true,
has_hsts: true,
hsts_max_age: Some(31536000),
hsts_include_subdomains: true,
hsts_preload: true,
};
let issues = vec![];
let score = calculate_security_score(&headers, &ssl, &issues);
assert_eq!(score, 100);
}
#[test]
fn test_calculate_security_score_no_https() {
let headers = SecurityHeaders::default();
let ssl = SslInfo::default();
let issues = generate_security_issues(&headers, false);
let score = calculate_security_score(&headers, &ssl, &issues);
assert!(score < 50);
}
}