scripts/VM/Windows/eset-config.ps1

# eset-config: what ESET is installed on this server, whether it is actually protecting
# it, and - the point of the script - how its NOTIFICATIONS are configured
# (Advanced setup -> Tools -> Notifications: email/SMTP notifications, desktop
# notifications, minimum verbosity).
#
# WHERE THE SETTINGS COME FROM
# ----------------------------
# HKLM\SOFTWARE\ESET\ESET Security\CurrentVersion. That is the effective settings tree
# ekrn actually runs on: always present, needs no ESET feature to be switched on, and it
# already reflects any ESET PROTECT policy merged over the local settings. The two CLI
# tools that can dump a nicer XML config - ermm.exe and ecmd.exe - are deliberately NOT
# used here: ecmd.exe only answers when "ESET CMD" has been enabled in Advanced setup, and
# both return a full config document that would blow the output budget below.
#
# THE 4096-BYTE OUTPUT BUDGET
# ---------------------------
# Azure Run-Command returns only the LAST 4096 bytes of stdout, and truncation happens
# silently in Azure, not here - a clipped JSON object would reach the caller as an
# unparseable string with no clue why. So this emits a summary, not a config dump, and the
# one free-text field (NotificationSettings) is hard-capped with an explicit '...(+N more)'
# marker so a trimmed list can never pass for a complete one.
#
# WHY THE NOTIFICATION FIELDS ARE MATCHED, NOT LOOKED UP
# ------------------------------------------------------
# ESET keys its settings by opaque plugin id - Plugins\01000103\Settings\... - and those
# ids move between product versions and between Endpoint and Server Security. A hardcoded
# path would silently report "not configured" on the next version, which is the worst
# possible failure for a notification check: it looks like a finding. So the fields below
# are matched by value name within notification-ish keys, and every matched leaf is also
# returned raw in NotificationSettings. On the first run against your build, read that
# field - it tells you the real key names on YOUR product, and it is the thing to widen
# $notifyPattern with if a setting you expect is missing.
#
# Continue (not Stop) so an individual probe failure degrades to nulls rather than
# yielding no row at all.
$ErrorActionPreference = 'Continue'

$nowUtc = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')

# Both registry views plus the pre-rename 'Nod' path used by older builds. ESET installs
# exactly one of these; first hit wins.
$infoKey = $null
foreach ($candidate in @(
    'HKLM:\SOFTWARE\ESET\ESET Security\CurrentVersion\Info'
    'HKLM:\SOFTWARE\WOW6432Node\ESET\ESET Security\CurrentVersion\Info'
    'HKLM:\SOFTWARE\ESET\Nod\CurrentVersion\Info'
    'HKLM:\SOFTWARE\WOW6432Node\ESET\Nod\CurrentVersion\Info'
)) {
    if (Test-Path -Path $candidate) { $infoKey = $candidate; break }
}

if (-not $infoKey) {
    # "No ESET on this server" is a legitimate audit finding, not a script failure - emit a
    # normal row so the VM appears in the report instead of as a Status=Failed blank.
    [pscustomobject]@{
        NowUtc    = $nowUtc
        Installed = $false
        Compliant = $false
        Notes     = 'No ESET product found under HKLM\SOFTWARE\ESET.'
    } | ConvertTo-Json -Compress
    return
}

$info = Get-ItemProperty -Path $infoKey -ErrorAction SilentlyContinue

# The settings tree is Info's parent. Derived rather than hardcoded so the WOW6432Node and
# legacy 'Nod' layouts resolve without a second list to keep in sync.
$settingsRoot = Split-Path -Path $infoKey -Parent

# ekrn is the kernel service. If it is not running, every setting below describes what ESET
# WOULD do rather than what it is doing - which is the more urgent finding of the two.
$ekrn = Get-Service -Name 'ekrn' -ErrorAction SilentlyContinue
$ekrnState = if ($ekrn) { [string] $ekrn.Status } else { $null }

# An ESET PROTECT agent means the console policy, not the local GUI, decides these
# settings - so it tells the reader where to go and change anything found wrong here.
$managed = $false
foreach ($agentKey in @(
    'HKLM:\SOFTWARE\ESET\RemoteAdministrator\Agent\CurrentVersion\Settings'
    'HKLM:\SOFTWARE\WOW6432Node\ESET\RemoteAdministrator\Agent\CurrentVersion\Settings'
)) {
    if (Test-Path -Path $agentKey) { $managed = $true; break }
}

