diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/fixtures/reportable.eml | 22 | ||||
| -rw-r--r-- | tests/test_cli.py | 20 | ||||
| -rw-r--r-- | tests/test_offline.py | 8 | ||||
| -rw-r--r-- | tests/test_parse.py | 62 |
4 files changed, 111 insertions, 1 deletions
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() |
