Lesson 3 of 3 · 20 min read

Azure AD / Entra ID Investigation — Conditional Access, Service Principals, and Tenant-Wide Scope

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)