use crate::policy::PolicyException;
use chrono::{NaiveDate, Utc};
use regex::Regex;
pub struct ExceptionMatcher {
exceptions: Vec<PolicyException>,
}
impl ExceptionMatcher {
pub fn new(exceptions: Vec<PolicyException>) -> Self {
Self { exceptions }
}
pub fn is_exception(&self, target: &str, rule_path: &str) -> Option<&PolicyException> {
for exception in &self.exceptions {
if let Some(ref expires) = exception.expires
&& self.is_expired(expires)
{
continue;
}
if let Some(ref domain_pattern) = exception.domain
&& !self.matches_domain(target, domain_pattern)
{
continue;
}
if exception.rules.contains(&rule_path.to_string()) {
return Some(exception);
}
}
None
}
fn matches_domain(&self, target: &str, pattern: &str) -> bool {
let hostname = if let Some(pos) = target.find(':') {
&target[..pos]
} else {
target
};
let regex_pattern = pattern.replace('.', r"\.").replace('*', ".*");
if let Ok(re) = Regex::new(&format!("^{}$", regex_pattern)) {
return re.is_match(hostname);
}
false
}
fn is_expired(&self, expires_str: &str) -> bool {
if let Ok(expires_date) = NaiveDate::parse_from_str(expires_str, "%Y-%m-%d") {
let today = Utc::now().date_naive();
return today > expires_date;
}
true
}
pub fn get_exceptions_for_target(&self, target: &str) -> Vec<&PolicyException> {
let mut result = Vec::new();
for exception in &self.exceptions {
if let Some(ref expires) = exception.expires
&& self.is_expired(expires)
{
continue;
}
if let Some(ref domain_pattern) = exception.domain {
if self.matches_domain(target, domain_pattern) {
result.push(exception);
}
} else {
result.push(exception);
}
}
result
}
pub fn format_exception(exception: &PolicyException) -> String {
let mut parts = Vec::new();
if let Some(ref domain) = exception.domain {
parts.push(format!("Domain: {}", domain));
}
parts.push(format!("Rules: {}", exception.rules.join(", ")));
parts.push(format!("Reason: {}", exception.reason));
parts.push(format!("Approved by: {}", exception.approved_by));
if let Some(ref ticket) = exception.ticket {
parts.push(format!("Ticket: {}", ticket));
}
if let Some(ref expires) = exception.expires {
parts.push(format!("Expires: {}", expires));
}
parts.join(" | ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wildcard_matching() {
let exception = PolicyException {
domain: Some("*.example.com".to_string()),
rules: vec!["protocols.prohibited".to_string()],
reason: "Test".to_string(),
expires: None,
approved_by: "Admin".to_string(),
ticket: None,
};
let matcher = ExceptionMatcher::new(vec![exception]);
assert!(matcher.matches_domain("subdomain.example.com", "*.example.com"));
assert!(matcher.matches_domain("test.example.com", "*.example.com"));
assert!(!matcher.matches_domain("example.com", "*.example.com"));
assert!(!matcher.matches_domain("other.org", "*.example.com"));
}
#[test]
fn test_exact_domain_matching() {
let exception = PolicyException {
domain: Some("example.com".to_string()),
rules: vec!["protocols.prohibited".to_string()],
reason: "Test".to_string(),
expires: None,
approved_by: "Admin".to_string(),
ticket: None,
};
let matcher = ExceptionMatcher::new(vec![exception]);
assert!(matcher.matches_domain("example.com", "example.com"));
assert!(!matcher.matches_domain("subdomain.example.com", "example.com"));
}
#[test]
fn test_exception_expiration() {
let matcher = ExceptionMatcher::new(Vec::new());
assert!(matcher.is_expired("2020-01-01"));
assert!(!matcher.is_expired("2099-12-31"));
assert!(matcher.is_expired("invalid-date"));
}
#[test]
fn test_is_exception() {
let exception = PolicyException {
domain: Some("test.example.com".to_string()),
rules: vec!["protocols.prohibited".to_string()],
reason: "Legacy system".to_string(),
expires: Some("2099-12-31".to_string()),
approved_by: "CISO".to_string(),
ticket: Some("SEC-1234".to_string()),
};
let matcher = ExceptionMatcher::new(vec![exception]);
assert!(
matcher
.is_exception("test.example.com", "protocols.prohibited")
.is_some()
);
assert!(
matcher
.is_exception("test.example.com:443", "protocols.prohibited")
.is_some()
);
assert!(
matcher
.is_exception("test.example.com", "ciphers.prohibited")
.is_none()
);
assert!(
matcher
.is_exception("other.example.com", "protocols.prohibited")
.is_none()
);
}
#[test]
fn test_exception_with_port() {
let exception = PolicyException {
domain: Some("example.com".to_string()),
rules: vec!["certificates.max_days_until_expiry".to_string()],
reason: "Test".to_string(),
expires: None,
approved_by: "Admin".to_string(),
ticket: None,
};
let matcher = ExceptionMatcher::new(vec![exception]);
assert!(
matcher
.is_exception("example.com:443", "certificates.max_days_until_expiry")
.is_some()
);
assert!(
matcher
.is_exception("example.com:8443", "certificates.max_days_until_expiry")
.is_some()
);
}
}