security.ps1

function Get-DefenderThreatDetection {
  <#
    .SYNOPSIS
      Retrieves Microsoft Defender threat detections, optionally filtered by date.
    .DESCRIPTION
      Wraps Get-MpThreatDetection. -Date sets a cutoff -- only detections with an
      InitialDetectionTime on or after that point are returned. -OutputPath and
      -OutputFormat control whether results are printed to the terminal or written
      to a file (TXT or JSON).
    .PARAMETER Date
      Cutoff date for detections. Accepts any value that Get-Date can parse
      (string, DateTime, etc.). Defaults to right now.
    .PARAMETER OutputPath
      File path to write results to. When omitted, results are printed to the
      terminal.
    .PARAMETER OutputFormat
      Output format: TXT (Formatted-List) or JSON. Defaults to TXT.
    .PARAMETER IncludeURLs
      Augment each detection with a ThreatDescriptionURL property pointing to the
      official Microsoft threat encyclopedia entry.
    .EXAMPLE
      Get-DefenderThreatDetection
      Prints all threat detections to the terminal.
    .EXAMPLE
      Get-DefenderThreatDetection -Date '2026-04-01' -OutputPath '.\detections.json' -OutputFormat JSON
      Writes detections since April 1st 2026 as JSON.
    .EXAMPLE
      Get-DefenderThreatDetection -IncludeURLs -OutputPath '.\detections.json' -OutputFormat JSON
      Writes detections as JSON, each augmented with a ThreatDescriptionURL.
  #>

  [CmdletBinding()]
  param (
    [Parameter(Mandatory = $false)]
    [object]
    $Date = (Get-Date),

    [Parameter(Mandatory = $false)]
    [string]
    $OutputPath,

    [Parameter(Mandatory = $false)]
    [ValidateSet('TXT', 'JSON')]
    [string]
    $OutputFormat = 'TXT',

    [Parameter(Mandatory = $false)]
    [switch]
    $IncludeURLs
  )

  $cutoffDate = if ($Date -is [datetime]) { $Date } else { Get-Date $Date }
  Write-Log -Message "Filtering Defender threat detections since $($cutoffDate.ToString('yyyy-MM-dd HH:mm:ss'))" -Color Yellow

  $detections = Get-MpThreatDetection | Where-Object { $_.InitialDetectionTime -ge $cutoffDate }

  if ($IncludeURLs -and $detections.Count -gt 0) {
    $detections = $detections | ForEach-Object {
      $_urlName = if ($_.PSObject.Properties.Name -contains 'ThreatName') { $_.ThreatName } else { $_.Name }
      $_ | Add-Member -NotePropertyName 'ThreatDescriptionURL' -NotePropertyValue (Get-DefenderThreatDescriptionURL -ThreatName $_urlName) -PassThru
    }
    Write-Log -Message ' -> ThreatDescriptionURL(s) appended' -Color Gray
  }
  Write-Log -Message " -> $($detections.Count) detection(s) found" -Color Gray

  if ($PSBoundParameters.ContainsKey('OutputPath') -and -not [string]::IsNullOrWhiteSpace($OutputPath)) {
    $_outPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
    switch ($OutputFormat) {
      'JSON' {
        $detections | ConvertTo-Json -Depth 3 | Out-File -FilePath $_outPath -Encoding utf8
      }
      'TXT' {
        $detections | Format-List * | Out-String -Width 4096 | Out-File -FilePath $_outPath -Encoding utf8
      }
    }
    Write-Log -Message " -> Written to: $_outPath" -Color Green
  }
  else {
    $detections | Format-List *
  }
}

function Get-DefenderThreat {
  <#
    .SYNOPSIS
      Retrieves the full Microsoft Defender threat catalog.
    .DESCRIPTION
      Wraps Get-MpThreat. -OutputPath and -OutputFormat control whether results
      are printed to the terminal or written to a file (TXT or JSON).
    .PARAMETER OutputPath
      File path to write results to. When omitted, results are printed to the
      terminal.
    .PARAMETER OutputFormat
      Output format: TXT (Formatted-List) or JSON. Defaults to TXT.
    .PARAMETER IncludeURLs
      Augment each threat with a ThreatDescriptionURL property pointing to the
      official Microsoft threat encyclopedia entry.
    .EXAMPLE
      Get-DefenderThreat
      Prints the threat catalog to the terminal.
    .EXAMPLE
      Get-DefenderThreat -OutputPath '.\threats.json' -OutputFormat JSON
      Writes the threat catalog as JSON.
    .EXAMPLE
      Get-DefenderThreat -IncludeURLs -OutputFormat JSON
      Prints the threat catalog as JSON, each augmented with a ThreatDescriptionURL.
  #>

  [CmdletBinding()]
  param (
    [Parameter(Mandatory = $false)]
    [string]
    $OutputPath,

    [Parameter(Mandatory = $false)]
    [ValidateSet('TXT', 'JSON')]
    [string]
    $OutputFormat = 'TXT',

    [Parameter(Mandatory = $false)]
    [switch]
    $IncludeURLs
  )

  Write-Log -Message 'Retrieving Microsoft Defender threat catalog' -Color Yellow

  $threats = Get-MpThreat

  if ($IncludeURLs -and $threats.Count -gt 0) {
    $threats = $threats | ForEach-Object {
      $_urlName = if ($_.PSObject.Properties.Name -contains 'ThreatName') { $_.ThreatName } else { $_.Name }
      $_ | Add-Member -NotePropertyName 'ThreatDescriptionURL' -NotePropertyValue (Get-DefenderThreatDescriptionURL -ThreatName $_urlName) -PassThru
    }
    Write-Log -Message ' -> ThreatDescriptionURL(s) appended' -Color Gray
  }
  Write-Log -Message " -> $($threats.Count) threat(s) found" -Color Gray

  if ($PSBoundParameters.ContainsKey('OutputPath') -and -not [string]::IsNullOrWhiteSpace($OutputPath)) {
    $_outPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
    switch ($OutputFormat) {
      'JSON' {
        $threats | ConvertTo-Json -Depth 3 | Out-File -FilePath $_outPath -Encoding utf8
      }
      'TXT' {
        $threats | Format-List * | Out-String -Width 4096 | Out-File -FilePath $_outPath -Encoding utf8
      }
    }
    Write-Log -Message " -> Written to: $_outPath" -Color Green
  }
  else {
    $threats | Format-List *
  }
}

