Private/HuntHelpers.ps1
|
# Small helpers shared by the hunt checks. $Hunt is the per-run state Invoke-NSPM365IocHunt builds: # Ioc (the set), Since / SinceIso, OutputPath, TenantLabel, Findings, AppRoleCache. function Add-NSPHuntFinding { param( [Parameter(Mandatory)][hashtable]$Hunt, [Parameter(Mandatory)][ValidateSet('Critical', 'High', 'Medium', 'Low', 'Info', 'Warning')][string]$Severity, [string]$Check, [string]$Object, [string]$Detail, $Time = $null, $Evidence = $null ) $ev = if ($null -ne $Evidence) { ConvertTo-Json -InputObject $Evidence -Depth 6 -Compress } else { '' } $Hunt.Findings.Add([pscustomobject]@{ Tenant = $Hunt.TenantLabel Severity = $Severity Check = $Check Object = $Object Detail = $Detail TimeUtc = $Time Evidence = $ev }) $color = switch ($Severity) { 'Critical' { 'Red' } 'High' { 'Red' } 'Medium' { 'Yellow' } 'Warning' { 'Magenta' } default { 'Gray' } } Write-Host (' [{0}] {1}: {2} - {3}' -f $Severity, $Check, $Object, $Detail) -ForegroundColor $color } function Get-NSPHuntCollection { # Every page of a Graph collection through Invoke-NSPGraphRequest (so the Fake transport # applies), stopping at -MaxPages, with a retry on throttling and transient errors. param( [Parameter(Mandatory)][string]$Uri, [ValidateSet('v1.0', 'beta')][string]$ApiVersion = 'v1.0', [int]$MaxPages = 200 ) $items = New-Object System.Collections.Generic.List[object] $next = $Uri $page = 0 while ($next -and $page -lt $MaxPages) { $attempt = 0 while ($true) { try { $response = Invoke-NSPGraphRequest -Method GET -Uri $next -ApiVersion $ApiVersion break } catch { $attempt++ if ($attempt -le 5 -and $_.Exception.Message -match '429|TooManyRequests|503|504|ServiceUnavailable|GatewayTimeout') { $wait = [math]::Min(60, [math]::Pow(2, $attempt) * 2) Write-Verbose "Throttled or transient error ($($_.Exception.Message)). Waiting $wait s." Start-Sleep -Seconds $wait continue } throw } } foreach ($item in @($response['value'])) { if ($null -ne $item) { $items.Add($item) } } $next = $response['@odata.nextLink'] $page++ } if ($next) { Write-Warning "Stopped after $MaxPages page(s) of $Uri; later results were not read." } , $items.ToArray() } function New-NSPHuntFilterUri { param([Parameter(Mandatory)][string]$Base, [Parameter(Mandatory)][string]$Filter, [string]$Extra) $uri = $Base + '?$filter=' + [uri]::EscapeDataString($Filter) if ($Extra) { $uri += '&' + $Extra } $uri } function Get-NSPHuntValue { # Nested read that tolerates missing keys (Graph omits nulls) on hashtables or objects. param($InputObject, [Parameter(Mandatory)][string[]]$Path) $current = $InputObject foreach ($key in $Path) { if ($null -eq $current) { return $null } if ($current -is [System.Collections.IDictionary]) { if (-not $current.Contains($key)) { return $null } $current = $current[$key] } elseif ($current.PSObject.Properties.Name -contains $key) { $current = $current.$key } else { return $null } } $current } function ConvertTo-NSPHuntUtc { # Graph dates arrive as strings or DateTime depending on host and output type. param($Value) if ($Value -is [datetime]) { if ($Value.Kind -eq [DateTimeKind]::Unspecified) { return [datetime]::SpecifyKind($Value, [DateTimeKind]::Utc) } return $Value.ToUniversalTime() } $styles = [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal [datetime]::Parse([string]$Value, [Globalization.CultureInfo]::InvariantCulture, $styles) } function Format-NSPHuntUtc { param($Value) if ($null -eq $Value -or "$Value" -eq '') { return '' } try { (ConvertTo-NSPHuntUtc $Value).ToString('yyyy-MM-dd HH:mm:ss') + 'Z' } catch { [string]$Value } } function Test-NSPHuntIocIp { # 'Exact', 'Prefix', 'Context', or $null. param([Parameter(Mandatory)][hashtable]$Hunt, [string]$Ip) if (-not $Ip) { return $null } if ($Hunt.Ioc.IPs -contains $Ip) { return 'Exact' } foreach ($prefix in $Hunt.Ioc.IpPrefixes) { if ($Ip.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return 'Prefix' } } foreach ($prefix in $Hunt.Ioc.ContextIpPrefixes.Keys) { if ($Ip.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return 'Context' } } $null } function Get-NSPHuntContextNote { param([Parameter(Mandatory)][hashtable]$Hunt, [string]$Ip) foreach ($prefix in $Hunt.Ioc.ContextIpPrefixes.Keys) { if ($Ip -and $Ip.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return $Hunt.Ioc.ContextIpPrefixes[$prefix] } } '' } function Test-NSPHuntPremiumError { param([string]$Message) $Message -match 'RequestFromNonPremiumTenant|premium license|Authentication_RequestFromNonPremiumTenantOrB2CTenant' } function Save-NSPHuntEvidence { # Raw Graph data per tenant, so evidence outlives log retention. Written even under -WhatIf: # -WhatIf covers the remediation, not the report. param([Parameter(Mandatory)][hashtable]$Hunt, $Data, [Parameter(Mandatory)][string]$Name) $items = @(foreach ($d in $Data) { $d }) if ($items.Count -eq 0) { return } $folder = Join-Path $Hunt.OutputPath ($Hunt.TenantLabel -replace '[^\w\.-]', '_') if (-not (Test-Path -LiteralPath $folder)) { New-Item -ItemType Directory -Path $folder -Force -WhatIf:$false -Confirm:$false | Out-Null } ConvertTo-Json -InputObject $items -Depth 10 | Out-File -FilePath (Join-Path $folder "$Name.json") -Encoding UTF8 -WhatIf:$false -Confirm:$false } function Resolve-NSPHuntAppRoleName { # Application permission name for an app role ID, cached per resource service principal. param([Parameter(Mandatory)][hashtable]$Hunt, [string]$ResourceId, [string]$AppRoleId) if (-not $Hunt.AppRoleCache.ContainsKey($ResourceId)) { $map = @{} try { $resource = Invoke-NSPGraphRequest -Uri ("servicePrincipals/$ResourceId" + '?$select=appRoles') foreach ($role in @($resource['appRoles'])) { if ($role) { $map[[string]$role['id']] = [string]$role['value'] } } } catch { Write-Verbose "Could not read app roles of $ResourceId`: $($_.Exception.Message)" } $Hunt.AppRoleCache[$ResourceId] = $map } $map = $Hunt.AppRoleCache[$ResourceId] if ($map.ContainsKey($AppRoleId)) { return $map[$AppRoleId] } $AppRoleId } |