Private/Invoke-TripwireGraphWrite.ps1
|
<# .SYNOPSIS The one write path to Microsoft Graph, constrained by an explicit allowlist. .DESCRIPTION This module does write to a tenant -- it has to, because planting a decoy creates an object and containing an intruder disables one. What it must never do is grow an arbitrary write surface, so every write is matched against the allowlist below before it is issued, and anything unmatched throws instead of being sent. Two properties of the allowlist are worth stating because they are what make automated containment safe to hand to a machine: * The only PATCH bodies permitted are {"accountEnabled": false}. The gateway can disable an identity and is structurally incapable of enabling one. A bug, a bad merge or a hostile input cannot turn containment into a privilege grant. * There is no allowlist entry that adds a credential, assigns a role, grants consent, or writes to any object outside users, applications and service principals. tests/Safety.Tests.ps1 asserts both, and asserts that Invoke-MgGraphRequest appears in exactly two files in the whole module. #> $script:TripwireWriteAllowlist = @( @{ Method = 'POST'; Pattern = '^/v1\.0/users$'; Purpose = 'Plant a decoy user' } @{ Method = 'POST'; Pattern = '^/v1\.0/applications$'; Purpose = 'Plant a decoy application registration' } @{ Method = 'POST'; Pattern = '^/v1\.0/applications/[^/]+/addPassword$'; Purpose = 'Plant a decoy client secret' } @{ Method = 'POST'; Pattern = '^/v1\.0/users/[^/]+/revokeSignInSessions$'; Purpose = 'Containment: revoke the actor sessions' } @{ Method = 'PATCH'; Pattern = '^/v1\.0/users/[^/]+$'; Purpose = 'Containment: disable an identity'; BodyMustEqual = @{ accountEnabled = $false } } @{ Method = 'PATCH'; Pattern = '^/v1\.0/servicePrincipals/[^/]+$'; Purpose = 'Containment: disable a decoy service principal'; BodyMustEqual = @{ accountEnabled = $false } } @{ Method = 'DELETE'; Pattern = '^/v1\.0/users/[^/]+$'; Purpose = 'Remove a decoy user' } @{ Method = 'DELETE'; Pattern = '^/v1\.0/applications/[^/]+$'; Purpose = 'Remove a decoy application' } ) <# .SYNOPSIS Match a write against the allowlist. Returns the matching entry, or $null. .DESCRIPTION Separated from the issuing function so tests can interrogate the policy without a tenant, a mock, or a network stack. #> function Get-TripwireWritePermit { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Method, [Parameter(Mandatory)] [string] $Path, [AllowNull()] $Body ) foreach ($entry in $script:TripwireWriteAllowlist) { if ($entry.Method -ne $Method.ToUpperInvariant()) { continue } if ($Path -notmatch $entry.Pattern) { continue } if ($entry.ContainsKey('BodyMustEqual')) { if ($null -eq $Body) { continue } $expected = $entry.BodyMustEqual $actualKeys = @($Body.Keys | Sort-Object) $expectedKeys = @($expected.Keys | Sort-Object) if (($actualKeys -join ',') -ne ($expectedKeys -join ',')) { continue } $mismatch = $false foreach ($k in $expectedKeys) { if ([string]$Body[$k] -ne [string]$expected[$k]) { $mismatch = $true; break } } if ($mismatch) { continue } } return $entry } return $null } <# .SYNOPSIS Issue an allowlisted write. Refuses anything else. #> function Invoke-TripwireGraphWrite { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet('POST', 'PATCH', 'DELETE')] [string] $Method, [Parameter(Mandatory)] [string] $Path, [AllowNull()] [hashtable] $Body ) $permit = Get-TripwireWritePermit -Method $Method -Path $Path -Body $Body if ($null -eq $permit) { throw "EntraTripwire refused a write that is not on its allowlist: $Method $Path. This is a guard, not a transient error -- see Invoke-TripwireGraphWrite." } Write-TripwireLog -Message "Graph write permitted ($($permit.Purpose)): $Method $Path" $params = @{ Method = $Method; Uri = $Path; OutputType = 'PSObject'; ErrorAction = 'Stop' } if ($null -ne $Body) { $params['Body'] = ($Body | ConvertTo-Json -Depth 8 -Compress) } return Invoke-MgGraphRequest @params } |