use crate::core::{ScanContext, ScanOutcome, ScanReport, ThreatSeverity};
use crate::policy::action::PolicyAction;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyRule {
pub id: String,
pub name: String,
pub description: Option<String>,
pub conditions: Vec<Condition>,
pub action: PolicyAction,
pub priority: i32,
pub enabled: bool,
}
impl PolicyRule {
pub fn new(id: impl Into<String>, action: PolicyAction) -> Self {
Self {
id: id.into(),
name: String::new(),
description: None,
conditions: Vec::new(),
action,
priority: 0,
enabled: true,
}
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_condition(mut self, condition: Condition) -> Self {
self.conditions.push(condition);
self
}
pub fn with_priority(mut self, priority: i32) -> Self {
self.priority = priority;
self
}
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn matches(&self, report: &ScanReport, context: &ScanContext) -> bool {
if !self.enabled {
return false;
}
self.conditions.iter().all(|c| c.matches(report, context))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Condition {
OutcomeIs {
outcome: OutcomeType,
},
TenantEquals {
tenant_id: String,
},
TenantIn {
tenant_ids: Vec<String>,
},
FileTypeIn {
file_types: Vec<String>,
},
SeverityAtLeast {
severity: ThreatSeverity,
},
FileSizeExceeds {
size: u64,
},
FileSizeBelow {
size: u64,
},
ThreatNameContains {
substring: String,
},
SourceEquals {
source: String,
},
Always,
Never,
And {
conditions: Vec<Condition>,
},
Or {
conditions: Vec<Condition>,
},
Not {
condition: Box<Condition>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutcomeType {
Clean,
Infected,
Suspicious,
Error,
}
impl Condition {
pub fn is_clean() -> Self {
Self::OutcomeIs {
outcome: OutcomeType::Clean,
}
}
pub fn is_infected() -> Self {
Self::OutcomeIs {
outcome: OutcomeType::Infected,
}
}
pub fn is_suspicious() -> Self {
Self::OutcomeIs {
outcome: OutcomeType::Suspicious,
}
}
pub fn is_error() -> Self {
Self::OutcomeIs {
outcome: OutcomeType::Error,
}
}
pub fn tenant_equals(tenant_id: impl Into<String>) -> Self {
Self::TenantEquals {
tenant_id: tenant_id.into(),
}
}
pub fn file_type_in(types: Vec<String>) -> Self {
Self::FileTypeIn { file_types: types }
}
pub fn severity_at_least(severity: ThreatSeverity) -> Self {
Self::SeverityAtLeast { severity }
}
pub fn matches(&self, report: &ScanReport, context: &ScanContext) -> bool {
match self {
Self::OutcomeIs { outcome } => {
let report_outcome = match &report.aggregated_outcome {
ScanOutcome::Clean => OutcomeType::Clean,
ScanOutcome::Infected { .. } => OutcomeType::Infected,
ScanOutcome::Suspicious { .. } => OutcomeType::Suspicious,
ScanOutcome::Error { .. } => OutcomeType::Error,
};
*outcome == report_outcome
}
Self::TenantEquals { tenant_id } => {
context.tenant_id.as_ref() == Some(tenant_id)
}
Self::TenantIn { tenant_ids } => {
context
.tenant_id
.as_ref()
.map(|t| tenant_ids.contains(t))
.unwrap_or(false)
}
Self::FileTypeIn { file_types } => {
report
.results
.first()
.and_then(|r| r.file_metadata.filename.as_ref())
.and_then(|f| f.rsplit('.').next())
.map(|ext| file_types.iter().any(|t| t.eq_ignore_ascii_case(ext)))
.unwrap_or(false)
}
Self::SeverityAtLeast { severity } => {
report.all_threats().iter().any(|t| t.severity >= *severity)
}
Self::FileSizeExceeds { size } => {
report
.results
.first()
.map(|r| r.file_metadata.size > *size)
.unwrap_or(false)
}
Self::FileSizeBelow { size } => {
report
.results
.first()
.map(|r| r.file_metadata.size < *size)
.unwrap_or(false)
}
Self::ThreatNameContains { substring } => {
let lower = substring.to_lowercase();
report
.all_threats()
.iter()
.any(|t| t.name.to_lowercase().contains(&lower))
}
Self::SourceEquals { source } => context.source.as_ref() == Some(source),
Self::Always => true,
Self::Never => false,
Self::And { conditions } => conditions.iter().all(|c| c.matches(report, context)),
Self::Or { conditions } => conditions.iter().any(|c| c.matches(report, context)),
Self::Not { condition } => !condition.matches(report, context),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{FileHash, FileMetadata, ScanResult, ThreatInfo};
use std::time::Duration;
fn make_test_report(outcome: ScanOutcome) -> ScanReport {
let hash = FileHash::new("test");
let metadata = FileMetadata::new(1000, hash);
let result = ScanResult::new(
outcome,
metadata,
"test",
Duration::from_millis(10),
ScanContext::new(),
);
ScanReport::from_results(vec![result], ScanContext::new())
}
#[test]
fn test_condition_is_clean() {
let report = make_test_report(ScanOutcome::Clean);
let context = ScanContext::new();
assert!(Condition::is_clean().matches(&report, &context));
assert!(!Condition::is_infected().matches(&report, &context));
}
#[test]
fn test_condition_is_infected() {
let threats = vec![ThreatInfo::new("Test", ThreatSeverity::High, "test")];
let report = make_test_report(ScanOutcome::Infected { threats });
let context = ScanContext::new();
assert!(Condition::is_infected().matches(&report, &context));
assert!(!Condition::is_clean().matches(&report, &context));
}
#[test]
fn test_condition_tenant_equals() {
let report = make_test_report(ScanOutcome::Clean);
let context = ScanContext::new().with_tenant_id("tenant-1");
assert!(Condition::tenant_equals("tenant-1").matches(&report, &context));
assert!(!Condition::tenant_equals("tenant-2").matches(&report, &context));
}
#[test]
fn test_policy_rule_matches() {
let rule = PolicyRule::new("test-rule", PolicyAction::block("infected"))
.with_condition(Condition::is_infected());
let threats = vec![ThreatInfo::new("Test", ThreatSeverity::High, "test")];
let report = make_test_report(ScanOutcome::Infected { threats });
let context = ScanContext::new();
assert!(rule.matches(&report, &context));
}
}