aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-08 16:28:35 +0200
committerDanilo M. <danix@danix.xyz>2026-09-08 16:28:35 +0200
commitcc855e388dc2c1e02447578d05150c9cff63222f (patch)
tree86c2f295606843864b7e886a39f010d89b6e3d73
parent92dba06905ded925bc78e4bac74989363aad62d7 (diff)
downloadabusectl-cc855e388dc2c1e02447578d05150c9cff63222f.tar.gz
abusectl-cc855e388dc2c1e02447578d05150c9cff63222f.zip
feat: report List-Unsubscribe urls and a differing Sender
A sweep of the user's real spam found List-Unsubscribe naming a domain that appeared nowhere else in the message. It is attacker infrastructure and was going unreported. Every url from that header goes through redact.url() like a body url: an unsubscribe link has to say who is unsubscribing, which makes it one of the likeliest carriers of a recipient token. mailto: entries are skipped rather than redacted, since the address is the whole value and nothing useful survives removing it. Sender is collected on the same terms as Reply-To, included only when it differs from From. One repeating From is noise; one naming a separate relay is the infrastructure behind the run. Also drops the unused urlencode import left in redact.py when _redact_kv_string stopped using urllib to rebuild the query string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019NHaqA1Rz5ybed7wFUeQbK
-rw-r--r--abusectl/parse.py35
-rw-r--r--abusectl/redact.py1
-rw-r--r--tests/fixtures/leaky.eml2
-rw-r--r--tests/test_parse.py17
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"]