aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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.