aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-08 16:28:14 +0200
committerDanilo M. <danix@danix.xyz>2026-09-08 16:28:14 +0200
commit92dba06905ded925bc78e4bac74989363aad62d7 (patch)
treea6c3492b6f8789b8317c6ecd7f36eac23b3778b0
parentfd8339703ccb99fccb95059996001f6734043655 (diff)
downloadabusectl-92dba06905ded925bc78e4bac74989363aad62d7.tar.gz
abusectl-92dba06905ded925bc78e4bac74989363aad62d7.zip
feat: flag an address spoofed into a display name
A display name naming a recognisable address is a deliberate act and a real signal, so it is reported rather than merely ignored once _domain_of() stopped mistaking it for the sender. The IOC carries no value. One of the identities an attacker impersonates is the recipient themselves, so publishing the impersonated domain would leak the recipient's own domain in exactly the case worth flagging. What travels is only that it happened; the reviewer has the message and can see who was impersonated, and a third party does not need to. display_name_addresses() still returns the full address for local review. Only the published IOC list is stripped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019NHaqA1Rz5ybed7wFUeQbK
-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):