modules/Invoke-SentinelCoverage.ps1
|
#requires -Version 7.0 <# .SYNOPSIS Wrapper for Microsoft Sentinel coverage / posture -- analytic rules, watchlists, hunting queries, and connectors. .DESCRIPTION Companion to Invoke-SentinelIncidents.ps1. Whereas the incidents wrapper surfaces detection *output* (live SecurityIncident rows), this wrapper surfaces detection *posture* by enumerating the workspace's: * Microsoft.SecurityInsights/alertRules -- analytic rules * Microsoft.SecurityInsights/watchlists -- watchlists + items * Microsoft.SecurityInsights/dataConnectors -- connector inventory * Microsoft.OperationalInsights/.../savedSearches -- hunting queries (filtered to category 'Hunting Queries') Detection categories shipped (issue #159): 1. Sentinel-enabled workspaces with NO analytic rules (High) 2. Disabled analytic rules whose last edit is >7 days old (Medium) 3. Workspaces with <3 enabled / connected data connectors (Medium) 4. Watchlists whose default item TTL (defaultDuration) <30 days (Medium) 5. Empty watchlists (zero items) (Low) 6. Workspaces with no hunting queries at all (Info) Categories explicitly DEFERRED (require telemetry the REST surface does not expose; would need extra KQL crossref against SecurityIncident / saved-search execution records): * Enabled analytic rules with no incidents in 30 days * Hunting queries not run in 90 days Emits a v1 envelope (SchemaVersion 1.0). Normalize-SentinelCoverage.ps1 converts each finding to a v2 FindingRow keyed to the workspace ARM ID. All REST calls are wrapped in Invoke-WithRetry. All disk writes go through Remove-Credentials. .PARAMETER WorkspaceResourceId Full ARM resource ID of the Log Analytics workspace linked to Sentinel. .PARAMETER LookbackDays Days threshold for "disabled analytic rule is stale" (detection #2). Default 30 (matches the orchestrator-supplied default for workspace-scope tools and the watchlist-TTL minimum). A rule whose lastModifiedUtc is older than this threshold AND is currently disabled emits a Medium finding. Also accepted for orchestrator-shape parity with Invoke-SentinelIncidents. .PARAMETER OutputPath Optional directory for raw API JSON (sanitized). #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $WorkspaceResourceId, [ValidateRange(1, 365)] [int] $LookbackDays = 30, [string] $OutputPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $retryPath = Join-Path $PSScriptRoot 'shared' 'Retry.ps1' if (Test-Path $retryPath) { . $retryPath } if (-not (Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue)) { function Invoke-WithRetry { param([scriptblock]$ScriptBlock, [int]$MaxAttempts = 3) & $ScriptBlock } } $sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1' if (Test-Path $sanitizePath) { . $sanitizePath } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param([string]$Text) return $Text } } $errorsPath = Join-Path $PSScriptRoot 'shared' 'Errors.ps1' if (Test-Path $errorsPath) { . $errorsPath } $envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1' if (Test-Path $envelopePath) { . $envelopePath } if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } } if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) { function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } } } if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) { function Format-FindingErrorMessage { param([Parameter(Mandatory)]$FindingError) $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" } return $line } } # --- API versions (Microsoft Learn, verified 2024) -------------------------- # https://learn.microsoft.com/rest/api/securityinsights/alert-rules/list # https://learn.microsoft.com/rest/api/securityinsights/watchlists/list # https://learn.microsoft.com/rest/api/securityinsights/data-connectors/list # https://learn.microsoft.com/rest/api/loganalytics/saved-searches/list-by-workspace $script:SentinelApiVersion = '2024-09-01' $script:LogAnalyticsApiVersion = '2020-08-01' $script:ToolVersion = "securityinsights-$($script:SentinelApiVersion)+loganalytics-$($script:LogAnalyticsApiVersion)" $script:DisabledRuleStaleDays = 7 # legacy default, overridden by -LookbackDays at runtime $script:WatchlistTtlMinDays = 30 $script:MinEnabledConnectors = 3 $subId = '' if ($WorkspaceResourceId -match '/subscriptions/([^/]+)') { $subId = $Matches[1] } $rgName = '' if ($WorkspaceResourceId -match '/resourceGroups/([^/]+)') { $rgName = $Matches[1] } $workspaceName = '' if ($WorkspaceResourceId -match '/workspaces/([^/]+)$') { $workspaceName = $Matches[1] } $analyticsBladeUrl = "https://portal.azure.com/#view/Microsoft_Azure_Security_Insights/MainMenuBlade/~/Analytics/subscriptionId/$subId/resourceGroup/$rgName/workspaceName/$workspaceName" $result = [ordered]@{ SchemaVersion = '1.0' Source = 'sentinel-coverage' Status = 'Success' Message = '' Findings = @() Subscription = $subId Timestamp = (Get-Date).ToUniversalTime().ToString('o') } if (-not (Get-Module -ListAvailable -Name Az.Accounts)) { $result.Status = 'Skipped' $result.Message = 'Az.Accounts module not installed. Run: Install-Module Az.Accounts -Scope CurrentUser' return [pscustomobject]$result } Import-Module Az.Accounts -ErrorAction SilentlyContinue try { $ctx = Get-AzContext -ErrorAction Stop if (-not $ctx) { Write-Error 'No Az context' -ErrorAction Stop } } catch { $result.Status = 'Skipped' $result.Message = 'Not signed in. Run Connect-AzAccount first.' return [pscustomobject]$result } if ($WorkspaceResourceId -notmatch '^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.OperationalInsights/workspaces/[^/]+$') { $result.Status = 'Failed' $result.Message = 'Invalid WorkspaceResourceId format. Expected: /subscriptions/{guid}/resourceGroups/{rg}/providers/Microsoft.OperationalInsights/workspaces/{name}' return [pscustomobject]$result } $findings = [System.Collections.Generic.List[object]]::new() function Invoke-SentinelGet { param ([Parameter(Mandatory)] [string] $Uri) $resp = Invoke-WithRetry -MaxAttempts 3 -ScriptBlock { Invoke-AzRestMethod -Method GET -Uri $Uri -ErrorAction Stop } return $resp } function Invoke-SentinelGetPaged { <# Iterates ARM list responses by following payload.nextLink until exhausted or MaxPages hit. Returns an aggregate object: { StatusCode = <last>; Items = @(...); TerminalResponse = <last raw> } Non-200 on the first page short-circuits with the raw response (no Items). Non-200 on a subsequent page is treated as best-effort: a warning is emitted, pagination stops, and items collected so far are returned. #> param ( [Parameter(Mandatory)] [string] $Uri, [int] $MaxPages = 20 ) $items = [System.Collections.Generic.List[object]]::new() $next = $Uri $pages = 0 $last = $null while ($next -and $pages -lt $MaxPages) { $resp = Invoke-SentinelGet -Uri $next $last = $resp $pages++ if (-not $resp -or $resp.StatusCode -ne 200) { if ($pages -eq 1) { return [pscustomobject]@{ StatusCode = (if ($resp) { $resp.StatusCode } else { 0 }); Items = @(); TerminalResponse = $resp } } $code = if ($resp) { $resp.StatusCode } else { 'null' } Write-Warning ("Pagination stopped at page {0} (HTTP {1}); returning {2} item(s)." -f $pages, $code, $items.Count) break } $payload = $resp.Content | ConvertFrom-Json -Depth 20 if ($payload.PSObject.Properties['value'] -and $payload.value) { foreach ($v in @($payload.value)) { $items.Add($v) | Out-Null } } $next = $null if ($payload.PSObject.Properties['nextLink'] -and $payload.nextLink) { $next = [string]$payload.nextLink } } return [pscustomobject]@{ StatusCode = 200; Items = @($items); TerminalResponse = $last } } function ConvertFrom-Iso8601Duration { <# Returns a [TimeSpan] for an ISO-8601 duration like P30D, PT1H, P1Y2M3DT4H5M. Returns $null when the input cannot be parsed. Years are approximated as 365 days and months as 30 days (sufficient for "<30d" comparisons). #> param ([string] $Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $null } $pattern = '^P(?:(?<y>\d+)Y)?(?:(?<mo>\d+)M)?(?:(?<d>\d+)D)?(?:T(?:(?<h>\d+)H)?(?:(?<mi>\d+)M)?(?:(?<s>\d+(?:\.\d+)?)S)?)?$' if ($Value -notmatch $pattern) { return $null } $days = 0.0 if ($Matches['y']) { $days += [double]$Matches['y'] * 365 } if ($Matches['mo']) { $days += [double]$Matches['mo'] * 30 } if ($Matches['d']) { $days += [double]$Matches['d'] } $hours = 0.0 if ($Matches['h']) { $hours += [double]$Matches['h'] } if ($Matches['mi']) { $hours += [double]$Matches['mi'] / 60 } if ($Matches['s']) { $hours += [double]$Matches['s'] / 3600 } return [TimeSpan]::FromDays($days) + [TimeSpan]::FromHours($hours) } function Add-Finding { param ( [Parameter(Mandatory)] [string] $Id, [Parameter(Mandatory)] [string] $Category, [Parameter(Mandatory)] [string] $Severity, [Parameter(Mandatory)] [string] $Title, [Parameter(Mandatory)] [string] $Detail, [Parameter(Mandatory)] [string] $Remediation, [string] $LearnMoreUrl, [string[]] $MitreTactics = @(), [string[]] $MitreTechniques = @(), [object[]] $Frameworks = @(), [string] $DeepLinkUrl = '', [hashtable] $Extras ) $frameworkRows = @($Frameworks) if ($frameworkRows.Count -eq 0 -and @($MitreTechniques).Count -gt 0) { $frameworkRows = @( [ordered]@{ Name = 'MITRE ATT&CK' Controls = @($MitreTechniques) } ) } $row = [ordered]@{ Id = $Id Source = 'sentinel-coverage' Category = $Category Severity = $Severity Compliant = $false Title = $Title Detail = $Detail Remediation = $Remediation ResourceId = $WorkspaceResourceId LearnMoreUrl = if ($LearnMoreUrl) { $LearnMoreUrl } else { 'https://learn.microsoft.com/azure/sentinel/' } ToolVersion = $script:ToolVersion Pillar = 'Security' Frameworks = $frameworkRows MitreTactics = @($MitreTactics) MitreTechniques = @($MitreTechniques) DeepLinkUrl = if ($DeepLinkUrl) { $DeepLinkUrl } else { $analyticsBladeUrl } } if ($Extras) { foreach ($k in $Extras.Keys) { $row[$k] = $Extras[$k] } } $findings.Add([pscustomobject]$row) | Out-Null } function ConvertTo-StringArray { param ([object] $Value) $result = [System.Collections.Generic.List[string]]::new() if ($null -eq $Value) { return @($result) } foreach ($item in @($Value)) { if ($null -eq $item) { continue } if ($item -is [string]) { if (-not [string]::IsNullOrWhiteSpace($item)) { $result.Add($item) | Out-Null } continue } if ($item.PSObject.Properties['name'] -and $item.name) { $result.Add([string]$item.name) | Out-Null continue } if ($item.PSObject.Properties['id'] -and $item.id) { $result.Add([string]$item.id) | Out-Null continue } $s = [string]$item if (-not [string]::IsNullOrWhiteSpace($s)) { $result.Add($s) | Out-Null } } return @($result) } $base = "https://management.azure.com${WorkspaceResourceId}" $sentinelOK = $true $summary = [ordered]@{ AlertRules = 0; Watchlists = 0; Connectors = 0; HuntingQueries = 0 } # --- 0. Onboarding probe --------------------------------------------------- # Microsoft.SecurityInsights/onboardingStates/default returns 200 when the # workspace is onboarded to Sentinel, 404 when it is not. The alertRules # endpoint returns 200 + empty array on a non-onboarded workspace, so we # cannot rely on it for the skip path. See: # https://learn.microsoft.com/rest/api/securityinsights/sentinel-onboarding-states/get try { $probeUri = "${base}/providers/Microsoft.SecurityInsights/onboardingStates/default?api-version=$($script:SentinelApiVersion)" $probeResp = Invoke-SentinelGet -Uri $probeUri if ($probeResp -and $probeResp.StatusCode -eq 404) { $result.Status = 'Skipped' $result.Message = 'Sentinel is not onboarded on this workspace (onboardingStates/default returned 404).' return [pscustomobject]$result } elseif (-not $probeResp -or $probeResp.StatusCode -ge 400) { # 401/403 = not authorized; treat as Skipped rather than Failed since # the user may have Reader on the workspace but not Sentinel Reader. $code = if ($probeResp) { $probeResp.StatusCode } else { 'null' } if ($probeResp -and ($probeResp.StatusCode -eq 401 -or $probeResp.StatusCode -eq 403)) { $result.Status = 'Skipped' $result.Message = "Sentinel onboarding probe denied (HTTP $code). Microsoft Sentinel Reader role required." return [pscustomobject]$result } # Other non-200 (5xx, 409 etc): try alertRules anyway as a best-effort fallback. Write-Warning ("Sentinel onboarding probe returned HTTP {0}; continuing with best-effort detection." -f $code) } } catch { Write-Warning ("Sentinel onboarding probe failed: {0}; continuing with best-effort detection." -f (Remove-Credentials -Text ([string]$_.Exception.Message))) } # --- 1. Analytic rules ------------------------------------------------------ $rules = @() try { $uri = "${base}/providers/Microsoft.SecurityInsights/alertRules?api-version=$($script:SentinelApiVersion)" $paged = Invoke-SentinelGetPaged -Uri $uri if ($paged.StatusCode -eq 200) { $rules = @($paged.Items) } elseif ($paged.StatusCode -eq 404 -or $paged.StatusCode -eq 409) { # Defensive fallback: probe missed it but alertRules is definitive. $sentinelOK = $false $result.Status = 'Skipped' $result.Message = "Microsoft.SecurityInsights/alertRules returned HTTP $($paged.StatusCode). Sentinel may not be onboarded." return [pscustomobject]$result } else { $resp = $paged.TerminalResponse $statusCode = if ($resp) { $resp.StatusCode } else { 'null' } $content = if ($resp) { $resp.Content } else { 'No response' } throw (Format-FindingErrorMessage (New-FindingError ` -Source 'wrapper:sentinel-coverage' ` -Category 'TransientFailure' ` -Reason "alertRules list returned HTTP ${statusCode}." ` -Remediation 'Verify Microsoft Sentinel Reader role on the workspace and retry.' ` -Details (Remove-Credentials -Text ([string]$content)))) } } catch { $result.Status = 'Failed' $result.Message = "Sentinel alertRules query failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))" return [pscustomobject]$result } $summary.AlertRules = $rules.Count $enabledRules = @($rules | Where-Object { $_.properties.PSObject.Properties['enabled'] -and $_.properties.enabled }) $disabledRules = @($rules | Where-Object { $_.properties.PSObject.Properties['enabled'] -and -not $_.properties.enabled }) # Detection #1: workspace has NO analytic rules at all (High). if ($rules.Count -eq 0) { Add-Finding -Id "sentinel/coverage/no-analytic-rules" ` -Category 'ThreatDetection' -Severity 'High' ` -Title 'Sentinel workspace has no analytic rules' ` -Detail 'No analytic rules are configured on this workspace. Sentinel cannot generate incidents without enabled analytic rules.' ` -Remediation 'Enable Microsoft-provided analytic rule templates in the Sentinel portal (Analytics blade) or deploy ALZ Sentinel content packs.' ` -LearnMoreUrl 'https://learn.microsoft.com/azure/sentinel/detect-threats-built-in' ` -Extras @{ AnalyticRuleCount = 0 } } # Detection #2: disabled analytic rules whose last edit is >LookbackDays old (Medium). $staleThreshold = [TimeSpan]::FromDays($LookbackDays) $now = (Get-Date).ToUniversalTime() foreach ($r in $disabledRules) { $name = [string]$r.name $lastModRaw = $null $lastModUtc = $null if ($r.properties.PSObject.Properties['lastModifiedUtc']) { $rawValue = $r.properties.lastModifiedUtc if ($rawValue -is [datetime]) { # ConvertFrom-Json auto-parses ISO-8601 strings to DateTime; preserve the canonical 'o' format for output. $lastModUtc = $rawValue.ToUniversalTime() $lastModRaw = $lastModUtc.ToString('o') } else { $lastModRaw = [string]$rawValue try { $lastModUtc = ([datetime]::Parse($lastModRaw, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind)).ToUniversalTime() } catch { $lastModUtc = $null } } } $age = $null if ($lastModUtc) { $age = $now - $lastModUtc } if ($null -eq $age -or $age -ge $staleThreshold) { $ageDays = if ($age) { [math]::Round($age.TotalDays, 1) } else { 'unknown' } $title = if ($r.properties.PSObject.Properties['displayName']) { [string]$r.properties.displayName } else { $name } $mitreTactics = @() $mitreTechniques = @() if ($r.properties.PSObject.Properties['tactics']) { $mitreTactics = @(ConvertTo-StringArray -Value $r.properties.tactics) } if ($r.properties.PSObject.Properties['techniques']) { $mitreTechniques = @(ConvertTo-StringArray -Value $r.properties.techniques) } Add-Finding -Id "sentinel/coverage/disabled-rule/$name" ` -Category 'ThreatDetection' -Severity 'Medium' ` -Title "Analytic rule disabled >$LookbackDays days: $title" ` -Detail "Rule '$title' (id $name) has been disabled for $ageDays day(s). Disabled rules generate no incidents." ` -Remediation 'Re-enable the rule, archive it via configuration-as-code, or document the exception.' ` -LearnMoreUrl 'https://learn.microsoft.com/azure/sentinel/detect-threats-custom' ` -MitreTactics $mitreTactics ` -MitreTechniques $mitreTechniques ` -Extras @{ RuleId = $name; RuleDisplayName = $title; LastModifiedUtc = $lastModRaw; AgeDays = $ageDays; StaleThresholdDays = $LookbackDays } } } # --- 2. Data connectors ---------------------------------------------------- $connectors = @() try { $uri = "${base}/providers/Microsoft.SecurityInsights/dataConnectors?api-version=$($script:SentinelApiVersion)" $paged = Invoke-SentinelGetPaged -Uri $uri if ($paged.StatusCode -eq 200) { $connectors = @($paged.Items) } elseif ($paged.StatusCode -ge 400) { Write-Warning ("Sentinel dataConnectors query returned HTTP {0}; skipping connector checks." -f $paged.StatusCode) } } catch { Write-Warning ("Sentinel dataConnectors query failed: {0}" -f (Remove-Credentials -Text ([string]$_.Exception.Message))) } $summary.Connectors = $connectors.Count # Filter to connectors that have at least one dataType in an "Enabled" state. # Connectors registered but with all dataTypes Disabled do not produce telemetry. function Test-ConnectorEnabled { param ($Connector) if (-not $Connector -or -not $Connector.PSObject.Properties['properties']) { return $false } $props = $Connector.properties if (-not $props.PSObject.Properties['dataTypes'] -or -not $props.dataTypes) { return $false } $dataTypes = $props.dataTypes # dataTypes can be either an object whose properties are dataType buckets, # or an array. Normalize to a list of bucket objects. $buckets = @() if ($dataTypes -is [System.Collections.IEnumerable] -and $dataTypes -isnot [string]) { $buckets = @($dataTypes) } else { foreach ($p in $dataTypes.PSObject.Properties) { $buckets += $p.Value } } foreach ($b in $buckets) { if ($b -and $b.PSObject.Properties['state'] -and ([string]$b.state) -ieq 'Enabled') { return $true } } return $false } $enabledConnectors = @($connectors | Where-Object { Test-ConnectorEnabled -Connector $_ }) $summary['EnabledConnectors'] = $enabledConnectors.Count # Detection #3: workspace has <3 ENABLED connectors (Medium -- under-monitored). if ($enabledConnectors.Count -lt $script:MinEnabledConnectors) { Add-Finding -Id "sentinel/coverage/few-connectors" ` -Category 'ThreatDetection' -Severity 'Medium' ` -Title "Sentinel workspace has only $($enabledConnectors.Count) enabled data connector(s) (<$($script:MinEnabledConnectors))" ` -Detail "Workspace has $($connectors.Count) data connector(s) registered, of which $($enabledConnectors.Count) have at least one dataType in 'Enabled' state. A healthy Sentinel deployment typically has at least $($script:MinEnabledConnectors) enabled data sources (e.g., Azure Activity, Entra ID sign-ins, Defender XDR). Note: the dataConnectors REST surface does not enumerate every modern connector type (CCP / Defender XDR may be under-reported)." ` -Remediation 'Connect additional data sources (Azure Activity, Microsoft Entra ID, Microsoft 365 Defender, Threat Intelligence) via the Sentinel Data connectors blade.' ` -LearnMoreUrl 'https://learn.microsoft.com/azure/sentinel/connect-data-sources' ` -Extras @{ ConnectorCount = $connectors.Count; EnabledConnectorCount = $enabledConnectors.Count; MinExpected = $script:MinEnabledConnectors } } # --- 3. Watchlists --------------------------------------------------------- $watchlists = @() try { $uri = "${base}/providers/Microsoft.SecurityInsights/watchlists?api-version=$($script:SentinelApiVersion)" $paged = Invoke-SentinelGetPaged -Uri $uri if ($paged.StatusCode -eq 200) { $watchlists = @($paged.Items) } elseif ($paged.StatusCode -ge 400) { Write-Warning ("Sentinel watchlists query returned HTTP {0}; skipping watchlist checks." -f $paged.StatusCode) } } catch { Write-Warning ("Sentinel watchlists query failed: {0}" -f (Remove-Credentials -Text ([string]$_.Exception.Message))) } $summary.Watchlists = $watchlists.Count foreach ($w in $watchlists) { $alias = if ($w.properties.PSObject.Properties['watchlistAlias']) { [string]$w.properties.watchlistAlias } else { [string]$w.name } $name = [string]$w.name # Detection #4: watchlist default TTL <30 days (Medium). if ($w.properties.PSObject.Properties['defaultDuration'] -and $w.properties.defaultDuration) { $ttl = ConvertFrom-Iso8601Duration -Value ([string]$w.properties.defaultDuration) if ($ttl -and $ttl.TotalDays -lt $script:WatchlistTtlMinDays) { Add-Finding -Id "sentinel/coverage/watchlist-ttl/$alias" ` -Category 'ThreatDetection' -Severity 'Medium' ` -Title "Watchlist '$alias' has TTL <$($script:WatchlistTtlMinDays) days" ` -Detail "Watchlist '$alias' defaultDuration is $([string]$w.properties.defaultDuration) (~$([math]::Round($ttl.TotalDays,1)) days). Items may expire before analytic rules can use them." ` -Remediation 'Increase the watchlist defaultDuration in the Sentinel portal or via ARM template.' ` -LearnMoreUrl 'https://learn.microsoft.com/azure/sentinel/watchlists' ` -Extras @{ WatchlistAlias = $alias; WatchlistName = $name; DefaultDuration = ([string]$w.properties.defaultDuration); TtlDays = [math]::Round($ttl.TotalDays, 1) } } } # Detection #5: empty watchlist (Low). $itemCount = $null try { $aliasEnc = [uri]::EscapeDataString($alias) $itemUri = "${base}/providers/Microsoft.SecurityInsights/watchlists/$aliasEnc/watchlistItems?api-version=$($script:SentinelApiVersion)" $itemPaged = Invoke-SentinelGetPaged -Uri $itemUri if ($itemPaged.StatusCode -eq 200) { $itemCount = @($itemPaged.Items).Count } } catch { Write-Warning ("watchlistItems query failed for {0}: {1}" -f $alias, (Remove-Credentials -Text ([string]$_.Exception.Message))) } if ($null -ne $itemCount -and $itemCount -eq 0) { Add-Finding -Id "sentinel/coverage/watchlist-empty/$alias" ` -Category 'ThreatDetection' -Severity 'Low' ` -Title "Watchlist '$alias' is empty" ` -Detail "Watchlist '$alias' contains 0 items. Analytic rules referencing it will produce no matches." ` -Remediation 'Populate the watchlist via CSV upload, ARM template, or the Sentinel Logic Apps connector.' ` -LearnMoreUrl 'https://learn.microsoft.com/azure/sentinel/watchlists-create' ` -Extras @{ WatchlistAlias = $alias; WatchlistName = $name; ItemCount = 0 } } } # --- 4. Hunting queries (saved searches with category 'Hunting Queries') --- $hunting = @() try { $uri = "${base}/savedSearches?api-version=$($script:LogAnalyticsApiVersion)" $paged = Invoke-SentinelGetPaged -Uri $uri if ($paged.StatusCode -eq 200) { $hunting = @($paged.Items | Where-Object { $_.properties.PSObject.Properties['category'] -and ([string]$_.properties.category) -match '(?i)hunting' }) } elseif ($paged.StatusCode -ge 400) { Write-Warning ("savedSearches query returned HTTP {0}; skipping hunting-query checks." -f $paged.StatusCode) } } catch { Write-Warning ("savedSearches query failed: {0}" -f (Remove-Credentials -Text ([string]$_.Exception.Message))) } $summary.HuntingQueries = $hunting.Count # Detection #6: workspace has no hunting queries at all (Info). if ($hunting.Count -eq 0) { Add-Finding -Id "sentinel/coverage/no-hunting-queries" ` -Category 'ThreatDetection' -Severity 'Info' ` -Title 'Sentinel workspace has no hunting queries' ` -Detail 'No saved searches with category "Hunting Queries" are configured. Hunting queries enable proactive threat investigation beyond automated analytic rules.' ` -Remediation 'Import Microsoft-provided hunting query templates (Hunting blade) or deploy queries from the Sentinel content hub.' ` -LearnMoreUrl 'https://learn.microsoft.com/azure/sentinel/hunting' ` -Extras @{ HuntingQueryCount = 0 } } $result.Findings = @($findings) $enabledCount = $summary['EnabledConnectors'] if ($null -eq $enabledCount) { $enabledCount = 0 } $result.Message = "Sentinel coverage scan: $($findings.Count) finding(s). Inventory -- analyticRules: $($summary.AlertRules) (enabled: $($enabledRules.Count), disabled: $($disabledRules.Count)); watchlists: $($summary.Watchlists); connectors: $($summary.Connectors) (enabled: $enabledCount); huntingQueries: $($summary.HuntingQueries)." if ($OutputPath) { try { if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $raw = Join-Path $OutputPath "sentinel-coverage-$(Get-Date -Format yyyyMMddHHmmss).json" Set-Content -Path $raw -Value (Remove-Credentials ($result | ConvertTo-Json -Depth 20)) -Encoding utf8 } catch { Write-Warning "Failed to write raw Sentinel coverage JSON: $(Remove-Credentials -Text ([string]$_.Exception.Message))" } } return [pscustomobject]$result |