diff options
| -rw-r--r-- | abusectl/parse.py | 35 | ||||
| -rw-r--r-- | abusectl/redact.py | 1 | ||||
| -rw-r--r-- | tests/fixtures/leaky.eml | 2 | ||||
| -rw-r--r-- | tests/test_parse.py | 17 |
4 files changed, 53 insertions, 2 deletions
diff --git a/abusectl/parse.py b/abusectl/parse.py index 900543b..230ca2d 100644 --- a/abusectl/parse.py +++ b/abusectl/parse.py @@ -229,9 +229,38 @@ def sender_domains(raw: bytes) -> dict[str, str]: if reply_to is not None and reply_to != from_domain: domains["reply_to"] = reply_to + # Sender names who actually injected the message. Included on the same + # terms as Reply-To, since one repeating From is noise while one naming + # a separate relay is the infrastructure behind the run. + sender = _domain_of(message.get("Sender")) + if sender is not None and sender != from_domain: + domains["sender"] = sender + return domains +def unsubscribe_urls(raw: bytes) -> list[str]: + """Return http(s) URLs from List-Unsubscribe, redacted. + + The header routinely names a domain that appears nowhere else in the + message, which makes it infrastructure worth reporting. It is also one + of the likeliest places a recipient identifier hides, since an + unsubscribe link has to say who is unsubscribing, so every URL goes + through redact.url() exactly like a body URL. + + mailto: entries are skipped rather than redacted: the address IS the + whole value, so there is nothing left to report once it is removed. + """ + header = _message(raw).get("List-Unsubscribe") + if header is None: + return [] + found = [] + for entry in re.findall(r"<([^>]*)>", str(header)): + if re.match(r"(?i)^https?://", entry): + found.append(redact.url(entry)) + return found + + def display_name_addresses(raw: bytes) -> dict[str, str]: """Return addresses spoofed into the display name of a sender header. @@ -438,7 +467,8 @@ 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``, ``display-name-from``, ...). During review the user must be able to tell an + ``attachment``, ``display-name-from``, ``header-list_unsubscribe``, + ...). 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. @@ -482,6 +512,9 @@ def iocs(raw: bytes, trusted: list[str]) -> list[dict]: add(type="observation", value="display-name-carries-address", origin=f"display-name-{key}") + for url in unsubscribe_urls(raw): + add(type="url", value=url, origin="header-list_unsubscribe") + for url in urls(raw): entry = {"type": "url", "value": url, "origin": "body"} segments = _suspect_segments(originals.get(url, url)) diff --git a/abusectl/redact.py b/abusectl/redact.py index 15b1345..4e4c5ad 100644 --- a/abusectl/redact.py +++ b/abusectl/redact.py @@ -54,7 +54,6 @@ import string from urllib.parse import ( parse_qsl, unquote, - urlencode, urlsplit, urlunsplit, ) diff --git a/tests/fixtures/leaky.eml b/tests/fixtures/leaky.eml index f98d191..5ce3366 100644 --- a/tests/fixtures/leaky.eml +++ b/tests/fixtures/leaky.eml @@ -7,6 +7,8 @@ Received: from sender.example.invalid (unknown [203.0.113.42]) Return-Path: <bounce@sender.example.invalid> From: "Billing at you@example.org" <phish@sender.example.invalid> To: <you@example.org> +Sender: envelope@relay.example.invalid +List-Unsubscribe: <http://unsub.example.invalid/u?e=you@example.org>, <mailto:leave@unsub.example.invalid> Subject: Confirm now Message-ID: <eee555@sender.example.invalid> Date: Tue, 8 Sep 2026 16:00:00 +0200 diff --git a/tests/test_parse.py b/tests/test_parse.py index 60c1fb5..11345c7 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -98,6 +98,12 @@ class TestSenderDomains(unittest.TestCase): }, ) + def test_sender_is_collected_when_it_differs_from_from(self): + # Sender names the party who actually injected the message, which on + # a spam run is often a relay distinct from the forged From. + domains = parse.sender_domains(load("leaky.eml")) + self.assertEqual(domains["sender"], "relay.example.invalid") + def test_reply_to_is_absent_when_it_matches_from(self): # Only a DIFFERING Reply-To is an indicator; repeating From adds noise. domains = parse.sender_domains(load("with-attachment.eml")) @@ -245,6 +251,17 @@ class TestIocAssembly(unittest.TestCase): self.assertEqual(targets[0]["value"], "http://evil.example.invalid/pay?ref=REDACTED") + def test_an_unsubscribe_url_is_reported_and_redacted(self): + # List-Unsubscribe routinely names a domain appearing nowhere else, + # and an unsubscribe link is a prime carrier of a recipient token, + # so it is an indicator that must arrive redacted. + iocs = parse.iocs(load("leaky.eml"), trusted=["192.0.2.0/24"]) + unsub = [i for i in iocs if i["origin"] == "header-list_unsubscribe"] + self.assertEqual( + [i["value"] for i in unsub], + ["http://unsub.example.invalid/u?e=REDACTED"], + ) + def test_an_attachment_becomes_a_hash_ioc(self): iocs = parse.iocs(load("with-attachment.eml"), trusted=["192.0.2.0/24"]) hashes = [i for i in iocs if i["type"] == "sha256"] |
