Public/Invoke-TripwireContainment.ps1
|
<# .SYNOPSIS Plan -- and, with a Pro licence and an explicit -Execute, perform -- containment of a high-confidence trip. .DESCRIPTION Containment is the part of this tool that can hurt, so it is fenced on five sides. All five must be open before a single Graph write is issued: 1. The trip must be in the High band. Medium is an alert for a human, never an action. 2. The trip must not be Degraded. A score computed with a withheld signal is a FLOOR, and acting on a floor means acting on a number you know is incomplete. -AcceptDegraded overrides this, and says so in the plan and the log. 3. -Execute must be passed. The default is a dry run that returns the plan and touches nothing. There is no configuration setting that changes this default. 4. ShouldProcess must confirm, so -WhatIf and -Confirm behave as an operator expects. 5. The 'containment' Pro feature must be licensed. Without it the plan is returned in full -- including every parameter needed to run the actions by hand -- and nothing is executed. What it does, at most: * Revoke the actor's refresh tokens (POST users/{id}/revokeSignInSessions), only when the actor is a directory principal in this tenant. * Disable the decoy that was touched, so a credential that is now known to be in an intruder's hands stops working. * Write an incident capture to disk: the trip, its signals, the actor context. What it will NEVER do, by construction rather than by policy: contact, probe, scan or respond to the source address; feed the intruder anything; disable a non-decoy identity other than the actor; grant a permission; enable anything. The write allowlist in Invoke-TripwireGraphWrite has no entry for any of it, and its only PATCH bodies are {"accountEnabled": false} -- the gateway can disable and is structurally incapable of enabling. .PARAMETER Trip A scored trip from Watch-TripwireActivity or Invoke-TripwireAnalysis. .PARAMETER Action Which actions to include. Defaults to all three. .PARAMETER Execute Actually perform the plan. Requires the containment Pro feature. .PARAMETER AcceptDegraded Allow containment on a trip that was scored with a signal withheld. .PARAMETER CapturePath Directory for incident captures. Defaults to ./incidents. .EXAMPLE Watch-TripwireActivity -Demo | Where-Object Band -eq 'High' | Invoke-TripwireContainment .EXAMPLE $trip | Invoke-TripwireContainment -Execute -Confirm #> function Invoke-TripwireContainment { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ValueFromPipeline)] $Trip, [ValidateSet('RevokeActorSessions', 'DisableDecoy', 'CaptureContext')] [string[]] $Action = @('RevokeActorSessions', 'DisableDecoy', 'CaptureContext'), [switch] $Execute, [switch] $AcceptDegraded, [string] $CapturePath = './incidents' ) process { $blockers = [System.Collections.Generic.List[string]]::new() if ($Trip.Band -ne 'High') { $blockers.Add("Confidence $($Trip.Confidence) is in the $($Trip.Band) band. Containment is eligible in the High band only.") } if ($Trip.Degraded -and -not $AcceptDegraded) { $blockers.Add("Scored with signals withheld ($(@($Trip.Withheld) -join ', ')). The confidence is a lower bound, not a measurement. Pass -AcceptDegraded to proceed anyway.") } $plan = [System.Collections.Generic.List[object]]::new() $actorId = [string]$Trip.Touch.actor.id if ($Action -contains 'RevokeActorSessions') { if ([string]::IsNullOrWhiteSpace($actorId)) { $plan.Add([pscustomobject]@{ Name = 'RevokeActorSessions'; Method = $null; Path = $null; Eligible = $false; Why = 'The actor has no directory object id (an anonymous or external principal). There are no sessions in this tenant to revoke.' }) } else { $plan.Add([pscustomobject]@{ Name = 'RevokeActorSessions'; Method = 'POST'; Path = "/v1.0/users/$actorId/revokeSignInSessions"; Eligible = $true; Why = "Invalidate every refresh token held by $($Trip.ActorLabel)." }) } } if ($Action -contains 'DisableDecoy') { $decoyObjectId = [string]$Trip.Touch.actor.id if ($Trip.Touch.source -ne 'SignInLog') { $decoyObjectId = '' } if ([string]::IsNullOrWhiteSpace($decoyObjectId)) { $plan.Add([pscustomobject]@{ Name = 'DisableDecoy'; Method = $null; Path = $null; Eligible = $false; Why = 'The touch came from the directory audit log, so the decoy was read rather than used. Disabling it would only tell the intruder they were seen.' }) } else { $plan.Add([pscustomobject]@{ Name = 'DisableDecoy'; Method = 'PATCH'; Path = "/v1.0/users/$decoyObjectId"; Body = @{ accountEnabled = $false }; Eligible = $true; Why = "Decoy '$($Trip.DecoyName)' was authenticated with, so its credential is in an intruder's hands." }) } } if ($Action -contains 'CaptureContext') { $plan.Add([pscustomobject]@{ Name = 'CaptureContext'; Method = $null; Path = (Join-Path $CapturePath "$($Trip.TouchId).json"); Eligible = $true; Why = 'Preserve the trip, its signals and the actor context for attribution. Written locally; nothing is sent anywhere.' }) } $licensed = Test-TripwireFeature -Feature 'containment' if ($Execute -and -not $licensed) { $blockers.Add('Automated containment is an EntraTripwire Pro feature. The plan below is complete: every action lists the exact Graph call, so it can be run by hand. See docs/PRO.md.') } $willExecute = $Execute -and $licensed -and $blockers.Count -eq 0 $performed = [System.Collections.Generic.List[object]]::new() if ($willExecute) { foreach ($step in $plan) { if (-not $step.Eligible) { continue } $target = "$($Trip.DecoyName) / $($Trip.ActorLabel)" if (-not $PSCmdlet.ShouldProcess($target, $step.Name)) { $performed.Add([pscustomobject]@{ Name = $step.Name; Status = 'Declined'; Detail = 'Not confirmed.' }) continue } try { switch ($step.Name) { 'CaptureContext' { if (-not (Test-Path -LiteralPath $CapturePath)) { New-Item -ItemType Directory -Path $CapturePath -Force | Out-Null } $Trip | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $step.Path -Encoding utf8 $performed.Add([pscustomobject]@{ Name = $step.Name; Status = 'Done'; Detail = $step.Path }) } 'DisableDecoy' { Invoke-TripwireGraphWrite -Method 'PATCH' -Path $step.Path -Body $step.Body | Out-Null $performed.Add([pscustomobject]@{ Name = $step.Name; Status = 'Done'; Detail = $step.Path }) } default { Invoke-TripwireGraphWrite -Method 'POST' -Path $step.Path | Out-Null $performed.Add([pscustomobject]@{ Name = $step.Name; Status = 'Done'; Detail = $step.Path }) } } } catch { # A failed containment action is reported as failed. It is never folded # into a summary that says the incident was contained. Write-TripwireLog -Level 'Warn' -Message "Containment step $($step.Name) FAILED: $($_.Exception.Message)" $performed.Add([pscustomobject]@{ Name = $step.Name; Status = 'Failed'; Detail = $_.Exception.Message }) } } } [pscustomobject]@{ TouchId = $Trip.TouchId DecoyName = $Trip.DecoyName ActorLabel = $Trip.ActorLabel Confidence = $Trip.Confidence Band = $Trip.Band Mode = if ($willExecute) { 'Executed' } else { 'DryRun' } Licensed = $licensed Blockers = @($blockers) Plan = @($plan) Performed = @($performed) } } } |