function Get-DefenderThreatDescriptionURL {
  <#
    .SYNOPSIS
      Builds the public Microsoft Defender threat description URL for a given
      threat family name.
    .DESCRIPTION
      Takes a threat name (e.g. 'Trojan:Win32/Emotet'), URL-encodes it, and
      returns the full HTTP/S link to the official WDSI (Windows Defender
      Security Intelligence) threat encyclopedia entry.
    .PARAMETER ThreatName
      The human-readable threat family name, exactly as reported by
      Get-MpThreat (Name property) or Get-MpThreatDetection (ThreatName).
    .EXAMPLE
      Get-DefenderThreatDescriptionURL -ThreatName 'Trojan:Win32/Emotet'
      https://www.microsoft.com/en-us/wdsi/threats/threat/Trojan%3AWin32%2FEmotet
  #>

  [CmdletBinding()]
  [OutputType([string])]
  param (
    [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
    [string]
    $ThreatName
  )

  process {
    $encoded = [System.Web.HttpUtility]::UrlEncode($ThreatName)
    return "https://www.microsoft.com/en-us/wdsi/threats/threat/$encoded"
  }
}

function Add-DefenderExclusion {
  <#
    .SYNOPSIS
      Adds a Microsoft Defender exclusion.
    .DESCRIPTION
      Wraps Add-MpPreference for path, extension, and process exclusions and
      returns a structured operation result instead of writing ad hoc console
      output. The helper supports ShouldProcess so scripts can use -WhatIf and
      -DryRun consistently.
    .PARAMETER Type
      Exclusion kind: Path, Extension, or Process.
    .PARAMETER Value
      Exclusion value to add.
    .EXAMPLE
      PS> Add-DefenderExclusion -Type Process -Value 'wsl.exe'
    .LINK
      https://github.com/adnoctem/winkit/lib/security.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([PSCustomObject])]
  [CmdletBinding(SupportsShouldProcess = $true)]
  param (
    [Parameter(Mandatory = $true)]
    [ValidateSet('Path', 'Extension', 'Process')]
    [string]
    $Type,

    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string]
    $Value
  )

  $_target = "$Type`: $Value"
  if (-not $PSCmdlet.ShouldProcess($_target, 'Add Microsoft Defender exclusion')) {
    return New-OperationResult -Target $_target -Source 'Defender' -Action 'AddExclusion' -Status 'Skipped' -Detail 'WhatIf'
  }

  try {
    $_parameters = @{
      ErrorAction = 'Stop'
    }

    switch ($Type) {
      'Path' { $_parameters.ExclusionPath = $Value }
      'Extension' { $_parameters.ExclusionExtension = $Value }
      'Process' { $_parameters.ExclusionProcess = $Value }
    }

    Add-MpPreference @_parameters
    New-OperationResult -Target $_target -Source 'Defender' -Action 'AddExclusion' -Status 'Completed' -Detail $Value
  }
  catch {
    New-OperationResult -Target $_target -Source 'Defender' -Action 'AddExclusion' -Status 'Failed' -Detail $_.Exception.Message
  }
}

function Enable-WSLFirewallRule {
  <#
    .SYNOPSIS
      Ensures the inbound WSL firewall allow rule exists.
    .DESCRIPTION
      Adds a Windows Firewall rule allowing inbound traffic on the WSL virtual
      Ethernet adapter. Existing rules with the same display name are treated as
      already configured to avoid creating duplicates.
    .PARAMETER DisplayName
      Firewall display name. Defaults to WSL.
    .PARAMETER InterfaceAlias
      Network interface alias for the WSL virtual switch.
    .EXAMPLE
      PS> Enable-WSLFirewallRule
    .LINK
      https://github.com/adnoctem/winkit/lib/security.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([PSCustomObject])]
  [CmdletBinding(SupportsShouldProcess = $true)]
  param (
    [string]
    $DisplayName = 'WSL',

    [string]
    $InterfaceAlias = 'vEthernet (WSL)'
  )

  try {
    $_existing = @(Get-NetFirewallRule -DisplayName $DisplayName -ErrorAction SilentlyContinue)
    if ($_existing.Count -gt 0) {
      return New-OperationResult -Target $DisplayName -Source 'Firewall' -Action 'AllowInbound' -Status 'Skipped' -Detail 'AlreadyExists'
    }

    if (-not $PSCmdlet.ShouldProcess($DisplayName, "Allow inbound traffic on $InterfaceAlias")) {
      return New-OperationResult -Target $DisplayName -Source 'Firewall' -Action 'AllowInbound' -Status 'Skipped' -Detail 'WhatIf'
    }

    $null = New-NetFirewallRule -DisplayName $DisplayName -Direction Inbound -InterfaceAlias $InterfaceAlias -Action Allow -ErrorAction Stop
    New-OperationResult -Target $DisplayName -Source 'Firewall' -Action 'AllowInbound' -Status 'Completed' -Detail $InterfaceAlias
  }
  catch {
    New-OperationResult -Target $DisplayName -Source 'Firewall' -Action 'AllowInbound' -Status 'Failed' -Detail $_.Exception.Message
  }
}

function Disable-JetBrainsFirewallRule {
  <#
    .SYNOPSIS
      Disables public-profile JetBrains IDE firewall rules.
    .DESCRIPTION
      Finds firewall rules attached to the Public profile whose display names
      begin with known JetBrains IDE names and disables them. This supports the
      JetBrains WSL debugging workaround while returning structured operation
      results for every matching rule.
    .PARAMETER Prefix
      Display-name prefixes to match. Defaults to known JetBrains IDE product
      names.
    .EXAMPLE
      PS> Disable-JetBrainsFirewallRule
    .LINK
      https://github.com/adnoctem/winkit/lib/security.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([PSCustomObject[]])]
  [CmdletBinding(SupportsShouldProcess = $true)]
  param (
    [string[]]
    $Prefix = @('PhpStorm', 'IntelliJ', 'PyCharm', 'RubyMine', 'WebStorm', 'DataGrip', 'GoLand', 'Rider')
  )

  $_results = New-Object System.Collections.ArrayList

  if ($WhatIfPreference) {
    foreach ($_prefix in $Prefix) {
      Add-OperationResult -Results $_results -Target "$_prefix*" -Source 'Firewall' -Action 'Disable' -Status 'Skipped' -Detail 'WhatIf'
    }

    return $_results
  }

  try {
    $_publicProfile = Get-NetFirewallProfile -Name Public -ErrorAction Stop
    $_rules = @($_publicProfile | Get-NetFirewallRule -ErrorAction Stop | Where-Object {
        $_rule = $_
        @($Prefix | Where-Object { $_rule.DisplayName -like "$_*" }).Count -gt 0
      })

    if ($_rules.Count -eq 0) {
      Add-OperationResult -Results $_results -Target 'JetBrainsPublicFirewallRules' -Source 'Firewall' -Action 'Disable' -Status 'Skipped' -Detail 'NoMatch'
      return $_results
    }

    foreach ($_rule in $_rules) {
      if (-not $PSCmdlet.ShouldProcess($_rule.DisplayName, 'Disable public firewall rule')) {
        Add-OperationResult -Results $_results -Target $_rule.DisplayName -Source 'Firewall' -Action 'Disable' -Status 'Skipped' -Detail 'WhatIf'
        continue
      }

      try {
        $null = $_rule | Disable-NetFirewallRule -ErrorAction Stop
        Add-OperationResult -Results $_results -Target $_rule.DisplayName -Source 'Firewall' -Action 'Disable' -Status 'Completed' -Detail 'Public profile rule disabled.'
      }
      catch {
        Add-OperationResult -Results $_results -Target $_rule.DisplayName -Source 'Firewall' -Action 'Disable' -Status 'Failed' -Detail $_.Exception.Message
      }
    }
  }
  catch {
    Add-OperationResult -Results $_results -Target 'JetBrainsPublicFirewallRules' -Source 'Firewall' -Action 'Disable' -Status 'Failed' -Detail $_.Exception.Message
  }

  $_results
}

