diff --git a/CHANGELOG.md b/CHANGELOG.md
index 577480f8..45a07ab3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
### Bug fixes
- **The Elasticsearch/OpenSearch aggregate dashboards' over-time charts (and the Grafana ES dashboard's summary pies and time series) bucketed on the multi-valued `date_range` field**; a date histogram counts a report once per value, double-counting any report whose begin and end dates fall in different buckets. All date histograms and time-range filters now use the single-valued `date_begin`, matching the report-begin semantics of the PostgreSQL (`begin_date`) and Splunk (`_time` = interval begin) dashboards.
+- **Aggregate-report policy and authentication result words are now normalized to lowercase** ([#288](https://github.com/domainaware/parsedmarc/issues/288)): reporters that emit mixed-case values such as `Pass` no longer create duplicate result categories in outputs and dashboards.
## 10.2.4
diff --git a/parsedmarc/__init__.py b/parsedmarc/__init__.py
index 96e6dd90..b02ea901 100644
--- a/parsedmarc/__init__.py
+++ b/parsedmarc/__init__.py
@@ -181,6 +181,15 @@ def _text(value: Any) -> str | None:
return value
+def _normalize_result_word(value: Any) -> Any:
+ """Lowercase a reporter-supplied enum word; RFC 7489 Appendix C and
+ RFC 9990 define result/disposition types as lowercase tokens. Non-string
+ values (e.g. xmltodict dicts from attribute-bearing elements) pass
+ through unchanged.
+ """
+ return value.lower() if isinstance(value, str) else value
+
+
def _bucket_interval_by_day(
begin: datetime,
end: datetime,
@@ -422,11 +431,12 @@ def _parse_report_record(
"policy_override_reasons": [],
}
if "disposition" in policy_evaluated:
- new_policy_evaluated["disposition"] = policy_evaluated["disposition"]
- if "dkim" in policy_evaluated:
- new_policy_evaluated["dkim"] = policy_evaluated["dkim"]
- if "spf" in policy_evaluated:
- new_policy_evaluated["spf"] = policy_evaluated["spf"]
+ new_policy_evaluated["disposition"] = _normalize_result_word(
+ policy_evaluated["disposition"]
+ )
+ for key in ("dkim", "spf"):
+ if key in policy_evaluated:
+ new_policy_evaluated[key] = _normalize_result_word(policy_evaluated[key])
reasons = []
spf_aligned = (
policy_evaluated["spf"] is not None
@@ -505,7 +515,7 @@ def _parse_report_record(
)
new_result["selector"] = "none"
if "result" in result and result["result"] is not None:
- new_result["result"] = result["result"]
+ new_result["result"] = _normalize_result_word(result["result"])
else:
new_result["result"] = "none"
new_result["human_result"] = _text(result.get("human_result"))
@@ -521,7 +531,7 @@ def _parse_report_record(
else:
new_result["scope"] = "mfrom"
if "result" in result and result["result"] is not None:
- new_result["result"] = result["result"]
+ new_result["result"] = _normalize_result_word(result["result"])
else:
new_result["result"] = "none"
new_result["human_result"] = _text(result.get("human_result"))
diff --git a/samples/aggregate_invalid/report_with_upper_cased_pass.xml b/samples/aggregate_invalid/report_with_upper_cased_pass.xml
index 406ab6c9..14cc7592 100644
--- a/samples/aggregate_invalid/report_with_upper_cased_pass.xml
+++ b/samples/aggregate_invalid/report_with_upper_cased_pass.xml
@@ -21,7 +21,7 @@
23.104.41.189
1
- none
+ None
Pass
Pass
diff --git a/tests/test_init.py b/tests/test_init.py
index 4553061f..7eb1cfd5 100644
--- a/tests/test_init.py
+++ b/tests/test_init.py
@@ -166,6 +166,25 @@ class Test(unittest.TestCase):
)
print("Passed!")
+ def testAggregateResultWordsAreLowercase(self):
+ """Reporter-supplied result words are lowercased at ingest; RFC 7489
+ Appendix C and RFC 9990 define the result and disposition types as
+ lowercase enum tokens (issue #288).
+ """
+ result = parsedmarc.parse_report_file(
+ "samples/aggregate_invalid/report_with_upper_cased_pass.xml",
+ offline=True,
+ )
+ assert result["report_type"] == "aggregate"
+ report = cast(AggregateReport, result["report"])
+ record = report["records"][0]
+
+ self.assertEqual(record["policy_evaluated"]["dkim"], "pass")
+ self.assertEqual(record["policy_evaluated"]["spf"], "pass")
+ self.assertEqual(record["auth_results"]["dkim"][0]["result"], "pass")
+ self.assertEqual(record["auth_results"]["spf"][0]["result"], "pass")
+ self.assertEqual(record["policy_evaluated"]["disposition"], "none")
+
def testEmptySample(self):
"""Test empty/unparasable report"""
with self.assertRaises(parsedmarc.ParserError):