aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--abusectl/parse.py49
-rw-r--r--tests/test_parse.py16
2 files changed, 64 insertions, 1 deletions
diff --git a/abusectl/parse.py b/abusectl/parse.py
index b1c38f5..900543b 100644
--- a/abusectl/parse.py
+++ b/abusectl/parse.py
@@ -45,6 +45,7 @@ from abusectl import redact
_BRACKETED_IP = re.compile(r"\[([0-9a-fA-F:.]+)\]")
_ADDR_DOMAIN = re.compile(r"@([A-Za-z0-9.-]+)")
+_DISPLAY_ADDR = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+")
_AUTH_VERDICT = re.compile(r"(spf|dkim|dmarc)=(\w+)", re.IGNORECASE)
# Deliberately permissive about where a URL stops: mail wraps URLs across
@@ -188,6 +189,19 @@ def _domain_of(header_value: str | None) -> str | None:
return domain or None
+def _display_name_address_of(header_value: str | None) -> str | None:
+ """Return an address spoofed into the display name, if there is one.
+
+ Kept as an indicator in its own right: a display name naming a bank, or
+ naming the recipient themselves, is a deliberate act worth reporting. It
+ is never treated as a sender domain, which is what _domain_of() is for.
+ """
+ if header_value is None:
+ return None
+ match = _DISPLAY_ADDR.search(parseaddr(header_value)[0])
+ return match.group(0).lower() if match else None
+
+
def sender_domains(raw: bytes) -> dict[str, str]:
"""Return the domain of Return-Path, From, and Reply-To.
@@ -218,6 +232,29 @@ def sender_domains(raw: bytes) -> dict[str, str]:
return domains
+def display_name_addresses(raw: bytes) -> dict[str, str]:
+ """Return addresses spoofed into the display name of a sender header.
+
+ Reported separately from sender_domains() because the two say different
+ things: a sender domain is where the mail came from, while this is who
+ the attacker chose to impersonate. Collapsing them was the defect this
+ function exists beside, so keep them apart.
+
+ The value can be the recipient's own address, since that is one of the
+ identities worth impersonating. It is the only place in this module
+ where a recipient identifier can legitimately appear, and it is here
+ because the ATTACKER wrote it into a sender header, not because a
+ recipient header was read.
+ """
+ message = _message(raw)
+ spoofed = {}
+ for key in ("From", "Reply-To", "Return-Path"):
+ address = _display_name_address_of(message.get(key))
+ if address is not None:
+ spoofed[key.lower().replace("-", "_")] = address
+ return spoofed
+
+
def auth_results(raw: bytes) -> dict[str, str]:
"""Return the SPF, DKIM and DMARC verdicts from Authentication-Results.
@@ -401,7 +438,7 @@ def iocs(raw: bytes, trusted: list[str]) -> list[dict]:
Every IOC carries an ``origin`` saying where it came from
(``received-chain``, ``header-from``, ``body``, ``redirect-target``,
- ``attachment``, ...). During review the user must be able to tell an
+ ``attachment``, ``display-name-from``, ...). During review the user must be able to tell an
IP taken from a header we trust from one the attacker wrote; without
this, review is guesswork.
@@ -435,6 +472,16 @@ def iocs(raw: bytes, trusted: list[str]) -> list[dict]:
for key, domain in sender_domains(raw).items():
add(type="domain", value=domain, origin=f"header-{key}")
+ # Reported as a FLAG carrying no value. Not even the impersonated domain
+ # survives here: one of the identities an attacker impersonates is the
+ # recipient themselves, so publishing the domain leaks the recipient's
+ # own domain in exactly the case worth flagging. The reviewer has the
+ # message and can see who was impersonated; a third party does not need
+ # to. What travels is only that it happened.
+ for key in display_name_addresses(raw):
+ add(type="observation", value="display-name-carries-address",
+ origin=f"display-name-{key}")
+
for url in urls(raw):
entry = {"type": "url", "value": url, "origin": "body"}
segments = _suspect_segments(originals.get(url, url))
diff --git a/tests/test_parse.py b/tests/test_parse.py
index de78b5a..60c1fb5 100644
--- a/tests/test_parse.py
+++ b/tests/test_parse.py
@@ -115,6 +115,22 @@ class TestSenderDomains(unittest.TestCase):
domains = parse.sender_domains(load("leaky.eml"))
self.assertEqual(domains["from"], "sender.example.invalid")
+ def test_a_display_name_address_is_kept_as_its_own_indicator(self):
+ # Spoofing a recognisable address in the display name is a real
+ # signal, so it is reported, but as a spoof rather than as a sender.
+ spoofed = parse.display_name_addresses(load("leaky.eml"))
+ self.assertEqual(spoofed, {"from": "you@example.org"})
+
+ def test_the_spoof_reaches_the_iocs_as_a_flag_carrying_no_value(self):
+ # The impersonated identity can be the recipient's own, so the IOC
+ # records only THAT it happened. A domain here would leak in the
+ # exact case the flag exists to report.
+ iocs = parse.iocs(load("leaky.eml"), trusted=["192.0.2.0/24"])
+ flags = [i for i in iocs if i["type"] == "observation"]
+ self.assertEqual(len(flags), 1)
+ self.assertEqual(flags[0]["origin"], "display-name-from")
+ self.assertNotIn("example.org", flags[0]["value"])
+
class TestAuthResults(unittest.TestCase):
def test_verdicts_are_read_as_the_server_recorded_them(self):