DLP and mail flow rules: the rule model, the engine and the node cache
Phase 2e of the DLP and mail flow rules spec, in the features crate. - rules.rs: a rule (§2.2) with its conditions (§2.3) and actions (§2.4), as JSON under R/r in the fork's subspace. validate() enforces the spec's shape: DLP rules check outgoing mail and have exactly one of block, warn or hold; transport rules have neither those nor detectors; lists, header names, header values (one line), addresses, texts, word lists, patterns and detector ids are checked. - engine.rs: rules compiled once (word lists to automata, patterns to size-limited regexes) and run in priority order with exceptions and stop processing. Each detector runs at most once per message and only when a rule asks for it. The outcome lists what matched with each detector's count, and decides DLP strictest first: block, hold, warn; an override answers warnings only (§2.5). - cache.rs: each node's compiled copy, refreshed after 30 seconds or at once when this node changes a rule. Nothing calls this yet: the JMAP object and the check at DATA follow. 55 unit tests in mailflow.
This commit is contained in:
@@ -0,0 +1,695 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Evaluating rules against a message (§2.1–§2.4). Rules are compiled once,
|
||||
//! when they change: word lists become automata, patterns regexes. A message
|
||||
//! is then checked against every enabled rule in order; each detector runs
|
||||
//! at most once per message, and only when some rule asks for it.
|
||||
//!
|
||||
//! Pure: the caller parses the message, extracts attachment text
|
||||
//! ([`super::extract`]) and knows the sender's groups and tenant. What comes
|
||||
//! back is which rules matched, with each detector's count, and what DLP
|
||||
//! decided; the matched text itself never leaves here (§2.7).
|
||||
|
||||
use super::{
|
||||
detectors::{self, Findings},
|
||||
extract::Extracted,
|
||||
rules::{Action, Condition, Direction, Kind, Rule},
|
||||
words::{Pattern, WordList},
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Who sent a message, and to whom.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Envelope<'a> {
|
||||
/// Outgoing (an authenticated sender) or incoming.
|
||||
pub outgoing: bool,
|
||||
pub sender: &'a str,
|
||||
pub sender_groups: &'a [u32],
|
||||
pub sender_tenant: Option<u32>,
|
||||
pub recipients: Vec<Recipient<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Recipient<'a> {
|
||||
pub address: &'a str,
|
||||
/// At a domain this server hosts.
|
||||
pub local: bool,
|
||||
pub groups: &'a [u32],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Attachment<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
/// Declared type, or detected where the caller knows better.
|
||||
pub content_type: &'a str,
|
||||
pub size: u64,
|
||||
pub extracted: Extracted,
|
||||
}
|
||||
|
||||
/// What rules look at.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Content<'a> {
|
||||
pub subject: &'a str,
|
||||
/// Each text and HTML part, as text.
|
||||
pub bodies: Vec<Cow<'a, str>>,
|
||||
pub headers: Vec<(&'a str, &'a str)>,
|
||||
pub attachments: Vec<Attachment<'a>>,
|
||||
pub size: u64,
|
||||
/// Text past the inspection limit wasn't read.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
impl Content<'_> {
|
||||
fn texts(&self) -> impl Iterator<Item = &str> {
|
||||
std::iter::once(self.subject)
|
||||
.chain(self.bodies.iter().map(|b| b.as_ref()))
|
||||
.chain(self.attachments.iter().filter_map(|a| match &a.extracted {
|
||||
Extracted::Text(text) => Some(text.as_str()),
|
||||
_ => None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn cant_be_inspected(&self) -> bool {
|
||||
self.truncated
|
||||
|| self
|
||||
.attachments
|
||||
.iter()
|
||||
.any(|a| matches!(a.extracted, Extracted::NotInspectable(_)))
|
||||
}
|
||||
}
|
||||
|
||||
enum Check {
|
||||
Plain(Condition),
|
||||
Words(WordList, u32),
|
||||
Pattern(Pattern, u32),
|
||||
Header {
|
||||
name: String,
|
||||
contains: Option<String>,
|
||||
matches: Option<Pattern>,
|
||||
},
|
||||
AttachmentName(Pattern),
|
||||
}
|
||||
|
||||
struct CompiledRule {
|
||||
rule: Rule,
|
||||
conditions: Vec<Check>,
|
||||
exceptions: Vec<Check>,
|
||||
}
|
||||
|
||||
/// The enabled rules, ready to run.
|
||||
pub struct Compiled {
|
||||
rules: Vec<CompiledRule>,
|
||||
}
|
||||
|
||||
/// A rule reference, for notices and the audit record.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuleRef {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub notice: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Match {
|
||||
pub rule_id: u32,
|
||||
pub name: String,
|
||||
pub kind: Kind,
|
||||
pub actions: Vec<Action>,
|
||||
/// Each detector (or `words`, `pattern`) that counted, and its count.
|
||||
pub counts: Vec<(String, usize)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Outcome {
|
||||
pub matched: Vec<Match>,
|
||||
pub blocks: Vec<RuleRef>,
|
||||
pub holds: Vec<(RuleRef, bool)>,
|
||||
pub warns: Vec<RuleRef>,
|
||||
}
|
||||
|
||||
/// What DLP decided, strictest first (§2.4).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
Pass,
|
||||
Block(Vec<RuleRef>),
|
||||
Hold {
|
||||
rules: Vec<RuleRef>,
|
||||
notify_sender: bool,
|
||||
},
|
||||
Warn(Vec<RuleRef>),
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
/// Block beats hold beats warn. An override (§2.5) answers the warnings
|
||||
/// only: a block or hold still applies.
|
||||
pub fn decision(&self, overridden: bool) -> Decision {
|
||||
if !self.blocks.is_empty() {
|
||||
Decision::Block(self.blocks.clone())
|
||||
} else if !self.holds.is_empty() {
|
||||
Decision::Hold {
|
||||
rules: self.holds.iter().map(|(r, _)| r.clone()).collect(),
|
||||
notify_sender: self.holds.iter().any(|(_, notify)| *notify),
|
||||
}
|
||||
} else if !self.warns.is_empty() && !overridden {
|
||||
Decision::Warn(self.warns.clone())
|
||||
} else {
|
||||
Decision::Pass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_check(condition: &Condition) -> Result<Check, String> {
|
||||
Ok(match condition {
|
||||
Condition::Words { words, at_least } => Check::Words(WordList::new(words)?, *at_least),
|
||||
Condition::Pattern { pattern, at_least } => {
|
||||
Check::Pattern(Pattern::new(pattern)?, *at_least)
|
||||
}
|
||||
Condition::Header {
|
||||
name,
|
||||
contains,
|
||||
matches,
|
||||
} => Check::Header {
|
||||
name: name.to_ascii_lowercase(),
|
||||
contains: contains.as_ref().map(|c| c.to_lowercase()),
|
||||
matches: matches.as_deref().map(Pattern::new).transpose()?,
|
||||
},
|
||||
Condition::AttachmentName { pattern } => Check::AttachmentName(Pattern::new(pattern)?),
|
||||
other => Check::Plain(other.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
impl Compiled {
|
||||
/// Compiles the enabled rules; one that no longer compiles (a detector
|
||||
/// renamed since it was saved) is skipped and named in the second list.
|
||||
pub fn new(rules: &[Rule]) -> (Self, Vec<(u32, String)>) {
|
||||
let mut compiled = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for rule in rules.iter().filter(|r| r.enabled) {
|
||||
let result = rule.validate().map_err(|e| e.reason).and_then(|_| {
|
||||
Ok(CompiledRule {
|
||||
rule: rule.clone(),
|
||||
conditions: rule
|
||||
.conditions
|
||||
.iter()
|
||||
.map(compile_check)
|
||||
.collect::<Result<_, _>>()?,
|
||||
exceptions: rule
|
||||
.exceptions
|
||||
.iter()
|
||||
.map(compile_check)
|
||||
.collect::<Result<_, _>>()?,
|
||||
})
|
||||
});
|
||||
match result {
|
||||
Ok(c) => compiled.push(c),
|
||||
Err(reason) => skipped.push((rule.id, reason)),
|
||||
}
|
||||
}
|
||||
compiled.sort_by_key(|c| (c.rule.priority, c.rule.id));
|
||||
(Self { rules: compiled }, skipped)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// Whether any rule could apply to mail going this way, so a caller can
|
||||
/// skip parsing when none can.
|
||||
pub fn applies_to(&self, outgoing: bool) -> bool {
|
||||
self.rules
|
||||
.iter()
|
||||
.any(|c| direction_matches(c.rule.direction, outgoing))
|
||||
}
|
||||
|
||||
pub fn evaluate(&self, envelope: &Envelope<'_>, content: &Content<'_>) -> Outcome {
|
||||
let mut state = State {
|
||||
content,
|
||||
detected: AHashMap::new(),
|
||||
};
|
||||
let mut outcome = Outcome::default();
|
||||
for compiled in &self.rules {
|
||||
let rule = &compiled.rule;
|
||||
if !direction_matches(rule.direction, envelope.outgoing) {
|
||||
continue;
|
||||
}
|
||||
let mut counts = Vec::new();
|
||||
let all_match = compiled
|
||||
.conditions
|
||||
.iter()
|
||||
.all(|check| state.check(check, envelope, &mut counts));
|
||||
if !all_match {
|
||||
continue;
|
||||
}
|
||||
let mut ignored = Vec::new();
|
||||
if compiled
|
||||
.exceptions
|
||||
.iter()
|
||||
.any(|check| state.check(check, envelope, &mut ignored))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for action in &rule.actions {
|
||||
let reference = |notice: &str| RuleRef {
|
||||
id: rule.id,
|
||||
name: rule.name.clone(),
|
||||
notice: notice.to_string(),
|
||||
};
|
||||
match action {
|
||||
Action::Block { notice } => outcome.blocks.push(reference(notice)),
|
||||
Action::Hold {
|
||||
notice,
|
||||
notify_sender,
|
||||
} => outcome.holds.push((reference(notice), *notify_sender)),
|
||||
Action::Warn { notice } => outcome.warns.push(reference(notice)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
outcome.matched.push(Match {
|
||||
rule_id: rule.id,
|
||||
name: rule.name.clone(),
|
||||
kind: rule.kind,
|
||||
actions: rule.actions.clone(),
|
||||
counts,
|
||||
});
|
||||
if rule.stop_processing {
|
||||
break;
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
fn direction_matches(direction: Direction, outgoing: bool) -> bool {
|
||||
match direction {
|
||||
Direction::Any => true,
|
||||
Direction::Outgoing => outgoing,
|
||||
Direction::Incoming => !outgoing,
|
||||
}
|
||||
}
|
||||
|
||||
fn domain_of(address: &str) -> &str {
|
||||
address.rsplit_once('@').map_or("", |(_, d)| d)
|
||||
}
|
||||
|
||||
fn in_list(value: &str, list: &[String]) -> bool {
|
||||
list.iter().any(|v| v.eq_ignore_ascii_case(value))
|
||||
}
|
||||
|
||||
struct State<'c, 'a> {
|
||||
content: &'c Content<'a>,
|
||||
/// Each detector's count, run once per message.
|
||||
detected: AHashMap<&'static str, usize>,
|
||||
}
|
||||
|
||||
impl State<'_, '_> {
|
||||
fn detector_count(&mut self, id: &str) -> usize {
|
||||
let Some(detector) = detectors::by_id(id) else {
|
||||
return 0;
|
||||
};
|
||||
if let Some(count) = self.detected.get(detector.id) {
|
||||
return *count;
|
||||
}
|
||||
let mut findings = Findings::default();
|
||||
for text in self.content.texts() {
|
||||
detector.find(text, &mut findings);
|
||||
}
|
||||
self.detected.insert(detector.id, findings.len());
|
||||
findings.len()
|
||||
}
|
||||
|
||||
fn check(
|
||||
&mut self,
|
||||
check: &Check,
|
||||
envelope: &Envelope<'_>,
|
||||
counts: &mut Vec<(String, usize)>,
|
||||
) -> bool {
|
||||
let content = self.content;
|
||||
match check {
|
||||
Check::Words(list, at_least) => {
|
||||
let n: usize = content.texts().map(|t| list.count(t)).sum();
|
||||
counts.push(("words".into(), n));
|
||||
n >= *at_least as usize
|
||||
}
|
||||
Check::Pattern(pattern, at_least) => {
|
||||
let n: usize = content.texts().map(|t| pattern.count(t)).sum();
|
||||
counts.push(("pattern".into(), n));
|
||||
n >= *at_least as usize
|
||||
}
|
||||
Check::Header {
|
||||
name,
|
||||
contains,
|
||||
matches,
|
||||
} => content
|
||||
.headers
|
||||
.iter()
|
||||
.filter(|(n, _)| n.eq_ignore_ascii_case(name))
|
||||
.any(|(_, value)| match (contains, matches) {
|
||||
(Some(needle), _) => value.to_lowercase().contains(needle.as_str()),
|
||||
(_, Some(pattern)) => pattern.count(value) > 0,
|
||||
_ => true,
|
||||
}),
|
||||
Check::AttachmentName(pattern) => content
|
||||
.attachments
|
||||
.iter()
|
||||
.any(|a| a.name.is_some_and(|n| pattern.count(n) > 0)),
|
||||
Check::Plain(condition) => match condition {
|
||||
Condition::SenderAddress { addresses } => in_list(envelope.sender, addresses),
|
||||
Condition::SenderDomain { domains } => in_list(domain_of(envelope.sender), domains),
|
||||
Condition::SenderGroup { groups } => {
|
||||
envelope.sender_groups.iter().any(|g| groups.contains(g))
|
||||
}
|
||||
Condition::SenderTenant { tenants } => {
|
||||
envelope.sender_tenant.is_some_and(|t| tenants.contains(&t))
|
||||
}
|
||||
Condition::RecipientAddress { addresses } => envelope
|
||||
.recipients
|
||||
.iter()
|
||||
.any(|r| in_list(r.address, addresses)),
|
||||
Condition::RecipientDomain { domains } => envelope
|
||||
.recipients
|
||||
.iter()
|
||||
.any(|r| in_list(domain_of(r.address), domains)),
|
||||
Condition::RecipientGroup { groups } => envelope
|
||||
.recipients
|
||||
.iter()
|
||||
.any(|r| r.groups.iter().any(|g| groups.contains(g))),
|
||||
Condition::RecipientOutside => envelope.recipients.iter().any(|r| !r.local),
|
||||
Condition::AttachmentType { types } => content.attachments.iter().any(|a| {
|
||||
let ct = a.content_type.to_ascii_lowercase();
|
||||
types
|
||||
.iter()
|
||||
.any(|t| ct.starts_with(&t.to_ascii_lowercase()))
|
||||
}),
|
||||
Condition::AttachmentExtension { extensions } => {
|
||||
content.attachments.iter().any(|a| {
|
||||
a.name
|
||||
.and_then(|n| n.rsplit_once('.'))
|
||||
.is_some_and(|(_, ext)| {
|
||||
extensions
|
||||
.iter()
|
||||
.any(|e| e.trim_start_matches('.').eq_ignore_ascii_case(ext))
|
||||
})
|
||||
})
|
||||
}
|
||||
Condition::AttachmentSizeOver { bytes } => {
|
||||
content.attachments.iter().any(|a| a.size > *bytes)
|
||||
}
|
||||
Condition::AttachmentCountOver { count } => {
|
||||
content.attachments.len() > *count as usize
|
||||
}
|
||||
Condition::CantBeInspected => content.cant_be_inspected(),
|
||||
Condition::MessageSizeOver { bytes } => content.size > *bytes,
|
||||
Condition::Detected { detectors } => {
|
||||
let mut any = false;
|
||||
for d in detectors {
|
||||
let n = self.detector_count(&d.id);
|
||||
counts.push((d.id.clone(), n));
|
||||
any |= n >= d.at_least as usize;
|
||||
}
|
||||
any
|
||||
}
|
||||
// Compiled into their own checks
|
||||
Condition::Words { .. }
|
||||
| Condition::Pattern { .. }
|
||||
| Condition::Header { .. }
|
||||
| Condition::AttachmentName { .. } => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mailflow::{
|
||||
extract::Why,
|
||||
rules::{DetectorMin, Position},
|
||||
};
|
||||
|
||||
fn rule(id: u32, kind: Kind, conditions: Vec<Condition>, action: Action) -> Rule {
|
||||
Rule {
|
||||
id,
|
||||
name: format!("rule {id}"),
|
||||
description: String::new(),
|
||||
kind,
|
||||
enabled: true,
|
||||
priority: id as i32,
|
||||
direction: if kind == Kind::Dlp {
|
||||
Direction::Outgoing
|
||||
} else {
|
||||
Direction::Any
|
||||
},
|
||||
conditions,
|
||||
exceptions: vec![],
|
||||
actions: vec![action],
|
||||
stop_processing: false,
|
||||
created_by: String::new(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn envelope(outside: bool) -> Envelope<'static> {
|
||||
Envelope {
|
||||
outgoing: true,
|
||||
sender: "[email protected]",
|
||||
sender_groups: &[7],
|
||||
sender_tenant: None,
|
||||
recipients: vec![Recipient {
|
||||
address: if outside {
|
||||
"[email protected]"
|
||||
} else {
|
||||
"[email protected]"
|
||||
},
|
||||
local: !outside,
|
||||
groups: &[],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn cards(n: usize) -> Content<'static> {
|
||||
let body: String = [
|
||||
"4242 4242 4242 4242",
|
||||
"5555-5555-5555-4444",
|
||||
"378282246310005",
|
||||
"6011111111111117",
|
||||
"3566002020360505",
|
||||
]
|
||||
.iter()
|
||||
.take(n)
|
||||
.map(|c| format!("card {c}\n"))
|
||||
.collect();
|
||||
Content {
|
||||
subject: "Numbers",
|
||||
bodies: vec![body.into()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn five_cards_outside(action: Action) -> Rule {
|
||||
rule(
|
||||
1,
|
||||
Kind::Dlp,
|
||||
vec![
|
||||
Condition::RecipientOutside,
|
||||
Condition::Detected {
|
||||
detectors: vec![DetectorMin {
|
||||
id: "payment-card".into(),
|
||||
at_least: 5,
|
||||
}],
|
||||
},
|
||||
],
|
||||
action,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detector_threshold_and_recipients() {
|
||||
let (rules, skipped) = Compiled::new(&[five_cards_outside(Action::Hold {
|
||||
notice: "Held".into(),
|
||||
notify_sender: true,
|
||||
})]);
|
||||
assert!(skipped.is_empty());
|
||||
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||
assert_eq!(
|
||||
outcome.matched[0].counts,
|
||||
vec![("payment-card".to_string(), 5)]
|
||||
);
|
||||
assert!(matches!(
|
||||
outcome.decision(false),
|
||||
Decision::Hold {
|
||||
notify_sender: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
// Four cards, or everyone inside: nothing
|
||||
assert_eq!(
|
||||
rules.evaluate(&envelope(true), &cards(4)).decision(false),
|
||||
Decision::Pass
|
||||
);
|
||||
assert_eq!(
|
||||
rules.evaluate(&envelope(false), &cards(5)).decision(false),
|
||||
Decision::Pass
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strictest_wins_and_override_answers_warnings_only() {
|
||||
let warn = five_cards_outside(Action::Warn {
|
||||
notice: "Sure?".into(),
|
||||
});
|
||||
let mut block = five_cards_outside(Action::Block {
|
||||
notice: "No".into(),
|
||||
});
|
||||
block.id = 2;
|
||||
let (rules, _) = Compiled::new(&[warn.clone(), block]);
|
||||
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||
assert!(matches!(outcome.decision(true), Decision::Block(_)));
|
||||
let (rules, _) = Compiled::new(&[warn]);
|
||||
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||
assert!(matches!(outcome.decision(false), Decision::Warn(ref w) if w[0].notice == "Sure?"));
|
||||
assert_eq!(outcome.decision(true), Decision::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exceptions_order_and_stop_processing() {
|
||||
let disclaimer = |id| {
|
||||
rule(
|
||||
id,
|
||||
Kind::Transport,
|
||||
vec![Condition::RecipientOutside],
|
||||
Action::AddDisclaimer {
|
||||
text: "t".into(),
|
||||
html: None,
|
||||
position: Position::Bottom,
|
||||
},
|
||||
)
|
||||
};
|
||||
let mut first = disclaimer(1);
|
||||
first.stop_processing = true;
|
||||
let (rules, _) = Compiled::new(&[disclaimer(2), first.clone()]);
|
||||
let outcome = rules.evaluate(&envelope(true), &cards(0));
|
||||
assert_eq!(
|
||||
outcome
|
||||
.matched
|
||||
.iter()
|
||||
.map(|m| m.rule_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1]
|
||||
);
|
||||
|
||||
first.stop_processing = false;
|
||||
first.exceptions = vec![Condition::SenderGroup { groups: vec![7] }];
|
||||
let (rules, _) = Compiled::new(&[disclaimer(2), first]);
|
||||
let outcome = rules.evaluate(&envelope(true), &cards(0));
|
||||
assert_eq!(
|
||||
outcome
|
||||
.matched
|
||||
.iter()
|
||||
.map(|m| m.rule_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_conditions() {
|
||||
let content = Content {
|
||||
subject: "Project Falcon",
|
||||
bodies: vec!["see attached".into()],
|
||||
headers: vec![("X-Class", "Internal only")],
|
||||
attachments: vec![
|
||||
Attachment {
|
||||
name: Some("plan.docx"),
|
||||
content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
size: 40_000,
|
||||
extracted: Extracted::Text("IBAN GB29 NWBK 6016 1331 9268 19".into()),
|
||||
},
|
||||
Attachment {
|
||||
name: Some("scan.pdf"),
|
||||
content_type: "application/pdf",
|
||||
size: 900_000,
|
||||
extracted: Extracted::NotInspectable(Why::Pdf),
|
||||
},
|
||||
],
|
||||
size: 1_000_000,
|
||||
truncated: false,
|
||||
};
|
||||
let block = || Action::Block { notice: "n".into() };
|
||||
let checks = [
|
||||
(
|
||||
Condition::Words {
|
||||
words: vec!["project falcon".into()],
|
||||
at_least: 1,
|
||||
},
|
||||
true,
|
||||
),
|
||||
(
|
||||
Condition::Header {
|
||||
name: "x-class".into(),
|
||||
contains: Some("internal".into()),
|
||||
matches: None,
|
||||
},
|
||||
true,
|
||||
),
|
||||
(
|
||||
Condition::AttachmentExtension {
|
||||
extensions: vec![".PDF".into()],
|
||||
},
|
||||
true,
|
||||
),
|
||||
(
|
||||
Condition::AttachmentType {
|
||||
types: vec!["image/".into()],
|
||||
},
|
||||
false,
|
||||
),
|
||||
(Condition::AttachmentSizeOver { bytes: 500_000 }, true),
|
||||
(Condition::AttachmentCountOver { count: 2 }, false),
|
||||
(Condition::CantBeInspected, true),
|
||||
(Condition::MessageSizeOver { bytes: 2_000_000 }, false),
|
||||
(
|
||||
Condition::Detected {
|
||||
detectors: vec![DetectorMin {
|
||||
id: "iban".into(),
|
||||
at_least: 1,
|
||||
}],
|
||||
},
|
||||
true,
|
||||
),
|
||||
(
|
||||
Condition::SenderDomain {
|
||||
domains: vec!["EXAMPLE.com".into()],
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
for (condition, expected) in checks {
|
||||
let (rules, skipped) =
|
||||
Compiled::new(&[rule(1, Kind::Dlp, vec![condition.clone()], block())]);
|
||||
assert!(skipped.is_empty(), "{condition:?}");
|
||||
let matched = !rules.evaluate(&envelope(true), &content).matched.is_empty();
|
||||
assert_eq!(matched, expected, "{condition:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_and_disabled_rules() {
|
||||
let mut r = five_cards_outside(Action::Block { notice: "n".into() });
|
||||
let (rules, _) = Compiled::new(std::slice::from_ref(&r));
|
||||
assert!(rules.applies_to(true) && !rules.applies_to(false));
|
||||
let mut incoming = envelope(true);
|
||||
incoming.outgoing = false;
|
||||
assert_eq!(
|
||||
rules.evaluate(&incoming, &cards(5)).decision(false),
|
||||
Decision::Pass
|
||||
);
|
||||
r.enabled = false;
|
||||
assert!(Compiled::new(&[r]).0.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user