Files
claudetools/temp/vwp_bec_investigation.py
Mike Swanson fa15b03180 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>
2026-03-10 19:59:08 -07:00

722 lines
30 KiB
Python

#!/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()