aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--abusectl/cli.py3
-rw-r--r--abusectl/parse.py97
-rw-r--r--tests/fixtures/reportable.eml22
-rw-r--r--tests/test_cli.py20
-rw-r--r--tests/test_offline.py8
-rw-r--r--tests/test_parse.py62
6 files changed, 211 insertions, 1 deletions
diff --git a/abusectl/cli.py b/abusectl/cli.py
index 2200e14..ed31a4b 100644
--- a/abusectl/cli.py
+++ b/abusectl/cli.py
@@ -263,6 +263,9 @@ def _cmd_parse(args) -> int:
manifest = case.load(created.path)
manifest["iocs"] = parse_module.iocs(raw, trusted=settings.trusted_relays)
manifest["auth"] = parse_module.auth_results(raw)
+ manifest["headers"] = parse_module.report_headers(
+ raw, trusted=settings.trusted_relays
+ )
case.save(created.path, manifest)
except parse_module.NoTrustBoundary as exc:
print(f"abusectl parse: {exc}", file=sys.stderr)
diff --git a/abusectl/parse.py b/abusectl/parse.py
index 230ca2d..8773c85 100644
--- a/abusectl/parse.py
+++ b/abusectl/parse.py
@@ -60,6 +60,31 @@ _URL = re.compile(r"https?://[^\s<>\"')]+", re.IGNORECASE)
# stack or spin forever, independent of the `seen` cycle guard.
_MAX_REDIRECT_DEPTH = 5
+# The optional "for <addr>" clause of a Received header (RFC 5321 4.4). Our
+# own boundary relay writes the ENVELOPE RECIPIENT there, so the one Received
+# line a report publishes carries the victim's address verbatim unless this
+# is removed. Matched up to the clause terminator rather than to end of line
+# because "for" is not always last: a timestamp follows it after the ";".
+_RECEIVED_FOR = re.compile(r"(?is)\bfor\s+<[^>]*>\s*(?=;|$)")
+
+# The headers that may appear in a published report. A WHITELIST, never a
+# blacklist: a blacklist means every header this parser learns to read later
+# is a leak waiting for someone to remember. To, Cc, Delivered-To and
+# X-Original-To are absent by construction, which is the same reason iocs()
+# does not read them either.
+_REPORT_HEADERS = (
+ "From",
+ "Subject",
+ "Date",
+ "Message-ID",
+ "Reply-To",
+ "Return-Path",
+ "Authentication-Results",
+ "Received-SPF",
+ "MIME-Version",
+ "Content-Type",
+)
+
@dataclass(frozen=True)
class Hop:
@@ -156,6 +181,78 @@ def sending_ip(raw: bytes, trusted: list[str]) -> str | None:
return None
+def _strip_envelope_recipient(received_value: str) -> str:
+ """Remove the "for <addr>" clause from a Received header.
+
+ The boundary hop is written by OUR OWN relay, and that clause is where it
+ records the envelope recipient: the victim's address, in the one header a
+ report reproduces verbatim. Truncating the chain at the boundary does not
+ help here, because the leak is INSIDE the line being kept, which is why
+ this is a separate step rather than part of the walk.
+
+ The clause is optional (RFC 5321 4.4) and carries nothing a desk needs:
+ the report is about who SENT the message. Only the clause goes, so the
+ hop's own evidence, the address and the receiving server, survives.
+ """
+ return _RECEIVED_FOR.sub("", received_value).rstrip()
+
+
+def report_headers(raw: bytes, trusted: list[str]) -> list[tuple[str, str]]:
+ """Return the headers that may be published, outermost Received first.
+
+ `report` never opens source.eml: the decision about what may be disclosed
+ is made once, here, beside every other one. A second module filtering the
+ original at report time would put that decision in two places, and two
+ places to remember is how the fourth property leaked three times.
+
+ Received is published as EXACTLY ONE line, the boundary hop, and the
+ chain is cut in both directions. Above it are our own relays: publishing
+ them tells a third party about the user's mail path. Below it is the
+ attacker's own writing, and that half is the dangerous one. A forged
+ chain names an innocent third party (the forged-chain fixture plants
+ 198.51.100.7 for this), so publishing a hop below the boundary puts
+ someone else's address into a report an abuse desk will act on. This is
+ the third property, applied to what gets DISCLOSED rather than to what
+ sending_ip() concludes, and the answer is the same for the same reason:
+ the boundary hop is the last line we can stand behind.
+
+ That one surviving line still goes through _strip_envelope_recipient():
+ it was written by our own relay and names the victim in its "for" clause,
+ so the whitelist alone does not make it safe.
+
+ Everything else comes from the _REPORT_HEADERS whitelist, so a recipient
+ header is absent because it was never named rather than because it was
+ stripped.
+
+ A Received line naming no parseable IP cannot be placed against the
+ boundary, so it is dropped rather than guessed at. Publishing an
+ unplaceable line risks disclosing exactly the two things the cut exists
+ to prevent; iocs() still records every hop it can read, so nothing is
+ lost to review, only to the report.
+
+ Returned as a list of pairs rather than a dict because the whitelist may
+ later keep a header that repeats, and order carries meaning.
+ """
+ message = _message(raw)
+ result: list[tuple[str, str]] = []
+
+ for value in message.get_all("received") or []:
+ ip = _extract_ip(str(value))
+ if ip is None:
+ continue
+ if _in_any(ip, trusted):
+ continue
+ result.append(("Received", _strip_envelope_recipient(str(value))))
+ break
+
+ for name in _REPORT_HEADERS:
+ value = message.get(name)
+ if value is not None:
+ result.append((name, str(value)))
+
+ return result
+
+
def _address_of(header_value: str | None) -> str | None:
"""Return the addr-spec of a sender header, ignoring its display name.
diff --git a/tests/fixtures/reportable.eml b/tests/fixtures/reportable.eml
new file mode 100644
index 0000000..e939059
--- /dev/null
+++ b/tests/fixtures/reportable.eml
@@ -0,0 +1,22 @@
+Received: from relay.example.org (relay.example.org [192.0.2.10])
+ by mx.example.org with ESMTP id abc123
+ for <you@example.org>; Mon, 07 Sep 2026 09:12:44 +0000
+Received: from sender.invalid (sender.invalid [203.0.113.42])
+ by relay.example.org with ESMTP id def456
+ for <you@example.org>; Mon, 07 Sep 2026 09:12:40 +0000
+Return-Path: <bounce@sender.invalid>
+Authentication-Results: mx.example.org; spf=fail; dkim=none; dmarc=fail
+Received-SPF: fail (mx.example.org: domain of sender.invalid does not designate 203.0.113.42)
+From: "Example Bank" <phish@sender.invalid>
+To: victim@example.org
+Cc: colleague@example.org
+Delivered-To: victim@example.org
+X-Original-To: victim@example.org
+Reply-To: "Support" <reply@sender.invalid>
+Subject: Your account requires verification
+Date: Mon, 07 Sep 2026 09:12:40 +0000
+Message-ID: <case-one@sender.invalid>
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf-8
+
+Please verify at http://login.sender.invalid/verify?id=abc123
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 1c023c3..425bce9 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -145,7 +145,25 @@ class TestParse(unittest.TestCase):
str(FIXTURES / "simple.eml"),
)
text = (pathlib.Path(out.strip()) / "manifest.json").read_text()
- self.assertNotIn("example.org", text)
+ self.assertNotIn("you@example.org", text)
+ # The bare domain is still barred everywhere the IOCs live. The
+ # headers block is the one exception and it is a NARROW one: the
+ # whitelist publishes the boundary Received line and
+ # Authentication-Results, and both name our own receiving relay in a
+ # "by"/authserv-id clause. That is the user's mail host, not the
+ # user's identity, and a desk learns it from the report's own From
+ # regardless. The address itself must still be absent, which the
+ # assertion above and report_headers' own tests cover.
+ import json
+
+ manifest = json.loads(text)
+ headers = manifest.pop("headers")
+ self.assertNotIn("example.org", json.dumps(manifest))
+ # And nothing shaped like an address survives in the exception.
+ self.assertNotIn("@example.org", json.dumps(headers))
+ names = [name for name, _ in headers]
+ for name in ("To", "Cc", "Delivered-To", "X-Original-To"):
+ self.assertNotIn(name, names)
def test_a_missing_config_points_at_init(self):
code, _, err = self._run(
diff --git a/tests/test_offline.py b/tests/test_offline.py
index 19d9cee..547bd96 100644
--- a/tests/test_offline.py
+++ b/tests/test_offline.py
@@ -53,6 +53,14 @@ class NothingOpensASocket(unittest.TestCase):
b"Subject: test\r\n\r\nbody\r\n")
parse.iocs(raw, trusted=["192.0.2.0/24"])
+ def test_selecting_report_headers_opens_no_socket(self):
+ # A new entry point into the parse path, so it is held to the same
+ # guarantee: choosing what to publish resolves nothing.
+ raw = (b"Received: from relay.example.invalid ([192.0.2.10])\r\n"
+ b"From: sender@example.invalid\r\n"
+ b"Subject: test\r\n\r\nbody\r\n")
+ parse.report_headers(raw, trusted=["192.0.2.0/24"])
+
def test_resolving_with_an_injected_fetch_opens_no_socket(self):
iocs = [{"id": "ioc-1", "type": "ipv4", "value": "198.51.100.7"}]
bootstraps = {
diff --git a/tests/test_parse.py b/tests/test_parse.py
index 11345c7..fa3526a 100644
--- a/tests/test_parse.py
+++ b/tests/test_parse.py
@@ -290,5 +290,67 @@ class TestIocAssembly(unittest.TestCase):
self.assertNotIn("you%40example.org", blob)
+class ReportHeaders(unittest.TestCase):
+ def test_the_whitelist_keeps_what_a_desk_needs(self):
+ headers = parse.report_headers(load("reportable.eml"),
+ trusted=["192.0.2.0/24"])
+ names = [name for name, _ in headers]
+ for wanted in ("From", "Subject", "Date", "Message-ID", "Reply-To",
+ "Return-Path", "Authentication-Results", "Received-SPF"):
+ self.assertIn(wanted, names)
+
+ def test_recipient_headers_never_survive_the_whitelist(self):
+ # The first property, at the one place a report reproduces header
+ # text verbatim. A blacklist would have to remember each of these;
+ # the whitelist never names them at all.
+ headers = parse.report_headers(load("reportable.eml"),
+ trusted=["192.0.2.0/24"])
+ names = [name for name, _ in headers]
+ blob = repr(headers)
+ for name in ("To", "Cc", "Delivered-To", "X-Original-To"):
+ self.assertNotIn(name, names)
+ self.assertNotIn("victim@example.org", blob)
+ self.assertNotIn("colleague@example.org", blob)
+
+ def test_received_stops_at_the_boundary_hop(self):
+ # 192.0.2.10 is ours, so its Received line is our own infrastructure
+ # and must not be published; the hop below it is the one being
+ # reported and is kept.
+ headers = parse.report_headers(load("reportable.eml"),
+ trusted=["192.0.2.0/24"])
+ received = [value for name, value in headers if name == "Received"]
+ self.assertEqual(len(received), 1)
+ self.assertIn("203.0.113.42", received[0])
+ self.assertNotIn("mx.example.org with ESMTP id abc123", received[0])
+
+ def test_the_published_hop_carries_no_envelope_recipient(self):
+ # The boundary Received line is written by OUR OWN relay, and its
+ # optional "for <addr>" clause is the envelope recipient: the
+ # victim's address, verbatim, in the one header a report reproduces
+ # in full. Truncating the chain is not enough on its own.
+ headers = parse.report_headers(load("reportable.eml"),
+ trusted=["192.0.2.0/24"])
+ received = [value for name, value in headers if name == "Received"]
+ self.assertNotIn("you@example.org", received[0])
+ self.assertNotIn("for <", received[0])
+ # The rest of the hop survives; this is a cut, not a blanking.
+ self.assertIn("203.0.113.42", received[0])
+
+ def test_a_forged_chain_publishes_no_hop_below_the_boundary(self):
+ # The same job test_a_forged_chain_stops_at_the_first_untrusted_hop
+ # does for sending_ip(), asserted over what actually gets published:
+ # 198.51.100.7 is an innocent party the attacker named.
+ headers = parse.report_headers(load("forged-chain.eml"),
+ trusted=["192.0.2.0/24"])
+ received = [value for name, value in headers if name == "Received"]
+ # Asserted in BOTH directions: dropping Received altogether would
+ # satisfy the "not published" half on its own, and a test that
+ # passes when the feature is missing protects nothing.
+ self.assertEqual(len(received), 1)
+ self.assertIn("203.0.113.99", received[0])
+ self.assertTrue(all("198.51.100.7" not in value for value in received))
+ self.assertTrue(all("198.51.100.8" not in value for value in received))
+
+
if __name__ == "__main__":
unittest.main()