diff options
| -rw-r--r-- | AGENTS.md | 27 | ||||
| -rw-r--r-- | tests/fixtures/personalised-subject.eml | 14 | ||||
| -rw-r--r-- | tests/test_report.py | 41 |
3 files changed, 81 insertions, 1 deletions
@@ -332,6 +332,27 @@ failures: AFRINIC and `nic.cz` publish a handle but no `abuse` role, and `example.museum` has no RDAP server for the TLD. The IANA bootstrap held 5 IPv4 services, 5 IPv6 and 590 DNS. +`report` was swept the same way on 2026-09-10, after `parse.py` changed. +Sweep A: 92 messages, 1685 indicators, 92 bodies generated, 0 crashes, 0 empty +parses, 475 query targets. No address from a raw source reached the IOC output, +and no recipient address reached a generated body. + +That sweep measured the limit the spec accepts rather than finding a defect. +The `Subject` header is published verbatim, and 14 of the 92 messages carried +the recipient's LOCAL PART inside it, because the kit personalises the lure. +None carried it in the `From` display name. The envelope recipient was cut +from the boundary `Received` line in every message. `tests/fixtures/` +`personalised-subject.eml` pins all three behaviours, including the accepted +one, so that number cannot change silently. Re-measure it when the whitelist +changes. + +Sweep B on the same date: 12 of 12 targets completed, 0 failures, all five +RIRs returning a parseable jCard, IPv6 live, the label walk reducing +`www.ripe.net` and `a.b.c.example.org`, and `nic.uk` resolving the multi-part +suffix. Three non-resolutions were correct: AFRINIC and `nic.cz` publish no +abuse role, and `.museum` has no RDAP server. Bootstrap held 5 IPv4 services, +5 IPv6 and 590 DNS. + **Sweep B must never draw its targets from the user's own spam corpus.** A query tells a registrar which of their customers someone is investigating, and for a phishing domain that registrar may be the attacker's own. Pick targets that are @@ -359,6 +380,12 @@ settles only what they share. touching RDAP, the bootstrap cache, or anything that issues a query. The fourth property it introduced is stated above in its own right; the spec carries the reasoning behind the rest of the module. +- `docs/specs/2026-09-09-report.md`, the `report` spec. Read it before + changing the publishable-header whitelist, the freeze rule, or what a + report body contains. It records what is deliberately NOT filtered and + why, which is the first thing to read if a sweep result looks like a leak. +- `docs/plans/2026-09-09-report.md`, the plan `report` was built from. + Historical in the same way, and it records the defect found in each task. - `docs/plans/2026-09-08-parse.md`, the plan `init` and `parse` were built from. Historical once built, but it records why each test exists. - `docs/plans/2026-09-09-contacts.md`, the plan `contacts` and `rdap` were diff --git a/tests/fixtures/personalised-subject.eml b/tests/fixtures/personalised-subject.eml new file mode 100644 index 0000000..aba2529 --- /dev/null +++ b/tests/fixtures/personalised-subject.eml @@ -0,0 +1,14 @@ +Received: from mx.example.org (mx.example.org [192.0.2.10]) + by mail.example.org with ESMTP id ABC123 + for <alicejones@example.org>; Tue, 08 Sep 2026 09:15:00 +0000 +Received: from sender.invalid (sender.invalid [203.0.113.55]) + by mx.example.org with ESMTP id DEF456 + for <alicejones@example.org>; Tue, 08 Sep 2026 09:14:58 +0000 +From: Account Security <noreply@sender.invalid> +To: alicejones@example.org +Subject: alicejones, your account has been suspended +Date: Tue, 08 Sep 2026 09:14:55 +0000 +Message-ID: <case-two@sender.invalid> +Content-Type: text/plain; charset=us-ascii + +Verify at http://phish.sender.invalid/verify diff --git a/tests/test_report.py b/tests/test_report.py index 9159dc7..9a19d00 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -8,7 +8,7 @@ import tempfile import unittest from pathlib import Path -from abusectl import report +from abusectl import parse, report class Grouping(unittest.TestCase): @@ -1958,3 +1958,42 @@ class Writing(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class PersonalisedSubject(unittest.TestCase): + """The documented limit, pinned so a later change has to face it. + + A phishing kit personalises the Subject with the recipient's local part. + The whitelist governs WHICH headers travel, never what is inside one, so + that local part is published. The spec accepts this deliberately: Subject + is what lets a desk recognise a campaign, and filtering free text is the + judgement-shaped problem AGENTS.md names as the origin of every leak this + project has had. + + Measured, not theoretical: a sweep of 92 real messages found 14 carrying + the recipient's local part in the Subject, and none in the From display + name. This test exists so that number cannot change silently. + """ + + def _headers(self): + raw = (Path(__file__).parent + / "fixtures" / "personalised-subject.eml").read_bytes() + return dict(parse.report_headers(raw, trusted=["192.0.2.0/24"])) + + def test_the_envelope_recipient_is_cut_from_the_received_line(self): + # The protection that DOES hold: our own relay wrote the "for" + # clause, and it is cut before the line is ever stored. + received = self._headers()["Received"] + self.assertNotIn("alicejones", received) + self.assertNotIn("@example.org", received) + + def test_no_published_header_carries_the_full_recipient_address(self): + published = " ".join(self._headers().values()) + self.assertNotIn("alicejones@example.org", published) + + def test_the_subject_still_carries_the_local_part(self): + # Deliberate. If this ever fails because Subject was filtered, the + # spec's "considered and not built" section is what to read first: + # the fix is a redaction rule with its own tests, not a passthrough + # quietly turned into a filter. + self.assertIn("alicejones", self._headers()["Subject"]) |
