DLP at DATA: block, warn and override over SMTP and JMAP
ci / fork-checks (pull_request) Successful in 49s
ci / build (pull_request) Successful in 23m36s

Phase 2f of the DLP and mail flow rules spec: the rules now run on mail
an authenticated sender submits, after the DATA system script and
before headers and DKIM signing (§2.1).

- smtp/inbound/mailflow.rs: builds what the rules look at from the
  message (subject, the text version of each body, one level of attached
  messages, attachment text via the extractor, 10 MB of text at most)
  and the envelope (sender's groups and tenant; each recipient local or
  not, and its groups). Skipped entirely when no enabled rule applies to
  outgoing mail. Rules that can't be loaded refuse with a 451: nothing
  unchecked leaves.
- Block: 550 5.7.1 with the rule's notice. Warn: 550 5.7.1 with the
  notice and how to override: "[override: reason]" at the start of the
  subject, taken out before the message goes on (settled answer 1).
  Until phase 3, a hold rule blocks rather than let mail through.
- JMAP: EmailSubmission takes inbuxa:dlpOverride {reason}; a refusal
  comes back as inbuxa:dlpWarning or inbuxa:dlpBlocked with each rule's
  name and notice (description too, for older clients).
- Audit: one record per DLP match, the sender as actor, action create,
  target a message: the recipient domains, each rule with its detectors'
  counts, the outcome, an override's reason. Never the matched text. No
  new audit action: an older node that meets one fails its daily
  clean-up, which would make rolling back unsafe (spec §2.7 updated).

