Fix blank SMTP TLS failure-detail fields crashing saves; read RFC 8460 additional-information (11.0.3) (#916)

* Treat blank SMTP TLS failure-detail fields as absent; read RFC 8460 additional-information

Some reporters send optional failure-details fields as empty strings
(e.g. "sending-mta-ip": "" on sts-policy-fetch-error). The parser copied
them verbatim, so the PostgreSQL save failed on the INET columns
("invalid input syntax for type inet") and the Elasticsearch/OpenSearch
saves failed Ip() validation, discarding the whole report. Blank or
whitespace-only optional values are now omitted.

RFC 8460 section 4.4 names the URI key "additional-information"; the
parser only read "additional-info-uri" (the schema's value placeholder),
so conformant reports lost the URI. The RFC key is now read, with the old
key kept as a fallback.

Bump version to 11.0.3.

Closes #915

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Import parse_report_file directly in the ES/OpenSearch tests

Addresses github-code-quality's "Module is imported with 'import' and
'import from'" findings on tests/test_elastic.py and
tests/test_opensearch.py.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sean Whalen
2026-09-24 10:42:34 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6648be7d02
commit e077136b60
8 changed files with 307 additions and 23 deletions
+95
View File
@@ -963,6 +963,26 @@ class Test(unittest.TestCase):
)
print("Passed!")
def testSmtpTlsRfc8460SampleReadsAdditionalInformation(self):
"""samples/smtp_tls/rfc8460.json is RFC 8460 §4.4's own worked
example, whose second failure-details entry sets
"additional-information" (not the non-RFC "additional-info-uri").
Before the fix, the parser only read "additional-info-uri", so this
URI was silently dropped even from the RFC's own example."""
result = parsedmarc.parse_report_file(
"samples/smtp_tls/rfc8460.json", offline=True
)
report = cast(SMTPTLSReport, result["report"])
failure_details = report["policies"][0].get("failure_details", [])
starttls_detail = next(
d for d in failure_details if d["result_type"] == "starttls-not-supported"
)
self.assertEqual(
starttls_detail.get("additional_info_uri"),
"https://reports.company-x.example/report_info?"
"id=5065427c-23d3#StarttlsNotSupported",
)
def testSmtpTlsCsvStripsNulFromFields(self):
"""A NUL character in an SMTP TLS report text field is stripped
from CSV output instead of reaching the ``csv`` writer.
@@ -1487,6 +1507,81 @@ class Test(unittest.TestCase):
self.assertEqual(result["additional_info_uri"], "https://example.com/info")
self.assertEqual(result["failure_reason_code"], "TLS_ERROR")
def testParseSmtpTlsFailureDetailsBlankOptionalFieldsOmitted(self):
"""Empty-string and whitespace-only optional fields are treated as
absent, not passed through.
Real-world reporters send "" for sending-mta-ip/receiving-ip on
results like sts-policy-fetch-error (see samples/smtp_tls/
empty_failure_detail_fields.json). An empty string is not a valid
IP address, so it previously reached PostgreSQL's INET columns
verbatim and crashed the PostgreSQL save, and would also have
crashed the Elasticsearch/OpenSearch saves' Ip() field validation
with a bare ValueError (issue #915).
"""
details = {
"result-type": "sts-policy-fetch-error",
"failed-session-count": 1,
"sending-mta-ip": "",
"receiving-ip": " ",
"receiving-mx-hostname": "",
"receiving-mx-helo": "\t",
"additional-information": "",
"additional-info-uri": "",
"failure-reason-code": "",
}
result = parsedmarc._parse_smtp_tls_failure_details(details)
self.assertEqual(result["result_type"], "sts-policy-fetch-error")
self.assertEqual(result["failed_session_count"], 1)
self.assertNotIn("sending_mta_ip", result)
self.assertNotIn("receiving_ip", result)
self.assertNotIn("receiving_mx_hostname", result)
self.assertNotIn("receiving_mx_helo", result)
self.assertNotIn("additional_info_uri", result)
self.assertNotIn("failure_reason_code", result)
def testParseSmtpTlsFailureDetailsAdditionalInformationKey(self):
"""RFC 8460 §4.4's JSON key is "additional-information" (its value
is described as an "additional-info-uri"); it is read into the
parser's additional_info_uri field.
"""
details = {
"result-type": "starttls-not-supported",
"failed-session-count": 1,
"additional-information": "https://example.com/rfc-key-info",
}
result = parsedmarc._parse_smtp_tls_failure_details(details)
self.assertEqual(
result["additional_info_uri"], "https://example.com/rfc-key-info"
)
def testParseSmtpTlsFailureDetailsAdditionalInformationWinsOverLegacy(self):
"""When both the RFC key and the legacy key are present and
non-blank, the RFC key's value wins."""
details = {
"result-type": "starttls-not-supported",
"failed-session-count": 1,
"additional-information": "https://example.com/rfc-key-info",
"additional-info-uri": "https://example.com/legacy-key-info",
}
result = parsedmarc._parse_smtp_tls_failure_details(details)
self.assertEqual(
result["additional_info_uri"], "https://example.com/rfc-key-info"
)
def testParseSmtpTlsFailureDetailsAdditionalInformationBlankFallsBack(self):
"""A blank RFC-key value falls back to a non-blank legacy value."""
details = {
"result-type": "starttls-not-supported",
"failed-session-count": 1,
"additional-information": "",
"additional-info-uri": "https://example.com/legacy-key-info",
}
result = parsedmarc._parse_smtp_tls_failure_details(details)
self.assertEqual(
result["additional_info_uri"], "https://example.com/legacy-key-info"
)
def testParseSmtpTlsFailureDetailsMissingRequired(self):
"""Missing required field raises InvalidSMTPTLSReport"""
with self.assertRaises(parsedmarc.InvalidSMTPTLSReport):