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>
722 lines
30 KiB
Python
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()
|