pub mod module;
mod sourcemap;
pub use module::SecurityModule;
pub use sourcemap::{audit_source_maps, SourceMapLeak, SourceMapLeakAudit};
use reqwest::header::HeaderMap;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use tracing::info;
use crate::error::{AuditError, Result};
use crate::taxonomy::Severity;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedProtection {
pub name: String,
pub kind: String,
pub is_waf: bool,
pub is_cdn: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProtectionDetection {
pub services: Vec<DetectedProtection>,
pub has_waf: bool,
pub has_cdn: bool,
}
#[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>,
pub protection: ProtectionDetection,
#[serde(default)]
pub sourcemap_leaks: SourceMapLeakAudit,
}
#[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 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>,
pub access_control_allow_origin: Option<String>,
pub access_control_allow_credentials: 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.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(),
self.access_control_allow_origin.is_some(),
self.access_control_allow_credentials.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,
#[serde(skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cipher: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub certificate_subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub certificate_issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub certificate_valid_to: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub certificate_expires_in_days: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub certificate_chain_length: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub certificate_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub certificate_has_weak_signature: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub certificate_has_sha1_signature: Option<bool>,
}
#[derive(Debug, Clone, Default)]
pub struct BrowserCertificateDetails {
pub protocol: Option<String>,
pub cipher: Option<String>,
pub subject: Option<String>,
pub issuer: Option<String>,
pub valid_to: Option<i64>,
pub expires_in_days: Option<i64>,
pub chain_length: Option<usize>,
pub error: Option<String>,
pub has_weak_signature: Option<bool>,
pub has_sha1_signature: Option<bool>,
}
impl SslInfo {
pub fn apply_certificate_details(&mut self, details: BrowserCertificateDetails) {
self.protocol = details.protocol;
self.cipher = details.cipher;
self.certificate_subject = details.subject;
self.certificate_issuer = details.issuer;
self.certificate_valid_to = details.valid_to;
self.certificate_expires_in_days = details.expires_in_days;
self.certificate_chain_length = details.chain_length;
self.certificate_error = details.error;
self.certificate_has_weak_signature = details.has_weak_signature;
self.certificate_has_sha1_signature = details.has_sha1_signature;
if self.certificate_error.is_some() {
self.valid_certificate = false;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HeaderTier {
Baseline,
ArchitectureDependent,
ContextDependent,
NotAssessable,
}
pub fn header_tier(header: &str) -> HeaderTier {
match header {
"Content-Security-Policy"
| "Strict-Transport-Security"
| "X-Content-Type-Options"
| "X-Frame-Options"
| "HTTPS" => HeaderTier::Baseline,
"Referrer-Policy" | "Permissions-Policy" => HeaderTier::ArchitectureDependent,
"Cross-Origin-Opener-Policy" | "Cross-Origin-Resource-Policy" => {
HeaderTier::ContextDependent
}
"Access-Control-Allow-Origin" | "Access-Control-Allow-Credentials" => {
HeaderTier::NotAssessable
}
_ => HeaderTier::Baseline,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityIssue {
pub header: String,
pub issue_type: String,
pub message: String,
pub severity: Severity,
pub tier: HeaderTier,
#[serde(default, skip_serializing_if = "SecurityIssueValues::is_empty")]
pub values: SecurityIssueValues,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecurityIssueKind {
MissingHttps,
MissingCsp,
MissingXContentTypeOptions,
MissingXFrameOptions,
MissingHsts,
MissingReferrerPolicy,
MissingPermissionsPolicy,
MissingCoop,
MissingCorp,
CorsWildcardCredentials,
HstsPreloadIneligible,
PermissionsPolicyPermissive,
PublicSourceMap,
CspUnsafeInlineScript,
CspUnsafeEvalScript,
CspWildcardScriptSource,
CspUnsafeInlineStyle,
CspWildcardSource,
CspMissingDirective,
}
pub const CSP_REQUIRED_DIRECTIVES: [&str; 3] = ["object-src", "base-uri", "frame-ancestors"];
impl SecurityIssueKind {
pub const ALL: [SecurityIssueKind; 19] = [
SecurityIssueKind::MissingHttps,
SecurityIssueKind::MissingCsp,
SecurityIssueKind::MissingXContentTypeOptions,
SecurityIssueKind::MissingXFrameOptions,
SecurityIssueKind::MissingHsts,
SecurityIssueKind::MissingReferrerPolicy,
SecurityIssueKind::MissingPermissionsPolicy,
SecurityIssueKind::MissingCoop,
SecurityIssueKind::MissingCorp,
SecurityIssueKind::CorsWildcardCredentials,
SecurityIssueKind::HstsPreloadIneligible,
SecurityIssueKind::PermissionsPolicyPermissive,
SecurityIssueKind::PublicSourceMap,
SecurityIssueKind::CspUnsafeInlineScript,
SecurityIssueKind::CspUnsafeEvalScript,
SecurityIssueKind::CspWildcardScriptSource,
SecurityIssueKind::CspUnsafeInlineStyle,
SecurityIssueKind::CspWildcardSource,
SecurityIssueKind::CspMissingDirective,
];
pub fn header(self) -> &'static str {
use SecurityIssueKind::*;
match self {
MissingHttps => "HTTPS",
MissingCsp
| CspUnsafeInlineScript
| CspUnsafeEvalScript
| CspWildcardScriptSource
| CspUnsafeInlineStyle
| CspWildcardSource
| CspMissingDirective => "Content-Security-Policy",
MissingXContentTypeOptions => "X-Content-Type-Options",
MissingXFrameOptions => "X-Frame-Options",
MissingHsts | HstsPreloadIneligible => "Strict-Transport-Security",
MissingReferrerPolicy => "Referrer-Policy",
MissingPermissionsPolicy | PermissionsPolicyPermissive => "Permissions-Policy",
MissingCoop => "Cross-Origin-Opener-Policy",
MissingCorp => "Cross-Origin-Resource-Policy",
CorsWildcardCredentials => "Access-Control-Allow-Origin",
PublicSourceMap => "Source Map",
}
}
}
pub fn all_security_check_ids() -> Vec<String> {
let mut ids = Vec::new();
for kind in SecurityIssueKind::ALL {
if kind == SecurityIssueKind::CspMissingDirective {
for directive in CSP_REQUIRED_DIRECTIVES {
let values = SecurityIssueValues {
csp_directive: Some(directive.to_string()),
..Default::default()
};
ids.push(format!(
"{}:{}",
kind.header(),
issue_type_for(kind, &values)
));
}
} else {
ids.push(format!(
"{}:{}",
kind.header(),
issue_type_for(kind, &SecurityIssueValues::default())
));
}
}
ids.sort();
ids.dedup();
ids
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecurityIssueValues {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hsts_max_age: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hsts_include_subdomains: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_map_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_map_example: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub csp_directive: Option<String>,
}
impl SecurityIssueValues {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
}
impl SecurityIssue {
pub fn kind(&self) -> Option<SecurityIssueKind> {
use SecurityIssueKind::*;
let kind = match self.issue_type.as_str() {
"missing_https" => MissingHttps,
"missing_header" => match self.header.as_str() {
"Content-Security-Policy" => MissingCsp,
"X-Content-Type-Options" => MissingXContentTypeOptions,
"X-Frame-Options" => MissingXFrameOptions,
"Strict-Transport-Security" => MissingHsts,
"Referrer-Policy" => MissingReferrerPolicy,
"Permissions-Policy" => MissingPermissionsPolicy,
"Cross-Origin-Opener-Policy" => MissingCoop,
"Cross-Origin-Resource-Policy" => MissingCorp,
_ => return None,
},
"cors_wildcard_credentials" => CorsWildcardCredentials,
"hsts_preload_ineligible" => HstsPreloadIneligible,
"permissions_policy_permissive" => PermissionsPolicyPermissive,
"public_source_map" => PublicSourceMap,
"unsafe_inline_script" => CspUnsafeInlineScript,
"unsafe_eval_script" => CspUnsafeEvalScript,
"wildcard_script_source" => CspWildcardScriptSource,
"unsafe_inline_style" => CspUnsafeInlineStyle,
"wildcard_source" => CspWildcardSource,
other if other.starts_with("missing_") => CspMissingDirective,
_ => return None,
};
Some(kind)
}
pub fn localized_message(&self, en: bool) -> String {
match self.kind() {
Some(kind) => security_issue_text(kind, &self.values, en),
None => self.message.clone(),
}
}
}
pub fn security_issue_text(
kind: SecurityIssueKind,
values: &SecurityIssueValues,
en: bool,
) -> String {
use SecurityIssueKind::*;
match kind {
MissingHttps => if en {
"Site is not served over HTTPS"
} else {
"Seite wird nicht über HTTPS ausgeliefert"
}
.to_string(),
MissingCsp => if en {
"Missing Content-Security-Policy header"
} else {
"Content-Security-Policy-Header fehlt"
}
.to_string(),
MissingXContentTypeOptions => if en {
"Missing X-Content-Type-Options header"
} else {
"X-Content-Type-Options-Header fehlt"
}
.to_string(),
MissingXFrameOptions => if en {
"Missing X-Frame-Options header (clickjacking protection)"
} else {
"X-Frame-Options-Header fehlt (Clickjacking-Schutz)"
}
.to_string(),
MissingHsts => if en {
"Missing HSTS header"
} else {
"HSTS-Header fehlt"
}
.to_string(),
MissingReferrerPolicy => if en {
"Missing Referrer-Policy header"
} else {
"Referrer-Policy-Header fehlt"
}
.to_string(),
MissingPermissionsPolicy => if en {
"Missing Permissions-Policy header"
} else {
"Permissions-Policy-Header fehlt"
}
.to_string(),
MissingCoop => if en {
"Cross-Origin-Opener-Policy is not set. This header only matters if the page uses \
SharedArrayBuffer, high-resolution timers, or needs to isolate itself from \
cross-origin popups — if none of that applies, no action is needed here. If it \
does apply, set it to same-origin and verify popup/window interactions still work \
as expected."
} else {
"Cross-Origin-Opener-Policy ist nicht gesetzt. Dieser Header ist nur relevant, wenn \
die Seite SharedArrayBuffer, hochauflösende Timer verwendet oder sich von \
Cross-Origin-Popups isolieren muss — trifft das nicht zu, ist hier keine Maßnahme \
nötig. Trifft es zu: auf same-origin setzen und prüfen, ob Popup-/Fenster-\
Interaktionen weiterhin wie erwartet funktionieren."
}
.to_string(),
MissingCorp => if en {
"Cross-Origin-Resource-Policy is not set. This header only matters if the page \
serves fonts, scripts, or media that other origins should be prevented from \
loading — if the site's resources are intentionally public, no action is needed \
here. If cross-origin loading should be restricted, set it to same-origin or \
same-site."
} else {
"Cross-Origin-Resource-Policy ist nicht gesetzt. Dieser Header ist nur relevant, \
wenn die Seite Schriften, Skripte oder Medien ausliefert, die andere Origins nicht \
laden können sollen — sind die Ressourcen bewusst öffentlich, ist hier keine \
Maßnahme nötig. Soll Cross-Origin-Laden eingeschränkt werden: auf same-origin oder \
same-site setzen."
}
.to_string(),
CorsWildcardCredentials => if en {
"CORS allows any origin while also allowing credentials"
} else {
"CORS erlaubt jede Origin und gleichzeitig Credentials"
}
.to_string(),
HstsPreloadIneligible => {
let max_age = values.hsts_max_age.as_deref().unwrap_or("unset");
let subdomains = values.hsts_include_subdomains.unwrap_or(false);
if en {
format!(
"HSTS preload directive is set but requirements aren't met (max-age={max_age}, includeSubDomains={subdomains}) — the site won't qualify for the HSTS preload list"
)
} else {
format!(
"HSTS-preload-Direktive ist gesetzt, die Voraussetzungen sind aber nicht erfüllt (max-age={max_age}, includeSubDomains={subdomains}) — die Seite wird nicht in die HSTS-Preload-Liste aufgenommen"
)
}
}
PermissionsPolicyPermissive => if en {
"Permissions-Policy header is present but doesn't restrict any feature (empty or wildcard-only)"
} else {
"Permissions-Policy-Header ist vorhanden, schränkt aber kein Feature ein (leer oder nur Wildcards)"
}
.to_string(),
PublicSourceMap => {
let count = values.source_map_count.unwrap_or(1);
let example = values
.source_map_example
.as_deref()
.map(|url| format!(" (e.g. {url})"))
.unwrap_or_default();
if en {
format!(
"{count} publicly reachable source map{} found{example} — exposes original source, comments, and internal file paths",
if count == 1 { "" } else { "s" }
)
} else {
let example = example.replace(" (e.g. ", " (z. B. ");
format!(
"{count} öffentlich erreichbare Source-Map{} gefunden{example} — legt Originalquellcode, Kommentare und interne Dateipfade offen",
if count == 1 { "" } else { "s" }
)
}
}
CspUnsafeInlineScript => if en {
"CSP allows unsafe-inline scripts without nonce/hash protection"
} else {
"CSP erlaubt unsafe-inline-Skripte ohne Nonce-/Hash-Absicherung"
}
.to_string(),
CspUnsafeEvalScript => if en {
"CSP allows unsafe-eval in script sources"
} else {
"CSP erlaubt unsafe-eval in den Skript-Quellen"
}
.to_string(),
CspWildcardScriptSource => if en {
"CSP allows wildcard script sources"
} else {
"CSP erlaubt Wildcard-Skriptquellen"
}
.to_string(),
CspUnsafeInlineStyle => if en {
"CSP allows unsafe-inline styles without nonce/hash protection"
} else {
"CSP erlaubt unsafe-inline-Styles ohne Nonce-/Hash-Absicherung"
}
.to_string(),
CspWildcardSource => if en {
"CSP contains wildcard source expressions"
} else {
"CSP enthält Wildcard-Quellausdrücke"
}
.to_string(),
CspMissingDirective => {
let directive = values.csp_directive.as_deref().unwrap_or("object-src");
if en {
format!("CSP missing {directive} directive")
} else {
format!("CSP fehlt die {directive}-Direktive")
}
}
}
}
fn issue_type_for(kind: SecurityIssueKind, values: &SecurityIssueValues) -> String {
use SecurityIssueKind::*;
match kind {
MissingHttps => "missing_https".to_string(),
MissingCsp
| MissingXContentTypeOptions
| MissingXFrameOptions
| MissingHsts
| MissingReferrerPolicy
| MissingPermissionsPolicy
| MissingCoop
| MissingCorp => "missing_header".to_string(),
CorsWildcardCredentials => "cors_wildcard_credentials".to_string(),
HstsPreloadIneligible => "hsts_preload_ineligible".to_string(),
PermissionsPolicyPermissive => "permissions_policy_permissive".to_string(),
PublicSourceMap => "public_source_map".to_string(),
CspUnsafeInlineScript => "unsafe_inline_script".to_string(),
CspUnsafeEvalScript => "unsafe_eval_script".to_string(),
CspWildcardScriptSource => "wildcard_script_source".to_string(),
CspUnsafeInlineStyle => "unsafe_inline_style".to_string(),
CspWildcardSource => "wildcard_source".to_string(),
CspMissingDirective => format!(
"missing_{}",
values.csp_directive.as_deref().unwrap_or("object-src")
),
}
}
fn security_issue(
header: &str,
kind: SecurityIssueKind,
values: SecurityIssueValues,
severity: Severity,
tier: HeaderTier,
) -> SecurityIssue {
SecurityIssue {
header: header.to_string(),
issue_type: issue_type_for(kind, &values),
message: security_issue_text(kind, &values, true),
severity,
tier,
values,
}
}
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, protection) = match response {
Ok(response) => {
let raw = response.headers();
let h = extract_security_headers(raw);
let p = detect_protection(raw);
(h, p)
}
Err(err) => {
info!(
"Security header request failed for {}; continuing with URL-only security analysis: {}",
url, err
);
(SecurityHeaders::default(), ProtectionDetection::default())
}
};
let ssl = analyze_ssl(https, &headers);
let mut issues = generate_security_issues(&headers, https);
if hsts_preload_ineligible(&ssl) {
issues.push(security_issue(
"Strict-Transport-Security",
SecurityIssueKind::HstsPreloadIneligible,
SecurityIssueValues {
hsts_max_age: Some(
ssl.hsts_max_age
.map(|v| v.to_string())
.unwrap_or_else(|| "unset".to_string()),
),
hsts_include_subdomains: Some(ssl.hsts_include_subdomains),
..Default::default()
},
Severity::Low,
HeaderTier::Baseline,
));
}
if permissions_policy_is_permissive(&headers) {
issues.push(security_issue(
"Permissions-Policy",
SecurityIssueKind::PermissionsPolicyPermissive,
SecurityIssueValues::default(),
Severity::Low,
HeaderTier::ArchitectureDependent,
));
}
let sourcemap_leaks = audit_source_maps(url).await;
if !sourcemap_leaks.leaks.is_empty() {
issues.push(security_issue(
"Source Map",
SecurityIssueKind::PublicSourceMap,
SecurityIssueValues {
source_map_count: Some(sourcemap_leaks.leaks.len()),
source_map_example: Some(sourcemap_leaks.leaks[0].map_url.clone()),
..Default::default()
},
Severity::High,
HeaderTier::Baseline,
));
}
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={}, protection={:?}",
score,
grade,
headers.count(),
protection
.services
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>(),
);
Ok(SecurityAnalysis {
score,
grade,
headers,
ssl,
issues,
recommendations,
protection,
sourcemap_leaks,
})
}
fn detect_protection(headers: &HeaderMap) -> ProtectionDetection {
let hdr = |name: &str| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_lowercase()
};
let has = |name: &str| headers.contains_key(name);
let mut services: Vec<DetectedProtection> = Vec::new();
macro_rules! push {
($name:expr, $kind:expr, $waf:expr, $cdn:expr) => {
services.push(DetectedProtection {
name: $name.to_string(),
kind: $kind.to_string(),
is_waf: $waf,
is_cdn: $cdn,
});
};
}
if has("cf-ray") || hdr("server").contains("cloudflare") {
push!("Cloudflare", "CDN + WAF", true, true);
}
if has("x-amz-cf-id") || hdr("via").contains("cloudfront") {
push!("AWS CloudFront", "CDN", false, true);
}
if has("x-akamai-request-id")
|| has("x-check-cacheable")
|| has("akamai-origin-hop")
|| hdr("server").contains("akamaighost")
{
push!("Akamai", "CDN + WAF", true, true);
}
if has("x-fastly-request-id") || has("fastly-restarts") || hdr("x-served-by").contains("cache-")
{
push!("Fastly", "CDN", false, true);
}
if has("x-sucuri-id") || has("x-sucuri-cache") || hdr("server").contains("sucuri") {
push!("Sucuri", "WAF + CDN", true, true);
}
if has("x-iinfo") || hdr("x-cdn").contains("imperva") || hdr("x-cdn").contains("incapsula") {
push!("Imperva", "WAF + CDN", true, true);
}
if has("x-vercel-id") {
push!("Vercel", "Hosting + CDN", false, true);
}
if has("x-nf-request-id") || hdr("server").contains("netlify") {
push!("Netlify", "Hosting + CDN", false, true);
}
if has("cdn-pullzone") || has("bunny-request-id") || has("cdn-requestid") {
push!("BunnyCDN", "CDN", false, true);
}
if hdr("server").contains("keycdn-engine") || (has("x-edge-location") && !has("x-amz-cf-id")) {
push!("KeyCDN", "CDN", false, true);
}
if has("x-varnish") || hdr("via").contains("varnish") {
push!("Varnish", "Cache", false, true);
}
let has_waf = services.iter().any(|s| s.is_waf);
let has_cdn = services.iter().any(|s| s.is_cdn);
ProtectionDetection {
services,
has_waf,
has_cdn,
}
}
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),
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),
access_control_allow_origin: headers
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok())
.map(String::from),
access_control_allow_credentials: headers
.get("access-control-allow-credentials")
.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,
..Default::default()
}
}
pub(crate) fn generate_security_issues(
headers: &SecurityHeaders,
https: bool,
) -> Vec<SecurityIssue> {
let mut issues = Vec::new();
if !https {
issues.push(security_issue(
"HTTPS",
SecurityIssueKind::MissingHttps,
SecurityIssueValues::default(),
Severity::Critical,
HeaderTier::Baseline,
));
}
if headers.content_security_policy.is_none() {
issues.push(security_issue(
"Content-Security-Policy",
SecurityIssueKind::MissingCsp,
SecurityIssueValues::default(),
Severity::High,
HeaderTier::Baseline,
));
} else if let Some(ref csp) = headers.content_security_policy {
issues.extend(collect_csp_quality_issues(csp));
}
if headers.x_content_type_options.is_none() {
issues.push(security_issue(
"X-Content-Type-Options",
SecurityIssueKind::MissingXContentTypeOptions,
SecurityIssueValues::default(),
Severity::Medium,
HeaderTier::Baseline,
));
}
if headers.x_frame_options.is_none() {
issues.push(security_issue(
"X-Frame-Options",
SecurityIssueKind::MissingXFrameOptions,
SecurityIssueValues::default(),
Severity::Medium,
HeaderTier::Baseline,
));
}
if https && headers.strict_transport_security.is_none() {
issues.push(security_issue(
"Strict-Transport-Security",
SecurityIssueKind::MissingHsts,
SecurityIssueValues::default(),
Severity::High,
HeaderTier::Baseline,
));
}
if headers.referrer_policy.is_none() {
issues.push(security_issue(
"Referrer-Policy",
SecurityIssueKind::MissingReferrerPolicy,
SecurityIssueValues::default(),
Severity::Low,
HeaderTier::ArchitectureDependent,
));
}
if headers.permissions_policy.is_none() {
issues.push(security_issue(
"Permissions-Policy",
SecurityIssueKind::MissingPermissionsPolicy,
SecurityIssueValues::default(),
Severity::Low,
HeaderTier::ArchitectureDependent,
));
}
if headers.cross_origin_opener_policy.is_none() {
issues.push(security_issue(
"Cross-Origin-Opener-Policy",
SecurityIssueKind::MissingCoop,
SecurityIssueValues::default(),
Severity::Low,
HeaderTier::ContextDependent,
));
}
if headers.cross_origin_resource_policy.is_none() {
issues.push(security_issue(
"Cross-Origin-Resource-Policy",
SecurityIssueKind::MissingCorp,
SecurityIssueValues::default(),
Severity::Low,
HeaderTier::ContextDependent,
));
}
if cors_allows_wildcard_credentials(headers) {
issues.push(security_issue(
"Access-Control-Allow-Origin",
SecurityIssueKind::CorsWildcardCredentials,
SecurityIssueValues::default(),
Severity::High,
HeaderTier::Baseline,
));
}
issues
}
fn cors_allows_wildcard_credentials(headers: &SecurityHeaders) -> bool {
headers
.access_control_allow_origin
.as_deref()
.is_some_and(|origin| origin.trim() == "*")
&& headers
.access_control_allow_credentials
.as_deref()
.is_some_and(|credentials| credentials.trim().eq_ignore_ascii_case("true"))
}
fn hsts_preload_ineligible(ssl: &SslInfo) -> bool {
const ONE_YEAR_SECONDS: u64 = 31_536_000;
ssl.hsts_preload
&& (ssl.hsts_max_age.unwrap_or(0) < ONE_YEAR_SECONDS || !ssl.hsts_include_subdomains)
}
fn permissions_policy_is_permissive(headers: &SecurityHeaders) -> bool {
let Some(value) = headers.permissions_policy.as_deref() else {
return false;
};
let trimmed = value.trim();
if trimmed.is_empty() {
return true;
}
trimmed.split(',').all(|directive| {
directive
.split('=')
.nth(1)
.map(|allowlist| allowlist.trim() == "*")
.unwrap_or(false)
})
}
fn collect_csp_quality_issues(policy: &str) -> Vec<SecurityIssue> {
let directives = parse_csp_directives(policy);
let mut issues = Vec::new();
let effective_script = directive_values(&directives, "script-src")
.or_else(|| directive_values(&directives, "default-src"))
.unwrap_or_default();
if effective_script.contains(&"'unsafe-inline'") && !has_nonce_or_hash(effective_script) {
issues.push(csp_issue(
SecurityIssueKind::CspUnsafeInlineScript,
SecurityIssueValues::default(),
Severity::High,
));
}
if effective_script.contains(&"'unsafe-eval'") {
issues.push(csp_issue(
SecurityIssueKind::CspUnsafeEvalScript,
SecurityIssueValues::default(),
Severity::High,
));
}
if has_wildcard_source(effective_script) {
issues.push(csp_issue(
SecurityIssueKind::CspWildcardScriptSource,
SecurityIssueValues::default(),
Severity::High,
));
}
let effective_style = directive_values(&directives, "style-src")
.or_else(|| directive_values(&directives, "default-src"))
.unwrap_or_default();
if effective_style.contains(&"'unsafe-inline'") && !has_nonce_or_hash(effective_style) {
issues.push(csp_issue(
SecurityIssueKind::CspUnsafeInlineStyle,
SecurityIssueValues::default(),
Severity::Medium,
));
}
if has_wildcard_source(
directives
.values()
.flatten()
.copied()
.collect::<Vec<_>>()
.as_slice(),
) {
issues.push(csp_issue(
SecurityIssueKind::CspWildcardSource,
SecurityIssueValues::default(),
Severity::Medium,
));
}
for directive in CSP_REQUIRED_DIRECTIVES {
let severity = Severity::Medium;
if !directives.contains_key(directive) {
issues.push(csp_issue(
SecurityIssueKind::CspMissingDirective,
SecurityIssueValues {
csp_directive: Some(directive.to_string()),
..Default::default()
},
severity,
));
}
}
issues
}
fn generate_csp_recommendations(policy: &str) -> Vec<String> {
let issue_types: std::collections::BTreeSet<_> = collect_csp_quality_issues(policy)
.into_iter()
.map(|issue| issue.issue_type)
.collect();
let mut recommendations = Vec::new();
if issue_types.contains("unsafe_inline_script") || issue_types.contains("unsafe_inline_style") {
recommendations.push(
"Remove unsafe-inline from CSP or replace it with nonces/hashes for approved inline code"
.to_string(),
);
}
if issue_types.contains("unsafe_eval_script") {
recommendations.push(
"Remove unsafe-eval from script-src; avoid runtime string evaluation in production"
.to_string(),
);
}
if issue_types.contains("wildcard_script_source") || issue_types.contains("wildcard_source") {
recommendations
.push("Replace wildcard CSP sources with explicit trusted origins".to_string());
}
if issue_types
.iter()
.any(|issue| issue.starts_with("missing_"))
{
recommendations.push(
"Harden CSP with object-src 'none', base-uri 'self', and frame-ancestors 'none' or a trusted origin"
.to_string(),
);
}
recommendations
}
fn csp_issue(
kind: SecurityIssueKind,
values: SecurityIssueValues,
severity: Severity,
) -> SecurityIssue {
security_issue(
"Content-Security-Policy",
kind,
values,
severity,
HeaderTier::Baseline,
)
}
fn parse_csp_directives(policy: &str) -> BTreeMap<String, Vec<&str>> {
let mut directives = BTreeMap::new();
for directive in policy.split(';') {
let mut parts = directive.split_whitespace();
let Some(name) = parts.next() else {
continue;
};
directives.insert(name.to_ascii_lowercase(), parts.collect());
}
directives
}
fn directive_values<'a>(
directives: &'a BTreeMap<String, Vec<&'a str>>,
name: &str,
) -> Option<&'a [&'a str]> {
directives.get(name).map(Vec::as_slice)
}
fn has_nonce_or_hash(values: &[&str]) -> bool {
values.iter().any(|value| {
value.starts_with("'nonce-")
|| value.starts_with("'sha256-")
|| value.starts_with("'sha384-")
|| value.starts_with("'sha512-")
})
}
fn has_wildcard_source(values: &[&str]) -> bool {
values.iter().any(|value| {
*value == "*"
|| value.starts_with("*.")
|| value.starts_with("https://*")
|| value.starts_with("http://*")
})
}
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(),
);
} else if let Some(ref csp) = headers.content_security_policy {
recommendations.extend(generate_csp_recommendations(csp));
}
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());
}
if headers.cross_origin_opener_policy.is_none() {
recommendations.push(
"Consider Cross-Origin-Opener-Policy (same-origin) if the site uses SharedArrayBuffer, \
high-resolution timers, or cross-origin popups — not required for standard sites."
.to_string(),
);
}
if headers.cross_origin_resource_policy.is_none() {
recommendations.push(
"Consider Cross-Origin-Resource-Policy if the site serves fonts, scripts, or media \
that other origins should not be able to load — not required if resources are intentionally public."
.to_string(),
);
}
if cors_allows_wildcard_credentials(headers) {
recommendations.push(
"Do not combine Access-Control-Allow-Origin: * with credentialed CORS; allowlist trusted origins instead."
.to_string(),
);
}
recommendations
}
pub(crate) fn calculate_security_score(
_headers: &SecurityHeaders,
ssl: &SslInfo,
issues: &[SecurityIssue],
) -> u32 {
let mut score = 100u32;
const MISSING_CSP_PENALTY: u32 = 15;
let deduction = |sev: &Severity| match sev {
Severity::Critical => 25,
Severity::High => 15,
Severity::Medium => 10,
Severity::Low => 5,
};
let mut csp_quality_penalty = 0u32;
for issue in issues {
let is_csp_quality =
issue.header == "Content-Security-Policy" && issue.issue_type != "missing_header";
if is_csp_quality {
csp_quality_penalty += deduction(&issue.severity);
} else {
score = score.saturating_sub(deduction(&issue.severity));
}
}
score = score.saturating_sub(csp_quality_penalty.min(MISSING_CSP_PENALTY));
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
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SecurityCategory {
#[default]
ResponseHeaders,
TransportSecurity,
AccessPolicies,
ThirdPartyExposure,
}
impl SecurityCategory {
pub fn label(&self, en: bool) -> &'static str {
if en {
match self {
Self::ResponseHeaders => "Response headers",
Self::TransportSecurity => "Transport security",
Self::AccessPolicies => "Access policies",
Self::ThirdPartyExposure => "Third-party exposure",
}
} else {
match self {
Self::ResponseHeaders => "Response-Header",
Self::TransportSecurity => "Transportsicherheit",
Self::AccessPolicies => "Zugriffs-Richtlinien",
Self::ThirdPartyExposure => "Drittanbieter-Exposition",
}
}
}
pub fn all() -> [SecurityCategory; 4] {
[
Self::ResponseHeaders,
Self::TransportSecurity,
Self::AccessPolicies,
Self::ThirdPartyExposure,
]
}
fn for_header(header: &str) -> SecurityCategory {
match header {
"HTTPS" | "Strict-Transport-Security" => Self::TransportSecurity,
"Content-Security-Policy" | "X-Content-Type-Options" | "X-Frame-Options" => {
Self::ResponseHeaders
}
"Referrer-Policy"
| "Permissions-Policy"
| "Cross-Origin-Opener-Policy"
| "Cross-Origin-Resource-Policy"
| "Access-Control-Allow-Origin" => Self::AccessPolicies,
"Source Map" => Self::ThirdPartyExposure,
_ => Self::ResponseHeaders,
}
}
}
pub fn calculate_security_category_scores(
issues: &[SecurityIssue],
) -> Vec<(SecurityCategory, u32)> {
let deduction = |sev: &Severity| match sev {
Severity::Critical => 25,
Severity::High => 15,
Severity::Medium => 10,
Severity::Low => 5,
};
SecurityCategory::all()
.into_iter()
.map(|category| {
let mut score = 100u32;
for issue in issues {
if SecurityCategory::for_header(&issue.header) == category {
score = score.saturating_sub(deduction(&issue.severity));
}
}
(category, score)
})
.collect()
}
fn calculate_grade(score: u32) -> String {
crate::registry::SECURITY_GRADE
.label(score as f32, false)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_security_message_is_localized_and_english_stays_english() {
let values = SecurityIssueValues {
hsts_max_age: Some("100".to_string()),
hsts_include_subdomains: Some(false),
source_map_count: Some(3),
source_map_example: Some("https://example.com/app.js.map".to_string()),
csp_directive: Some("object-src".to_string()),
};
for kind in SecurityIssueKind::ALL {
let en = security_issue_text(kind, &values, true);
let de = security_issue_text(kind, &values, false);
assert!(!en.is_empty(), "{kind:?}: empty English text");
assert_ne!(en, de, "{kind:?}: German text must differ from English");
assert!(
!en.chars().any(|c| "äöüÄÖÜß".contains(c)),
"{kind:?} English text leaks German: {en}"
);
}
}
#[test]
fn security_issue_types_match_the_stored_contract() {
use SecurityIssueKind::*;
let empty = SecurityIssueValues::default();
for (kind, expected) in [
(MissingHttps, "missing_https"),
(MissingCsp, "missing_header"),
(MissingCoop, "missing_header"),
(CorsWildcardCredentials, "cors_wildcard_credentials"),
(HstsPreloadIneligible, "hsts_preload_ineligible"),
(PermissionsPolicyPermissive, "permissions_policy_permissive"),
(PublicSourceMap, "public_source_map"),
(CspUnsafeInlineScript, "unsafe_inline_script"),
(CspUnsafeEvalScript, "unsafe_eval_script"),
(CspWildcardScriptSource, "wildcard_script_source"),
(CspUnsafeInlineStyle, "unsafe_inline_style"),
(CspWildcardSource, "wildcard_source"),
] {
assert_eq!(issue_type_for(kind, &empty), expected, "{kind:?}");
}
assert_eq!(
issue_type_for(
CspMissingDirective,
&SecurityIssueValues {
csp_directive: Some("frame-ancestors".to_string()),
..Default::default()
}
),
"missing_frame-ancestors"
);
}
#[test]
fn kind_round_trips_through_the_stored_fields() {
let issue = security_issue(
"Cross-Origin-Opener-Policy",
SecurityIssueKind::MissingCoop,
SecurityIssueValues::default(),
Severity::Low,
HeaderTier::ContextDependent,
);
assert_eq!(issue.kind(), Some(SecurityIssueKind::MissingCoop));
assert_eq!(issue.message, issue.localized_message(true));
assert_ne!(issue.message, issue.localized_message(false));
}
#[test]
fn unknown_issue_type_falls_back_to_the_stored_message() {
let issue = SecurityIssue {
header: "X-Frame-Options".to_string(),
issue_type: "something_this_build_does_not_know".to_string(),
message: "stored message".to_string(),
severity: Severity::Low,
tier: HeaderTier::Baseline,
values: SecurityIssueValues::default(),
};
assert_eq!(issue.kind(), None);
assert_eq!(issue.localized_message(false), "stored message");
}
#[test]
fn test_security_headers_count() {
let headers = SecurityHeaders {
content_security_policy: Some(strong_csp()),
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_generate_recommendations_coop_corp_generate_low_issues() {
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()),
permissions_policy: Some("camera=()".to_string()),
..Default::default()
};
let recs = generate_recommendations(&headers, true);
assert!(recs
.iter()
.any(|r| r.contains("Cross-Origin-Opener-Policy")));
assert!(recs
.iter()
.any(|r| r.contains("Cross-Origin-Resource-Policy")));
let issues = generate_security_issues(&headers, true);
assert!(issues.iter().any(|i| i.header.contains("Cross-Origin")
&& i.severity == Severity::Low
&& i.tier == HeaderTier::ContextDependent));
}
#[test]
fn test_header_tier_classification() {
assert_eq!(header_tier("Content-Security-Policy"), HeaderTier::Baseline);
assert_eq!(
header_tier("Strict-Transport-Security"),
HeaderTier::Baseline
);
assert_eq!(header_tier("X-Content-Type-Options"), HeaderTier::Baseline);
assert_eq!(header_tier("X-Frame-Options"), HeaderTier::Baseline);
assert_eq!(header_tier("HTTPS"), HeaderTier::Baseline);
assert_eq!(
header_tier("Referrer-Policy"),
HeaderTier::ArchitectureDependent
);
assert_eq!(
header_tier("Permissions-Policy"),
HeaderTier::ArchitectureDependent
);
assert_eq!(
header_tier("Cross-Origin-Opener-Policy"),
HeaderTier::ContextDependent
);
assert_eq!(
header_tier("Cross-Origin-Resource-Policy"),
HeaderTier::ContextDependent
);
assert_eq!(
header_tier("Access-Control-Allow-Origin"),
HeaderTier::NotAssessable
);
assert_eq!(
header_tier("Access-Control-Allow-Credentials"),
HeaderTier::NotAssessable
);
}
#[test]
fn test_coop_missing_message_is_a_verification_question_not_a_flat_instruction() {
let headers = SecurityHeaders::default();
let issues = generate_security_issues(&headers, true);
let coop = issues
.iter()
.find(|i| i.header == "Cross-Origin-Opener-Policy")
.expect("COOP issue expected when header is absent");
assert_eq!(coop.severity, Severity::Low);
assert_eq!(coop.tier, HeaderTier::ContextDependent);
assert!(
coop.message.contains("SharedArrayBuffer"),
"message should name the concrete scenario that makes COOP relevant: {}",
coop.message
);
assert!(
coop.message.contains("no action is needed"),
"message should tell the reader when COOP does NOT apply: {}",
coop.message
);
assert!(
!coop.message.starts_with("Missing"),
"message should not read as a flat missing-header instruction: {}",
coop.message
);
}
#[test]
fn test_corp_missing_message_covers_cross_origin_resource_scenario() {
let headers = SecurityHeaders::default();
let issues = generate_security_issues(&headers, true);
let corp = issues
.iter()
.find(|i| i.header == "Cross-Origin-Resource-Policy")
.expect("CORP issue expected when header is absent");
assert_eq!(corp.severity, Severity::Low);
assert_eq!(corp.tier, HeaderTier::ContextDependent);
assert!(
corp.message.contains("other origins"),
"message should name the cross-origin resource-loading scenario: {}",
corp.message
);
assert!(
corp.message.contains("intentionally public"),
"message should tell the reader when CORP does NOT apply: {}",
corp.message
);
}
#[test]
fn permissive_csp_scores_at_least_as_high_as_missing_csp() {
let ssl = SslInfo::default();
let missing_csp = vec![SecurityIssue {
header: "Content-Security-Policy".into(),
issue_type: "missing_header".into(),
message: "Missing Content-Security-Policy header".into(),
severity: Severity::High,
tier: HeaderTier::Baseline,
values: Default::default(),
}];
let permissive_csp: Vec<SecurityIssue> = [
"unsafe_inline_script",
"unsafe_eval_script",
"wildcard_script_source",
"unsafe_inline_style",
"wildcard_source",
]
.iter()
.map(|t| SecurityIssue {
header: "Content-Security-Policy".into(),
issue_type: (*t).into(),
message: "csp quality".into(),
severity: Severity::High,
tier: HeaderTier::Baseline,
values: Default::default(),
})
.collect();
let s_missing = calculate_security_score(&SecurityHeaders::default(), &ssl, &missing_csp);
let s_permissive =
calculate_security_score(&SecurityHeaders::default(), &ssl, &permissive_csp);
assert!(
s_permissive >= s_missing,
"a present (permissive) CSP must not score worse than no CSP: permissive={s_permissive} missing={s_missing}"
);
}
#[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(strong_csp()),
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()),
permissions_policy: Some("camera=()".to_string()),
cross_origin_opener_policy: Some("same-origin".to_string()),
cross_origin_resource_policy: Some("same-origin".to_string()),
..Default::default()
};
let issues = generate_security_issues(&headers, true);
assert!(issues.is_empty());
}
#[test]
fn test_cors_wildcard_credentials_generates_high_issue() {
let headers = SecurityHeaders {
access_control_allow_origin: Some("*".to_string()),
access_control_allow_credentials: Some("true".to_string()),
..Default::default()
};
let issues = generate_security_issues(&headers, true);
assert!(issues.iter().any(|issue| {
issue.issue_type == "cors_wildcard_credentials" && issue.severity == Severity::High
}));
}
#[test]
fn test_cors_wildcard_without_credentials_is_not_flagged() {
let headers = SecurityHeaders {
access_control_allow_origin: Some("*".to_string()),
..Default::default()
};
let issues = generate_security_issues(&headers, true);
assert!(!issues
.iter()
.any(|issue| issue.issue_type == "cors_wildcard_credentials"));
}
#[test]
fn hsts_preload_ineligible_flags_short_max_age() {
let ssl = SslInfo {
hsts_preload: true,
hsts_max_age: Some(3600),
hsts_include_subdomains: true,
..Default::default()
};
assert!(hsts_preload_ineligible(&ssl));
}
#[test]
fn hsts_preload_ineligible_flags_missing_subdomains() {
let ssl = SslInfo {
hsts_preload: true,
hsts_max_age: Some(31536000),
hsts_include_subdomains: false,
..Default::default()
};
assert!(hsts_preload_ineligible(&ssl));
}
#[test]
fn hsts_preload_eligible_is_not_flagged() {
let ssl = SslInfo {
hsts_preload: true,
hsts_max_age: Some(31536000),
hsts_include_subdomains: true,
..Default::default()
};
assert!(!hsts_preload_ineligible(&ssl));
}
#[test]
fn hsts_preload_not_set_is_not_flagged_regardless_of_max_age() {
let ssl = SslInfo {
hsts_preload: false,
hsts_max_age: Some(60),
hsts_include_subdomains: false,
..Default::default()
};
assert!(!hsts_preload_ineligible(&ssl));
}
#[test]
fn permissions_policy_empty_value_is_permissive() {
let headers = SecurityHeaders {
permissions_policy: Some("".to_string()),
..Default::default()
};
assert!(permissions_policy_is_permissive(&headers));
}
#[test]
fn permissions_policy_wildcard_only_is_permissive() {
let headers = SecurityHeaders {
permissions_policy: Some("camera=*, microphone=*".to_string()),
..Default::default()
};
assert!(permissions_policy_is_permissive(&headers));
}
#[test]
fn permissions_policy_restrictive_is_not_flagged() {
let headers = SecurityHeaders {
permissions_policy: Some("camera=(), microphone=(self)".to_string()),
..Default::default()
};
assert!(!permissions_policy_is_permissive(&headers));
}
#[test]
fn permissions_policy_absent_is_not_flagged_as_permissive() {
let headers = SecurityHeaders::default();
assert!(!permissions_policy_is_permissive(&headers));
}
#[test]
fn test_csp_quality_flags_unsafe_and_wildcards() {
let issues = collect_csp_quality_issues(
"default-src *; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*",
);
assert!(issues
.iter()
.any(|issue| issue.issue_type == "unsafe_inline_script"));
assert!(issues
.iter()
.any(|issue| issue.issue_type == "unsafe_eval_script"));
assert!(issues
.iter()
.any(|issue| issue.issue_type == "wildcard_script_source"));
assert!(issues
.iter()
.any(|issue| issue.issue_type == "missing_object-src"));
}
#[test]
fn test_csp_quality_accepts_nonce_hardened_policy() {
let issues = collect_csp_quality_issues(
"default-src 'self'; script-src 'self' 'nonce-abc123'; style-src 'self' 'sha256-abc'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
);
assert!(issues.is_empty());
}
#[test]
fn test_csp_recommendations_explain_quality_findings() {
let recommendations =
generate_csp_recommendations("default-src *; script-src 'unsafe-inline' 'unsafe-eval'");
assert!(recommendations
.iter()
.any(|recommendation| recommendation.contains("unsafe-inline")));
assert!(recommendations
.iter()
.any(|recommendation| recommendation.contains("unsafe-eval")));
assert!(recommendations
.iter()
.any(|recommendation| recommendation.contains("wildcard")));
}
#[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,
..Default::default()
};
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);
}
fn strong_csp() -> String {
"default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'".to_string()
}
}