diff options
| -rw-r--r-- | abusectl/parse.py | 49 | ||||
| -rw-r--r-- | tests/test_parse.py | 16 |
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): |