# ---- walk the settings tree, keeping only notification-ish leaves -----------------------
#
# Widen this if a setting you expect is missing from NotificationSettings. It over-matches
# slightly on purpose: a false positive is a row you skip, a false negative is a setting
# you wrongly believe is unset.
$notifyPattern = 'notif|smtp|mail|sender|recipient|verbosity|desktop|tray|balloon|alert|popup'

# It is matched against "<path>\<name>" rather than either half alone: some settings are
# named for what they configure ('SMTP_Server'), others are generic leaves under a
# notification-named key ('...\Notifications\Enabled'). Either half can be the only hit.

# Things that trip $notifyPattern without being notification config. Every entry is a false
# positive seen in real output, not a guess:
# scanners\...\MailEnable the email SCANNER (scan mail files) - nothing to do with
# notifying anyone. One per scan profile, so on a stock
# install these alone outnumber the real settings. Matched
# as (^|\\)scanners\\ because the same keys sit at the root
# on some products and under Config\ on others.
# LicenseInfo\ExpirationNotify* licence expiry warning lead time.
# State\EmailClients\* mail-client integration state, not notifications.
# Without these the verdict below gets computed from junk and reports a false finding.
$notifyExclude = '(^|\\)scanners\\|ExpirationNotify|(^|\\)State\\EmailClients\\'

# REG_BINARY -> something you can put in a column.
#
# The v4-generation products (ESET File Security 4.x) store nearly every string setting as
# REG_BINARY rather than REG_SZ, so 'Config\plugins\01000600\settings\EKRN_CFG\SMTP_Address'
# is 15 raw bytes holding NUL-terminated ASCII. Ports and on/off flags in the same tree are
# little-endian integers in 4 bytes. Both have to be decoded here or every notification
# field reads as empty on exactly the products that need this script most.
function ConvertFrom-EsetBinary {
    param([byte[]] $Bytes)

    if ($null -eq $Bytes -or $Bytes.Length -eq 0) { return '' }

    # All zero. Short = a numeric 0 - a flag that is OFF, which must NOT read as blank,
    # because blank means "not found" everywhere else in this script. Long = empty string.
    if (-not ($Bytes | Where-Object { $_ -ne 0 })) {
        if ($Bytes.Length -le 4) { return 0 }
        return ''
    }

    # UTF-16LE FIRST, on the UNTRIMMED array. Trimming trailing NULs before this test
    # desyncs it: the terminator plus the high byte of the final character are three zero
    # bytes, the trim eats all three, and what is left is an odd-length span that can no
    # longer look like UTF-16 at all.
    if ($Bytes.Length -ge 2 -and $Bytes.Length % 2 -eq 0) {
        $wide = $true
        for ($i = 1; $i -lt $Bytes.Length; $i += 2) { if ($Bytes[$i] -ne 0) { $wide = $false; break } }
        if ($wide) {
            $decoded = [System.Text.Encoding]::Unicode.GetString($Bytes).TrimEnd([char] 0)
            if ($decoded.Length -and $decoded -match '^[\x20-\x7E]+$') { return $decoded }
        }
    }

    $end = $Bytes.Length
    while ($end -gt 0 -and $Bytes[$end - 1] -eq 0) { $end-- }
    $trim = $Bytes[0..($end - 1)]

    $printable = $true
    foreach ($b in $trim) { if ($b -lt 0x20 -or $b -gt 0x7E) { $printable = $false; break } }
    if ($printable) { return [System.Text.Encoding]::UTF8.GetString($trim) }

    # Small non-text blob: a little-endian integer. Ports and flags land here.
    if ($Bytes.Length -le 4) {
        $n = 0
        for ($i = $Bytes.Length - 1; $i -ge 0; $i--) { $n = ($n -shl 8) -bor $Bytes[$i] }
        return $n
    }

    return "<binary:$($Bytes.Length)b>"
}

$all       = New-Object System.Collections.ArrayList   # every leaf, for the value-shape scan
$leaves    = New-Object System.Collections.ArrayList   # the notification-ish subset
$readError = 0

# -ErrorAction SilentlyContinue is load-bearing: ESET self-defense ACLs some of its own
# subkeys against read even for SYSTEM, and one such key would otherwise abort the walk.
# -ErrorVariable then COUNTS what was skipped: silently walking a tree whose interesting
# half was unreadable is what makes an empty result look like a configured-nothing server.
$blocked = $null
$keys = @(Get-Item -Path $settingsRoot -ErrorAction SilentlyContinue) +
        @(Get-ChildItem -Path $settingsRoot -Recurse -ErrorAction SilentlyContinue -ErrorVariable blocked)
