mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-11-07 09:19:22 +00:00
Merge commit from fork
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>
This commit is contained in:
1 parent
7908907f2c
commit
7d4661c2af
4 files changed
+647
-57
No files matched your search
+456
-42
@@ -1,11 +1,23 @@
|
||||
"""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."""
|
||||
@@ -79,44 +91,59 @@ class TestS3ClientInit(unittest.TestCase):
|
||||
self.assertEqual(client.bucket, "bucket-handle")
|
||||
|
||||
|
||||
class TestS3ClientSavePathsAndMetadata(unittest.TestCase):
|
||||
"""The S3 key is built from the report's begin_date and report_id.
|
||||
Wrong format = unfindable reports; wrong metadata filtering = secret
|
||||
leakage onto the S3 object."""
|
||||
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 _client_with_mock_bucket(self):
|
||||
with patch("parsedmarc.s3.boto3.resource"):
|
||||
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",
|
||||
)
|
||||
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>.json."""
|
||||
client = self._client_with_mock_bucket()
|
||||
<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()
|
||||
call = client.bucket.put_object.call_args
|
||||
self.assertEqual(
|
||||
call.kwargs["Key"],
|
||||
"dmarc/aggregate/year=2024/month=01/day=15/agg-123.json",
|
||||
self.assertRegex(
|
||||
_saved_key(client),
|
||||
r"\Admarc/aggregate/year=2024/month=01/day=15/agg-123"
|
||||
+ _HASH_SUFFIX
|
||||
+ r"\Z",
|
||||
)
|
||||
|
||||
def test_failure_dispatches_with_failure_in_key_path(self):
|
||||
client = self._client_with_mock_bucket()
|
||||
report = _sample_aggregate_report()
|
||||
report["report_metadata"]["report_id"] = "fail-789"
|
||||
client.save_failure_report_to_s3(report)
|
||||
key = client.bucket.put_object.call_args.kwargs["Key"]
|
||||
self.assertEqual(key, "dmarc/failure/year=2024/month=01/day=15/fail-789.json")
|
||||
|
||||
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.
|
||||
@@ -127,25 +154,33 @@ class TestS3ClientSavePathsAndMetadata(unittest.TestCase):
|
||||
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 = self._client_with_mock_bucket()
|
||||
client = _client_with_mock_bucket()
|
||||
client.save_smtp_tls_report_to_s3(_sample_smtp_tls_report())
|
||||
key = client.bucket.put_object.call_args.kwargs["Key"]
|
||||
self.assertEqual(key, "dmarc/smtp_tls/year=2024/month=02/day=03/tls-456.json")
|
||||
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 = self._client_with_mock_bucket()
|
||||
client = _client_with_mock_bucket()
|
||||
client.save_smtp_tls_report_to_s3(_sample_smtp_tls_report())
|
||||
meta = client.bucket.put_object.call_args.kwargs["Metadata"]
|
||||
self.assertEqual(meta["org_name"], "example.com")
|
||||
self.assertEqual(meta["report_id"], "tls-456")
|
||||
self.assertEqual(meta["begin_date"], "2024-02-03T00:00:00Z")
|
||||
self.assertEqual(meta["end_date"], "2024-02-04T00:00:00Z")
|
||||
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 = self._client_with_mock_bucket()
|
||||
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"]
|
||||
@@ -157,12 +192,12 @@ class TestS3ClientSavePathsAndMetadata(unittest.TestCase):
|
||||
"""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 = self._client_with_mock_bucket()
|
||||
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 = client.bucket.put_object.call_args.kwargs["Metadata"]
|
||||
meta = _saved_metadata(client)
|
||||
self.assertEqual(
|
||||
set(meta.keys()),
|
||||
{"org_name", "org_email", "report_id", "begin_date", "end_date"},
|
||||
@@ -171,6 +206,385 @@ class TestS3ClientSavePathsAndMetadata(unittest.TestCase):
|
||||
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."""
|
||||
|
||||
Reference in new issue
Block a user