2026-05-12 15:21:06 -04:00
|
|
|
"""
|
|
|
|
|
Email processor Lambda.
|
|
|
|
|
|
|
|
|
|
Triggered by S3 events when SES delivers an email.
|
|
|
|
|
Parses the raw email, sends it to Claude for structured extraction,
|
|
|
|
|
then writes the result to DynamoDB.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import email
|
2026-07-15 20:09:58 -04:00
|
|
|
import hashlib
|
2026-05-12 15:21:06 -04:00
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import re
|
2026-07-15 20:09:58 -04:00
|
|
|
from datetime import datetime, timezone
|
2026-05-12 15:21:06 -04:00
|
|
|
from email import policy
|
|
|
|
|
|
|
|
|
|
import boto3
|
Add fail-closed SES sender authentication (INFRA-107) (#98)
* Add fail-closed SES sender authentication
The From header and any raw-MIME Authentication-Results copies are
attacker-forgeable, so a forged email to apm@int.seahaven.com or
amazon_po@int.seahaven.com could create or mutate a WO/PO (INFRA-107,
CRITICAL). Both S3-triggered email processors now authenticate the
sender against the Authentication-Results header SES itself prepends
at delivery: only the topmost header is consulted, its authserv-id
must be amazonses.com, and it must carry dkim=pass for a domain in
the per-pipeline ALLOWED_DKIM_DOMAINS env var (comma-separated, set
in CDK so ops can adjust without code changes).
Allowlists come from live traffic observed 2026-07-15 on both ingest
buckets: WO mail arrives via the apm@ Google Groups forward, which
re-signs as seahaven.com (the hxgnsmartcloud.com signature does not
survive the forward); PO mail passes for amazon.coupahost.com.
amazonses.com also passes on PO mail but is deliberately excluded --
every SES customer's outbound mail passes for it.
Every failure path (env var unset, header missing or unparseable,
verdict fail, unaligned domain) rejects the email: a structured
warning with the reason and S3 key is logged and the record skipped
without erroring the invocation, so rejected mail causes no Lambda
retries or DLQ messages. Handler signatures and event sources are
unchanged.
Refs: INFRA-107
* Harden AR parser per cross-family review
Cross-family (GPT-4.1) review findings: terminate the dkim result
token at end-of-clause, whitespace, or a comment so a value like
"dkim=pass-fake" can never be read as a pass; normalize trailing
dots off allowlist entries so "seahaven.com." matches; make the
compat32 parser policy explicit. Adds tests for result-token
boundaries, comments after the result, quoted domain values, and
folding inside a dkim clause.
Refs: INFRA-107
* Harden AR parsing and alarm on sender-auth rejects
The SES-stamped Authentication-Results value echoes attacker-controlled
SMTP-session tokens (envelope-from, helo, header.from) as their own
semicolon-delimited property clauses. A naive split(";") tore an RFC 5321
quoted-local-part MAIL FROM apart and manufactured a forged dkim=pass
clause, so a fully spoofed email was accepted on the genuinely
SES-stamped topmost header. Tokenise comment- and quoted-string-aware
(RFC 8601 / RFC 5322): strip CFWS comments, split clauses only on
semicolons outside a quoted-string, and fail closed on unbalanced
quotes/comments so a ';' inside a quoted pvalue can never start a clause.
Rejected mail returns normally (no error, no retry, no DLQ message), so a
signing-domain drift or a wrong allowlist would silently discard 100% of
legitimate mail while every alarm stayed green. Add a CloudWatch Logs
metric filter + alarm on the sender_auth_rejected warning to both stacks
so a false-reject storm pages instead of vanishing. This is also the
safety net for the WO seahaven.com allowlist assumption, which must be
validated against a live SES-stamped header (a plain Gmail auto-forward
re-signs under the sending Workspace domain, not seahaven.com).
Refs: INFRA-107
* chore: retrigger CI (no run recorded for 7c74ac1)
* Fix quoted-AUID DKIM domain spoof in sender auth
Resolve three confirmed /sh-security-review findings on the fail-closed
SES sender-authentication control.
HIGH: header.i/header.d domain extraction was not quoted-string aware.
An attacker with a valid DKIM key for their own domain could set an
RFC 6376-legal AUID such as i="@seahaven.com"@attacker.com; the naive
extractor stopped at the closing quote and returned seahaven.com,
accepting forged mail. Extraction now tokenises the clause with the same
quoted-string discipline already used for clause splitting: header.d
(the plain signing domain) is authoritative when present, otherwise the
header.i domain is the part after the AUID's LAST top-level "@", so a "@"
inside a quoted local-part is treated as signer-controlled label text and
yields the true signer (attacker.com), not seahaven.com.
LOW: the topmost-header parse ran outside evaluate_sender_authentication's
try/except, so an unexpected parser exception on crafted input could
propagate into the handler and Lambda async retries/DLQ. The parse now
fails CLOSED with an authentication_results_unparseable reason.
MEDIUM: the sender_auth_rejected alarm used Sum>=3 over 15 min, blind to
a low-volume total-reject outage (a trickle that never sums to 3). Both
stacks now alarm on >=1 reject per 5-min period with evaluation_periods=3
/ datapoints_to_alarm=2, so a sustained reject condition pages even at one
reject per period while a lone stray probe self-clears.
Refs: INFRA-107
* Load Lambda function dir on sys.path in tests
Rebasing INFRA-107 onto main folded #95's pytest suite into this
branch's tests. The unified conftest loads the PO/WO handlers by file
path, and handler.py now does `from ses_auth import
authenticate_inbound_email` -- a bare sibling import that resolves in
the Lambda only because the runtime puts each function's own directory
on sys.path. The shared load_handler now adds that directory so the
handler tests import correctly alongside the sender-auth tests.
Refs: INFRA-107
* Note #97 test files in README directory tree
The rebase onto main brought in #97's tests/requirements.txt and
tests/test_po_merge.py. List both in the directory tree so it matches
the tree on disk.
Refs: INFRA-107
* Document INFRA-107 forwarder-binding risk acceptance
Record the accepted risk that WO sender auth binds to the apm@ forward's
re-signing domain (seahaven.com) rather than the Hexagon originator; the
apm@ Google Group's restricted posting policy is the load-bearing control
(escalates to HIGH if the group is opened to external posting). Also
correct the sender-auth-rejected alarm docs to match the shipped config
(>=1 per 5-min, 2-of-3 datapoints, not the superseded >=3/15min) and
note the SES-AR-01/02 parser hardening follow-ups.
Refs: INFRA-107
2026-07-15 20:58:47 -04:00
|
|
|
from ses_auth import authenticate_inbound_email
|
2026-05-12 15:21:06 -04:00
|
|
|
|
2026-07-15 20:09:58 -04:00
|
|
|
from template_parser import try_deterministic_parse
|
|
|
|
|
|
2026-05-12 15:21:06 -04:00
|
|
|
logger = logging.getLogger()
|
|
|
|
|
logger.setLevel(logging.INFO)
|
|
|
|
|
|
|
|
|
|
s3 = boto3.client("s3")
|
|
|
|
|
dynamodb = boto3.resource("dynamodb")
|
2026-07-15 20:09:58 -04:00
|
|
|
bedrock = boto3.client("bedrock-runtime")
|
2026-05-12 15:21:06 -04:00
|
|
|
|
|
|
|
|
WORK_ORDERS_TABLE = os.environ.get("WORK_ORDERS_TABLE", "WorkOrders")
|
|
|
|
|
COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments")
|
2026-07-15 20:09:58 -04:00
|
|
|
BEDROCK_MODEL_ID = os.environ.get(
|
|
|
|
|
"BEDROCK_MODEL_ID", "us.anthropic.claude-haiku-4-5-20251001-v1:0"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# CloudWatch EMF namespace + metric for parse-outcome observability.
|
|
|
|
|
METRIC_NAMESPACE = "Seahaven/WorkorderIngest"
|
|
|
|
|
METRIC_NAME = "ParseOutcome"
|
2026-05-12 15:21:06 -04:00
|
|
|
|
|
|
|
|
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).
|
|
|
|
|
|
|
|
|
|
Analyze the following email and extract structured data. Return ONLY valid JSON with these fields:
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
"email_type": "new_work_order" | "update" | "comment" | "cancellation",
|
|
|
|
|
"work_order_id": "string or null",
|
|
|
|
|
"description": "work order description or null",
|
|
|
|
|
"status": "new" | "assigned" | "in_progress" | "on_hold" | "completed" | "cancelled" | "unknown",
|
|
|
|
|
"site_code": "building/site code like WIL1, ZDL8, etc. or null",
|
|
|
|
|
"building": "full building identifier or null",
|
|
|
|
|
"address": "physical address or null",
|
|
|
|
|
"severity": "severity level or null",
|
|
|
|
|
"priority": "priority level or null",
|
|
|
|
|
"date_reported": "ISO 8601 date or null",
|
|
|
|
|
"scheduled_start": "ISO 8601 date or null",
|
|
|
|
|
"due_date": "ISO 8601 date or null",
|
|
|
|
|
"assigned_to": "person/team assigned or null",
|
|
|
|
|
"commenter": "person who left a comment or null",
|
|
|
|
|
"comment_text": "the comment text or null",
|
|
|
|
|
"comment_time": "ISO 8601 datetime of the comment or null"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Rules:
|
|
|
|
|
- "email_type" detection:
|
|
|
|
|
- "new_work_order": email announces a new WO assignment
|
|
|
|
|
- "comment": email contains a new comment on an existing WO
|
|
|
|
|
- "cancellation": email announces a WO has been cancelled
|
|
|
|
|
- "update": any other update to an existing WO (status change, reassignment, etc.)
|
|
|
|
|
- Extract the site_code from the building field (e.g., "WIL1" from "building WIL1")
|
|
|
|
|
- Dates should be converted to ISO 8601 format
|
|
|
|
|
- If a field is not present in the email, set it to null
|
|
|
|
|
- Do NOT invent or infer data that is not explicitly in the email
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_raw_email(raw_bytes: bytes) -> dict:
|
|
|
|
|
"""Parse a raw email into subject, sender, body text."""
|
|
|
|
|
msg = email.message_from_bytes(raw_bytes, policy=policy.default)
|
|
|
|
|
|
|
|
|
|
subject = msg.get("Subject", "")
|
|
|
|
|
sender = msg.get("From", "")
|
|
|
|
|
to = msg.get("To", "")
|
|
|
|
|
cc = msg.get("Cc", "")
|
|
|
|
|
date = msg.get("Date", "")
|
|
|
|
|
|
|
|
|
|
body = ""
|
|
|
|
|
if msg.is_multipart():
|
|
|
|
|
for part in msg.walk():
|
|
|
|
|
content_type = part.get_content_type()
|
|
|
|
|
if content_type == "text/plain":
|
|
|
|
|
body = part.get_content()
|
|
|
|
|
break
|
|
|
|
|
elif content_type == "text/html" and not body:
|
|
|
|
|
body = part.get_content()
|
|
|
|
|
else:
|
|
|
|
|
body = msg.get_content()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"subject": subject,
|
|
|
|
|
"sender": sender,
|
|
|
|
|
"to": to,
|
|
|
|
|
"cc": cc,
|
|
|
|
|
"date": date,
|
|
|
|
|
"body": body,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 20:09:58 -04:00
|
|
|
def extract_with_bedrock(email_data: dict) -> dict:
|
|
|
|
|
"""Send parsed email to Claude on Bedrock for structured extraction."""
|
2026-05-12 15:21:06 -04:00
|
|
|
email_text = (
|
|
|
|
|
f"Subject: {email_data['subject']}\n"
|
|
|
|
|
f"From: {email_data['sender']}\n"
|
|
|
|
|
f"To: {email_data['to']}\n"
|
|
|
|
|
f"CC: {email_data['cc']}\n"
|
|
|
|
|
f"Date: {email_data['date']}\n"
|
|
|
|
|
f"\n---\n\n"
|
|
|
|
|
f"{email_data['body']}"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-15 20:09:58 -04:00
|
|
|
resp = bedrock.invoke_model(
|
|
|
|
|
modelId=BEDROCK_MODEL_ID,
|
|
|
|
|
body=json.dumps(
|
2026-05-12 15:21:06 -04:00
|
|
|
{
|
2026-07-15 20:09:58 -04:00
|
|
|
"anthropic_version": "bedrock-2023-05-31",
|
|
|
|
|
"max_tokens": 1024,
|
|
|
|
|
"messages": [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": f"{EXTRACTION_PROMPT}\n\nEMAIL:\n{email_text}",
|
|
|
|
|
}
|
|
|
|
|
],
|
2026-05-12 15:21:06 -04:00
|
|
|
}
|
2026-07-15 20:09:58 -04:00
|
|
|
),
|
2026-05-12 15:21:06 -04:00
|
|
|
)
|
2026-07-15 20:09:58 -04:00
|
|
|
response_text = json.loads(resp["body"].read())["content"][0]["text"]
|
2026-05-12 15:21:06 -04:00
|
|
|
|
|
|
|
|
# Extract JSON from response (handle markdown code blocks)
|
|
|
|
|
json_match = re.search(r"```(?:json)?\s*(.*?)```", response_text, re.DOTALL)
|
|
|
|
|
if json_match:
|
|
|
|
|
response_text = json_match.group(1)
|
|
|
|
|
|
|
|
|
|
return json.loads(response_text.strip())
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 20:09:58 -04:00
|
|
|
def emit_parse_metric(method, template_id, reason_code, work_order_id):
|
|
|
|
|
"""Emit one CloudWatch EMF line recording the parse outcome.
|
|
|
|
|
|
|
|
|
|
Zero-latency (no PutMetricData API call): the extraction path is async and
|
|
|
|
|
the role already has logs:PutLogEvents. ParseMethod/TemplateId are the only
|
|
|
|
|
promoted (dimensioned) fields to keep cardinality low; ReasonCode and
|
2026-07-15 20:30:53 -04:00
|
|
|
work_order_id ride along as Logs-Insights-queryable properties.
|
|
|
|
|
|
|
|
|
|
Two dimension sets are published: ["ParseMethod"] (aggregated across all
|
|
|
|
|
template ids -- the series the fallback-rate alarm queries) AND
|
|
|
|
|
["ParseMethod", "TemplateId"] (per-template breakdown for Logs Insights /
|
|
|
|
|
dashboards). CloudWatch materializes only the exact dimension sets listed
|
|
|
|
|
here and does NOT auto-aggregate, so the alarm's single-dimension query
|
|
|
|
|
would receive no data unless ["ParseMethod"] is emitted explicitly."""
|
2026-07-15 20:09:58 -04:00
|
|
|
emf = {
|
|
|
|
|
"_aws": {
|
|
|
|
|
"CloudWatchMetrics": [
|
|
|
|
|
{
|
|
|
|
|
"Namespace": METRIC_NAMESPACE,
|
2026-07-15 20:30:53 -04:00
|
|
|
"Dimensions": [["ParseMethod"], ["ParseMethod", "TemplateId"]],
|
2026-07-15 20:09:58 -04:00
|
|
|
"Metrics": [{"Name": METRIC_NAME, "Unit": "Count"}],
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
"ParseMethod": method,
|
|
|
|
|
"TemplateId": template_id or "unknown",
|
|
|
|
|
"ReasonCode": reason_code or "ok",
|
|
|
|
|
"work_order_id": work_order_id or "",
|
|
|
|
|
METRIC_NAME: 1,
|
|
|
|
|
}
|
|
|
|
|
print(json.dumps(emf))
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 15:21:06 -04:00
|
|
|
def save_work_order(parsed: dict, s3_key: str):
|
|
|
|
|
"""Create or update a work order in DynamoDB."""
|
|
|
|
|
table = dynamodb.Table(WORK_ORDERS_TABLE)
|
|
|
|
|
work_order_id = parsed["work_order_id"]
|
2026-07-15 20:09:58 -04:00
|
|
|
now = datetime.now(timezone.utc).isoformat()
|
2026-05-12 15:21:06 -04:00
|
|
|
|
|
|
|
|
# Build update expression dynamically from non-null fields
|
|
|
|
|
field_map = {
|
|
|
|
|
"description": "description",
|
|
|
|
|
"status": "wo_status", # 'status' is a DynamoDB reserved word
|
|
|
|
|
"site_code": "site_code",
|
|
|
|
|
"building": "building",
|
|
|
|
|
"address": "address",
|
|
|
|
|
"severity": "severity",
|
|
|
|
|
"priority": "priority",
|
|
|
|
|
"date_reported": "date_reported",
|
|
|
|
|
"scheduled_start": "scheduled_start",
|
|
|
|
|
"due_date": "due_date",
|
|
|
|
|
"assigned_to": "assigned_to",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
update_parts = ["#updated_at = :updated_at", "#source_key = :source_key"]
|
|
|
|
|
attr_names = {
|
|
|
|
|
"#updated_at": "updated_at",
|
|
|
|
|
"#source_key": "source_email_s3_key",
|
|
|
|
|
}
|
|
|
|
|
attr_values = {
|
|
|
|
|
":updated_at": now,
|
|
|
|
|
":source_key": s3_key,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for src_field, dynamo_field in field_map.items():
|
|
|
|
|
value = parsed.get(src_field)
|
|
|
|
|
if value is not None:
|
|
|
|
|
placeholder = f":{dynamo_field}"
|
|
|
|
|
name_placeholder = f"#{dynamo_field}"
|
|
|
|
|
update_parts.append(f"{name_placeholder} = {placeholder}")
|
|
|
|
|
attr_names[name_placeholder] = dynamo_field
|
|
|
|
|
attr_values[placeholder] = value
|
|
|
|
|
|
|
|
|
|
# For new items, set created_at
|
|
|
|
|
update_parts.append("#created_at = if_not_exists(#created_at, :created_at)")
|
|
|
|
|
attr_names["#created_at"] = "created_at"
|
|
|
|
|
attr_values[":created_at"] = now
|
|
|
|
|
|
|
|
|
|
# Customer is always AMAZON for now
|
|
|
|
|
update_parts.append("#customer = :customer")
|
|
|
|
|
attr_names["#customer"] = "customer"
|
|
|
|
|
attr_values[":customer"] = "AMAZON"
|
|
|
|
|
|
|
|
|
|
# Track the record type (new_work_order, update, comment)
|
|
|
|
|
email_type = parsed.get("email_type")
|
|
|
|
|
if email_type:
|
|
|
|
|
update_parts.append("#record_type = :record_type")
|
|
|
|
|
attr_names["#record_type"] = "record_type"
|
|
|
|
|
attr_values[":record_type"] = email_type
|
|
|
|
|
|
|
|
|
|
table.update_item(
|
|
|
|
|
Key={"work_order_id": work_order_id},
|
|
|
|
|
UpdateExpression="SET " + ", ".join(update_parts),
|
|
|
|
|
ExpressionAttributeNames=attr_names,
|
|
|
|
|
ExpressionAttributeValues=attr_values,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info(f"Saved work order {work_order_id}")
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 20:09:58 -04:00
|
|
|
def save_event(parsed: dict, s3_key: str, object_key: str):
|
|
|
|
|
"""Save an event to the events table. Every email creates an event entry.
|
|
|
|
|
|
|
|
|
|
Issue #23: the range key (attr name stays ``comment_id``) must be unique per
|
|
|
|
|
source email AND identical across Lambda async retries of the same S3 object.
|
|
|
|
|
The uniqueness suffix is a deterministic hash of the S3 object key alone (not
|
|
|
|
|
the s3:// URI, so it is stable across a bucket rename), and wall-clock now()
|
|
|
|
|
is kept OUT of the key. When comment_time is absent we use the literal
|
|
|
|
|
'nocomment' segment rather than now() -- otherwise each retry would produce a
|
|
|
|
|
different key and duplicate the row. Two distinct emails on the same WO map
|
|
|
|
|
to distinct object keys -> distinct rows."""
|
2026-05-12 15:21:06 -04:00
|
|
|
table = dynamodb.Table(COMMENTS_TABLE)
|
|
|
|
|
|
|
|
|
|
work_order_id = parsed["work_order_id"]
|
|
|
|
|
email_type = parsed.get("email_type", "unknown")
|
2026-07-15 20:09:58 -04:00
|
|
|
key_suffix = hashlib.sha256(object_key.encode("utf-8")).hexdigest()[:12]
|
|
|
|
|
comment_time = parsed.get("comment_time")
|
|
|
|
|
time_part = comment_time if comment_time else "nocomment"
|
|
|
|
|
event_id = f"{work_order_id}#{time_part}#{key_suffix}"
|
|
|
|
|
# created_at is display-only; may fall back to now() without affecting the key.
|
|
|
|
|
display_time = comment_time or datetime.now(timezone.utc).isoformat()
|
2026-05-12 15:21:06 -04:00
|
|
|
|
|
|
|
|
item = {
|
|
|
|
|
"work_order_id": work_order_id,
|
|
|
|
|
"comment_id": event_id, # keeping key name for table compatibility
|
|
|
|
|
"record_type": email_type,
|
|
|
|
|
"commenter": parsed.get("commenter") or "",
|
|
|
|
|
"text": parsed.get("comment_text") or "",
|
2026-07-15 20:09:58 -04:00
|
|
|
"created_at": display_time,
|
2026-05-12 15:21:06 -04:00
|
|
|
"source_email_s3_key": s3_key,
|
2026-07-15 20:09:58 -04:00
|
|
|
"ingested_at": datetime.now(timezone.utc).isoformat(),
|
2026-05-12 15:21:06 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
table.put_item(Item=item)
|
|
|
|
|
logger.info(f"Saved event {event_id} (type={email_type})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handler(event, context):
|
|
|
|
|
"""Lambda entry point. Triggered by S3 ObjectCreated events."""
|
|
|
|
|
for record in event.get("Records", []):
|
|
|
|
|
bucket = record["s3"]["bucket"]["name"]
|
|
|
|
|
key = record["s3"]["object"]["key"]
|
Add fail-closed SES sender authentication (INFRA-107) (#98)
* Add fail-closed SES sender authentication
The From header and any raw-MIME Authentication-Results copies are
attacker-forgeable, so a forged email to apm@int.seahaven.com or
amazon_po@int.seahaven.com could create or mutate a WO/PO (INFRA-107,
CRITICAL). Both S3-triggered email processors now authenticate the
sender against the Authentication-Results header SES itself prepends
at delivery: only the topmost header is consulted, its authserv-id
must be amazonses.com, and it must carry dkim=pass for a domain in
the per-pipeline ALLOWED_DKIM_DOMAINS env var (comma-separated, set
in CDK so ops can adjust without code changes).
Allowlists come from live traffic observed 2026-07-15 on both ingest
buckets: WO mail arrives via the apm@ Google Groups forward, which
re-signs as seahaven.com (the hxgnsmartcloud.com signature does not
survive the forward); PO mail passes for amazon.coupahost.com.
amazonses.com also passes on PO mail but is deliberately excluded --
every SES customer's outbound mail passes for it.
Every failure path (env var unset, header missing or unparseable,
verdict fail, unaligned domain) rejects the email: a structured
warning with the reason and S3 key is logged and the record skipped
without erroring the invocation, so rejected mail causes no Lambda
retries or DLQ messages. Handler signatures and event sources are
unchanged.
Refs: INFRA-107
* Harden AR parser per cross-family review
Cross-family (GPT-4.1) review findings: terminate the dkim result
token at end-of-clause, whitespace, or a comment so a value like
"dkim=pass-fake" can never be read as a pass; normalize trailing
dots off allowlist entries so "seahaven.com." matches; make the
compat32 parser policy explicit. Adds tests for result-token
boundaries, comments after the result, quoted domain values, and
folding inside a dkim clause.
Refs: INFRA-107
* Harden AR parsing and alarm on sender-auth rejects
The SES-stamped Authentication-Results value echoes attacker-controlled
SMTP-session tokens (envelope-from, helo, header.from) as their own
semicolon-delimited property clauses. A naive split(";") tore an RFC 5321
quoted-local-part MAIL FROM apart and manufactured a forged dkim=pass
clause, so a fully spoofed email was accepted on the genuinely
SES-stamped topmost header. Tokenise comment- and quoted-string-aware
(RFC 8601 / RFC 5322): strip CFWS comments, split clauses only on
semicolons outside a quoted-string, and fail closed on unbalanced
quotes/comments so a ';' inside a quoted pvalue can never start a clause.
Rejected mail returns normally (no error, no retry, no DLQ message), so a
signing-domain drift or a wrong allowlist would silently discard 100% of
legitimate mail while every alarm stayed green. Add a CloudWatch Logs
metric filter + alarm on the sender_auth_rejected warning to both stacks
so a false-reject storm pages instead of vanishing. This is also the
safety net for the WO seahaven.com allowlist assumption, which must be
validated against a live SES-stamped header (a plain Gmail auto-forward
re-signs under the sending Workspace domain, not seahaven.com).
Refs: INFRA-107
* chore: retrigger CI (no run recorded for 7c74ac1)
* Fix quoted-AUID DKIM domain spoof in sender auth
Resolve three confirmed /sh-security-review findings on the fail-closed
SES sender-authentication control.
HIGH: header.i/header.d domain extraction was not quoted-string aware.
An attacker with a valid DKIM key for their own domain could set an
RFC 6376-legal AUID such as i="@seahaven.com"@attacker.com; the naive
extractor stopped at the closing quote and returned seahaven.com,
accepting forged mail. Extraction now tokenises the clause with the same
quoted-string discipline already used for clause splitting: header.d
(the plain signing domain) is authoritative when present, otherwise the
header.i domain is the part after the AUID's LAST top-level "@", so a "@"
inside a quoted local-part is treated as signer-controlled label text and
yields the true signer (attacker.com), not seahaven.com.
LOW: the topmost-header parse ran outside evaluate_sender_authentication's
try/except, so an unexpected parser exception on crafted input could
propagate into the handler and Lambda async retries/DLQ. The parse now
fails CLOSED with an authentication_results_unparseable reason.
MEDIUM: the sender_auth_rejected alarm used Sum>=3 over 15 min, blind to
a low-volume total-reject outage (a trickle that never sums to 3). Both
stacks now alarm on >=1 reject per 5-min period with evaluation_periods=3
/ datapoints_to_alarm=2, so a sustained reject condition pages even at one
reject per period while a lone stray probe self-clears.
Refs: INFRA-107
* Load Lambda function dir on sys.path in tests
Rebasing INFRA-107 onto main folded #95's pytest suite into this
branch's tests. The unified conftest loads the PO/WO handlers by file
path, and handler.py now does `from ses_auth import
authenticate_inbound_email` -- a bare sibling import that resolves in
the Lambda only because the runtime puts each function's own directory
on sys.path. The shared load_handler now adds that directory so the
handler tests import correctly alongside the sender-auth tests.
Refs: INFRA-107
* Note #97 test files in README directory tree
The rebase onto main brought in #97's tests/requirements.txt and
tests/test_po_merge.py. List both in the directory tree so it matches
the tree on disk.
Refs: INFRA-107
* Document INFRA-107 forwarder-binding risk acceptance
Record the accepted risk that WO sender auth binds to the apm@ forward's
re-signing domain (seahaven.com) rather than the Hexagon originator; the
apm@ Google Group's restricted posting policy is the load-bearing control
(escalates to HIGH if the group is opened to external posting). Also
correct the sender-auth-rejected alarm docs to match the shipped config
(>=1 per 5-min, 2-of-3 datapoints, not the superseded >=3/15min) and
note the SES-AR-01/02 parser hardening follow-ups.
Refs: INFRA-107
2026-07-15 20:58:47 -04:00
|
|
|
s3_key = f"s3://{bucket}/{key}"
|
2026-05-12 15:21:06 -04:00
|
|
|
|
Add fail-closed SES sender authentication (INFRA-107) (#98)
* Add fail-closed SES sender authentication
The From header and any raw-MIME Authentication-Results copies are
attacker-forgeable, so a forged email to apm@int.seahaven.com or
amazon_po@int.seahaven.com could create or mutate a WO/PO (INFRA-107,
CRITICAL). Both S3-triggered email processors now authenticate the
sender against the Authentication-Results header SES itself prepends
at delivery: only the topmost header is consulted, its authserv-id
must be amazonses.com, and it must carry dkim=pass for a domain in
the per-pipeline ALLOWED_DKIM_DOMAINS env var (comma-separated, set
in CDK so ops can adjust without code changes).
Allowlists come from live traffic observed 2026-07-15 on both ingest
buckets: WO mail arrives via the apm@ Google Groups forward, which
re-signs as seahaven.com (the hxgnsmartcloud.com signature does not
survive the forward); PO mail passes for amazon.coupahost.com.
amazonses.com also passes on PO mail but is deliberately excluded --
every SES customer's outbound mail passes for it.
Every failure path (env var unset, header missing or unparseable,
verdict fail, unaligned domain) rejects the email: a structured
warning with the reason and S3 key is logged and the record skipped
without erroring the invocation, so rejected mail causes no Lambda
retries or DLQ messages. Handler signatures and event sources are
unchanged.
Refs: INFRA-107
* Harden AR parser per cross-family review
Cross-family (GPT-4.1) review findings: terminate the dkim result
token at end-of-clause, whitespace, or a comment so a value like
"dkim=pass-fake" can never be read as a pass; normalize trailing
dots off allowlist entries so "seahaven.com." matches; make the
compat32 parser policy explicit. Adds tests for result-token
boundaries, comments after the result, quoted domain values, and
folding inside a dkim clause.
Refs: INFRA-107
* Harden AR parsing and alarm on sender-auth rejects
The SES-stamped Authentication-Results value echoes attacker-controlled
SMTP-session tokens (envelope-from, helo, header.from) as their own
semicolon-delimited property clauses. A naive split(";") tore an RFC 5321
quoted-local-part MAIL FROM apart and manufactured a forged dkim=pass
clause, so a fully spoofed email was accepted on the genuinely
SES-stamped topmost header. Tokenise comment- and quoted-string-aware
(RFC 8601 / RFC 5322): strip CFWS comments, split clauses only on
semicolons outside a quoted-string, and fail closed on unbalanced
quotes/comments so a ';' inside a quoted pvalue can never start a clause.
Rejected mail returns normally (no error, no retry, no DLQ message), so a
signing-domain drift or a wrong allowlist would silently discard 100% of
legitimate mail while every alarm stayed green. Add a CloudWatch Logs
metric filter + alarm on the sender_auth_rejected warning to both stacks
so a false-reject storm pages instead of vanishing. This is also the
safety net for the WO seahaven.com allowlist assumption, which must be
validated against a live SES-stamped header (a plain Gmail auto-forward
re-signs under the sending Workspace domain, not seahaven.com).
Refs: INFRA-107
* chore: retrigger CI (no run recorded for 7c74ac1)
* Fix quoted-AUID DKIM domain spoof in sender auth
Resolve three confirmed /sh-security-review findings on the fail-closed
SES sender-authentication control.
HIGH: header.i/header.d domain extraction was not quoted-string aware.
An attacker with a valid DKIM key for their own domain could set an
RFC 6376-legal AUID such as i="@seahaven.com"@attacker.com; the naive
extractor stopped at the closing quote and returned seahaven.com,
accepting forged mail. Extraction now tokenises the clause with the same
quoted-string discipline already used for clause splitting: header.d
(the plain signing domain) is authoritative when present, otherwise the
header.i domain is the part after the AUID's LAST top-level "@", so a "@"
inside a quoted local-part is treated as signer-controlled label text and
yields the true signer (attacker.com), not seahaven.com.
LOW: the topmost-header parse ran outside evaluate_sender_authentication's
try/except, so an unexpected parser exception on crafted input could
propagate into the handler and Lambda async retries/DLQ. The parse now
fails CLOSED with an authentication_results_unparseable reason.
MEDIUM: the sender_auth_rejected alarm used Sum>=3 over 15 min, blind to
a low-volume total-reject outage (a trickle that never sums to 3). Both
stacks now alarm on >=1 reject per 5-min period with evaluation_periods=3
/ datapoints_to_alarm=2, so a sustained reject condition pages even at one
reject per period while a lone stray probe self-clears.
Refs: INFRA-107
* Load Lambda function dir on sys.path in tests
Rebasing INFRA-107 onto main folded #95's pytest suite into this
branch's tests. The unified conftest loads the PO/WO handlers by file
path, and handler.py now does `from ses_auth import
authenticate_inbound_email` -- a bare sibling import that resolves in
the Lambda only because the runtime puts each function's own directory
on sys.path. The shared load_handler now adds that directory so the
handler tests import correctly alongside the sender-auth tests.
Refs: INFRA-107
* Note #97 test files in README directory tree
The rebase onto main brought in #97's tests/requirements.txt and
tests/test_po_merge.py. List both in the directory tree so it matches
the tree on disk.
Refs: INFRA-107
* Document INFRA-107 forwarder-binding risk acceptance
Record the accepted risk that WO sender auth binds to the apm@ forward's
re-signing domain (seahaven.com) rather than the Hexagon originator; the
apm@ Google Group's restricted posting policy is the load-bearing control
(escalates to HIGH if the group is opened to external posting). Also
correct the sender-auth-rejected alarm docs to match the shipped config
(>=1 per 5-min, 2-of-3 datapoints, not the superseded >=3/15min) and
note the SES-AR-01/02 parser hardening follow-ups.
Refs: INFRA-107
2026-07-15 20:58:47 -04:00
|
|
|
logger.info(f"Processing email: {s3_key}")
|
2026-05-12 15:21:06 -04:00
|
|
|
|
|
|
|
|
# Fetch raw email from S3
|
|
|
|
|
response = s3.get_object(Bucket=bucket, Key=key)
|
|
|
|
|
raw_email = response["Body"].read()
|
|
|
|
|
|
Add fail-closed SES sender authentication (INFRA-107) (#98)
* Add fail-closed SES sender authentication
The From header and any raw-MIME Authentication-Results copies are
attacker-forgeable, so a forged email to apm@int.seahaven.com or
amazon_po@int.seahaven.com could create or mutate a WO/PO (INFRA-107,
CRITICAL). Both S3-triggered email processors now authenticate the
sender against the Authentication-Results header SES itself prepends
at delivery: only the topmost header is consulted, its authserv-id
must be amazonses.com, and it must carry dkim=pass for a domain in
the per-pipeline ALLOWED_DKIM_DOMAINS env var (comma-separated, set
in CDK so ops can adjust without code changes).
Allowlists come from live traffic observed 2026-07-15 on both ingest
buckets: WO mail arrives via the apm@ Google Groups forward, which
re-signs as seahaven.com (the hxgnsmartcloud.com signature does not
survive the forward); PO mail passes for amazon.coupahost.com.
amazonses.com also passes on PO mail but is deliberately excluded --
every SES customer's outbound mail passes for it.
Every failure path (env var unset, header missing or unparseable,
verdict fail, unaligned domain) rejects the email: a structured
warning with the reason and S3 key is logged and the record skipped
without erroring the invocation, so rejected mail causes no Lambda
retries or DLQ messages. Handler signatures and event sources are
unchanged.
Refs: INFRA-107
* Harden AR parser per cross-family review
Cross-family (GPT-4.1) review findings: terminate the dkim result
token at end-of-clause, whitespace, or a comment so a value like
"dkim=pass-fake" can never be read as a pass; normalize trailing
dots off allowlist entries so "seahaven.com." matches; make the
compat32 parser policy explicit. Adds tests for result-token
boundaries, comments after the result, quoted domain values, and
folding inside a dkim clause.
Refs: INFRA-107
* Harden AR parsing and alarm on sender-auth rejects
The SES-stamped Authentication-Results value echoes attacker-controlled
SMTP-session tokens (envelope-from, helo, header.from) as their own
semicolon-delimited property clauses. A naive split(";") tore an RFC 5321
quoted-local-part MAIL FROM apart and manufactured a forged dkim=pass
clause, so a fully spoofed email was accepted on the genuinely
SES-stamped topmost header. Tokenise comment- and quoted-string-aware
(RFC 8601 / RFC 5322): strip CFWS comments, split clauses only on
semicolons outside a quoted-string, and fail closed on unbalanced
quotes/comments so a ';' inside a quoted pvalue can never start a clause.
Rejected mail returns normally (no error, no retry, no DLQ message), so a
signing-domain drift or a wrong allowlist would silently discard 100% of
legitimate mail while every alarm stayed green. Add a CloudWatch Logs
metric filter + alarm on the sender_auth_rejected warning to both stacks
so a false-reject storm pages instead of vanishing. This is also the
safety net for the WO seahaven.com allowlist assumption, which must be
validated against a live SES-stamped header (a plain Gmail auto-forward
re-signs under the sending Workspace domain, not seahaven.com).
Refs: INFRA-107
* chore: retrigger CI (no run recorded for 7c74ac1)
* Fix quoted-AUID DKIM domain spoof in sender auth
Resolve three confirmed /sh-security-review findings on the fail-closed
SES sender-authentication control.
HIGH: header.i/header.d domain extraction was not quoted-string aware.
An attacker with a valid DKIM key for their own domain could set an
RFC 6376-legal AUID such as i="@seahaven.com"@attacker.com; the naive
extractor stopped at the closing quote and returned seahaven.com,
accepting forged mail. Extraction now tokenises the clause with the same
quoted-string discipline already used for clause splitting: header.d
(the plain signing domain) is authoritative when present, otherwise the
header.i domain is the part after the AUID's LAST top-level "@", so a "@"
inside a quoted local-part is treated as signer-controlled label text and
yields the true signer (attacker.com), not seahaven.com.
LOW: the topmost-header parse ran outside evaluate_sender_authentication's
try/except, so an unexpected parser exception on crafted input could
propagate into the handler and Lambda async retries/DLQ. The parse now
fails CLOSED with an authentication_results_unparseable reason.
MEDIUM: the sender_auth_rejected alarm used Sum>=3 over 15 min, blind to
a low-volume total-reject outage (a trickle that never sums to 3). Both
stacks now alarm on >=1 reject per 5-min period with evaluation_periods=3
/ datapoints_to_alarm=2, so a sustained reject condition pages even at one
reject per period while a lone stray probe self-clears.
Refs: INFRA-107
* Load Lambda function dir on sys.path in tests
Rebasing INFRA-107 onto main folded #95's pytest suite into this
branch's tests. The unified conftest loads the PO/WO handlers by file
path, and handler.py now does `from ses_auth import
authenticate_inbound_email` -- a bare sibling import that resolves in
the Lambda only because the runtime puts each function's own directory
on sys.path. The shared load_handler now adds that directory so the
handler tests import correctly alongside the sender-auth tests.
Refs: INFRA-107
* Note #97 test files in README directory tree
The rebase onto main brought in #97's tests/requirements.txt and
tests/test_po_merge.py. List both in the directory tree so it matches
the tree on disk.
Refs: INFRA-107
* Document INFRA-107 forwarder-binding risk acceptance
Record the accepted risk that WO sender auth binds to the apm@ forward's
re-signing domain (seahaven.com) rather than the Hexagon originator; the
apm@ Google Group's restricted posting policy is the load-bearing control
(escalates to HIGH if the group is opened to external posting). Also
correct the sender-auth-rejected alarm docs to match the shipped config
(>=1 per 5-min, 2-of-3 datapoints, not the superseded >=3/15min) and
note the SES-AR-01/02 parser hardening follow-ups.
Refs: INFRA-107
2026-07-15 20:58:47 -04:00
|
|
|
# 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
|
|
|
|
|
|
2026-05-12 15:21:06 -04:00
|
|
|
# Parse the raw email
|
|
|
|
|
email_data = parse_raw_email(raw_email)
|
|
|
|
|
logger.info(f"Subject: {email_data['subject']}")
|
|
|
|
|
|
2026-07-15 20:09:58 -04:00
|
|
|
# Deterministic template parse first; fall back to the AI extractor only
|
|
|
|
|
# on a miss or an invalid (fail-closed) result.
|
|
|
|
|
parsed, method, template_id, reason = try_deterministic_parse(email_data)
|
|
|
|
|
if parsed is None:
|
|
|
|
|
parsed = extract_with_bedrock(email_data)
|
|
|
|
|
method = "ai_fallback"
|
2026-05-12 15:21:06 -04:00
|
|
|
logger.info(
|
2026-07-15 20:09:58 -04:00
|
|
|
f"Parsed ({method}/{template_id}/{reason}): "
|
|
|
|
|
f"type={parsed.get('email_type')}, wo={parsed.get('work_order_id')}"
|
2026-05-12 15:21:06 -04:00
|
|
|
)
|
|
|
|
|
|
2026-07-15 20:09:58 -04:00
|
|
|
emit_parse_metric(method, template_id, reason, parsed.get("work_order_id"))
|
|
|
|
|
|
2026-05-12 15:21:06 -04:00
|
|
|
if not parsed.get("work_order_id"):
|
|
|
|
|
logger.warning(f"No work order ID found in email, skipping: {key}")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Always upsert the work order with any new info
|
|
|
|
|
save_work_order(parsed, s3_key)
|
|
|
|
|
|
2026-07-15 20:09:58 -04:00
|
|
|
# Save every email as an event for history tracking. The raw object key
|
|
|
|
|
# (not the s3:// URI) drives the retry-idempotent comment_id suffix.
|
|
|
|
|
save_event(parsed, s3_key, key)
|
2026-05-12 15:21:06 -04:00
|
|
|
|
|
|
|
|
return {"statusCode": 200, "body": "OK"}
|