public/Get-MsecDefenderIndicator.ps1
|
function Get-MsecDefenderIndicator { <# .SYNOPSIS Every custom indicator of compromise in Defender for Endpoint - one row per indicator, with what it matches, what it does, who created it and whether it ever expires. .DESCRIPTION New-MsecDefenderIndicator could create one and nothing could list them, so the only way to see what was already in place was the portal. This answers "what are we already blocking, allowing or auditing, and who decided that". THIS API HAS NO READ-ONLY PERMISSION, WHICH MAKES THIS A Get-* THAT NEEDS A WRITE SCOPE. Listing indicators requires Ti.ReadWrite - normally through the Security Administrator role - because Microsoft never shipped a Ti.Read. That is surprising enough to state before anything else: this command reads, but the permission it needs can also write, and no narrower one exists to ask for. IT RUNS ON THE Az CONTEXT, AS YOU - not as the msec app. The app holds *.Read.All and is deliberately NOT given Ti.ReadWrite: adding a write permission to the read-only identity so that a read becomes convenient is exactly the trade this module refuses. So this needs Connect-MsecAzure, and what you can see is what your own roles allow. AN INDICATOR WITH NO EXPIRY NEVER EXPIRES, and that is the normal case rather than the exception - measured on one tenant, 63 of 63 had no expirationTime at all. ExpiresUtc is $null for those, never a date far in the future, because "permanent" and "expires in 2099" are different facts and a report that conflates them is lying quietly. AN EMPTY SCOPE MEANS TENANT-WIDE, NOT UNSCOPED. rbacGroupNames comes back empty when an indicator applies to every device, which reads like missing data and is the opposite - measured on the same tenant, 0 of 63 were scoped to a device group, so all 63 applied everywhere. Scope is reported as 'Tenant-wide' rather than left blank. COMMERCIAL CLOUD ONLY. Defender for Endpoint has no securitycenter endpoint in the sovereign clouds, so this throws there rather than returning an empty list. .PARAMETER Type Indicator types to return - FileSha256, FileSha1, FileMd5, IpAddress, DomainName, Url, CertificateThumbprint, WebCategory. Matched case-insensitively against what the API returns, and an unknown name is NOT an error: it filters to nothing and says so, since Microsoft adds types without warning. .PARAMETER Action Actions to return - Allowed, Audit, Block, BlockAndRemediate, Warn. Same matching rules as -Type. .PARAMETER Value Wildcard filter on the indicator value itself, e.g. '*.example.com' or a partial hash. .PARAMETER CreatedBy Wildcard filter on the creating account. .EXAMPLE Get-MsecDefenderIndicator | Group-Object Action, Type | Sort-Object Count -Descending What the tenant is actually enforcing, and in what proportion. On one tenant: 36 Audit, 20 Block, 7 Allowed - so more than half of it was watching rather than stopping. .EXAMPLE Get-MsecDefenderIndicator -Action Allowed The allow-list, which is the half of this worth reviewing most often - every entry is something the product WOULD have stopped. .OUTPUTS One PSCustomObject per indicator, PSTypeName 'MsecDefenderIndicator'. Raw keeps the untouched API object. .NOTES Needs Connect-MsecAzure and the Ti.ReadWrite scope on YOUR account - Security Administrator or equivalent. The msec app cannot do this and is not asked to. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [string[]] $Type, [string[]] $Action, [string] $Value, [string] $CreatedBy ) $endpoints = Get-MsecEnvironment $defender = $endpoints.DefenderResource if (-not $defender) { throw ("Microsoft Defender for Endpoint has no endpoint in the '$($endpoints.EnvironmentName)' cloud, " + 'so indicators cannot be listed here. This API is commercial-only.') } $context = Get-AzContext -ErrorAction SilentlyContinue if (-not $context) { throw 'No Azure context. Run Connect-MsecAzure before Get-MsecDefenderIndicator - this reads as you, not as the msec app.' } $tokenResponse = Get-AzAccessToken -ResourceUrl $defender -ErrorAction Stop $token = if ($tokenResponse.Token -is [System.Security.SecureString]) { [System.Net.NetworkCredential]::new('', $tokenResponse.Token).Password } else { [string] $tokenResponse.Token } $headers = @{ Authorization = "Bearer $token" } # PAGED DEFENSIVELY. One tenant returned all 63 in a single response with no # @odata.nextLink, but the indicator limit is in the thousands and a silently truncated # security inventory is worse than a slow one. $raw = [System.Collections.Generic.List[object]]::new() $uri = "$defender/api/indicators" try { while ($uri) { $page = Invoke-RestMethod -Headers $headers -Uri $uri -ErrorAction Stop foreach ($i in @($page.value)) { $raw.Add($i) } $uri = $page.'@odata.nextLink' } } catch { $detail = $_.Exception.Message if ($detail -match '401|403|Unauthorized|Forbidden') { throw ("Forbidden listing Defender indicators as $($context.Account.Id). This API has NO read-only " + 'permission - listing requires the Ti.ReadWrite scope, normally through the Security ' + "Administrator role. This is an authorization failure on YOUR account, not on the msec app. " + "Original error: $detail") } throw "Could not list Defender indicators: $detail" } $rows = foreach ($i in $raw) { # EMPTY SCOPE IS TENANT-WIDE. See the help - blank here reads as missing data and means # the opposite. $groups = @($i.rbacGroupNames | Where-Object { $_ }) $scope = if ($groups.Count) { $groups -join '; ' } else { 'Tenant-wide' } [PSCustomObject]@{ PSTypeName = 'MsecDefenderIndicator' Value = [string] $i.indicatorValue Type = [string] $i.indicatorType Action = [string] $i.action Severity = [string] $i.severity Title = [string] $i.title Scope = $scope GenerateAlert = [bool] $i.generateAlert CreatedBy = [string] $i.createdBy CreatedSource = [string] $i.createdBySource CreatedUtc = $i.creationTimeDateTimeUtc LastUpdatedUtc = $i.lastUpdateTime # $null, never a far-future date: "never expires" is a different fact from "expires # eventually", and most indicators are the former. ExpiresUtc = if ($i.expirationTime) { [datetime] $i.expirationTime } else { $null } Id = [string] $i.id Raw = $i } } $rows = @($rows) if ($Type) { $known = @($rows.Type | Sort-Object -Unique) $unknown = @($Type | Where-Object { $_ -notin $known }) if ($unknown.Count) { Write-Warning ("No indicator has type(s): $($unknown -join ', '). Present in this tenant: " + "$($known -join ', '). Microsoft adds types without warning, so this is a filter " + 'that matched nothing rather than an invalid value.') } $rows = @($rows | Where-Object { $_.Type -in $Type }) } if ($Action) { $known = @($rows.Action | Sort-Object -Unique) $unknown = @($Action | Where-Object { $_ -notin $known }) if ($unknown.Count) { Write-Warning ("No indicator has action(s): $($unknown -join ', '). Present in this tenant: " + "$($known -join ', ').") } $rows = @($rows | Where-Object { $_.Action -in $Action }) } if ($Value) { $rows = @($rows | Where-Object { $_.Value -like $Value }) } if ($CreatedBy) { $rows = @($rows | Where-Object { $_.CreatedBy -like $CreatedBy }) } $rows } |