Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
This script queries Microsoft Sentinel incidents in Log Analytics, calculates the ratio of false-positive and informational closures, and identifies the noisiest analytics rules.
The script dot-sources _GsaOpsHelpers.ps1, so place the helper file in the same folder before you run this sample.
Prerequisites
- PowerShell 7.0 or later.
- Install the modules listed in the script's
.NOTESblock. - Use only permissions and roles that your organization has approved for the task.
Script
<#
.SYNOPSIS
Calculates the alert noise ratio from Sentinel incidents and alerts on excessive false positives.
.DESCRIPTION
Queries Log Analytics for Sentinel incident classifications over a configurable lookback
period. Calculates the ratio of false-positive and informational closures to total incidents.
If the ratio exceeds the threshold (default 20%), sends an alert email with details on the
noisiest analytics rules.
Run this script weekly as an Azure Automation runbook.
.PARAMETER WorkspaceId
Log Analytics workspace ID containing Sentinel incident data.
.PARAMETER ThresholdPercent
Maximum acceptable false-positive/informational ratio. Default: 20.
.PARAMETER LookbackDays
Number of days to analyze. Default: 7.
.PARAMETER AlertRecipient
Email address to receive the alert when the threshold is exceeded.
.PARAMETER SenderId
UserId or UPN of the mailbox used to send alert emails.
.EXAMPLE
.\Test-GsaAlertNoiseRatio.ps1 -WorkspaceId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -AlertRecipient "gsa-ops@contoso.com" -SenderId "gsa-automation@contoso.com"
.NOTES
Required permissions: Azure — Log Analytics Reader on the workspace; Graph — Mail.Send
Minimum module versions: Az.Accounts 2.x, Az.OperationalInsights 3.x, Microsoft.Graph.Authentication 2.x, Microsoft.Graph.Users.Actions 2.x
Dot-sources scripts/automation/_GsaOpsHelpers.ps1 for shared auth and email helpers.
Author: GSA Operations
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[string]$WorkspaceId,
[int]$ThresholdPercent = 20,
[int]$LookbackDays = 7,
[string]$AlertRecipient,
[string]$SenderId,
[string]$TenantId,
[string]$SubscriptionId,
[switch]$SkipEmail
)
$ErrorActionPreference = 'Stop'
# Load shared helpers (Connect-GsaRuntime, Send-GsaAlertEmail)
. "$PSScriptRoot\_GsaOpsHelpers.ps1"
# Connect only the services we need — skip Graph when -SkipEmail
$svc = if ($SkipEmail) { 'Az' } else { 'Both' }
Connect-GsaRuntime -Service $svc -TenantId $TenantId -SubscriptionId $SubscriptionId
# Ensure the session holds a Log Analytics token (CA policies may require MFA)
Confirm-GsaLogAnalyticsAccess
# KQL query to calculate noise ratio and identify noisy rules
$kqlQuery = @"
SecurityIncident
| where TimeGenerated > ago(${LookbackDays}d)
| where Classification != ""
| extend IsNoise = Classification in ("FalsePositive", "BenignPositive", "InformationalExpectedActivity")
| summarize
TotalClassified = count(),
NoiseCount = countif(IsNoise),
TruePositives = countif(Classification == "TruePositive")
| extend NoiseRatioPercent = round(100.0 * NoiseCount / TotalClassified, 1)
"@
$noisyRulesQuery = @"
SecurityIncident
| where TimeGenerated > ago(${LookbackDays}d)
| where Classification in ("FalsePositive", "BenignPositive", "InformationalExpectedActivity")
| extend RuleName = tostring(parse_json(tostring(AdditionalData)).alertProductNames[0])
| summarize FalsePositiveCount = count() by RuleName
| order by FalsePositiveCount desc
| take 10
"@
# Verify workspace is reachable with a lightweight probe
Write-Verbose "Querying workspace $WorkspaceId in subscription $((Get-AzContext).Subscription.Id)..."
try {
$null = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query 'print probe = "ok"' -ErrorAction Stop
Write-Verbose "Workspace reachable."
} catch {
Write-Error "Workspace $WorkspaceId is not reachable. Verify the workspace ID and that you have Log Analytics Reader on subscription $((Get-AzContext).Subscription.Id). Error: $($_.Exception.Message)"
return
}
# Check that the SecurityIncident table exists (requires Microsoft Sentinel)
Write-Verbose "Checking for SecurityIncident table..."
try {
$tableCheck = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId `
-Query 'SecurityIncident | take 0' -ErrorAction Stop
Write-Verbose "SecurityIncident table exists."
} catch {
Write-Warning "The SecurityIncident table does not exist in workspace $WorkspaceId. Microsoft Sentinel must be enabled and have at least one incident for this script to work."
[PSCustomObject]@{
Status = "NoData"
CheckedAt = (Get-Date)
LookbackDays = $LookbackDays
Reason = "SecurityIncident table not found — is Microsoft Sentinel enabled on this workspace?"
}
return
}
# Execute queries
try {
$ratioResult = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $kqlQuery -ErrorAction Stop
} catch {
Write-Error "Noise ratio query failed. Error: $($_.Exception.Message)"
return
}
try {
$noisyRulesResult = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $noisyRulesQuery -ErrorAction Stop
} catch {
Write-Warning "Noisy-rules query failed (non-fatal): $($_.Exception.Message)"
$noisyRulesResult = $null
}
$ratioData = $ratioResult.Results
if (-not $ratioData -or $ratioData.Count -eq 0) {
Write-Verbose "No classified incidents found in the last $LookbackDays days. Skipping noise ratio check."
[PSCustomObject]@{
Status = "NoData"
CheckedAt = (Get-Date)
LookbackDays = $LookbackDays
}
return
}
$noiseRatio = [double]$ratioData[0].NoiseRatioPercent
$totalClassified = [int]$ratioData[0].TotalClassified
$noiseCount = [int]$ratioData[0].NoiseCount
Write-Verbose "Alert noise ratio: $noiseRatio% ($noiseCount noise / $totalClassified total) — threshold: $ThresholdPercent%"
if ($noiseRatio -le $ThresholdPercent) {
Write-Verbose "Noise ratio is within acceptable range."
[PSCustomObject]@{
Status = "Compliant"
CheckedAt = (Get-Date)
NoiseRatioPercent = $noiseRatio
TotalClassified = $totalClassified
NoiseCount = $noiseCount
ThresholdPercent = $ThresholdPercent
}
return
}
# Build alert email with noisy rules table
$noisyRules = if ($noisyRulesResult) { $noisyRulesResult.Results } else { $null }
$rulesTableRows = ""
if ($noisyRules -and $noisyRules.Count -gt 0) {
$rulesTableRows = ($noisyRules | ForEach-Object {
"<tr><td>$($_.RuleName)</td><td>$($_.FalsePositiveCount)</td></tr>"
}) -join "`n"
}
$emailBody = @"
<h2>GSA Alert Noise Ratio Exceeded Threshold</h2>
<p>The alert noise ratio for the past <strong>$LookbackDays days</strong> is <strong>${noiseRatio}%</strong>, exceeding the <strong>${ThresholdPercent}%</strong> threshold.</p>
<ul>
<li>Total classified incidents: <strong>$totalClassified</strong></li>
<li>False positive / informational: <strong>$noiseCount</strong></li>
</ul>
<h3>Top Noisy Analytics Rules</h3>
<table border="1" cellpadding="5" cellspacing="0">
<tr><th>Rule Name</th><th>False Positive Count</th></tr>
$rulesTableRows
</table>
<p><strong>Action required:</strong></p>
<ol>
<li>Open Microsoft Sentinel > Analytics and review the rules listed above.</li>
<li>For each noisy rule, consider: adjusting the query threshold, adding exclusions for known-good patterns, or disabling the rule if it provides no actionable signal.</li>
<li>Re-assess after the next reporting period to confirm the noise ratio has improved.</li>
<li>Update your 30-day baseline if environmental changes (new users, new apps) caused a legitimate shift in alert volume.</li>
</ol>
"@
if ($SkipEmail) {
Write-Verbose "Skipping alert email (-SkipEmail specified)."
} elseif (-not $SenderId -or -not $AlertRecipient) {
Write-Warning "Skipping alert email — -SenderId and -AlertRecipient are required when -SkipEmail is not set."
} else {
try {
Send-GsaAlertEmail `
-SenderId $SenderId `
-Recipient $AlertRecipient `
-Subject "GSA Alert Noise Ratio Alert — ${noiseRatio}% (threshold: ${ThresholdPercent}%)" `
-HtmlBody $emailBody
} catch {
Write-Warning "Alert email failed (non-fatal): $_"
}
}
# Return summary
[PSCustomObject]@{
Status = "NonCompliant"
CheckedAt = (Get-Date)
NoiseRatioPercent = $noiseRatio
TotalClassified = $totalClassified
NoiseCount = $noiseCount
ThresholdPercent = $ThresholdPercent
TopNoisyRules = $noisyRules
}