mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-11-06 16:59:23 +00:00
save_report_to_s3() put the sender-controlled report ID into the object
key and org_name/org_email/report_id into user-defined metadata verbatim.
botocore rejects non-ASCII metadata client-side (ParamValidationError) and
a bare CR/LF with "Invalid header value", which the CLI counts as an S3
output error, so one report held back its whole mailbox batch. "/" and
".." in the report ID added key segments, and keys had no sender identity.
- Percent-encode the report ID into one key segment ("/", the AWS
"characters to avoid", "%", control and non-ASCII characters) and
shorten it so the key stays within S3's 1,024-byte limit.
- Percent-encode metadata values (reversible with unquote) and cap each at
256 characters, under S3's 2 KB metadata limit.
- Append the first 16 hex characters of a SHA-256 over the report type and
its identifying, report-sourced fields (never enrichment), so reporters
that reuse one report ID no longer overwrite their own reports and a
retry still overwrites the same object.
- Fix failure reports, which raised KeyError on report_metadata (absent
from FailureReport in types.py) since S3 output was added in 7.0.0.
Sources: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html,
https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html,
botocore handlers.validate_ascii_metadata.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
635 lines
26 KiB
Python
635 lines
26 KiB
Python
"""Tests for parsedmarc.s3"""
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
from urllib.parse import unquote
|
|
|
|
from botocore.exceptions import ParamValidationError
|
|
from botocore.stub import ANY, Stubber
|
|
|
|
import parsedmarc
|
|
from parsedmarc.s3 import S3Client
|
|
|
|
_SAMPLES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "samples")
|
|
_HASH_SUFFIX = r"_[0-9a-f]{16}\.json"
|
|
|
|
|
|
def _sample_aggregate_report():
|
|
"""Minimal aggregate report shape used by S3Client.save_*_to_s3."""
|
|
return {
|
|
"report_metadata": {
|
|
"org_name": "example.com",
|
|
"org_email": "dmarc@example.com",
|
|
"report_id": "agg-123",
|
|
"begin_date": "2024-01-15 00:00:00",
|
|
"end_date": "2024-01-16 00:00:00",
|
|
# not in S3Client.metadata_keys; should NOT appear on the S3 object
|
|
"errors": [],
|
|
},
|
|
"policy_published": {"domain": "example.com", "p": "none"},
|
|
"records": [],
|
|
}
|
|
|
|
|
|
def _sample_smtp_tls_report():
|
|
"""Minimal SMTP TLS report shape as parse_smtp_tls_report_json
|
|
produces it — flat, with ISO-string begin_date / end_date pulled
|
|
directly from the report JSON."""
|
|
return {
|
|
"organization_name": "example.com",
|
|
"begin_date": "2024-02-03T00:00:00Z",
|
|
"end_date": "2024-02-04T00:00:00Z",
|
|
"report_id": "tls-456",
|
|
"contact_info": "tls-admin@example.com",
|
|
"policies": [],
|
|
}
|
|
|
|
|
|
class TestS3ClientInit(unittest.TestCase):
|
|
"""S3Client.__init__ delegates to boto3.resource() with the supplied
|
|
credentials and endpoint. A regression in argument names or order
|
|
would silently send reports to the wrong bucket or auth as the wrong
|
|
principal."""
|
|
|
|
def test_init_forwards_credentials_to_boto3(self):
|
|
with patch("parsedmarc.s3.boto3.resource") as mock_resource:
|
|
S3Client(
|
|
bucket_name="my-bucket",
|
|
bucket_path="dmarc",
|
|
region_name="us-east-1",
|
|
endpoint_url="https://s3.example.com",
|
|
access_key_id="AKIA-test",
|
|
secret_access_key="secret-test",
|
|
)
|
|
mock_resource.assert_called_once_with(
|
|
"s3",
|
|
region_name="us-east-1",
|
|
endpoint_url="https://s3.example.com",
|
|
aws_access_key_id="AKIA-test",
|
|
aws_secret_access_key="secret-test",
|
|
)
|
|
|
|
def test_init_caches_bucket_handle(self):
|
|
"""self.bucket is the Bucket(bucket_name) on the boto3 resource,
|
|
so subsequent save_* calls go to the right bucket."""
|
|
with patch("parsedmarc.s3.boto3.resource") as mock_resource:
|
|
mock_resource.return_value.Bucket.return_value = "bucket-handle"
|
|
client = S3Client(
|
|
bucket_name="my-bucket",
|
|
bucket_path="dmarc",
|
|
region_name="us-east-1",
|
|
endpoint_url="https://s3.example.com",
|
|
access_key_id="k",
|
|
secret_access_key="s",
|
|
)
|
|
mock_resource.return_value.Bucket.assert_called_once_with("my-bucket")
|
|
self.assertEqual(client.bucket, "bucket-handle")
|
|
|
|
|
|
def _client_with_mock_bucket(bucket_path="dmarc"):
|
|
with patch("parsedmarc.s3.boto3.resource"):
|
|
client = S3Client(
|
|
bucket_name="b",
|
|
bucket_path=bucket_path,
|
|
region_name="us-east-1",
|
|
endpoint_url="https://s3.example.com",
|
|
access_key_id="k",
|
|
secret_access_key="s",
|
|
)
|
|
client.bucket = MagicMock()
|
|
return client
|
|
|
|
|
|
def _saved_key(client):
|
|
return client.bucket.put_object.call_args.kwargs["Key"]
|
|
|
|
|
|
def _saved_metadata(client):
|
|
return client.bucket.put_object.call_args.kwargs["Metadata"]
|
|
|
|
|
|
def _aggregate_key(report, bucket_path="dmarc"):
|
|
client = _client_with_mock_bucket(bucket_path)
|
|
client.save_aggregate_report_to_s3(report)
|
|
return _saved_key(client)
|
|
|
|
|
|
def _aggregate_key_segment(report_id):
|
|
"""The last key segment for an aggregate report with ``report_id``."""
|
|
report = _sample_aggregate_report()
|
|
report["report_metadata"]["report_id"] = report_id
|
|
return _aggregate_key(report).rsplit("/", 1)[1]
|
|
|
|
|
|
class TestS3ClientSavePathsAndMetadata(unittest.TestCase):
|
|
"""The S3 key is built from the report's begin_date, report_id, and a
|
|
hash of the fields that identify the report. Wrong format = unfindable
|
|
reports; wrong metadata filtering = secret leakage onto the S3 object."""
|
|
|
|
def test_aggregate_dispatches_with_aggregate_in_key_path(self):
|
|
"""save_aggregate_report_to_s3 puts the object under
|
|
<bucket_path>/aggregate/year=YYYY/month=MM/day=DD/<report_id>_<hash>.json."""
|
|
client = _client_with_mock_bucket()
|
|
client.save_aggregate_report_to_s3(_sample_aggregate_report())
|
|
client.bucket.put_object.assert_called_once()
|
|
self.assertRegex(
|
|
_saved_key(client),
|
|
r"\Admarc/aggregate/year=2024/month=01/day=15/agg-123"
|
|
+ _HASH_SUFFIX
|
|
+ r"\Z",
|
|
)
|
|
|
|
def test_smtp_tls_uses_report_begin_date(self):
|
|
"""SMTP TLS reports are flat — no report_metadata — and
|
|
begin_date is the ISO string produced by parse_smtp_tls_report_json.
|
|
The S3 path-builder parses that string into a datetime for the
|
|
year=/month=/day= key segments.
|
|
|
|
Regression: an earlier version assumed ALL reports carried a
|
|
report_metadata sub-object, which crashed with KeyError on every
|
|
SMTP TLS save. The CLI swallowed the error and only logged it,
|
|
so the bug was invisible in production."""
|
|
client = _client_with_mock_bucket()
|
|
client.save_smtp_tls_report_to_s3(_sample_smtp_tls_report())
|
|
self.assertRegex(
|
|
_saved_key(client),
|
|
r"\Admarc/smtp_tls/year=2024/month=02/day=03/tls-456"
|
|
+ _HASH_SUFFIX
|
|
+ r"\Z",
|
|
)
|
|
|
|
def test_smtp_tls_metadata_comes_from_flat_report_fields(self):
|
|
"""SMTP TLS object metadata is built from the flat report
|
|
instead of report_metadata. organization_name is renamed to
|
|
org_name (the S3 metadata key) for consistency with DMARC."""
|
|
client = _client_with_mock_bucket()
|
|
client.save_smtp_tls_report_to_s3(_sample_smtp_tls_report())
|
|
self.assertEqual(
|
|
_saved_metadata(client),
|
|
{
|
|
"org_name": "example.com",
|
|
"report_id": "tls-456",
|
|
"begin_date": "2024-02-03T00:00:00Z",
|
|
"end_date": "2024-02-04T00:00:00Z",
|
|
},
|
|
)
|
|
|
|
def test_object_body_is_json_serialized_report(self):
|
|
client = _client_with_mock_bucket()
|
|
report = _sample_aggregate_report()
|
|
client.save_aggregate_report_to_s3(report)
|
|
body = client.bucket.put_object.call_args.kwargs["Body"]
|
|
# Round-trip the JSON to make sure it actually deserializes and
|
|
# carries every top-level key the source report had.
|
|
self.assertEqual(json.loads(body), report)
|
|
|
|
def test_metadata_filtered_to_documented_keys_only(self):
|
|
"""report_metadata fields outside `metadata_keys` must not be
|
|
attached to the S3 object — they could leak large or sensitive
|
|
payloads (errors lists, internal IDs) into object metadata."""
|
|
client = _client_with_mock_bucket()
|
|
report = _sample_aggregate_report()
|
|
report["report_metadata"]["errors"] = ["a", "b"]
|
|
report["report_metadata"]["internal_diag"] = "secret"
|
|
client.save_aggregate_report_to_s3(report)
|
|
meta = _saved_metadata(client)
|
|
self.assertEqual(
|
|
set(meta.keys()),
|
|
{"org_name", "org_email", "report_id", "begin_date", "end_date"},
|
|
)
|
|
self.assertNotIn("errors", meta)
|
|
self.assertNotIn("internal_diag", meta)
|
|
|
|
|
|
class TestS3ClientKeyHash(unittest.TestCase):
|
|
"""Every key ends in ``_<hash>.json``, where the hash covers the fields
|
|
that identify the report, so two different reports that share a report
|
|
ID (by accident, or because a sender reuses another reporter's ID) are
|
|
saved to different objects, while a retry of the same report
|
|
overwrites its own object."""
|
|
|
|
def test_normal_report_id_prefix_and_id_unchanged(self):
|
|
"""Report IDs real reporters use keep their punctuation, so
|
|
existing key prefixes and IDs stay readable."""
|
|
for report_id in (
|
|
"agg-123",
|
|
"example.com:1538463741",
|
|
"x@mail.ru",
|
|
"2024-01-09T00:00:00Z_example.com",
|
|
):
|
|
with self.subTest(report_id=report_id):
|
|
report = _sample_aggregate_report()
|
|
report["report_metadata"]["report_id"] = report_id
|
|
key = _aggregate_key(report)
|
|
self.assertTrue(
|
|
key.startswith("dmarc/aggregate/year=2024/month=01/day=15/")
|
|
)
|
|
self.assertRegex(
|
|
key.rsplit("/", 1)[1],
|
|
r"\A" + re.escape(report_id) + _HASH_SUFFIX + r"\Z",
|
|
)
|
|
|
|
def test_key_hash_pinned_for_fixed_report(self):
|
|
"""The hash recipe is pinned. Changing it moves every object to a
|
|
new key, so a batch retried across the upgrade is saved twice;
|
|
that is a compatibility event that needs a changelog entry, not a
|
|
silent refactor.
|
|
|
|
The golden value is the first 16 hex characters of
|
|
sha256('["aggregate","example.com","agg-123","example.com",
|
|
"2024-01-15 00:00:00","2024-01-16 00:00:00"]'): report type, then
|
|
org_name, report_id, policy_published.domain, begin_date, end_date,
|
|
as compact JSON. It is recomputed independently below."""
|
|
canonical = json.dumps(
|
|
[
|
|
"aggregate",
|
|
"example.com",
|
|
"agg-123",
|
|
"example.com",
|
|
"2024-01-15 00:00:00",
|
|
"2024-01-16 00:00:00",
|
|
],
|
|
separators=(",", ":"),
|
|
)
|
|
self.assertEqual(
|
|
hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16],
|
|
"f80ea35e1b3f6ad4",
|
|
)
|
|
self.assertEqual(
|
|
_aggregate_key(_sample_aggregate_report()),
|
|
"dmarc/aggregate/year=2024/month=01/day=15/agg-123_f80ea35e1b3f6ad4.json",
|
|
)
|
|
|
|
def test_key_hash_ignores_enrichment(self):
|
|
"""A report retried later may get different reverse DNS or GeoIP
|
|
results; only fields from the report itself are hashed, so the
|
|
retry still overwrites the same object."""
|
|
first = _sample_aggregate_report()
|
|
first["records"] = [
|
|
{"source": {"ip_address": "192.0.2.1", "reverse_dns": None}}
|
|
]
|
|
second = copy.deepcopy(first)
|
|
second["records"][0]["source"]["reverse_dns"] = "mail.example.net"
|
|
second["records"][0]["source"]["country"] = "US"
|
|
self.assertEqual(_aggregate_key(first), _aggregate_key(second))
|
|
|
|
def test_key_hash_separates_reports_sharing_report_id(self):
|
|
"""Reports with the same org_name and report_id but a different
|
|
policy domain, begin_date (same day), end_date, or org_name are
|
|
different reports and must not overwrite each other."""
|
|
base_key = _aggregate_key(_sample_aggregate_report())
|
|
variants = {
|
|
"domain": ("policy_published", "domain", "other.example"),
|
|
"begin_date": ("report_metadata", "begin_date", "2024-01-15 06:00:00"),
|
|
"end_date": ("report_metadata", "end_date", "2024-01-17 00:00:00"),
|
|
"org_name": ("report_metadata", "org_name", "attacker.example"),
|
|
}
|
|
for name, (section, field, value) in variants.items():
|
|
with self.subTest(field=name):
|
|
report = _sample_aggregate_report()
|
|
report[section][field] = value
|
|
key = _aggregate_key(report)
|
|
# Same directory and report ID; only the hash differs.
|
|
self.assertEqual(key.rsplit("_", 1)[0], base_key.rsplit("_", 1)[0])
|
|
self.assertNotEqual(key, base_key)
|
|
|
|
def test_smtp_tls_hash_includes_policy_domains(self):
|
|
"""Two SMTP TLS reports sharing organization, report ID, and dates
|
|
but covering different policy domains get different keys; the
|
|
policy order inside one report does not matter."""
|
|
|
|
def key_for(*domains):
|
|
client = _client_with_mock_bucket()
|
|
report = _sample_smtp_tls_report()
|
|
report["policies"] = [{"policy_domain": d} for d in domains]
|
|
client.save_smtp_tls_report_to_s3(report)
|
|
return _saved_key(client)
|
|
|
|
self.assertNotEqual(key_for("a.example"), key_for("b.example"))
|
|
self.assertEqual(
|
|
key_for("a.example", "b.example"), key_for("b.example", "a.example")
|
|
)
|
|
|
|
|
|
class TestS3ClientKeySanitization(unittest.TestCase):
|
|
"""The report ID is sender-controlled. It is percent-encoded into one
|
|
key segment so it cannot add path segments, carry characters AWS says
|
|
to avoid, or push the key past S3's 1,024-byte limit
|
|
(https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html)."""
|
|
|
|
def test_traversal_report_id_stays_one_segment(self):
|
|
report = _sample_aggregate_report()
|
|
report["report_metadata"]["report_id"] = "../../etc/passwd"
|
|
key = _aggregate_key(report)
|
|
normal_key = _aggregate_key(_sample_aggregate_report())
|
|
self.assertEqual(key.count("/"), normal_key.count("/"))
|
|
self.assertTrue(key.startswith("dmarc/aggregate/year=2024/month=01/day=15/"))
|
|
self.assertRegex(
|
|
key.rsplit("/", 1)[1], r"\A\.\.%2F\.\.%2Fetc%2Fpasswd" + _HASH_SUFFIX
|
|
)
|
|
|
|
def test_report_id_backslash_percent_control_nonascii_encoded(self):
|
|
""" "/", every printable character in the AWS "characters to avoid"
|
|
list, control characters, and non-ASCII characters are encoded.
|
|
quote() never encodes "~" on its own, so it is checked too."""
|
|
cases = {
|
|
"a/b": "a%2Fb",
|
|
"a{b}": "a%7Bb%7D",
|
|
"a^b": "a%5Eb",
|
|
"a[b]": "a%5Bb%5D",
|
|
'a"b': "a%22b",
|
|
"a<b>": "a%3Cb%3E",
|
|
"a~b": "a%7Eb",
|
|
"a#b": "a%23b",
|
|
"a|b": "a%7Cb",
|
|
"a`b": "a%60b",
|
|
"a\\b": "a%5Cb",
|
|
"a%b": "a%25b",
|
|
"a\r\nb": "a%0D%0Ab",
|
|
"a\x00b": "a%00b",
|
|
"a\x7fb": "a%7Fb",
|
|
"café": "caf%C3%A9",
|
|
}
|
|
for report_id, encoded in cases.items():
|
|
with self.subTest(report_id=report_id):
|
|
segment = _aggregate_key_segment(report_id)
|
|
self.assertRegex(
|
|
segment, r"\A" + re.escape(encoded) + _HASH_SUFFIX + r"\Z"
|
|
)
|
|
# The encoding is reversible.
|
|
self.assertEqual(unquote(segment.rsplit("_", 1)[0]), report_id)
|
|
|
|
def test_empty_dot_dotdot_report_id(self):
|
|
"""The hash suffix keeps the last segment from ever being empty or
|
|
period-only, which AWS says to avoid."""
|
|
for report_id, expected in (("", ""), (".", "."), ("..", "..")):
|
|
with self.subTest(report_id=report_id):
|
|
self.assertRegex(
|
|
_aggregate_key_segment(report_id),
|
|
r"\A" + re.escape(expected) + _HASH_SUFFIX + r"\Z",
|
|
)
|
|
|
|
def test_oversized_report_id_key_within_1024_bytes(self):
|
|
"""A 5,000-character multibyte report ID under a long bucket_path
|
|
is shortened between whole characters so the key fits in 1,024
|
|
bytes, the hash suffix is intact, and no %XX triplet or UTF-8
|
|
sequence is split."""
|
|
report = _sample_aggregate_report()
|
|
report_id = "€" * 5000 # 3 UTF-8 bytes -> 9 encoded characters
|
|
report["report_metadata"]["report_id"] = report_id
|
|
key = _aggregate_key(report, bucket_path="p" * 300)
|
|
self.assertLessEqual(len(key.encode("utf-8")), 1024)
|
|
# Only a partial character's worth of room may be left unused.
|
|
self.assertGreater(len(key.encode("utf-8")), 1024 - 9)
|
|
segment = key.rsplit("/", 1)[1]
|
|
self.assertRegex(segment, r"\A(%E2%82%AC)+" + _HASH_SUFFIX + r"\Z")
|
|
decoded = unquote(segment.rsplit("_", 1)[0], errors="strict")
|
|
self.assertTrue(report_id.startswith(decoded))
|
|
|
|
def test_oversized_tilde_report_id_counts_encoded_length(self):
|
|
""" "~" grows to "%7E" after quote() returns, and the shortening
|
|
counts the encoded length, so the key still fits in 1,024 bytes
|
|
and ends on a whole triplet."""
|
|
report = _sample_aggregate_report()
|
|
report["report_metadata"]["report_id"] = "~" * 2000
|
|
key = _aggregate_key(report)
|
|
self.assertLessEqual(len(key.encode("utf-8")), 1024)
|
|
self.assertGreater(len(key.encode("utf-8")), 1024 - 3)
|
|
self.assertRegex(key.rsplit("/", 1)[1], r"\A(%7E)+" + _HASH_SUFFIX + r"\Z")
|
|
|
|
def test_bucket_path_longer_than_key_limit_leaves_id_empty(self):
|
|
"""If bucket_path alone uses up the 1,024 bytes, the report ID is
|
|
dropped rather than sliced with a negative length; the hash still
|
|
identifies the report and S3 reports the over-long key."""
|
|
key = _aggregate_key(_sample_aggregate_report(), bucket_path="p" * 1100)
|
|
self.assertRegex(key, r"/year=2024/month=01/day=15/" + _HASH_SUFFIX + r"\Z")
|
|
|
|
|
|
class TestS3ClientMetadataSanitization(unittest.TestCase):
|
|
"""S3 user-defined metadata must be ASCII (botocore rejects anything
|
|
else before sending) and is limited to 2 KB in total, counted as the
|
|
UTF-8 bytes of every key and value
|
|
(https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html).
|
|
Values are percent-encoded so they round-trip through unquote()."""
|
|
|
|
def _stubbed_client(self):
|
|
"""A real S3Client whose boto3 client is stubbed, so botocore's
|
|
own parameter handlers, including the ASCII metadata check, run."""
|
|
client = S3Client(
|
|
bucket_name="b",
|
|
bucket_path="dmarc",
|
|
region_name="us-east-1",
|
|
endpoint_url="https://s3.example.com",
|
|
access_key_id="k",
|
|
secret_access_key="s",
|
|
)
|
|
self.addCleanup(client.close)
|
|
meta = client.s3.meta
|
|
assert meta is not None
|
|
stubber = Stubber(meta.client)
|
|
stubber.activate()
|
|
self.addCleanup(stubber.deactivate)
|
|
return client, stubber
|
|
|
|
def test_metadata_non_ascii_org_name_accepted_by_botocore(self):
|
|
client, stubber = self._stubbed_client()
|
|
report = _sample_aggregate_report()
|
|
report["report_metadata"]["org_name"] = "Ünïcode Org"
|
|
stubber.add_response(
|
|
"put_object",
|
|
{},
|
|
expected_params={
|
|
"Bucket": "b",
|
|
"Key": ANY,
|
|
"Body": ANY,
|
|
"Metadata": {
|
|
"org_name": "%C3%9Cn%C3%AFcode Org",
|
|
"org_email": "dmarc@example.com",
|
|
"report_id": "agg-123",
|
|
"begin_date": "2024-01-15 00:00:00",
|
|
"end_date": "2024-01-16 00:00:00",
|
|
},
|
|
},
|
|
)
|
|
client.save_aggregate_report_to_s3(report)
|
|
stubber.assert_no_pending_responses()
|
|
self.assertEqual(unquote("%C3%9Cn%C3%AFcode Org"), "Ünïcode Org")
|
|
|
|
# The SMTP TLS path goes through the same encoding.
|
|
tls_report = _sample_smtp_tls_report()
|
|
tls_report["organization_name"] = "Ünïcode Org"
|
|
stubber.add_response(
|
|
"put_object",
|
|
{},
|
|
expected_params={
|
|
"Bucket": "b",
|
|
"Key": ANY,
|
|
"Body": ANY,
|
|
"Metadata": {
|
|
"org_name": "%C3%9Cn%C3%AFcode Org",
|
|
"report_id": "tls-456",
|
|
"begin_date": "2024-02-03T00:00:00Z",
|
|
"end_date": "2024-02-04T00:00:00Z",
|
|
},
|
|
},
|
|
)
|
|
client.save_smtp_tls_report_to_s3(tls_report)
|
|
stubber.assert_no_pending_responses()
|
|
|
|
# The validator is live under the Stubber: the raw value that the
|
|
# unfixed code sent is rejected before any request is made.
|
|
stubber.add_response("put_object", {})
|
|
with self.assertRaises(ParamValidationError):
|
|
client.bucket.put_object(
|
|
Body="{}", Key="k.json", Metadata={"org_name": "Ünïcode Org"}
|
|
)
|
|
|
|
def test_metadata_crlf_and_percent_round_trip(self):
|
|
client = _client_with_mock_bucket()
|
|
report = _sample_aggregate_report()
|
|
org_name = "Org\r\nX-Injected: 1 100%"
|
|
report["report_metadata"]["org_name"] = org_name
|
|
client.save_aggregate_report_to_s3(report)
|
|
value = _saved_metadata(client)["org_name"]
|
|
self.assertEqual(value, "Org%0D%0AX-Injected: 1 100%25")
|
|
self.assertEqual(unquote(value), org_name)
|
|
|
|
def test_metadata_values_capped_total_under_2kb(self):
|
|
"""Each value is at most 256 characters, cut between whole
|
|
characters, so the metadata sent stays under 2 KB, counting the
|
|
x-amz-meta- header names."""
|
|
client = _client_with_mock_bucket()
|
|
report = _sample_aggregate_report()
|
|
# begin_date is parsed for the key, so it stays a real date.
|
|
for field in ("org_name", "org_email", "report_id"):
|
|
report["report_metadata"][field] = "x" * 10_000
|
|
# 255 ASCII characters, then one that encodes to 6: it must be
|
|
# dropped whole, not cut to "%C3" or "%C3%9".
|
|
report["report_metadata"]["end_date"] = "a" * 255 + "Ü"
|
|
client.save_aggregate_report_to_s3(report)
|
|
meta = _saved_metadata(client)
|
|
self.assertEqual(meta["end_date"], "a" * 255)
|
|
for value in meta.values():
|
|
self.assertLessEqual(len(value), 256)
|
|
total = sum(
|
|
len(f"x-amz-meta-{k}".encode("utf-8")) + len(v.encode("utf-8"))
|
|
for k, v in meta.items()
|
|
)
|
|
self.assertLess(total, 2048)
|
|
|
|
def test_smtp_tls_lone_surrogate_does_not_raise(self):
|
|
"""A lone surrogate has no UTF-8 encoding; it becomes "?" instead
|
|
of raising UnicodeEncodeError and failing the batch."""
|
|
client = _client_with_mock_bucket()
|
|
report = _sample_smtp_tls_report()
|
|
report["organization_name"] = "Org\ud800"
|
|
report["report_id"] = "id\udfff"
|
|
client.save_smtp_tls_report_to_s3(report)
|
|
self.assertEqual(_saved_metadata(client)["org_name"], "Org?")
|
|
self.assertEqual(_saved_metadata(client)["report_id"], "id?")
|
|
self.assertRegex(_saved_key(client), r"/id\?" + _HASH_SUFFIX + r"\Z")
|
|
|
|
|
|
class TestS3ClientFailureReports(unittest.TestCase):
|
|
"""Failure reports (FailureReport in parsedmarc/types.py) have no
|
|
report_metadata and no report ID. Saving one used to raise KeyError
|
|
on report_metadata, so no failure report was ever saved to S3."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
parsed = parsedmarc.parse_report_file(
|
|
os.path.join(_SAMPLES_DIR, "failure", "dmarc_ruf_report_linkedin.eml"),
|
|
offline=True,
|
|
)
|
|
assert parsed["report_type"] == "failure"
|
|
cls.report = parsed["report"]
|
|
|
|
def _save(self, report):
|
|
client = _client_with_mock_bucket()
|
|
client.save_failure_report_to_s3(report)
|
|
return client
|
|
|
|
def test_failure_report_key_from_arrival_date_and_reported_domain(self):
|
|
client = self._save(copy.deepcopy(self.report))
|
|
key = _saved_key(client)
|
|
self.assertRegex(
|
|
key,
|
|
r"\Admarc/failure/year=2019/month=04/day=30/example\.com"
|
|
+ _HASH_SUFFIX
|
|
+ r"\Z",
|
|
)
|
|
self.assertEqual(
|
|
_saved_metadata(client),
|
|
{
|
|
"reported_domain": "example.com",
|
|
"arrival_date": "2019-04-30 02:09:00",
|
|
},
|
|
)
|
|
body = json.loads(client.bucket.put_object.call_args.kwargs["Body"])
|
|
self.assertEqual(body["reported_domain"], "example.com")
|
|
|
|
# A different sample is a different report.
|
|
other = copy.deepcopy(self.report)
|
|
other["sample"] = other["sample"] + "\n"
|
|
self.assertNotEqual(_saved_key(self._save(other)), key)
|
|
|
|
# Different enrichment of the same report is the same report.
|
|
enriched = copy.deepcopy(self.report)
|
|
enriched["source"]["reverse_dns"] = "mail.example.net"
|
|
enriched["source"]["country"] = "US"
|
|
self.assertEqual(_saved_key(self._save(enriched)), key)
|
|
|
|
|
|
class TestS3ClientClose(unittest.TestCase):
|
|
"""close() must release the underlying boto3 client; a slow leak
|
|
here matters for long-running watch-mode processes."""
|
|
|
|
def test_close_calls_underlying_client_close(self):
|
|
with patch("parsedmarc.s3.boto3.resource") as mock_resource:
|
|
client = S3Client(
|
|
bucket_name="b",
|
|
bucket_path="p",
|
|
region_name="r",
|
|
endpoint_url="https://s3.example.com",
|
|
access_key_id="k",
|
|
secret_access_key="s",
|
|
)
|
|
client.close()
|
|
mock_resource.return_value.meta.client.close.assert_called_once()
|
|
|
|
def test_close_swallows_exceptions_from_underlying_client(self):
|
|
"""close() is called during shutdown/reload; if boto3 raises
|
|
from the close path, we don't want it to propagate and prevent
|
|
clean exit. The except is defensive but deliberate."""
|
|
with patch("parsedmarc.s3.boto3.resource") as mock_resource:
|
|
mock_resource.return_value.meta.client.close.side_effect = RuntimeError(
|
|
"boom"
|
|
)
|
|
client = S3Client(
|
|
bucket_name="b",
|
|
bucket_path="p",
|
|
region_name="r",
|
|
endpoint_url="https://s3.example.com",
|
|
access_key_id="k",
|
|
secret_access_key="s",
|
|
)
|
|
# Should not raise.
|
|
client.close()
|
|
|
|
|
|
class TestS3ClientBackwardCompatAlias(unittest.TestCase):
|
|
def test_forensic_alias_points_to_failure_method(self):
|
|
self.assertIs(
|
|
S3Client.save_forensic_report_to_s3,
|
|
S3Client.save_failure_report_to_s3,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|