mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 10:43:14 +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
|
||||||
|
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')}"
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue