From 2611c6485fa733e627f2c62c0369260f3a96d0bc Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 10 Sep 2026 11:23:27 +0200 Subject: docs: record the report spec and both sweeps Sweep A over 92 real messages after task 1 changed parse.py: 1685 indicators, 92 bodies, 0 crashes, 0 empty parses, no address from a raw source in the IOC output and no recipient address in any generated body. What it measured is the limit the spec already accepts. Subject 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, and the envelope recipient was cut from the boundary Received line in every message. That is the whitelist governing which headers travel rather than what is inside one, which the spec's "attacker-controlled free text is published unfiltered" section states outright and names the sweep as the cover for. personalised-subject.eml pins all three behaviours, the accepted one included, so the number cannot drift unnoticed. The for-clause test is mutation-checked: stop cutting the clause and all three fail. Sweep B, 12 hand-picked public targets and none from the corpus: 12 of 12, 0 failures, all five RIRs parseable, IPv6 live, the label walk and the multi-part suffix both correct. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LByBnw83xr9YP85nskzkyE --- AGENTS.md | 27 ++++++++++++++++++++++ tests/fixtures/personalised-subject.eml | 14 +++++++++++ tests/test_report.py | 41 ++++++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/personalised-subject.eml 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 ; 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 ; Tue, 08 Sep 2026 09:14:58 +0000 +From: Account Security +To: alicejones@example.org +Subject: alicejones, your account has been suspended +Date: Tue, 08 Sep 2026 09:14:55 +0000 +Message-ID: +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"]) -- cgit v1.2.3