Files
Anthropic-Cybersecurity-Skills/skills/detecting-email-account-compromise/scripts/agent.py
T
mukul975 c47eed6a64 Production hardening: security fixes, code quality, 724 skills complete
- Fix 25 shell=True subprocess calls with list-based commands
- Fix 49 verify=False in defensive skills (env-var override)
- Add timeout to 231 HTTP/subprocess/socket calls
- Fix 6 SQL injection patterns with whitelist validation
- Replace 8 __import__() with standard imports
- Remove 701 unused imports across 442 files
- Add authorized-testing disclaimers to all offensive skills
- Complete 11 incomplete skill directories
- Expand 10 stub SKILL.md files with full content
- Fix 2 YAML parse errors in frontmatter
- Fix 5 pre-existing syntax errors
- Convert 22 hardcoded paths/ports to environment variables
- Back up 21 redundant skill pairs to .bak
- Fix 2 global declaration errors
- 724/724 skills with full folder anatomy (SKILL.md + agent.py + api-reference.md + LICENSE)
- 0 compile errors across all 724 agent.py files
2026-03-19 13:26:49 +01:00

205 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""Email Account Compromise Detection agent - analyzes inbox rules, sign-in logs, and OAuth grants to detect O365/Google Workspace account compromise"""
import argparse
import json
import math
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
SUSPICIOUS_UA_PATTERNS = [
"python-requests", "python-urllib", "curl", "wget", "powershell",
"go-http-client", "httpie", "postman", "insomnia",
]
FINANCIAL_KEYWORDS = [
"invoice", "payment", "wire", "transfer", "bank", "account",
"payroll", "salary", "remittance", "ach", "swift",
]
def load_data(path):
return json.loads(Path(path).read_text(encoding="utf-8"))
def haversine_km(lat1, lon1, lat2, lon2):
R = 6371.0
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def analyze_inbox_rules(rules):
"""Detect malicious inbox rules: external forwarding, deletion rules, keyword-based filters."""
findings = []
for rule in rules:
user = rule.get("user", rule.get("mailbox", ""))
rule_name = rule.get("displayName", rule.get("name", ""))
actions = rule.get("actions", {})
forward_to = actions.get("forwardTo", []) or actions.get("forward_to", [])
redirect_to = actions.get("redirectTo", []) or actions.get("redirect_to", [])
delete_msg = actions.get("delete", False) or actions.get("moveToDeletedItems", False)
move_to = actions.get("moveToFolder", "") or ""
conditions = rule.get("conditions", {})
subject_contains = conditions.get("subjectContains", []) or []
body_contains = conditions.get("bodyContains", []) or []
for dest in forward_to + redirect_to:
addr = dest.get("emailAddress", {}).get("address", dest) if isinstance(dest, dict) else str(dest)
domain = addr.split("@")[-1] if "@" in str(addr) else ""
user_domain = user.split("@")[-1] if "@" in user else ""
if domain and domain != user_domain:
findings.append({
"type": "external_forwarding_rule",
"severity": "critical",
"resource": user,
"detail": f"Rule '{rule_name}' forwards to external address: {addr}",
})
if delete_msg:
findings.append({
"type": "deletion_rule",
"severity": "high",
"resource": user,
"detail": f"Rule '{rule_name}' auto-deletes messages",
})
keyword_hits = [kw for kw in FINANCIAL_KEYWORDS
if any(kw in s.lower() for s in subject_contains + body_contains)]
if keyword_hits:
findings.append({
"type": "financial_keyword_filter",
"severity": "high",
"resource": user,
"detail": f"Rule '{rule_name}' targets financial keywords: {', '.join(keyword_hits)}",
})
return findings
def analyze_sign_ins(sign_ins):
"""Detect impossible travel, suspicious user agents, and risky sign-in patterns."""
findings = []
user_logins = defaultdict(list)
for si in sign_ins:
user = si.get("userPrincipalName", si.get("user", ""))
ua = si.get("userAgent", si.get("user_agent", ""))
ip = si.get("ipAddress", si.get("ip", ""))
ts = si.get("createdDateTime", si.get("timestamp", ""))
lat = si.get("location", {}).get("geoCoordinates", {}).get("latitude", 0)
lon = si.get("location", {}).get("geoCoordinates", {}).get("longitude", 0)
country = si.get("location", {}).get("countryOrRegion", "")
risk = si.get("riskLevelAggregated", si.get("risk_level", "none"))
for pattern in SUSPICIOUS_UA_PATTERNS:
if pattern.lower() in (ua or "").lower():
findings.append({
"type": "suspicious_user_agent",
"severity": "high",
"resource": user,
"detail": f"Sign-in from suspicious UA '{ua[:60]}' at IP {ip}",
})
break
if risk in ("high", "medium"):
findings.append({
"type": "risky_sign_in",
"severity": "high" if risk == "high" else "medium",
"resource": user,
"detail": f"Azure AD risk level '{risk}' from {country or ip}",
})
if lat and lon and ts:
user_logins[user].append({"ts": ts, "lat": lat, "lon": lon, "ip": ip})
for user, logins in user_logins.items():
try:
logins.sort(key=lambda x: x["ts"])
except TypeError:
continue
for i in range(1, len(logins)):
try:
t1 = datetime.fromisoformat(logins[i - 1]["ts"].replace("Z", "+00:00"))
t2 = datetime.fromisoformat(logins[i]["ts"].replace("Z", "+00:00"))
hours = abs((t2 - t1).total_seconds()) / 3600.0
if hours < 0.01:
continue
dist = haversine_km(logins[i - 1]["lat"], logins[i - 1]["lon"], logins[i]["lat"], logins[i]["lon"])
speed = dist / hours
if speed > 900:
findings.append({
"type": "impossible_travel",
"severity": "critical",
"resource": user,
"detail": f"Impossible travel: {dist:.0f} km in {hours:.1f}h ({speed:.0f} km/h) between IPs {logins[i-1]['ip']} and {logins[i]['ip']}",
})
except (ValueError, TypeError):
continue
return findings
def analyze_oauth_grants(grants):
"""Detect suspicious OAuth application consent grants."""
findings = []
for grant in grants:
user = grant.get("user", grant.get("principalDisplayName", ""))
app = grant.get("appDisplayName", grant.get("app_name", ""))
scopes = grant.get("scope", grant.get("scopes", ""))
consent_type = grant.get("consentType", "")
risky_scopes = ["Mail.ReadWrite", "Mail.Send", "MailboxSettings.ReadWrite", "Files.ReadWrite.All"]
granted_risky = [s for s in risky_scopes if s.lower() in (scopes or "").lower()]
if granted_risky:
findings.append({
"type": "risky_oauth_grant",
"severity": "high",
"resource": user,
"detail": f"App '{app}' granted risky scopes: {', '.join(granted_risky)}",
})
if consent_type == "AllPrincipals":
findings.append({
"type": "admin_consent_grant",
"severity": "critical",
"resource": user,
"detail": f"App '{app}' has admin consent (AllPrincipals) with scopes: {scopes[:80]}",
})
return findings
def analyze(data):
findings = []
if isinstance(data, list):
findings.extend(analyze_inbox_rules(data))
return findings
findings.extend(analyze_inbox_rules(data.get("inbox_rules", data.get("rules", []))))
findings.extend(analyze_sign_ins(data.get("sign_ins", data.get("logins", []))))
findings.extend(analyze_oauth_grants(data.get("oauth_grants", data.get("app_consents", []))))
return findings
def generate_report(input_path):
data = load_data(input_path)
findings = analyze(data)
sev = Counter(f["severity"] for f in findings)
compromised = set(f["resource"] for f in findings if f["severity"] in ("critical", "high"))
return {
"report": "email_account_compromise_detection",
"generated_at": datetime.utcnow().isoformat() + "Z",
"total_findings": len(findings),
"severity_summary": dict(sev),
"potentially_compromised_accounts": sorted(compromised),
"findings": findings,
}
def main():
ap = argparse.ArgumentParser(description="Email Account Compromise Detection Agent")
ap.add_argument("--input", required=True, help="Input JSON with inbox rules/sign-in/OAuth data")
ap.add_argument("--output", help="Output JSON report path")
args = ap.parse_args()
report = generate_report(args.input)
out = json.dumps(report, indent=2)
if args.output:
Path(args.output).write_text(out, encoding="utf-8")
print(f"Report written to {args.output}")
else:
print(out)
if __name__ == "__main__":
main()