function Find-NewlyWrittenObject {
  <#
    .SYNOPSIS
      Finds files written near a point in time (e.g. around a Defender detection).
    .DESCRIPTION
      Recursively scans C:\ (or a custom path) for files whose LastWriteTime falls
      within a configurable window around the supplied -Date. Designed to help
      identify artifacts dropped by malware at the time of a Defender alert.
      Results can be printed to the terminal or exported as TXT / JSON.
    .PARAMETER Date
      Anchor date/time. Accepts any value that Get-Date can parse (string,
      DateTime, etc.). Defaults to right now.
    .PARAMETER Before
      Number of hours before the anchor date to include. Defaults to 2.
    .PARAMETER After
      Number of hours after the anchor date to include. Defaults to 1.
    .PARAMETER Path
      Root path to search. Defaults to the system drive (C:\).
    .PARAMETER OutputPath
      File path to write results to. When omitted, results are printed to the
      terminal.
    .PARAMETER OutputFormat
      Output format: TXT (Formatted custom table) or JSON. Defaults to TXT.
    .EXAMPLE
      Find-NewlyWrittenObject -Date '2026-04-30 10:15'
      Searches for files written between 08:15 and 11:15 on 2026-04-30.
    .EXAMPLE
      Find-NewlyWrittenObject -Date '2026-04-30' -Before 4 -After 2 -OutputPath '.\artifacts.json' -OutputFormat JSON
      Wider window, exported as JSON.
  #>

  [CmdletBinding()]
  param (
    [Parameter(Mandatory = $false)]
    [object]
    $Date = (Get-Date),

    [Parameter(Mandatory = $false)]
    [ValidateRange(0, 168)]
    [int]
    $Before = 2,

    [Parameter(Mandatory = $false)]
    [ValidateRange(0, 168)]
    [int]
    $After = 1,

    [Parameter(Mandatory = $false)]
    [string]
    $Path = "$env:SystemDrive\",

    [Parameter(Mandatory = $false)]
    [string]
    $OutputPath,

    [Parameter(Mandatory = $false)]
    [ValidateSet('TXT', 'JSON')]
    [string]
    $OutputFormat = 'TXT'
  )

  $anchorDate = if ($Date -is [datetime]) { $Date } else { Get-Date $Date }
  $windowStart = $anchorDate.AddHours(-$Before)
  $windowEnd = $anchorDate.AddHours($After)

  Write-Log -Message "Searching for files written between $($windowStart.ToString('yyyy-MM-dd HH:mm:ss')) and $($windowEnd.ToString('yyyy-MM-dd HH:mm:ss'))" -Color Yellow
  Write-Log -Message " Root path: $Path" -Color Gray

  $items = Get-ChildItem -LiteralPath $Path -Recurse -ErrorAction SilentlyContinue |
    Where-Object { -not $_.PSIsContainer -and $_.LastWriteTime -gt $windowStart -and $_.LastWriteTime -lt $windowEnd } |
    Sort-Object LastWriteTime |
    Select-Object LastWriteTime,
    LastWriteTimeUtc,
    LastAccessTime,
    LastAccessTimeUtc,
    CreationTime,
    CreationTimeUtc,
    Mode,
    IsReadOnly,
    Length,
    Extension,
    FullName

  Write-Log -Message " -> $($items.Count) file(s) found" -Color Gray

  if ($items.Count -eq 0) { return }

  if ($PSBoundParameters.ContainsKey('OutputPath') -and -not [string]::IsNullOrWhiteSpace($OutputPath)) {
    $_outPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
    switch ($OutputFormat) {
      'JSON' {
        $items | ConvertTo-Json -Depth 2 | Out-File -FilePath $_outPath -Encoding utf8
      }
      'TXT' {
        $items | Format-Table -AutoSize | Out-String -Width 4096 | Out-File -FilePath $_outPath -Encoding utf8
      }
    }
    Write-Log -Message " -> Written to: $_outPath" -Color Green
  }
  else {
    $items | Format-Table -AutoSize
  }
}

function Invoke-SafeProcess {
  <#
    .SYNOPSIS
      Runs an external executable and captures stdout/stderr to a file or the pipeline.
    .DESCRIPTION
      Uses System.Diagnostics.Process to invoke an executable with argument list
      and redirects standard output and standard error. The combined output is
      written to -OutputPath. Use -PassThru to return the output as a string
      instead of writing to disk.
 
      Designed for IR/forensics collection where external tools (reg.exe,
      wevtutil.exe, systeminfo.exe, etc.) need to be called safely and their
      output captured without risking interactive prompts or policy blocks.
    .PARAMETER FilePath
      Executable path (resolved from PATH when a bare name is supplied).
    .PARAMETER ArgumentList
      Array of arguments. Each element is one argument token.
    .PARAMETER OutputPath
      File to write combined stdout + stderr to. When omitted and -PassThru is
      not supplied, output is discarded.
    .PARAMETER PassThru
      Return stdout as a string. When combined with -OutputPath, output is
      both written to disk and returned.
    .EXAMPLE
      PS> Invoke-SafeProcess -FilePath 'whoami.exe' -ArgumentList @('/all') -OutputPath '.\whoami.txt'
    .EXAMPLE
      PS> Invoke-SafeProcess -FilePath 'systeminfo.exe' -PassThru
    .LINK
      https://github.com/adnoctem/winkit/lib/security.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  [OutputType([string])]
  param (
    [Parameter(Mandatory = $true)]
    [string]
    $FilePath,

    [Parameter(Mandatory = $false)]
    [string[]]
    $ArgumentList,

    [Parameter(Mandatory = $false)]
    [string]
    $OutputPath,

    [Parameter(Mandatory = $false)]
    [switch]
    $PassThru
  )

  try {
    $psi = New-Object System.Diagnostics.ProcessStartInfo
    $psi.FileName = $FilePath
    if ($ArgumentList) {
      foreach ($arg in $ArgumentList) {
        [void]$psi.ArgumentList.Add($arg)
      }
    }
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError = $true
    $psi.UseShellExecute = $false
    $psi.CreateNoWindow = $true

    $p = New-Object System.Diagnostics.Process
    $p.StartInfo = $psi
    [void]$p.Start()
    $stdout = $p.StandardOutput.ReadToEnd()
    $stderr = $p.StandardError.ReadToEnd()
    $p.WaitForExit()

    $content = @()
    if (-not [string]::IsNullOrWhiteSpace($stdout)) { $content += $stdout }
    if (-not [string]::IsNullOrWhiteSpace($stderr)) {
      $content += "`r`n--- STDERR ---`r`n$stderr"
    }
    $content += "`r`n--- EXITCODE: $($p.ExitCode) ---`r`n"

    $result = $content -join ''

    if ($OutputPath) {
      $result | Out-File -LiteralPath $OutputPath -Encoding UTF8
    }

    if ($PassThru) {
      return $result
    }
  }
  catch {
    $errorMessage = "ERROR running $FilePath $($ArgumentList -join ' '): $($_.Exception.Message)"
    if ($OutputPath) {
      $errorMessage | Out-File -LiteralPath $OutputPath -Encoding UTF8
    }
    if ($PassThru) {
      return $errorMessage
    }
    Write-Error $errorMessage
  }
}

