Validate sender and require Secrets Manager key

The PO and WO email processors acted on email from any sender — the
public addresses (amazon_po@, apm@) accept mail from anyone, so an
attacker could forge POs/WOs. Reject email whose verified sender
domain is not on a configurable allowlist (ALLOWED_SENDER_DOMAINS),
and drop messages with an explicit SES SPF/DKIM/spam/virus failure.

Also remove the silent fallback to a plaintext ANTHROPIC_API_KEY env
var; require ANTHROPIC_API_KEY_SECRET_ARN and raise if absent so a
misconfigured deploy fails loudly instead of using an unmanaged key.
This commit is contained in:
Adam Moussa 2026-06-17 11:37:29 -04:00
parent 31d5f5d432
commit f175323939
2 changed files with 192 additions and 16 deletions

View file

@ -7,6 +7,7 @@ then writes the result to the purchase-orders DynamoDB table.
""" """
import email import email
import email.utils
import json import json
import logging import logging
import os import os
@ -27,6 +28,21 @@ dynamodb = boto3.resource("dynamodb")
PO_TABLE = os.environ.get("PO_TABLE", "purchase-orders") PO_TABLE = os.environ.get("PO_TABLE", "purchase-orders")
ANTHROPIC_API_KEY_SECRET_ARN = os.environ.get("ANTHROPIC_API_KEY_SECRET_ARN") 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 = """\ EXTRACTION_PROMPT = """\
You are an email parser for a purchase order ingest pipeline. You are an email parser for a purchase order ingest pipeline.
The emails are Coupa procurement platform notifications containing purchase order 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: def get_anthropic_client() -> anthropic.Anthropic:
"""Create Anthropic client, fetching API key from Secrets Manager.""" """Create Anthropic client, fetching API key from Secrets Manager.
if ANTHROPIC_API_KEY_SECRET_ARN:
secrets = boto3.client("secretsmanager") Requires ANTHROPIC_API_KEY_SECRET_ARN to be set. The previous silent
secret = secrets.get_secret_value(SecretId=ANTHROPIC_API_KEY_SECRET_ARN) fallback to a plaintext ANTHROPIC_API_KEY env var is removed: a misconfigured
api_key = secret["SecretString"] deploy must fail loudly rather than quietly run on an unmanaged key.
return anthropic.Anthropic(api_key=api_key) """
# Fall back to ANTHROPIC_API_KEY env var (for local testing) if not ANTHROPIC_API_KEY_SECRET_ARN:
return anthropic.Anthropic() 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 <user@domain>" 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: def parse_raw_email(raw_bytes: bytes) -> dict:
@ -248,6 +318,10 @@ def parse_raw_email(raw_bytes: bytes) -> dict:
"to": to, "to": to,
"date": date, "date": date,
"body": body, "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) email_data = parse_raw_email(raw_email)
logger.info(f"Subject: {email_data['subject']}") 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) parsed = extract_with_claude(email_data)
logger.info( logger.info(
f"Parsed: type={parsed.get('email_type')}, po={parsed.get('po_number')}" f"Parsed: type={parsed.get('email_type')}, po={parsed.get('po_number')}"

View file

@ -7,6 +7,7 @@ then writes the result to DynamoDB.
""" """
import email import email
import email.utils
import json import json
import logging import logging
import os import os
@ -27,6 +28,21 @@ WORK_ORDERS_TABLE = os.environ.get("WORK_ORDERS_TABLE", "WorkOrders")
COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments") COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments")
ANTHROPIC_API_KEY_SECRET_ARN = os.environ.get("ANTHROPIC_API_KEY_SECRET_ARN") 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 = """\ EXTRACTION_PROMPT = """\
You are an email parser for a facilities maintenance work order system. 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). The emails come from Amazon's APM system (via Hexagon EAM / HxGN SmartCloud).
@ -66,14 +82,62 @@ Rules:
def get_anthropic_client() -> anthropic.Anthropic: def get_anthropic_client() -> anthropic.Anthropic:
"""Create Anthropic client, fetching API key from Secrets Manager if configured.""" """Create Anthropic client, fetching API key from Secrets Manager.
if ANTHROPIC_API_KEY_SECRET_ARN:
secrets = boto3.client("secretsmanager") Requires ANTHROPIC_API_KEY_SECRET_ARN to be set. The previous silent
secret = secrets.get_secret_value(SecretId=ANTHROPIC_API_KEY_SECRET_ARN) fallback to a plaintext ANTHROPIC_API_KEY env var is removed: a misconfigured
api_key = secret["SecretString"] deploy must fail loudly rather than quietly run on an unmanaged key.
return anthropic.Anthropic(api_key=api_key) """
# Fall back to ANTHROPIC_API_KEY env var (for local testing) if not ANTHROPIC_API_KEY_SECRET_ARN:
return anthropic.Anthropic() 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: def parse_raw_email(raw_bytes: bytes) -> dict:
@ -105,6 +169,10 @@ def parse_raw_email(raw_bytes: bytes) -> dict:
"cc": cc, "cc": cc,
"date": date, "date": date,
"body": body, "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) email_data = parse_raw_email(raw_email)
logger.info(f"Subject: {email_data['subject']}") 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 # Extract structured data with Claude
parsed = extract_with_claude(email_data) parsed = extract_with_claude(email_data)
logger.info( logger.info(