Private/Export-Report.ps1
|
function Export-Report { <# .SYNOPSIS Exports the Log Horizon analysis report to JSON, Markdown, or static HTML. MD and HTML render identical sections via a shared section renderer. JSON is the complete data dump containing all analysis properties. #> [CmdletBinding()] param( [Parameter(Mandatory)][PSCustomObject]$Analysis, [Parameter(Mandatory)][ValidateSet('json', 'markdown', 'md', 'html')][string]$Format, [Parameter(Mandatory)][string]$OutputPath, [string]$WorkspaceName, [PSCustomObject]$DefenderXDR ) $moduleVersion = (Import-PowerShellDataFile "$PSScriptRoot\..\LogHorizon.psd1").ModuleVersion $timestamp = Get-Date -Format 'yyyy-MM-dd_HHmm' $generatedStr = Get-Date -Format 'yyyy-MM-dd HH:mm:ss UTC' -AsUTC # Validate output path — if directory, auto-generate timestamped filename $resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) if (Test-Path -Path $resolvedPath -PathType Container) { $ext = if ($Format -in 'markdown', 'md') { 'md' } else { $Format } $resolvedPath = Join-Path $resolvedPath "LogHorizon_Report_${timestamp}.$ext" $OutputPath = $resolvedPath } if (-not (Test-Path -Path (Split-Path -Path $resolvedPath -Parent) -IsValid)) { throw "Invalid output path: $OutputPath" } # --- Sanitisation helpers --- function ConvertTo-SafeMarkdown([string]$Text) { if ([string]::IsNullOrWhiteSpace($Text)) { return "" } return $Text -replace '([\\`*_{}[\]()#+\-.!])', '\$1' -replace '<', '<' -replace '>', '>' } function ConvertTo-HtmlSafe([string]$Text) { if ([string]::IsNullOrWhiteSpace($Text)) { return "" } return [System.Net.WebUtility]::HtmlEncode($Text) } $safeWorkspaceMD = ConvertTo-SafeMarkdown $WorkspaceName $safeWorkspaceHTML = ConvertTo-HtmlSafe $WorkspaceName switch ($Format) { 'json' { $export = [ordered]@{ metadata = [ordered]@{ tool = 'Log Horizon' version = $moduleVersion workspace = $WorkspaceName generated = (Get-Date -Format 'o') } summary = $Analysis.Summary tableAnalysis = $Analysis.TableAnalysis recommendations = $Analysis.Recommendations keywordGaps = $Analysis.KeywordGaps correlationExcluded = $Analysis.CorrelationExcluded correlationIncluded = $Analysis.CorrelationIncluded socRecommendations = $Analysis.SocRecommendations dataTransforms = $Analysis.DataTransforms detectionAnalyzer = $Analysis.DetectionAnalyzer xdrChecker = $Analysis.XdrChecker } if ($DefenderXDR) { $xdrStreamed = @($Analysis.TableAnalysis | Where-Object IsXDRStreaming) $export.defenderXDR = [ordered]@{ totalXDRRules = $DefenderXDR.TotalXDRRules xdrTableCoverage = $DefenderXDR.XDRTableCoverage knownXDRTables = $DefenderXDR.KnownXDRTables streamingTables = @($xdrStreamed | ForEach-Object { [ordered]@{ tableName = $_.TableName; plan = $_.XDRState } }) } } $export | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding utf8 Write-Output "JSON report written to $OutputPath" } { $_ -in 'markdown', 'md' } { $sections = ConvertTo-ReportSections -Analysis $Analysis -DefenderXDR $DefenderXDR $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('# Log Horizon - Sentinel Log Analysis Report') [void]$sb.AppendLine('') [void]$sb.AppendLine("**Workspace:** $safeWorkspaceMD ") [void]$sb.AppendLine("**Generated:** $generatedStr ") [void]$sb.AppendLine("**Version:** $moduleVersion ") [void]$sb.AppendLine('') foreach ($section in $sections) { [void]$sb.Append($section.Markdown) } $sb.ToString() | Set-Content -Path $OutputPath -Encoding utf8 Write-Output "Markdown report written to $OutputPath" } 'html' { $sections = ConvertTo-ReportSections -Analysis $Analysis -DefenderXDR $DefenderXDR $templatePath = Join-Path -Path $PSScriptRoot -ChildPath '..\Data\ReportTemplate.html' $template = Get-Content -Path $templatePath -Raw # Build tab navigation (radios + labels) and tab panes $tabRadios = [System.Text.StringBuilder]::new() $tabLabels = [System.Text.StringBuilder]::new() $tabBody = [System.Text.StringBuilder]::new() $tabIndex = 0 foreach ($section in $sections) { $tabId = $section.TabId $checked = if ($tabIndex -eq 0) { ' checked' } else { '' } [void]$tabRadios.AppendLine(" <input type=`"radio`" name=`"tabs`" id=`"tab-$tabId`" class=`"tab-radio`"$checked>") [void]$tabLabels.AppendLine(" <label for=`"tab-$tabId`" class=`"tab-label`">$($section.Title)</label>") [void]$tabBody.AppendLine(" <section class=`"tab-pane`" id=`"pane-$tabId`">") [void]$tabBody.Append($section.Html) [void]$tabBody.AppendLine(' </section>') $tabIndex++ } $html = $template.Replace('__WORKSPACE__', $safeWorkspaceHTML) $html = $html.Replace('__GENERATED__', (ConvertTo-HtmlSafe $generatedStr)) $html = $html.Replace('__VERSION__', (ConvertTo-HtmlSafe $moduleVersion)) $html = $html.Replace('__TAB_NAVIGATION__', $tabRadios.ToString()) $html = $html.Replace('__TAB_LABELS__', $tabLabels.ToString()) $html = $html.Replace('__TAB_PANES__', $tabBody.ToString()) $html | Set-Content -Path $OutputPath -Encoding utf8 Write-Output "HTML report written to $OutputPath" } } } # --------------------------------------------------------------------------- # Shared section renderer — produces identical content for MD and HTML # --------------------------------------------------------------------------- function ConvertTo-ReportSections { param( [Parameter(Mandatory)][PSCustomObject]$Analysis, [PSCustomObject]$DefenderXDR ) function hEnc([string]$Text) { [System.Net.WebUtility]::HtmlEncode($Text) } function mdEsc([string]$Text) { if ([string]::IsNullOrWhiteSpace($Text)) { return "" } $Text -replace '([\\`*_{}[\]()#+\-.!])', '\$1' -replace '<', '<' -replace '>', '>' } $sections = [System.Collections.Generic.List[PSCustomObject]]::new() $s = $Analysis.Summary # - 1. Summary - $mdSb = [System.Text.StringBuilder]::new() [void]$mdSb.AppendLine('## Summary') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Metric | Value |') [void]$mdSb.AppendLine('| --- | --- |') [void]$mdSb.AppendLine("| Total Tables | $($s.TotalTables) |") [void]$mdSb.AppendLine("| Primary Sources | $($s.PrimaryCount) |") [void]$mdSb.AppendLine("| Secondary Sources | $($s.SecondaryCount) |") [void]$mdSb.AppendLine("| Total Ingestion | $($s.TotalMonthlyGB) GB/mo |") [void]$mdSb.AppendLine("| Est. Monthly Cost | `$$($s.TotalMonthlyCost) |") [void]$mdSb.AppendLine("| Active Rules | $($s.EnabledRules) |") [void]$mdSb.AppendLine("| Hunting Queries | $($s.HuntingQueries) |") [void]$mdSb.AppendLine("| Coverage | $($s.CoveragePercent)% |") [void]$mdSb.AppendLine("| Potential Savings | `$$($s.EstTotalSavings) |") if ($s.RetentionChecked -gt 0) { [void]$mdSb.AppendLine("| Retention Compliant | $($s.RetentionCompliant) of $($s.RetentionChecked) |") } if ($s.TablesWithTransforms -gt 0) { [void]$mdSb.AppendLine("| Tables with Transforms | $($s.TablesWithTransforms) |") } if ($s.SplitTables -gt 0) { [void]$mdSb.AppendLine("| Split Tables | $($s.SplitTables) |") } [void]$mdSb.AppendLine('') $htmlSb = [System.Text.StringBuilder]::new() [void]$htmlSb.AppendLine(' <div class="summary-grid">') $metrics = @( @{ Value = $s.TotalTables; Label = 'Total Tables' } @{ Value = "$($s.TotalMonthlyGB) GB/mo"; Label = 'Ingestion' } @{ Value = "`$$($s.TotalMonthlyCost)/mo"; Label = 'Est. Cost' } @{ Value = $s.EnabledRules; Label = 'Active Rules' } @{ Value = "$($s.CoveragePercent)%"; Label = 'Rule Coverage' } @{ Value = "`$$($s.EstTotalSavings)/mo"; Label = 'Potential Savings'; Class = 'savings' } ) foreach ($m in $metrics) { $cls = if ($m.Class) { " class=`"metric-value $($m.Class)`"" } else { ' class="metric-value"' } [void]$htmlSb.AppendLine(" <div class=`"metric-card`"><div$cls>$(hEnc "$($m.Value)")</div><div class=`"metric-label`">$(hEnc $m.Label)</div></div>") } [void]$htmlSb.AppendLine(' </div>') $sections.Add([PSCustomObject]@{ Title = 'Summary'; TabId = 'summary'; Markdown = $mdSb.ToString(); Html = $htmlSb.ToString() }) # - 2. Recommendations - if ($Analysis.Recommendations.Count -gt 0) { $sortedRecs = $Analysis.Recommendations | Sort-Object @{Expression={ switch ($_.Priority) { 'High' { 1 } 'Medium' { 2 } 'Low' { 3 } default { 4 } } }} $mdSb = [System.Text.StringBuilder]::new() [void]$mdSb.AppendLine('## Recommendations') [void]$mdSb.AppendLine('') $num = 1 foreach ($rec in $sortedRecs) { $icon = switch ($rec.Priority) { 'High' { '🔴' } 'Medium' { '🟡' } 'Low' { '🔵' } } [void]$mdSb.AppendLine("### $num. $icon $($rec.Title)") [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine("**Priority:** $($rec.Priority) ") [void]$mdSb.AppendLine("**Type:** $($rec.Type) ") [void]$mdSb.AppendLine("**Current Cost:** `$$($rec.CurrentCost)/mo ") if ($rec.EstSavingsUSD -gt 0) { [void]$mdSb.AppendLine("**Est. Savings:** `$$($rec.EstSavingsUSD)/mo ") } [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine($rec.Detail) [void]$mdSb.AppendLine('') $num++ } $htmlSb = [System.Text.StringBuilder]::new() $num = 1 foreach ($rec in $sortedRecs) { $prioClass = switch ($rec.Priority) { 'High' { 'badge-high' } 'Medium' { 'badge-medium' } 'Low' { 'badge-low' } } $saveBadge = if ($rec.EstSavingsUSD -gt 0) { " <span class=`"badge badge-savings`">Saves `$$($rec.EstSavingsUSD)/mo</span>" } else { '' } [void]$htmlSb.AppendLine(" <article class=`"rec-card`">") [void]$htmlSb.AppendLine(" <div class=`"rec-header`"><span class=`"badge $prioClass`">$($rec.Priority)</span> <span class=`"badge`">$($rec.Type)</span>$saveBadge <strong>$(hEnc $rec.Title)</strong></div>") [void]$htmlSb.AppendLine(" <p>$(hEnc $rec.Detail)</p>") [void]$htmlSb.AppendLine(' </article>') $num++ } $sections.Add([PSCustomObject]@{ Title = 'Recommendations'; TabId = 'recs'; Markdown = $mdSb.ToString(); Html = $htmlSb.ToString() }) } # - 3. Table Analysis - $sorted = $Analysis.TableAnalysis | Sort-Object EstMonthlyCostUSD -Descending $mdSb = [System.Text.StringBuilder]::new() [void]$mdSb.AppendLine('## Table Analysis') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Table | Class | GB/mo | Cost/mo | Rules | Hunting | Assessment |') [void]$mdSb.AppendLine('| --- | --- | ---: | ---: | ---: | ---: | --- |') foreach ($t in $sorted) { $costStr = if ($t.IsFree) { 'FREE' } else { "`$$($t.EstMonthlyCostUSD)" } [void]$mdSb.AppendLine("| $($t.TableName) | $($t.Classification) | $($t.MonthlyGB) | $costStr | $($t.AnalyticsRules) | $($t.HuntingQueries) | $($t.Assessment) |") } [void]$mdSb.AppendLine('') $htmlSb = [System.Text.StringBuilder]::new() [void]$htmlSb.AppendLine(' <div class="table-wrap"><table>') [void]$htmlSb.AppendLine(' <thead><tr><th>Table</th><th>Class</th><th>GB/mo</th><th>Cost/mo</th><th>Rules</th><th>Hunting</th><th>Assessment</th></tr></thead>') [void]$htmlSb.AppendLine(' <tbody>') foreach ($t in $sorted) { $clsClass = switch ($t.Classification) { 'primary' { 'cls-primary' } 'secondary' { 'cls-secondary' } default { 'cls-unknown' } } $costStr = if ($t.IsFree) { '<span class="badge badge-savings">FREE</span>' } else { "`$$($t.EstMonthlyCostUSD)" } [void]$htmlSb.AppendLine(" <tr><td>$(hEnc $t.TableName)</td><td class=`"$clsClass`">$($t.Classification.ToUpper())</td><td class=`"num`">$($t.MonthlyGB)</td><td class=`"num`">$costStr</td><td class=`"num`">$($t.TotalCoverage)</td><td class=`"num`">$($t.HuntingQueries)</td><td>$(hEnc $t.Assessment)</td></tr>") } [void]$htmlSb.AppendLine(' </tbody>') [void]$htmlSb.AppendLine(' </table></div>') $sections.Add([PSCustomObject]@{ Title = 'Tables & Costs'; TabId = 'tables'; Markdown = $mdSb.ToString(); Html = $htmlSb.ToString() }) # - 4. Keyword Gaps - if ($Analysis.KeywordGaps -and $Analysis.KeywordGaps.Count -gt 0) { $mdSb = [System.Text.StringBuilder]::new() [void]$mdSb.AppendLine('## Missing Log Sources (Keyword Matches)') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Table | Connector | Classification | Keyword |') [void]$mdSb.AppendLine('| --- | --- | --- | --- |') foreach ($kg in $Analysis.KeywordGaps) { [void]$mdSb.AppendLine("| $($kg.TableName) | $($kg.Connector) | $($kg.Classification) | $($kg.MatchedKeyword) |") } [void]$mdSb.AppendLine('') $htmlSb = [System.Text.StringBuilder]::new() [void]$htmlSb.AppendLine(' <p>Tables matching your keywords that are not currently ingested.</p>') [void]$htmlSb.AppendLine(' <div class="table-wrap"><table>') [void]$htmlSb.AppendLine(' <thead><tr><th>Table</th><th>Connector</th><th>Classification</th><th>Keyword</th></tr></thead>') [void]$htmlSb.AppendLine(' <tbody>') foreach ($kg in $Analysis.KeywordGaps) { [void]$htmlSb.AppendLine(" <tr><td>$(hEnc $kg.TableName)</td><td>$(hEnc $kg.Connector)</td><td>$(hEnc $kg.Classification)</td><td>$(hEnc $kg.MatchedKeyword)</td></tr>") } [void]$htmlSb.AppendLine(' </tbody>') [void]$htmlSb.AppendLine(' </table></div>') $sections.Add([PSCustomObject]@{ Title = 'Keyword Gaps'; TabId = 'keywords'; Markdown = $mdSb.ToString(); Html = $htmlSb.ToString() }) } # - 5. Retention Assessment - $nonCompliant = @($Analysis.TableAnalysis | Where-Object { $_.RetentionCompliant -eq $false } | Sort-Object ActualRetentionDays) $improvable = @($Analysis.TableAnalysis | Where-Object { $_.RetentionCanImprove -eq $true } | Sort-Object RecommendedRetentionDays -Descending) if ($nonCompliant.Count -gt 0 -or $improvable.Count -gt 0) { $mdSb = [System.Text.StringBuilder]::new() [void]$mdSb.AppendLine('## Retention Assessment') [void]$mdSb.AppendLine('') if ($s.WorkspaceRetentionDays -gt 0 -and $s.WorkspaceRetentionDays -lt 90) { [void]$mdSb.AppendLine("> **Warning:** Workspace default retention is $($s.WorkspaceRetentionDays)d — increase to at least 90d. ") [void]$mdSb.AppendLine('') } if ($nonCompliant.Count -gt 0) { [void]$mdSb.AppendLine('### Below 90-Day Baseline') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Table | Plan | Current | Baseline | Shortfall |') [void]$mdSb.AppendLine('| --- | --- | ---: | ---: | ---: |') foreach ($t in $nonCompliant) { $shortfall = 90 - $t.ActualRetentionDays $plan = if ($t.TablePlan) { $t.TablePlan } else { '-' } [void]$mdSb.AppendLine("| $($t.TableName) | $plan | $($t.ActualRetentionDays)d | 90d | +${shortfall}d |") } [void]$mdSb.AppendLine('') } if ($improvable.Count -gt 0) { [void]$mdSb.AppendLine('### Extended Retention Recommendations') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Table | Category | Current | Recommended |') [void]$mdSb.AppendLine('| --- | --- | ---: | ---: |') foreach ($t in $improvable) { $currentStr = if ($null -ne $t.ActualRetentionDays) { "$($t.ActualRetentionDays)d" } else { '-' } [void]$mdSb.AppendLine("| $($t.TableName) | $($t.Category) | $currentStr | $($t.RecommendedRetentionDays)d |") } [void]$mdSb.AppendLine('') } $htmlSb = [System.Text.StringBuilder]::new() if ($s.WorkspaceRetentionDays -gt 0 -and $s.WorkspaceRetentionDays -lt 90) { [void]$htmlSb.AppendLine(" <p class=`"warning`">⚠ Workspace default retention is $($s.WorkspaceRetentionDays)d — increase to at least 90d.</p>") } if ($nonCompliant.Count -gt 0) { [void]$htmlSb.AppendLine(' <h3>Below 90-Day Baseline</h3>') [void]$htmlSb.AppendLine(' <div class="table-wrap"><table>') [void]$htmlSb.AppendLine(' <thead><tr><th>Table</th><th>Plan</th><th>Current</th><th>Baseline</th><th>Shortfall</th></tr></thead>') [void]$htmlSb.AppendLine(' <tbody>') foreach ($t in $nonCompliant) { $shortfall = 90 - $t.ActualRetentionDays $plan = if ($t.TablePlan) { hEnc $t.TablePlan } else { '-' } [void]$htmlSb.AppendLine(" <tr><td>$(hEnc $t.TableName)</td><td>$plan</td><td class=`"num bad`">$($t.ActualRetentionDays)d</td><td class=`"num`">90d</td><td class=`"num bad`">+${shortfall}d</td></tr>") } [void]$htmlSb.AppendLine(' </tbody></table></div>') } if ($improvable.Count -gt 0) { [void]$htmlSb.AppendLine(' <h3>Extended Retention Recommendations</h3>') [void]$htmlSb.AppendLine(' <div class="table-wrap"><table>') [void]$htmlSb.AppendLine(' <thead><tr><th>Table</th><th>Category</th><th>Current</th><th>Recommended</th></tr></thead>') [void]$htmlSb.AppendLine(' <tbody>') foreach ($t in $improvable) { $currentStr = if ($null -ne $t.ActualRetentionDays) { "$($t.ActualRetentionDays)d" } else { '-' } [void]$htmlSb.AppendLine(" <tr><td>$(hEnc $t.TableName)</td><td>$(hEnc $t.Category)</td><td class=`"num`">$currentStr</td><td class=`"num`">$($t.RecommendedRetentionDays)d</td></tr>") } [void]$htmlSb.AppendLine(' </tbody></table></div>') } $sections.Add([PSCustomObject]@{ Title = 'Retention'; TabId = 'retention'; Markdown = $mdSb.ToString(); Html = $htmlSb.ToString() }) } # - 6. Data Transforms - $transforms = $Analysis.DataTransforms $tablesWithTransforms = @($Analysis.TableAnalysis | Where-Object { $_.HasTransform }) $splitTables = @($Analysis.TableAnalysis | Where-Object { $_.IsSplitTable }) if ($tablesWithTransforms.Count -gt 0 -or $splitTables.Count -gt 0) { $mdSb = [System.Text.StringBuilder]::new() [void]$mdSb.AppendLine('## Data Transforms') [void]$mdSb.AppendLine('') if ($splitTables.Count -gt 0) { [void]$mdSb.AppendLine('### Split Tables') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Split Table | Parent | Split GB/mo | Plan |') [void]$mdSb.AppendLine('| --- | --- | ---: | --- |') foreach ($t in $splitTables) { $plan = if ($t.TablePlan) { $t.TablePlan } else { 'Data Lake' } [void]$mdSb.AppendLine("| $($t.TableName) | $($t.ParentTable) | $($t.MonthlyGB) | $plan |") } [void]$mdSb.AppendLine('') } if ($tablesWithTransforms.Count -gt 0 -and $transforms -and $transforms.Transforms.Count -gt 0) { [void]$mdSb.AppendLine('### DCR Transforms') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Table | Type | KQL |') [void]$mdSb.AppendLine('| --- | --- | --- |') foreach ($tr in $transforms.Transforms) { $kqlPreview = $tr.TransformKql -replace '\r?\n', ' ' -replace '\s+', ' ' $kqlPreview = $kqlPreview -replace '\|', '|' [void]$mdSb.AppendLine("| $($tr.OutputTable) | $($tr.TransformType) | <code>$kqlPreview</code> |") } [void]$mdSb.AppendLine('') } $htmlSb = [System.Text.StringBuilder]::new() if ($splitTables.Count -gt 0) { [void]$htmlSb.AppendLine(' <h3>Split Tables</h3>') [void]$htmlSb.AppendLine(' <div class="table-wrap"><table>') [void]$htmlSb.AppendLine(' <thead><tr><th>Split Table</th><th>Parent</th><th>Split GB/mo</th><th>Plan</th></tr></thead>') [void]$htmlSb.AppendLine(' <tbody>') foreach ($t in $splitTables) { $plan = if ($t.TablePlan) { hEnc $t.TablePlan } else { 'Data Lake' } [void]$htmlSb.AppendLine(" <tr><td>$(hEnc $t.TableName)</td><td>$(hEnc $t.ParentTable)</td><td class=`"num`">$($t.MonthlyGB)</td><td>$plan</td></tr>") } [void]$htmlSb.AppendLine(' </tbody></table></div>') } if ($tablesWithTransforms.Count -gt 0 -and $transforms -and $transforms.Transforms.Count -gt 0) { [void]$htmlSb.AppendLine(' <h3>DCR Transforms</h3>') [void]$htmlSb.AppendLine(' <div class="table-wrap"><table>') [void]$htmlSb.AppendLine(' <thead><tr><th>Table</th><th>Type</th><th>KQL</th></tr></thead>') [void]$htmlSb.AppendLine(' <tbody>') foreach ($tr in $transforms.Transforms) { [void]$htmlSb.AppendLine(" <tr><td>$(hEnc $tr.OutputTable)</td><td>$(hEnc $tr.TransformType)</td><td><code class=`"kql`">$(hEnc $tr.TransformKql)</code></td></tr>") } [void]$htmlSb.AppendLine(' </tbody></table></div>') } $sections.Add([PSCustomObject]@{ Title = 'Transforms'; TabId = 'transforms'; Markdown = $mdSb.ToString(); Html = $htmlSb.ToString() }) } # - 7. Log Tuning Suggestions - $splitRecs = @($Analysis.Recommendations | Where-Object { $_.Type -eq 'SplitCandidate' -and $_.SplitSuggestion -and $_.SplitSuggestion.Source -ne 'none' }) $liveTuning = @($Analysis.LiveTuningAnalysis | Where-Object { $_.RuleCount -gt 0 }) $hasLogTuning = ($splitRecs.Count -gt 0) -or ($liveTuning.Count -gt 0) if ($hasLogTuning) { $mdSb = [System.Text.StringBuilder]::new() [void]$mdSb.AppendLine('## Log Tuning Suggestions') [void]$mdSb.AppendLine('') # Live data tuning section if ($liveTuning.Count -gt 0) { [void]$mdSb.AppendLine('### Live Data Tuning') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('Based on deployed analytics and hunting rules.') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Table | GB/mo | Rules | Fields Used | Unused Cols | Filter Savings | Project Savings |') [void]$mdSb.AppendLine('| --- | --- | --- | --- | --- | --- | --- |') foreach ($lt in ($liveTuning | Sort-Object EstMonthlyCostUSD -Descending)) { $unusedStr = if ($lt.SchemaColumnCount -gt 0) { "$($lt.UnusedFieldCount) of $($lt.SchemaColumnCount)" } else { '-' } $filterStr = if ($lt.EstFilterSavings -gt 0) { "`$$($lt.EstFilterSavings)" } else { '-' } $projectStr = if ($lt.EstProjectSavings -gt 0) { "`$$($lt.EstProjectSavings)" } else { '-' } [void]$mdSb.AppendLine("| $(mdEsc $lt.TableName) | $($lt.MonthlyGB) | $($lt.RuleCount) | $($lt.FieldCount) | $unusedStr | $filterStr | $projectStr |") } [void]$mdSb.AppendLine('') foreach ($lt in ($liveTuning | Sort-Object EstMonthlyCostUSD -Descending)) { if ($lt.FilterKql -or $lt.ProjectKql) { [void]$mdSb.AppendLine("#### $($lt.TableName)") [void]$mdSb.AppendLine('') if ($lt.FilterKql) { [void]$mdSb.AppendLine('**Filter KQL:**') [void]$mdSb.AppendLine('```kql') [void]$mdSb.AppendLine($lt.FilterKql) [void]$mdSb.AppendLine('```') [void]$mdSb.AppendLine('') } if ($lt.ProjectKql) { [void]$mdSb.AppendLine('**Projection KQL:**') [void]$mdSb.AppendLine('```kql') [void]$mdSb.AppendLine($lt.ProjectKql) [void]$mdSb.AppendLine('```') [void]$mdSb.AppendLine('') } } } } # Knowledge base tuning section if ($splitRecs.Count -gt 0) { [void]$mdSb.AppendLine('### Knowledge Base Tuning') [void]$mdSb.AppendLine('') foreach ($rec in $splitRecs) { $ss = $rec.SplitSuggestion [void]$mdSb.AppendLine("#### $($rec.TableName)") [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine("**Source:** $($ss.Source) | **Rules:** $($ss.RuleCount) | **Est. Savings:** `$$($rec.EstSavingsUSD)/mo ") [void]$mdSb.AppendLine('') if ($ss.SplitKql) { [void]$mdSb.AppendLine('**Split KQL:**') [void]$mdSb.AppendLine('```kql') [void]$mdSb.AppendLine($ss.SplitKql) [void]$mdSb.AppendLine('```') [void]$mdSb.AppendLine('') } if ($ss.ProjectKql) { [void]$mdSb.AppendLine('**Projection KQL:**') [void]$mdSb.AppendLine('```kql') [void]$mdSb.AppendLine($ss.ProjectKql) [void]$mdSb.AppendLine('```') [void]$mdSb.AppendLine('') } } } $htmlSb = [System.Text.StringBuilder]::new() [void]$htmlSb.AppendLine(' <p>Log tuning suggestions to reduce ingestion costs via row splitting and column reduction.</p>') if ($liveTuning.Count -gt 0) { [void]$htmlSb.AppendLine(' <h3>Live Data Tuning</h3>') [void]$htmlSb.AppendLine(' <p>Based on deployed analytics and hunting rules.</p>') foreach ($lt in ($liveTuning | Sort-Object EstMonthlyCostUSD -Descending)) { if ($lt.FilterKql -or $lt.ProjectKql) { [void]$htmlSb.AppendLine(" <article class=`"rec-card`">") [void]$htmlSb.AppendLine(" <div class=`"rec-header`"><strong>$(hEnc $lt.TableName)</strong> <span class=`"badge`">live</span></div>") if ($lt.FilterKql) { [void]$htmlSb.AppendLine(" <p><strong>Filter KQL:</strong></p>") [void]$htmlSb.AppendLine(" <pre class=`"kql-block`">$(hEnc $lt.FilterKql)</pre>") } if ($lt.ProjectKql) { [void]$htmlSb.AppendLine(" <p><strong>Projection KQL:</strong></p>") [void]$htmlSb.AppendLine(" <pre class=`"kql-block`">$(hEnc $lt.ProjectKql)</pre>") } [void]$htmlSb.AppendLine(' </article>') } } } if ($splitRecs.Count -gt 0) { [void]$htmlSb.AppendLine(' <h3>Knowledge Base Tuning</h3>') foreach ($rec in $splitRecs) { $ss = $rec.SplitSuggestion [void]$htmlSb.AppendLine(" <article class=`"rec-card`">") [void]$htmlSb.AppendLine(" <div class=`"rec-header`"><strong>$(hEnc $rec.TableName)</strong> <span class=`"badge`">$($ss.Source)</span> <span class=`"badge badge-savings`">Saves `$$($rec.EstSavingsUSD)/mo</span></div>") if ($ss.SplitKql) { [void]$htmlSb.AppendLine(" <p><strong>Split KQL:</strong></p>") [void]$htmlSb.AppendLine(" <pre class=`"kql-block`">$(hEnc $ss.SplitKql)</pre>") } if ($ss.ProjectKql) { [void]$htmlSb.AppendLine(" <p><strong>Projection KQL:</strong></p>") [void]$htmlSb.AppendLine(" <pre class=`"kql-block`">$(hEnc $ss.ProjectKql)</pre>") } [void]$htmlSb.AppendLine(' </article>') } } $sections.Add([PSCustomObject]@{ Title = 'Log Tuning'; TabId = 'logtuning'; Markdown = $mdSb.ToString(); Html = $htmlSb.ToString() }) } # - 8. Correlation Rules - $corrExcluded = $Analysis.CorrelationExcluded $corrIncluded = $Analysis.CorrelationIncluded if (($corrExcluded -and $corrExcluded.Count -gt 0) -or ($corrIncluded -and $corrIncluded.Count -gt 0)) { $mdSb = [System.Text.StringBuilder]::new() [void]$mdSb.AppendLine('## Correlation Rules') [void]$mdSb.AppendLine('') if ($corrExcluded -and $corrExcluded.Count -gt 0) { [void]$mdSb.AppendLine('### Excluded from Correlation') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Rule | Kind | Tables |') [void]$mdSb.AppendLine('| --- | --- | --- |') foreach ($cr in $corrExcluded) { $tables = if ($cr.Tables) { ($cr.Tables -join ', ') } else { '-' } [void]$mdSb.AppendLine("| $(mdEsc $cr.RuleName) | $($cr.Kind) | $tables |") } [void]$mdSb.AppendLine('') } if ($corrIncluded -and $corrIncluded.Count -gt 0) { [void]$mdSb.AppendLine('### Included in Correlation') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Rule | Kind | Tables |') [void]$mdSb.AppendLine('| --- | --- | --- |') foreach ($cr in $corrIncluded) { $tables = if ($cr.Tables) { ($cr.Tables -join ', ') } else { '-' } [void]$mdSb.AppendLine("| $(mdEsc $cr.RuleName) | $($cr.Kind) | $tables |") } [void]$mdSb.AppendLine('') } $htmlSb = [System.Text.StringBuilder]::new() if ($corrExcluded -and $corrExcluded.Count -gt 0) { [void]$htmlSb.AppendLine(' <h3>Excluded from Correlation</h3>') [void]$htmlSb.AppendLine(' <div class="table-wrap"><table>') [void]$htmlSb.AppendLine(' <thead><tr><th>Rule</th><th>Kind</th><th>Tables</th></tr></thead>') [void]$htmlSb.AppendLine(' <tbody>') foreach ($cr in $corrExcluded) { $tables = if ($cr.Tables) { hEnc ($cr.Tables -join ', ') } else { '-' } [void]$htmlSb.AppendLine(" <tr><td>$(hEnc $cr.RuleName)</td><td>$(hEnc $cr.Kind)</td><td>$tables</td></tr>") } [void]$htmlSb.AppendLine(' </tbody></table></div>') } if ($corrIncluded -and $corrIncluded.Count -gt 0) { [void]$htmlSb.AppendLine(' <h3>Included in Correlation</h3>') [void]$htmlSb.AppendLine(' <div class="table-wrap"><table>') [void]$htmlSb.AppendLine(' <thead><tr><th>Rule</th><th>Kind</th><th>Tables</th></tr></thead>') [void]$htmlSb.AppendLine(' <tbody>') foreach ($cr in $corrIncluded) { $tables = if ($cr.Tables) { hEnc ($cr.Tables -join ', ') } else { '-' } [void]$htmlSb.AppendLine(" <tr><td>$(hEnc $cr.RuleName)</td><td>$(hEnc $cr.Kind)</td><td>$tables</td></tr>") } [void]$htmlSb.AppendLine(' </tbody></table></div>') } $sections.Add([PSCustomObject]@{ Title = 'Correlation'; TabId = 'correlation'; Markdown = $mdSb.ToString(); Html = $htmlSb.ToString() }) } # - 9. Defender XDR (conditional) - if ($DefenderXDR) { $mdSb = [System.Text.StringBuilder]::new() [void]$mdSb.AppendLine('## Defender XDR') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine("**Custom Detections:** $($DefenderXDR.TotalXDRRules) rules ") $xdrStreaming = @($Analysis.TableAnalysis | Where-Object IsXDRStreaming) if ($xdrStreaming.Count -gt 0) { $streamingNames = ($xdrStreaming | ForEach-Object { $tierLabel = if ($_.XDRState -eq 'Auxiliary') { 'data lake' } else { $_.XDRState } "$($_.TableName) ($tierLabel)" }) -join ', ' [void]$mdSb.AppendLine("**Streaming to Sentinel:** $streamingNames ") } if ($Analysis.XdrChecker -and $Analysis.XdrChecker.Summary.NotStreamedCount -gt 0) { $notStreamedFindings = @($Analysis.XdrChecker.Findings | Where-Object Type -eq 'NotStreaming') $notStreamedNames = ($notStreamedFindings | ForEach-Object { $_.TableName }) -join ', ' [void]$mdSb.AppendLine("**Not streaming (XDR default 30d):** $notStreamedNames ") } [void]$mdSb.AppendLine('') $xdrRecs = @($Analysis.Recommendations | Where-Object Type -eq 'XDROptimize') if ($xdrRecs.Count -gt 0) { [void]$mdSb.AppendLine('### XDR Optimization Opportunities') [void]$mdSb.AppendLine('') foreach ($xr in $xdrRecs) { [void]$mdSb.AppendLine("- **$($xr.Title):** $($xr.Detail)") } [void]$mdSb.AppendLine('') } $htmlSb = [System.Text.StringBuilder]::new() [void]$htmlSb.AppendLine(" <p><strong>Custom Detections:</strong> $($DefenderXDR.TotalXDRRules) rules</p>") if ($xdrStreaming.Count -gt 0) { [void]$htmlSb.AppendLine(" <p><strong>Streaming to Sentinel:</strong> $(hEnc $streamingNames)</p>") } if ($Analysis.XdrChecker -and $Analysis.XdrChecker.Summary.NotStreamedCount -gt 0) { [void]$htmlSb.AppendLine(" <p><strong>Not streaming (XDR default 30d):</strong> $(hEnc $notStreamedNames)</p>") } if ($xdrRecs.Count -gt 0) { [void]$htmlSb.AppendLine(' <h3>XDR Optimization Opportunities</h3>') [void]$htmlSb.AppendLine(' <ul>') foreach ($xr in $xdrRecs) { [void]$htmlSb.AppendLine(" <li><strong>$(hEnc $xr.Title):</strong> $(hEnc $xr.Detail)</li>") } [void]$htmlSb.AppendLine(' </ul>') } $sections.Add([PSCustomObject]@{ Title = 'Defender XDR'; TabId = 'xdr'; Markdown = $mdSb.ToString(); Html = $htmlSb.ToString() }) } # - 10. Detection Analyzer (conditional) - if ($Analysis.DetectionAnalyzer -and $Analysis.DetectionAnalyzer.RuleMetrics.Count -gt 0) { $scored = @($Analysis.DetectionAnalyzer.RuleMetrics | Where-Object { $null -ne $_.NoisinessScore } | Sort-Object NoisinessScore -Descending) $unscored = @($Analysis.DetectionAnalyzer.RuleMetrics | Where-Object { $null -eq $_.NoisinessScore }) $sortedMetrics = @($scored) + @($unscored) $mdSb = [System.Text.StringBuilder]::new() [void]$mdSb.AppendLine('## Detection Analyzer') [void]$mdSb.AppendLine('') $daSummary = $Analysis.DetectionAnalyzer.Summary if ($null -ne $daSummary.TotalTables -and $daSummary.TotalTables -gt 0) { [void]$mdSb.AppendLine('### Ingestion Coverage') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine("| Metric | Coverage | Tables | Total Tables |") [void]$mdSb.AppendLine("| --- | ---: | ---: | ---: |") [void]$mdSb.AppendLine("| Detection (Analytics/CDR) | $($daSummary.DetectionCoveragePct)% | $($daSummary.TablesWithDetection) | $($daSummary.TotalTables) |") [void]$mdSb.AppendLine("| Hunting Queries | $($daSummary.HuntingCoveragePct)% | $($daSummary.TablesWithHunting) | $($daSummary.TotalTables) |") [void]$mdSb.AppendLine("| Combined | $($daSummary.CombinedCoveragePct)% | $($daSummary.TablesWithCombined) | $($daSummary.TotalTables) |") [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine("**Avg detections per table:** $($daSummary.AvgDetectionsPerTable) (analytics + CDR rules) ") [void]$mdSb.AppendLine('') } [void]$mdSb.AppendLine("**Rules analyzed:** $($daSummary.RulesAnalyzed) ") [void]$mdSb.AppendLine("**Noisy rules (score >= 70):** $($daSummary.NoisyRules) ") [void]$mdSb.AppendLine("**Incidents analyzed:** $($daSummary.IncidentsAnalyzed) ") if ($daSummary.CustomDetectionRules -gt 0) { [void]$mdSb.AppendLine("**Custom Detection Rules:** $($daSummary.CustomDetectionRules) ($($daSummary.CDRCorrelatedIncidents) with incidents) ") } [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Rule | Kind | Incidents | AutoClose % | FalsePositive % | Noisiness Score |') [void]$mdSb.AppendLine('| --- | --- | ---: | ---: | ---: | ---: |') foreach ($r in ($sortedMetrics | Select-Object -First 25)) { $scoreStr = if ($null -eq $r.NoisinessScore) { 'N/A' } else { $r.NoisinessScore } [void]$mdSb.AppendLine("| $($r.RuleName) | $($r.RuleKind) | $($r.IncidentsTotal) | $([math]::Round($r.AutoCloseRatio * 100, 1)) | $([math]::Round($r.FalsePositiveRatio * 100, 1)) | $scoreStr |") } [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('**Scoring:** Score = (Volume_percentile x 35%) + (AutoClose_percentile x 40%) + (FalsePos_percentile x 25%). >= 70 = noisy, >= 50 = watch, < 50 = healthy. ') [void]$mdSb.AppendLine('*A high score does not conclusively mean a detection is bad -- it is an indicator that the rule may warrant closer review.* ') [void]$mdSb.AppendLine('') $htmlSb = [System.Text.StringBuilder]::new() if ($null -ne $daSummary.TotalTables -and $daSummary.TotalTables -gt 0) { [void]$htmlSb.AppendLine(' <h3>Ingestion Coverage</h3>') [void]$htmlSb.AppendLine(' <div class="table-wrap"><table>') [void]$htmlSb.AppendLine(' <thead><tr><th>Metric</th><th>Coverage</th><th>Tables</th><th>Total Tables</th></tr></thead>') [void]$htmlSb.AppendLine(' <tbody>') [void]$htmlSb.AppendLine(" <tr><td>Detection (Analytics/CDR)</td><td class=`"num`">$($daSummary.DetectionCoveragePct)%</td><td class=`"num`">$($daSummary.TablesWithDetection)</td><td class=`"num`">$($daSummary.TotalTables)</td></tr>") [void]$htmlSb.AppendLine(" <tr><td>Hunting Queries</td><td class=`"num`">$($daSummary.HuntingCoveragePct)%</td><td class=`"num`">$($daSummary.TablesWithHunting)</td><td class=`"num`">$($daSummary.TotalTables)</td></tr>") [void]$htmlSb.AppendLine(" <tr><td>Combined</td><td class=`"num`">$($daSummary.CombinedCoveragePct)%</td><td class=`"num`">$($daSummary.TablesWithCombined)</td><td class=`"num`">$($daSummary.TotalTables)</td></tr>") [void]$htmlSb.AppendLine(' </tbody>') [void]$htmlSb.AppendLine(' </table></div>') [void]$htmlSb.AppendLine(" <p><strong>Avg detections per table:</strong> $($daSummary.AvgDetectionsPerTable) (analytics + CDR rules)</p>") } [void]$htmlSb.AppendLine(" <p><strong>Rules analyzed:</strong> $($daSummary.RulesAnalyzed)</p>") [void]$htmlSb.AppendLine(" <p><strong>Noisy rules:</strong> $($daSummary.NoisyRules)</p>") if ($daSummary.CustomDetectionRules -gt 0) { [void]$htmlSb.AppendLine(" <p><strong>Custom Detection Rules:</strong> $($daSummary.CustomDetectionRules) ($($daSummary.CDRCorrelatedIncidents) with incidents)</p>") } [void]$htmlSb.AppendLine(' <div class="table-wrap"><table>') [void]$htmlSb.AppendLine(' <thead><tr><th>Rule</th><th>Kind</th><th>Incidents</th><th>AutoClose %</th><th>FalsePositive %</th><th>Noisiness Score</th></tr></thead>') [void]$htmlSb.AppendLine(' <tbody>') foreach ($r in ($sortedMetrics | Select-Object -First 25)) { $scoreStr = if ($null -eq $r.NoisinessScore) { 'N/A' } else { $r.NoisinessScore } [void]$htmlSb.AppendLine(" <tr><td>$(hEnc $r.RuleName)</td><td>$(hEnc $r.RuleKind)</td><td class=`"num`">$($r.IncidentsTotal)</td><td class=`"num`">$([math]::Round($r.AutoCloseRatio * 100, 1))</td><td class=`"num`">$([math]::Round($r.FalsePositiveRatio * 100, 1))</td><td class=`"num`">$scoreStr</td></tr>") } [void]$htmlSb.AppendLine(' </tbody>') [void]$htmlSb.AppendLine(' </table></div>') [void]$htmlSb.AppendLine(' <p><strong>Scoring:</strong> Score = (Volume_percentile x 35%) + (AutoClose_percentile x 40%) + (FalsePos_percentile x 25%). >= 70 = noisy, >= 50 = watch, < 50 = healthy.</p>') [void]$htmlSb.AppendLine(' <p><em>A high score does not conclusively mean a detection is bad -- it is an indicator that the rule may warrant closer review.</em></p>') $sections.Add([PSCustomObject]@{ Title = 'Detection Analyzer'; TabId = 'detanalyzer'; Markdown = $mdSb.ToString(); Html = $htmlSb.ToString() }) } # - 11. XDR Checker (conditional) - if ($Analysis.XdrChecker -and $Analysis.XdrChecker.Findings.Count -gt 0) { $mdSb = [System.Text.StringBuilder]::new() [void]$mdSb.AppendLine('## XDR Checker') [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine("**Advisory retention target:** $($Analysis.XdrChecker.Summary.AdvisoryRetentionDays) days (Data Lake) ") [void]$mdSb.AppendLine('') [void]$mdSb.AppendLine('| Table | Type | Severity | Detail |') [void]$mdSb.AppendLine('| --- | --- | --- | --- |') foreach ($f in $Analysis.XdrChecker.Findings) { [void]$mdSb.AppendLine("| $($f.TableName) | $($f.Type) | $($f.Severity) | $($f.Detail) |") } [void]$mdSb.AppendLine('') $htmlSb = [System.Text.StringBuilder]::new() [void]$htmlSb.AppendLine(" <p><strong>Advisory retention target:</strong> $($Analysis.XdrChecker.Summary.AdvisoryRetentionDays) days in Data Lake for XDR-related logs.</p>") [void]$htmlSb.AppendLine(' <div class="table-wrap"><table>') [void]$htmlSb.AppendLine(' <thead><tr><th>Table</th><th>Type</th><th>Severity</th><th>Detail</th></tr></thead>') [void]$htmlSb.AppendLine(' <tbody>') foreach ($f in $Analysis.XdrChecker.Findings) { [void]$htmlSb.AppendLine(" <tr><td>$(hEnc $f.TableName)</td><td>$(hEnc $f.Type)</td><td>$(hEnc $f.Severity)</td><td>$(hEnc $f.Detail)</td></tr>") } [void]$htmlSb.AppendLine(' </tbody>') [void]$htmlSb.AppendLine(' </table></div>') $sections.Add([PSCustomObject]@{ Title = 'XDR Checker'; TabId = 'xdrchecker'; Markdown = $mdSb.ToString(); Html = $htmlSb.ToString() }) } return $sections } |