function Export-EventLog {
  <#
    .SYNOPSIS
      Exports a named Windows event log to an .evtx file via wevtutil.
    .DESCRIPTION
      Wraps wevtutil.exe epl. If the log name does not exist, a warning
      is recorded in -MissingLogPath (when supplied) and no error is thrown.
      Designed for bulk log collection during IR triage.
    .PARAMETER LogName
      Full event log name, e.g. 'Security', 'Microsoft-Windows-PowerShell/Operational'.
    .PARAMETER OutputPath
      Path for the exported .evtx file.
    .PARAMETER MissingLogPath
      When supplied and the log is not found, the missing log name is appended
      to this text file so collectors can report what was unavailable.
    .EXAMPLE
      PS> Export-EventLog -LogName 'Security' -OutputPath '.\EVTX\Security.evtx'
    .LINK
      https://github.com/adnoctem/winkit/lib/security.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  param (
    [Parameter(Mandatory = $true)]
    [string]
    $LogName,

    [Parameter(Mandatory = $true)]
    [string]
    $OutputPath,

    [Parameter(Mandatory = $false)]
    [string]
    $MissingLogPath
  )

  try {
    $result = Invoke-SafeProcess -FilePath 'wevtutil.exe' -ArgumentList @('el') -PassThru
    $exists = $result -split "`r`n" | Where-Object { $_ -eq $LogName }

    if (-not $exists) {
      if ($MissingLogPath) {
        "Log not present: $LogName" | Out-File -LiteralPath $MissingLogPath -Append -Encoding UTF8
      }
      Write-Verbose "Event log not found: $LogName"
      return $false
    }

    $null = Invoke-SafeProcess -FilePath 'wevtutil.exe' -ArgumentList @('epl', $LogName, $OutputPath)
    return $true
  }
  catch {
    Write-Error "Failed to export event log '$LogName': $_"
    return $false
  }
}

function Get-ScheduledTaskAction {
  <#
    .SYNOPSIS
      Returns structured scheduled task action data for all registered tasks.
    .DESCRIPTION
      Enumerates every scheduled task via Get-ScheduledTask and expands each
      task's Actions collection into a flat list of [PSCustomObject] records
      with TaskName, TaskPath, State, Execute, and Arguments properties.
      Use -SuspiciousOnly to filter to known execution-host paths (powershell,
      cmd, wscript, cscript, mshta, rundll32, regsvr32, InstallUtil).
    .PARAMETER SuspiciousOnly
      Only return actions with an Execute path matching common scripting and
      LOLBin hosts.
    .EXAMPLE
      PS> Get-ScheduledTaskAction | Export-Csv .\ScheduledTasks-Actions.csv -NoTypeInformation
    .EXAMPLE
      PS> Get-ScheduledTaskAction -SuspiciousOnly
    .LINK
      https://github.com/adnoctem/winkit/lib/security.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  [OutputType([PSCustomObject[]])]
  param (
    [Parameter(Mandatory = $false)]
    [switch]
    $SuspiciousOnly
  )

  $suspiciousHosts = 'powershell|pwsh|cmd|wscript|cscript|mshta|rundll32|regsvr32|InstallUtil'

  $rows = Get-ScheduledTask -ErrorAction SilentlyContinue |
    ForEach-Object {
      $task = $_
      foreach ($action in $task.Actions) {
        $obj = [PSCustomObject]@{
          TaskName = $task.TaskName
          TaskPath = $task.TaskPath
          State = $task.State
          Execute = $action.Execute
          Arguments = $action.Arguments
        }
        if ($SuspiciousOnly) {
          if ($obj.Execute -match $suspiciousHosts) { $obj }
        }
        else {
          $obj
        }
      }
    }

  $rows | Sort-Object TaskPath, TaskName
}

function Get-WMIPersistence {
  <#
    .SYNOPSIS
      Enumerates WMI subscription-based persistence.
    .DESCRIPTION
      Queries the root\subscription namespace for __EventFilter,
      CommandLineEventConsumer, and __FilterToConsumerBinding instances.
      Returns an object with three properties: EventFilters, CommandLineConsumers,
      and Bindings, each an array of the corresponding WMI objects.
      Returns $null when no subscriptions exist.
    .EXAMPLE
      PS> $wmi = Get-WMIPersistence
      PS> $wmi.EventFilters | Format-List
    .LINK
      https://github.com/adnoctem/winkit/lib/security.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  [OutputType([PSCustomObject])]
  param()

  [PSCustomObject]@{
    EventFilters = @(Get-CimInstance -Namespace root\subscription -ClassName __EventFilter -ErrorAction SilentlyContinue)
    CommandLineConsumers = @(Get-CimInstance -Namespace root\subscription -ClassName CommandLineEventConsumer -ErrorAction SilentlyContinue)
    Bindings = @(Get-CimInstance -Namespace root\subscription -ClassName __FilterToConsumerBinding -ErrorAction SilentlyContinue)
  }
}

# ==============================================================================
# Windows Security Event API
# ==============================================================================

function Import-SecurityEventConfiguration {
  <#
    .SYNOPSIS
      Loads and caches the security event configuration from security.psd1.
    .DESCRIPTION
      Imports the security event definition data file and caches it in script
      scope. Subsequent calls return the cached configuration unless -Force is
      supplied, avoiding repeated file reads.
    .PARAMETER Path
      Path to the security.psd1 configuration file. Defaults to the file
      alongside this script.
    .PARAMETER Force
      Reload the configuration even if it is already cached.
    .EXAMPLE
      PS> $config = Import-SecurityEventConfiguration
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [string] $Path = (Join-Path -Path $PSScriptRoot -ChildPath 'security.psd1'),

    [Parameter(Mandatory = $false)]
    [switch] $Force
  )

  $cached = try { $script:SecurityEventConfiguration } catch { $null }
  if ($cached -and -not $Force) {
    return $cached
  }

  if (-not (Test-Path -LiteralPath $Path)) {
    throw "Security event configuration file not found: $Path"
  }

  $script:SecurityEventConfiguration = Import-PowerShellDataFile -Path $Path
  return $script:SecurityEventConfiguration
}

function Get-SecurityEventGroup {
  <#
    .SYNOPSIS
      Retrieves a single semantic event group from the configuration.
    .DESCRIPTION
      Looks up a named group (e.g. Logon, Service, PowerShell) from the
      cached configuration. Throws if the group is unknown.
    .PARAMETER Name
      Case-sensitive group name to retrieve.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to the cached configuration from
      Import-SecurityEventConfiguration.
    .EXAMPLE
      PS> $group = Get-SecurityEventGroup -Name 'Logon'
      PS> $group.EventIds
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string] $Name,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  if (-not $Configuration.Groups.ContainsKey($Name)) {
    throw "Unknown security event group: $Name"
  }

  return $Configuration.Groups[$Name]
}

