sync: Auto-sync from ACG-M-L5090 at 2026-03-10 19:11:00
Synced files: - Quote wizard frontend (all components, hooks, types, config) - API updates (config, models, routers, schemas, services) - Client work (bg-builders, gurushow) - Scripts (BGB Lesley termination, CIPP, Datto, migration) - Temp files (Bardach contacts, VWP investigation, misc) - Credentials and session logs - Email service, PHP API, session logs Machine: ACG-M-L5090 Timestamp: 2026-03-10 19:11:00 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
721
temp/vwp_bec_investigation.py
Normal file
721
temp/vwp_bec_investigation.py
Normal file
@@ -0,0 +1,721 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Valley Wide Plastering - BEC (Business Email Compromise) Investigation
|
||||
Target: jrguerrero@valleywideplastering.com
|
||||
Date: 2026-03-05
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Configuration
|
||||
TENANT_ID = "5c53ae9f-7071-4248-b834-8685b646450f"
|
||||
CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418"
|
||||
CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO"
|
||||
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
||||
GRAPH_BETA = "https://graph.microsoft.com/beta"
|
||||
TARGET_USER = "jrguerrero"
|
||||
TARGET_DOMAIN = "valleywideplastering.com"
|
||||
RESULTS_FILE = "D:/ClaudeTools/temp/vwp_bec_results.json"
|
||||
|
||||
results = {}
|
||||
|
||||
def get_token():
|
||||
"""Get OAuth2 token via client credentials flow."""
|
||||
cmd = [
|
||||
"curl", "-s", "-X", "POST",
|
||||
f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",
|
||||
"-H", "Content-Type: application/x-www-form-urlencoded",
|
||||
"-d", f"client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default&grant_type=client_credentials"
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
data = json.loads(result.stdout)
|
||||
if "access_token" not in data:
|
||||
print(f"[ERROR] Failed to get token: {json.dumps(data, indent=2)}")
|
||||
sys.exit(1)
|
||||
return data["access_token"]
|
||||
|
||||
|
||||
def graph_get(token, url, label=""):
|
||||
"""Make a GET request to Microsoft Graph API."""
|
||||
cmd = ["curl", "-s", "-G", "-H", f"Authorization: Bearer {token}"]
|
||||
# Split URL and query params to let curl handle encoding
|
||||
if "?" in url:
|
||||
base_url, query_string = url.split("?", 1)
|
||||
cmd.append(base_url)
|
||||
# Pass each query param via --data-urlencode
|
||||
for param in query_string.split("&"):
|
||||
if "=" in param:
|
||||
cmd.extend(["--data-urlencode", param])
|
||||
else:
|
||||
cmd.extend(["--data-urlencode", param])
|
||||
else:
|
||||
cmd.append(url)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
data = {"error": "Failed to parse response", "raw": result.stdout[:500]}
|
||||
if "error" in data:
|
||||
err_msg = data['error'].get('message', data['error']) if isinstance(data['error'], dict) else data['error']
|
||||
print(f" [WARNING] {label}: {err_msg}")
|
||||
return data
|
||||
|
||||
|
||||
def graph_get_all_pages(token, url, label=""):
|
||||
"""Get all pages of a paginated Graph API response."""
|
||||
all_values = []
|
||||
current_url = url
|
||||
page = 0
|
||||
while current_url:
|
||||
page += 1
|
||||
data = graph_get(token, current_url, f"{label} page {page}")
|
||||
if "value" in data:
|
||||
all_values.extend(data["value"])
|
||||
else:
|
||||
break
|
||||
current_url = data.get("@odata.nextLink")
|
||||
return all_values
|
||||
|
||||
|
||||
def print_separator(title):
|
||||
print(f"\n{'='*70}")
|
||||
print(f" {title}")
|
||||
print(f"{'='*70}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print(" VALLEY WIDE PLASTERING - BEC INVESTIGATION")
|
||||
print(f" Date: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||
print("=" * 70)
|
||||
|
||||
# Get token
|
||||
print("\n[*] Acquiring access token...")
|
||||
token = get_token()
|
||||
print("[OK] Token acquired successfully")
|
||||
|
||||
# ===== STEP 1: LIST ALL USERS =====
|
||||
print_separator("STEP 1: ALL TENANT USERS")
|
||||
users_data = graph_get(token, f"{GRAPH_BASE}/users?$select=id,displayName,userPrincipalName,mail,accountEnabled,createdDateTime", "users")
|
||||
users = users_data.get("value", [])
|
||||
results["users"] = users
|
||||
|
||||
jr_id = None
|
||||
jr_upn = None
|
||||
all_upns = []
|
||||
|
||||
for u in users:
|
||||
upn = u.get("userPrincipalName", "")
|
||||
all_upns.append(upn)
|
||||
enabled = u.get("accountEnabled", False)
|
||||
created = u.get("createdDateTime", "N/A")
|
||||
status = "[ENABLED]" if enabled else "[DISABLED]"
|
||||
name = u.get("displayName", "N/A")
|
||||
uid = u.get("id", "N/A")
|
||||
print(f" {status} {name} | {upn} | ID: {uid} | Created: {created}")
|
||||
|
||||
if TARGET_USER.lower() in upn.lower():
|
||||
jr_id = uid
|
||||
jr_upn = upn
|
||||
print(f" >>> TARGET USER FOUND: {jr_upn} (ID: {jr_id})")
|
||||
|
||||
if not jr_id:
|
||||
print(f"\n[INFO] Exact match for '{TARGET_USER}' not found, searching by name...")
|
||||
# Search for JR Guerrero specifically
|
||||
for u in users:
|
||||
upn = u.get("userPrincipalName", "")
|
||||
name = u.get("displayName", "")
|
||||
# Match "JR Guerrero" by display name or j-r@ pattern
|
||||
if name.lower() == "jr guerrero" and TARGET_DOMAIN in upn.lower():
|
||||
jr_id = u["id"]
|
||||
jr_upn = upn
|
||||
print(f" >>> TARGET USER FOUND: {jr_upn} (ID: {jr_id})")
|
||||
break
|
||||
# Fallback broader search
|
||||
if not jr_id:
|
||||
for u in users:
|
||||
upn = u.get("userPrincipalName", "")
|
||||
name = u.get("displayName", "")
|
||||
if ("j-r" in upn.lower() or "jr" in upn.lower()) and TARGET_DOMAIN in upn.lower():
|
||||
jr_id = u["id"]
|
||||
jr_upn = upn
|
||||
print(f" >>> TARGET USER FOUND: {jr_upn} (ID: {jr_id})")
|
||||
break
|
||||
|
||||
if not jr_id:
|
||||
print("[ERROR] Cannot proceed without target user. Exiting.")
|
||||
results["error"] = "Target user not found"
|
||||
with open(RESULTS_FILE, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
sys.exit(1)
|
||||
|
||||
results["target_user"] = {"id": jr_id, "upn": jr_upn}
|
||||
|
||||
# ===== STEP 2: SIGN-IN LOGS =====
|
||||
print_separator("STEP 2: SIGN-IN LOGS (Last 14 Days)")
|
||||
|
||||
# Try v1.0 first, then beta
|
||||
signin_url = f"{GRAPH_BASE}/auditLogs/signIns?$filter=userPrincipalName eq '{jr_upn}'&$top=100&$orderby=createdDateTime desc"
|
||||
signin_data = graph_get(token, signin_url, "sign-ins v1.0")
|
||||
signins = signin_data.get("value", [])
|
||||
|
||||
if not signins and "error" in signin_data:
|
||||
print(" [*] Trying beta endpoint...")
|
||||
signin_url = f"{GRAPH_BETA}/auditLogs/signIns?$filter=userPrincipalName eq '{jr_upn}'&$top=100&$orderby=createdDateTime desc"
|
||||
signin_data = graph_get(token, signin_url, "sign-ins beta")
|
||||
signins = signin_data.get("value", [])
|
||||
|
||||
results["signins"] = signins
|
||||
|
||||
if signins:
|
||||
ips_seen = {}
|
||||
locations_seen = {}
|
||||
failed_count = 0
|
||||
risky_count = 0
|
||||
legacy_auth = []
|
||||
timestamps_by_ip = {}
|
||||
|
||||
for s in signins:
|
||||
ts = s.get("createdDateTime", "N/A")
|
||||
ip = s.get("ipAddress", "N/A")
|
||||
loc = s.get("location", {})
|
||||
city = loc.get("city", "Unknown")
|
||||
state = loc.get("state", "Unknown")
|
||||
country = loc.get("countryOrRegion", "Unknown")
|
||||
loc_str = f"{city}, {state}, {country}"
|
||||
status_code = s.get("status", {}).get("errorCode", 0)
|
||||
status_reason = s.get("status", {}).get("failureReason", "")
|
||||
risk_level = s.get("riskLevelDuringSignIn", "none")
|
||||
risk_state = s.get("riskState", "none")
|
||||
app = s.get("clientAppUsed", "N/A")
|
||||
resource = s.get("resourceDisplayName", "N/A")
|
||||
app_name = s.get("appDisplayName", "N/A")
|
||||
|
||||
# Track IPs
|
||||
ips_seen[ip] = ips_seen.get(ip, 0) + 1
|
||||
locations_seen[loc_str] = locations_seen.get(loc_str, 0) + 1
|
||||
if ip not in timestamps_by_ip:
|
||||
timestamps_by_ip[ip] = []
|
||||
timestamps_by_ip[ip].append(ts)
|
||||
|
||||
# Check for issues
|
||||
is_failed = status_code != 0
|
||||
is_risky = risk_level not in ("none", "low", None)
|
||||
is_legacy = app in ("IMAP4", "POP3", "SMTP", "Exchange ActiveSync", "Authenticated SMTP",
|
||||
"Other clients", "Exchange Online PowerShell", "MAPI Over HTTP",
|
||||
"Offline Address Book", "Outlook Anywhere (RPC over HTTP)",
|
||||
"Exchange Web Services", "POP", "IMAP")
|
||||
|
||||
if is_failed:
|
||||
failed_count += 1
|
||||
if is_risky:
|
||||
risky_count += 1
|
||||
if is_legacy:
|
||||
legacy_auth.append({"timestamp": ts, "protocol": app, "ip": ip})
|
||||
|
||||
# Flag suspicious entries
|
||||
flags = []
|
||||
if is_failed:
|
||||
flags.append("FAILED")
|
||||
if is_risky:
|
||||
flags.append(f"RISKY:{risk_level}")
|
||||
if is_legacy:
|
||||
flags.append("LEGACY_AUTH")
|
||||
if country not in ("US", "Unknown", "", None):
|
||||
flags.append(f"FOREIGN:{country}")
|
||||
|
||||
flag_str = f" [{'|'.join(flags)}]" if flags else ""
|
||||
marker = "[SUSPICIOUS]" if flags else " "
|
||||
|
||||
print(f" {marker} {ts} | IP: {ip} | {loc_str} | App: {app_name} | Client: {app} | Status: {status_code}{flag_str}")
|
||||
|
||||
# Summary
|
||||
print(f"\n --- Sign-in Summary ---")
|
||||
print(f" Total sign-ins: {len(signins)}")
|
||||
print(f" Unique IPs: {len(ips_seen)}")
|
||||
print(f" Failed sign-ins: {failed_count}")
|
||||
print(f" Risky sign-ins: {risky_count}")
|
||||
print(f" Legacy auth protocols: {len(legacy_auth)}")
|
||||
|
||||
print(f"\n --- IP Address Breakdown ---")
|
||||
for ip, count in sorted(ips_seen.items(), key=lambda x: x[1], reverse=True):
|
||||
print(f" {ip}: {count} sign-ins")
|
||||
|
||||
print(f"\n --- Location Breakdown ---")
|
||||
for loc, count in sorted(locations_seen.items(), key=lambda x: x[1], reverse=True):
|
||||
print(f" {loc}: {count} sign-ins")
|
||||
|
||||
if legacy_auth:
|
||||
print(f"\n [CRITICAL] Legacy Authentication Detected!")
|
||||
for la in legacy_auth:
|
||||
print(f" {la['timestamp']} | Protocol: {la['protocol']} | IP: {la['ip']}")
|
||||
|
||||
# Impossible travel check
|
||||
if len(ips_seen) > 1:
|
||||
print(f"\n --- Impossible Travel Check ---")
|
||||
print(f" Multiple IPs detected. Review timestamps above for geographic anomalies.")
|
||||
|
||||
results["signin_summary"] = {
|
||||
"total": len(signins),
|
||||
"unique_ips": len(ips_seen),
|
||||
"failed": failed_count,
|
||||
"risky": risky_count,
|
||||
"legacy_auth_count": len(legacy_auth),
|
||||
"ips": ips_seen,
|
||||
"locations": locations_seen,
|
||||
"legacy_auth_details": legacy_auth
|
||||
}
|
||||
else:
|
||||
print(" No sign-in logs found (tenant may not have Azure AD P1/P2)")
|
||||
|
||||
# ===== STEP 3: RECENT SENT MAIL =====
|
||||
print_separator("STEP 3: RECENT SENT MAIL (Last 14 Days)")
|
||||
|
||||
mail_url = f"{GRAPH_BASE}/users/{jr_id}/mailFolders/SentItems/messages?$top=100&$orderby=sentDateTime desc&$select=subject,toRecipients,ccRecipients,sentDateTime,bodyPreview,from,sender,hasAttachments"
|
||||
mail_data = graph_get(token, mail_url, "sent mail")
|
||||
sent_msgs = mail_data.get("value", [])
|
||||
results["sent_mail"] = sent_msgs
|
||||
|
||||
suspicious_subjects = ["invoice", "payment", "wire", "transfer", "docusign", "urgent",
|
||||
"overdue", "past due", "bank", "account", "verify", "confirm",
|
||||
"update", "action required", "immediately", "asap", "w-2", "w2",
|
||||
"tax", "direct deposit", "ach", "routing", "gift card"]
|
||||
|
||||
if sent_msgs:
|
||||
sus_mail_count = 0
|
||||
external_recipients = set()
|
||||
|
||||
for msg in sent_msgs:
|
||||
subject = msg.get("subject", "(No Subject)")
|
||||
sent_dt = msg.get("sentDateTime", "N/A")
|
||||
has_attach = msg.get("hasAttachments", False)
|
||||
body_preview = msg.get("bodyPreview", "")[:150]
|
||||
to_list = msg.get("toRecipients", [])
|
||||
cc_list = msg.get("ccRecipients", [])
|
||||
|
||||
# Check recipients
|
||||
to_addrs = []
|
||||
for r in to_list:
|
||||
addr = r.get("emailAddress", {}).get("address", "N/A")
|
||||
to_addrs.append(addr)
|
||||
if TARGET_DOMAIN not in addr.lower():
|
||||
external_recipients.add(addr)
|
||||
for r in cc_list:
|
||||
addr = r.get("emailAddress", {}).get("address", "N/A")
|
||||
if TARGET_DOMAIN not in addr.lower():
|
||||
external_recipients.add(addr)
|
||||
|
||||
# Check for suspicious subjects
|
||||
is_suspicious = any(kw in subject.lower() for kw in suspicious_subjects)
|
||||
if is_suspicious:
|
||||
sus_mail_count += 1
|
||||
|
||||
attach_str = " [HAS ATTACHMENTS]" if has_attach else ""
|
||||
marker = "[SUSPICIOUS]" if is_suspicious else " "
|
||||
|
||||
print(f" {marker} {sent_dt} | To: {', '.join(to_addrs[:3])} | Subject: {subject}{attach_str}")
|
||||
if is_suspicious:
|
||||
print(f" Preview: {body_preview}")
|
||||
|
||||
print(f"\n --- Sent Mail Summary ---")
|
||||
print(f" Total sent messages: {len(sent_msgs)}")
|
||||
print(f" Suspicious subjects: {sus_mail_count}")
|
||||
print(f" External recipients: {len(external_recipients)}")
|
||||
if external_recipients:
|
||||
print(f" External recipient list:")
|
||||
for addr in sorted(external_recipients):
|
||||
print(f" - {addr}")
|
||||
|
||||
results["sent_mail_summary"] = {
|
||||
"total": len(sent_msgs),
|
||||
"suspicious_count": sus_mail_count,
|
||||
"external_recipients": list(external_recipients)
|
||||
}
|
||||
else:
|
||||
print(" No sent mail found or access denied")
|
||||
|
||||
# ===== STEP 4: INBOX RULES =====
|
||||
print_separator("STEP 4: INBOX RULES (CRITICAL CHECK)")
|
||||
|
||||
rules_url = f"{GRAPH_BASE}/users/{jr_id}/mailFolders/inbox/messageRules"
|
||||
rules_data = graph_get(token, rules_url, "inbox rules")
|
||||
rules = rules_data.get("value", [])
|
||||
results["inbox_rules"] = rules
|
||||
|
||||
if rules:
|
||||
for rule in rules:
|
||||
rule_name = rule.get("displayName", "Unnamed Rule")
|
||||
is_enabled = rule.get("isEnabled", False)
|
||||
actions = rule.get("actions", {})
|
||||
conditions = rule.get("conditions", {})
|
||||
|
||||
# Check for malicious rule patterns
|
||||
flags = []
|
||||
forward_to = actions.get("forwardTo", [])
|
||||
forward_as = actions.get("forwardAsAttachmentTo", [])
|
||||
redirect_to = actions.get("redirectTo", [])
|
||||
delete = actions.get("delete", False)
|
||||
move_to = actions.get("moveToFolder", "")
|
||||
mark_read = actions.get("markAsRead", False)
|
||||
perm_delete = actions.get("permanentDelete", False)
|
||||
|
||||
if forward_to:
|
||||
fwd_addrs = [f.get("emailAddress", {}).get("address", "N/A") for f in forward_to]
|
||||
flags.append(f"FORWARDS TO: {', '.join(fwd_addrs)}")
|
||||
if forward_as:
|
||||
fwd_addrs = [f.get("emailAddress", {}).get("address", "N/A") for f in forward_as]
|
||||
flags.append(f"FORWARDS AS ATTACHMENT TO: {', '.join(fwd_addrs)}")
|
||||
if redirect_to:
|
||||
redir_addrs = [r.get("emailAddress", {}).get("address", "N/A") for r in redirect_to]
|
||||
flags.append(f"REDIRECTS TO: {', '.join(redir_addrs)}")
|
||||
if delete:
|
||||
flags.append("DELETES MESSAGES")
|
||||
if perm_delete:
|
||||
flags.append("PERMANENTLY DELETES MESSAGES")
|
||||
if mark_read:
|
||||
flags.append("MARKS AS READ")
|
||||
|
||||
status = "[ENABLED]" if is_enabled else "[DISABLED]"
|
||||
marker = "[CRITICAL]" if (forward_to or redirect_to or delete or perm_delete) else " "
|
||||
|
||||
print(f" {marker} {status} Rule: '{rule_name}'")
|
||||
if flags:
|
||||
for f in flags:
|
||||
print(f" >>> {f}")
|
||||
if conditions:
|
||||
print(f" Conditions: {json.dumps(conditions, indent=6)}")
|
||||
print(f" Full actions: {json.dumps(actions, indent=6)}")
|
||||
else:
|
||||
print(" [OK] No inbox rules found")
|
||||
|
||||
# ===== STEP 5: MAILBOX SETTINGS =====
|
||||
print_separator("STEP 5: MAILBOX SETTINGS (Forwarding & Auto-Reply)")
|
||||
|
||||
mailbox_url = f"{GRAPH_BASE}/users/{jr_id}/mailboxSettings"
|
||||
mailbox_data = graph_get(token, mailbox_url, "mailbox settings")
|
||||
results["mailbox_settings"] = mailbox_data
|
||||
|
||||
if "error" not in mailbox_data:
|
||||
auto_reply = mailbox_data.get("automaticRepliesSetting", {})
|
||||
ar_status = auto_reply.get("status", "disabled")
|
||||
ar_external = auto_reply.get("externalReplyMessage", "")
|
||||
ar_internal = auto_reply.get("internalReplyMessage", "")
|
||||
|
||||
print(f" Auto-Reply Status: {ar_status}")
|
||||
if ar_status != "disabled":
|
||||
print(f" [SUSPICIOUS] Auto-replies are ENABLED!")
|
||||
print(f" External message: {ar_external[:200]}")
|
||||
print(f" Internal message: {ar_internal[:200]}")
|
||||
else:
|
||||
print(f" [OK] Auto-replies are disabled")
|
||||
|
||||
# Check other settings
|
||||
print(f" Language: {mailbox_data.get('language', {}).get('locale', 'N/A')}")
|
||||
print(f" Timezone: {mailbox_data.get('timeZone', 'N/A')}")
|
||||
print(f" Date format: {mailbox_data.get('dateFormat', 'N/A')}")
|
||||
else:
|
||||
print(" Could not retrieve mailbox settings")
|
||||
|
||||
# Also check forwarding via Exchange settings
|
||||
print("\n Checking SMTP forwarding...")
|
||||
fwd_url = f"{GRAPH_BASE}/users/{jr_id}?$select=mail,otherMails,proxyAddresses"
|
||||
fwd_data = graph_get(token, fwd_url, "forwarding check")
|
||||
if "error" not in fwd_data:
|
||||
proxy = fwd_data.get("proxyAddresses", [])
|
||||
other = fwd_data.get("otherMails", [])
|
||||
print(f" Proxy addresses: {proxy}")
|
||||
print(f" Other emails: {other}")
|
||||
results["forwarding_check"] = fwd_data
|
||||
|
||||
# ===== STEP 6: AUTHENTICATION METHODS =====
|
||||
print_separator("STEP 6: AUTHENTICATION METHODS")
|
||||
|
||||
auth_url = f"{GRAPH_BASE}/users/{jr_id}/authentication/methods"
|
||||
auth_data = graph_get(token, auth_url, "auth methods")
|
||||
auth_methods = auth_data.get("value", [])
|
||||
results["auth_methods"] = auth_methods
|
||||
|
||||
if auth_methods:
|
||||
for m in auth_methods:
|
||||
method_type = m.get("@odata.type", "Unknown")
|
||||
method_id = m.get("id", "N/A")
|
||||
|
||||
# Clean up type name
|
||||
clean_type = method_type.replace("#microsoft.graph.", "")
|
||||
|
||||
detail = ""
|
||||
if "phone" in method_type.lower():
|
||||
detail = f" | Phone: {m.get('phoneNumber', 'N/A')} ({m.get('phoneType', 'N/A')})"
|
||||
elif "email" in method_type.lower():
|
||||
detail = f" | Email: {m.get('emailAddress', 'N/A')}"
|
||||
elif "fido2" in method_type.lower():
|
||||
detail = f" | Model: {m.get('model', 'N/A')} | Created: {m.get('createdDateTime', 'N/A')}"
|
||||
elif "microsoftAuthenticator" in method_type:
|
||||
detail = f" | Device: {m.get('displayName', 'N/A')} | Created: {m.get('createdDateTime', 'N/A')}"
|
||||
elif "softwareOath" in method_type:
|
||||
detail = f" | Created: {m.get('createdDateTime', 'N/A')}"
|
||||
|
||||
print(f" [{clean_type}] ID: {method_id}{detail}")
|
||||
else:
|
||||
print(" No auth methods returned (may need different permissions)")
|
||||
|
||||
# ===== STEP 7: OAUTH PERMISSION GRANTS =====
|
||||
print_separator("STEP 7: OAUTH PERMISSION GRANTS & THIRD-PARTY APPS")
|
||||
|
||||
oauth_url = f"{GRAPH_BASE}/users/{jr_id}/oauth2PermissionGrants"
|
||||
oauth_data = graph_get(token, oauth_url, "oauth grants")
|
||||
oauth_grants = oauth_data.get("value", [])
|
||||
results["oauth_grants"] = oauth_grants
|
||||
|
||||
if oauth_grants:
|
||||
print(f" Found {len(oauth_grants)} OAuth permission grant(s):")
|
||||
for grant in oauth_grants:
|
||||
client_id = grant.get("clientId", "N/A")
|
||||
scope = grant.get("scope", "N/A")
|
||||
consent = grant.get("consentType", "N/A")
|
||||
start = grant.get("startTime", "N/A")
|
||||
|
||||
# Flag mail-related scopes
|
||||
mail_scopes = ["Mail", "IMAP", "POP", "SMTP", "EWS"]
|
||||
has_mail = any(ms.lower() in scope.lower() for ms in mail_scopes)
|
||||
marker = "[SUSPICIOUS]" if has_mail else " "
|
||||
|
||||
print(f" {marker} ClientID: {client_id}")
|
||||
print(f" Scope: {scope}")
|
||||
print(f" Consent: {consent} | Start: {start}")
|
||||
else:
|
||||
print(" [OK] No OAuth permission grants found for user")
|
||||
|
||||
# Check third-party service principals
|
||||
print("\n Checking third-party service principals...")
|
||||
sp_url = f"{GRAPH_BASE}/servicePrincipals?$filter=appOwnerOrganizationId ne {TENANT_ID}&$select=displayName,appId,appOwnerOrganizationId&$top=50"
|
||||
sp_data = graph_get(token, sp_url, "service principals")
|
||||
sps = sp_data.get("value", [])
|
||||
results["third_party_apps"] = sps
|
||||
|
||||
if sps:
|
||||
print(f" Third-party apps in tenant ({len(sps)}):")
|
||||
# Known Microsoft org IDs
|
||||
ms_org_ids = [
|
||||
"f8cdef31-a31e-4b4a-93e4-5f571e91255a", # Microsoft
|
||||
"47df5bb7-e6bc-4256-afb0-dd8c8e3c1ce8", # Microsoft
|
||||
"72f988bf-86f1-41af-91ab-2d7cd011db47", # Microsoft Corp
|
||||
]
|
||||
for sp in sps:
|
||||
org_id = sp.get("appOwnerOrganizationId", "")
|
||||
is_ms = org_id in ms_org_ids
|
||||
if not is_ms:
|
||||
print(f" [INFO] {sp.get('displayName', 'N/A')} | AppID: {sp.get('appId', 'N/A')} | OrgID: {org_id}")
|
||||
else:
|
||||
print(" No third-party service principals found or filter not supported")
|
||||
|
||||
# ===== STEP 8: AUDIT LOGS =====
|
||||
print_separator("STEP 8: DIRECTORY AUDIT LOGS (Recent Changes)")
|
||||
|
||||
# Try different filter approaches
|
||||
audit_url = f"{GRAPH_BASE}/auditLogs/directoryAudits?$filter=targetResources/any(t:t/userPrincipalName eq '{jr_upn}')&$top=50&$orderby=activityDateTime desc"
|
||||
audit_data = graph_get(token, audit_url, "audit logs")
|
||||
audit_entries = audit_data.get("value", [])
|
||||
|
||||
if not audit_entries and "error" in audit_data:
|
||||
# Try beta
|
||||
print(" [*] Trying beta endpoint...")
|
||||
audit_url = f"{GRAPH_BETA}/auditLogs/directoryAudits?$filter=targetResources/any(t:t/userPrincipalName eq '{jr_upn}')&$top=50&$orderby=activityDateTime desc"
|
||||
audit_data = graph_get(token, audit_url, "audit logs beta")
|
||||
audit_entries = audit_data.get("value", [])
|
||||
|
||||
if not audit_entries and "error" in audit_data:
|
||||
# Try without filter
|
||||
print(" [*] Trying unfiltered audit logs (will filter manually)...")
|
||||
audit_url = f"{GRAPH_BASE}/auditLogs/directoryAudits?$top=100&$orderby=activityDateTime desc"
|
||||
audit_data = graph_get(token, audit_url, "audit logs unfiltered")
|
||||
all_audits = audit_data.get("value", [])
|
||||
# Filter manually
|
||||
for a in all_audits:
|
||||
targets = a.get("targetResources", [])
|
||||
for t in targets:
|
||||
if jr_upn.lower() in t.get("userPrincipalName", "").lower() or jr_id in t.get("id", ""):
|
||||
audit_entries.append(a)
|
||||
break
|
||||
|
||||
results["audit_logs"] = audit_entries
|
||||
|
||||
if audit_entries:
|
||||
# Key activities to watch for
|
||||
critical_activities = [
|
||||
"Reset password", "Change password", "Reset user password",
|
||||
"Update user", "Add member to role", "Add app role assignment",
|
||||
"Consent to application", "Add OAuth2PermissionGrant",
|
||||
"Update authentication method", "Register security info",
|
||||
"Add registered owner", "Add service principal",
|
||||
"Set force change password", "Disable account"
|
||||
]
|
||||
|
||||
for a in audit_entries:
|
||||
activity = a.get("activityDisplayName", "N/A")
|
||||
ts = a.get("activityDateTime", "N/A")
|
||||
result_status = a.get("result", "N/A")
|
||||
initiated_by = a.get("initiatedBy", {})
|
||||
user_info = initiated_by.get("user") or {}
|
||||
app_info = initiated_by.get("app") or {}
|
||||
actor_upn = user_info.get("userPrincipalName", "N/A")
|
||||
actor_app = app_info.get("displayName", "")
|
||||
|
||||
is_critical = any(ca.lower() in activity.lower() for ca in critical_activities)
|
||||
marker = "[CRITICAL]" if is_critical else " "
|
||||
|
||||
actor = actor_upn if actor_upn != "N/A" else actor_app
|
||||
print(f" {marker} {ts} | {activity} | Result: {result_status} | Actor: {actor}")
|
||||
|
||||
if is_critical:
|
||||
targets = a.get("targetResources", [])
|
||||
for t in targets:
|
||||
modified = t.get("modifiedProperties", [])
|
||||
for mp in modified:
|
||||
print(f" Changed: {mp.get('displayName', 'N/A')}: {str(mp.get('oldValue', ''))[:80]} -> {str(mp.get('newValue', ''))[:80]}")
|
||||
else:
|
||||
print(" No audit log entries found for target user")
|
||||
|
||||
# ===== STEP 9: CHECK ALL OTHER USERS FOR RISKY SIGN-INS =====
|
||||
print_separator("STEP 9: LATERAL MOVEMENT CHECK (All Users Risky Sign-ins)")
|
||||
|
||||
other_users = [u for u in users if u.get("id") != jr_id and u.get("accountEnabled", False)]
|
||||
results["lateral_movement"] = {}
|
||||
|
||||
for u in other_users:
|
||||
upn = u.get("userPrincipalName", "")
|
||||
name = u.get("displayName", "")
|
||||
if not upn:
|
||||
continue
|
||||
|
||||
# Check for risky sign-ins
|
||||
risk_url = f"{GRAPH_BASE}/auditLogs/signIns?$filter=userPrincipalName eq '{upn}'&$top=10&$orderby=createdDateTime desc"
|
||||
risk_data = graph_get(token, risk_url, f"risk check {upn}")
|
||||
risk_signins = risk_data.get("value", [])
|
||||
|
||||
user_risks = []
|
||||
for s in risk_signins:
|
||||
risk_level = s.get("riskLevelDuringSignIn", "none")
|
||||
risk_state = s.get("riskState", "none")
|
||||
status_code = s.get("status", {}).get("errorCode", 0)
|
||||
ip = s.get("ipAddress", "N/A")
|
||||
loc = s.get("location", {})
|
||||
country = loc.get("countryOrRegion", "")
|
||||
ts = s.get("createdDateTime", "")
|
||||
app = s.get("clientAppUsed", "")
|
||||
|
||||
# Flag anything risky
|
||||
is_risky = risk_level not in ("none", "low", None, "")
|
||||
is_foreign = country not in ("US", "Unknown", "", None)
|
||||
is_legacy = app in ("IMAP4", "POP3", "SMTP", "Authenticated SMTP", "Other clients")
|
||||
is_failed = status_code != 0
|
||||
|
||||
if is_risky or is_foreign or is_legacy:
|
||||
user_risks.append({
|
||||
"timestamp": ts, "ip": ip, "country": country,
|
||||
"risk_level": risk_level, "protocol": app,
|
||||
"failed": is_failed
|
||||
})
|
||||
|
||||
if user_risks:
|
||||
print(f"\n [SUSPICIOUS] {name} ({upn}):")
|
||||
for r in user_risks:
|
||||
print(f" {r['timestamp']} | IP: {r['ip']} | Country: {r['country']} | Risk: {r['risk_level']} | Protocol: {r['protocol']}")
|
||||
results["lateral_movement"][upn] = user_risks
|
||||
else:
|
||||
print(f" [OK] {name} ({upn}): No risky sign-ins detected")
|
||||
|
||||
# ===== SAVE RESULTS =====
|
||||
print_separator("SAVING RESULTS")
|
||||
with open(RESULTS_FILE, "w") as f:
|
||||
json.dump(results, f, indent=2, default=str)
|
||||
print(f" Results saved to: {RESULTS_FILE}")
|
||||
|
||||
# ===== INCIDENT REPORT SUMMARY =====
|
||||
print_separator("INCIDENT REPORT SUMMARY")
|
||||
print(f"""
|
||||
Target: {jr_upn} (ID: {jr_id})
|
||||
Investigation Date: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}
|
||||
Tenant: Valley Wide Plastering ({TENANT_ID})
|
||||
Total Users in Tenant: {len(users)}
|
||||
|
||||
KEY FINDINGS:
|
||||
=============
|
||||
""")
|
||||
|
||||
# Summarize findings
|
||||
findings = []
|
||||
|
||||
# Sign-in findings
|
||||
signin_summary = results.get("signin_summary", {})
|
||||
if signin_summary:
|
||||
if signin_summary.get("failed", 0) > 10:
|
||||
findings.append(f"[CRITICAL] {signin_summary['failed']} failed sign-ins detected - possible brute force")
|
||||
if signin_summary.get("risky", 0) > 0:
|
||||
findings.append(f"[CRITICAL] {signin_summary['risky']} risky sign-ins flagged by Microsoft")
|
||||
if signin_summary.get("legacy_auth_count", 0) > 0:
|
||||
findings.append(f"[SUSPICIOUS] {signin_summary['legacy_auth_count']} legacy auth protocol usage detected")
|
||||
if signin_summary.get("unique_ips", 0) > 5:
|
||||
findings.append(f"[SUSPICIOUS] {signin_summary['unique_ips']} unique IP addresses used")
|
||||
|
||||
# Mail findings
|
||||
mail_summary = results.get("sent_mail_summary", {})
|
||||
if mail_summary:
|
||||
if mail_summary.get("suspicious_count", 0) > 0:
|
||||
findings.append(f"[SUSPICIOUS] {mail_summary['suspicious_count']} emails with suspicious subjects")
|
||||
if len(mail_summary.get("external_recipients", [])) > 10:
|
||||
findings.append(f"[SUSPICIOUS] {len(mail_summary['external_recipients'])} external recipients in sent mail")
|
||||
|
||||
# Inbox rules
|
||||
if rules:
|
||||
for rule in rules:
|
||||
actions = rule.get("actions", {})
|
||||
if actions.get("forwardTo") or actions.get("redirectTo") or actions.get("forwardAsAttachmentTo"):
|
||||
findings.append(f"[CRITICAL] Inbox rule '{rule.get('displayName', 'N/A')}' forwards/redirects mail externally")
|
||||
if actions.get("delete") or actions.get("permanentDelete"):
|
||||
findings.append(f"[CRITICAL] Inbox rule '{rule.get('displayName', 'N/A')}' deletes incoming mail")
|
||||
|
||||
# Auto-reply
|
||||
if results.get("mailbox_settings", {}).get("automaticRepliesSetting", {}).get("status", "disabled") != "disabled":
|
||||
findings.append("[SUSPICIOUS] Auto-replies are enabled - check for phishing content")
|
||||
|
||||
# OAuth
|
||||
if oauth_grants:
|
||||
findings.append(f"[INFO] {len(oauth_grants)} OAuth grants found - review for suspicious app access")
|
||||
|
||||
# Lateral movement
|
||||
lateral = results.get("lateral_movement", {})
|
||||
if lateral:
|
||||
findings.append(f"[SUSPICIOUS] {len(lateral)} other users show suspicious sign-in activity")
|
||||
|
||||
if findings:
|
||||
for f in findings:
|
||||
print(f" {f}")
|
||||
else:
|
||||
print(" No critical findings detected. Review detailed output above for context.")
|
||||
|
||||
print(f"""
|
||||
RECOMMENDED ACTIONS:
|
||||
====================
|
||||
1. Reset JR Guerrero's password immediately
|
||||
2. Revoke all active sessions (Entra ID > Users > Revoke sessions)
|
||||
3. Enable MFA if not already enabled
|
||||
4. Remove any suspicious inbox rules
|
||||
5. Disable any unauthorized OAuth app grants
|
||||
6. Block legacy authentication via Conditional Access
|
||||
7. Review sent items for any phishing emails sent from this account
|
||||
8. Notify recipients of any suspicious emails
|
||||
9. Check for data exfiltration via OneDrive/SharePoint
|
||||
10. Monitor account for next 30 days
|
||||
|
||||
Investigation script: D:/ClaudeTools/temp/vwp_bec_investigation.py
|
||||
Raw results: {RESULTS_FILE}
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user