modules/EventLogs/EventLogs.psm1

<#
    EventLogs.psm1 - event log summary/samples and audit policy configuration (read-only).
    Never clears logs. Produces: EventLogSummary, EventLogSamples, AuditPolicySettings,
    AuditPolicyGaps. Optional full export with -FullEventLogExport.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName='EventLogs'; DisplayName='Event Logs'; Category='System'; Version='1.0.0'
        DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false
        RequiresRole=$null; EstimatedImpact='Low'; CanRunAsSystem=$true
        ProducesDatasets=@('EventLogSummary','EventLogSamples','AuditPolicySettings','AuditPolicyGaps')
        ProducesRisks=$true; ProducesFollowUpQuestions=$false; SupportsDeepMode=$true; SupportsComplianceLens=$true
    }
}

# Advanced audit subcategories worth flagging specifically when set to "No Auditing" - not
# exhaustive (auditpol reports ~60 subcategories total), just the ones most directly tied to
# "can we tell who logged on, who was added to an admin group, or who changed the audit policy
# itself" - the baseline questions an incident investigation or compliance review asks first.
# Matched by exact name as auditpol.exe itself prints it (confirmed live against a real
# Server 2025 box's `auditpol /get /category:*` output).
$script:CriticalAuditSubcategories = @(
    'Logon', 'Account Lockout', 'Special Logon',
    'Security State Change', 'Audit Policy Change', 'Authentication Policy Change',
    'Sensitive Privilege Use',
    'User Account Management', 'Security Group Management', 'Computer Account Management'
)

function Get-AuditPolicySettings {
    <#
        Parses `auditpol /get /category:*` into one row per subcategory. Real output has
        category headers at column 0 ("Logon/Logoff") followed by subcategory lines indented
        two spaces (" Logon Success and Failure") - the indentation, not
        the text, is what tells them apart, confirmed against real output on a live Server
        2025 box (the two header lines "System audit policy" and "Category/Subcategory ...
        Setting" also start at column 0, so they're explicitly excluded from being read as a
        category name).
    #>

    $rows = [System.Collections.Generic.List[object]]::new()
    if (-not (Get-CommandAvailable -Name 'auditpol.exe')) { return ,@($rows) }
    try {
        $r = Invoke-CommandLineSafe -FilePath 'auditpol.exe' -Arguments @('/get', '/category:*') -TimeoutSeconds 30
        if (-not $r.Succeeded -or -not $r.StdOut) { return ,@($rows) }
        $category = ''
        foreach ($ln in ($r.StdOut -split "`r?`n")) {
            if ([string]::IsNullOrWhiteSpace($ln)) { continue }
            if ($ln -match '^\s{2,}(.+?)\s{2,}(No Auditing|Success and Failure|Success|Failure)\s*$') {
                $rows.Add([pscustomobject]@{ Category = $category; Subcategory = $Matches[1].Trim(); Setting = $Matches[2] })
            } elseif ($ln -notmatch 'audit policy' -and $ln -notmatch 'Category/Subcategory') {
                $category = $ln.Trim()
            }
        }
    } catch { }
    return ,@($rows)
}

function Test-DiscoveryPrerequisites {
    param([object]$Context)
    [pscustomobject]@{ ModuleName='EventLogs'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() }
}