function Get-SecurityEventDefinition {
  <#
    .SYNOPSIS
      Retrieves event definition objects from the configuration.
    .DESCRIPTION
      Flattens the nested Events structure into a list of event definition
      hashtables, optionally filtered by Group, LogName, ProviderName, Id,
      or Name.
    .PARAMETER Group
      Limit results to events belonging to this semantic group.
    .PARAMETER LogName
      Limit results to events from this event log channel.
    .PARAMETER ProviderName
      Limit results to events from this provider.
    .PARAMETER Id
      Limit results to events with these event IDs.
    .PARAMETER Name
      Limit results to events with this logical name.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to cached.
    .EXAMPLE
      PS> Get-SecurityEventDefinition -Id 4624, 4625
    .EXAMPLE
      PS> Get-SecurityEventDefinition -Group 'Logon' -LogName 'Security'
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [string] $Group,

    [Parameter(Mandatory = $false)]
    [string] $LogName,

    [Parameter(Mandatory = $false)]
    [string] $ProviderName,

    [Parameter(Mandatory = $false)]
    [int[]] $Id,

    [Parameter(Mandatory = $false)]
    [string] $Name,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  $results = New-Object System.Collections.ArrayList

  foreach ($logKey in $Configuration.Events.Keys) {
    foreach ($subKey in $Configuration.Events[$logKey].Keys) {
      foreach ($def in $Configuration.Events[$logKey][$subKey]) {
        if ($Group -and $def.UtilityGroup -ne $Group) { continue }
        if ($LogName -and $def.LogName -ne $LogName) { continue }
        if ($ProviderName -and $def.ProviderName -ne $ProviderName) { continue }
        if ($Id -and $def.Id -notin $Id) { continue }
        if ($Name -and $def.Name -ne $Name) { continue }
        [void]$results.Add($def)
      }
    }
  }

  return $results
}

function Test-WindowsEventLogChannel {
  <#
    .SYNOPSIS
      Tests whether an event log channel exists on the local machine.
    .DESCRIPTION
      Uses Get-WinEvent -ListLog to verify channel existence. Returns $true
      if the channel is available, $false otherwise. No errors are thrown
      for missing channels.
    .PARAMETER LogName
      Full event log channel name, e.g. 'Security' or
      'Microsoft-Windows-Sysmon/Operational'.
    .EXAMPLE
      PS> if (Test-WindowsEventLogChannel -LogName 'Microsoft-Windows-Sysmon/Operational') { ... }
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string] $LogName
  )

  try {
    $null = Get-WinEvent -ListLog $LogName -ErrorAction Stop
    return $true
  }
  catch {
    return $false
  }
}

function Get-WindowsEventByDefinition {
  <#
    .SYNOPSIS
      Generic event query helper wrapping Get-WinEvent -FilterHashtable.
    .DESCRIPTION
      Queries Windows event logs using filter-hashtable-based queries for
      performance. Accepts event definitions, a group name, or ID lists.
      Definitions are grouped by LogName to minimise individual queries.
      Supports remote computers and optional channel skipping.
    .PARAMETER Definition
      Array of event definition hashtables.
    .PARAMETER Group
      Semantic group name resolved from the configuration.
    .PARAMETER Id
      Event IDs to query (bypasses definition lookup).
    .PARAMETER LogName
      Event log channel(s) to query.
    .PARAMETER StartTime
      Earliest event timestamp. Defaults to 24 hours ago.
    .PARAMETER EndTime
      Latest event timestamp. Defaults to now.
    .PARAMETER ComputerName
      Target remote computer(s).
    .PARAMETER SkipMissingChannel
      Skip channels that do not exist rather than throwing.
    .PARAMETER MaxEvents
      Maximum events to return per log/channel query.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to cached.
    .EXAMPLE
      PS> Get-WindowsEventByDefinition -Group 'Logon' -StartTime (Get-Date).AddHours(-4)
    .EXAMPLE
      PS> Get-WindowsEventByDefinition -Id 4624, 4625 -LogName 'Security' -MaxEvents 100
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false, ValueFromPipeline = $true)]
    [hashtable[]] $Definition,

    [Parameter(Mandatory = $false)]
    [string] $Group,

    [Parameter(Mandatory = $false)]
    [int[]] $Id,

    [Parameter(Mandatory = $false)]
    [string[]] $LogName,

    [Parameter(Mandatory = $false)]
    [datetime] $StartTime = (Get-Date).AddDays(-1),

    [Parameter(Mandatory = $false)]
    [datetime] $EndTime = (Get-Date),

    [Parameter(Mandatory = $false)]
    [string[]] $ComputerName,

    [Parameter(Mandatory = $false)]
    [switch] $SkipMissingChannel,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, [int]::MaxValue)]
    [int] $MaxEvents,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  begin {
    $allDefs = New-Object System.Collections.ArrayList
  }

  process {
    if ($Definition) {
      [void]$allDefs.AddRange($Definition)
    }
  }

  end {
    if ($Group) {
      $groupDefinition = Get-SecurityEventGroup -Name $Group -Configuration $Configuration
      $resolvedDefs = @(Get-SecurityEventDefinition -Group $Group -Configuration $Configuration)

      if ($resolvedDefs.Count -eq 0 -and $groupDefinition.EventIds) {
        foreach ($log in $groupDefinition.DefaultLogs) {
          foreach ($eventId in $groupDefinition.EventIds) {
            [void]$allDefs.Add(@{ LogName = $log; Id = $eventId })
          }
        }
      }
      else {
        [void]$allDefs.AddRange($resolvedDefs)
      }
    }

    if ($Id) {
      if ($LogName) {
        foreach ($log in $LogName) {
          foreach ($eventId in $Id) {
            [void]$allDefs.Add(@{ LogName = $log; Id = $eventId })
          }
        }
      }
      else {
        foreach ($eventId in $Id) {
          [void]$allDefs.Add(@{ Id = $eventId })
        }
      }
    }

    if ($allDefs.Count -eq 0) {
      throw 'No event definitions supplied or resolved. Provide -Definition, -Group, or -Id.'
    }

    $allDefs |
      Group-Object LogName |
      ForEach-Object {
        $log = $_.Name
        $ids = $_.Group.Id | Sort-Object -Unique

        if ($SkipMissingChannel -and -not (Test-WindowsEventLogChannel -LogName $log)) {
          return
        }

        $filter = @{
          LogName = $log
          Id = $ids
          StartTime = $StartTime
          EndTime = $EndTime
        }

        if ($MaxEvents) {
          $filter.MaxEvents = $MaxEvents
        }

        try {
          if ($ComputerName) {
            foreach ($computer in $ComputerName) {
              Get-WinEvent -ComputerName $computer -FilterHashtable $filter -ErrorAction SilentlyContinue
            }
          }
          else {
            Get-WinEvent -FilterHashtable $filter -ErrorAction SilentlyContinue
          }
        }
        catch {
          if (-not $SkipMissingChannel) {
            Write-Error "Failed to query log '$log': $_"
          }
        }
      }
  }
}

function Resolve-WindowsEventMappedField {
  <#
    .SYNOPSIS
      Resolves a mapped field value to its semantic name.
    .DESCRIPTION
      Looks up a value in a named field map (e.g. LogonType, ImpersonationLevel)
      and returns the map entry. Returns $null when the map or value is not
      found.
    .PARAMETER MapName
      Name of the field map (e.g. 'LogonType').
    .PARAMETER Value
      Raw value to resolve. Handles both integer and string keys.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to cached.
    .EXAMPLE
      PS> Resolve-WindowsEventMappedField -MapName 'LogonType' -Value 10
      Returns a hashtable with Name = 'RemoteInteractive'.
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string] $MapName,

    [Parameter(Mandatory = $true)]
    [object] $Value,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  if (-not $Configuration.FieldMaps.ContainsKey($MapName)) {
    return $null
  }

  $map = $Configuration.FieldMaps[$MapName]

  if ($map.ContainsKey($Value)) {
    return $map[$Value]
  }

  $stringValue = [string] $Value
  if ($map.ContainsKey($stringValue)) {
    return $map[$stringValue]
  }

  return $null
}

