diff --git a/lambdas/po/email_processor/handler.py b/lambdas/po/email_processor/handler.py index 9c5b620..ceb1f00 100644 --- a/lambdas/po/email_processor/handler.py +++ b/lambdas/po/email_processor/handler.py @@ -7,6 +7,7 @@ then writes the result to the purchase-orders DynamoDB table. """ import email +import email.utils import json import logging import os @@ -27,6 +28,21 @@ dynamodb = boto3.resource("dynamodb") PO_TABLE = os.environ.get("PO_TABLE", "purchase-orders") ANTHROPIC_API_KEY_SECRET_ARN = os.environ.get("ANTHROPIC_API_KEY_SECRET_ARN") +# Allowlist of sender domains permitted to create/modify POs. Coupa sends Amazon +# procurement notifications from the coupahost.com family; the leading dot means +# "this domain or any subdomain". Override via the ALLOWED_SENDER_DOMAINS env var +# (comma-separated) without a redeploy of code. An attacker who can email +# amazon_po@int.seahaven.com but cannot forge a verified sender in this list is +# rejected before any DynamoDB write. +DEFAULT_ALLOWED_SENDER_DOMAINS = "coupahost.com,amazon.com" +ALLOWED_SENDER_DOMAINS = [ + d.strip().lower().lstrip("@") + for d in os.environ.get( + "ALLOWED_SENDER_DOMAINS", DEFAULT_ALLOWED_SENDER_DOMAINS + ).split(",") + if d.strip() +] + EXTRACTION_PROMPT = """\ You are an email parser for a purchase order ingest pipeline. The emails are Coupa procurement platform notifications containing purchase order @@ -211,14 +227,68 @@ The Coupa commodity/category field if present in the email (e.g., "Maintenance - def get_anthropic_client() -> anthropic.Anthropic: - """Create Anthropic client, fetching API key from Secrets Manager.""" - if ANTHROPIC_API_KEY_SECRET_ARN: - secrets = boto3.client("secretsmanager") - secret = secrets.get_secret_value(SecretId=ANTHROPIC_API_KEY_SECRET_ARN) - api_key = secret["SecretString"] - return anthropic.Anthropic(api_key=api_key) - # Fall back to ANTHROPIC_API_KEY env var (for local testing) - return anthropic.Anthropic() + """Create Anthropic client, fetching API key from Secrets Manager. + + Requires ANTHROPIC_API_KEY_SECRET_ARN to be set. The previous silent + fallback to a plaintext ANTHROPIC_API_KEY env var is removed: a misconfigured + deploy must fail loudly rather than quietly run on an unmanaged key. + """ + if not ANTHROPIC_API_KEY_SECRET_ARN: + raise RuntimeError( + "ANTHROPIC_API_KEY_SECRET_ARN is not set; refusing to fall back to a " + "plaintext API key. Configure the Secrets Manager ARN." + ) + secrets = boto3.client("secretsmanager") + secret = secrets.get_secret_value(SecretId=ANTHROPIC_API_KEY_SECRET_ARN) + api_key = secret["SecretString"] + return anthropic.Anthropic(api_key=api_key) + + +def extract_sender_domain(sender: str) -> str | None: + """Extract the lowercased domain from a From header value. + + Handles "Name " and bare "user@domain" forms. + """ + if not sender: + return None + _, addr = email.utils.parseaddr(sender) + if "@" not in addr: + return None + return addr.rsplit("@", 1)[1].strip().lower() + + +def is_sender_allowed(sender: str) -> bool: + """Return True if the sender domain is in the configured allowlist. + + A domain matches if it equals an allowlist entry or is a subdomain of one + (e.g. "notifications.coupahost.com" matches "coupahost.com"). + """ + domain = extract_sender_domain(sender) + if not domain: + return False + for allowed in ALLOWED_SENDER_DOMAINS: + if domain == allowed or domain.endswith("." + allowed): + return True + return False + + +def ses_auth_failed(email_data: dict) -> bool: + """Return True if SES recorded a hard SPF or DKIM failure for this email. + + SES (when receipt-rule spam/virus/auth scanning is enabled) stamps the stored + object with X-SES-Spam-Verdict / X-SES-Virus-Verdict and an + Authentication-Results header carrying spf=/dkim= results. We only block on an + explicit "fail" so that mails delivered before scanning is enabled (no header) + are not silently dropped; the domain allowlist remains the primary gate. + """ + spam = (email_data.get("ses_spam_verdict") or "").upper() + virus = (email_data.get("ses_virus_verdict") or "").upper() + if spam == "FAIL" or virus == "FAIL": + return True + auth = (email_data.get("authentication_results") or "").lower() + if "spf=fail" in auth or "dkim=fail" in auth: + return True + return False def parse_raw_email(raw_bytes: bytes) -> dict: @@ -248,6 +318,10 @@ def parse_raw_email(raw_bytes: bytes) -> dict: "to": to, "date": date, "body": body, + # SES stamps these on the stored object when receipt-rule scanning is on. + "ses_spam_verdict": msg.get("X-SES-Spam-Verdict", ""), + "ses_virus_verdict": msg.get("X-SES-Virus-Verdict", ""), + "authentication_results": msg.get("Authentication-Results", ""), } @@ -368,6 +442,23 @@ def handler(event, context): email_data = parse_raw_email(raw_email) logger.info(f"Subject: {email_data['subject']}") + # Authz: only act on email from an allowlisted sender domain that passed + # SES auth checks. Anyone can email amazon_po@int.seahaven.com, but only + # legitimate Coupa/Amazon senders may create or mutate POs. + sender = email_data.get("sender", "") + if not is_sender_allowed(sender): + logger.warning( + f"Rejecting email from disallowed sender '{sender}' " + f"(domain not in allowlist): {key}" + ) + continue + if ses_auth_failed(email_data): + logger.warning( + f"Rejecting email from '{sender}' due to SES SPF/DKIM/spam " + f"failure: {key}" + ) + continue + parsed = extract_with_claude(email_data) logger.info( f"Parsed: type={parsed.get('email_type')}, po={parsed.get('po_number')}" diff --git a/lambdas/wo/email_processor/handler.py b/lambdas/wo/email_processor/handler.py index 2abdc8f..02684d1 100644 --- a/lambdas/wo/email_processor/handler.py +++ b/lambdas/wo/email_processor/handler.py @@ -7,6 +7,7 @@ then writes the result to DynamoDB. """ import email +import email.utils import json import logging import os @@ -27,6 +28,21 @@ WORK_ORDERS_TABLE = os.environ.get("WORK_ORDERS_TABLE", "WorkOrders") COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments") ANTHROPIC_API_KEY_SECRET_ARN = os.environ.get("ANTHROPIC_API_KEY_SECRET_ARN") +# Allowlist of sender domains permitted to create/modify work orders. APM emails +# originate from Amazon's APM (via Hexagon EAM / HxGN SmartCloud). The leading dot +# semantics ("domain or subdomain") are applied in is_sender_allowed. Override via +# the ALLOWED_SENDER_DOMAINS env var (comma-separated) without a code redeploy. An +# attacker who can email apm@int.seahaven.com but cannot forge a verified sender in +# this list is rejected before any DynamoDB write. +DEFAULT_ALLOWED_SENDER_DOMAINS = "amazon.com,hxgnsmartcloud.com,hexagon.com" +ALLOWED_SENDER_DOMAINS = [ + d.strip().lower().lstrip("@") + for d in os.environ.get( + "ALLOWED_SENDER_DOMAINS", DEFAULT_ALLOWED_SENDER_DOMAINS + ).split(",") + if d.strip() +] + EXTRACTION_PROMPT = """\ You are an email parser for a facilities maintenance work order system. The emails come from Amazon's APM system (via Hexagon EAM / HxGN SmartCloud). @@ -66,14 +82,62 @@ Rules: def get_anthropic_client() -> anthropic.Anthropic: - """Create Anthropic client, fetching API key from Secrets Manager if configured.""" - if ANTHROPIC_API_KEY_SECRET_ARN: - secrets = boto3.client("secretsmanager") - secret = secrets.get_secret_value(SecretId=ANTHROPIC_API_KEY_SECRET_ARN) - api_key = secret["SecretString"] - return anthropic.Anthropic(api_key=api_key) - # Fall back to ANTHROPIC_API_KEY env var (for local testing) - return anthropic.Anthropic() + """Create Anthropic client, fetching API key from Secrets Manager. + + Requires ANTHROPIC_API_KEY_SECRET_ARN to be set. The previous silent + fallback to a plaintext ANTHROPIC_API_KEY env var is removed: a misconfigured + deploy must fail loudly rather than quietly run on an unmanaged key. + """ + if not ANTHROPIC_API_KEY_SECRET_ARN: + raise RuntimeError( + "ANTHROPIC_API_KEY_SECRET_ARN is not set; refusing to fall back to a " + "plaintext API key. Configure the Secrets Manager ARN." + ) + secrets = boto3.client("secretsmanager") + secret = secrets.get_secret_value(SecretId=ANTHROPIC_API_KEY_SECRET_ARN) + api_key = secret["SecretString"] + return anthropic.Anthropic(api_key=api_key) + + +def extract_sender_domain(sender: str) -> str | None: + """Extract the lowercased domain from a From header value.""" + if not sender: + return None + _, addr = email.utils.parseaddr(sender) + if "@" not in addr: + return None + return addr.rsplit("@", 1)[1].strip().lower() + + +def is_sender_allowed(sender: str) -> bool: + """Return True if the sender domain is in the configured allowlist. + + A domain matches if it equals an allowlist entry or is a subdomain of one. + """ + domain = extract_sender_domain(sender) + if not domain: + return False + for allowed in ALLOWED_SENDER_DOMAINS: + if domain == allowed or domain.endswith("." + allowed): + return True + return False + + +def ses_auth_failed(email_data: dict) -> bool: + """Return True if SES recorded a hard SPF/DKIM/spam/virus failure. + + Only blocks on an explicit "fail" so mails delivered before receipt-rule + scanning was enabled (no header) are not silently dropped; the domain + allowlist remains the primary gate. + """ + spam = (email_data.get("ses_spam_verdict") or "").upper() + virus = (email_data.get("ses_virus_verdict") or "").upper() + if spam == "FAIL" or virus == "FAIL": + return True + auth = (email_data.get("authentication_results") or "").lower() + if "spf=fail" in auth or "dkim=fail" in auth: + return True + return False def parse_raw_email(raw_bytes: bytes) -> dict: @@ -105,6 +169,10 @@ def parse_raw_email(raw_bytes: bytes) -> dict: "cc": cc, "date": date, "body": body, + # SES stamps these on the stored object when receipt-rule scanning is on. + "ses_spam_verdict": msg.get("X-SES-Spam-Verdict", ""), + "ses_virus_verdict": msg.get("X-SES-Virus-Verdict", ""), + "authentication_results": msg.get("Authentication-Results", ""), } @@ -250,6 +318,23 @@ def handler(event, context): email_data = parse_raw_email(raw_email) logger.info(f"Subject: {email_data['subject']}") + # Authz: only act on email from an allowlisted sender domain that passed + # SES auth checks. Anyone can email apm@int.seahaven.com, but only + # legitimate APM/Hexagon senders may create or mutate work orders. + sender = email_data.get("sender", "") + if not is_sender_allowed(sender): + logger.warning( + f"Rejecting email from disallowed sender '{sender}' " + f"(domain not in allowlist): {key}" + ) + continue + if ses_auth_failed(email_data): + logger.warning( + f"Rejecting email from '{sender}' due to SES SPF/DKIM/spam " + f"failure: {key}" + ) + continue + # Extract structured data with Claude parsed = extract_with_claude(email_data) logger.info(