#!/usr/bin/env python3 """Report new same-day AWS account-risk mail from an IMAP mailbox.""" import argparse from contextlib import contextmanager import email import html from html.parser import HTMLParser import imaplib import json import os from pathlib import Path import re import sys from uuid import uuid4 from datetime import datetime, timedelta, timezone from email import policy from email.header import decode_header from email.utils import parsedate_to_datetime from zoneinfo import ZoneInfo EMPTY = "未提取" NO_NEW = "今日无新增AWS账号风控邮件。" REPORT_HISTORY_LIMIT = 48 RISK_TERMS = re.compile( r"\bAWS\b|Amazon Web Services|亚马逊云", re.I ) WARNING_TERMS = re.compile( r"suspicious|unwanted|compromis|abuse|phishing|on hold|suspend|" r"verification|validate|limited|restricted|disabled|terminate|risk|fraud|可疑|异常|" r"滥用|钓鱼|暂停|停用|验证|限制|风控|未授权|泄露|风险", re.I ) EXPLICIT_CLOSURE_SUBJECT = re.compile( r"permanent closure confirmation|永久关闭确认", re.I ) ACCESS_VALIDATED = re.compile( r"your\s+request\s+for\s+accessing\s+AWS\s+resources\s+has\s+been\s+" r"(?:successfully\s+)?validated\b|" r"您访问\s*AWS\s*资源的请求已(?:得到验证|通过验证|验证成功)", re.I ) RISK_ALERT_SUBJECT = re.compile( r"suspicious activity|security (?:alert|warning)|abuse report|phishing|compromis|" r"可疑活动|异常活动|滥用|钓鱼|泄露|安全警告", re.I ) SECRET = re.compile( r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|" r"\bBearer\s+[A-Za-z0-9._~+/-]+|" r"(?:\b(?:AWS[_ -]*)?SECRET[_ -]*ACCESS[_ -]*KEY\b|" r"\bSECRET[_ -]*KEY\b|\bACCESS[_ -]*KEY\b|" r"\b(?:PASSWORD|PASSWD|TOKEN|API[_ -]*KEY|AUTHORIZATION[_ -]*CODE)\b|" r"授权码|访问密钥|秘密访问密钥)" r"\s*(?:[::=]|\bis\b|为)?\s*['\"]?[^\s'\",。;,;]+['\"]?", re.I, ) ACCOUNT_PATTERNS = [ re.compile(r"(?i)\b(?:AWS\s*(?:Account|账户|账号)?\s*ID|Account\s*ID)\s*[::#]?\s*\[?\s*(\d{12})\b"), re.compile(r"(?i)\bAWS\s*(?:账户|账号|Account)\s*\[\s*(\d{12})\s*\]"), re.compile(r"(?i)\bAWS\s*(?:账户|账号|Account)\b[^\n]{0,100}?\[\s*(\d{12})\s*\]"), ] RESOURCE = re.compile(r"\b(?:i-[0-9a-f]{8,17}|eni-[0-9a-f]{8,17})\b", re.I) DEADLINE = re.compile(r"\b(?:20\d{2}[-/]\d{1,2}[-/]\d{1,2}|\d{1,2}/\d{1,2}/20\d{2})\b") CODE_TERMS = re.compile( r"verification code|security code|authentication code|one[- ]time (?:password|passcode|code)|" r"\bOTP\b|验证码|驗證碼|动态口令|動態口令|一次性(?:密码|密碼|口令)", re.I ) CODE_USE = re.compile( r"sign[- ]?in|log[- ]?in|enter.{0,60}code|use.{0,60}code|code.{0,60}(?:expires?|valid)|" r"登录|登入|输入.{0,30}(?:代码|验证码)|(?:代码|验证码).{0,60}(?:过期|有效)|" r"\b\d{4,8}\b", re.I ) ACCOUNT_RISK = re.compile( r"(?:account|account access).{0,60}(?:has been placed on hold|is on hold|on hold|" r"(?:(?:has been|is|was|will be|may be)\s+)?(?:suspended|restricted|disabled|terminated|closed)|" r"permanent closure)|(?:suspend|restrict|disable|terminate|close).{0,30}your.{0,10}account|" r"limited your ability|(?:can't|cannot|could not) validate important details|" r"(?:账[户号]|帳[戶號]).{0,30}(?:已暂停|被暂停|将被暂停|已停用|被停用|已关闭|永久关闭|" r"已受限|受限|被限制|受到限制)|(?:暂停|限制|关闭).{0,20}(?:账[户号]|帳[戶號])|" r"(?:resource.{0,80}implicated in|has been implicated in hosting)|" r"access key.{0,100}compromised|detected potentially unwanted activity", re.I ) DOCUMENT_REQUEST = re.compile( r"(?:please|must|required to).{0,50}(?:upload|submit|provide).{0,80}" r"(?:document|identification|passport|statement|proof)|" r"(?:请|需要|必须).{0,30}(?:上传|提交|提供).{0,40}(?:材料|证件|身份证|护照|账单|证明)", re.I ) RISK_EVIDENCE = re.compile(ACCOUNT_RISK.pattern + "|" + DOCUMENT_REQUEST.pattern, re.I) RESOURCE_RISK_ACTION = re.compile( r"(?:please|must|required to|take action).{0,80}(?:remove|delete|stop|disable|revoke|rotate)" r".{0,100}(?:phishing|abus(?:e|ive)|malicious|compromised|unauthorized)|" r"(?:请|必须|需要|立即).{0,40}(?:删除|移除|停止|停用|撤销|轮换|更换)" r".{0,60}(?:钓鱼|滥用|恶意|泄露|未授权)", re.I ) ACCESS_NOTICE_RISK_EVIDENCE = re.compile( RISK_EVIDENCE.pattern + "|" + RESOURCE_RISK_ACTION.pattern, re.I ) CONDITIONAL = re.compile(r"\bif\b|如果|假如|倘若|若|如(?:您|你)", re.I) NON_RESPONSE = re.compile( r"\b(?:do not|don't|fail to|failed to|unless)\b|" r"未(?:能|在)|不(?:上传|提交|回复|回应|联系|提供)|" r"没有(?:上传|提交|回复|回应|联系|提供)", re.I ) def has_explicit_risk(text, evidence=RISK_EVIDENCE): # Evaluate each clause, so a conditional security tip cannot hide an actual # restriction elsewhere in the same paragraph (or make a code into an alert). for sentence in re.split(r"[\n.!?。!?;;]+|\b(?:but|however)\b|但是|然而", text, flags=re.I): conditional_help = False # A comma does not end an if-clause's scope over its following advice. # Independent account restrictions still qualify in a later clause. for clause in re.split(r"[,,]+", sentence): if CONDITIONAL.search(clause): conditional_help = not bool(NON_RESPONSE.search(clause)) for match in evidence.finditer(clause): prefix = clause[:match.start()] if CONDITIONAL.search(prefix) and not NON_RESPONSE.search(prefix): continue if conditional_help and (DOCUMENT_REQUEST.match(match.group()) or RESOURCE_RISK_ACTION.match(match.group())): continue return True return False def is_verification_code(subject, body): """Ignore login codes, but retain explicit restrictions or document requests.""" combined = subject + "\n" + body return bool( CODE_TERMS.search(combined) and CODE_USE.search(body) and not (has_explicit_risk(combined) or EXPLICIT_CLOSURE_SUBJECT.search(subject)) ) def is_access_validated_notice(subject, body): """Ignore completed resource-access verification, while keeping risk notices.""" combined = subject + "\n" + body return bool( ACCESS_VALIDATED.search(combined) and not (has_explicit_risk(combined, ACCESS_NOTICE_RISK_EVIDENCE) or EXPLICIT_CLOSURE_SUBJECT.search(subject) or RISK_ALERT_SUBJECT.search(subject)) ) class PlainHTML(HTMLParser): def __init__(self): super().__init__() self.parts = [] self.hidden = False def handle_starttag(self, tag, attrs): if tag in ("script", "style"): self.hidden = True elif tag in ("br", "p", "div", "li", "tr"): self.parts.append("\n") def handle_endtag(self, tag): if tag in ("script", "style"): self.hidden = False elif tag in ("p", "div", "li", "tr"): self.parts.append("\n") def handle_data(self, data): if not self.hidden: self.parts.append(data) def clean(value, limit=240): value = html.unescape(str(value or "")) value = SECRET.sub("[敏感信息已隐藏]", value) value = re.sub(r"https?://\S+", "[链接已隐藏]", value) value = re.sub(r"[\x00-\x1f\x7f]+", " ", value) value = re.sub(r"\s+", " ", value).strip(" |::-\t") if len(value) > limit: prefix = value[:limit] if " " in prefix and len(prefix.rsplit(" ", 1)[0]) >= limit * 0.75: prefix = prefix.rsplit(" ", 1)[0] value = prefix + "…" return value if value else EMPTY def header_value(message, name): raw = message.get(name, "") if isinstance(raw, str): return raw return "".join( part.decode(charset or "utf-8", errors="replace") if isinstance(part, bytes) else part for part, charset in decode_header(str(raw)) ) def body_text(message): plain, rich = [], [] for part in message.walk(): if part.is_multipart() or part.get_content_disposition() == "attachment": continue kind = part.get_content_type() if kind not in ("text/plain", "text/html"): continue data = part.get_payload(decode=True) if data is None: continue charset = part.get_content_charset() or "utf-8" try: content = data.decode(charset) except (LookupError, UnicodeError): content = data.decode("utf-8", errors="replace") if kind == "text/html": parser = PlainHTML() parser.feed(content) rich.append("".join(parser.parts)) else: plain.append(content) # Plain text is preferred; HTML is a fallback, not a duplicate source. return "\n".join(plain if plain else rich)[:200000] def account_id(text): for pattern in ACCOUNT_PATTERNS: match = pattern.search(text) if match: return match.group(1) return EMPTY def lines_matching(body, pattern, max_lines=2, exclude=None): result = [] for raw in body.splitlines(): if re.match(r"\s*(?:Subject|Date|From|To|Cc|主题|日期|发件人|收件人)\s*[::]", raw, re.I): continue line = clean(raw) if line != EMPTY and pattern.search(line) and not (exclude and exclude.search(line)) and line not in result: result.append(line) if len(result) >= max_lines: break return ";".join(result) if result else EMPTY def risk_type(subject, body): # A specific subject describes the notice more reliably than quoted mail, # security boilerplate, or a generic footer in the body. subject_rules = [ (r"permanent closure confirmation|永久关闭确认", "账户永久关闭确认"), (r"验证您的身份|verify your identity|identity verification", "身份验证通知"), (r"phishing|钓鱼", "钓鱼网站滥用报告"), (r"suspicious activity|存在可疑活动|可疑活动", "账号可疑活动警告"), (r"account on hold|账户已暂停|账户被暂停", "账户验证或暂停"), (r"abuse report|滥用报告", "资源滥用报告"), ] for pattern, label in subject_rules: if re.search(pattern, subject, re.I): return label body_rules = [ (r"(?:hosting content that resembles a phishing website|资源.{0,60}钓鱼网站)", "钓鱼网站滥用报告"), (r"(?:detected potentially unwanted activity|access key.{0,100}compromised|检测到.{0,60}可疑活动)", "账号可疑活动警告"), (r"(?:account has been placed on hold|账户已暂停|账户被暂停)", "账户验证或暂停"), (r"(?:resource.{0,80}implicated in|资源.{0,60}滥用)", "资源滥用报告"), ] for pattern, label in body_rules: if re.search(pattern, body, re.I): return label return "账号风控通知" def extract(message, received): subject = header_value(message, "Subject") body = body_text(message) combined = subject + "\n" + body if is_verification_code(subject, body) or is_access_validated_notice(subject, body): return None if not RISK_TERMS.search(combined) or not ( WARNING_TERMS.search(combined) or EXPLICIT_CLOSURE_SUBJECT.search(subject) ): return None # Only explicit 12-digit IDs with account context qualify. Case IDs and dates do not. identifier = account_id(combined) warning = lines_matching(body, re.compile( r"(?:account has been placed on hold|limited your ability|" r"your account will be suspended|we may suspend your account|" r"(?:resource.{0,80}implicated in|has been implicated in hosting)|" r"access key.{0,100}compromised|" r"detected potentially unwanted activity|can't validate important details|" r"could not validate important details|检测到.{0,60}可疑活动|" r"账户已暂停|账户被暂停|暂时限制.{0,30}AWS|账号.{0,30}将被暂停)", re.I )) action = lines_matching(body, re.compile( r"please take action|please upload|please reply|must contact|must respond|" r"to resolve this issue|立即|请.*(?:联系|回复|上传|删除|更换)", re.I ), exclude=re.compile( r"如果.{0,50}(?:发现|有任何问题)|若您.{0,50}有任何问题|" r"if you (?:have any questions|notice|discover)", re.I )) resources = list(dict.fromkeys(RESOURCE.findall(body)))[:5] deadlines = [] for line in body.splitlines(): if re.search(r"respond by|contact AWS by|before|until|deadline|due by|之前|截止|有效期", line, re.I): deadlines.extend(DEADLINE.findall(line)) return { "received": received, "subject": clean(subject), "account": identifier, "type": risk_type(subject, body), "warning": warning, "resources": "、".join(resources) if resources else EMPTY, "action": action, "deadline": clean(deadlines[0]) if deadlines else EMPTY, } def format_report(record, zone): lines = ["【AWS账号风控邮件】"] received = record.get("received") if received is not None: lines.append(f"收件时间:{received.astimezone(zone):%Y-%m-%d %H:%M:%S} ({zone.key})") for key, label in ( ("subject", "邮件主题"), ("account", "AWS账号ID"), ("type", "风险类型"), ("warning", "警告信息"), ("resources", "涉及资源"), ("action", "要求操作"), ("deadline", "截止时间"), ): value = record.get(key) if value is not None and str(value).strip() not in ("", EMPTY): lines.append(f"{label}:{value}") return "\n".join(lines) def day_window(zone, date_override=None): today = datetime.fromisoformat(date_override).date() if date_override else datetime.now(zone).date() start = datetime(today.year, today.month, today.day, tzinfo=zone) return start, start + timedelta(days=1) def parse_internaldate(fetch_meta): match = re.search(rb'INTERNALDATE "([^"]+)"', fetch_meta, re.I) if not match: raise ValueError("IMAP 响应缺少 INTERNALDATE") result = parsedate_to_datetime(match.group(1).decode("ascii")) if result.tzinfo is None: raise ValueError("IMAP INTERNALDATE 缺少时区") return result def atomic_save(path, state): path.parent.mkdir(parents=True, exist_ok=True) temp = path.with_name(path.name + f".{os.getpid()}.tmp") try: with temp.open("w", encoding="utf-8") as stream: json.dump(state, stream, ensure_ascii=False, indent=2) stream.flush() os.fsync(stream.fileno()) os.replace(temp, path) finally: temp.unlink(missing_ok=True) def load_state(path): if not path.exists(): return {"mailboxes": {}} with path.open(encoding="utf-8") as stream: state = json.load(stream) if not isinstance(state.get("mailboxes"), dict): raise ValueError("状态文件格式无效") return state def report_history(state): history = state.get("report_history", []) if not isinstance(history, list) or any( not isinstance(item, dict) or not isinstance(item.get("id"), str) or not item["id"] or not isinstance(item.get("report"), str) or not item["report"] for item in history ): raise ValueError("报告历史格式无效") # Existing state files only held the most recent report. legacy = state.get("last_report") if isinstance(legacy, str) and legacy and not history: return [{"id": "legacy", "report": legacy}] return history def save_report_batch(state, report): history = report_history(state) if history and history[0]["id"] == "legacy": history = history.copy() batch_id = f"{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-{uuid4().hex[:12]}" state["report_history"] = (history + [{"id": batch_id, "report": report}])[-REPORT_HISTORY_LIMIT:] state["last_report"] = report @contextmanager def state_lock(path): path.parent.mkdir(parents=True, exist_ok=True) with path.with_name(path.name + ".lock").open("a+b") as stream: if os.name == "nt": import msvcrt stream.seek(0) if not stream.read(1): stream.seek(0) stream.write(b"0") stream.flush() stream.seek(0) msvcrt.locking(stream.fileno(), msvcrt.LK_LOCK, 1) try: yield finally: stream.seek(0) msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) else: import fcntl fcntl.flock(stream.fileno(), fcntl.LOCK_EX) try: yield finally: fcntl.flock(stream.fileno(), fcntl.LOCK_UN) def check_imap(config, zone, start, end, state_path): with state_lock(state_path): return _check_imap_unlocked(config, zone, start, end, state_path) def imap_response_reason(data, settings): parts = data if isinstance(data, (list, tuple)) else [data] reason = " ".join( part.decode("utf-8", errors="replace") if isinstance(part, bytes) else str(part) for part in parts if part is not None ) for field in ("authorization_code", "username"): value = settings.get(field) if value: reason = reason.replace(str(value), "[敏感信息已隐藏]") return clean(reason) def send_imap_id(conn, settings): mode = settings.get("send_id", "auto") if mode != "auto" and not isinstance(mode, bool): raise ValueError("IMAP 配置 send_id 只能为 auto、true 或 false") host = str(settings["host"]).lower().rstrip(".") enabled = host == "imap.163.com" if mode == "auto" else mode if not enabled: return try: status, data = conn.xatom("ID", '("name" "IMAPClient" "version" "1.0.0")') except Exception as exc: reason = imap_response_reason(str(exc), settings) raise RuntimeError(f"IMAP ID 命令失败:{type(exc).__name__}: {reason}") from None if status != "OK": raise RuntimeError(f"IMAP ID 命令返回 {status}:{imap_response_reason(data, settings)}") def _check_imap_unlocked(config, zone, start, end, state_path): settings = config["imap"] for field in ("host", "username", "authorization_code"): if not settings.get(field) or str(settings[field]).startswith("填写"): raise ValueError(f"请填写 IMAP 配置:{field}") state = load_state(state_path) mailbox = settings.get("mailbox", "INBOX") key = f"{settings['username']}@{settings['host']}/{mailbox}" reports = [] with imaplib.IMAP4_SSL(settings["host"], int(settings.get("port", 993))) as conn: send_imap_id(conn, settings) try: login_status, login_data = conn.login(settings["username"], settings["authorization_code"]) except Exception as exc: raise RuntimeError( f"IMAP 登录失败:{type(exc).__name__}: {imap_response_reason(str(exc), settings)}" ) from None if login_status != "OK": raise RuntimeError(f"IMAP 登录失败:{imap_response_reason(login_data, settings)}") status, data = conn.select(mailbox, readonly=True) if status != "OK": raise RuntimeError(f"无法选择 IMAP 邮箱:{imap_response_reason(data, settings)}") validity = (conn.response("UIDVALIDITY")[1] or [b""])[0] if not validity: raise RuntimeError("IMAP 未返回 UIDVALIDITY,无法安全去重") validity = validity.decode("ascii") if isinstance(validity, bytes) else str(validity) entry = state["mailboxes"].setdefault(key, {"uidvalidity": validity, "uids": [], "message_ids": []}) if entry.get("uidvalidity") != validity: entry = {"uidvalidity": validity, "uids": [], "message_ids": entry.get("message_ids", [])} state["mailboxes"][key] = entry seen_uids = set(entry["uids"]) seen_ids = set(entry["message_ids"]) # SEARCH date uses the server's calendar day. Two UTC days of margin # keep the later INTERNALDATE timezone comparison authoritative. search_date = (start.astimezone(timezone.utc) - timedelta(days=2)).strftime("%d-%b-%Y") status, data = conn.uid("SEARCH", None, "SINCE", search_date) if status != "OK": raise RuntimeError("IMAP 搜索失败") for uid in (data[0] or b"").split(): uid_str = uid.decode("ascii") if uid_str in seen_uids: continue status, parts = conn.uid("FETCH", uid, "(UID INTERNALDATE BODY.PEEK[])") if status != "OK": raise RuntimeError(f"IMAP 读取 UID {uid_str} 失败") tuples = [part for part in parts if isinstance(part, tuple) and len(part) == 2] if not tuples: raise RuntimeError(f"IMAP UID {uid_str} 无邮件内容") meta, raw = tuples[0] received = parse_internaldate(meta) if not (start <= received.astimezone(zone) < end): continue message = email.message_from_bytes(raw, policy=policy.default) mid = header_value(message, "Message-ID").strip().lower() if mid and mid in seen_ids: entry["uids"].append(uid_str) seen_uids.add(uid_str) continue report = extract(message, received) if report: reports.append(report) entry["uids"].append(uid_str) seen_uids.add(uid_str) if mid: entry["message_ids"].append(mid) seen_ids.add(mid) # Retain bounded report batches alongside deduplication state for explicit recovery. if reports: save_report_batch(state, "\n\n".join(format_report(item, zone) for item in reports)) # State is committed only after every message was successfully processed. atomic_save(state_path, state) return reports def check_eml(folder, zone, start, end): reports = [] for path in sorted(folder.glob("*.eml")): message = email.message_from_bytes(path.read_bytes(), policy=policy.default) date = parsedate_to_datetime(header_value(message, "Date")) if date.tzinfo is None: raise ValueError(f"邮件日期缺少时区:{path.name}") if start <= date.astimezone(zone) < end: report = extract(message, date) if report: reports.append(report) return reports def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--config", default="config.json") mode = parser.add_mutually_exclusive_group() mode.add_argument("--eml-dir", type=Path, help="仅本地样本测试,不读写生产状态") mode.add_argument("--replay-last", action="store_true", help="仅重放上次已保存的报告,不连接邮箱") mode.add_argument("--list-report-batches", action="store_true", help="列出可重放报告的批次 ID,不连接邮箱") mode.add_argument("--replay-id", metavar="BATCH_ID", help="按批次 ID 重放报告,不连接邮箱") parser.add_argument("--date", help="本地样本测试日期,YYYY-MM-DD") args = parser.parse_args(argv) if args.date and not args.eml_dir: parser.error("--date 仅限 --eml-dir 本地样本模式") config_path = Path(args.config).resolve() config = json.loads(config_path.read_text(encoding="utf-8")) state_path = Path(config.get("state_file", "state/processed.json")) if not state_path.is_absolute(): state_path = config_path.parent / state_path if args.replay_last or args.list_report_batches or args.replay_id: history = report_history(load_state(state_path)) if args.list_report_batches: print("\n".join(item["id"] for item in reversed(history)) if history else "没有可重放的报告批次") return if args.replay_last: if not history: raise RuntimeError("没有可重放的上次报告") print(history[-1]["report"]) return match = next((item for item in history if item["id"] == args.replay_id), None) if match is None: raise RuntimeError("报告批次 ID 不存在") print(match["report"]) return zone = ZoneInfo(config.get("timezone", "Asia/Shanghai")) start, end = day_window(zone, args.date) if args.eml_dir: reports = check_eml(args.eml_dir, zone, start, end) else: reports = check_imap(config, zone, start, end, state_path) print("\n\n".join(format_report(item, zone) for item in reports) if reports else NO_NEW) if __name__ == "__main__": try: main() except Exception as exc: print(f"邮件检查失败:{type(exc).__name__}: {exc}", file=sys.stderr) sys.exit(1)