function ConvertFrom-WinEvent {
  <#
    .SYNOPSIS
      Converts a raw EventRecord into a structured enriched object.
    .DESCRIPTION
      Parses the XML representation of an event record, extracts named
      EventData fields into a RawData hashtable, and produces a PSCustomObject
      with common top-level properties plus enriched mapped fields such as
      LogonTypeName. The original EventRecord is preserved on the object.
    .PARAMETER Event
      A [System.Diagnostics.Eventing.Reader.EventRecord] to convert.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to cached.
    .EXAMPLE
      PS> Get-WindowsEventByDefinition -Id 4624 -MaxEvents 1 | ConvertFrom-WinEvent
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
    [object] $Event,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  process {
    $xml = [xml] $Event.ToXml()

    $rawData = [ordered] @{}
    if ($xml.Event.EventData -and $xml.Event.EventData.Data) {
      foreach ($node in $xml.Event.EventData.Data) {
        if (-not [string]::IsNullOrWhiteSpace($node.Name)) {
          $rawData[$node.Name] = $node.'#text'
        }
      }
    }

    $logonType = $null
    $logonTypeName = $null
    if ($rawData.Contains('LogonType')) {
      try {
        $logonType = [int] $rawData['LogonType']
        $resolved = Resolve-WindowsEventMappedField -MapName 'LogonType' -Value $logonType -Configuration $Configuration
        if ($resolved) {
          $logonTypeName = $resolved.Name
        }
      }
      catch {
        $logonType = $rawData['LogonType']
      }
    }

    $impersonationLevelName = $null
    if ($rawData.Contains('ImpersonationLevel')) {
      $resolved = Resolve-WindowsEventMappedField -MapName 'ImpersonationLevel' -Value $rawData['ImpersonationLevel'] -Configuration $Configuration
      if ($resolved) {
        $impersonationLevelName = $resolved.Name
      }
    }

    [pscustomobject] @{
      TimeCreated = $Event.TimeCreated
      Id = $Event.Id
      ProviderName = $Event.ProviderName
      LogName = $Event.LogName
      MachineName = $Event.MachineName
      RecordId = $Event.RecordId
      LevelDisplayName = $Event.LevelDisplayName
      TargetUserName = $rawData['TargetUserName']
      TargetDomainName = $rawData['TargetDomainName']
      SubjectUserName = $rawData['SubjectUserName']
      SubjectDomainName = $rawData['SubjectDomainName']
      LogonType = $logonType
      LogonTypeName = $logonTypeName
      ImpersonationLevel = $rawData['ImpersonationLevel']
      ImpersonationLevelName = $impersonationLevelName
      IpAddress = $rawData['IpAddress']
      IpPort = $rawData['IpPort']
      WorkstationName = $rawData['WorkstationName']
      ProcessName = $rawData['ProcessName']
      ProcessId = $rawData['ProcessId']
      LogonProcessName = $rawData['LogonProcessName']
      AuthenticationPackageName = $rawData['AuthenticationPackageName']
      Status = $rawData['Status']
      SubStatus = $rawData['SubStatus']
      TargetLogonId = $rawData['TargetLogonId']
      ServiceName = $rawData['ServiceName']
      ImagePath = $rawData['ImagePath']
      ServiceFileName = $rawData['ServiceFileName']
      RawData = $rawData
      EventRecord = $Event
    }
  }
}

function Get-WindowsLogonEvent {
  <#
    .SYNOPSIS
      Queries and normalises logon-related security events.
    .DESCRIPTION
      Wraps Get-WindowsEventByDefinition for the Logon group. Supports
      filtering by event ID and logon type. Suppresses noisy system accounts
      by default unless -IncludeSystem is supplied.
    .PARAMETER StartTime
      Earliest event timestamp. Defaults to 24 hours ago.
    .PARAMETER EndTime
      Latest event timestamp. Defaults to now.
    .PARAMETER Id
      Specific event IDs to return. Defaults to all Logon group IDs.
    .PARAMETER LogonType
      Filter by numeric logon type(s), e.g. 10 for RDP.
    .PARAMETER IncludeSystem
      Include system and machine accounts normally suppressed.
    .PARAMETER ComputerName
      Target remote computer(s).
    .PARAMETER MaxEvents
      Maximum events to return.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to cached.
    .EXAMPLE
      PS> Get-WindowsLogonEvent -Id 4625
      Returns failed logon events from the past 24 hours.
    .EXAMPLE
      PS> Get-WindowsLogonEvent -Id 4624 -LogonType 10
      Returns successful RDP logons.
    .EXAMPLE
      PS> Get-WindowsLogonEvent -Id 4624, 4800, 4801 -LogonType 2, 7
      Returns local console and unlock activity.
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [datetime] $StartTime = (Get-Date).AddDays(-1),

    [Parameter(Mandatory = $false)]
    [datetime] $EndTime = (Get-Date),

    [Parameter(Mandatory = $false)]
    [int[]] $Id,

    [Parameter(Mandatory = $false)]
    [int[]] $LogonType,

    [Parameter(Mandatory = $false)]
    [switch] $IncludeSystem,

    [Parameter(Mandatory = $false)]
    [string[]] $ComputerName,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, [int]::MaxValue)]
    [int] $MaxEvents,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  $group = Get-SecurityEventGroup -Name 'Logon' -Configuration $Configuration

  if (-not $Id) {
    $Id = $group.EventIds
  }

  $queryParams = @{
    Group = 'Logon'
    StartTime = $StartTime
    EndTime = $EndTime
    Configuration = $Configuration
  }
  if ($ComputerName) { $queryParams.ComputerName = $ComputerName }
  if ($MaxEvents) { $queryParams.MaxEvents = $MaxEvents }

  $events = Get-WindowsEventByDefinition @queryParams

  $events |
    ConvertFrom-WinEvent -Configuration $Configuration |
    Where-Object {
      if ($_.Id -notin $Id) {
        return $false
      }

      if ($LogonType -and $_.LogonType -notin $LogonType) {
        return $false
      }

      if (-not $IncludeSystem) {
        if ($group.DefaultSuppressions -and
          $group.DefaultSuppressions.TargetUserName -and
          $_.TargetUserName -in $group.DefaultSuppressions.TargetUserName) {
          return $false
        }

        if ($group.DefaultSuppressions -and
          $group.DefaultSuppressions.TargetUserNameEndsWith) {
          foreach ($suffix in $group.DefaultSuppressions.TargetUserNameEndsWith) {
            if ($_.TargetUserName -like "*$suffix") {
              return $false
            }
          }
        }
      }

      return $true
    }
}