$blockedCount = @($blocked).Count

foreach ($key in $keys) {
    if (-not $key) { continue }

    # Trim the fixed prefix so the emitted paths stay short (output budget) and stay
    # comparable between a 64-bit and a WOW6432Node server.
    $path = $key.Name -replace '^HKEY_LOCAL_MACHINE\\SOFTWARE\\(WOW6432Node\\)?ESET\\[^\\]+\\CurrentVersion\\', ''

    foreach ($name in $key.GetValueNames()) {
        $value = $null
        try { $value = $key.GetValue($name) } catch { $readError++; continue }

        # REG_BINARY is where the v4-generation products keep their notification config -
        # SMTP_Address arrives as 15 raw bytes, not as a string - so this has to decode
        # rather than summarise. Reporting it as '<binary:15b>' is what made the SmtpServer
        # column come back empty on a server that had the setting populated all along.
        if ($value -is [byte[]]) { $value = ConvertFrom-EsetBinary -Bytes $value }

        # Redacted unconditionally - the SMTP password for email notifications lands here,
        # and this output is meant to end up in an audit CSV. ESET stores it obfuscated
        # rather than in clear, but it is still a credential leaving the server.
        if ($name -match 'pass|secret|token|credential') { $value = '<redacted>' }

        $leaf = [pscustomobject]@{ Path = $path; Name = $name; Value = $value }
        [void] $all.Add($leaf)

        if ("$path\$name" -match $notifyPattern -and "$path\$name" -notmatch $notifyExclude) {
            [void] $leaves.Add($leaf)
        }
    }
}

# ---- value-shape scan: find the addresses regardless of what the keys are called --------
#
# The keyword match above depends on ESET naming a key something recognisable, which on an
# older product it may simply not do. This does not: if event notifications are configured
# at all, SOME value holds a recipient address. Matching the VALUE shape instead of the key
# name finds it whatever the key is called, and the paths it returns are how you learn the
# real key names on a product version nobody has mapped yet.
$mailish = @(
    $all | Where-Object {
        $_.Value -is [string] -and
        $_.Value -match '^[^@\s;,]+@[^@\s;,]+\.[A-Za-z]{2,}$'
    }
)

# ---- tree inventory: which top-level subkeys yielded anything ---------------------------
#
# The cheapest way to tell "this product genuinely has no notification settings here" apart
# from "the subtree holding them was not readable". A top-level key present in the registry
# but reporting 0 values is the signature of the second case.
$counts = @{}
foreach ($leaf in $all) {
    $top = ($leaf.Path -split '\\')[0]
    if (-not $counts.ContainsKey($top)) { $counts[$top] = 0 }
    $counts[$top]++
}
foreach ($child in @(Get-ChildItem -Path $settingsRoot -ErrorAction SilentlyContinue)) {
    if (-not $counts.ContainsKey($child.PSChildName)) { $counts[$child.PSChildName] = 0 }
}
$treeSummary = (($counts.Keys | Sort-Object | ForEach-Object { "$_`:$($counts[$_])" }) -join ',')

# Pick one leaf by value-name pattern, preferring those under a mail/SMTP-named key so a
# generic name like 'Server' resolves to the SMTP server and not to something unrelated.
function Get-Leaf {
    param([string] $NamePattern, [string] $PreferPath = 'smtp|mail|notif')

    $hits = @($leaves | Where-Object { $_.Name -match $NamePattern })
    if (-not $hits.Count) { return $null }

    # Preference is tested against "path\name", not the path alone. The v4 layout carries
    # the meaning in the value NAME (EKRN_CFG\SMTP_Address) while the path says only which
    # plugin id owns it, so a path-only test never prefers anything and the tie-break falls
    # back to registry order.
    $preferred = @($hits | Where-Object { "$($_.Path)\$($_.Name)" -match $PreferPath })
    if ($preferred.Count) { return $preferred[0].Value }
    return $hits[0].Value
}

# ESET stores these as DWORD 0/1. A missing leaf stays $null ("we did not find this
# setting") rather than collapsing to $false ("it is switched off") - the two mean very
# different things in a notification audit. The try/catch covers the leaf that is neither:
# some of these are stored as text on some builds, and an uncastable value is likewise
# unknown rather than off.
function ConvertTo-Flag {
    param($Value)
    if ($null -eq $Value) { return $null }
    if ($Value -is [string]) {
        if ($Value -match '^(?i:true|yes|on|enabled)$')  { return $true }
        if ($Value -match '^(?i:false|no|off|disabled)$') { return $false }
    }
    try   { return [bool][int] $Value }
    catch { return $null }
}

