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):