Atliso-BitLockerKeyAccessAudit.ps1
|
<#PSScriptInfo .VERSION 1.0.0 .GUID 70fab440-2211-4a48-b025-39663dc60b54 .AUTHOR Atliso .COMPANYNAME Atliso .COPYRIGHT (c) 2026 Atliso. MIT License. .TAGS BitLocker RecoveryKey Entra Intune AuditLog Graph Audit ReadOnly .LICENSEURI https://github.com/Atliso-Dev/bitlocker-key-access-audit/blob/main/LICENSE .PROJECTURI https://atliso.com/blog/who-viewed-bitlocker-recovery-key-entra .REQUIREDMODULES Microsoft.Graph.Authentication .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES First release. #> <# .SYNOPSIS Atliso BitLocker Key Access Audit. Read-only. .DESCRIPTION Lists every BitLocker recovery key that was viewed in Entra ID: who read it, when, from which IP, for which device, and whether the key has been rotated since. A key that was read and never rotated still unlocks the drive, so those come first. Also flags anyone who read keys for several devices in the period. Never reads a key itself: it only lists key IDs and dates, which doesn't create an audit event. Exports a CSV and exits 1 when a viewed key is still valid. Changes nothing in your tenant. https://atliso.com/blog/who-viewed-bitlocker-recovery-key-entra .PARAMETER TenantId Optional. A tenant ID or domain to sign in to, for MSPs running it against customer tenants. .PARAMETER Days How far back to look. Entra keeps audit logs for 7 days on Entra ID Free and 30 days on P1 or P2. .NOTES Requires Microsoft.Graph.Authentication. Scopes: AuditLog.Read.All, BitlockerKey.ReadBasic.All, Device.Read.All Exit 0 = every viewed key has been rotated, or none were viewed. Exit 1 = a viewed key still works. #> [CmdletBinding()] param( [string]$TenantId, [ValidateRange(1, 30)] [int]$Days = 30 ) # ===== CONFIG ===== $OutputPath = Join-Path $env:USERPROFILE 'Desktop\Atliso-BitLockerKeyReads.csv' $BulkThreshold = 3 # flag anyone who read keys for this many devices or more in the period $LogPath = Join-Path $env:LOCALAPPDATA 'Atliso\BitLockerKeyAccessAudit.log' # ================== $ErrorActionPreference = 'Stop' function Write-Log([string]$Message) { try { $dir = Split-Path -Path $LogPath -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } Add-Content -Path $LogPath -Value ("{0} {1}" -f (Get-Date -Format s), $Message) } catch { } } # HTTP status from a Graph error. PowerShell 7 and Windows PowerShell 5.1 throw different exception shapes. function Get-HttpStatus($ErrorRecord) { $ex = $ErrorRecord.Exception try { if ($ex.Response -and $ex.Response.StatusCode) { return [int]$ex.Response.StatusCode } } catch { } if ([string]$ex.Message -match '\b(403|404|429)\b') { return [int]$Matches[1] } return $null } function Invoke-Graph([string]$Uri) { for ($try = 1; $try -le 5; $try++) { try { return Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject } catch { $status = Get-HttpStatus $_ if ($status -eq 429 -and $try -lt 5) { $wait = 10 try { $ra = $_.Exception.Response.Headers.RetryAfter.Delta; if ($ra) { $wait = [int][math]::Ceiling($ra.TotalSeconds) } } catch { } Write-Log "429 on $Uri, waiting $wait s" Start-Sleep -Seconds $wait continue } throw } } } function Get-GraphList([string]$Uri) { $items = New-Object System.Collections.Generic.List[object] while ($Uri) { $page = Invoke-Graph $Uri if (-not $page) { break } foreach ($v in @($page.value)) { if ($null -ne $v) { $items.Add($v) } } $Uri = $page.'@odata.nextLink' } return ,$items } function ConvertTo-UtcDate($Value) { if (-not $Value) { return $null } if ($Value -is [datetime]) { $d = $Value.ToUniversalTime() } else { $d = ([datetimeoffset]::Parse([string]$Value, [Globalization.CultureInfo]::InvariantCulture)).UtcDateTime } if ($d.Year -lt 2000 -or $d.Year -gt 9000) { return $null } return $d } $now = (Get-Date).ToUniversalTime() function Format-Date($Date) { if ($Date) { $Date.ToString('yyyy-MM-dd HH:mm') + ' UTC' } else { '' } } # The key ID and device ID of a "Read BitLocker key" event. Entra writes both into the detail text: # Successfully retrieved BitLocker recovery key associated with key ID: '<key id>'. Backed up from device: '<device id>' # The key ID is also the first target resource's display name, so that's the fallback. $GuidPattern = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' function Get-KeyRead($Event) { $text = (@($Event.additionalDetails) | ForEach-Object { [string]$_.value }) -join ' ' $keyId = $null; $deviceId = $null if ($text -match "key ID:\s*'($GuidPattern)'") { $keyId = $Matches[1] } if ($text -match "device:\s*'($GuidPattern)'") { $deviceId = $Matches[1] } if (-not $keyId) { foreach ($t in @($Event.targetResources)) { foreach ($v in @($t.displayName, $t.id)) { if ([string]$v -match "^$GuidPattern$") { $keyId = [string]$v; break } } if ($keyId) { break } } } $user = $Event.initiatedBy.user $app = $Event.initiatedBy.app $userId = $null if ($user -and $user.userPrincipalName) { $who = [string]$user.userPrincipalName; $kind = 'User'; $ip = [string]$user.ipAddress; $userId = ([string]$user.id).ToLower() } elseif ($app -and $app.displayName) { $who = [string]$app.displayName; $kind = 'App'; $ip = '' } else { $who = 'Unknown'; $kind = 'Unknown'; $ip = '' } [pscustomobject]@{ Time = ConvertTo-UtcDate $Event.activityDateTime Result = [string]$Event.result Reader = $who Kind = $kind UserId = $userId IP = $ip KeyId = if ($keyId) { $keyId.ToLower() } else { $null } DeviceId = if ($deviceId) { $deviceId.ToLower() } else { $null } } } Write-Log 'Run started' $connect = @{ Scopes = @('AuditLog.Read.All', 'BitlockerKey.ReadBasic.All', 'Device.Read.All'); NoWelcome = $true } if ($TenantId) { $connect.TenantId = $TenantId } Connect-MgGraph @connect # 1. Every key read in the period, including failed attempts. $since = $now.AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ') $filter = "activityDisplayName eq 'Read BitLocker key' and activityDateTime ge $since" $events = Get-GraphList ("https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter={0}" -f [uri]::EscapeDataString($filter)) $reads = @($events | ForEach-Object { Get-KeyRead $_ }) Write-Log ("Read {0} key read events since {1}" -f $reads.Count, $since) # 2. The keys Entra holds now. ReadBasic lists IDs and dates only, never the key itself, so this creates no audit event. $keysById = @{}; $keysByDevice = @{} foreach ($k in (Get-GraphList 'https://graph.microsoft.com/v1.0/informationProtection/bitlocker/recoveryKeys?$select=id,deviceId,createdDateTime,volumeType')) { $entry = [pscustomobject]@{ Id = ([string]$k.id).ToLower(); DeviceId = ([string]$k.deviceId).ToLower(); Created = ConvertTo-UtcDate $k.createdDateTime } $keysById[$entry.Id] = $entry if (-not $keysByDevice.ContainsKey($entry.DeviceId)) { $keysByDevice[$entry.DeviceId] = New-Object System.Collections.Generic.List[object] } $keysByDevice[$entry.DeviceId].Add($entry) } # 3. Device names and registered owners, so a user reading their own device's key reads as self-service. # Owners are matched by object ID: Device.Read.All doesn't return the owner's UPN. $devices = @{} foreach ($d in (Get-GraphList 'https://graph.microsoft.com/v1.0/devices?$select=deviceId,displayName&$expand=registeredOwners($select=id)')) { $devices[([string]$d.deviceId).ToLower()] = [pscustomobject]@{ Name = [string]$d.displayName Owners = @($d.registeredOwners | ForEach-Object { ([string]$_.id).ToLower() } | Where-Object { $_ }) } } # How many different devices each reader opened keys for. $devicesPerReader = @{} foreach ($r in $reads | Where-Object { $_.Result -eq 'success' }) { $device = if ($r.DeviceId) { $r.DeviceId } elseif ($r.KeyId -and $keysById.ContainsKey($r.KeyId)) { $keysById[$r.KeyId].DeviceId } else { $r.KeyId } if (-not $devicesPerReader.ContainsKey($r.Reader)) { $devicesPerReader[$r.Reader] = @{} } if ($device) { $devicesPerReader[$r.Reader][$device] = $true } } $rows = New-Object System.Collections.Generic.List[object] foreach ($r in $reads) { $key = if ($r.KeyId -and $keysById.ContainsKey($r.KeyId)) { $keysById[$r.KeyId] } else { $null } $deviceId = if ($r.DeviceId) { $r.DeviceId } elseif ($key) { $key.DeviceId } else { $null } $device = if ($deviceId -and $devices.ContainsKey($deviceId)) { $devices[$deviceId] } else { $null } $notes = @() if ($r.Result -ne 'success') { $status = 'Failed attempt' $next = "Someone tried to read a key and was refused. Check that $($r.Reader) should be asking." } elseif (-not $key) { $status = 'Key gone' $next = 'The key is no longer in Entra. The device was deleted or its keys were replaced. Nothing to rotate.' } else { # Rotated when the device backed up a newer key after the read. $newer = @($keysByDevice[$key.DeviceId] | Where-Object { $_.Created -and $r.Time -and $_.Created -gt $r.Time }) if ($newer.Count) { $status = 'Rotated' $next = 'A new key was backed up after the read. The one that was viewed is no longer the current key.' } else { $status = 'Still valid' $next = 'Rotate it: Intune > Devices > this device > BitLocker key rotation. Until then, whoever saw it can unlock the drive.' } } $role = $r.Kind if ($r.Kind -eq 'User' -and $device -and $r.UserId -and $device.Owners -contains $r.UserId) { $role = 'Device owner' } elseif ($r.Kind -eq 'User') { $role = 'Admin or helpdesk' } if ($r.Kind -eq 'App') { $notes += 'Read by an app, not a person. Check it is a tool you expect to read keys.' } if ($devicesPerReader.ContainsKey($r.Reader) -and $devicesPerReader[$r.Reader].Count -ge $BulkThreshold) { $notes += "$($r.Reader) read keys for $($devicesPerReader[$r.Reader].Count) devices in this period." } $rows.Add([pscustomobject]@{ Status = $status ReadAt = Format-Date $r.Time Reader = $r.Reader ReaderType = $role IP = $r.IP DeviceName = if ($device) { $device.Name } else { '' } DeviceId = $deviceId KeyId = $r.KeyId KeyCreated = if ($key) { Format-Date $key.Created } else { '' } NextStep = $next Note = ($notes -join ' ') }) } # Output: keys that still work first, newest read at the top. $order = @{ 'Still valid' = 0; 'Failed attempt' = 1; 'Rotated' = 2; 'Key gone' = 3 } $sorted = @($rows | Sort-Object @{ Expression = { $order[$_.Status] } }, @{ Expression = { $_.ReadAt }; Descending = $true }) if ($sorted.Count -gt 0) { $outDir = Split-Path -Path $OutputPath -Parent if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null } $sorted | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 $sorted | Format-Table Status, ReadAt, Reader, ReaderType, DeviceName, KeyCreated -AutoSize | Out-String -Width 250 | Write-Host } $bulk = @($devicesPerReader.Keys | Where-Object { $devicesPerReader[$_].Count -ge $BulkThreshold } | Sort-Object) foreach ($b in $bulk) { Write-Host ("{0} read keys for {1} devices in the last {2} days." -f $b, $devicesPerReader[$b].Count, $Days) -ForegroundColor Yellow } $valid = @($sorted | Where-Object Status -eq 'Still valid') if ($sorted.Count -gt 0) { $counts = $sorted | Group-Object Status | ForEach-Object { "{0} {1}" -f $_.Count, $_.Name.ToLower() } Write-Host ("Key reads in the last {0} days: {1}. CSV: {2}" -f $Days, ($counts -join ', '), $OutputPath) } else { Write-Host "Nobody read a BitLocker recovery key in the last $Days days." } Write-Host 'Entra keeps audit logs for 7 days on Entra ID Free and 30 days on P1 or P2. Run this on a schedule and keep the CSVs if you need a longer record.' Write-Host 'Have a check like this run every night across every client tenant: https://atliso.com/pro?ref=bitlocker-key-access-audit' -ForegroundColor Cyan Write-Log ("Run finished: {0} reads, {1} still valid" -f $sorted.Count, $valid.Count) if ($valid.Count -gt 0) { exit 1 } else { exit 0 } |