aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md27
-rw-r--r--tests/fixtures/personalised-subject.eml14
-rw-r--r--tests/test_report.py41
3 files changed, 81 insertions, 1 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 2dc08cc..e627e50 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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"])