aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-08 13:34:43 +0200
committerDanilo M. <danix@danix.xyz>2026-09-08 13:34:43 +0200
commit96bd15309df4bb3a46051718ef30ae023f310b06 (patch)
tree33fa9b4d4dda6f2a5d8cd7edb681a7a902d97d1b
parentc734feaf769c936698a52f38a2099152a6098c43 (diff)
downloadabusectl-96bd15309df4bb3a46051718ef30ae023f310b06.tar.gz
abusectl-96bd15309df4bb3a46051718ef30ae023f310b06.zip
feat: walk the Received chain to the trust boundary
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
-rw-r--r--abusectl/parse.py114
-rw-r--r--tests/test_parse.py68
2 files changed, 182 insertions, 0 deletions
diff --git a/abusectl/parse.py b/abusectl/parse.py
new file mode 100644
index 0000000..f243e0c
--- /dev/null
+++ b/abusectl/parse.py
@@ -0,0 +1,114 @@
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Walk a message's Received chain to find the reportable sending IP.
+
+This module is pure and offline. It opens no socket and reads no config: the
+trust boundary (which relays are ours) arrives as an argument from the
+caller, which keeps this testable with no setup and no environment.
+
+Received headers are prepended by each relay that handles a message, so the
+list is newest-first: our own infrastructure at the top, the claimed
+originator at the bottom. Everything below the point where the message
+leaves our own infrastructure was written by whoever was talking to our
+server and can be fabricated wholesale. The only defensible answer is the
+first hop outside that boundary, walking from the top inward; anything
+past it is an attacker's own words about who else to blame.
+
+Nothing in this module ever resolves a hostname or fetches a URL. That is a
+safety property, not a performance choice: following a link or resolving an
+address the sender controls confirms to them that the address is live and
+can fire a tracker embedded in the DNS or HTTP response.
+"""
+
+import ipaddress
+import re
+from dataclasses import dataclass
+from email import policy
+from email.parser import BytesParser
+
+_BRACKETED_IP = re.compile(r"\[([0-9a-fA-F:.]+)\]")
+
+
+@dataclass(frozen=True)
+class Hop:
+ ip: str
+ trusted: bool = False
+
+
+class NoTrustBoundary(Exception):
+ """Raised when no trusted relays are configured.
+
+ This is deliberately an error, not a warning with a best-effort guess.
+ Guessing the outermost public IP is wrong precisely in the case that
+ matters: an attacker forges Received headers naming innocent parties,
+ and a confident wrong answer gets an innocent party reported for abuse
+ they did not commit. Refusing to guess is the safe failure mode.
+ """
+
+ def __init__(self):
+ super().__init__("no trusted_relays configured: run `abusectl init`")
+
+
+def _extract_ip(received_value: str) -> str | None:
+ # The bracketed literal after the connecting hostname is the only part
+ # of a Received header the accepting server itself wrote; everything
+ # else (the claimed hostname) is supplied by the connecting client and
+ # cannot be trusted. Validate through ipaddress so a stray bracketed
+ # token that isn't actually an IP is skipped rather than misreported.
+ for candidate in _BRACKETED_IP.findall(received_value):
+ try:
+ ipaddress.ip_address(candidate)
+ except ValueError:
+ continue
+ return candidate
+ return None
+
+
+def received_hops(raw: bytes) -> list[Hop]:
+ """Return every Received hop that names an IP, outermost first."""
+ message = BytesParser(policy=policy.default).parsebytes(raw)
+ hops = []
+ for value in message.get_all("received") or []:
+ ip = _extract_ip(str(value))
+ if ip is not None:
+ hops.append(Hop(ip=ip))
+ return hops
+
+
+def _in_any(ip: str, networks: list[str]) -> bool:
+ address = ipaddress.ip_address(ip)
+ return any(
+ address in ipaddress.ip_network(network, strict=False)
+ for network in networks
+ )
+
+
+def sending_ip(raw: bytes, trusted: list[str]) -> str | None:
+ """Return the first hop outside the trust boundary, or None.
+
+ Walks the Received chain outermost first (the order received_hops
+ returns) and stops at the first IP not inside any network in `trusted`.
+ That is the point where the message left our own infrastructure; every
+ hop below it in the chain may have been written by the attacker.
+
+ Returns None when every hop is inside the boundary, meaning the message
+ never left our own infrastructure as far as this chain shows.
+ """
+ if not trusted:
+ raise NoTrustBoundary()
+ for hop in received_hops(raw):
+ if not _in_any(hop.ip, trusted):
+ return hop.ip
+ return None
diff --git a/tests/test_parse.py b/tests/test_parse.py
new file mode 100644
index 0000000..92095fd
--- /dev/null
+++ b/tests/test_parse.py
@@ -0,0 +1,68 @@
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Tests for the Received-chain trust boundary walk."""
+
+import pathlib
+import unittest
+
+from abusectl import parse
+
+FIXTURES = pathlib.Path(__file__).parent / "fixtures"
+
+
+def load(name: str) -> bytes:
+ return (FIXTURES / name).read_bytes()
+
+
+class TestReceivedChain(unittest.TestCase):
+ def test_hops_are_returned_outermost_first(self):
+ hops = parse.received_hops(load("simple.eml"))
+ self.assertEqual([h.ip for h in hops], ["192.0.2.11", "203.0.113.42"])
+
+ def test_the_first_untrusted_hop_is_the_sender(self):
+ ip = parse.sending_ip(load("simple.eml"), trusted=["192.0.2.0/24"])
+ self.assertEqual(ip, "203.0.113.42")
+
+ def test_a_forged_chain_stops_at_the_first_untrusted_hop(self):
+ # The attacker prepended two hops naming an innocent third party.
+ # Walking past the boundary would report 198.51.100.7, which is
+ # someone else's address in a header the attacker wrote.
+ ip = parse.sending_ip(load("forged-chain.eml"), trusted=["192.0.2.0/24"])
+ self.assertEqual(ip, "203.0.113.99")
+
+ def test_hops_below_the_boundary_are_still_recorded(self):
+ # Recorded, but as untrusted: they may be useful and must not be
+ # presented as fact.
+ hops = parse.received_hops(load("forged-chain.eml"))
+ self.assertEqual(
+ [h.ip for h in hops],
+ ["192.0.2.11", "203.0.113.99", "198.51.100.7", "198.51.100.8"],
+ )
+
+ def test_no_trusted_relays_is_an_error_not_a_guess(self):
+ # Guessing the outermost public IP is wrong in exactly the case that
+ # matters, and a confident wrong answer gets a third party reported.
+ with self.assertRaises(parse.NoTrustBoundary):
+ parse.sending_ip(load("simple.eml"), trusted=[])
+
+ def test_a_chain_entirely_inside_the_boundary_has_no_sender(self):
+ ip = parse.sending_ip(
+ load("simple.eml"), trusted=["192.0.2.0/24", "203.0.113.0/24"]
+ )
+ self.assertIsNone(ip)
+
+
+if __name__ == "__main__":
+ unittest.main()