# The on/off patterns are ANCHORED, the descriptive ones are not, and that asymmetry is
# deliberate. An unanchored 'send' matches SenderAddress before it matches Enabled, so the
# email-enabled flag comes back holding an email address, fails the cast, and reports as
# "unknown" on a server that has notifications switched on perfectly well. A wrong value
# for a descriptive field is visible in the output; a wrong value for a flag is not.
#
# The SMTP_* names are EKRN_CFG's, confirmed against a real ESET Server Security 12.1 tree:
#
# SMTP_Server smtp.elasticemail.com the server
# SMTP_Address soc@example.com the RECIPIENT, despite the name
# SMTP_SenderAddress no-reply@example.net
# SMTP_Username person@example.com SMTP auth account
#
# SMTP_Address reads like a server address and is a mailbox. Matching it as the server -
# which an earlier version of this script did - puts a recipient in SmtpServer and leaves
# the real hostname unreported, so the server pattern below excludes 'address' on purpose
# and the recipient pattern claims it explicitly.
$smtpServer = Get-Leaf -NamePattern '^(smtp)?server$|smtpserver|mailserver|smtphost|^smtp_?(host|server)$'
$recipients = Get-Leaf -NamePattern 'recipient|addressee|mailto|^smtp_?(to|address)$'
$sender     = Get-Leaf -NamePattern 'sender|mailfrom|fromaddress|^smtp_?from$'
$smtpUser   = Get-Leaf -NamePattern '^smtp_?(username|user|login|account)$|^(mail|smtp)auth'
$smtpPort   = Get-Leaf -NamePattern '^(smtp_?)?port$'
$emailOn    = ConvertTo-Flag (Get-Leaf -NamePattern '^(enabled?|active|send(mail|email|notifications?)?)$|^(email|smtp)_?notification|^smtp_?(enabled?|active|on)$' -PreferPath 'smtp|mail')
$desktopOn  = ConvertTo-Flag (Get-Leaf -NamePattern '^(enabled?|active|show|display|visible)$|^(show|display)' -PreferPath 'desktop|tray|balloon|popup')
$tls        = ConvertTo-Flag (Get-Leaf -NamePattern '^(use)?(tls|ssl|starttls)$|securesmtp|^smtp_?(tls|ssl)')
$verbosity  = Get-Leaf -NamePattern 'verbosity|minlevel|loglevel|severity'

# Fall back to the value-shape scan when the key names gave nothing. An address found this
# way is real config - it just lives under a key this script cannot yet name - so it is
# better evidence than the $null the keyword match produced.
#
# Addresses already identified as the sender or the SMTP auth account are subtracted first.
# Without that, a server whose recipient key simply was not matched reports every mailbox in
# the tree as a recipient - which is how one run came back claiming three recipients when
# two of them were the from-address and the login.
if ([string]::IsNullOrWhiteSpace([string] $recipients) -and $mailish.Count) {
    $claimed    = @([string] $sender, [string] $smtpUser) | Where-Object { $_ }
    $candidates = @($mailish | ForEach-Object { $_.Value } |
                        Where-Object { $_ -notin $claimed } | Select-Object -Unique)
    if ($candidates.Count) { $recipients = $candidates -join ',' }
}

# ---- detection engine age ----------------------------------------------------------------
#
# ScannerVersion reads '30023 (20241009)' - build, then the engine's release date. The date
# is the part that matters: a server can be running, licensed and fully "configured" while
# detecting nothing newer than the day its updates stopped, and that failure is invisible in
# every other field here.
$engineDate = $null
$engineAge  = $null
$match = [regex]::Match([string] $info.ScannerVersion, '\((\d{8})\)')
if ($match.Success) {
    $parsed = [DateTime]::MinValue
    if ([DateTime]::TryParseExact($match.Groups[1].Value, 'yyyyMMdd', $null, 'None', [ref] $parsed)) {
        $engineDate = $parsed.ToString('yyyy-MM-dd')
        $engineAge  = [int] ([DateTime]::UtcNow - $parsed).TotalDays
    }
}