function Invoke-DiscoveryCollection {
    param([object]$Context)
    $summary=[System.Collections.Generic.List[object]]::new(); $samples=[System.Collections.Generic.List[object]]::new()
    $days = [int]$Context.Parameters['EventLogDays']; if ($days -le 0) { $days = 14 }
    $maxSamples = [int]$Context.Parameters['MaxEventSamplesPerLog']; if ($maxSamples -le 0) { $maxSamples = 50 }
    if ($Context.Mode -eq 'Fast' -and $maxSamples -gt 25) { $maxSamples = 25 }
    $since = (Get-Date).AddDays(-$days)
    $deep = ($Context.Mode -eq 'Deep')

    $logs = @('System','Application')
    # 'Security' requires elevation to read (Get-WinEvent throws Access Denied otherwise) - it
    # used to never be attempted at all despite a limitation message below specifically for it
    # failing, which meant that message could never actually fire and the single most
    # compliance-relevant log on the box went untouched regardless of whether this ran
    # elevated. Gating on IsAdmin fixes both: the message is now reachable, and an elevated run
    # actually samples it.
    if ($Context.IsAdmin) { $logs += 'Security' }
    foreach ($opt in @('DNS Server','Directory Service','DFS Replication','Microsoft-Windows-Hyper-V-VMMS-Admin','Microsoft-Windows-Dhcp-Server/Operational')) {
        try { if (Get-WinEvent -ListLog $opt -ErrorAction SilentlyContinue) { $logs += $opt } } catch { }
    }

    # Each log is queried for ONLY the events reported on - critical/error/warning (levels 1-3), or
    # for Security, audit failures (Level 0 + the AuditFailure keyword; Security has essentially no
    # level 1-3 events) - newest first, capped at $maxQueryEvents. Pulling every event in the window
    # instead blew the 600s module deadline on a lab DC with only 180k Security records (losing
    # this module's data, audit policy included); a busy client DC has millions. Filtered, the same
    # DC takes ~5s total.
    $maxQueryEvents = 20000
    $auditFailureKeyword = 4503599627370496
    $recordsInLog = @{}
    try { foreach ($l in @(Get-WinEvent -ListLog $logs -ErrorAction SilentlyContinue)) { if ($l.LogName) { $recordsInLog[$l.LogName] = $l.RecordCount } } } catch { }
    $sampleCount = if ($deep) { $maxSamples } else { [math]::Min(5, $maxSamples) }

    foreach ($log in $logs) {
        $isSecurity = ($log -eq 'Security')
        $filter = if ($isSecurity) { @{ LogName=$log; StartTime=$since; Keywords=$auditFailureKeyword } } else { @{ LogName=$log; StartTime=$since; Level=1,2,3 } }
        $events = @()
        try {
            $events = @(Get-WinEvent -FilterHashtable $filter -MaxEvents $maxQueryEvents -ErrorAction Stop)
        } catch {
            # A filtered query matching nothing throws NoMatchingEventsFound - that's a clean zero, not a failure.
            if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') {
                if ($isSecurity) { Add-Limitation -Context $Context -Module 'EventLogs' -Message 'Security log query failed even though this run was elevated.' -Reason $_.Exception.Message | Out-Null }
                else { Write-Log -Level DEBUG -Message ("Event log '{0}' query skipped: {1}" -f $log, $_.Exception.Message) -Module 'EventLogs' -Context $Context }
                continue
            }
        }
        $flagged = @(if ($isSecurity) { $events } else { $events | Where-Object { $_.Level -in @(1,2) } })
        $topRec = (($flagged | Group-Object Id | Sort-Object Count -Descending | Select-Object -First 5 | ForEach-Object { "Id $($_.Name) x$($_.Count)" }) -join '; ')
        $summary.Add([pscustomobject]@{
            LogName=$log; DaysWindow=$days; RecordsInLog=$recordsInLog[$log]
            CriticalCount     = if ($isSecurity) { $null } else { @($events | Where-Object { $_.Level -eq 1 }).Count }
            ErrorCount        = if ($isSecurity) { $null } else { @($events | Where-Object { $_.Level -eq 2 }).Count }
            WarningCount      = if ($isSecurity) { $null } else { @($events | Where-Object { $_.Level -eq 3 }).Count }
            AuditFailureCount = if ($isSecurity) { $events.Count } else { $null }
            QueryCapped=($events.Count -ge $maxQueryEvents); TopRecurring=$topRec
        })
        foreach ($e in ($flagged | Select-Object -First $sampleCount)) {
            $msg = ''
            try { $msg = if ($e.Message) { $e.Message.Substring(0, [math]::Min(300, $e.Message.Length)) } else { '' } } catch { }
            $level = if ($isSecurity) { 'Audit Failure' } else { [string]$e.LevelDisplayName }
            $samples.Add([pscustomobject]@{ LogName=$log; TimeCreated=(Normalize-DateTime $e.TimeCreated); Id=$e.Id; Level=$level; ProviderName=$e.ProviderName; Message=(Redact-SensitiveValue -InputString $msg -Context $Context) })
        }
    }
    if (-not $Context.IsAdmin) { Add-Limitation -Context $Context -Module 'EventLogs' -Message 'Security log not sampled (requires elevation).' -Impact 'No audit-relevant event data' | Out-Null }

    # ---- Audit policy configuration ----
    $auditRows = Get-AuditPolicySettings
    $auditGaps = @($auditRows | Where-Object { $script:CriticalAuditSubcategories -contains $_.Subcategory -and $_.Setting -eq 'No Auditing' })
    if ($auditRows.Count -eq 0) { Add-Limitation -Context $Context -Module 'EventLogs' -Message 'Audit policy (auditpol) could not be read; audit configuration gaps cannot be assessed.' | Out-Null }

    # ---- Full export (opt-in only; exports copies, never clears) ----
    if ([bool]$Context.Parameters['FullEventLogExport']) {
        $rawDir = $Context.Paths['Raw']
        if ($rawDir -and (Get-CommandAvailable -Name 'wevtutil.exe')) {
            Add-Limitation -Context $Context -Module 'EventLogs' -Message 'Full event log export requested: raw\ output may be large.' -Impact 'Output size' | Out-Null
            foreach ($log in @('System','Application')) {
                try { Invoke-CommandLineSafe -FilePath 'wevtutil.exe' -Arguments @('epl', $log, ("`"" + (Join-Path $rawDir ("{0}.evtx" -f ($log -replace '[\\/]','_'))) + "`"")) -TimeoutSeconds 120 | Out-Null } catch { }
            }
        }
    }

    return ,@{ EventLogSummary=@($summary); EventLogSamples=@($samples); AuditPolicySettings=@($auditRows); AuditPolicyGaps=@($auditGaps) }
}

function ConvertTo-DiscoveryDatasets {
    param([object]$Context, $RawData)
    if (-not $RawData) { $RawData = @{} }
    $get = { param($k) if ($RawData[$k]) { @($RawData[$k]) } else { @() } }
    Add-DataSet -Context $Context -Name 'EventLogSummary'    -Description 'Event log critical/error/warning counts (audit failures for Security) and top recurring events.' -Rows (& $get 'EventLogSummary')    -Visibility 'Internal' -SourceModule 'EventLogs' | Out-Null
    Add-DataSet -Context $Context -Name 'EventLogSamples'    -Description 'Sample critical/error events (redacted).'                                 -Rows (& $get 'EventLogSamples')    -Visibility 'Internal' -SourceModule 'EventLogs' | Out-Null
    Add-DataSet -Context $Context -Name 'AuditPolicySettings' -Description 'Advanced audit policy (auditpol) - every subcategory and its setting.'    -Rows (& $get 'AuditPolicySettings') -Visibility 'Internal' -SourceModule 'EventLogs' | Out-Null
    Add-DataSet -Context $Context -Name 'AuditPolicyGaps'     -Description 'Critical audit subcategories set to No Auditing.'                          -Rows (& $get 'AuditPolicyGaps')     -Visibility 'Internal' -SourceModule 'EventLogs' | Out-Null
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','Get-AuditPolicySettings'