Remediation/EntraID/ENT-RISK.ps1
|
# UNTESTED: this script has never been run on a real tenant. Read it, try it on a test tenant first, and use it at your own risk. <# .SYNOPSIS Handles the risky or compromised accounts, according to the conclusion of the inquiry on each (procedure ENT-RISK). .DESCRIPTION decisions\ENT-RISK.csv: one row per account flagged by Identity Protection, with no proposal, because only an inquiry can conclude: examine the detections and sign-ins, then enter Compromised or False alarm. No risk is closed and no password is reset without this decision. - False alarm: the risk is dismissed. Note the reason in the Note column. - Compromised: the account is confirmed compromised, its password reset, its sessions revoked, then its inbox rules, consents and authentication methods are read into exports\ for review. A synced account is reset in on-premises AD: a reset done in Entra would not hold. The script waits for the change before revoking the sessions. The password of a cloud account is generated here and encrypted with DPAPI in secrets\ in the log folder. Warn the user first: they must sign in again everywhere. Emergency accounts and migration team accounts are never touched. IDENTITY-MATRIX takes the result into the Notes column. Limits: removing an inbox rule, a consent or a method stays a human decision, after reviewing the export. #> $c = Open-TcRemediation $RiskUri = 'v1.0/identityProtection/riskyUsers' function Get-RiskEntry { <# Current risk entry; an account with no entry no longer has a risk. #> param([string]$Tenant, [string]$Id) try { return (Invoke-TcGraph -Tenant $Tenant -Uri "$RiskUri/$Id") } catch { if ((Get-TcErrorText $_) -match '404|NotFound') { return @{ riskState = 'none' } }; throw } } function Test-PasswordReplaced { <# Risk cleared, or password changed after the last risk update: running again does not reset twice. #> param([string]$Tenant, [string]$Id) $r = Get-RiskEntry $Tenant $Id if ((Get-TcMember $r 'riskState') -notin 'atRisk', 'confirmedCompromised') { return $true } $u = Invoke-TcGraph -Tenant $Tenant -Uri ("v1.0/users/{0}?`$select=lastPasswordChangeDateTime" -f $Id) return [bool]((Get-TcMember $u 'lastPasswordChangeDateTime') -and [datetime](Get-TcMember $u 'lastPasswordChangeDateTime') -gt [datetime](Get-TcMember $r 'riskLastUpdatedDateTime')) } $targets = @{} foreach ($s in (Get-TcSourceRole)) { foreach ($l in (Get-TcMember (Get-TcRemediationIdentity $s) 'rows')) { if ((Get-TcMember $l 'Decision') -eq 'Duplicate' -and (Get-TcMember $l 'Target UPN')) { $targets[$l.'Target UPN'.ToLowerInvariant()] = 1 } if ((Get-TcMember $l 'Notes') -match 'likely duplicate of ([^\s;]+)') { $targets[$Matches[1].ToLowerInvariant()] = 1 } } } $proposals = @(foreach ($t in $c.Tenants) { Write-TcRemediationTenant $t 'Accounts flagged by Identity Protection' if ($t -ne 'Target') { Write-TcRemediationNote 'The snapshot carries at-risk accounts for the target only'; continue } if (-not (Test-TcCollectionComplete $t 'riskyUsers')) { Write-TcRemediationAlert 'Incomplete at-risk account collection: the list may be partial' } $risks = @(Get-TcRemediationTable $t 'riskyUsers') $i = Get-TcRemediationTenant $t $details = (@($risks | Group-Object { '{0} {1}' -f $_['Level'], $_['State'] } | ForEach-Object { '{0}: {1}' -f (Get-TcMember $_ 'Name'), (Get-TcCount $_) }) -join ' - ') Write-TcRemediationNote ("{0} account(s) flagged; {1}" -f $risks.Count, $details) foreach ($r in $risks) { if (Test-TcProtectedAccount $r['UPN'] $i) { Write-TcRemediationAlert ("{0} is a protected account: to examine by hand" -f $r['UPN']); continue } $u = Get-TcRemediationTable $t 'users' | Where-Object { $_['UPN'] -eq $r['UPN'] } | Select-Object -First 1 [ordered]@{ Tenant = $t; Account = $r['UPN']; Id = $(if ($u) { $u['Id'] }); Level = $r['Level']; 'State' = $r['State']; 'Updated' = $r['Updated'] 'Synced' = $(if ($u -and (ConvertTo-TcBoolean $u['Synced'])) { 'Yes' } else { 'No' }); 'Duplicate target' = $(if ($targets[([string]$r['UPN']).ToLowerInvariant()]) { 'Yes' } else { 'No' }); Proposal = '' } } }) $decisions = @(Get-TcDecision -Name 'ENT-RISK' -Proposal $proposals -Key 'Account' -Choice 'Compromised', 'False alarm') $IdByAccount = @{} foreach ($d in $decisions) { if (Test-TcProtectedAccount (Get-TcMember $d 'Account') (Get-TcRemediationTenant (Get-TcMember $d 'Tenant'))) { Write-TcRemediationAlert ("{0} is a protected account: decision ignored" -f (Get-TcMember $d 'Account')); continue } if (-not (Get-TcMember $d 'Id')) { Write-TcRemediationAlert ("{0}: account missing from the snapshot, to handle in the portal" -f (Get-TcMember $d 'Account')); continue } $t = (Get-TcMember $d 'Tenant') $IdByAccount[$d.Account] = (Get-TcMember $d 'Id') if ((Get-TcMember $d 'Decision') -eq 'False alarm') { if (-not (Get-TcMember $d 'Note')) { Write-TcRemediationAlert ("{0}: the reason for the false alarm is missing from the Note column, the risk is not ignored" -f (Get-TcMember $d 'Account')); continue } Add-TcGraphAction -Tenant $t -Method POST -Uri "$RiskUri/dismiss" -Body @{ userIds = @((Get-TcMember $d 'Id')) } -Object (Get-TcMember $d 'Account') -Description ("Ignore the risk of {0}: false alarm" -f (Get-TcMember $d 'Account')) ` -Test { param($a) (Get-TcMember (Get-RiskEntry $a.tenant $a.body.userIds[0]) 'riskState') -notin 'atRisk', 'confirmedCompromised' } -Undo $null -Reason 'An ignored risk cannot be restored through the API.' continue } Add-TcGraphAction -Tenant $t -Method POST -Uri "$RiskUri/confirmCompromised" -Body @{ userIds = @((Get-TcMember $d 'Id')) } -Object (Get-TcMember $d 'Account') -Description ("Confirm the compromise of {0}" -f (Get-TcMember $d 'Account')) ` -Test { param($a) (Get-TcMember (Get-RiskEntry $a.tenant $a.body.userIds[0]) 'riskState') -in 'confirmedCompromised', 'remediated', 'dismissed' } -Undo $null -Reason 'The Identity Protection report keeps this state; lifting it goes through a documented false alarm.' if ((Get-TcMember $d 'Synced') -eq 'Yes') { Add-TcManualAction -Tenant $t -Object (Get-TcMember $d 'Account') -Minutes 60 -Description ("Reset the password of {0} in the on-premises AD" -f (Get-TcMember $d 'Account')) ` -Instruction @('Synced account: the reset is done in the on-premises AD, then synchronization carries it over.', 'The script watches the date of the change and then revokes the sessions.') ` -WaitUntil { param($a) Test-PasswordReplaced $a.tenant $IdByAccount[$a.object] } } else { Add-TcBlockAction -Tenant $t -Service Graph -Object (Get-TcMember $d 'Account') -Description ("Reset the password of {0}" -f (Get-TcMember $d 'Account')) -Data @{ Id = (Get-TcMember $d 'Id') } ` -Reason 'A replaced password cannot be restored.' ` -Test { param($a) Test-PasswordReplaced $a.tenant $a.data.Id } ` -Block { param($a) $passwordValue = Get-TcRandomPassword -Length 24 Save-TcRemediationSecret -Name ("reset-{0}" -f $a.data.Id) -Value $passwordValue Invoke-TcGraph -Tenant $a.tenant -Method PATCH -Uri ("v1.0/users/{0}" -f $a.data.Id) -Body @{ passwordProfile = @{ forceChangePasswordNextSignIn = $true; password = $passwordValue } } } } Add-TcBlockAction -Tenant $t -Service Graph -Object (Get-TcMember $d 'Account') -Description ("Revoke the sessions of {0}" -f (Get-TcMember $d 'Account')) -Data @{ Id = (Get-TcMember $d 'Id') } -Reason 'A revoked session cannot be restored.' ` -Test { param($a) $u = Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/users/{0}?`$select=signInSessionsValidFromDateTime,lastPasswordChangeDateTime" -f $a.data.Id) return [bool]((Get-TcMember $u 'signInSessionsValidFromDateTime') -and (Get-TcMember $u 'lastPasswordChangeDateTime') -and [datetime](Get-TcMember $u 'signInSessionsValidFromDateTime') -gt [datetime](Get-TcMember $u 'lastPasswordChangeDateTime')) } ` -Block { param($a) Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri ("v1.0/users/{0}/revokeSignInSessions" -f $a.data.Id) } Add-TcBlockAction -Tenant $t -Service Graph -Object (Get-TcMember $d 'Account') -Description ("Read the inbox rules, consents and methods of {0}" -f (Get-TcMember $d 'Account')) -Data @{ Id = (Get-TcMember $d 'Id'); Account = (Get-TcMember $d 'Account') } -Reason 'Read only.' ` -Block { param($a) $rows = [System.Collections.Generic.List[object]]::new() foreach ($r in Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/users/{0}/mailFolders/inbox/messageRules" -f $a.data.Id) -All) { $rows.Add([ordered]@{ Account = $a.data.Account; Nature = 'Inbox rule'; Name = (Get-TcMember $r 'displayName'); Active = (Get-TcMember $r 'isEnabled'); Details = ((Get-TcMember $r 'actions') | ConvertTo-Json -Depth 6 -Compress) }) } foreach ($g in Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/oauth2PermissionGrants?`$filter=principalId eq '{0}'" -f $a.data.Id) -All) { $rows.Add([ordered]@{ Account = $a.data.Account; Nature = 'Consent'; Name = (Get-TcMember $g 'clientId'); Active = ''; Details = (Get-TcMember $g 'scope') }) } foreach ($m in Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/users/{0}/authentication/methods" -f $a.data.Id) -All) { $rows.Add([ordered]@{ Account = $a.data.Account; Nature = 'Method'; Name = $m['@odata.type']; Active = ''; Details = (Get-TcMember $m 'id') }) } Export-TcRemediationReport -Name ("account-review-{0}" -f $a.data.Id) -Rows @($rows) | Out-Null } Add-TcManualAction -Tenant $t -Object (Get-TcMember $d 'Account') -Description ("Review the snapshot of {0} and remove what is not legitimate" -f (Get-TcMember $d 'Account')) ` -Instruction @(("Snapshot: {0}" -f (Join-Path $c.Folder ("exports/account-review-{0}.csv" -f (Get-TcMember $d 'Id')))), 'Remove the forwardings, deletions, consents and methods that the user does not recognize. A malicious rule would follow the account into the copy.') } Invoke-TcRemediationPlan -Verify { param($t) if ($t -ne 'Target') { return } $remaining = @(Invoke-TcGraph -Tenant $t -Uri ("{0}?`$filter=riskState eq 'atRisk'" -f $RiskUri) -All) $known = @($decisions | ForEach-Object { (Get-TcMember $_ 'Account') }) $others = @($remaining | Where-Object { (Get-TcMember $_ 'userPrincipalName') -notin $known }) if ($remaining.Count) { New-TcRemediationResult Warning ("{0} account(s) still at risk, of which {1} with no decision" -f $remaining.Count, $others.Count) } else { New-TcRemediationResult OK 'no more at-risk accounts' } } Close-TcRemediation |