Private/Invoke-Analysis.ps1
|
function Invoke-Analysis { <# .SYNOPSIS Computes the cost-value matrix and generates prioritised recommendations. .OUTPUTS PSCustomObject with TableAnalysis (array), Recommendations (array), and Summary statistics. #> [CmdletBinding()] param( [Parameter(Mandatory)][array]$TableUsage, [Parameter(Mandatory)][PSCustomObject]$Classifications, [Parameter(Mandatory)][PSCustomObject]$RulesData, [Parameter(Mandatory)][PSCustomObject]$HuntingData, [PSCustomObject]$DefenderXDR, [array]$SocRecommendations, [array]$TableRetention, [int]$WorkspaceRetentionDays = 0, [decimal]$PricePerGB = 5.59, [decimal]$BasicPricePerGB = 1.15, [decimal]$LakePricePerGB = 0.20, [PSCustomObject]$DataTransforms, [hashtable]$HighValueFields, [hashtable]$FieldFrequencyStats = @{}, [array]$Incidents = @(), [array]$AutomationRules = @(), [hashtable]$AutoCloseHealthData, [switch]$IncludeDetectionAnalyzer ) $classMap = $Classifications.Classifications # hashtable $ruleCoverage = $RulesData.TableCoverage # hashtable: table -> count $huntCoverage = $HuntingData.TableCoverage # hashtable: table -> count $implicitCoverage = if ($RulesData.PSObject.Properties.Name -contains 'ImplicitCoverage' -and $RulesData.ImplicitCoverage) { $RulesData.ImplicitCoverage } else { @{} } $platformTables = if ($RulesData.PSObject.Properties.Name -contains 'PlatformTables' -and $RulesData.PlatformTables) { @($RulesData.PlatformTables) } else { @() } $xdrCoverage = if ($DefenderXDR -and $DefenderXDR.XDRTableCoverage) { $DefenderXDR.XDRTableCoverage } else { @{} } $knownXDRTables = if ($DefenderXDR -and $DefenderXDR.KnownXDRTables) { @($DefenderXDR.KnownXDRTables) } else { @() } # The classification DB is the source of truth for which XDR tables the connector can stream $knownXDRTables = @($knownXDRTables | Where-Object { $c = $classMap[$_] -not ($c -and $c.PSObject.Properties.Name -contains 'XdrStreamable' -and $c.XdrStreamable -eq $false) }) # Build retention lookup from Tables API data $retentionMap = @{} if ($TableRetention) { foreach ($tr in $TableRetention) { $retentionMap[$tr.TableName] = $tr } } # Build transform lookup from DCR data $transformLookup = @{} if ($DataTransforms -and $DataTransforms.TableLookup) { $transformLookup = $DataTransforms.TableLookup } Write-Verbose "Starting analysis for $($TableUsage.Count) table(s)." # Group enabled rules by table once so Get-SplitKql does not rescan every rule per table $rulesByTable = @{} foreach ($rule in @($RulesData.Rules)) { if (-not $rule.Enabled) { continue } foreach ($t in @($rule.Tables)) { if ([string]::IsNullOrWhiteSpace($t)) { continue } if (-not $rulesByTable.ContainsKey($t)) { $rulesByTable[$t] = [System.Collections.Generic.List[object]]::new() } $rulesByTable[$t].Add($rule) } } # Per-table analysis $tableAnalysis = foreach ($table in $TableUsage) { $name = $table.TableName $cls = $classMap[$name] $ruleCount = if ($ruleCoverage.ContainsKey($name)) { [int]$ruleCoverage[$name] } else { 0 } $huntCount = if ($huntCoverage.ContainsKey($name)) { [int]$huntCoverage[$name] } else { 0 } $xdrRuleCount = if ($xdrCoverage.ContainsKey($name)) { [int]$xdrCoverage[$name] } else { 0 } $implicitCount = if ($implicitCoverage.ContainsKey($name)) { [int]$implicitCoverage[$name] } else { 0 } $isPlatform = ($name -in $platformTables) -or ($cls -and $cls.PSObject.Properties.Name -contains 'IsPlatform' -and $cls.IsPlatform -eq $true) $lifecycleStatus = if ($cls -and $cls.PSObject.Properties.Name -contains 'Status') { $cls.Status } else { $null } $replacedBy = if ($cls -and $cls.PSObject.Properties.Name -contains 'ReplacedBy') { @($cls.ReplacedBy) } else { @() } $totalCoverage = $ruleCount + $huntCount $effectiveCoverage = $ruleCount + $huntCount + $xdrRuleCount + $implicitCount # Where the coverage signal comes from (first match wins for display) $coverageSource = if ($ruleCount -gt 0 -or $huntCount -gt 0) { 'kql' } elseif ($xdrRuleCount -gt 0) { 'xdr' } elseif ($implicitCount -gt 0) { 'implicit' } elseif ($isPlatform) { 'platform' } else { 'none' } # Usage.IsBillable wins; the classification DB only decides when Usage had no flag $isFree = [bool]$table.IsFree $monthlyCost = $table.EstMonthlyCostUSD if ($table.IsFreeSource -eq 'database' -and $cls -and $null -ne $cls.IsFree) { $isFree = [bool]$cls.IsFree if ($isFree) { $monthlyCost = 0 } } # Cost tier $costTier = switch ($true) { ($isFree) { 'Free'; break } ($table.MonthlyGB -ge 50) { 'Very High'; break } ($table.MonthlyGB -ge 10) { 'High'; break } ($table.MonthlyGB -ge 1) { 'Medium'; break } default { 'Low' } } # Detection value tier (includes CDR coverage for accurate assessment) $detectionTier = switch ($true) { ($effectiveCoverage -ge 10) { 'High'; break } ($effectiveCoverage -ge 3) { 'Medium'; break } ($effectiveCoverage -ge 1) { 'Low'; break } default { 'None' } } $classification = if ($cls) { $cls.Classification } else { 'unknown' } # Combined assessment (platform tables are never "missing" coverage) $assessment = if ($isPlatform -and $detectionTier -eq 'None' -and -not $isFree) { 'Platform' } else { Get-Assessment -Classification $classification ` -CostTier $costTier ` -DetectionTier $detectionTier ` -IsFree $isFree } # Retention data $ret = $retentionMap[$name] # XDR state: known XDR table + plan from workspace (null = not XDR, NotStreaming = XDR default 30d only) $isKnownXDR = $name -in $knownXDRTables $xdrState = if (-not $isKnownXDR) { $null } elseif (-not $ret) { 'NotStreaming' } else { $ret.Plan } # Analytics, Basic, or Auxiliary $isXDRStreaming = $isKnownXDR -and ($null -ne $ret) $recommendedRetention = if ($cls -and $null -ne $cls.RecommendedRetentionDays -and $cls.RecommendedRetentionDays -gt 0) { [int]$cls.RecommendedRetentionDays } else { 90 } $actualTotal = if ($ret -and $null -ne $ret.TotalRetentionInDays) { [int]$ret.TotalRetentionInDays } else { $null } $actualInteractive = if ($ret -and $null -ne $ret.RetentionInDays) { [int]$ret.RetentionInDays } else { $null } $tablePlan = if ($ret) { $ret.Plan } else { $null } $tableSubType = if ($ret) { $ret.TableSubType } else { $null } $observedPlanBreakdown = if ($table.ObservedPlanBreakdown) { @($table.ObservedPlanBreakdown) } else { @() } $observedPlans = if ($table.ObservedPlans) { @($table.ObservedPlans) } else { @() } $observedKnownPlans = @($observedPlans | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_ -ne 'Unknown' } | Sort-Object -Unique) $observedPlanCount = $observedKnownPlans.Count $observedPlanSummary = if ($observedPlanBreakdown.Count -gt 0) { (($observedPlanBreakdown | ForEach-Object { "$($_.Plan) $($_.MonthlyGB) GB/mo" }) -join '; ') } else { $null } $hasMultipleObservedPlans = $observedPlanCount -gt 1 $observedPlanMismatch = if ($tablePlan -and $observedPlanCount -gt 0) { $tablePlan -notin $observedKnownPlans } else { $false } # Compliant = at least 90 days total retention (baseline) $retentionCompliant = if ($null -ne $actualTotal -and $tablePlan -eq 'Analytics') { $actualTotal -ge 90 } else { $null } # Can improve = meets 90d baseline but below category-specific recommendation; free and platform tables are excluded $retentionCanImprove = if ($retentionCompliant -and $recommendedRetention -gt 90 -and -not $isFree -and -not $isPlatform) { $actualTotal -lt $recommendedRetention } else { $false } # Interactive (hot) retention below the 90-day Sentinel baseline on an Analytics table $interactiveBelowBaseline = ($tablePlan -eq 'Analytics' -and $null -ne $actualInteractive -and $actualInteractive -lt 90) # Transform data $tableTransforms = $transformLookup[$name] $hasTransform = $null -ne $tableTransforms -and $tableTransforms.Count -gt 0 $transformTypes = if ($hasTransform) { @($tableTransforms | ForEach-Object { $_.TransformType } | Select-Object -Unique) } else { @() } $transformKql = if ($hasTransform) { @($tableTransforms | ForEach-Object { $_.TransformKql }) } else { @() } # Split table detection $isSplitTable = if ($cls) { [bool]$cls.IsSplitTable } else { $false } $parentTable = if ($cls) { $cls.ParentTable } else { $null } # Schema columns from retention data $schemaColumns = if ($ret -and $ret.Columns) { @($ret.Columns) } else { @() } $supportsAuxiliary = Test-TableSupportsAuxiliaryPlan -Table ([PSCustomObject]@{ TableName = $name; Plan = $tablePlan; TableSubType = $tableSubType }) $splitSuggestion = Get-SplitKql -TableName $name ` -Rules $(if ($rulesByTable.ContainsKey($name)) { @($rulesByTable[$name]) } else { @() }) ` -HighValueFieldsDB $HighValueFields ` -FieldFrequencyStats $FieldFrequencyStats ` -TableCategory $(if ($cls) { $cls.Category } else { $null }) ` -SchemaColumns $schemaColumns [PSCustomObject]@{ TableName = $name Classification = $classification Category = if ($cls) { $cls.Category } else { 'Unknown' } MonthlyGB = $table.MonthlyGB EstMonthlyCostUSD = $monthlyCost IsFree = $isFree IsFreeSource = $(if ($table.IsFreeSource) { $table.IsFreeSource } else { 'database' }) AnalyticsRules = $ruleCount HuntingQueries = $huntCount XDRRules = $xdrRuleCount ImplicitRules = $implicitCount IsPlatform = $isPlatform Status = $lifecycleStatus ReplacedBy = $replacedBy SupportsAuxiliaryPlan = $supportsAuxiliary CoverageSource = $coverageSource TotalCoverage = $totalCoverage EffectiveCoverage = $effectiveCoverage CostTier = $costTier DetectionTier = $detectionTier Assessment = $assessment IsXDRStreaming = $isXDRStreaming XDRState = $xdrState RecommendedTier = if ($cls) { $cls.RecommendedTier } else { 'analytics' } ActualRetentionDays = $actualTotal ActualInteractiveRetentionDays = $actualInteractive ArchiveRetentionInDays = if ($ret -and $null -ne $ret.ArchiveRetentionInDays) { [int]$ret.ArchiveRetentionInDays } else { $null } RecommendedRetentionDays = $recommendedRetention TablePlan = $tablePlan TableSubType = $tableSubType TableType = if ($ret -and $ret.PSObject.Properties.Name -contains 'TableType') { $ret.TableType } else { $null } RetentionInDaysAsDefault = if ($ret -and $ret.PSObject.Properties.Name -contains 'RetentionInDaysAsDefault') { [bool]$ret.RetentionInDaysAsDefault } else { $false } TotalRetentionInDaysAsDefault = if ($ret -and $ret.PSObject.Properties.Name -contains 'TotalRetentionInDaysAsDefault') { [bool]$ret.TotalRetentionInDaysAsDefault } else { $false } LastPlanModifiedDate = if ($ret -and $ret.PSObject.Properties.Name -contains 'LastPlanModifiedDate') { $ret.LastPlanModifiedDate } else { $null } ObservedPlans = $observedPlans ObservedKnownPlans = $observedKnownPlans ObservedPlanCount = $observedPlanCount ObservedPlanBreakdown = $observedPlanBreakdown ObservedPlanSummary = $observedPlanSummary HasMultipleObservedPlans = $hasMultipleObservedPlans ObservedPlanMismatch = $observedPlanMismatch RetentionCompliant = $retentionCompliant RetentionCanImprove = $retentionCanImprove InteractiveBelowBaseline = $interactiveBelowBaseline HasTransform = $hasTransform TransformTypes = $transformTypes TransformKql = $transformKql IsSplitTable = $isSplitTable ParentTable = $parentTable SchemaColumns = $schemaColumns SplitSuggestion = $splitSuggestion } } $tableAnalysis = @($tableAnalysis) $cdrRules = if ($DefenderXDR -and $DefenderXDR.CustomRules) { $DefenderXDR.CustomRules } else { @() } $detectionAnalyzer = if ($IncludeDetectionAnalyzer) { Get-DetectionAnalyzerData -Rules $RulesData.Rules ` -Incidents $Incidents ` -AutomationRules $AutomationRules ` -CustomDetectionRules $cdrRules ` -AutoCloseHealthData $AutoCloseHealthData } else { $null } $xdrChecker = Get-XdrCheckerData -TableAnalysis $tableAnalysis -KnownXDRTables $knownXDRTables -RetentionMap $retentionMap # Generate recommendations $recommendations = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($t in $tableAnalysis) { # 1. Data lake candidates: secondary + high cost + low rules (split copies are already lake data). # Only when the table can actually move: Auxiliary where the feature matrix allows it, else Basic. if ($t.Classification -eq 'secondary' -and -not $t.IsSplitTable -and $t.TablePlan -ne 'Auxiliary' -and $t.CostTier -in @('High', 'Very High') -and $t.DetectionTier -in @('None', 'Low')) { $planProbe = [PSCustomObject]@{ TableName = $t.TableName; Plan = $t.TablePlan; TableSubType = $t.TableSubType } if ($t.SupportsAuxiliaryPlan) { $savings = [math]::Max(0, [math]::Round($t.EstMonthlyCostUSD - ($t.MonthlyGB * $LakePricePerGB), 2)) $recommendations.Add([PSCustomObject]@{ Priority = 'High' Type = 'DataLake' TableName = $t.TableName Title = "Move $($t.TableName) to Data Lake tier" Detail = "Secondary source ingesting $($t.MonthlyGB) GB/mo with $($t.EffectiveCoverage) detection(s). " + "Create summary rules to aggregate key events back to analytics tier." EstSavingsUSD = $savings CurrentCost = $t.EstMonthlyCostUSD }) } elseif ($t.TablePlan -ne 'Basic' -and (Test-TableSupportsBasicPlan -Table $planProbe)) { $savings = [math]::Max(0, [math]::Round($t.EstMonthlyCostUSD - ($t.MonthlyGB * $BasicPricePerGB), 2)) $recommendations.Add([PSCustomObject]@{ Priority = 'High' Type = 'DataLake' TableName = $t.TableName Title = "Move $($t.TableName) to Basic plan" Detail = "Secondary source ingesting $($t.MonthlyGB) GB/mo with $($t.EffectiveCoverage) detection(s). " + "This table does not support the Auxiliary (Data Lake) plan; Basic is the lowest supported tier." EstSavingsUSD = $savings CurrentCost = $t.EstMonthlyCostUSD }) } else { Write-Verbose "$($t.TableName) is a data lake candidate but supports neither the Auxiliary nor the Basic plan; no tier recommendation." } } # 12. Deprecated or legacy source still ingesting. Informational: migrating moves the # ingestion to the replacement table rather than removing it, so no savings are claimed. if ($t.Status -in @('deprecated', 'legacy') -and $t.MonthlyGB -gt 0) { $replacement = if (@($t.ReplacedBy).Count -gt 0) { "Replacement table(s): $(@($t.ReplacedBy) -join ', '). " } else { 'No direct replacement table is documented. ' } $verb = if ($t.Status -eq 'deprecated') { 'is deprecated' } else { 'uses a legacy collection path' } $recommendations.Add([PSCustomObject]@{ Priority = 'Medium' Type = 'DeprecatedSource' TableName = $t.TableName Title = "$($t.TableName) $verb" Detail = "Ingesting $($t.MonthlyGB) GB/mo (~`$$($t.EstMonthlyCostUSD)/mo) into a table whose connector $verb. " + $replacement + 'Migrate detections to the replacement, then retire the old connector to avoid paying for both.' EstSavingsUSD = 0 CurrentCost = $t.EstMonthlyCostUSD }) } # 2. Zero-detection high-cost tables if (-not $t.IsFree -and $t.CostTier -in @('High', 'Very High') -and $t.DetectionTier -eq 'None') { $lowValueNextStep = if ($t.TablePlan -eq 'Auxiliary') { 'Consider: add analytics rules, apply ingest-time filtering, or review whether the current Data Lake placement is still appropriate.' } else { 'Consider: add analytics rules, apply ingest-time filtering, or move to data lake.' } $recommendations.Add([PSCustomObject]@{ Priority = 'High' Type = 'LowValue' TableName = $t.TableName Title = "$($t.TableName) has zero detections" Detail = "Ingesting $($t.MonthlyGB) GB/mo (~`$$($t.EstMonthlyCostUSD)/mo) with no analytics rules or hunting queries. " + $lowValueNextStep EstSavingsUSD = $t.EstMonthlyCostUSD CurrentCost = $t.EstMonthlyCostUSD }) } # 3. XDR streaming optimization if ($t.IsXDRStreaming -and $t.AnalyticsRules -eq 0 -and $t.XDRRules -gt 0) { $recommendations.Add([PSCustomObject]@{ Priority = 'Medium' Type = 'XDROptimize' TableName = $t.TableName Title = "Stop streaming $($t.TableName) to Sentinel" Detail = "$($t.XDRRules) Defender XDR custom detection rule(s) cover this table. " + "No Sentinel-specific rules exist. Stop streaming to save cost; use unified XDR portal instead." EstSavingsUSD = $t.EstMonthlyCostUSD CurrentCost = $t.EstMonthlyCostUSD }) } # 4. Missing coverage on primary sources (not platform tables, which are consumed by Sentinel itself) if ($t.Classification -eq 'primary' -and -not $t.IsFree -and -not $t.IsPlatform -and $t.EffectiveCoverage -eq 0) { $recommendations.Add([PSCustomObject]@{ Priority = 'Medium' Type = 'MissingCoverage' TableName = $t.TableName Title = "No detections for $($t.TableName)" Detail = "Primary security source with zero analytics rules or hunting queries. " + "Add detection rules to realise value from this data source." EstSavingsUSD = 0 CurrentCost = $t.EstMonthlyCostUSD }) } # 5. Ingest-time filtering candidates if (-not $t.IsFree -and $t.MonthlyGB -ge 20 -and $t.Classification -eq 'primary' -and $t.EffectiveCoverage -ge 1 -and $t.EffectiveCoverage -le 3) { $recommendations.Add([PSCustomObject]@{ Priority = 'Low' Type = 'Filter' TableName = $t.TableName Title = "Consider filtering $($t.TableName)" Detail = "High-volume primary source ($($t.MonthlyGB) GB/mo) with only $($t.EffectiveCoverage) detection(s). " + "Review if ingest-time transformation can filter unneeded event types." EstSavingsUSD = [math]::Round($t.EstMonthlyCostUSD * 0.3, 2) CurrentCost = $t.EstMonthlyCostUSD }) } # 9. Split candidate - high-volume tables with some detections that could benefit from split if (-not $t.IsFree -and -not $t.IsSplitTable -and -not $t.HasTransform -and $t.MonthlyGB -ge 10 -and $t.EffectiveCoverage -ge 1 -and $t.Classification -eq 'primary') { $splitSavings = [math]::Round($t.EstMonthlyCostUSD * 0.50, 2) # Use cached split KQL suggestion $splitSuggestion = $t.SplitSuggestion $detail = "High-volume primary source ($($t.MonthlyGB) GB/mo) with $($t.EffectiveCoverage) detection(s). " + "Use a Sentinel split transform to route low-value events to Data Lake tier " + "while keeping detection-relevant events in Analytics." if ($splitSuggestion.Source -ne 'none') { $detail += " Split KQL suggestion available (source: $($splitSuggestion.Source))." } $recommendations.Add([PSCustomObject]@{ Priority = 'Medium' Type = 'SplitCandidate' TableName = $t.TableName Title = "Consider split transform for $($t.TableName)" Detail = $detail EstSavingsUSD = $splitSavings CurrentCost = $t.EstMonthlyCostUSD SplitSuggestion = $splitSuggestion }) } # 10. Tables observed under multiple plans during the selected period if ($t.HasMultipleObservedPlans) { $configuredPlan = if ($t.TablePlan) { $t.TablePlan } else { 'Unknown' } $recommendations.Add([PSCustomObject]@{ Priority = 'Low' Type = 'PlanUsage' TableName = $t.TableName Title = "$($t.TableName) was observed under multiple plans" Detail = "Usage records show $($t.ObservedKnownPlans -join ', ') during the selected period. " + "Configured plan is $configuredPlan. Review whether the transition was expected." EstSavingsUSD = 0 CurrentCost = $t.EstMonthlyCostUSD }) } # 11. Tables whose configured plan does not appear in observed Usage data if (-not $t.HasMultipleObservedPlans -and $t.ObservedPlanMismatch) { $observedPlan = if ($t.ObservedKnownPlans.Count -gt 0) { $t.ObservedKnownPlans -join ', ' } else { 'Unknown' } $recommendations.Add([PSCustomObject]@{ Priority = 'Low' Type = 'PlanUsage' TableName = $t.TableName Title = "$($t.TableName) configured plan differs from observed usage" Detail = "Configured plan is $($t.TablePlan), but Usage records show $observedPlan during the selected period. " + "Review recent table plan changes and confirm expected billing behavior." EstSavingsUSD = 0 CurrentCost = $t.EstMonthlyCostUSD }) } } # Correlation data pass-through $corrExcluded = @($RulesData.Rules | Where-Object ExcludedFromCorrelation) $corrIncluded = @($RulesData.Rules | Where-Object IncludedInCorrelation) # 6. Workspace retention check if ($WorkspaceRetentionDays -gt 0 -and $WorkspaceRetentionDays -lt 90) { $recommendations.Add([PSCustomObject]@{ Priority = 'High' Type = 'RetentionShortfall' TableName = '(workspace default)' Title = "Workspace default retention is $($WorkspaceRetentionDays)d - increase to at least 90d" Detail = "The workspace default retention is $($WorkspaceRetentionDays) days. " + "A 90-day minimum is recommended as a security baseline. " + "Tables inheriting the default will not meet compliance requirements." EstSavingsUSD = 0 CurrentCost = 0 }) } # 7. Per-table retention below 90d baseline foreach ($t in $tableAnalysis) { if ($null -eq $t.RetentionCompliant -or $t.RetentionCompliant) { continue } $prio = if ($t.Classification -eq 'primary') { 'High' } else { 'Medium' } $recommendations.Add([PSCustomObject]@{ Priority = $prio Type = 'RetentionShortfall' TableName = $t.TableName Title = "$($t.TableName) retention below 90d baseline ($($t.ActualRetentionDays)d)" Detail = "Total retention is $($t.ActualRetentionDays) days. A 90-day minimum is the recommended " + "security baseline. Increase total retention or add archive retention." EstSavingsUSD = 0 CurrentCost = $t.EstMonthlyCostUSD }) } # 8. Retention improvement opportunities (meets 90d but below category recommendation) foreach ($t in $tableAnalysis) { if (-not $t.RetentionCanImprove) { continue } $recommendations.Add([PSCustomObject]@{ Priority = 'Low' Type = 'RetentionImprovement' TableName = $t.TableName Title = "$($t.TableName) could benefit from $($t.RecommendedRetentionDays)d retention (currently $($t.ActualRetentionDays)d)" Detail = "Meets the 90-day baseline but best-practice guidance recommends $($t.RecommendedRetentionDays) days " + "for $($t.Category) tables." EstSavingsUSD = 0 CurrentCost = $t.EstMonthlyCostUSD }) } # 12. Interactive retention below the 90-day Sentinel baseline (total may still be compliant via archive) foreach ($t in $tableAnalysis) { if (-not $t.InteractiveBelowBaseline) { continue } $recommendations.Add([PSCustomObject]@{ Priority = 'Medium' Type = 'RetentionInteractiveBelowBaseline' TableName = $t.TableName Title = "$($t.TableName) interactive retention is $($t.ActualInteractiveRetentionDays)d" Detail = "Analytics-tier interactive retention is $($t.ActualInteractiveRetentionDays) days; Sentinel includes 90 days at no extra charge. " + "Total retention is $($t.ActualRetentionDays) days. Raise interactive retention to 90 days unless the shorter hot window is deliberate." EstSavingsUSD = 0 CurrentCost = $t.EstMonthlyCostUSD }) } foreach ($rec in $detectionAnalyzer.Recommendations) { $recommendations.Add($rec) } foreach ($rec in $xdrChecker.Recommendations) { $recommendations.Add($rec) } # Single canonical order: High > Medium > Low, then savings descending $sortedRecs = @(Get-SortedRecommendation -Recommendations $recommendations) # Build schema lookup for live tuning analysis $schemaLookup = @{} foreach ($t in $tableAnalysis) { if ($t.SchemaColumns -and $t.SchemaColumns.Count -gt 0) { $schemaLookup[$t.TableName] = $t.SchemaColumns } } # Live tuning analysis (deployed rules + schema) $liveTuningAnalysis = Get-LiveTuningAnalysis -Rules $RulesData.Rules ` -HuntingQueries $HuntingData.Queries ` -TableAnalysis $tableAnalysis ` -SchemaLookup $schemaLookup # Summary stats $primaryTables = @($tableAnalysis | Where-Object Classification -eq 'primary') $secondaryTables = @($tableAnalysis | Where-Object Classification -eq 'secondary') $unknownTables = @($tableAnalysis | Where-Object Classification -eq 'unknown') $totalMonthlyGB = ($tableAnalysis | Measure-Object MonthlyGB -Sum).Sum $totalMonthlyCost = ($tableAnalysis | Measure-Object EstMonthlyCostUSD -Sum).Sum $totalSavings = ($sortedRecs | Measure-Object EstSavingsUSD -Sum).Sum $tablesWithRules = @($tableAnalysis | Where-Object { $_.EffectiveCoverage -gt 0 }).Count $coveragePercent = if ($tableAnalysis.Count -gt 0) { [math]::Round(($tablesWithRules / $tableAnalysis.Count) * 100, 0) } else { 0 } $retentionCompliantCount = @($tableAnalysis | Where-Object { $_.RetentionCompliant -eq $true }).Count $retentionNonCompliant = @($tableAnalysis | Where-Object { $_.RetentionCompliant -eq $false }).Count $retentionChecked = $retentionCompliantCount + $retentionNonCompliant $retentionImprovableCount = @($tableAnalysis | Where-Object { $_.RetentionCanImprove -eq $true }).Count $multiPlanUsageCount = @($tableAnalysis | Where-Object { $_.HasMultipleObservedPlans }).Count $observedPlanMismatchCount = @($tableAnalysis | Where-Object { $_.ObservedPlanMismatch }).Count # Transform stats $tablesWithTransforms = @($tableAnalysis | Where-Object { $_.HasTransform }).Count $splitTables = @($tableAnalysis | Where-Object { $_.IsSplitTable }).Count $transformDCRCount = if ($DataTransforms) { $DataTransforms.RelevantDCRs.Count } else { 0 } # Detection coverage stats (table-count-based, all tables including free) $allTables = @($tableAnalysis) $totalTableCount = $allTables.Count $nonFreeTables = @($tableAnalysis | Where-Object { -not $_.IsFree }) $totalNonFreeGB = ($nonFreeTables | Measure-Object MonthlyGB -Sum).Sum if ($totalNonFreeGB -le 0) { $totalNonFreeGB = 0 } $tablesWithDetection = @($allTables | Where-Object { ($_.AnalyticsRules + $_.XDRRules) -gt 0 }) $tablesWithHunting = @($allTables | Where-Object { $_.HuntingQueries -gt 0 }) $tablesWithCombined = @($allTables | Where-Object { $_.EffectiveCoverage -gt 0 }) $detectionCoveredGB = ($tablesWithDetection | Measure-Object MonthlyGB -Sum).Sum $huntingCoveredGB = ($tablesWithHunting | Measure-Object MonthlyGB -Sum).Sum $combinedCoveredGB = ($tablesWithCombined | Measure-Object MonthlyGB -Sum).Sum $totalAllGB = ($allTables | Measure-Object MonthlyGB -Sum).Sum if ($totalAllGB -le 0) { $totalAllGB = 0 } $detectionCoveragePct = if ($totalTableCount -gt 0) { [math]::Round(($tablesWithDetection.Count / $totalTableCount) * 100, 1) } else { 0 } $huntingCoveragePct = if ($totalTableCount -gt 0) { [math]::Round(($tablesWithHunting.Count / $totalTableCount) * 100, 1) } else { 0 } $combinedCoveragePct = if ($totalTableCount -gt 0) { [math]::Round(($tablesWithCombined.Count / $totalTableCount) * 100, 1) } else { 0 } $detectionCoverageGBPct = if ($totalAllGB -gt 0) { [math]::Round(($detectionCoveredGB / $totalAllGB) * 100, 1) } else { 0 } $huntingCoverageGBPct = if ($totalAllGB -gt 0) { [math]::Round(($huntingCoveredGB / $totalAllGB) * 100, 1) } else { 0 } $combinedCoverageGBPct = if ($totalAllGB -gt 0) { [math]::Round(($combinedCoveredGB / $totalAllGB) * 100, 1) } else { 0 } $avgDetectionsPerTable = if ($tablesWithDetection.Count -gt 0) { [math]::Round((($tablesWithDetection | ForEach-Object { $_.AnalyticsRules + $_.XDRRules } | Measure-Object -Sum).Sum / $tablesWithDetection.Count), 1) } else { 0 } # Enrich DetectionAnalyzer Summary with coverage stats if ($detectionAnalyzer) { Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName DetectionCoverageGB -NotePropertyValue ([math]::Round($detectionCoveredGB, 2)) Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName HuntingCoverageGB -NotePropertyValue ([math]::Round($huntingCoveredGB, 2)) Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName CombinedCoverageGB -NotePropertyValue ([math]::Round($combinedCoveredGB, 2)) Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName TotalIngestionGB -NotePropertyValue ([math]::Round($totalNonFreeGB, 2)) Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName TotalAllGB -NotePropertyValue ([math]::Round($totalAllGB, 2)) Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName DetectionCoveragePct -NotePropertyValue $detectionCoveragePct Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName HuntingCoveragePct -NotePropertyValue $huntingCoveragePct Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName CombinedCoveragePct -NotePropertyValue $combinedCoveragePct Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName DetectionCoverageGBPct -NotePropertyValue $detectionCoverageGBPct Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName HuntingCoverageGBPct -NotePropertyValue $huntingCoverageGBPct Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName CombinedCoverageGBPct -NotePropertyValue $combinedCoverageGBPct Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName AvgDetectionsPerTable -NotePropertyValue $avgDetectionsPerTable Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName TablesWithDetection -NotePropertyValue $tablesWithDetection.Count Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName TablesWithHunting -NotePropertyValue $tablesWithHunting.Count Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName TablesWithCombined -NotePropertyValue $tablesWithCombined.Count Add-Member -InputObject $detectionAnalyzer.Summary -NotePropertyName TotalTables -NotePropertyValue $totalTableCount } [PSCustomObject]@{ TableAnalysis = $tableAnalysis Recommendations = @($sortedRecs) KeywordGaps = $Classifications.KeywordGaps SocRecommendations = $SocRecommendations CorrelationExcluded = $corrExcluded CorrelationIncluded = $corrIncluded DataTransforms = $DataTransforms DetectionAnalyzer = $detectionAnalyzer XdrChecker = $xdrChecker LiveTuningAnalysis = @($liveTuningAnalysis) Summary = [PSCustomObject]@{ TotalTables = $tableAnalysis.Count PrimaryCount = $primaryTables.Count SecondaryCount = $secondaryTables.Count UnknownCount = $unknownTables.Count TotalMonthlyGB = [math]::Round($totalMonthlyGB, 2) TotalMonthlyCost = [math]::Round($totalMonthlyCost, 2) TotalRules = $RulesData.TotalRules EnabledRules = $RulesData.EnabledRules DontCorrCount = $RulesData.DontCorrCount IncCorrCount = $RulesData.IncCorrCount HuntingQueries = $HuntingData.TotalQueries CoveragePercent = $coveragePercent EstTotalSavings = [math]::Round($totalSavings, 2) PricePerGB = $PricePerGB BasicPricePerGB = $BasicPricePerGB LakePricePerGB = $LakePricePerGB UsageObservedDays = $(if ($TableUsage.Count -gt 0 -and $null -ne $TableUsage[0].ObservedDays) { [int]$TableUsage[0].ObservedDays } else { $null }) WorkspaceRetentionDays = $WorkspaceRetentionDays RetentionCompliant = $retentionCompliantCount RetentionNonCompliant = $retentionNonCompliant RetentionChecked = $retentionChecked RetentionImprovable = $retentionImprovableCount MultiPlanUsageTables = $multiPlanUsageCount ObservedPlanMismatches = $observedPlanMismatchCount TablesWithTransforms = $tablesWithTransforms SplitTables = $splitTables TransformDCRs = $transformDCRCount DetectionRulesAnalyzed = $detectionAnalyzer.Summary.RulesAnalyzed NoisyRulesDetected = $detectionAnalyzer.Summary.NoisyRules AutoClosedIncidents = $detectionAnalyzer.Summary.AutoClosedIncidents XdrCheckerIssues = $xdrChecker.Summary.IssueCount XdrAdvisoryRetention = $xdrChecker.Summary.AdvisoryRetentionDays } } } function Get-SortedRecommendation { <# .SYNOPSIS Orders recommendations High > Medium > Low, then by estimated savings descending. This is the only place recommendations are sorted; exports and the TUI keep this order. #> [CmdletBinding()] param([array]$Recommendations) $prioOrder = @{ 'High' = 0; 'Medium' = 1; 'Low' = 2 } @($Recommendations | Sort-Object ` @{ Expression = { if ($prioOrder.ContainsKey("$($_.Priority)")) { $prioOrder["$($_.Priority)"] } else { 3 } } }, ` @{ Expression = { [double]($_.EstSavingsUSD ?? 0) }; Descending = $true }) } function Get-TableStatusLabel { <# .SYNOPSIS Short lifecycle label for a table analysis row ("deprecated, use X, Y" or "legacy"), or $null when the source has no status. #> [CmdletBinding()] param([object]$Table) if ($null -eq $Table -or $Table.PSObject.Properties.Name -notcontains 'Status') { return $null } $status = "$($Table.Status)".Trim().ToLowerInvariant() if ($status -notin 'deprecated', 'legacy') { return $null } $replacement = @() if ($Table.PSObject.Properties.Name -contains 'ReplacedBy') { $replacement = @($Table.ReplacedBy | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") }) } if ($replacement.Count -gt 0) { "$status, use $($replacement -join ', ')" } else { $status } } function Get-Assessment { [CmdletBinding()] param( [string]$Classification, [string]$CostTier, [string]$DetectionTier, [bool]$IsFree ) if ($IsFree) { return 'Free Tier' } switch ($true) { ($Classification -eq 'primary' -and $DetectionTier -in @('High', 'Medium')) { 'High Value'; break } ($Classification -eq 'primary' -and $DetectionTier -eq 'Low' -and $CostTier -in @('Low', 'Medium')) { 'Good Value'; break } ($Classification -eq 'primary' -and $DetectionTier -eq 'None') { 'Missing Coverage'; break } ($Classification -eq 'secondary' -and $CostTier -in @('High', 'Very High') -and $DetectionTier -in @('None', 'Low')) { 'Optimize'; break } ($CostTier -in @('High', 'Very High') -and $DetectionTier -eq 'None') { 'Low Value'; break } ($DetectionTier -eq 'None') { 'Underutilized'; break } default { 'Good Value' } } } function Get-DetectionAnalyzerData { [CmdletBinding()] param( [array]$Rules, [array]$Incidents, [array]$AutomationRules, [array]$CustomDetectionRules = @(), [hashtable]$AutoCloseHealthData ) $allRulesEmpty = (-not $Rules -or $Rules.Count -eq 0) -and (-not $CustomDetectionRules -or $CustomDetectionRules.Count -eq 0) if ($allRulesEmpty) { return [PSCustomObject]@{ RuleMetrics = @() Recommendations = @() Summary = [PSCustomObject]@{ RulesAnalyzed = 0 NoisyRules = 0 IncidentsAnalyzed = 0 AutoClosedIncidents = 0 CustomDetectionRules = 0 CDRCorrelatedIncidents = 0 } } } # Build a unified rule list: analytics rules first, then CDRs. # RuleKey is the stable bucket key: rule id when known, display name otherwise. $unifiedRules = [System.Collections.Generic.List[object]]::new() if ($Rules) { foreach ($rule in $Rules) { $ruleId = if ($rule.PSObject.Properties.Name -contains 'RuleId' -and $rule.RuleId) { "$($rule.RuleId)" } else { $null } $unifiedRules.Add([PSCustomObject]@{ RuleKey = if ($ruleId) { $ruleId } else { "name:$($rule.RuleName)" } RuleId = $ruleId RuleName = $rule.RuleName Kind = $rule.Kind Enabled = $rule.Enabled Source = 'Sentinel' Tables = @() Frequency = $null }) } } $cdrCount = 0 if ($CustomDetectionRules -and $CustomDetectionRules.Count -gt 0) { foreach ($cdr in $CustomDetectionRules) { $displayName = $null if ($cdr.PSObject.Properties.Name -contains 'displayName') { $displayName = $cdr.displayName } if (-not $displayName -and $cdr.PSObject.Properties.Name -contains 'detectionAction') { $displayName = $cdr.detectionAction } if (-not $displayName) { $displayName = "CDR-$cdrCount" } $isEnabled = $true if ($cdr.PSObject.Properties.Name -contains 'isEnabled') { $isEnabled = [bool]$cdr.isEnabled } $tables = @() $query = $null if ($cdr.PSObject.Properties.Name -contains 'queryCondition' -and $cdr.queryCondition) { $query = $cdr.queryCondition.queryText } if ($query) { $tables = @(Get-TablesFromKql -Kql $query) } $frequency = $null if ($cdr.PSObject.Properties.Name -contains 'schedule' -and $cdr.schedule) { if ($cdr.schedule.PSObject.Properties.Name -contains 'period') { $frequency = $cdr.schedule.period } } $cdrId = if ($cdr.PSObject.Properties.Name -contains 'id' -and $cdr.id) { "cdr:$($cdr.id)" } else { $null } $unifiedRules.Add([PSCustomObject]@{ RuleKey = if ($cdrId) { $cdrId } else { "name:$displayName" } RuleId = $cdrId RuleName = $displayName Kind = 'CustomDetection' Enabled = $isEnabled Source = 'DefenderXDR' Tables = $tables Frequency = $frequency }) $cdrCount++ } } # Lookups: GUID tail of the rule id -> keys, display name -> keys (duplicate names map to several keys) $incidentBuckets = @{} $keysByGuid = @{} $keysByName = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($rule in $unifiedRules) { $incidentBuckets[$rule.RuleKey] = [System.Collections.Generic.List[object]]::new() if ($rule.RuleId) { $guid = ("$($rule.RuleId)" -split '/')[-1] if (-not $keysByGuid.ContainsKey($guid)) { $keysByGuid[$guid] = [System.Collections.Generic.List[string]]::new() } $keysByGuid[$guid].Add($rule.RuleKey) } if (-not [string]::IsNullOrWhiteSpace($rule.RuleName)) { if (-not $keysByName.ContainsKey($rule.RuleName)) { $keysByName[$rule.RuleName] = [System.Collections.Generic.List[string]]::new() } $keysByName[$rule.RuleName].Add($rule.RuleKey) } } foreach ($incident in $Incidents) { $candidateKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) # 1. Rule ids (most precise) foreach ($id in @($incident.RelatedAnalyticRuleIds)) { if ([string]::IsNullOrWhiteSpace($id)) { continue } $guid = ("$id" -split '/')[-1] if ($keysByGuid.ContainsKey($guid)) { foreach ($k in $keysByGuid[$guid]) { [void]$candidateKeys.Add($k) } } } # 2. Rule names if ($candidateKeys.Count -eq 0) { foreach ($n in @($incident.RelatedAnalyticRuleNames)) { if ([string]::IsNullOrWhiteSpace($n)) { continue } if ($keysByName.ContainsKey($n)) { foreach ($k in $keysByName[$n]) { [void]$candidateKeys.Add($k) } } } } # 3. Title heuristic fallback - works for both analytics and CDR rules if ($candidateKeys.Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($incident.Title)) { foreach ($rule in $unifiedRules) { if ([string]::IsNullOrWhiteSpace($rule.RuleName)) { continue } if ($incident.Title.IndexOf($rule.RuleName, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { [void]$candidateKeys.Add($rule.RuleKey) } } } foreach ($key in $candidateKeys) { if ($incidentBuckets.ContainsKey($key)) { $incidentBuckets[$key].Add($incident) } } } # Automation rules that can close incidents, computed once $enabledAutoCloseRules = @($AutomationRules | Where-Object { ($_.IsCloseIncidentRule -or $_.HasPlaybookAction) -and $_.Enabled }) $distinctAutoClosed = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) # Compute per-rule metrics $ruleMetrics = [System.Collections.Generic.List[object]]::new() foreach ($rule in $unifiedRules) { $ruleIncidents = @($incidentBuckets[$rule.RuleKey]) $total = $ruleIncidents.Count $closed = @($ruleIncidents | Where-Object { $_.Status -eq 'Closed' }) $autoClosed = [System.Collections.Generic.List[object]]::new() $autoClosedIds = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $linkedAutomation = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($inc in $closed) { $isAutoClose = $false # Primary: SentinelHealth data (definitive if available) if ($null -ne $AutoCloseHealthData -and $inc.IncidentNumber -and $AutoCloseHealthData.ContainsKey([int]$inc.IncidentNumber)) { $isAutoClose = $true } # Automation rule condition matching: fallback attribution, and the source of linked rule names if ($enabledAutoCloseRules.Count -gt 0) { $matched = @($enabledAutoCloseRules | Where-Object { Test-AutomationRuleIncidentMatch -AutomationRule $_ -IncidentTitle $inc.Title -IncidentRuleIds $inc.RelatedAnalyticRuleIds -IncidentSeverity "$($inc.Severity)" }) if ($matched.Count -gt 0) { $isAutoClose = $true foreach ($m in $matched) { if ($m.DisplayName) { [void]$linkedAutomation.Add($m.DisplayName) } } } } # Timing heuristic: only use this when no enabled close/playbook rule exists # to avoid overriding an explicit non-match on automation conditions. if (-not $isAutoClose -and $enabledAutoCloseRules.Count -eq 0 -and $inc.CreatedTimeUtc -and $inc.ClosedTimeUtc) { $closeDelta = ($inc.ClosedTimeUtc - $inc.CreatedTimeUtc).TotalMinutes if ($closeDelta -ge 0 -and $closeDelta -le 5) { $isAutoClose = $true } } if ($isAutoClose) { $autoClosed.Add($inc) $incKey = if ($inc.IncidentId) { "$($inc.IncidentId)" } elseif ($inc.IncidentNumber) { "num:$($inc.IncidentNumber)" } else { $null } if ($incKey) { [void]$autoClosedIds.Add($incKey); [void]$distinctAutoClosed.Add($incKey) } } } $manualClosed = @($closed | Where-Object { $k = if ($_.IncidentId) { "$($_.IncidentId)" } elseif ($_.IncidentNumber) { "num:$($_.IncidentNumber)" } else { $null } -not ($k -and $autoClosedIds.Contains($k)) }) $falsePositive = @($closed | Where-Object { $_.Classification -eq 'FalsePositive' }) $benignPositive = @($closed | Where-Object { $_.Classification -eq 'BenignPositive' }) $truePositive = @($closed | Where-Object { $_.Classification -eq 'TruePositive' }) $closeMinutes = @($closed | ForEach-Object { if ($_.CreatedTimeUtc -and $_.ClosedTimeUtc) { [math]::Max([math]::Round(($_.ClosedTimeUtc - $_.CreatedTimeUtc).TotalMinutes, 2), 0) } } | Where-Object { $null -ne $_ }) $avgClose = if ($closeMinutes.Count -gt 0) { [math]::Round((($closeMinutes | Measure-Object -Average).Average), 2) } else { $null } $autoCloseRatio = if ($closed.Count -gt 0) { [math]::Round(($autoClosed.Count / $closed.Count), 4) } else { 0 } $falseRatio = if ($closed.Count -gt 0) { [math]::Round(($falsePositive.Count / $closed.Count), 4) } else { 0 } $benignRatio = if ($closed.Count -gt 0) { [math]::Round(($benignPositive.Count / $closed.Count), 4) } else { 0 } $ruleMetrics.Add([PSCustomObject]@{ RuleKey = $rule.RuleKey RuleId = $rule.RuleId RuleName = $rule.RuleName RuleKind = $rule.Kind Enabled = $rule.Enabled Source = $rule.Source Tables = $rule.Tables Frequency = $rule.Frequency IncidentsTotal = $total IncidentsClosed = $closed.Count IncidentsAutoClosed = $autoClosed.Count IncidentsManualClosed = $manualClosed.Count FalsePositiveClosed = $falsePositive.Count BenignPositiveClosed = $benignPositive.Count TruePositiveClosed = $truePositive.Count AutoCloseRatio = $autoCloseRatio FalsePositiveRatio = $falseRatio BenignPositiveRatio = $benignRatio AvgCloseMinutes = $avgClose LinkedAutomationRules = @($linkedAutomation | Sort-Object) }) } # Noisiness scoring - only score rules that have incident data, and only when # the population is large enough for a percentile to carry meaning. $scorableMetrics = @($ruleMetrics | Where-Object { $_.IncidentsTotal -gt 0 }) $minScorablePopulation = 3 if ($scorableMetrics.Count -ge $minScorablePopulation) { $volumes = @($scorableMetrics | ForEach-Object IncidentsTotal) $autoRatios = @($scorableMetrics | ForEach-Object AutoCloseRatio) $falseRatios = @($scorableMetrics | ForEach-Object FalsePositiveRatio) foreach ($metric in $scorableMetrics) { $volumePct = Get-PercentileRank -Value $metric.IncidentsTotal -Population $volumes $autoPct = Get-PercentileRank -Value $metric.AutoCloseRatio -Population $autoRatios $falsePct = Get-PercentileRank -Value $metric.FalsePositiveRatio -Population $falseRatios $score = [math]::Round(($volumePct * 0.35) + ($autoPct * 0.40) + ($falsePct * 0.25), 2) Add-Member -InputObject $metric -NotePropertyName NoisinessScore -NotePropertyValue $score Add-Member -InputObject $metric -NotePropertyName ScoreStatus -NotePropertyValue 'Scored' Add-Member -InputObject $metric -NotePropertyName PercentileVolume -NotePropertyValue $volumePct Add-Member -InputObject $metric -NotePropertyName PercentileAutoClose -NotePropertyValue $autoPct Add-Member -InputObject $metric -NotePropertyName PercentileFalsePositive -NotePropertyValue $falsePct } } # Rules without a score: no incidents, or too few scorable rules for a percentile foreach ($metric in $ruleMetrics) { if (-not ($metric.PSObject.Properties.Name -contains 'NoisinessScore')) { $status = if ($metric.IncidentsTotal -gt 0) { 'InsufficientSample' } else { 'NoIncidents' } Add-Member -InputObject $metric -NotePropertyName NoisinessScore -NotePropertyValue $null Add-Member -InputObject $metric -NotePropertyName ScoreStatus -NotePropertyValue $status Add-Member -InputObject $metric -NotePropertyName PercentileVolume -NotePropertyValue $null Add-Member -InputObject $metric -NotePropertyName PercentileAutoClose -NotePropertyValue $null Add-Member -InputObject $metric -NotePropertyName PercentileFalsePositive -NotePropertyValue $null } } $noisyRules = @($ruleMetrics | Where-Object { $_.Enabled -and $_.IncidentsTotal -ge 5 -and $null -ne $_.NoisinessScore -and $_.NoisinessScore -ge 70 }) $recList = [System.Collections.Generic.List[object]]::new() foreach ($rule in $noisyRules) { $recList.Add([PSCustomObject]@{ Priority = 'High' Type = 'DetectionAnalyzer' TableName = '(rule-level)' Title = "Review noisy rule: $($rule.RuleName)" Detail = "Rule appears noisy (score $($rule.NoisinessScore)). Auto-close ratio: $($rule.AutoCloseRatio), false positive ratio: $($rule.FalsePositiveRatio), incidents: $($rule.IncidentsTotal)." EstSavingsUSD = 0 CurrentCost = 0 }) } $cdrMetrics = @($ruleMetrics | Where-Object { $_.RuleKind -eq 'CustomDetection' }) $cdrCorrelated = @($cdrMetrics | Where-Object { $_.IncidentsTotal -gt 0 }).Count [PSCustomObject]@{ RuleMetrics = @($ruleMetrics) Recommendations = @($recList) Summary = [PSCustomObject]@{ RulesAnalyzed = $ruleMetrics.Count NoisyRules = $noisyRules.Count IncidentsAnalyzed = $Incidents.Count AutoClosedIncidents = $distinctAutoClosed.Count ScorableRules = $scorableMetrics.Count MinScorablePopulation = $minScorablePopulation CustomDetectionRules = $cdrMetrics.Count CDRCorrelatedIncidents = $cdrCorrelated } } } function Get-XdrCheckerData { [CmdletBinding()] param( [array]$TableAnalysis, [array]$KnownXDRTables = @(), [hashtable]$RetentionMap = @{}, [int]$AdvisoryRetentionDays = 365 ) $findings = [System.Collections.Generic.List[object]]::new() $recommendations = [System.Collections.Generic.List[object]]::new() $xdrStreamedTables = @($TableAnalysis | Where-Object IsXDRStreaming) # Identify known XDR tables not streamed to Sentinel at all. # A known XDR table may exist in the workspace (RetentionMap) with zero ingestion during the # lookback window, meaning it won't appear in TableAnalysis. Use the union of usage-based # and retention-based sources so workspace-present tables are never falsely flagged as NotStreaming. # The retention fallback requires ArchiveRetentionInDays > 0 because the Tables API creates # schema entries for all known XDR tables when streaming is configured, even if no data has # been received. Only tables with explicit archive retention are treated as actively managed. $streamedFromUsage = @($xdrStreamedTables | ForEach-Object { $_.TableName }) $streamedFromRetention = @($KnownXDRTables | Where-Object { $_ -and $RetentionMap.ContainsKey($_) -and $RetentionMap[$_].ArchiveRetentionInDays -gt 0 }) $streamedNames = @($streamedFromUsage + $streamedFromRetention | Select-Object -Unique) $notStreamedNames = @($KnownXDRTables | Where-Object { $_ -and ($_ -notin $streamedNames) }) foreach ($tableName in $notStreamedNames) { $findings.Add([PSCustomObject]@{ Type = 'NotStreaming' TableName = $tableName Severity = 'Information' Detail = 'Known Defender XDR table is not streamed to Sentinel. Data is only available via XDR Advanced Hunting with 30-day retention.' }) $recommendations.Add([PSCustomObject]@{ Priority = 'Low' Type = 'XDRChecker' TableName = $tableName Title = "Consider streaming $tableName to Sentinel" Detail = 'This Defender XDR table is not ingested into the workspace. Consider streaming to Analytics or Data Lake tier for long-term retention and cross-workspace correlation.' EstSavingsUSD = 0 CurrentCost = 0 }) } foreach ($table in $xdrStreamedTables) { if ($table.AnalyticsRules -eq 0 -and $table.XDRRules -eq 0) { $findings.Add([PSCustomObject]@{ Type = 'StreamingNoCoverage' TableName = $table.TableName Severity = 'Medium' Detail = 'Table is streamed from Defender XDR but has no Sentinel analytics or Defender custom rule coverage.' }) $recommendations.Add([PSCustomObject]@{ Priority = 'Medium' Type = 'XDRChecker' TableName = $table.TableName Title = "Validate necessity of streaming $($table.TableName)" Detail = 'No Sentinel or Defender custom detection coverage found for this streamed table. Consider reducing ingestion if not needed.' EstSavingsUSD = $table.EstMonthlyCostUSD CurrentCost = $table.EstMonthlyCostUSD }) } if ($table.XDRState -ne 'Auxiliary' -and $null -ne $table.ArchiveRetentionInDays -and $table.ArchiveRetentionInDays -eq 0) { $findings.Add([PSCustomObject]@{ Type = 'NotForwardedToDataLake' TableName = $table.TableName Severity = 'Low' Detail = "XDR streaming table has no archive/data lake retention configured. Consider forwarding to Data Lake tier for long-term investigations." }) $recommendations.Add([PSCustomObject]@{ Priority = 'Low' Type = 'XDRChecker' TableName = $table.TableName Title = "Forward $($table.TableName) to Data Lake tier" Detail = "XDR streaming table is only in Analytics tier with no archive retention. Configure Data Lake forwarding for at least $AdvisoryRetentionDays days." EstSavingsUSD = 0 CurrentCost = $table.EstMonthlyCostUSD }) } elseif ($null -ne $table.ActualRetentionDays -and $table.ActualRetentionDays -lt $AdvisoryRetentionDays) { $findings.Add([PSCustomObject]@{ Type = 'AdvisoryRetentionGap' TableName = $table.TableName Severity = 'Low' Detail = "Retention is $($table.ActualRetentionDays)d. Advisory target for XDR-related logs is at least $AdvisoryRetentionDays days in Data Lake." }) $recommendations.Add([PSCustomObject]@{ Priority = 'Low' Type = 'XDRChecker' TableName = $table.TableName Title = "Consider one-year retention path for $($table.TableName)" Detail = "Advisory guidance: keep XDR-related telemetry available in Data Lake for at least $AdvisoryRetentionDays days for long-term investigations." EstSavingsUSD = 0 CurrentCost = $table.EstMonthlyCostUSD }) } } [PSCustomObject]@{ Findings = @($findings) Recommendations = @($recommendations) Summary = [PSCustomObject]@{ IssueCount = $findings.Count AdvisoryRetentionDays = $AdvisoryRetentionDays StreamedTableCount = $xdrStreamedTables.Count NotStreamedCount = $notStreamedNames.Count } } } function Get-PercentileRank { <# .SYNOPSIS Percentage of the population less than or equal to Value. Returns 0 when the population is empty or flat (no relative signal), so a single rule or an all-equal set is never ranked as noisy. #> [CmdletBinding()] param( [double]$Value, [array]$Population ) $clean = @($Population | Where-Object { $null -ne $_ } | ForEach-Object { [double]$_ }) if ($clean.Count -eq 0) { return 0 } $min = ($clean | Measure-Object -Minimum).Minimum $max = ($clean | Measure-Object -Maximum).Maximum if ($min -eq $max) { return 0 } $lessOrEqual = @($clean | Where-Object { $_ -le $Value }).Count return [math]::Round((100 * $lessOrEqual / $clean.Count), 2) } function Test-AutomationRuleIncidentMatch { <# .SYNOPSIS Decides whether an automation rule's modelled conditions apply to an incident. .DESCRIPTION Sentinel ANDs all conditions on a rule, so analytic-rule-id, title and severity groups must all match when present. A rule whose conditions are not modelled here (status, tactics, entities) is treated as a match, the same as a rule with no conditions at all. #> [CmdletBinding()] param( [PSCustomObject]$AutomationRule, [string]$IncidentTitle, [string[]]$IncidentRuleIds, [string]$IncidentSeverity ) if (-not $AutomationRule.HasConditions) { return $true } $ruleIdFilters = @($AutomationRule.RuleIdFilters | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $titleConditions = @(Get-AutomationTitleConditions -AutomationRule $AutomationRule) $severityConditions = @() if ($AutomationRule.PSObject.Properties.Name -contains 'SeverityConditions') { $severityConditions = @($AutomationRule.SeverityConditions | Where-Object { $_ -and @($_.Values).Count -gt 0 }) } # Only unmodelled conditions (status, tactics, entities ...) - cannot exclude, so it applies if ($ruleIdFilters.Count -eq 0 -and $titleConditions.Count -eq 0 -and $severityConditions.Count -eq 0) { return $true } foreach ($sc in $severityConditions) { # Unknown incident severity cannot satisfy an Equals condition and cannot be excluded by NotEquals if ([string]::IsNullOrWhiteSpace($IncidentSeverity)) { if ("$($sc.Operator)" -ne 'NotEquals') { return $false } else { continue } } $inSet = $IncidentSeverity -in @($sc.Values) if ("$($sc.Operator)" -eq 'NotEquals') { if ($inSet) { return $false } } elseif (-not $inSet) { return $false } } if ($ruleIdFilters.Count -gt 0) { $idMatch = $false foreach ($filter in $ruleIdFilters) { $filterGuid = ($filter -split '/')[-1] foreach ($incidentRuleId in @($IncidentRuleIds)) { if ([string]::IsNullOrWhiteSpace($incidentRuleId)) { continue } if ($incidentRuleId -eq $filter) { $idMatch = $true; break } $incidentGuid = ($incidentRuleId -split '/')[-1] if ($filterGuid -and $incidentGuid -and $filterGuid -eq $incidentGuid) { $idMatch = $true; break } } if ($idMatch) { break } } if (-not $idMatch) { return $false } } if ($titleConditions.Count -gt 0) { if ([string]::IsNullOrWhiteSpace($IncidentTitle)) { return $false } $titleMatch = $false foreach ($cond in $titleConditions) { $filter = $cond.Value switch ($cond.Operator) { 'Equals' { if ($IncidentTitle -eq $filter) { $titleMatch = $true } } 'StartsWith' { if ($IncidentTitle.StartsWith($filter, [System.StringComparison]::OrdinalIgnoreCase)) { $titleMatch = $true } } 'EndsWith' { if ($IncidentTitle.EndsWith($filter, [System.StringComparison]::OrdinalIgnoreCase)) { $titleMatch = $true } } default { # Contains or unknown operator: substring match; also support wildcard patterns $pattern = [regex]::Escape($filter).Replace('\*', '.*') if ($IncidentTitle -match $pattern) { $titleMatch = $true } } } if ($titleMatch) { break } } if (-not $titleMatch) { return $false } } return $true } function Get-AutomationTitleConditions { <# .SYNOPSIS Returns the rule's title conditions as (Value, Operator) pairs. Prefers the TitleConditions property; falls back to zipping TitleFilters with TitleOperators (missing operators default to Contains). #> [CmdletBinding()] param([PSCustomObject]$AutomationRule) $result = [System.Collections.Generic.List[object]]::new() if ($AutomationRule.PSObject.Properties.Name -contains 'TitleConditions' -and $AutomationRule.TitleConditions) { foreach ($c in @($AutomationRule.TitleConditions)) { if ([string]::IsNullOrWhiteSpace("$($c.Value)")) { continue } $op = if ([string]::IsNullOrWhiteSpace("$($c.Operator)")) { 'Contains' } else { "$($c.Operator)" } $result.Add([PSCustomObject]@{ Value = "$($c.Value)"; Operator = $op }) } return @($result) } $filters = @($AutomationRule.TitleFilters) $operators = @($AutomationRule.TitleOperators) for ($i = 0; $i -lt $filters.Count; $i++) { if ([string]::IsNullOrWhiteSpace("$($filters[$i])")) { continue } $op = if ($i -lt $operators.Count -and -not [string]::IsNullOrWhiteSpace("$($operators[$i])")) { "$($operators[$i])" } else { 'Contains' } $result.Add([PSCustomObject]@{ Value = "$($filters[$i])"; Operator = $op }) } @($result) } |