Azure AD (now Entra ID) is the identity backbone of M365 — compromise of the directory itself is the highest-severity cloud IR scenario.
Azure AD Sign-In Log Analysis
# Connect to Microsoft Graph (requires appropriate permissions)
Connect-MgGraph -Scopes "AuditLog.Read.All", "Directory.Read.All"
# Get sign-in logs for compromised user
$signins = Get-MgAuditLogSignIn -Filter `
"userPrincipalName eq 'jsmith@company.com' and createdDateTime gt 2024-09-01" `
-All
$signins | Select-Object CreatedDateTime, IpAddress, Location,
ClientAppUsed, ConditionalAccessStatus, Status, AppDisplayName |
Format-Table -AutoSize
# Find legacy authentication (bypass MFA)
$legacy_auth = Get-MgAuditLogSignIn -Filter `
"clientAppUsed eq 'IMAP4' or clientAppUsed eq 'POP3' or
clientAppUsed eq 'SMTP' or clientAppUsed eq 'Exchange ActiveSync'" `
-All | Where-Object {$_.CreatedDateTime -gt (Get-Date).AddDays(-30)}
# Find risky sign-ins
$risky = Get-MgAuditLogSignIn -Filter `
"riskLevelDuringSignIn eq 'high' or riskLevelDuringSignIn eq 'medium'" `
-All | Select-Object CreatedDateTime, UserPrincipalName, IpAddress, `
RiskLevelDuringSignIn, RiskStateDetail
Azure AD Audit Log — Detecting Persistence
# Get directory audit events for tenant changes
Get-MgAuditLogDirectoryAudit -Filter `
"activityDateTime gt 2024-09-01 and
(activityDisplayName eq 'Add member to role' or
activityDisplayName eq 'Add service principal' or
activityDisplayName eq 'Add app role assignment to service principal' or
activityDisplayName eq 'Update application – Certificates and secrets management')" `
-All |
Select-Object ActivityDateTime, ActivityDisplayName, InitiatedBy, `
TargetResources | Format-List
# Check for recently added service principal credentials
Get-MgApplication | ForEach-Object {
$app = $_
$creds = Get-MgApplicationPassword -ApplicationId $app.Id
$creds | Where-Object {$_.StartDateTime -gt (Get-Date).AddDays(-30)} |
ForEach-Object {
Write-Host "⚠ Recent credential on app: $($app.DisplayName) | Added: $($_.StartDateTime)"
}
}
# Check for new Global Admins
Get-MgDirectoryRole -Filter "displayName eq 'Global Administrator'" |
ForEach-Object {
Get-MgDirectoryRoleMember -DirectoryRoleId $_.Id
} | Select-Object DisplayName, UserPrincipalName, CreatedDateTime
Azure AD Containment
# Step 1: Disable compromised user account
Update-MgUser -UserId "jsmith@company.com" -AccountEnabled:$false
# Step 2: Revoke all active sessions/tokens
Revoke-MgUserSignInSession -UserId "jsmith@company.com"
# Step 3: Remove from all admin roles
$admin_roles = Get-MgUserMemberOf -UserId "jsmith@company.com" |
Where-Object {$_.AdditionalProperties.displayName -like "*Admin*"}
$admin_roles | ForEach-Object {
Remove-MgDirectoryRoleMemberByRef `
-DirectoryRoleId $_.Id `
-DirectoryObjectId "jsmith@company.com"
}
# Step 4: Create emergency Conditional Access policy (block specific user or IP)
# This is faster than waiting for account changes to propagate
# Azure Portal → Entra ID → Security → Conditional Access → New Policy
# OR via Graph API:
$body = @{
displayName = "INCIDENT-BLOCK-$(Get-Date -Format yyyyMMdd)"
state = "enabled"
conditions = @{
users = @{ includeUsers = @("jsmith@company.com") }
applications = @{ includeApplications = @("All") }
}
grantControls = @{ operator = "OR"; builtInControls = @("block") }
}
Invoke-MgGraphRequest -Method POST -Uri "/v1.0/identity/conditionalAccessPolicies" `
-Body ($body | ConvertTo-Json -Depth 5)