diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-10 11:38:20 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-10 11:38:20 +0200 |
| commit | f1d706040785c90f114f80e7f45c597af06a03a9 (patch) | |
| tree | 32a0db3ee24bf6e3f22b971226fcbf984eddf685 /tests/test_report.py | |
| parent | 2611c6485fa733e627f2c62c0369260f3a96d0bc (diff) | |
| download | abusectl-f1d706040785c90f114f80e7f45c597af06a03a9.tar.gz abusectl-f1d706040785c90f114f80e7f45c597af06a03a9.zip | |
fix: emit only registered ARF fields, verified against IANA
Backlog item 4 asked for a primary source for the machine-readable part.
It exists, and it is IANA's MARF registry rather than x-arf's own docs.
Checked against that registry and RFC 5965 itself on 2026-09-10.
Report-Type is not a registered field. The registry lists 25 names and it
is absent; it came from this spec's worked example rather than a decision.
Removed, because Feedback-Type already says it. Keeping it would have been
harmless, since RFC 5965 section 6 makes ignoring an unknown field a MUST
for the receiver, but that same section requires an extension field be
registered, and a desk should be able to look up every field in a document
this tool sends.
Feedback-Type moves from abuse to fraud. RFC 5965 registers fraud as
"indicates some kind of fraud or phishing activity" and abuse as
"unsolicited email or some other kind of email abuse". This tool reports
phishing, and some desks route fraud separately from bulk spam.
Reported-Uri becomes Reported-URI, the spelling in the ABNF and the
registration. The RFC's own worked example uses the other one, which is
where this came from. Names are case-insensitive so nothing was broken.
A new test holds the registry's 25 names and asserts every emitted field
is one of them, with Source named as the single deliberate exception, so
a new unregistered field cannot arrive unnoticed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LByBnw83xr9YP85nskzkyE
Diffstat (limited to 'tests/test_report.py')
| -rw-r--r-- | tests/test_report.py | 54 |
1 files changed, 43 insertions, 11 deletions
diff --git a/tests/test_report.py b/tests/test_report.py index 9a19d00..c8c39f8 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -961,19 +961,51 @@ class FeedbackPart(unittest.TestCase): self.lookup = dict(self.fields) def test_the_three_rfc5965_required_fields_are_present(self): - self.assertEqual(self.lookup["Feedback-Type"], "abuse") + self.assertEqual(self.lookup["Feedback-Type"], "fraud") self.assertEqual(self.lookup["Version"], "1") self.assertTrue(self.lookup["User-Agent"].startswith("abusectl/")) - def test_the_xarf_report_type_is_phishing(self): - self.assertEqual(self.lookup["Report-Type"], "phishing") + def test_the_feedback_type_is_the_registered_value_naming_phishing(self): + """Checked against the IANA MARF registry, not a worked example. + + RFC 5965 registers fraud as "indicates some kind of fraud or + phishing activity" and abuse as "unsolicited email or some other + kind of email abuse". This tool reports phishing. + """ + self.assertEqual(self.lookup["Feedback-Type"], "fraud") + + def test_no_unregistered_field_is_emitted(self): + """Every name here appears in the IANA MARF registry. + + Report-Type: phishing used to be emitted and is NOT registered. It + was harmless, since RFC 5965 section 6 makes ignoring an unknown + field a MUST, but that same section requires an extension field be + registered, and a desk should be able to look up every field in a + document this tool sends. + """ + registered = { + "Arrival-Date", "Auth-Failure", "Authentication-Results", + "Delivery-Result", "DKIM-ADSP-DNS", "DKIM-Canonicalized-Body", + "DKIM-Canonicalized-Header", "DKIM-Domain", "DKIM-Identity", + "DKIM-Selector", "DKIM-Selector-DNS", "Feedback-Type", + "Identity-Alignment", "Incidents", "Original-Mail-From", + "Original-Rcpt-To", "Received-Date", "Reported-Domain", + "Reported-URI", "Reporting-MTA", "Source-IP", "Source-Port", + "SPF-DNS", "User-Agent", "Version", + } + # Source is x-arf's, deliberately: RFC 5965 has no field for "this + # host is the thing being reported", which is why the envelope + # carries x-arf fields at all. It is the one exception and is named + # here so a NEW unregistered field cannot slip in unnoticed. + emitted = {name for name, _ in self.fields} + self.assertEqual(emitted - registered - {"Source"}, set()) def test_source_is_the_primary_indicator(self): self.assertEqual(self.lookup["Source"], "203.0.113.42") self.assertEqual(self.lookup["Source-IP"], "203.0.113.42") def test_every_url_appears_as_a_reported_uri(self): - uris = [value for name, value in self.fields if name == "Reported-Uri"] + uris = [value for name, value in self.fields if name == "Reported-URI"] self.assertEqual( uris, ["http://login.sender.invalid/verify?id=REDACTED"] ) @@ -1048,11 +1080,11 @@ class FeedbackPart(unittest.TestCase): seen: dict[str, int] = {} for name, _ in fields: seen[name] = seen.get(name, 0) + 1 - repeatable = {"Reported-Uri", "Reported-Domain"} + repeatable = {"Reported-URI", "Reported-Domain"} for name, count in seen.items(): if name not in repeatable: self.assertEqual(count, 1, f"{name} appeared {count} times") - self.assertEqual(seen["Reported-Uri"], 2) + self.assertEqual(seen["Reported-URI"], 2) self.assertEqual(seen["Reported-Domain"], 2) def test_an_ipv6_indicator_fills_source_ip_too(self): @@ -1086,7 +1118,7 @@ class FeedbackPart(unittest.TestCase): self.assertNotIn("Source", lookup) self.assertNotIn("Source-IP", lookup) # The envelope is still well formed: a desk gets a valid part. - self.assertEqual(lookup["Feedback-Type"], "abuse") + self.assertEqual(lookup["Feedback-Type"], "fraud") self.assertEqual(lookup["Version"], "1") def test_a_type_this_module_does_not_place_is_not_invented_into_one(self): @@ -1133,7 +1165,7 @@ class FeedbackPart(unittest.TestCase): "target": "abuse@host.invalid", "body": None, "status": "pending"} lookup = dict(report.feedback_fields(MANIFEST, destination)) - self.assertEqual(lookup["Feedback-Type"], "abuse") + self.assertEqual(lookup["Feedback-Type"], "fraud") def test_arrival_date_is_not_taken_from_the_senders_date_header(self): """RFC 5965: Arrival-Date is when the generating ADMD's MTA received @@ -1189,7 +1221,7 @@ class FeedbackInjection(unittest.TestCase): destination = report.email_destinations(manifest["contacts"])[0] fields = report.feedback_fields(manifest, destination) for name, out in fields: - if name == "Reported-Uri": + if name == "Reported-URI": return out return None @@ -1386,7 +1418,7 @@ class FeedbackInjection(unittest.TestCase): fields = report.feedback_fields(manifest, destination) self.assertEqual( {name for name, _ in fields}, - {"Feedback-Type", "User-Agent", "Version", "Report-Type"}) + {"Feedback-Type", "User-Agent", "Version"}) def test_a_non_string_value_does_not_crash_the_report(self): """A manifest is edited by hand and JSON has numbers. @@ -1800,7 +1832,7 @@ class Writing(unittest.TestCase): self.assertNotIn(b"\n", header.replace(b"\r\n", b"")) # And the bare LFs inside the feedback part survive untranslated, # which is the same defect seen from the other side. - self.assertIn(b"Feedback-Type: abuse\nUser-Agent:", raw) + self.assertIn(b"Feedback-Type: fraud\nUser-Agent:", raw) def test_an_untouched_case_regenerates_without_complaint(self): """The regression that the plan's read_text() caused. |
