diff --git a/README.md b/README.md index 3b7fb55..e584faa 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,13 @@ Coupa PO emails are received at `amazon_po@int.seahaven.com`, parsed by Claude H 1. Coupa sends a PO email (new, revision, or cancellation). 2. SES (`INBOUND_MAIL` rule set) drops the raw MIME into `s3://po-ingest-emails-{AccountId}/inbound/`. 3. S3 `ObjectCreated` triggers the `po-email-processor` Lambda. -4. Claude extracts structured JSON (PO number, status, supplier, site code, trade classification, line items, fiscal year). -5. Conditional write to DynamoDB: +4. Fail-closed sender authentication (INFRA-107): the SES-stamped `Authentication-Results` header must show `dkim=pass` for `amazon.coupahost.com` (see [Sender authentication](#sender-authentication-infra-107)); otherwise the email is logged and dropped. +5. Claude extracts structured JSON (PO number, status, supplier, site code, trade classification, line items, fiscal year). +6. Conditional write to DynamoDB: - `new_po` — idempotent insert (no-op if PO exists) - `revision` — unconditional overwrite - `cancellation` — marks existing row `Cancelled` -6. DynamoDB Streams feeds downstream consumers: +7. DynamoDB Streams feeds downstream consumers: - **LedgerFlow** (`seahaven-slack-bot/po-sync`) — daily KB sync - **Site extractor** (`po-ingest-site-extractor`) — real-time site address extraction into `verified-sites` table @@ -46,8 +47,9 @@ Amazon APM work order emails (from Hexagon EAM / HxGN SmartCloud) are received a 2. Gmail filter forwards APM emails to `apm@int.seahaven.com` (SES). 3. SES drops the raw MIME into `s3://workorder-ingest-emails-{AccountId}/inbound/`. 4. S3 triggers the `workorder-email-processor` Lambda. -5. Claude extracts structured JSON (work order ID, site code, severity, priority, dates, assigned technician). -6. Work order upserted to `WorkOrders`, event/comment appended to `WorkOrderComments`. +5. Fail-closed sender authentication (INFRA-107): the SES-stamped `Authentication-Results` header must show `dkim=pass` for `seahaven.com` (the Google Groups forward re-signs the mail; see [Sender authentication](#sender-authentication-infra-107)); otherwise the email is logged and dropped. +6. Claude extracts structured JSON (work order ID, site code, severity, priority, dates, assigned technician). +7. Work order upserted to `WorkOrders`, event/comment appended to `WorkOrderComments`. **Lambdas** (`lambdas/wo/`): | Function | Trigger | Purpose | @@ -71,6 +73,23 @@ All Lambdas: Python 3.12, ARM64, 60-day log retention. **SES:** Both stacks add rules to the shared `INBOUND_MAIL` receipt rule set on `int.seahaven.com`. +### Sender authentication (INFRA-107) + +The `From` header and any `Authentication-Results` header inside the raw MIME are attacker-forgeable, so neither is trusted. Instead, both email processors (`lambdas/*/email_processor/ses_auth.py`) authenticate the sender against the verdicts SES itself stamps at delivery time, failing closed: + +1. Take **only the topmost** `Authentication-Results` header (SES prepends its trace headers; any lower copies arrived inside the message and are ignored). +2. Require its authserv-id to be `amazonses.com`. +3. Require a `dkim=pass` clause whose `header.d=`/`header.i=` domain is in the pipeline's allowlist. + +The allowlist is the `ALLOWED_DKIM_DOMAINS` Lambda environment variable (comma-separated, set per stack in CDK — no code change needed to adjust): + +| Pipeline | `ALLOWED_DKIM_DOMAINS` | Why | +|---|---|---| +| Work orders | `seahaven.com` | APM mail arrives via the `apm@` Google Groups forward, which re-signs as `seahaven.com` (the original `hxgnsmartcloud.com` signature does not survive the forward) | +| Purchase orders | `amazon.coupahost.com` | Coupa signs as `amazon.coupahost.com`. `amazonses.com` also passes but is deliberately not allowlisted — every SES customer's mail passes for it | + +On any failure (env var unset, header missing/unparseable, verdict fail, unaligned domain) the processor logs a structured `sender_auth_rejected` warning with the reason and S3 key, skips the email, and returns normally — rejected mail never triggers Lambda retries or DLQ messages. Unit tests live in `tests/test_ses_auth.py`. + **Failure handling (INFRA-41):** Each email-processor is async-invoked (S3 → Lambda). Both have a CDK-managed SQS dead-letter queue (`dead_letter_queue=`, 14-day retention, SSL-enforced) so a failed parse is captured rather than silently dropped after Lambda's retries. ### CloudWatch alarms @@ -187,4 +206,7 @@ shared/ scripts/ reprocess.py backfill_sites.py +tests/ + conftest.py # AWS env stubs + per-pipeline module loader + test_ses_auth.py # Sender-authentication parser tests (INFRA-107) ``` diff --git a/cdk/po_stack.py b/cdk/po_stack.py index 4648842..42a1a30 100644 --- a/cdk/po_stack.py +++ b/cdk/po_stack.py @@ -174,7 +174,7 @@ class PoIngestStack(Stack): "-c", "pip install --platform manylinux2014_aarch64 --only-binary=:all: " "-r requirements.txt -t /asset-output && " - "cp handler.py /asset-output/", + "cp handler.py ses_auth.py /asset-output/", ], ), ), @@ -185,6 +185,14 @@ class PoIngestStack(Stack): environment={ "PO_TABLE": "purchase-orders", "ANTHROPIC_API_KEY_SECRET_ARN": anthropic_secret.secret_arn, + # Fail-closed sender auth (INFRA-107): the handler only + # accepts mail whose SES-stamped Authentication-Results + # header carries dkim=pass for one of these domains. + # Observed on live traffic 2026-07-15: Coupa PO mail passes + # DKIM for amazon.coupahost.com (and amazonses.com, which is + # deliberately NOT allowlisted — every SES customer's mail + # passes that). Unset/empty ⇒ the handler rejects all mail. + "ALLOWED_DKIM_DOMAINS": "amazon.coupahost.com", }, ) diff --git a/cdk/wo_stack.py b/cdk/wo_stack.py index fc8d166..c70a9e2 100644 --- a/cdk/wo_stack.py +++ b/cdk/wo_stack.py @@ -185,6 +185,15 @@ class WorkorderIngestStack(Stack): "WORK_ORDERS_TABLE": work_orders_table.table_name, "COMMENTS_TABLE": comments_table.table_name, "ANTHROPIC_API_KEY_SECRET_ARN": anthropic_secret.secret_arn, + # Fail-closed sender auth (INFRA-107): the handler only + # accepts mail whose SES-stamped Authentication-Results + # header carries dkim=pass for one of these domains. APM + # mail arrives via the apm@ Google Groups forward, which + # re-signs as seahaven.com (observed on live traffic + # 2026-07-15: "dkim=pass header.i=@seahaven.com"; the + # original hxgnsmartcloud.com signature does not survive + # the forward). Unset/empty ⇒ the handler rejects all mail. + "ALLOWED_DKIM_DOMAINS": "seahaven.com", }, ) diff --git a/lambdas/po/email_processor/handler.py b/lambdas/po/email_processor/handler.py index 9c5b620..1ea25bf 100644 --- a/lambdas/po/email_processor/handler.py +++ b/lambdas/po/email_processor/handler.py @@ -17,6 +17,7 @@ from email import policy import anthropic import boto3 +from ses_auth import authenticate_inbound_email logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -365,6 +366,13 @@ def handler(event, context): response = s3.get_object(Bucket=bucket, Key=key) raw_email = response["Body"].read() + # Fail-closed sender authentication (INFRA-107): only mail with an + # SES-stamped dkim=pass verdict for an allowlisted domain may create + # or update purchase orders. Rejected mail is logged and skipped + # without erroring the invocation (no retries / DLQ spam). + if not authenticate_inbound_email(raw_email, s3_key): + continue + email_data = parse_raw_email(raw_email) logger.info(f"Subject: {email_data['subject']}") diff --git a/lambdas/po/email_processor/ses_auth.py b/lambdas/po/email_processor/ses_auth.py new file mode 100644 index 0000000..6ad786b --- /dev/null +++ b/lambdas/po/email_processor/ses_auth.py @@ -0,0 +1,164 @@ +"""Fail-closed SES sender authentication (INFRA-107). + +SES Email Receiving *prepends* its own trace headers -- including an +``Authentication-Results`` header whose authserv-id is ``amazonses.com`` -- +to the top of the raw MIME it writes to S3. Everything below those +prepended headers (the From header, any additional Authentication-Results +copies) is attacker-controlled, so ONLY the topmost Authentication-Results +header is trusted, and only when its authserv-id is ``amazonses.com``. + +An email is accepted only when that header carries ``dkim=pass`` for a +domain in the ``ALLOWED_DKIM_DOMAINS`` allowlist (a comma-separated Lambda +environment variable set by the CDK stack). Every other outcome fails +closed and the email is rejected: + +- ``ALLOWED_DKIM_DOMAINS`` unset or empty +- no Authentication-Results header at all +- topmost header unparseable or from an authserv-id other than SES +- no ``dkim=pass`` clause +- ``dkim=pass`` only for domains outside the allowlist + +Observed SES format (2026-07-15, both ingest buckets):: + + Authentication-Results: amazonses.com; + spf=pass (spfCheck: ...) client-ip=...; envelope-from=...; helo=...; + dkim=pass header.i=@seahaven.com; + dmarc=none header.from=hxgnsmartcloud.com; + +Note SES reports the passing DKIM identity as ``header.i=@`` +(RFC 6376 AUID), not ``header.d=``; the parser accepts both. +""" + +import email.parser +import json +import logging +import os +import re + +logger = logging.getLogger() + +ALLOWED_DKIM_DOMAINS_ENV = "ALLOWED_DKIM_DOMAINS" +SES_AUTHSERV_ID = "amazonses.com" + +# One resinfo clause of an Authentication-Results value, e.g. +# "dkim=pass header.i=@seahaven.com". The clause must START with the +# method=result pair; header.d= / header.i= may appear anywhere after it. +_DKIM_RESULT_RE = re.compile(r"^dkim\s*=\s*([a-z0-9]+)", re.IGNORECASE) +_HEADER_D_RE = re.compile(r"header\.d\s*=\s*\"?([^\s\";]+)", re.IGNORECASE) +_HEADER_I_RE = re.compile(r"header\.i\s*=\s*\"?([^\s\";]+)", re.IGNORECASE) + + +def get_allowed_dkim_domains() -> frozenset: + """Read the DKIM-domain allowlist from the environment (may be empty).""" + raw = os.environ.get(ALLOWED_DKIM_DOMAINS_ENV, "") + return frozenset(d.strip().lower().lstrip("@") for d in raw.split(",") if d.strip()) + + +def _unfold(value: str) -> str: + """Collapse RFC 5322 folding whitespace into single spaces.""" + return re.sub(r"[\r\n\t ]+", " ", value).strip() + + +def parse_authentication_results(value: str) -> tuple: + """Parse one Authentication-Results header value. + + Returns ``(authserv_id, passing_dkim_domains)`` where the domains are + the lowercased d=/i= domains of every ``dkim=pass`` clause. Malformed + input yields ``("", frozenset())``, which callers treat as a rejection. + """ + text = _unfold(value) + clauses = [c.strip() for c in text.split(";")] + if not clauses or not clauses[0]: + return "", frozenset() + + # First clause is the authserv-id, optionally followed by a version + # token ("amazonses.com 1"); take only the first token. + authserv_id = clauses[0].split()[0].strip('"').lower() + + passing = set() + for clause in clauses[1:]: + match = _DKIM_RESULT_RE.match(clause) + if not match or match.group(1).lower() != "pass": + continue + d_match = _HEADER_D_RE.search(clause) + if d_match: + passing.add(d_match.group(1).lower().rstrip(".")) + continue + i_match = _HEADER_I_RE.search(clause) + if i_match: + # AUID (header.i) is "local-part@domain" or "@domain"; + # the signing domain is everything after the last "@". + identity = i_match.group(1).lower().rstrip(".") + domain = identity.rsplit("@", 1)[-1] + if domain: + passing.add(domain) + return authserv_id, frozenset(passing) + + +def evaluate_sender_authentication(raw_email: bytes, allowed_domains) -> tuple: + """Evaluate the SES-stamped verdicts in a raw MIME message. + + Returns ``(accepted, reason, detail)``. Pure function of its inputs so + it can be unit-tested without touching the environment. + """ + if not allowed_domains: + return False, "allowlist_not_configured", {} + + try: + # compat32 keeps header values as raw strings (we unfold ourselves) + # and never raises on structurally odd headers; headersonly avoids + # parsing the body at all. + msg = email.parser.BytesParser().parsebytes(raw_email, headersonly=True) + except Exception: + return False, "unparseable_message", {} + + ar_headers = msg.get_all("Authentication-Results") or [] + if not ar_headers: + return False, "authentication_results_missing", {} + + # SES prepends its trace headers, so index 0 is the SES-stamped copy. + # Any Authentication-Results header further down arrived inside the + # message (attacker-suppliable) and is deliberately ignored. + authserv_id, passing = parse_authentication_results(str(ar_headers[0])) + detail = { + "authserv_id": authserv_id, + "passing_dkim_domains": sorted(passing), + } + + if authserv_id != SES_AUTHSERV_ID: + return False, "untrusted_authserv_id", detail + if not passing: + return False, "no_passing_dkim_signature", detail + + matched = passing & set(allowed_domains) + if not matched: + return False, "dkim_domain_not_allowlisted", detail + + detail["matched_domains"] = sorted(matched) + return True, "authenticated", detail + + +def authenticate_inbound_email(raw_email: bytes, s3_key: str) -> bool: + """Fail-closed gate used by the S3-triggered handlers. + + On rejection: logs a structured warning with the reason and S3 key and + returns False. Callers skip the message and return normally, so + rejected mail never errors the invocation (no retries, no DLQ spam). + """ + allowed = get_allowed_dkim_domains() + accepted, reason, detail = evaluate_sender_authentication(raw_email, allowed) + if accepted: + logger.info(json.dumps({"event": "sender_auth_ok", "s3_key": s3_key, **detail})) + return True + logger.warning( + json.dumps( + { + "event": "sender_auth_rejected", + "reason": reason, + "s3_key": s3_key, + "allowed_dkim_domains": sorted(allowed), + **detail, + } + ) + ) + return False diff --git a/lambdas/wo/email_processor/handler.py b/lambdas/wo/email_processor/handler.py index 2abdc8f..8faf9d0 100644 --- a/lambdas/wo/email_processor/handler.py +++ b/lambdas/wo/email_processor/handler.py @@ -16,6 +16,7 @@ from email import policy import anthropic import boto3 +from ses_auth import authenticate_inbound_email logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -239,13 +240,21 @@ def handler(event, context): for record in event.get("Records", []): bucket = record["s3"]["bucket"]["name"] key = record["s3"]["object"]["key"] + s3_key = f"s3://{bucket}/{key}" - logger.info(f"Processing email: s3://{bucket}/{key}") + logger.info(f"Processing email: {s3_key}") # Fetch raw email from S3 response = s3.get_object(Bucket=bucket, Key=key) raw_email = response["Body"].read() + # Fail-closed sender authentication (INFRA-107): only mail with an + # SES-stamped dkim=pass verdict for an allowlisted domain may create + # or update work orders. Rejected mail is logged and skipped without + # erroring the invocation (no retries / DLQ spam). + if not authenticate_inbound_email(raw_email, s3_key): + continue + # Parse the raw email email_data = parse_raw_email(raw_email) logger.info(f"Subject: {email_data['subject']}") @@ -260,8 +269,6 @@ def handler(event, context): logger.warning(f"No work order ID found in email, skipping: {key}") continue - s3_key = f"s3://{bucket}/{key}" - # Always upsert the work order with any new info save_work_order(parsed, s3_key) diff --git a/lambdas/wo/email_processor/ses_auth.py b/lambdas/wo/email_processor/ses_auth.py new file mode 100644 index 0000000..6ad786b --- /dev/null +++ b/lambdas/wo/email_processor/ses_auth.py @@ -0,0 +1,164 @@ +"""Fail-closed SES sender authentication (INFRA-107). + +SES Email Receiving *prepends* its own trace headers -- including an +``Authentication-Results`` header whose authserv-id is ``amazonses.com`` -- +to the top of the raw MIME it writes to S3. Everything below those +prepended headers (the From header, any additional Authentication-Results +copies) is attacker-controlled, so ONLY the topmost Authentication-Results +header is trusted, and only when its authserv-id is ``amazonses.com``. + +An email is accepted only when that header carries ``dkim=pass`` for a +domain in the ``ALLOWED_DKIM_DOMAINS`` allowlist (a comma-separated Lambda +environment variable set by the CDK stack). Every other outcome fails +closed and the email is rejected: + +- ``ALLOWED_DKIM_DOMAINS`` unset or empty +- no Authentication-Results header at all +- topmost header unparseable or from an authserv-id other than SES +- no ``dkim=pass`` clause +- ``dkim=pass`` only for domains outside the allowlist + +Observed SES format (2026-07-15, both ingest buckets):: + + Authentication-Results: amazonses.com; + spf=pass (spfCheck: ...) client-ip=...; envelope-from=...; helo=...; + dkim=pass header.i=@seahaven.com; + dmarc=none header.from=hxgnsmartcloud.com; + +Note SES reports the passing DKIM identity as ``header.i=@`` +(RFC 6376 AUID), not ``header.d=``; the parser accepts both. +""" + +import email.parser +import json +import logging +import os +import re + +logger = logging.getLogger() + +ALLOWED_DKIM_DOMAINS_ENV = "ALLOWED_DKIM_DOMAINS" +SES_AUTHSERV_ID = "amazonses.com" + +# One resinfo clause of an Authentication-Results value, e.g. +# "dkim=pass header.i=@seahaven.com". The clause must START with the +# method=result pair; header.d= / header.i= may appear anywhere after it. +_DKIM_RESULT_RE = re.compile(r"^dkim\s*=\s*([a-z0-9]+)", re.IGNORECASE) +_HEADER_D_RE = re.compile(r"header\.d\s*=\s*\"?([^\s\";]+)", re.IGNORECASE) +_HEADER_I_RE = re.compile(r"header\.i\s*=\s*\"?([^\s\";]+)", re.IGNORECASE) + + +def get_allowed_dkim_domains() -> frozenset: + """Read the DKIM-domain allowlist from the environment (may be empty).""" + raw = os.environ.get(ALLOWED_DKIM_DOMAINS_ENV, "") + return frozenset(d.strip().lower().lstrip("@") for d in raw.split(",") if d.strip()) + + +def _unfold(value: str) -> str: + """Collapse RFC 5322 folding whitespace into single spaces.""" + return re.sub(r"[\r\n\t ]+", " ", value).strip() + + +def parse_authentication_results(value: str) -> tuple: + """Parse one Authentication-Results header value. + + Returns ``(authserv_id, passing_dkim_domains)`` where the domains are + the lowercased d=/i= domains of every ``dkim=pass`` clause. Malformed + input yields ``("", frozenset())``, which callers treat as a rejection. + """ + text = _unfold(value) + clauses = [c.strip() for c in text.split(";")] + if not clauses or not clauses[0]: + return "", frozenset() + + # First clause is the authserv-id, optionally followed by a version + # token ("amazonses.com 1"); take only the first token. + authserv_id = clauses[0].split()[0].strip('"').lower() + + passing = set() + for clause in clauses[1:]: + match = _DKIM_RESULT_RE.match(clause) + if not match or match.group(1).lower() != "pass": + continue + d_match = _HEADER_D_RE.search(clause) + if d_match: + passing.add(d_match.group(1).lower().rstrip(".")) + continue + i_match = _HEADER_I_RE.search(clause) + if i_match: + # AUID (header.i) is "local-part@domain" or "@domain"; + # the signing domain is everything after the last "@". + identity = i_match.group(1).lower().rstrip(".") + domain = identity.rsplit("@", 1)[-1] + if domain: + passing.add(domain) + return authserv_id, frozenset(passing) + + +def evaluate_sender_authentication(raw_email: bytes, allowed_domains) -> tuple: + """Evaluate the SES-stamped verdicts in a raw MIME message. + + Returns ``(accepted, reason, detail)``. Pure function of its inputs so + it can be unit-tested without touching the environment. + """ + if not allowed_domains: + return False, "allowlist_not_configured", {} + + try: + # compat32 keeps header values as raw strings (we unfold ourselves) + # and never raises on structurally odd headers; headersonly avoids + # parsing the body at all. + msg = email.parser.BytesParser().parsebytes(raw_email, headersonly=True) + except Exception: + return False, "unparseable_message", {} + + ar_headers = msg.get_all("Authentication-Results") or [] + if not ar_headers: + return False, "authentication_results_missing", {} + + # SES prepends its trace headers, so index 0 is the SES-stamped copy. + # Any Authentication-Results header further down arrived inside the + # message (attacker-suppliable) and is deliberately ignored. + authserv_id, passing = parse_authentication_results(str(ar_headers[0])) + detail = { + "authserv_id": authserv_id, + "passing_dkim_domains": sorted(passing), + } + + if authserv_id != SES_AUTHSERV_ID: + return False, "untrusted_authserv_id", detail + if not passing: + return False, "no_passing_dkim_signature", detail + + matched = passing & set(allowed_domains) + if not matched: + return False, "dkim_domain_not_allowlisted", detail + + detail["matched_domains"] = sorted(matched) + return True, "authenticated", detail + + +def authenticate_inbound_email(raw_email: bytes, s3_key: str) -> bool: + """Fail-closed gate used by the S3-triggered handlers. + + On rejection: logs a structured warning with the reason and S3 key and + returns False. Callers skip the message and return normally, so + rejected mail never errors the invocation (no retries, no DLQ spam). + """ + allowed = get_allowed_dkim_domains() + accepted, reason, detail = evaluate_sender_authentication(raw_email, allowed) + if accepted: + logger.info(json.dumps({"event": "sender_auth_ok", "s3_key": s3_key, **detail})) + return True + logger.warning( + json.dumps( + { + "event": "sender_auth_rejected", + "reason": reason, + "s3_key": s3_key, + "allowed_dkim_domains": sorted(allowed), + **detail, + } + ) + ) + return False diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bee1c5e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,41 @@ +"""Shared test fixtures. + +The Lambda handlers create boto3 clients at import time, so a region and +dummy credentials must be in the environment before any handler module is +imported. Setting them here (conftest runs before test collection imports +anything) keeps every test hermetic — no real AWS calls can succeed with +these values. +""" + +import importlib.util +import os +import sys +from pathlib import Path + +import pytest + +os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1") +os.environ.setdefault("AWS_ACCESS_KEY_ID", "testing") +os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "testing") +os.environ.setdefault("AWS_SESSION_TOKEN", "testing") + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def load_module(name: str, path: Path): + """Import a module from an explicit file path under a unique name.""" + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(params=["wo", "po"]) +def ses_auth(request): + """The ses_auth module of each pipeline (duplicated file, kept in sync).""" + pipeline = request.param + return load_module( + f"{pipeline}_ses_auth", + REPO_ROOT / "lambdas" / pipeline / "email_processor" / "ses_auth.py", + ) diff --git a/tests/test_ses_auth.py b/tests/test_ses_auth.py new file mode 100644 index 0000000..8c3337b --- /dev/null +++ b/tests/test_ses_auth.py @@ -0,0 +1,206 @@ +"""Unit tests for the fail-closed SES sender authentication (INFRA-107). + +Fixtures mirror real SES-stamped headers observed on the two ingest +buckets on 2026-07-15: SES prepends a folded Authentication-Results +header with authserv-id amazonses.com and reports passing signers as +``dkim=pass header.i=@``. +""" + +BODY = "\r\n\r\nWork Order 12345 assigned.\r\n" + +# Folded exactly like real SES output (continuation lines, header.i form). +WO_SES_HEADER = ( + "Authentication-Results: amazonses.com;\r\n" + " spf=pass (spfCheck: domain of seahaven.com designates 209.85.219.70 as" + " permitted sender) client-ip=209.85.219.70;" + " envelope-from=apm+bnc@seahaven.com; helo=mail-qv1-f70.google.com;\r\n" + " dkim=pass header.i=@seahaven.com;\r\n" + " dmarc=none header.from=hxgnsmartcloud.com;\r\n" +) + +# Real PO traffic carries two dkim=pass clauses; amazonses.com must not be +# sufficient on its own (every SES customer's mail passes for it). +PO_SES_HEADER = ( + "Authentication-Results: amazonses.com;\r\n" + " spf=pass (spfCheck: domain of mail.coupahost.com designates" + " 54.240.41.238 as permitted sender) client-ip=54.240.41.238;\r\n" + " dkim=pass header.i=@amazonses.com;\r\n" + " dkim=pass header.i=@amazon.coupahost.com;\r\n" + " dmarc=pass header.from=amazon.coupahost.com;\r\n" +) + +FROM_TO = ( + "From: APM \r\n" + "To: apm@int.seahaven.com\r\n" + "Subject: WO 12345\r\n" +) + + +def raw(*headers: str) -> bytes: + return ("".join(headers) + FROM_TO + BODY).encode() + + +class TestParseAuthenticationResults: + def test_ses_wo_header(self, ses_auth): + value = WO_SES_HEADER.split(":", 1)[1] + authserv_id, passing = ses_auth.parse_authentication_results(value) + assert authserv_id == "amazonses.com" + assert passing == frozenset({"seahaven.com"}) + + def test_ses_po_header_multiple_dkim_clauses(self, ses_auth): + value = PO_SES_HEADER.split(":", 1)[1] + authserv_id, passing = ses_auth.parse_authentication_results(value) + assert authserv_id == "amazonses.com" + assert passing == frozenset({"amazonses.com", "amazon.coupahost.com"}) + + def test_header_d_form(self, ses_auth): + _, passing = ses_auth.parse_authentication_results( + "amazonses.com; dkim=pass header.d=Example.COM." + ) + assert passing == frozenset({"example.com"}) + + def test_case_insensitive_result(self, ses_auth): + _, passing = ses_auth.parse_authentication_results( + "amazonses.com; DKIM=Pass HEADER.I=@SeaHaven.COM" + ) + assert passing == frozenset({"seahaven.com"}) + + def test_dkim_fail_yields_no_domains(self, ses_auth): + _, passing = ses_auth.parse_authentication_results( + "amazonses.com; dkim=fail header.i=@seahaven.com" + ) + assert passing == frozenset() + + def test_authserv_id_version_token(self, ses_auth): + authserv_id, _ = ses_auth.parse_authentication_results( + "amazonses.com 1; dkim=pass header.i=@seahaven.com" + ) + assert authserv_id == "amazonses.com" + + def test_garbage_value(self, ses_auth): + authserv_id, passing = ses_auth.parse_authentication_results(";;;") + assert authserv_id == "" + assert passing == frozenset() + + +class TestEvaluateSenderAuthentication: + def test_ses_stamped_pass_accepted(self, ses_auth): + accepted, reason, detail = ses_auth.evaluate_sender_authentication( + raw(WO_SES_HEADER), {"seahaven.com"} + ) + assert accepted + assert reason == "authenticated" + assert detail["matched_domains"] == ["seahaven.com"] + + def test_po_pass_accepted_on_coupa_domain(self, ses_auth): + accepted, reason, _ = ses_auth.evaluate_sender_authentication( + raw(PO_SES_HEADER), {"amazon.coupahost.com"} + ) + assert accepted + assert reason == "authenticated" + + def test_amazonses_identity_alone_is_not_allowlisted(self, ses_auth): + # Any SES customer's outbound mail passes DKIM for amazonses.com, + # so a pass for it must not satisfy a coupahost-only allowlist. + forged_via_ses = ( + "Authentication-Results: amazonses.com;\r\n" + " spf=pass client-ip=54.240.41.1;\r\n" + " dkim=pass header.i=@amazonses.com;\r\n" + ) + accepted, reason, _ = ses_auth.evaluate_sender_authentication( + raw(forged_via_ses), {"amazon.coupahost.com"} + ) + assert not accepted + assert reason == "dkim_domain_not_allowlisted" + + def test_forged_ar_below_failing_ses_header_rejected(self, ses_auth): + # SES's (topmost) header says dkim=fail; the attacker smuggled a + # perfect-looking AR header inside the message. Only the topmost + # header may be consulted. + ses_fail = ( + "Authentication-Results: amazonses.com;\r\n" + " spf=fail client-ip=203.0.113.7;\r\n" + " dkim=fail header.i=@seahaven.com;\r\n" + " dmarc=fail header.from=hxgnsmartcloud.com;\r\n" + ) + forged = ( + "Authentication-Results: amazonses.com;\r\n" + " dkim=pass header.i=@seahaven.com;\r\n" + ) + accepted, reason, _ = ses_auth.evaluate_sender_authentication( + raw(ses_fail, forged), {"seahaven.com"} + ) + assert not accepted + assert reason == "no_passing_dkim_signature" + + def test_missing_ar_header_rejected(self, ses_auth): + accepted, reason, _ = ses_auth.evaluate_sender_authentication( + raw(), {"seahaven.com"} + ) + assert not accepted + assert reason == "authentication_results_missing" + + def test_untrusted_authserv_id_rejected(self, ses_auth): + attacker_ar = ( + "Authentication-Results: mail.attacker.example;\r\n" + " dkim=pass header.i=@seahaven.com;\r\n" + ) + accepted, reason, _ = ses_auth.evaluate_sender_authentication( + raw(attacker_ar), {"seahaven.com"} + ) + assert not accepted + assert reason == "untrusted_authserv_id" + + def test_unaligned_domain_rejected(self, ses_auth): + evil = ( + "Authentication-Results: amazonses.com;\r\n" + " dkim=pass header.i=@evil.example.com;\r\n" + ) + accepted, reason, _ = ses_auth.evaluate_sender_authentication( + raw(evil), {"seahaven.com"} + ) + assert not accepted + assert reason == "dkim_domain_not_allowlisted" + + def test_lookalike_domain_rejected(self, ses_auth): + # Substring containment must not match: notseahaven.com != seahaven.com + lookalike = ( + "Authentication-Results: amazonses.com;\r\n" + " dkim=pass header.i=@notseahaven.com;\r\n" + ) + accepted, _, _ = ses_auth.evaluate_sender_authentication( + raw(lookalike), {"seahaven.com"} + ) + assert not accepted + + def test_empty_allowlist_fails_closed(self, ses_auth): + accepted, reason, _ = ses_auth.evaluate_sender_authentication( + raw(WO_SES_HEADER), frozenset() + ) + assert not accepted + assert reason == "allowlist_not_configured" + + +class TestAuthenticateInboundEmail: + S3_KEY = "s3://bucket/inbound/abc123" + + def test_accepts_with_configured_allowlist(self, ses_auth, monkeypatch): + monkeypatch.setenv("ALLOWED_DKIM_DOMAINS", "seahaven.com") + assert ses_auth.authenticate_inbound_email(raw(WO_SES_HEADER), self.S3_KEY) + + def test_allowlist_is_comma_separated_and_normalized(self, ses_auth, monkeypatch): + monkeypatch.setenv("ALLOWED_DKIM_DOMAINS", " Other.Example , @SEAHAVEN.com ,") + assert ses_auth.authenticate_inbound_email(raw(WO_SES_HEADER), self.S3_KEY) + + def test_env_var_unset_fails_closed(self, ses_auth, monkeypatch, caplog): + monkeypatch.delenv("ALLOWED_DKIM_DOMAINS", raising=False) + assert not ses_auth.authenticate_inbound_email(raw(WO_SES_HEADER), self.S3_KEY) + assert "allowlist_not_configured" in caplog.text + assert self.S3_KEY in caplog.text + + def test_rejection_logs_reason_and_key(self, ses_auth, monkeypatch, caplog): + monkeypatch.setenv("ALLOWED_DKIM_DOMAINS", "seahaven.com") + assert not ses_auth.authenticate_inbound_email(raw(), self.S3_KEY) + assert "sender_auth_rejected" in caplog.text + assert "authentication_results_missing" in caplog.text + assert self.S3_KEY in caplog.text