function Get-WindowsAccountChangeEvent {
  <#
    .SYNOPSIS
      Queries account lifecycle and group membership change events.
    .DESCRIPTION
      Wraps Get-WindowsEventByDefinition for the AccountChange group.
      Supports filtering by target user, subject user, and event ID.
    .PARAMETER StartTime
      Earliest event timestamp. Defaults to 24 hours ago.
    .PARAMETER EndTime
      Latest event timestamp. Defaults to now.
    .PARAMETER Id
      Specific event IDs to return. Defaults to all AccountChange group IDs.
    .PARAMETER TargetUserName
      Filter by the target account name of the change.
    .PARAMETER SubjectUserName
      Filter by the account that performed the change.
    .PARAMETER ComputerName
      Target remote computer(s).
    .PARAMETER MaxEvents
      Maximum events to return.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to cached.
    .EXAMPLE
      PS> Get-WindowsAccountChangeEvent -Id 4720
      Returns user account creation events.
    .EXAMPLE
      PS> Get-WindowsAccountChangeEvent -Id 4728, 4732 -StartTime (Get-Date).AddDays(-7)
      Returns group membership additions for the past 7 days.
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [datetime] $StartTime = (Get-Date).AddDays(-1),

    [Parameter(Mandatory = $false)]
    [datetime] $EndTime = (Get-Date),

    [Parameter(Mandatory = $false)]
    [int[]] $Id,

    [Parameter(Mandatory = $false)]
    [string] $TargetUserName,

    [Parameter(Mandatory = $false)]
    [string] $SubjectUserName,

    [Parameter(Mandatory = $false)]
    [string[]] $ComputerName,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, [int]::MaxValue)]
    [int] $MaxEvents,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  $group = Get-SecurityEventGroup -Name 'AccountChange' -Configuration $Configuration

  if (-not $Id) {
    $Id = $group.EventIds
  }

  $queryParams = @{
    Group = 'AccountChange'
    StartTime = $StartTime
    EndTime = $EndTime
    Configuration = $Configuration
  }
  if ($ComputerName) { $queryParams.ComputerName = $ComputerName }
  if ($MaxEvents) { $queryParams.MaxEvents = $MaxEvents }

  $events = Get-WindowsEventByDefinition @queryParams

  $events |
    ConvertFrom-WinEvent -Configuration $Configuration |
    Where-Object {
      if ($_.Id -notin $Id) { return $false }
      if ($TargetUserName -and $_.TargetUserName -ne $TargetUserName) { return $false }
      if ($SubjectUserName -and $_.SubjectUserName -ne $SubjectUserName) { return $false }
      return $true
    }
}

function Get-WindowsServiceEvent {
  <#
    .SYNOPSIS
      Queries service lifecycle, failure, and installation events.
    .DESCRIPTION
      Wraps Get-WindowsEventByDefinition for the Service group. Supports
      filtering by service name and event ID.
    .PARAMETER StartTime
      Earliest event timestamp. Defaults to 24 hours ago.
    .PARAMETER EndTime
      Latest event timestamp. Defaults to now.
    .PARAMETER Id
      Specific event IDs to return. Defaults to all Service group IDs.
    .PARAMETER ServiceName
      Filter by the service name involved.
    .PARAMETER ComputerName
      Target remote computer(s).
    .PARAMETER MaxEvents
      Maximum events to return.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to cached.
    .EXAMPLE
      PS> Get-WindowsServiceEvent -Id 7045
      Returns new service installation events (common persistence vector).
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [datetime] $StartTime = (Get-Date).AddDays(-1),

    [Parameter(Mandatory = $false)]
    [datetime] $EndTime = (Get-Date),

    [Parameter(Mandatory = $false)]
    [int[]] $Id,

    [Parameter(Mandatory = $false)]
    [string] $ServiceName,

    [Parameter(Mandatory = $false)]
    [string[]] $ComputerName,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, [int]::MaxValue)]
    [int] $MaxEvents,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  $group = Get-SecurityEventGroup -Name 'Service' -Configuration $Configuration

  if (-not $Id) {
    $Id = $group.EventIds
  }

  $queryParams = @{
    Group = 'Service'
    StartTime = $StartTime
    EndTime = $EndTime
    Configuration = $Configuration
  }
  if ($ComputerName) { $queryParams.ComputerName = $ComputerName }
  if ($MaxEvents) { $queryParams.MaxEvents = $MaxEvents }

  $events = Get-WindowsEventByDefinition @queryParams

  $events |
    ConvertFrom-WinEvent -Configuration $Configuration |
    Where-Object {
      if ($_.Id -notin $Id) { return $false }
      if ($ServiceName -and $_.ServiceName -ne $ServiceName) { return $false }
      return $true
    }
}

function Get-WindowsBootEvent {
  <#
    .SYNOPSIS
      Queries boot, shutdown, and crash events.
    .DESCRIPTION
      Wraps Get-WindowsEventByDefinition for the BootShutdown group.
      Covers unexpected reboots, BSODs, clean shutdowns, and service
      lifecycle transitions.
    .PARAMETER StartTime
      Earliest event timestamp. Defaults to 24 hours ago.
    .PARAMETER EndTime
      Latest event timestamp. Defaults to now.
    .PARAMETER Id
      Specific event IDs to return. Defaults to all BootShutdown group IDs.
    .PARAMETER ComputerName
      Target remote computer(s).
    .PARAMETER MaxEvents
      Maximum events to return.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to cached.
    .EXAMPLE
      PS> Get-WindowsBootEvent -Id 41, 1001
      Returns unexpected shutdowns and BSOD events.
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [datetime] $StartTime = (Get-Date).AddDays(-1),

    [Parameter(Mandatory = $false)]
    [datetime] $EndTime = (Get-Date),

    [Parameter(Mandatory = $false)]
    [int[]] $Id,

    [Parameter(Mandatory = $false)]
    [string[]] $ComputerName,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, [int]::MaxValue)]
    [int] $MaxEvents,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  $group = Get-SecurityEventGroup -Name 'BootShutdown' -Configuration $Configuration

  if (-not $Id) {
    $Id = $group.EventIds
  }

  $queryParams = @{
    Group = 'BootShutdown'
    StartTime = $StartTime
    EndTime = $EndTime
    Configuration = $Configuration
  }
  if ($ComputerName) { $queryParams.ComputerName = $ComputerName }
  if ($MaxEvents) { $queryParams.MaxEvents = $MaxEvents }

  $events = Get-WindowsEventByDefinition @queryParams

  $events |
    ConvertFrom-WinEvent -Configuration $Configuration |
    Where-Object {
      if ($Id -and $_.Id -notin $Id) { return $false }
      return $true
    }
}

