aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-10 11:38:20 +0200
committerDanilo M. <danix@danix.xyz>2026-09-10 11:38:20 +0200
commitf1d706040785c90f114f80e7f45c597af06a03a9 (patch)
tree32a0db3ee24bf6e3f22b971226fcbf984eddf685
parent2611c6485fa733e627f2c62c0369260f3a96d0bc (diff)
downloadabusectl-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
-rw-r--r--abusectl/report.py26
-rw-r--r--docs/BACKLOG.md55
-rw-r--r--docs/specs/2026-09-09-report.md13
-rw-r--r--tests/test_report.py54
4 files changed, 108 insertions, 40 deletions
diff --git a/abusectl/report.py b/abusectl/report.py
index 6bf3c54..45559a4 100644
--- a/abusectl/report.py
+++ b/abusectl/report.py
@@ -646,7 +646,7 @@ def feedback_fields(manifest: dict, destination: dict) -> list[tuple[str, str]]:
ignore fields it does not support, so a standards parser reads what it
knows and x-arf tooling finds what it wants.
- Returned as PAIRS, not a dict, because Reported-Uri and Reported-Domain
+ Returned as PAIRS, not a dict, because Reported-URI and Reported-Domain
repeat. Everything else does not, and that is enforced rather than
assumed: RFC 5965 gives Source-IP and Arrival-Date "once maximum". A
strict parser meeting a repeated single-occurrence field either rejects
@@ -698,11 +698,24 @@ def feedback_fields(manifest: dict, destination: dict) -> list[tuple[str, str]]:
# covers every URL under it.
primary = (ips or domains or urls or [None])[0]
+ # "fraud" over "abuse", and both are checked against the IANA MARF
+ # registry rather than a worked example. RFC 5965 defines 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, so fraud is the registered value that says what
+ # the report is, and some desks route it separately from bulk spam.
+ #
+ # There is no Report-Type. It is NOT in the registry (25 registered
+ # field names, it is absent), it was carried from the spec's worked
+ # example rather than chosen, and Feedback-Type: fraud already states
+ # what it was saying. Nothing would have broken by keeping it, since
+ # 5965 section 6 makes ignoring unknown fields a MUST, but that same
+ # section requires extension fields be registered, and every field
+ # emitted here should be one a desk can look up.
fields = [
- ("Feedback-Type", "abuse"),
+ ("Feedback-Type", "fraud"),
("User-Agent", f"abusectl/{VERSION}"),
("Version", "1"),
- ("Report-Type", "phishing"),
]
if primary is not None:
@@ -713,7 +726,12 @@ def feedback_fields(manifest: dict, destination: dict) -> list[tuple[str, str]]:
for domain in domains:
fields.append(("Reported-Domain", domain))
for url in urls:
- fields.append(("Reported-Uri", url))
+ # "Reported-URI", the spelling in RFC 5965's ABNF and its IANA
+ # registration. The RFC's own worked example writes "Reported-Uri"
+ # and that inconsistency is where this module's old spelling came
+ # from. Field names are case-insensitive per RFC 5322, so nothing
+ # was broken; matching the normative spelling costs nothing.
+ fields.append(("Reported-URI", url))
return [(name, _field_value(value)) for name, value in fields]
diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md
index 3ab642f..87ec4b3 100644
--- a/docs/BACKLOG.md
+++ b/docs/BACKLOG.md
@@ -8,7 +8,7 @@ number and gains a status rather than being renumbered.
| 1 | Skip boilerplate namespace URLs | XS | open |
| 2 | An IDN indicator resolves to no contact | S | open |
| 3 | Expose kept cases so qtmaildir can tag spam | ? | open, unsized |
-| 4 | `Report-Type: phishing` is unverified against x-arf | XS | open |
+| 4 | `Report-Type: phishing` is unverified against x-arf | XS | closed |
| 5 | `report.build()` raises KeyError on an identity with no email | XS | closed |
## 1. Skip boilerplate namespace URLs
@@ -114,27 +114,38 @@ to qtmaildir. The second is much more in keeping with the rest of the tool.
## 4. `Report-Type: phishing` is unverified against x-arf
-**Observed.** `report.feedback_fields()` emits `Report-Type: phishing` in the
-machine-readable part. Every other field there was verified against RFC 5965
-itself; this one was not, because no primary source for x-arf's own field
-semantics could be reached while building it. The abusix README documents only
-the v3 to v4 deprecation and does not define the field.
-
-**Cause.** Not a defect found in the code. The value follows the worked
-example in `docs/specs/2026-09-09-report.md`, so it is internally consistent,
-and the hybrid envelope means a strict RFC 5965 parser ignores the field
-either way (the RFC requires implementors ignore fields they do not support).
-The exposure is limited to x-arf tooling reading a field name or value that
-does not exist in the version it implements.
-
-**Approach.** Find a primary source for x-arf v4 field names, confirm or
-correct the value, and record what it was checked against. If x-arf turns out
-to name the field differently, the fix is one string and one test.
-
-**Constraints.** Low urgency: nothing here is a leak, and the failure mode is
-a field an x-arf parser skips rather than acts on wrongly. Worth doing before
-the first real report is filed, so a desk running x-arf tooling gets what it
-expects.
+**Closed** against the IANA MARF registry
+(`https://www.iana.org/assignments/marf-parameters/marf-parameters.xml`),
+checked 2026-09-10, plus RFC 5965 itself. The primary source the item asked
+for exists; it is IANA's, not x-arf's.
+
+Two things were wrong, and neither was a leak.
+
+`Report-Type` is NOT a registered field. The registry lists 25 field names
+and it is absent. It came from the worked example in the report spec rather
+than from a decision. It is now REMOVED: `Feedback-Type` already carries what
+it was saying. 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` was `abuse` and is now `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, so `fraud` is the registered value that says what the report is,
+and some desks route it separately from bulk spam.
+
+A third, cosmetic, finding: the module emitted `Reported-Uri` while the RFC's
+ABNF and its IANA registration both spell it `Reported-URI`. The RFC's own
+worked example writes `Reported-Uri`, which is where the module's spelling
+came from. Field names are case-insensitive per RFC 5322 so nothing was
+broken, and it now matches the normative spelling.
+
+`tests/test_report.py::FeedbackPart::test_no_unregistered_field_is_emitted`
+holds the registry's 25 names and asserts every emitted field is one of them.
+The single exception is `Source`, which is x-arf's and is the reason the
+envelope carries x-arf fields at all; it is named in the test so a NEW
+unregistered field cannot arrive unnoticed.
## 5. `report.build()` raises KeyError on an identity with no email
diff --git a/docs/specs/2026-09-09-report.md b/docs/specs/2026-09-09-report.md
index 12d2157..b3a67ae 100644
--- a/docs/specs/2026-09-09-report.md
+++ b/docs/specs/2026-09-09-report.md
@@ -111,20 +111,27 @@ second body to keep in sync with the first for no reader.
An RFC 5965 envelope carrying x-arf fields inside it.
```
-Feedback-Type: abuse
+Feedback-Type: fraud
User-Agent: abusectl/0.1.0
Version: 1
-Report-Type: phishing
Source: 198.51.100.7
Source-IP: 198.51.100.7
Reported-Domain: example.invalid
Arrival-Date: Mon, 08 Sep 2026 09:12:44 +0000
-Reported-Uri: http://login-example.invalid/verify?id=REDACTED
+Reported-URI: http://login-example.invalid/verify?id=REDACTED
```
`Feedback-Type`, `User-Agent` and `Version` are the three fields RFC 5965
requires. The rest are optional there or come from x-arf.
+**Every field name here is in the IANA MARF registry except `Source`**, which
+is x-arf's and is the reason the envelope carries x-arf fields at all. An
+earlier draft of this example also carried `Report-Type: phishing`, which is
+not registered anywhere; it was removed once the registry was checked, since
+`Feedback-Type` already says it. `fraud` rather than `abuse` because RFC 5965
+defines it as "some kind of fraud or phishing activity", which is what this
+tool reports. See backlog item 4 for what was checked against what.
+
**Why an RFC 5965 envelope with x-arf fields inside, rather than either
alone.** RFC 5965 is an IETF standard and universally understood, but it was
designed for feedback loops, where a report is ABOUT A MESSAGE. These reports
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.