DLP at DATA: block, warn and override over SMTP and JMAP
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:
+333
-13
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user