prodfail/m365-ir-toolkit
GitHub: prodfail/m365-ir-toolkit
一套用于 Microsoft 365 安全事件调查的只读 PowerShell 命令工具包,提供登录分析、审计日志取证和带证据链管理的标准化排查流程。
Stars: 1 | Forks: 0
# M365 Incident Response: PowerShell Investigation Toolkit
**Environment:** PowerShell 7.x (`pwsh`) on macOS, Windows, or Linux
**Modules assumed installed:** `ExchangeOnlineManagement` (v3.7.0+), `Microsoft.Graph`
**Posture:** **READ-ONLY.** All commands are non-destructive. This toolkit does not include `Set-`, `New-`, `Remove-`, `Add-`, or `Revoke-` commands. If you adapt any command into a write/containment action, flag it clearly with a `# ⚠️ WRITE OPERATION` comment.
A modular, copy-paste-ready set of PowerShell commands for investigating Microsoft 365 security incidents: phishing, business email compromise (BEC), account takeover, MFA/AiTM bypass, inbox-rule persistence, OAuth abuse, data exfiltration, and audit-log gaps.
All tenant-specific values (admin UPN, target user, date window, IOCs) are collected at runtime via prompts. Nothing is hardcoded. Set your environment once and the variables flow through every module.
## Step 1: Incident Intake (Run First)
Before running any commands, work through a structured triage:
1. What happened, and how was it detected? (user report, alert, SOC, automated detection)
2. Which user(s) or mailbox(es) are involved? (provide UPN, e.g. `user@example.com`)
3. What is the approximate timeframe? (date/time range, timezone)
4. Do you have any known IOCs? (sender addresses, IPs, subject lines, URLs, attachment names)
5. Has any containment action already been taken? (account disabled, sessions revoked, etc.)
6. Which M365 workload(s) are involved? (Exchange, Entra ID/sign-ins, Teams, SharePoint, Defender)
7. Is this a known incident type? (phishing, BEC, account takeover, spam, data leak, audit gap, other)
Use the answers to select the relevant modules below. Run the **Preflight Tooling Check** and **Module 0: Connection Setup** first in every investigation.
## Data Retention Limits (Know Before You Query)
Set your investigation window with these ceilings in mind. Queries beyond a source's retention return silently empty results, which mid-incident is worse than an error.
| Source | Retention | Notes |
|---|---|---|
| Entra sign-in logs (Graph) | 30 days (P1/P2), 7 days (free) | Interactive and non-interactive |
| Entra directory audit (Graph) | 30 days | Password resets, MFA changes, role grants |
| Unified Audit Log | 180 days (Audit Standard), 1 year (Audit Premium) | Verify ingestion is on (Module 11) |
| Message trace (V2) | 90 days total, **max 10 days per query** | 5000 results per query, no pagination cmdlet; see Module 4 paging pattern |
| Quarantine | 30 days | |
## Preflight: Tooling Check
Run this before Module 0. It verifies the PowerShell version and required modules, reports anything missing or outdated, and asks before installing or updating anything. Nothing is downloaded without an explicit `Y`.
# ── Requirements ─────────────────────────────────────────────────────────────
$RequiredPSMajor = 7
$RequiredModules = @(
@{ Name = 'ExchangeOnlineManagement'; MinVersion = [version]'3.7.0' } # 3.7.0+ required for Get-MessageTraceV2
@{ Name = 'Microsoft.Graph'; MinVersion = [version]'2.0.0' }
)
$PreflightPass = $true
# ── PowerShell version ───────────────────────────────────────────────────────
if ($PSVersionTable.PSVersion.Major -lt $RequiredPSMajor) {
Write-Host "[FAIL] PowerShell $($PSVersionTable.PSVersion) detected. This toolkit requires PowerShell $RequiredPSMajor+ (pwsh)." -ForegroundColor Red
Write-Host " Install from https://learn.microsoft.com/powershell/scripting/install/installing-powershell and re-run in pwsh." -ForegroundColor Yellow
$PreflightPass = $false
} else {
Write-Host "[ OK ] PowerShell $($PSVersionTable.PSVersion)" -ForegroundColor Green
}
# ── Module checks (report first, then gated install/update) ──────────────────
foreach ($req in $RequiredModules) {
$installed = Get-Module -ListAvailable -Name $req.Name |
Sort-Object Version -Descending | Select-Object -First 1
if (-not $installed) {
Write-Host "[MISS] $($req.Name) is not installed (minimum $($req.MinVersion) required)." -ForegroundColor Red
$answer = Read-Host " Install $($req.Name) from PSGallery now? (Y/N)"
if ($answer -match '^[Yy]') {
# ⚠️ WRITE OPERATION (local machine only): installs a module to CurrentUser scope
try {
Install-Module -Name $req.Name -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
Write-Host "[ OK ] $($req.Name) installed." -ForegroundColor Green
} catch {
Write-Host "[FAIL] Install failed: $_" -ForegroundColor Red
$PreflightPass = $false
}
} else {
Write-Host "[SKIP] $($req.Name) not installed. Dependent modules of this toolkit will fail." -ForegroundColor Yellow
$PreflightPass = $false
}
}
elseif ($installed.Version -lt $req.MinVersion) {
Write-Host "[ OLD] $($req.Name) $($installed.Version) found; minimum $($req.MinVersion) required." -ForegroundColor Yellow
$answer = Read-Host " Update $($req.Name) from PSGallery now? (Y/N)"
if ($answer -match '^[Yy]') {
# ⚠️ WRITE OPERATION (local machine only): updates a module in CurrentUser scope
try {
Update-Module -Name $req.Name -Force -ErrorAction Stop
$newVer = (Get-Module -ListAvailable -Name $req.Name | Sort-Object Version -Descending | Select-Object -First 1).Version
Write-Host "[ OK ] $($req.Name) updated to $newVer." -ForegroundColor Green
if ($newVer -lt $req.MinVersion) {
Write-Host "[FAIL] Still below minimum after update. Try Install-Module -Name $($req.Name) -Scope CurrentUser -Force" -ForegroundColor Red
$PreflightPass = $false
}
} catch {
Write-Host "[FAIL] Update failed: $_" -ForegroundColor Red
$PreflightPass = $false
}
} else {
Write-Host "[SKIP] $($req.Name) left at $($installed.Version). Get-MessageTraceV2 and other newer cmdlets may be unavailable." -ForegroundColor Yellow
$PreflightPass = $false
}
}
else {
Write-Host "[ OK ] $($req.Name) $($installed.Version)" -ForegroundColor Green
}
}
# ── Verdict ──────────────────────────────────────────────────────────────────
if ($PreflightPass) {
Write-Host "`nPreflight PASSED. Proceed to Module 0: Connection Setup.`n" -ForegroundColor Green
} else {
Write-Host "`nPreflight FAILED. Resolve the items above before continuing. Running modules against missing tooling will produce errors or silent gaps.`n" -ForegroundColor Red
}
**Notes:**
- `Microsoft.Graph` is the meta-module and pulls all sub-modules used here (Users, Reports, Identity.SignIns, Applications, Security). If you prefer a leaner footprint, install those five sub-modules individually and adjust `$RequiredModules`.
- **Optional:** Module 13 (Power Automate persistence) requires `Microsoft.PowerApps.Administration.PowerShell`. It is not checked here because most investigations don't need it; install on demand with `Install-Module Microsoft.PowerApps.Administration.PowerShell -Scope CurrentUser` when you reach that module.
- Module 12 (threat intel) additionally needs the `THREATFOX_API_KEY` and `OTX_API_KEY` environment variables; the preflight doesn't check those since they're optional to core IR work. Add a check if you want it enforced.
## Case Setup & Evidence Export
Console output dies with the session. Open a case at the start of any investigation that might matter: it creates a case folder, starts a full session transcript, and gives you an `Export-IREvidence` function that writes any module's output to timestamped CSV + JSON with a SHA256 hash logged to an evidence manifest. That manifest is your chain-of-custody record and doubles as audit evidence.
**Usage pattern:** append `| Export-IREvidence -Name "M2_SignIns_TargetUser"` to any module command. Objects pass through, so you still see them on the console.
# ── Open a case (run once per investigation, before or after Module 0) ────────
function New-IRCase {
param(
[string]$CaseId = ("IR-{0}" -f (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss')),
[string]$BasePath = (Join-Path $HOME 'IR-Cases')
)
$script:CaseDir = Join-Path $BasePath $CaseId
$script:CaseId = $CaseId
$script:ManifestPath = Join-Path $script:CaseDir 'evidence-manifest.csv'
New-Item -ItemType Directory -Path (Join-Path $script:CaseDir 'evidence') -Force | Out-Null
if (-not (Test-Path $script:ManifestPath)) {
'TimestampUTC,Name,File,Records,SHA256' | Out-File -FilePath $script:ManifestPath -Encoding utf8
}
Start-Transcript -Path (Join-Path $script:CaseDir "transcript-$CaseId.txt") -Append | Out-Null
Write-Host "Case $CaseId opened. Folder: $script:CaseDir" -ForegroundColor Cyan
Write-Host "Transcript running. Pipe module output through Export-IREvidence to preserve it." -ForegroundColor Cyan
}
# ── Export evidence with hash + manifest entry ────────────────────────────────
function Export-IREvidence {
param(
[Parameter(ValueFromPipeline)]$InputObject,
[Parameter(Mandatory)][string]$Name
)
begin { $items = [System.Collections.Generic.List[object]]::new() }
process { if ($null -ne $InputObject) { $items.Add($InputObject) } }
end {
if (-not $script:CaseDir) {
Write-Warning "No case open. Run New-IRCase first. Output NOT preserved."
return $items
}
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmssZ')
$safeName = $Name -replace '[^\w\-]', '_'
$csvPath = Join-Path $script:CaseDir "evidence/$safeName-$stamp.csv"
$jsonPath = Join-Path $script:CaseDir "evidence/$safeName-$stamp.json"
$items | Export-Csv -Path $csvPath -NoTypeInformation -Encoding utf8
$items | ConvertTo-Json -Depth 10 | Out-File -FilePath $jsonPath -Encoding utf8
$hash = (Get-FileHash -Path $csvPath -Algorithm SHA256).Hash
"$((Get-Date).ToUniversalTime().ToString('o')),$safeName,$csvPath,$($items.Count),$hash" |
Add-Content -Path $script:ManifestPath
Write-Host "[EVIDENCE] $($items.Count) records -> $csvPath" -ForegroundColor DarkCyan
Write-Host " SHA256: $hash" -ForegroundColor DarkCyan
$items # pass through for console viewing / further piping
}
}
# ── Close the case (stops the transcript) ─────────────────────────────────────
function Close-IRCase {
Stop-Transcript | Out-Null
Write-Host "Case $script:CaseId transcript closed. Evidence remains in $script:CaseDir" -ForegroundColor Cyan
}
**Chain-of-custody notes:** the CSV is the hashed artifact of record; the JSON preserves nested structures (AuditData) that CSV flattens. If evidence may support legal or breach proceedings, copy the entire case folder to controlled storage and record the manifest hash values in your incident tracking log. Do not edit exported files; re-export instead.
## Module 0: Connection Setup
Run this block after the preflight passes. It prompts for your tenant details and connections, then stores everything in session variables that every later module reuses. No values are hardcoded.
Dates are stored as **UTC `[datetime]` objects** and consumed two ways: Exchange Online cmdlets take the datetime objects directly (`$StartDate` / `$EndDate`), while Microsoft Graph OData filters require ISO 8601 strings with a `Z` suffix (`$GraphStart` / `$GraphEnd`). Both are generated here; use the right one per module.
# ── Interactive configuration (prompts for all incident-specific values) ─────
$AdminUPN = Read-Host "Enter your admin UPN (e.g. admin@example.com)"
$TargetUser = Read-Host "Enter the affected user's UPN (e.g. user@example.com)"
# Date window: enter in UTC. Leave blank to default to the last 7 days.
$StartInput = Read-Host "Investigation START (UTC, format yyyy-MM-dd HH:mm:ss) [blank = 7 days ago]"
$EndInput = Read-Host "Investigation END (UTC, format yyyy-MM-dd HH:mm:ss) [blank = now]"
if ([string]::IsNullOrWhiteSpace($StartInput)) {
$StartDate = (Get-Date).ToUniversalTime().AddDays(-7)
} else {
$StartDate = [datetime]::SpecifyKind(
[datetime]::ParseExact($StartInput, 'yyyy-MM-dd HH:mm:ss', $null),
[System.DateTimeKind]::Utc)
}
if ([string]::IsNullOrWhiteSpace($EndInput)) {
$EndDate = (Get-Date).ToUniversalTime()
} else {
$EndDate = [datetime]::SpecifyKind(
[datetime]::ParseExact($EndInput, 'yyyy-MM-dd HH:mm:ss', $null),
[System.DateTimeKind]::Utc)
}
# Graph OData filters require ISO 8601 with Z suffix. EXO cmdlets use $StartDate/$EndDate directly.
$GraphStart = $StartDate.ToString("yyyy-MM-ddTHH:mm:ssZ")
$GraphEnd = $EndDate.ToString("yyyy-MM-ddTHH:mm:ssZ")
Write-Host "`nConfigured:" -ForegroundColor Cyan
Write-Host " Admin UPN : $AdminUPN"
Write-Host " Target user : $TargetUser"
Write-Host " Window (EXO) : $StartDate -> $EndDate (UTC)"
Write-Host " Window (Graph): $GraphStart -> $GraphEnd`n"
# ── Connect to Exchange Online ──────────────────────────────────────────────
Connect-ExchangeOnline -UserPrincipalName $AdminUPN -ShowBanner:$false
# ── Connect to Microsoft Graph (broad read scopes for IR) ───────────────────
Connect-MgGraph -Scopes "AuditLog.Read.All","Directory.Read.All","MailboxSettings.Read",`
"User.Read.All","Policy.Read.All","SecurityEvents.Read.All","IdentityRiskyUser.Read.All"
# ── Verify connections ───────────────────────────────────────────────────────
Get-ConnectionInformation # Exchange Online: should show Connected
Get-MgContext # Graph: should show signed-in account and scopes
## Module 1: Account & Identity Baseline
**When to use:** Any account compromise, suspicious sign-in, MFA bypass, privilege escalation, or as a first step in any investigation.
# ── Basic account info ───────────────────────────────────────────────────────
Get-MgUser -UserId $TargetUser -Property DisplayName,UserPrincipalName,AccountEnabled,CreatedDateTime,LastPasswordChangeDateTime,AssignedLicenses,OnPremisesSyncEnabled |
Select-Object DisplayName,UserPrincipalName,AccountEnabled,CreatedDateTime,LastPasswordChangeDateTime,OnPremisesSyncEnabled
# ── MFA / authentication methods registered (with friendly method names) ─────
Get-MgUserAuthenticationMethod -UserId $TargetUser | ForEach-Object {
[PSCustomObject]@{
Id = $_.Id
Method = ($_.AdditionalProperties.'@odata.type' -replace '#microsoft.graph.','')
Detail = ($_.AdditionalProperties | ConvertTo-Json -Compress)
}
} | Format-Table -AutoSize
# ── Assigned roles (check for unexpected privilege) ─────────────────────────
Get-MgUserTransitiveMemberOf -UserId $TargetUser |
Where-Object { $_.'@odata.type' -eq '#microsoft.graph.directoryRole' } |
Select-Object DisplayName, Description
# ── Group memberships ────────────────────────────────────────────────────────
Get-MgUserMemberOf -UserId $TargetUser | Select-Object DisplayName, @{n='Type';e={$_.'@odata.type'}}
# ── Last sign-in activity ────────────────────────────────────────────────────
Get-MgUser -UserId $TargetUser -Property SignInActivity |
Select-Object -ExpandProperty SignInActivity |
Select-Object LastSignInDateTime, LastNonInteractiveSignInDateTime
**Normal vs. suspicious:** `AccountEnabled = True` with expected roles/groups is normal. Unexpected admin roles, a recent `LastPasswordChangeDateTime` you didn't initiate, or unfamiliar registered MFA methods are red flags.
## Module 2: Sign-In Log Analysis
**When to use:** Suspicious sign-ins, impossible travel, MFA bypass (AiTM), account takeover, unfamiliar IP/location, post-phishing review.
**Retention:** Graph sign-in logs cover 30 days with Entra ID P1/P2 (7 days on free tier). Anything older requires an exported SIEM/log-archive copy.
# ── Recent interactive sign-ins ──────────────────────────────────────────────
Get-MgAuditLogSignIn -Filter "userPrincipalName eq '$TargetUser' and createdDateTime ge $GraphStart and createdDateTime le $GraphEnd" -Top 50 |
Select-Object CreatedDateTime, IpAddress, Location, AppDisplayName, ClientAppUsed,
ConditionalAccessStatus, RiskLevelDuringSignIn, RiskState,
@{n='MFADetail';e={$_.AuthenticationDetails | ConvertTo-Json -Compress}},
Status | Sort-Object CreatedDateTime | Format-Table -AutoSize
# ── Flag risky sign-ins ──────────────────────────────────────────────────────
Get-MgAuditLogSignIn -Filter "userPrincipalName eq '$TargetUser' and riskLevelDuringSignIn ne 'none'" -Top 20 |
Select-Object CreatedDateTime, IpAddress, RiskLevelDuringSignIn, RiskState, RiskEventTypes
# ── Non-interactive (service/token) sign-ins ─────────────────────────────────
# NOTE: The signIns collection returns interactive sign-ins by default.
# Non-interactive events require the signInEventTypes filter; "isInteractive eq false" returns nothing.
Get-MgAuditLogSignIn -Filter "userPrincipalName eq '$TargetUser' and signInEventTypes/any(t: t eq 'nonInteractiveUser') and createdDateTime ge $GraphStart" -Top 50 |
Select-Object CreatedDateTime, IpAddress, AppDisplayName, ClientAppUsed, ResourceDisplayName |
Sort-Object CreatedDateTime
# ── Org-wide: any risky sign-ins in window ───────────────────────────────────
Get-MgAuditLogSignIn -Filter "createdDateTime ge $GraphStart and createdDateTime le $GraphEnd and riskLevelDuringSignIn ne 'none'" -Top 50 |
Select-Object CreatedDateTime, UserPrincipalName, IpAddress, RiskLevelDuringSignIn, RiskState
**What to look for:** Sign-ins from unexpected countries/IPs, atypical user agents or automation clients, non-interactive token sign-ins right after a suspicious interactive one (a hallmark of stolen-session/AiTM attacks), and `ConditionalAccessStatus` failures followed by a success.
## Module 3: Unified Audit Log (UAL) Search
**When to use:** Any M365 investigation. Core forensic source. Check it early. Verify logging is enabled before relying on results.
**Result caps:** A single `Search-UnifiedAuditLog` call returns at most 5000 records, and a small `-ResultSize` silently truncates. For anything forensic, use the paged helper below (returns up to ~50k via `ReturnLargeSet`). If a query returns exactly its cap, assume there is more.
# ── Verify audit logging is enabled (run this FIRST) ────────────────────────
Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled
Get-OrganizationConfig | Select-Object AuditDisabled
# Expected: UnifiedAuditLogIngestionEnabled = True | AuditDisabled = False
# ── Paged UAL search helper (use for complete forensic pulls) ────────────────
function Search-UALComplete {
param(
[Parameter(Mandatory)][datetime]$Start,
[Parameter(Mandatory)][datetime]$End,
[string]$UserIds,
[string[]]$Operations,
[string]$RecordType,
[string[]]$IPAddresses
)
$sessionId = [guid]::NewGuid().ToString()
$all = @()
do {
$params = @{
StartDate = $Start
EndDate = $End
SessionId = $sessionId
SessionCommand = 'ReturnLargeSet'
ResultSize = 5000
}
if ($UserIds) { $params.UserIds = $UserIds }
if ($Operations) { $params.Operations = $Operations }
if ($RecordType) { $params.RecordType = $RecordType }
if ($IPAddresses) { $params.IPAddresses = $IPAddresses }
$batch = Search-UnifiedAuditLog @params
if ($batch) { $all += $batch }
} while ($batch -and $batch.Count -eq 5000)
Write-Host "Retrieved $($all.Count) UAL records." -ForegroundColor Cyan
$all | Sort-Object CreatedDate
}
# ── UAL: mailbox access ──────────────────────────────────────────────────────
Search-UALComplete -Start $StartDate -End $EndDate -UserIds $TargetUser `
-Operations MailItemsAccessed,Send,SendAs,SendOnBehalf,SoftDelete,HardDelete |
Select-Object CreatedDate, UserIds, Operations, AuditData
# ── UAL: admin actions ───────────────────────────────────────────────────────
Search-UALComplete -Start $StartDate -End $EndDate -RecordType ExchangeAdmin |
Select-Object CreatedDate, UserIds, Operations, AuditData
# ── UAL: Entra ID / sign-in events ───────────────────────────────────────────
Search-UALComplete -Start $StartDate -End $EndDate -UserIds $TargetUser `
-RecordType AzureActiveDirectoryStsLogon |
Select-Object CreatedDate, UserIds, Operations, AuditData
# ── UAL: inbox rule creation/modification ────────────────────────────────────
Search-UALComplete -Start $StartDate -End $EndDate `
-Operations New-InboxRule,Set-InboxRule,Remove-InboxRule,UpdateInboxRules |
Select-Object CreatedDate, UserIds, Operations, AuditData
# ── Parse AuditData for a specific result (substitute your result object) ─────
# $result.AuditData | ConvertFrom-Json | Format-List
**What to look for:** If `UnifiedAuditLogIngestionEnabled` is `False` you have a logging blind spot; note it and treat results as incomplete. Look for `HardDelete`/`SoftDelete` bursts, `SendAs`/`SendOnBehalf` you don't recognize, and any inbox-rule operations.
## Module 3A: MailItemsAccessed Deep Parse (Breach Scoping)
**When to use:** Any confirmed or suspected mailbox compromise where you must determine *what was actually accessed*, especially where regulated data (PHI, PII, financial) may be involved. This is the module a breach determination hangs on.
**How to read the data.** Each `MailItemsAccessed` record carries an access type and a throttling flag, and interpreting them correctly is the whole game:
- **Bind** = individual messages were opened/read. The `InternetMessageId` list is your item-level access record.
- **Sync** = the client downloaded folder content wholesale. **Presume every item in that folder was accessed.** You will not get item-level IDs for sync; scope by folder contents instead.
- **IsThrottled = True** = more than 1,000 bind records fired within 24 hours and the log stopped recording. **The log undercounts.** You must assume broader access than what is shown, and say so in the determination.
Requires the `Search-UALComplete` helper from Module 3.
function Get-MailItemsAccessedSummary {
param(
[Parameter(Mandatory)][datetime]$Start,
[Parameter(Mandatory)][datetime]$End,
[Parameter(Mandatory)][string]$UserId,
[string[]]$FilterIPs, # optional: restrict to attacker IPs from Module 2
[string[]]$FilterSessionIds # optional: restrict to attacker session IDs
)
$records = Search-UALComplete -Start $Start -End $End -UserIds $UserId -Operations MailItemsAccessed
if (-not $records) { Write-Host "No MailItemsAccessed records in window." -ForegroundColor Yellow; return }
# ── Parse each event ──────────────────────────────────────────────────────
$events = foreach ($r in $records) {
$d = $r.AuditData | ConvertFrom-Json
$accessType = ($d.OperationProperties | Where-Object { $_.Name -eq 'MailAccessType' }).Value
$throttled = ($d.OperationProperties | Where-Object { $_.Name -eq 'IsThrottled' }).Value
$msgIds = @($d.Folders.FolderItems.InternetMessageId | Where-Object { $_ })
[PSCustomObject]@{
TimeUTC = $d.CreationTime
AccessType = $accessType
IsThrottled = ($throttled -eq 'True')
ClientIP = $d.ClientIPAddress
SessionId = $d.SessionId
AppId = $d.AppId
ClientInfo = $d.ClientInfoString
Folders = (@($d.Folders.Path | Where-Object { $_ }) -join '; ')
ItemCount = $msgIds.Count
MessageIds = ($msgIds -join '; ')
}
}
if ($FilterIPs) { $events = $events | Where-Object { $_.ClientIP -in $FilterIPs } }
if ($FilterSessionIds) { $events = $events | Where-Object { $_.SessionId -in $FilterSessionIds } }
if (-not $events) { Write-Host "No events match the supplied IP/session filters." -ForegroundColor Yellow; return }
# ── Session-level rollup ──────────────────────────────────────────────────
$sessions = $events | Group-Object SessionId, ClientIP | ForEach-Object {
$g = $_.Group | Sort-Object TimeUTC
[PSCustomObject]@{
SessionId = $g[0].SessionId
ClientIP = $g[0].ClientIP
ClientInfo = $g[0].ClientInfo
FirstSeenUTC = $g[0].TimeUTC
LastSeenUTC = $g[-1].TimeUTC
BindEvents = @($g | Where-Object { $_.AccessType -eq 'Bind' }).Count
SyncEvents = @($g | Where-Object { $_.AccessType -eq 'Sync' }).Count
UniqueMessages = @(($g.MessageIds -split '; ') | Where-Object { $_ } | Select-Object -Unique).Count
FoldersTouched = (@(($g.Folders -split '; ') | Where-Object { $_ } | Select-Object -Unique) -join '; ')
AnyThrottling = [bool]($g | Where-Object { $_.IsThrottled })
}
}
# ── Scoping warnings ──────────────────────────────────────────────────────
Write-Host "`n════ MAILITEMSACCESSED SCOPING SUMMARY ════" -ForegroundColor Cyan
$sessions | Format-Table SessionId, ClientIP, FirstSeenUTC, LastSeenUTC, BindEvents, SyncEvents, UniqueMessages, AnyThrottling -AutoSize
if ($sessions | Where-Object AnyThrottling) {
Write-Host "[!] THROTTLING DETECTED: the audit log undercounts. Item counts above are a FLOOR, not a total." -ForegroundColor Red
Write-Host " Breach scoping must assume access beyond the logged items for throttled sessions." -ForegroundColor Red
}
if ($sessions | Where-Object { $_.SyncEvents -gt 0 }) {
Write-Host "[!] SYNC ACCESS DETECTED: presume the ENTIRE CONTENTS of the synced folders were accessed." -ForegroundColor Red
Write-Host " Scope those folders by inventorying their contents, not by counting logged items." -ForegroundColor Red
}
Write-Host "Unique message counts reflect Bind (item-level) access only.`n" -ForegroundColor Yellow
# Return both layers: .Sessions for the summary, .Events for item-level detail
[PSCustomObject]@{ Sessions = $sessions; Events = $events }
}
# ── Run it ────────────────────────────────────────────────────────────────────
# Full view of the target's mailbox access in the window:
$mia = Get-MailItemsAccessedSummary -Start $StartDate -End $EndDate -UserId $TargetUser
# Restrict to attacker infrastructure once you have IOCs from Module 2:
# $mia = Get-MailItemsAccessedSummary -Start $StartDate -End $EndDate -UserId $TargetUser -FilterIPs "203.0.113.10","198.51.100.7"
# Preserve both layers as evidence:
# $mia.Sessions | Export-IREvidence -Name "M3A_MIA_Sessions"
# $mia.Events | Export-IREvidence -Name "M3A_MIA_Events"
**Turning this into a record count:** for Bind sessions, the deduplicated `InternetMessageId` list in `.Events` is your accessed-message inventory; resolve IDs to messages (and to affected records) via content search on the mailbox. For Sync sessions, inventory the synced folders' full contents as of the compromise window. If any session was throttled, document that the count is a floor and scope conservatively.
## Module 4: Email & Mailbox Forensics
**When to use:** Phishing investigation, BEC, suspicious sent items, inbox-rule tampering, forwarding, delegation abuse. Inbox rules, forwarding, and OAuth grants are common persistence mechanisms; always check them post-compromise.
**Message trace (V2):** The legacy `Get-MessageTrace`/`Get-MessageTraceDetail` cmdlets were deprecated starting September 1, 2025. Use `Get-MessageTraceV2`/`Get-MessageTraceDetailV2`. Constraints: 90 days of history, **max 10 days per query**, 5000 results per query, no pagination parameter (page manually as shown below), and tenant-wide throttling of 100 queries per 5 minutes. If your window exceeds 10 days, run multiple queries in 10-day slices.
# ── Mailbox overview ─────────────────────────────────────────────────────────
Get-Mailbox -Identity $TargetUser |
Select-Object UserPrincipalName, AuditEnabled, ForwardingAddress, ForwardingSMTPAddress,
DeliverToMailboxAndForward, LitigationHoldEnabled, RetentionPolicy
# ── Inbox rules (look for delete, forward, hide rules) ───────────────────────
Get-InboxRule -Mailbox $TargetUser |
Select-Object Name, Enabled, Priority, Description, ForwardTo, ForwardAsAttachmentTo,
RedirectTo, DeleteMessage, MoveToFolder, SubjectContainsWords, FromAddressContainsWords
# ── Mailbox permissions / delegates ─────────────────────────────────────────
Get-MailboxPermission -Identity $TargetUser |
Where-Object { $_.User -notlike "NT AUTHORITY*" -and $_.User -notlike "S-1-5*" }
Get-RecipientPermission -Identity $TargetUser |
Where-Object { $_.Trustee -notlike "NT AUTHORITY*" }
# ── Message trace: outbound from target ──────────────────────────────────────
$TraceOut = Get-MessageTraceV2 -SenderAddress $TargetUser -StartDate $StartDate -EndDate $EndDate -ResultSize 5000
$TraceOut | Select-Object Received, SenderAddress, RecipientAddress, Subject, Status, FromIP |
Sort-Object Received
# ── Message trace: inbound to target (look for phishing delivery) ────────────
$TraceIn = Get-MessageTraceV2 -RecipientAddress $TargetUser -StartDate $StartDate -EndDate $EndDate -ResultSize 5000
$TraceIn | Select-Object Received, SenderAddress, RecipientAddress, Subject, Status, FromIP |
Sort-Object Received
# ── Manual paging: if a trace returns exactly 5000 rows, there is more ────────
# Use the last row's Received time and RecipientAddress as the next query's anchor:
# $last = ($TraceOut | Sort-Object Received)[-1]
# Get-MessageTraceV2 -SenderAddress $TargetUser -StartDate $StartDate `
# -EndDate $last.Received -StartingRecipientAddress $last.RecipientAddress -ResultSize 5000
# Repeat until fewer than 5000 rows return. Stay under 100 queries per 5 minutes.
# ── Message trace detail (substitute values from V2 trace results) ───────────
# Get-MessageTraceDetailV2 -MessageTraceId "" -RecipientAddress "" |
# Select-Object Date, Event, Action, Detail | Sort-Object Date
# ── Quarantine: check for blocked messages (30-day retention) ─────────────────
Get-QuarantineMessage -RecipientAddress $TargetUser -StartReceivedDate $StartDate -EndReceivedDate $EndDate |
Select-Object ReceivedTime, SenderAddress, Subject, QuarantineTypes, Released
**What to look for:** Forwarding to external/unknown addresses, rules that delete or move mail to hide it (e.g. to `RSS Feeds` or `Conversation History`), delegates you didn't grant, and outbound mail you didn't send.
## Module 5: OAuth App & Consent Grants
**When to use:** Suspicious app activity, post-compromise persistence check, data exfiltration via app, unfamiliar app in sign-in logs.
# ── User-level OAuth grants ──────────────────────────────────────────────────
$UserId = (Get-MgUser -UserId $TargetUser).Id
Get-MgUserOauth2PermissionGrant -UserId $UserId |
Select-Object ClientId, ConsentType, Scope, ResourceId
# ── Expand app display names from grants ─────────────────────────────────────
Get-MgUserOauth2PermissionGrant -UserId $UserId | ForEach-Object {
$app = Get-MgServicePrincipal -ServicePrincipalId $_.ClientId -ErrorAction SilentlyContinue
[PSCustomObject]@{
AppName = $app.DisplayName
AppId = $_.ClientId
ConsentType = $_.ConsentType
Scope = $_.Scope
}
}
# ── Tenant-wide app registrations (look for recently added) ──────────────────
Get-MgApplication -Top 50 |
Select-Object DisplayName, AppId, CreatedDateTime, SignInAudience |
Sort-Object CreatedDateTime -Descending
# ── Service principals with credentials (look for new or unknown) ────────────
Get-MgServicePrincipal -Top 100 |
Where-Object { $_.KeyCredentials.Count -gt 0 -or $_.PasswordCredentials.Count -gt 0 } |
Select-Object DisplayName, AppId, CreatedDateTime
**What to look for:** Consent grants to unfamiliar apps, high-privilege scopes (e.g. `Mail.Read`, `Mail.Send`, `full_access_as_user`), and recently created app registrations or service-principal credentials.
## Module 6: Entra ID / Directory Audit Events
**When to use:** Password changes, MFA method changes, role assignments, guest account creation, device registration, Conditional Access modifications, post-compromise admin actions.
**Retention:** Directory audit logs cover 30 days via Graph.
# ── Directory audit log: user-related events ─────────────────────────────────
Get-MgAuditLogDirectoryAudit -Filter "targetResources/any(t:t/userPrincipalName eq '$TargetUser') and activityDateTime ge $GraphStart" -Top 50 |
Select-Object ActivityDateTime, ActivityDisplayName, InitiatedBy, TargetResources, Result |
Sort-Object ActivityDateTime
# ── Password changes / resets in window ──────────────────────────────────────
Get-MgAuditLogDirectoryAudit -Filter "activityDisplayName eq 'Reset password' and activityDateTime ge $GraphStart" -Top 20 |
Select-Object ActivityDateTime, ActivityDisplayName,
@{n='InitiatedBy';e={$_.InitiatedBy.User.UserPrincipalName}},
@{n='Target';e={$_.TargetResources[0].UserPrincipalName}}
# ── MFA method registration/deletion ─────────────────────────────────────────
Get-MgAuditLogDirectoryAudit -Filter "activityDisplayName eq 'User registered security info' and activityDateTime ge $GraphStart" -Top 20 |
Select-Object ActivityDateTime, @{n='User';e={$_.TargetResources[0].UserPrincipalName}}, Result
# ── Device registrations (common AiTM persistence step) ──────────────────────
Get-MgAuditLogDirectoryAudit -Filter "activityDisplayName eq 'Add registered device' and activityDateTime ge $GraphStart" -Top 20 |
Select-Object ActivityDateTime, ActivityDisplayName,
@{n='InitiatedBy';e={$_.InitiatedBy.User.UserPrincipalName}},
@{n='Device';e={$_.TargetResources[0].DisplayName}}, Result
# ── Admin role assignment changes ────────────────────────────────────────────
Get-MgAuditLogDirectoryAudit -Filter "category eq 'RoleManagement' and activityDateTime ge $GraphStart" -Top 20 |
Select-Object ActivityDateTime, ActivityDisplayName,
@{n='InitiatedBy';e={$_.InitiatedBy.User.UserPrincipalName}}, TargetResources, Result
# ── Conditional Access policy changes ────────────────────────────────────────
Get-MgAuditLogDirectoryAudit -Filter "category eq 'Policy' and activityDateTime ge $GraphStart" -Top 20 |
Select-Object ActivityDateTime, ActivityDisplayName,
@{n='InitiatedBy';e={$_.InitiatedBy.User.UserPrincipalName}}, Result
**What to look for:** Password resets or MFA registrations you didn't perform, device registrations from the compromise window (token-persistence beachhead), unexpected role grants, new guest accounts, and Conditional Access weakening around the incident window.
## Module 7: Threat Hunting: Lateral Movement & Org-Wide Scope
**When to use:** After confirming a single account is compromised. Expand scope to look for lateral movement, other targets of the same campaign, or attacker pivoting. If your org uses multiple/legacy domains, check that transport and spoofing protections are consistent across all of them.
Uses the `Search-UALComplete` helper from Module 3. Mind the message trace throttle (100 queries per 5 minutes tenant-wide).
# ── Prompt for IOCs discovered so far ────────────────────────────────────────
$SuspiciousSender = Read-Host "Suspicious sender address (blank to skip)"
$SuspiciousIP = Read-Host "Suspicious IP (blank to skip)"
$SuspiciousCountry = Read-Host "Suspicious country code, e.g. RU/CN (blank to skip)"
$SubjectKeyword = Read-Host "Phishing subject keyword (blank to skip)"
# ── Message trace: org-wide delivery from suspicious sender ──────────────────
if ($SuspiciousSender) {
Get-MessageTraceV2 -SenderAddress $SuspiciousSender -StartDate $StartDate -EndDate $EndDate -ResultSize 5000 |
Select-Object Received, SenderAddress, RecipientAddress, Subject, Status | Sort-Object Received
}
# ── UAL: org-wide activity from a suspicious IP (server-side filter) ──────────
if ($SuspiciousIP) {
Search-UALComplete -Start $StartDate -End $EndDate -IPAddresses $SuspiciousIP |
Select-Object CreatedDate, UserIds, Operations, AuditData
}
# ── Org-wide: sign-ins from a specific country ───────────────────────────────
if ($SuspiciousCountry) {
Get-MgAuditLogSignIn -Filter "location/countryOrRegion eq '$SuspiciousCountry' and createdDateTime ge $GraphStart" -Top 50 |
Select-Object CreatedDateTime, UserPrincipalName, IpAddress, Location, Status
}
# ── All users who received a message with a specific subject (server-side) ────
if ($SubjectKeyword) {
Get-MessageTraceV2 -StartDate $StartDate -EndDate $EndDate `
-Subject $SubjectKeyword -SubjectFilterType Contains -ResultSize 5000 |
Select-Object RecipientAddress, Subject, SenderAddress, Status
}
# ── Org-wide: any new inbox rules created in the window ──────────────────────
Search-UALComplete -Start $StartDate -End $EndDate -Operations New-InboxRule,Set-InboxRule |
Select-Object CreatedDate, UserIds, Operations, AuditData
**What to look for:** The same IOC touching multiple mailboxes, other recipients of the phishing subject, and a cluster of new inbox rules across users (campaign-level persistence).
## Module 8: Microsoft Defender & Security Alerts
**When to use:** Active malware alerts, endpoint detections, Defender for Office 365 findings, safe-links/attachments triggers, identity-protection alerts.
**Cmdlet note:** The legacy Graph alerts API (`Get-MgSecurityAlert`) is deprecated; use `Get-MgSecurityAlertV2` (alerts_v2). The old `Get-AdvancedThreatProtectionDocumentReport` cmdlet is retired; use `Get-SafeLinksAggregateReport` for summary data and the Defender portal (Explorer / Advanced Hunting) for per-click and per-file detail.
# ── Identity Protection: risky users ─────────────────────────────────────────
Get-MgRiskyUser -Filter "riskState ne 'dismissed' and riskState ne 'remediated'" |
Select-Object UserPrincipalName, RiskLevel, RiskState, RiskLastUpdatedDateTime |
Sort-Object RiskLastUpdatedDateTime -Descending
# ── Identity Protection: risk history for the target user ────────────────────
Get-MgRiskyUserHistory -RiskyUserId (Get-MgUser -UserId $TargetUser).Id |
Select-Object Activity, InitiatedBy, UserDisplayName
# ── Security alerts (alerts_v2) in window ─────────────────────────────────────
Get-MgSecurityAlertV2 -Filter "createdDateTime ge $GraphStart" -Top 50 |
Select-Object Title, Severity, Status, CreatedDateTime, Category, DetectionSource |
Sort-Object CreatedDateTime -Descending
# ── Alerts touching the target user (alerts_v2 has no user filter; check evidence client-side)
Get-MgSecurityAlertV2 -Filter "createdDateTime ge $GraphStart" -Top 100 |
Where-Object { ($_.Evidence | ConvertTo-Json -Depth 5) -match [regex]::Escape($TargetUser) } |
Select-Object Title, Severity, Status, CreatedDateTime
# ── Org-wide high/medium alerts ──────────────────────────────────────────────
Get-MgSecurityAlertV2 -Filter "severity eq 'high' or severity eq 'medium'" -Top 50 |
Select-Object Title, Severity, Status, CreatedDateTime |
Sort-Object CreatedDateTime -Descending
# ── Safe Links aggregate report (Defender for O365) ──────────────────────────
Get-SafeLinksAggregateReport -StartDate $StartDate -EndDate $EndDate |
Select-Object Action, MessageCount, RecipientCount
# Per-click detail: Defender portal > Explorer, or Advanced Hunting (UrlClickEvents table)
**What to look for:** Any risky-user entry for your target, high/medium alerts clustered around the window, and Safe Links blocks tied to the campaign.
## Module 9: SharePoint & OneDrive Activity
**When to use:** Suspected data exfiltration, file-sharing anomalies, unauthorized document access, leaked files.
Uses the `Search-UALComplete` helper from Module 3 to avoid silent truncation on bulk downloads.
# ── SharePoint / OneDrive access audit ───────────────────────────────────────
Search-UALComplete -Start $StartDate -End $EndDate -UserIds $TargetUser -RecordType SharePointFileOperation |
Select-Object CreatedDate, UserIds, Operations, AuditData
# ── External sharing events ──────────────────────────────────────────────────
Search-UALComplete -Start $StartDate -End $EndDate `
-Operations SharingInvitationCreated,AnonymousLinkCreated,SecureLinkCreated |
Select-Object CreatedDate, UserIds, Operations, AuditData
# ── Large / bulk file downloads ──────────────────────────────────────────────
Search-UALComplete -Start $StartDate -End $EndDate -UserIds $TargetUser -Operations FileDownloaded |
Select-Object CreatedDate, UserIds, Operations, AuditData
**What to look for:** Bursts of `FileDownloaded`, anonymous/external sharing links created around the incident, and access to sensitive libraries the user doesn't normally touch.
## Module 10: Spam, Quarantine & Transport Rule Analysis
**When to use:** Suspicious delivery failures, phishing that bypassed filters, quarantine releases, transport-rule investigations, or inconsistent mail-flow protection across domains.
# ── Current transport rules ──────────────────────────────────────────────────
Get-TransportRule |
Select-Object Name, State, Priority, Description, Conditions, Actions | Sort-Object Priority
# ── Quarantine: recent messages (30-day retention) ────────────────────────────
Get-QuarantineMessage -StartReceivedDate $StartDate -EndReceivedDate $EndDate -PageSize 100 |
Select-Object ReceivedTime, SenderAddress, RecipientAddress, Subject, QuarantineTypes, Released |
Sort-Object ReceivedTime -Descending
# ── Quarantine: check for a specific sender ──────────────────────────────────
if ($SuspiciousSender) {
Get-QuarantineMessage -SenderAddress $SuspiciousSender -StartReceivedDate $StartDate -EndReceivedDate $EndDate |
Select-Object ReceivedTime, SenderAddress, RecipientAddress, Subject, QuarantineTypes
}
# ── Message trace detail for a specific message (V2; substitute values) ───────
# Get-MessageTraceDetailV2 -MessageTraceId "" -RecipientAddress "" |
# Select-Object Date, Event, Action, Detail | Sort-Object Date
# ── DKIM / accepted-domain settings ──────────────────────────────────────────
Get-DkimSigningConfig | Select-Object Domain, Enabled, Status
Get-AcceptedDomain | Select-Object Name, DomainType, Default
**What to look for:** Transport rules that bypass filtering or allow-list senders, quarantine releases you didn't authorize, and DKIM/SPF/DMARC gaps, especially inconsistent enforcement across multiple accepted domains.
## Module 11: Audit Log Health Check
**When to use:** Beginning of any investigation, or any time you suspect logging gaps. Also worth running proactively on a schedule.
# ── Verify unified audit logging is on ───────────────────────────────────────
Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled
Get-OrganizationConfig | Select-Object AuditDisabled
# Expected: UnifiedAuditLogIngestionEnabled = True | AuditDisabled = False
# ── Check mailbox auditing across all mailboxes ──────────────────────────────
Get-Mailbox -ResultSize Unlimited |
Select-Object UserPrincipalName, AuditEnabled |
Where-Object { $_.AuditEnabled -eq $false }
# Goal: zero results (all mailboxes should have AuditEnabled = True)
# ── Summary count ────────────────────────────────────────────────────────────
Get-Mailbox -ResultSize Unlimited | Group-Object AuditEnabled | Select-Object Name, Count
# ── Admin audit log config, full view ────────────────────────────────────────
Get-AdminAuditLogConfig | Format-List
**What to look for:** Any mailbox with `AuditEnabled = False`, or tenant-level auditing disabled. Both create forensic blind spots that undermine every other module.
## Module 12: Threat Intelligence Enrichment: IOC Lookup
**When to use:** Any time IOCs are identified (IPs, domains, URLs, file hashes). Run concurrently with other modules as IOCs surface; don't wait until the end. Cross-references three free feeds: **ThreatFox**, **AlienVault OTX**, and **OpenPhish**.
**Prerequisites: free API keys (one-time setup):**
- **ThreatFox**: , register and get an `Auth-Key`
- **OTX**: , register and get an `X-OTX-API-KEY`
- **OpenPhish**: no key required for the community feed (URL list only)
Store keys as environment variables (add to `~/.zshrc` or `~/.bashrc`):
export THREATFOX_API_KEY="your-key-here"
export OTX_API_KEY="your-key-here"
The functions below read those variables automatically.
### 12a: ThreatFox Lookup (IPs, Domains, URLs, Hashes)
function Invoke-ThreatFoxLookup {
param(
[Parameter(Mandatory)][string]$IOC,
[string]$ApiKey = $env:THREATFOX_API_KEY
)
$body = @{ query = "search_ioc"; search_term = $IOC } | ConvertTo-Json
$headers = @{ "Auth-Key" = $ApiKey; "Content-Type" = "application/json" }
try {
$response = Invoke-RestMethod -Uri "https://threatfox-api.abuse.ch/api/v1/" `
-Method POST -Headers $headers -Body $body
if ($response.query_status -eq "no_result") {
Write-Host "[$IOC] ThreatFox: No match found." -ForegroundColor Green
} else {
$response.data | Select-Object ioc, threat_type, ioc_type, malware_printable,
confidence_level, first_seen, last_seen, @{n='Tags';e={$_.tags -join ', '}} |
Format-List
}
} catch {
Write-Warning "ThreatFox lookup failed for $IOC`: $_"
}
}
# ── Prompt for IOCs, then look each one up ───────────────────────────────────
$IOCInput = Read-Host "Enter IOCs to check (comma-separated: IPs, domains, URLs, hashes)"
$IOCs = $IOCInput -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
foreach ($ioc in $IOCs) {
Write-Host "`n=== ThreatFox: $ioc ===" -ForegroundColor Cyan
Invoke-ThreatFoxLookup -IOC $ioc
}
**What to look for:** `threat_type` of `phishing` or `botnet_cc` (direct confirmation of malicious infrastructure); `malware_printable` matching known AiTM/phishing kits (e.g. Evilginx, Modlishka, Tycoon2FA); `confidence_level >= 75` (treat as confirmed); and `first_seen`/`last_seen` to gauge whether the campaign is active.
### 12b: AlienVault OTX Lookup (IPs, Domains, File Hashes)
function Invoke-OTXLookup {
param(
[Parameter(Mandatory)][string]$Indicator,
[ValidateSet("IPv4","IPv6","domain","url","FileHash-MD5","FileHash-SHA256")]
[string]$Type = "IPv4",
[string]$ApiKey = $env:OTX_API_KEY
)
$headers = @{ "X-OTX-API-KEY" = $ApiKey }
$baseUrl = "https://otx.alienvault.com/api/v1/indicators/$Type/$Indicator"
try {
$general = Invoke-RestMethod -Uri "$baseUrl/general" -Headers $headers
Write-Host "`n--- OTX General ---" -ForegroundColor Yellow
[PSCustomObject]@{
Indicator = $Indicator
Type = $Type
PulseCount = $general.pulse_info.count
Country = $general.country_name
ASN = $general.asn
Reputation = $general.reputation
PulseNames = ($general.pulse_info.pulses | Select-Object -First 5 -ExpandProperty name) -join " | "
} | Format-List
$malware = Invoke-RestMethod -Uri "$baseUrl/malware" -Headers $headers
if ($malware.data.Count -gt 0) {
Write-Host "--- OTX Malware Samples ---" -ForegroundColor Red
$malware.data | Select-Object -First 5 | Format-Table hash, date -AutoSize
}
} catch {
Write-Warning "OTX lookup failed for $Indicator`: $_"
}
}
# ── Example lookups (edit indicator/type as needed) ──────────────────────────
# Invoke-OTXLookup -Indicator "203.0.113.10" -Type "IPv4"
# Invoke-OTXLookup -Indicator "example-bad-domain.test" -Type "domain"
# Invoke-OTXLookup -Indicator "" -Type "FileHash-MD5"
**What to look for:** `PulseCount > 0` (community has flagged it in a named campaign; read `PulseNames` for context/attribution), a negative `Reputation`, and any linked malware samples (escalate immediately).
### 12c: OpenPhish Community Feed Check (URLs/Domains)
function Invoke-OpenPhishCheck {
param([Parameter(Mandatory)][string]$URLOrDomain)
try {
$feed = Invoke-RestMethod -Uri "https://openphish.com/feed.txt"
# Note: do not use $matches as a variable name; it is a PowerShell automatic variable
$feedMatches = $feed -split "`n" | Where-Object { $_ -like "*$URLOrDomain*" }
if ($feedMatches.Count -gt 0) {
Write-Host "[!] MATCH in OpenPhish feed for: $URLOrDomain" -ForegroundColor Red
$feedMatches | Select-Object -First 10
} else {
Write-Host "[$URLOrDomain] OpenPhish: No match in current feed." -ForegroundColor Green
}
} catch {
Write-Warning "OpenPhish feed retrieval failed: $_"
}
}
# ── Prompt for a URL/domain and check it ─────────────────────────────────────
$OpenPhishTarget = Read-Host "Enter a URL or domain to check against OpenPhish"
if ($OpenPhishTarget) { Invoke-OpenPhishCheck -URLOrDomain $OpenPhishTarget }
**What to look for:** Any match = an active phishing URL in the community feed; treat as confirmed. A non-match does **not** mean clean. OpenPhish is reactive, so use it alongside ThreatFox/OTX.
### 12d: Bulk IOC Enrichment (All Three Feeds)
Fetches the OpenPhish feed **once** and caches it, and sleeps between OTX calls to respect rate limits.
function Invoke-BulkIOCEnrichment {
param([string[]]$IOCList)
# Fetch OpenPhish feed once, reuse for every IOC
$openPhishFeed = $null
try {
$openPhishFeed = (Invoke-RestMethod -Uri "https://openphish.com/feed.txt") -split "`n"
} catch {
Write-Warning "OpenPhish feed retrieval failed; skipping OpenPhish checks: $_"
}
$results = foreach ($ioc in $IOCList) {
$tfResult = "Pending"; $otxResult = "Pending"; $opResult = "Pending"
# ThreatFox
try {
$body = @{ query = "search_ioc"; search_term = $ioc } | ConvertTo-Json
$tf = Invoke-RestMethod -Uri "https://threatfox-api.abuse.ch/api/v1/" `
-Method POST -Headers @{"Auth-Key"=$env:THREATFOX_API_KEY;"Content-Type"="application/json"} `
-Body $body
$tfResult = if ($tf.query_status -eq "no_result") { "Clean" } else {
"$($tf.data[0].threat_type) | $($tf.data[0].malware_printable) | Confidence: $($tf.data[0].confidence_level)"
}
} catch { $tfResult = "Error" }
# OTX (IPv4 assumed for bulk); sleep to respect rate limits
try {
$otx = Invoke-RestMethod -Uri "https://otx.alienvault.com/api/v1/indicators/IPv4/$ioc/general" `
-Headers @{"X-OTX-API-KEY"=$env:OTX_API_KEY}
$otxResult = "Pulses: $($otx.pulse_info.count) | Country: $($otx.country_name)"
} catch { $otxResult = "N/A or Error" }
Start-Sleep -Seconds 1
# OpenPhish (cached feed)
if ($openPhishFeed) {
$opHits = $openPhishFeed | Where-Object { $_ -like "*$ioc*" }
$opResult = if ($opHits) { "MATCH - Active Phishing" } else { "No Match" }
} else { $opResult = "Feed unavailable" }
[PSCustomObject]@{ IOC = $ioc; ThreatFox = $tfResult; OTX = $otxResult; OpenPhish = $opResult }
}
$results | Format-Table -AutoSize -Wrap
}
# ── Prompt for IOCs and enrich in bulk ───────────────────────────────────────
$BulkInput = Read-Host "Enter IOCs for bulk enrichment (comma-separated)"
$BulkIOCs = $BulkInput -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
if ($BulkIOCs) { Invoke-BulkIOCEnrichment -IOCList $BulkIOCs }
## Module 13: Power Automate / Flow Persistence
**When to use:** Post-compromise persistence check. Malicious flows are a rising BEC persistence vector: an attacker creates a flow that forwards mail, exfiltrates attachments, or auto-responds, then loses nothing when you reset the password and revoke sessions. Almost nobody checks here.
**Dependency:** `Microsoft.PowerApps.Administration.PowerShell` (see preflight notes). Requires a Power Platform admin or Global Admin role.
# Install-Module Microsoft.PowerApps.Administration.PowerShell -Scope CurrentUser # if not present
Add-PowerAppsAccount # interactive sign-in with your admin account
# ── All flows across environments, newest first ───────────────────────────────
$AllFlows = Get-AdminFlow
$AllFlows | Select-Object DisplayName, EnvironmentName, CreatedTime, LastModifiedTime, Enabled,
@{n='CreatedBy';e={$_.CreatedBy.userId}} |
Sort-Object CreatedTime -Descending | Format-Table -AutoSize
# ── Flows created or modified inside the incident window ─────────────────────
$AllFlows | Where-Object {
([datetime]$_.CreatedTime -ge $StartDate) -or ([datetime]$_.LastModifiedTime -ge $StartDate)
} | Select-Object DisplayName, EnvironmentName, CreatedTime, LastModifiedTime, Enabled,
@{n='CreatedBy';e={$_.CreatedBy.userId}}
# ── Flows owned by the target user (match on Entra object ID) ─────────────────
$TargetObjectId = (Get-MgUser -UserId $TargetUser).Id
$AllFlows | Where-Object { $_.CreatedBy.userId -eq $TargetObjectId } |
Select-Object DisplayName, EnvironmentName, CreatedTime, LastModifiedTime, Enabled
# ── Connections held by the target (mail connectors are the red flag) ─────────
Get-AdminPowerAppConnection | Where-Object { $_.CreatedBy.id -eq $TargetObjectId } |
Select-Object DisplayName, ConnectorName, CreatedTime, Statuses
# ── UAL cross-check: flow activity events ─────────────────────────────────────
Search-UALComplete -Start $StartDate -End $EndDate -RecordType MicrosoftFlow |
Select-Object CreatedDate, UserIds, Operations, AuditData
**What to look for:** Flows created during or after the compromise window, flows with Outlook/Exchange/SMTP connectors owned by the compromised user, and connections to external services (HTTP, Dropbox, Google Drive) that could move mail or files out. A flow named something innocuous ("Sync", "Backup") with a mail connector created at 3 AM is your persistence.
## Module 14: Service Principal & Legacy Auth Sign-Ins
**When to use:** OAuth abuse follow-up, hunting attacker use of app credentials, or auditing what's still authenticating with password-only legacy protocols. Module 2 only sees user sign-ins; compromised app secrets and service principals authenticate through a different door.
# ── Service principal sign-ins in window ──────────────────────────────────────
Get-MgAuditLogSignIn -Filter "signInEventTypes/any(t: t eq 'servicePrincipal') and createdDateTime ge $GraphStart" -Top 100 |
Select-Object CreatedDateTime, AppDisplayName, AppId, IpAddress, ResourceDisplayName,
@{n='Status';e={$_.Status.ErrorCode}} |
Sort-Object CreatedDateTime | Format-Table -AutoSize
# ── Managed identity sign-ins ────────────────────────────────────────────────
Get-MgAuditLogSignIn -Filter "signInEventTypes/any(t: t eq 'managedIdentity') and createdDateTime ge $GraphStart" -Top 50 |
Select-Object CreatedDateTime, AppDisplayName, IpAddress, ResourceDisplayName
# ── Legacy auth usage, org-wide (password-only protocols; prime AiTM-free ATO path)
$LegacyClients = 'Exchange ActiveSync','IMAP4','POP3','Authenticated SMTP','Other clients'
foreach ($client in $LegacyClients) {
Get-MgAuditLogSignIn -Filter "clientAppUsed eq '$client' and createdDateTime ge $GraphStart" -Top 50 |
Select-Object CreatedDateTime, UserPrincipalName, IpAddress, ClientAppUsed,
@{n='Status';e={$_.Status.ErrorCode}}
}
# ── Legacy auth by the target user specifically ───────────────────────────────
Get-MgAuditLogSignIn -Filter "userPrincipalName eq '$TargetUser' and (clientAppUsed eq 'IMAP4' or clientAppUsed eq 'POP3' or clientAppUsed eq 'Authenticated SMTP' or clientAppUsed eq 'Exchange ActiveSync') and createdDateTime ge $GraphStart" -Top 50 |
Select-Object CreatedDateTime, IpAddress, ClientAppUsed, @{n='Status';e={$_.Status.ErrorCode}}
**What to look for:** Service principal sign-ins from IPs matching your user-compromise IOCs (attacker pivoted to an app secret), any successful legacy auth at all if you believe it's blocked (a Conditional Access gap, see Module 15), and new legacy-auth activity from the target account (attacker sidestepping MFA entirely).
## Module 15: Conditional Access Gap Analysis
**When to use:** Post-incident root cause ("how did they get in without MFA?"), proactive hardening review, or validating that CA actually covers what you think it covers. Requires `Policy.Read.All` (already in the Module 0 scopes).
# ── All CA policies with state ────────────────────────────────────────────────
$CAPolicies = Get-MgIdentityConditionalAccessPolicy
$CAPolicies | Select-Object DisplayName, State, CreatedDateTime, ModifiedDateTime |
Sort-Object State, DisplayName | Format-Table -AutoSize
# State: enabled | disabled | enabledForReportingButNotEnforced (report-only enforces NOTHING)
# ── Exclusions per policy (the gap inventory) ─────────────────────────────────
foreach ($p in $CAPolicies) {
[PSCustomObject]@{
Policy = $p.DisplayName
State = $p.State
ExcludedUsers = ($p.Conditions.Users.ExcludeUsers -join '; ')
ExcludedGroups = ($p.Conditions.Users.ExcludeGroups -join '; ')
ExcludedRoles = ($p.Conditions.Users.ExcludeRoles -join '; ')
ExcludedApps = ($p.Conditions.Applications.ExcludeApplications -join '; ')
GrantControls = ($p.GrantControls.BuiltInControls -join '; ')
}
} | Format-Table -AutoSize -Wrap
# ── Resolve excluded user/group GUIDs to names ────────────────────────────────
# Get-MgUser -UserId "" | Select-Object DisplayName, UserPrincipalName
# Get-MgGroup -GroupId "" | Select-Object DisplayName
# Get-MgGroupMember -GroupId "" | ForEach-Object { $_.AdditionalProperties.userPrincipalName }
# ── Does anything block legacy auth? ─────────────────────────────────────────
$CAPolicies | Where-Object {
$_.Conditions.ClientAppTypes -contains 'exchangeActiveSync' -or
$_.Conditions.ClientAppTypes -contains 'other'
} | Select-Object DisplayName, State, @{n='ClientAppTypes';e={$_.Conditions.ClientAppTypes -join '; '}},
@{n='Grant';e={$_.GrantControls.BuiltInControls -join '; '}}
**What to look for:** Policies stuck in report-only that everyone assumes are enforcing, excluded users/groups that have quietly grown (break-glass accounts are expected; "temporary" exclusions from last year are not), no policy at all targeting legacy client app types, and MFA policies that exclude the very admin roles attackers target. Cross-reference exclusions against Module 14's legacy-auth hits: a user signing in over IMAP who is also excluded from your MFA policy is your root cause.
## Module 16: Teams Activity (UAL)
**When to use:** Suspected internal phishing via Teams chat, data movement through Teams, unauthorized external access, or attacker reconnaissance inside the tenant. Uses the `Search-UALComplete` helper from Module 3.
# ── All Teams events for the target user ──────────────────────────────────────
Search-UALComplete -Start $StartDate -End $EndDate -UserIds $TargetUser -RecordType MicrosoftTeams |
Select-Object CreatedDate, UserIds, Operations, AuditData
# ── Org-wide membership and external-access changes ───────────────────────────
Search-UALComplete -Start $StartDate -End $EndDate `
-Operations MemberAdded,MemberRoleChanged,TeamsTenantSettingChanged,AppInstalled |
Select-Object CreatedDate, UserIds, Operations, AuditData
# ── Chat/channel messages sent by the target (metadata; content needs eDiscovery)
Search-UALComplete -Start $StartDate -End $EndDate -UserIds $TargetUser `
-Operations MessageSent,MessageEditedHasLink,ChatCreated |
Select-Object CreatedDate, UserIds, Operations, AuditData
**What to look for:** A burst of `ChatCreated`/`MessageSent` from a compromised account (internal phishing wave using the trusted identity), `MemberAdded` events adding external users, Teams `AppInstalled` events in the window (app-based persistence), and tenant setting changes loosening external access.
## Report Draft Generator
**When to use:** End of active investigation. Builds a markdown incident-report skeleton straight from the case folder: evidence inventory from the manifest, structure ready for timeline and findings. You're writing this document anyway; start from 80%.
function New-IRReportDraft {
param(
[string]$CaseDir = $script:CaseDir,
[string]$IncidentTitle = "(incident title)"
)
if (-not $CaseDir -or -not (Test-Path (Join-Path $CaseDir 'evidence-manifest.csv'))) {
Write-Warning "No case folder / manifest found. Run New-IRCase and export evidence first."
return
}
$manifest = Import-Csv (Join-Path $CaseDir 'evidence-manifest.csv')
$now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm') + ' UTC'
$caseId = Split-Path $CaseDir -Leaf
$evidenceTable = ($manifest | ForEach-Object {
"| $($_.Name) | $($_.Records) | $($_.TimestampUTC) | $($_.SHA256.Substring(0,16))... |"
}) -join "`n"
$report = @"
# Incident Report (DRAFT): $IncidentTitle
**Case ID:** $caseId | **Drafted:** $now | **Status:** DRAFT, pending review
## 1. Executive Summary
(2-4 sentences: what happened, impact, containment status, breach determination status.)
## 2. Timeline (UTC)
| Time | Event | Source |
|---|---|---|
| | Initial access | |
| | Detection | |
| | Containment | |
## 3. Findings
(Synthesis first: what the evidence shows, then supporting detail.)
## 4. Confirmed IOCs
| Type | Value | Source | Enrichment |
|---|---|---|---|
## 5. Scope & Data Impact
(MailItemsAccessed scoping from Module 3A: bind vs sync, throttling caveats, record counts or floors.)
## 6. Evidence Inventory
| Export | Records | Captured (UTC) | SHA256 (truncated) |
|---|---|---|---|
$evidenceTable
Full hashes: ``evidence-manifest.csv`` in the case folder. Session transcript: ``transcript-$caseId.txt``.
## 7. Root Cause
(How initial access succeeded; reference CA gap analysis if applicable.)
## 8. Containment & Remediation Actions
| Action | Time (UTC) | By | Authorized by |
|---|---|---|---|
## 9. Recommendations
1.
## 10. Open Items
- [ ]
"@
$outPath = Join-Path $CaseDir "Report-Draft-$caseId.md"
$report | Out-File -FilePath $outPath -Encoding utf8
Write-Host "Report draft written: $outPath" -ForegroundColor Green
}
# New-IRReportDraft -IncidentTitle "AiTM compromise of user@example.com"
## Disconnect When Done
Disconnect-ExchangeOnline -Confirm:$false
Disconnect-MgGraph
## Quick Reference: Incident Type to Modules
| Incident Type | Priority Modules |
|---|---|
| Phishing delivery investigation | 0, 3, 4, 10 |
| Account compromise / takeover | 0, 1, 2, 3, 3A, 4, 5, 13 |
| MFA bypass (AiTM) | 0, 1, 2, 3, 3A, 4, 6, 15 |
| BEC / suspicious sent email | 0, 2, 3, 4, 7, 13 |
| Breach scoping / data-access determination | 0, 3, 3A, 9 |
| Inbox rule / forwarding persistence | 0, 3, 4, 13 |
| OAuth app abuse | 0, 3, 5, 14 |
| Privilege escalation | 0, 1, 6 |
| Data exfiltration (SharePoint/OneDrive) | 0, 3, 9 |
| Malware / Defender alert | 0, 8 |
| Spam / quarantine anomaly | 0, 4, 10 |
| Audit log gap / health check | 0, 11 |
| Threat hunt (post-compromise) | 0, 7, 2, 3, 13, 14 |
| Internal phishing / Teams anomaly | 0, 16, 3 |
| Conditional Access / root-cause review | 0, 15, 14 |
| Post-defederation / domain-change validation | 0, 11, 6, 1 |
| IOC enrichment / threat intel | 12 (run concurrently) |
**Every investigation:** open a case (`New-IRCase`) at the start, pipe outputs through `Export-IREvidence`, generate the report draft at the end.
## Live Incident Tracking Template
A reusable running log. Start it at the beginning of any potentially significant investigation and update it after every action. Redact or store securely; it may contain sensitive detail.
═══════════════════════════════════════════════════════════
LIVE INCIDENT TRACKING
═══════════════════════════════════════════════════════════
Incident ID: [Assigned or TBD]
Classification: [Confidential - Internal Use Only]
Lead Analyst: [Name / role]
Investigation Start: [UTC timestamp]
Last Updated: [UTC timestamp]
Status: [Active / Contained / Pending direction]
───────────────────────────────────────────────────────────
AFFECTED USER(S)
───────────────────────────────────────────────────────────
UPN: [user@example.com]
Role/Dept: [If known]
Reported by: [Self / SOC / Automated alert / Other]
Report time: [UTC]
───────────────────────────────────────────────────────────
INVESTIGATION LOG (append chronologically)
───────────────────────────────────────────────────────────
[HH:MM UTC] ACTION: [What was done]
LOCATION: [Module / portal / tool used]
RESULT: [What was found, or "Nothing anomalous"]
EVIDENCE: [Paste relevant output or summarize]
───────────────────────────────────────────────────────────
CONFIRMED IOCs
───────────────────────────────────────────────────────────
IP Addresses: []
Domains/URLs: []
Sender Addresses: []
Subject Lines: []
File Names: []
User Agents: []
Other: []
───────────────────────────────────────────────────────────
EVIDENCE INVENTORY
───────────────────────────────────────────────────────────
[ ] Sign-in logs pulled - [timestamp, window]
[ ] UAL searched - [timestamp, operations]
[ ] Inbox rules checked - [timestamp, result]
[ ] Forwarding checked - [timestamp, result]
[ ] OAuth grants checked - [timestamp, result]
[ ] Message trace pulled - [timestamp, result]
[ ] Device registrations checked - [timestamp, result]
[ ] Threat hunt run - [timestamp, scope]
[ ] Defender alerts reviewed - [timestamp, result]
[ ] Audit log health verified - [timestamp, result]
───────────────────────────────────────────────────────────
CONTAINMENT ACTIONS TAKEN
───────────────────────────────────────────────────────────
[ ] Account disabled - [timestamp] by [who]
[ ] Sessions revoked - [timestamp] by [who]
[ ] MFA reset/verified - [timestamp] by [who]
[ ] Other: ____________ - [timestamp] by [who]
───────────────────────────────────────────────────────────
COMPLIANCE NOTES (if regulated data may be involved)
───────────────────────────────────────────────────────────
Sensitive data accessed: [Yes / No / Unknown]
Records potentially affected: [Number or Unknown]
Breach determination: [Pending / Yes / No]
Notification deadline: [Date if applicable]
Notes: []
───────────────────────────────────────────────────────────
OPEN ITEMS / NEXT STEPS
───────────────────────────────────────────────────────────
[ ] [Item]
═══════════════════════════════════════════════════════════
## Usage Notes
- Run the Preflight Tooling Check in any new shell or on any new machine before Module 0. Module installs/updates are gated behind Y/N prompts and touch only the local machine, never the tenant.
- Open a case with `New-IRCase` before running investigation modules; pipe outputs through `Export-IREvidence` so findings survive the session and carry hashes.
- For breach determinations, Module 3A's throttling and sync warnings are load-bearing: a throttled session means the logged count is a floor, and a sync event means presume the whole folder.
- Containment (write) actions live in a separate document: **CONTAINMENT.md**. Keep it at arm's reach but out of this file; the separation is what keeps this toolkit's read-only guarantee honest.
- Every command in THIS document is read-only. Anything you adapt into a containment action should be flagged `# ⚠️ WRITE OPERATION` and run deliberately.
- Respect retention limits (see the table at the top). An empty result outside a source's retention window means "no data available," not "nothing happened."
- If a query returns exactly its result cap (5000 for UAL and message trace), assume there is more and page.
- Present output in clean, copy-paste-ready blocks; annotate what a normal vs. suspicious result looks like.
- Enrich IOCs as they surface rather than at the end.
- If unified audit logging or mailbox auditing is off, note the blind spot; results are incomplete until it's remediated.
## License
MIT. See [LICENSE](LICENSE). Provided as-is for defensive security use, without warranty of any kind.
标签:AI合规, IPv6, Microsoft 365, PowerShell, 安全事件响应, 数字取证, 数据泄露, 自动化脚本