# ---- the raw matched leaves, capped ------------------------------------------------------
#
# Capped at 1200 chars: the fixed fields above run to roughly 700, and Azure returns only the
# last 4096 bytes of stdout. The '(+N more)' marker means a trimmed list can never be
# mistaken for the complete one.
function Format-Capped {
    param([string[]] $Lines, [int] $Budget)

    $text = ''
    $used = 0
    foreach ($line in $Lines) {
        if ($text.Length + $line.Length + 2 -gt $Budget) { break }
        if ($text) { $text += '; ' }
        $text += $line
        $used++
    }
    if ($used -lt $Lines.Count) { $text += " ...(+$($Lines.Count - $used) more)" }
    return $text
}

$blob     = Format-Capped -Lines @($leaves  | ForEach-Object { "$($_.Path)\$($_.Name)=$($_.Value)" }) -Budget 1200
$mailBlob = Format-Capped -Lines @($mailish | ForEach-Object { "$($_.Path)\$($_.Name)=$($_.Value)" }) -Budget 400

# ---- verdict ------------------------------------------------------------------------------
#
# Compliant answers one question: will this server tell anyone when something happens?
# So it needs a delivery target - an SMTP server, or an address found by shape - on top of
# ESET actually running.
#
# It is $null, NOT $false, whenever the evidence is merely absent rather than negative.
# Getting this backwards is the failure this script had on its first real run: eight junk
# leaves matched, none of them notification config, and it reported Compliant=False - a
# confident finding of "notifications are off" drawn from having found nothing at all.
# $false is now reserved for the case where the settings were genuinely readable and
# genuinely name no destination.
$haveTarget   = -not [string]::IsNullOrWhiteSpace([string] $smtpServer) -or
                -not [string]::IsNullOrWhiteSpace([string] $recipients)
# Did anything at all look like real notification config, as opposed to leaves that merely
# tripped a keyword? If not, this script has not yet been mapped to this product version.
$haveEvidence = $haveTarget -or ($null -ne $emailOn) -or ($null -ne $desktopOn) -or
                @($leaves | Where-Object { $_.Path -match 'notif|smtp' }).Count -gt 0

$compliant = $null
if ($haveEvidence) { $compliant = ($ekrnState -eq 'Running') -and $haveTarget }

$notes = New-Object System.Collections.ArrayList
if ($ekrnState -and $ekrnState -ne 'Running') {
    [void] $notes.Add("ekrn is '$ekrnState' - protection is not active.")
}
if (-not $haveEvidence) {
    # The actionable version of "no verdict": it names the two fields that say where to
    # look next, so the reader is not left guessing why the row came back empty.
    [void] $notes.Add('No notification config recognised on this product version - no verdict. Use TreeSummary and MailLeaves to find the real keys, then widen $notifyPattern.')
}
if ($blockedCount -or $readError) {
    [void] $notes.Add("$blockedCount key(s) and $readError value(s) unreadable (ESET self-defense); the tree is partial.")
}
if ($null -ne $engineAge -and $engineAge -gt 30) {
    [void] $notes.Add("Detection engine is $engineAge days old ($engineDate) - updates are not arriving.")
}
if ($managed) {
    [void] $notes.Add('ESET PROTECT agent present - change these in the console policy, not locally.')
}

[pscustomobject]@{
    NowUtc                = $nowUtc
    Installed             = $true
    Product               = $info.ProductName
    Version               = $info.ProductVersion
    # ESET calls this the detection engine in the UI and the scanner in the registry.
    DetectionEngine       = $info.ScannerVersion
    DetectionEngineDate   = $engineDate
    DetectionEngineAgeDays = $engineAge
    EkrnState             = $ekrnState
    ManagedByProtect      = $managed
    EmailNotifyEnabled    = $emailOn
    SmtpServer            = $smtpServer
    SmtpPort              = $smtpPort
    SmtpUsername          = $smtpUser
    SmtpSender            = $sender
    SmtpRecipients        = $recipients
    SmtpTls               = $tls
    MinVerbosity          = $verbosity
    DesktopNotifyEnabled  = $desktopOn
    NotificationCount     = $leaves.Count
    Compliant             = $compliant
    Notes                 = ($notes -join ' ')
    # The three discovery fields, last: they are the ones worth losing bytes off if anything
    # ever does overflow the budget, and the verdict above them stays intact either way.
    # TreeSummary is 'TopLevelKey:valueCount' for every top-level key under CurrentVersion -
    # one reporting 0 is a subtree that exists but yielded nothing, which is what an
    # unreadable Plugins tree looks like from here.
    TreeSummary           = $treeSummary
    MailLeaves            = $mailBlob
    NotificationSettings  = $blob
} | ConvertTo-Json -Compress