function Get-WindowsPowerShellEvent {
  <#
    .SYNOPSIS
      Queries PowerShell operational telemetry events.
    .DESCRIPTION
      Wraps Get-WindowsEventByDefinition for the PowerShell group. Supports
      filtering by event ID, executing user, and script block content pattern.
    .PARAMETER StartTime
      Earliest event timestamp. Defaults to 24 hours ago.
    .PARAMETER EndTime
      Latest event timestamp. Defaults to now.
    .PARAMETER Id
      Specific event IDs to return. Defaults to all PowerShell group IDs.
    .PARAMETER UserName
      Filter by the user account that executed the PowerShell code.
    .PARAMETER ScriptBlockText
      Regex pattern to search within captured script block content.
    .PARAMETER ComputerName
      Target remote computer(s).
    .PARAMETER MaxEvents
      Maximum events to return.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to cached.
    .EXAMPLE
      PS> Get-WindowsPowerShellEvent -Id 4104
      Returns script block logging events (highest-value PowerShell event).
    .EXAMPLE
      PS> Get-WindowsPowerShellEvent -ScriptBlockText 'DownloadString|FromBase64'
      Returns PowerShell events matching suspicious download or encoding patterns.
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [datetime] $StartTime = (Get-Date).AddDays(-1),

    [Parameter(Mandatory = $false)]
    [datetime] $EndTime = (Get-Date),

    [Parameter(Mandatory = $false)]
    [int[]] $Id,

    [Parameter(Mandatory = $false)]
    [string] $UserName,

    [Parameter(Mandatory = $false)]
    [string] $ScriptBlockText,

    [Parameter(Mandatory = $false)]
    [string[]] $ComputerName,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, [int]::MaxValue)]
    [int] $MaxEvents,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  $group = Get-SecurityEventGroup -Name 'PowerShell' -Configuration $Configuration

  if (-not $Id) {
    $Id = $group.EventIds
  }

  $queryParams = @{
    Group = 'PowerShell'
    StartTime = $StartTime
    EndTime = $EndTime
    Configuration = $Configuration
  }
  if ($ComputerName) { $queryParams.ComputerName = $ComputerName }
  if ($MaxEvents) { $queryParams.MaxEvents = $MaxEvents }

  $events = Get-WindowsEventByDefinition @queryParams

  $events |
    ConvertFrom-WinEvent -Configuration $Configuration |
    Where-Object {
      if ($_.Id -notin $Id) { return $false }

      if ($UserName) {
        $raw = $_.RawData
        $found = $false
        foreach ($key in @('UserName', 'UserId', 'User', 'SubjectUserName')) {
          if ($raw.ContainsKey($key) -and $raw[$key] -match $UserName) {
            $found = $true
            break
          }
        }
        if (-not $found) { return $false }
      }

      if ($ScriptBlockText) {
        $raw = $_.RawData
        $matched = $false
        foreach ($key in @('ScriptBlockText', 'Message', 'Payload')) {
          if ($raw.ContainsKey($key) -and $raw[$key] -match $ScriptBlockText) {
            $matched = $true
            break
          }
        }
        if (-not $matched) { return $false }
      }

      return $true
    }
}

function Get-WindowsScheduledTaskEvent {
  <#
    .SYNOPSIS
      Queries scheduled task lifecycle telemetry.
    .DESCRIPTION
      Wraps Get-WindowsEventByDefinition for the ScheduledTask group.
      Supports filtering by event ID and task name.
    .PARAMETER StartTime
      Earliest event timestamp. Defaults to 24 hours ago.
    .PARAMETER EndTime
      Latest event timestamp. Defaults to now.
    .PARAMETER Id
      Specific event IDs to return. Defaults to all ScheduledTask group IDs.
    .PARAMETER TaskName
      Filter by the name of the scheduled task.
    .PARAMETER ComputerName
      Target remote computer(s).
    .PARAMETER MaxEvents
      Maximum events to return.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to cached.
    .EXAMPLE
      PS> Get-WindowsScheduledTaskEvent -Id 106, 140, 141
      Returns task registration, update, and deletion events.
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [datetime] $StartTime = (Get-Date).AddDays(-1),

    [Parameter(Mandatory = $false)]
    [datetime] $EndTime = (Get-Date),

    [Parameter(Mandatory = $false)]
    [int[]] $Id,

    [Parameter(Mandatory = $false)]
    [string] $TaskName,

    [Parameter(Mandatory = $false)]
    [string[]] $ComputerName,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, [int]::MaxValue)]
    [int] $MaxEvents,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  $group = Get-SecurityEventGroup -Name 'ScheduledTask' -Configuration $Configuration

  if (-not $Id) {
    $Id = $group.EventIds
  }

  $queryParams = @{
    Group = 'ScheduledTask'
    StartTime = $StartTime
    EndTime = $EndTime
    Configuration = $Configuration
  }
  if ($ComputerName) { $queryParams.ComputerName = $ComputerName }
  if ($MaxEvents) { $queryParams.MaxEvents = $MaxEvents }

  $events = Get-WindowsEventByDefinition @queryParams

  $events |
    ConvertFrom-WinEvent -Configuration $Configuration |
    Where-Object {
      if ($_.Id -notin $Id) { return $false }

      if ($TaskName) {
        $raw = $_.RawData
        $found = $false
        foreach ($key in @('TaskName', 'Name')) {
          if ($raw.ContainsKey($key) -and $raw[$key] -match $TaskName) {
            $found = $true
            break
          }
        }
        if (-not $found) { return $false }
      }

      return $true
    }
}

function Get-WindowsSysmonEvent {
  <#
    .SYNOPSIS
      Queries Sysmon telemetry if the channel is available.
    .DESCRIPTION
      Wraps Get-WindowsEventByDefinition for the Sysmon group with
      -SkipMissingChannel enabled by default (Sysmon is optional).
    .PARAMETER StartTime
      Earliest event timestamp. Defaults to 24 hours ago.
    .PARAMETER EndTime
      Latest event timestamp. Defaults to now.
    .PARAMETER Id
      Specific event IDs to return. Defaults to all Sysmon group IDs.
    .PARAMETER ComputerName
      Target remote computer(s).
    .PARAMETER MaxEvents
      Maximum events to return.
    .PARAMETER Configuration
      Configuration hashtable. Defaults to cached.
    .EXAMPLE
      PS> Get-WindowsSysmonEvent -Id 1
      Returns Sysmon process creation events.
    .EXAMPLE
      PS> Get-WindowsSysmonEvent -Id 3, 22
      Returns Sysmon network connection and DNS query events.
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $false)]
    [datetime] $StartTime = (Get-Date).AddDays(-1),

    [Parameter(Mandatory = $false)]
    [datetime] $EndTime = (Get-Date),

    [Parameter(Mandatory = $false)]
    [int[]] $Id,

    [Parameter(Mandatory = $false)]
    [string[]] $ComputerName,

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, [int]::MaxValue)]
    [int] $MaxEvents,

    [Parameter(Mandatory = $false)]
    [hashtable] $Configuration = (Import-SecurityEventConfiguration)
  )

  if (-not (Test-WindowsEventLogChannel -LogName 'Microsoft-Windows-Sysmon/Operational')) {
    Write-Verbose 'Sysmon operational channel not found. Sysmon may not be installed or configured.'
    return
  }

  $group = Get-SecurityEventGroup -Name 'Sysmon' -Configuration $Configuration

  if (-not $Id) {
    $Id = $group.EventIds
  }

  $queryParams = @{
    Group = 'Sysmon'
    StartTime = $StartTime
    EndTime = $EndTime
    SkipMissingChannel = $true
    Configuration = $Configuration
  }
  if ($ComputerName) { $queryParams.ComputerName = $ComputerName }
  if ($MaxEvents) { $queryParams.MaxEvents = $MaxEvents }

  $events = Get-WindowsEventByDefinition @queryParams

  $events |
    ConvertFrom-WinEvent -Configuration $Configuration |
    Where-Object {
      if ($_.Id -notin $Id) { return $false }
      return $true
    }
}