Private/ConvertTo-TripwireTouch.ps1
|
<# Normalisation of the two Graph log shapes into one 'touch' record. A TOUCH is an observed contact with a decoy. A TRIP is a touch that has been scored. Keeping the words apart keeps the pipeline honest: collection produces touches and cannot produce a verdict, scoring produces trips and cannot produce evidence. #> <# .SYNOPSIS Index the decoy inventory by every identifier a log might refer to it by. #> function Get-TripwireDecoyIndex { [CmdletBinding()] [OutputType([hashtable])] param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Decoy) $index = @{} foreach ($d in $Decoy) { foreach ($key in @($d.objectId, $d.appId, $d.upn, $d.displayName)) { if (-not [string]::IsNullOrWhiteSpace([string]$key)) { $index[([string]$key).ToLowerInvariant()] = $d } } } return $index } <# .SYNOPSIS Convert a Graph sign-in log record into a touch, or $null if no decoy is involved. .DESCRIPTION A sign-in record matches when the principal that signed in IS a decoy -- somebody is using credentials that were planted to be found. That is the highest-value observation the tool makes, and it is why decoy secrets are worth planting at all. #> function ConvertTo-TripwireTouchFromSignIn { [CmdletBinding()] param( [Parameter(Mandatory)] $Record, [Parameter(Mandatory)] [hashtable] $DecoyIndex ) $decoy = $null foreach ($candidate in @($Record.userId, $Record.userPrincipalName, $Record.appId)) { if ([string]::IsNullOrWhiteSpace([string]$candidate)) { continue } $hit = $DecoyIndex[([string]$candidate).ToLowerInvariant()] if ($null -ne $hit) { $decoy = $hit; break } } if ($null -eq $decoy) { return $null } $errorCode = 0 if ($null -ne $Record.status -and $null -ne $Record.status.errorCode) { $errorCode = [int]$Record.status.errorCode } $lat = $null; $lon = $null; $country = $null if ($null -ne $Record.location) { $country = [string]$Record.location.countryOrRegion if ($null -ne $Record.location.geoCoordinates) { $lat = $Record.location.geoCoordinates.latitude $lon = $Record.location.geoCoordinates.longitude } } [pscustomobject]@{ id = [string]$Record.id timestamp = ConvertTo-TripwireUtc $Record.createdDateTime decoyId = [string]$decoy.id source = 'SignInLog' operation = "Sign-in to $($Record.appDisplayName)" result = if ($errorCode -eq 0) { 'success' } else { "failure ($errorCode)" } actor = [pscustomobject]@{ id = [string]$Record.userId upn = [string]$Record.userPrincipalName displayName = [string]$Record.userDisplayName type = 'DecoyPrincipal' } ip = [string]$Record.ipAddress country = $country asn = [string]$Record.autonomousSystemNumber latitude = $lat longitude = $lon } } <# .SYNOPSIS Convert a Graph directory-audit record into a touch, or $null if no decoy is involved. .DESCRIPTION An audit record matches when a decoy appears in targetResources -- somebody READ, listed or modified the decoy object. Reading is the interesting case: enumeration precedes use. #> function ConvertTo-TripwireTouchFromAudit { [CmdletBinding()] param( [Parameter(Mandatory)] $Record, [Parameter(Mandatory)] [hashtable] $DecoyIndex ) $decoy = $null foreach ($target in @($Record.targetResources)) { if ($null -eq $target) { continue } foreach ($candidate in @($target.id, $target.userPrincipalName, $target.displayName)) { if ([string]::IsNullOrWhiteSpace([string]$candidate)) { continue } $hit = $DecoyIndex[([string]$candidate).ToLowerInvariant()] if ($null -ne $hit) { $decoy = $hit; break } } if ($null -ne $decoy) { break } } if ($null -eq $decoy) { return $null } $actorId = ''; $actorUpn = ''; $actorName = ''; $actorType = 'Unknown'; $ip = '' if ($null -ne $Record.initiatedBy) { if ($null -ne $Record.initiatedBy.user) { $actorId = [string]$Record.initiatedBy.user.id $actorUpn = [string]$Record.initiatedBy.user.userPrincipalName $actorName = $actorUpn $actorType = 'User' $ip = [string]$Record.initiatedBy.user.ipAddress } elseif ($null -ne $Record.initiatedBy.app) { $actorId = [string]$Record.initiatedBy.app.servicePrincipalId $actorName = [string]$Record.initiatedBy.app.displayName $actorType = 'ServicePrincipal' } } [pscustomobject]@{ id = [string]$Record.id timestamp = ConvertTo-TripwireUtc $Record.activityDateTime decoyId = [string]$decoy.id source = 'DirectoryAudit' operation = [string]$Record.activityDisplayName result = [string]$Record.result actor = [pscustomobject]@{ id = $actorId upn = $actorUpn displayName = $actorName type = $actorType } # Directory audits carry no geo enrichment. The origin signal will withhold the # country and ASN components rather than score an absent country as "unseen", # which would make every audit-sourced touch look like it came from nowhere. ip = $ip country = $null asn = $null latitude = $null longitude = $null } } |