Tests: mail_rules_tests gains the DLP flow over JMAP (no rules, warning
with rule and notice, local recipient not warned, override with a
reason, block that no reason passes, the subject tag stripped from the
delivered message, audit records with no card or key text). smtp
inbound tests pass; system_tests passed twice after one timeout in the
email delivery tests that didn't recur.
This commit is contained in:
2026-09-28 17:52:37 -07:00
parent a3a36cd5d7
commit e0060c9e6e
10 changed files with 899 additions and 22 deletions
+333 -13
View File
@@ -21,6 +21,8 @@ use serde_json::{Value, json};
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
"urn:inbuxa:jmap",
"urn:inbuxa:jmap:registry",
];
@@ -29,13 +31,18 @@ async fn call(account: &Account, method: &str, mut arguments: Value) -> (String,
if arguments.get("accountId").is_none() {
arguments["accountId"] = account.id_string().into();
}
let response = account.jmap_request(USING, json!([[method, arguments, "0"]])).await;
let response = account
.jmap_request(USING, json!([[method, arguments, "0"]]))
.await;
let call = response
.0
.pointer("/methodResponses/0")
.cloned()
.unwrap_or_else(|| panic!("{method}: {}", response.0));
(call[0].as_str().unwrap_or_default().to_string(), call[1].clone())
(
call[0].as_str().unwrap_or_default().to_string(),
call[1].clone(),
)
}
fn dlp_rule() -> Value {
@@ -94,10 +101,16 @@ pub async fn test(test: &mut TestServer) {
let (_, response) = call(&admin, "inbuxa:MailRule/get", json!({"ids": [dlp_id]})).await;
let rule = &response["list"][0];
assert_eq!(rule["conditions"][1]["detectors"][0]["atLeast"], 5, "{rule}");
assert_eq!(
rule["conditions"][1]["detectors"][0]["atLeast"], 5,
"{rule}"
);
assert_eq!(rule["actions"][0]["notifySender"], true);
assert_eq!(rule["createdBy"], "[email protected]");
assert!(rule["createdAt"].as_str().is_some_and(|d| d.ends_with('Z')), "{rule}");
assert!(
rule["createdAt"].as_str().is_some_and(|d| d.ends_with('Z')),
"{rule}"
);
// Checked when written
let mut inbound = dlp_rule();
@@ -110,9 +123,15 @@ pub async fn test(test: &mut TestServer) {
json!({"create": {"a": inbound, "b": unknown, "c": {"name": "x", "kind": "dlp"}}}),
)
.await;
assert_eq!(response["notCreated"]["a"]["properties"][0], "direction", "{response}");
assert_eq!(
response["notCreated"]["a"]["properties"][0], "direction",
"{response}"
);
assert!(
response["notCreated"]["b"]["description"].as_str().unwrap().contains("no-such-detector"),
response["notCreated"]["b"]["description"]
.as_str()
.unwrap()
.contains("no-such-detector"),
"{response}"
);
assert!(response["notCreated"].get("c").is_some(), "{response}");
@@ -127,15 +146,26 @@ pub async fn test(test: &mut TestServer) {
}}),
)
.await;
assert!(response["updated"].get(transport_id.as_str()).is_some(), "{response}");
assert_eq!(response["notUpdated"][dlp_id.as_str()]["properties"][0], "createdBy", "{response}");
assert!(
response["updated"].get(transport_id.as_str()).is_some(),
"{response}"
);
assert_eq!(
response["notUpdated"][dlp_id.as_str()]["properties"][0],
"createdBy",
"{response}"
);
assert_eq!(names(&admin).await, vec!["Cards leaving", "Footer"]);
// A compliance officer sees DLP rules, not mail flow rules, and changes
// neither (settled answer 4)
let mut officer_role = None;
for id in admin
.registry_query_ids(ObjectType::Role, Vec::<(&str, &str)>::new(), Vec::<&str>::new())
.registry_query_ids(
ObjectType::Role,
Vec::<(&str, &str)>::new(),
Vec::<&str>::new(),
)
.await
{
let role = admin.registry_get::<Role>(id).await;
@@ -144,7 +174,13 @@ pub async fn test(test: &mut TestServer) {
}
}
let officer = admin
.create_user_account("[email protected]", "officer-secret-7731", "Officer", &[], vec![])
.create_user_account(
"[email protected]",
"officer-secret-7731",
"Officer",
&[],
vec![],
)
.await;
admin
.registry_update_object(
@@ -156,8 +192,12 @@ pub async fn test(test: &mut TestServer) {
)
.await;
assert_eq!(names(&officer).await, vec!["Cards leaving"]);
let (name, response) =
call(&officer, "inbuxa:MailRule/set", json!({"create": {"d": dlp_rule()}})).await;
let (name, response) = call(
&officer,
"inbuxa:MailRule/set",
json!({"create": {"d": dlp_rule()}}),
)
.await;
assert_eq!(name, "error", "the officer created a DLP rule: {response}");
// Deleted
@@ -167,7 +207,11 @@ pub async fn test(test: &mut TestServer) {
json!({"destroy": [transport_id]}),
)
.await;
assert_eq!(response["destroyed"][0], transport_id.as_str(), "{response}");
assert_eq!(
response["destroyed"][0],
transport_id.as_str(),
"{response}"
);
assert_eq!(names(&admin).await, vec!["Cards leaving"]);
// Every change is in the audit log
@@ -183,6 +227,281 @@ pub async fn test(test: &mut TestServer) {
);
}
/// A draft from `sender`, submitted; the submission call's response.
async fn submit(
sender: &Account,
identity: &str,
mailbox: &str,
to: &[&str],
subject: &str,
body: &str,
dlp_override: Option<&str>,
) -> Value {
let (_, response) = call(
sender,
"Email/set",
json!({"create": {"e": {
"mailboxIds": {mailbox: true},
"from": [{"email": sender.name()}],
"to": to.iter().map(|a| json!({"email": a})).collect::<Vec<_>>(),
"subject": subject,
"bodyValues": {"b": {"value": body}},
"textBody": [{"partId": "b", "type": "text/plain"}]
}}}),
)
.await;
let email = response["created"]["e"]["id"]
.as_str()
.unwrap_or_else(|| panic!("draft: {response}"))
.to_string();
let mut create = json!({"emailId": email, "identityId": identity});
if let Some(reason) = dlp_override {
create["inbuxa:dlpOverride"] = json!({"reason": reason});
}
call(
sender,
"EmailSubmission/set",
json!({"create": {"s": create}}),
)
.await
.1
}
/// DLP at DATA (§2.4–§2.7): a warning answered with a reason, a block no
/// reason answers, the subject tag, and records that name the rules and
/// counts but never what was found.
pub async fn dlp(test: &mut TestServer) {
println!("Running DLP sending tests...");
let admin = test.account("[email protected]");
let sender = admin
.create_user_account(
"[email protected]",
"dlp-sender-secret-5501",
"DLP sender",
&[],
vec![],
)
.await;
let (_, response) = call(
&sender,
"Identity/set",
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
)
.await;
let identity = response["created"]["i"]["id"]
.as_str()
.unwrap_or_else(|| panic!("{response}"))
.to_string();
let (_, response) = call(
&sender,
"Mailbox/set",
json!({"create": {"m": {"name": "DLP drafts"}}}),
)
.await;
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
let outside = ["[email protected]"];
let card = "Card 4242 4242 4242 4242, expires 12/31";
// No rules: sent
let response = submit(
&sender, &identity, &mailbox, &outside, "Numbers", card, None,
)
.await;
assert!(
response["created"].get("s").is_some(),
"no rules: {response}"
);
// A warning: refused with the rule and its notice, then sent with a reason
let (_, response) = call(
&admin,
"inbuxa:MailRule/set",
json!({"create": {"w": {
"name": "Cards leaving", "kind": "dlp", "direction": "outgoing",
"conditions": [{"type": "recipientOutside"},
{"type": "detected", "detectors": [{"id": "payment-card"}]}],
"actions": [{"type": "warn", "notice": "This looks like a card number."}]
}}}),
)
.await;
let warn_rule = response["created"]["w"]["id"]
.as_str()
.unwrap_or_else(|| panic!("{response}"))
.to_string();
let response = submit(
&sender, &identity, &mailbox, &outside, "Numbers", card, None,
)
.await;
let refused = &response["notCreated"]["s"];
assert_eq!(refused["type"], "inbuxa:dlpWarning", "{response}");
assert_eq!(refused["rules"][0]["name"], "Cards leaving");
assert_eq!(refused["description"], "This looks like a card number.");
// Inside the server: no warning
let response = submit(
&sender,
&identity,
&mailbox,
&["[email protected]"],
"Numbers",
card,
None,
)
.await;
assert!(
response["created"].get("s").is_some(),
"local recipient: {response}"
);
let response = submit(
&sender,
&identity,
&mailbox,
&outside,
"Numbers",
card,
Some("The client asked for it"),
)
.await;
assert!(
response["created"].get("s").is_some(),
"overridden: {response}"
);
// A block: no reason gets past it
let (_, response) = call(
&admin,
"inbuxa:MailRule/set",
json!({"create": {"b": {
"name": "Keys", "kind": "dlp", "direction": "outgoing",
"conditions": [{"type": "detected", "detectors": [{"id": "private-key"}]}],
"actions": [{"type": "block", "notice": "Private keys don't leave by mail."}]
}}}),
)
.await;
assert!(response["created"].get("b").is_some(), "{response}");
let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ\n-----END OPENSSH PRIVATE KEY-----";
let response = submit(
&sender,
&identity,
&mailbox,
&outside,
"Key",
key,
Some("Please"),
)
.await;
assert_eq!(
response["notCreated"]["s"]["type"], "inbuxa:dlpBlocked",
"{response}"
);
// The subject tag overrides, and doesn't go out: the sender's own copy
// arrives without it
let response = submit(
&sender,
&identity,
&mailbox,
&["[email protected]", "[email protected]"],
"[override: Agreed with finance] Tagged numbers",
card,
None,
)
.await;
assert!(
response["created"].get("s").is_some(),
"tag override: {response}"
);
let mut delivered = Vec::new();
for _ in 0..50 {
let (_, response) = call(
&sender,
"Email/query",
json!({"filter": {"text": "Tagged"}}),
)
.await;
let ids = response["ids"].clone();
let (_, response) = call(
&sender,
"Email/get",
json!({"ids": ids, "properties": ["subject", "mailboxIds"]}),
)
.await;
delivered = response["list"]
.as_array()
.unwrap()
.iter()
.filter(|e| !e["mailboxIds"].as_object().unwrap().contains_key(&mailbox))
.map(|e| e["subject"].as_str().unwrap_or_default().to_string())
// The bounce for the unreachable outside address quotes it
.filter(|subject| !subject.starts_with("Failed to deliver"))
.collect();
if !delivered.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
assert_eq!(
delivered,
vec!["Tagged numbers".to_string()],
"delivered subject"
);
// Recorded: sender, what happened, rules and counts, the reason; never
// the number itself
let (_, response) = call(
&admin,
"inbuxa:AuditEvent/query",
json!({"filter": {"targetKind": "message"}}),
)
.await;
let ids = response["ids"].clone();
let (_, response) = call(&admin, "inbuxa:AuditEvent/get", json!({"ids": ids})).await;
let events = response["list"].as_array().unwrap();
let details: Vec<&str> = events
.iter()
.filter_map(|e| e["details"].as_str())
.collect();
assert!(
details
.iter()
.any(|d| d
.starts_with("DLP warned, to elsewhere.org: \"Cards leaving\" (payment-card 1)")),
"{details:?}"
);
assert!(
details.iter().any(|d| d.starts_with("DLP blocked")),
"{details:?}"
);
assert!(
events.iter().any(
|e| e["reason"] == "The client asked for it" && e["outcome"]["status"] == "success"
),
"{response}"
);
assert!(
events.iter().any(|e| e["reason"] == "Agreed with finance"),
"{response}"
);
assert!(
events
.iter()
.all(|e| e["actor"]["name"] == "[email protected]"),
"{response}"
);
let all = response.to_string();
assert!(
!all.contains("4242") && !all.contains("b3BlbnNz"),
"matched text in the audit log"
);
// Rules off again for the tests that follow
call(
&admin,
"inbuxa:MailRule/set",
json!({"destroy": [warn_rule]}),
)
.await;
}
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn mail_rules_tests() {
@@ -195,6 +514,7 @@ pub async fn mail_rules_tests() {
let admin = test.create_admin_account("[email protected]").await;
test.insert_account(admin);
self::test(&mut test).await;
self::dlp(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();
}