mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 09:33:15 +00:00
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:
parent
31d5f5d432
commit
f175323939
2 changed files with 192 additions and 16 deletions
|
|
@ -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 <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:
|
||||
|
|
@ -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')}"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue