Public/Invoke-LogHorizon.ps1

function Invoke-LogHorizon {
    <#
    .SYNOPSIS
        Log Horizon - Sentinel SIEM log source analyser.
 
        Connects to a Microsoft Sentinel workspace, classifies every ingesting
        log source as primary or secondary security data, evaluates cost vs.
        detection value, and produces actionable optimisation recommendations.
 
    .EXAMPLE
        Invoke-LogHorizon -SubscriptionId '00000000-...' -ResourceGroup 'rg-sentinel' -WorkspaceName 'my-sentinel-ws'
 
    .EXAMPLE
        Invoke-LogHorizon -SubscriptionId '...' -ResourceGroup 'rg' -WorkspaceName 'ws' -Keywords 'CrowdStrike','AWS' -IncludeDefenderXDR
 
    .EXAMPLE
        Invoke-LogHorizon -SubscriptionId '...' -ResourceGroup 'rg' -WorkspaceName 'ws' -Output json -OutputPath ./report.json -PricePerGB 4.61
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, HelpMessage = 'Azure subscription ID')]
        [string]$SubscriptionId,

        [Parameter(Mandatory, HelpMessage = 'Resource group containing the Sentinel workspace')]
        [string]$ResourceGroup,

        [Parameter(Mandatory, HelpMessage = 'Log Analytics workspace name')]
        [string]$WorkspaceName,

        [string]$WorkspaceId,

        [ValidateSet('json', 'markdown', 'md', 'html')]
        [Alias('o')]
        [string]$Output,

        [string]$OutputPath,

        [switch]$NonInteractive,

        [Alias('kw')]
        [string[]]$Keywords,

        [switch]$IncludeDefenderXDR,

        [switch]$IncludeDetectionAnalyzer,

        [ValidateRange(1, 365)]
        [int]$DetectionLookbackDays = 90,

        [ValidateRange(1, 365)]
        [int]$DaysBack = 90,

        [Alias('ppgb')]
        [ValidateRange(0.01, 100)]
        [decimal]$PricePerGB = 5.59,

        [ValidateScript({ Test-Path $_ -PathType Leaf })]
        [Alias('clf')]
        [string]$CustomClassificationPath
    )

    $ErrorActionPreference = 'Stop'
    $sw = [System.Diagnostics.Stopwatch]::StartNew()

    # Phase 1 - Data collection
    $collectResult = Invoke-SpectreCommandWithStatus -Title "[deepskyblue1]Collecting data...[/]" -Spinner Dots -ScriptBlock {
        $result = [ordered]@{}

        # Connect
        $ctx = Connect-Sentinel -SubscriptionId $SubscriptionId `
                                -ResourceGroup $ResourceGroup `
                                -WorkspaceName $WorkspaceName `
                                -WorkspaceId $WorkspaceId
        $result.Context = $ctx

        # Table usage
        $result.TableUsage = Get-TableUsage -Context $ctx -DaysBack $DaysBack -PricePerGB $PricePerGB

        # Analytics rules
        $result.RulesData = Get-AnalyticsRules -Context $ctx

        # Hunting queries
        $result.HuntingData = Get-HuntingQueries -Context $ctx

        # Data connectors
        $result.Connectors = Get-DataConnectors -Context $ctx

        # Defender XDR (optional)
        $result.DefenderXDR = $null
        if ($IncludeDefenderXDR) {
            try { $result.DefenderXDR = Get-DefenderXDR -Context $ctx } catch { }
        }

        # Detection analyzer inputs (optional)
        $result.Incidents = @()
        $result.AutomationRules = @()
        $result.AutoCloseHealth = $null
        if ($IncludeDetectionAnalyzer) {
            try { $result.Incidents = Get-Incidents -Context $ctx -DaysBack $DetectionLookbackDays } catch { }
            try { $result.AutomationRules = Get-AutomationRules -Context $ctx } catch { }
            try {
                $closeRuleNames = @($result.AutomationRules | Where-Object { $_.IsCloseIncidentRule -and $_.Enabled } | ForEach-Object { $_.DisplayName })
                $result.AutoCloseHealth = Get-AutoCloseFromHealth -Context $ctx -DaysBack $DetectionLookbackDays -CloseRuleNames $closeRuleNames
            } catch { }
        }

        # SOC Optimization
        $result.SocRecs = Get-SocOptimization -Context $ctx

        # Table retention configuration
        $result.TableRetention = Get-TableRetention -Context $ctx

        # Data transforms (DCR-based)
        $result.DataTransforms = Get-DataTransforms -Context $ctx

        [PSCustomObject]$result
    }

    $ctx         = $collectResult.Context
    $tableUsage  = $collectResult.TableUsage
    $rulesData   = $collectResult.RulesData
    $huntingData = $collectResult.HuntingData
    $defenderXDR = $collectResult.DefenderXDR
    $socRecs     = $collectResult.SocRecs
    $tableRetentionResult = $collectResult.TableRetention
    $tableRetention = $tableRetentionResult.Tables
    $workspaceRetentionDays = $tableRetentionResult.WorkspaceRetentionDays
    $dataTransforms = $collectResult.DataTransforms
    $incidents = $collectResult.Incidents
    $automationRules = $collectResult.AutomationRules
    $autoCloseHealth = $collectResult.AutoCloseHealth

    # Phase 2 - Classification
    $classifications = Invoke-SpectreCommandWithStatus -Title "[deepskyblue1]Classifying log sources...[/]" -Spinner Dots -ScriptBlock {
        Invoke-Classification -TableUsage $tableUsage `
                              -RuleTableCoverage $rulesData.TableCoverage `
                              -Keywords $Keywords `
                              -CustomClassificationPath $CustomClassificationPath
    }

    # Load high-value-fields knowledge base
    $hvFieldsPath = Join-Path $PSScriptRoot '..\Data\high-value-fields.json'
    $highValueFields = @{}
    if (Test-Path $hvFieldsPath) {
        $hvRaw = Get-Content $hvFieldsPath -Raw | ConvertFrom-Json
        foreach ($prop in $hvRaw.PSObject.Properties) {
            $highValueFields[$prop.Name] = $prop.Value
        }
    }

    # Load field-frequency-stats knowledge base (generated by Build-FieldKnowledgeBase.ps1)
    $ffStatsPath = Join-Path $PSScriptRoot '..\Data\field-frequency-stats.json'
    $fieldFrequencyStats = @{}
    if (Test-Path $ffStatsPath) {
        $ffRaw = Get-Content $ffStatsPath -Raw | ConvertFrom-Json
        # Convert to hashtable for easy lookup
        $fieldFrequencyStats = @{
            universalFields  = @($ffRaw.universalFields)
            categoryDefaults = @{}
            perTable         = @{}
        }
        if ($ffRaw.categoryDefaults) {
            foreach ($prop in $ffRaw.categoryDefaults.PSObject.Properties) {
                $fieldFrequencyStats.categoryDefaults[$prop.Name] = @($prop.Value)
            }
        }
        if ($ffRaw.perTable) {
            foreach ($prop in $ffRaw.perTable.PSObject.Properties) {
                $fieldFrequencyStats.perTable[$prop.Name] = $prop.Value
            }
        }
    }

    # Phase 3 - Analysis
    $analysis = Invoke-SpectreCommandWithStatus -Title "[deepskyblue1]Computing cost-value analysis...[/]" -Spinner Dots -ScriptBlock {
        Invoke-Analysis -TableUsage $tableUsage `
                        -Classifications $classifications `
                        -RulesData $rulesData `
                        -HuntingData $huntingData `
                        -DefenderXDR $defenderXDR `
                        -SocRecommendations $socRecs `
                        -TableRetention $tableRetention `
                        -WorkspaceRetentionDays $workspaceRetentionDays `
                        -PricePerGB $PricePerGB `
                        -DataTransforms $dataTransforms `
                        -HighValueFields $highValueFields `
                        -FieldFrequencyStats $fieldFrequencyStats `
                        -Incidents $incidents `
                        -AutomationRules $automationRules `
                        -AutoCloseHealthData $autoCloseHealth `
                        -IncludeDetectionAnalyzer:$IncludeDetectionAnalyzer
    }

    $sw.Stop()
    Write-SpectreHost "[green]Analysis complete in $([math]::Round($sw.Elapsed.TotalSeconds, 1))s[/]"
    Write-SpectreHost ""

    # Phase 4 - Output
    if ($NonInteractive) {
        if ($Output) {
            if (-not $OutputPath) {
                $OutputPath = $PWD.Path
            }
            Write-SpectreHost "[deepskyblue1]Non-interactive mode: exporting directly to $OutputPath[/]"
            Export-Report -Analysis $analysis `
                          -Format $Output `
                          -OutputPath $OutputPath `
                          -WorkspaceName $ctx.WorkspaceName `
                          -DefenderXDR $defenderXDR
        } else {
            Write-Warning "NonInteractive switch was provided but -Output was omitted. Returning data to pipeline."
            $analysis
        }
    } else {
        Write-Report -Analysis $analysis `
                     -WorkspaceName $ctx.WorkspaceName `
                     -DefenderXDR $defenderXDR `
                     -ExportFormat $Output `
                     -ExportPath $OutputPath
    }

    # Phase 5 - Cleanup
    if ($null -ne $ctx) {
        $ctx.ArmToken = $null
        $ctx.LaToken = $null
    }
}