Private/Kinds/Security.ps1

# The Security Kind: what is scanning every file this machine touches.
#
# One antivirus product is how a machine is supposed to be. Two is a configuration nobody
# chose deliberately - a vendor product installed without Defender being stood down - and
# it is one of the few findings that explains a uniformly slow machine on its own, because
# every file open is scanned twice by two products that also contend with each other.

# Windows Security Center reports each product's state as a bitmask. The bit that matters
# is whether on-access scanning is running: a product installed but switched off is not
# scanning anything and must not count towards the total.
#
# A constant rather than a Check Definition parameter: it is a fact about the Windows API,
# and no Customer has an opinion about it.
$script:SecurityRealtimeScanningBit = 0x1000

# Defender is asked as well, about itself. Security Center is a register that products
# write their state into, and it can be out of date: seen on a real machine, a vendor
# product registered five times and Defender still listed as scanning beside it. Whether
# both scan every file is then a question only Defender can answer - it runs passively
# beside another product when the hand-over worked (AMRunningMode "Passive Mode",
# https://learn.microsoft.com/en-us/defender-endpoint/microsoft-defender-antivirus-compatibility),
# and normally with real-time protection when it did not. Observed 2026-10-09 on a
# machine with Defender alone: AMRunningMode "Normal", RealTimeProtectionEnabled True,
# readable without admin rights.
$script:SecurityDefenderNamePattern = 'Defender'

function Get-SecurityData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $products = @()
    $available = $true
    try {
        $products = @(Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntiVirusProduct -ErrorAction Stop |
            ForEach-Object {
                # The raw bitmask, undecoded. Which products count is the Judge's call.
                [pscustomobject]@{
                    DisplayName  = "$($_.displayName)"
                    ProductState = $_.productState
                }
            })
    }
    catch {
        # Server SKUs and some hardened builds do not expose Security Center at all, which
        # is not the same fact as a machine with no antivirus on it.
        $available = $false
    }

    # Not asked with -ErrorAction Stop: a machine whose Defender has been removed has no
    # such command or no answer, and that is an answer here, not an error for the transcript.
    $defender = $null
    if (Get-Command -Name Get-MpComputerStatus -ErrorAction SilentlyContinue) {
        $problems = $null
        $status = Get-MpComputerStatus -ErrorAction SilentlyContinue -ErrorVariable problems
        if ($status) {
            $defender = [pscustomobject]@{
                RunningMode        = "$($status.AMRunningMode)"
                RealTimeProtection = [bool]$status.RealTimeProtectionEnabled
                AntivirusEnabled   = [bool]$status.AntivirusEnabled
            }
        }
    }

    [pscustomobject]@{
        PSTypeName            = 'Gutcheck.Data.Security'
        SecurityCentrePresent = $available
        AntivirusProducts     = $products
        # $null when Defender did not answer: then Security Center is all there is.
        Defender              = $defender
    }
}

function Test-DefenderScanning {
    <#
    .SYNOPSIS
        Whether Defender says of itself that it scans every file: $true, $false, or $null
        when it was not asked or did not answer. Pure.
    .DESCRIPTION
        Scanning is real-time protection on, in a mode that is not passive. "Passive
        Mode", "EDR Block Mode" and "SxS Passive Mode" are Defender standing beside
        another product without scanning on access.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$Defender)

    if ($null -eq $Defender) { return $null }
    if (-not [bool](Get-DataProperty $Defender 'RealTimeProtection')) { return $false }
    "$(Get-DataProperty $Defender 'RunningMode')" -notmatch '(?i)passive|EDR Block'
}

function ConvertTo-SecurityFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    New-AntivirusFinding -Data $Data -Parameters $Parameters
}

function ConvertTo-SecuritySection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $products = Get-DataCollection $Data 'AntivirusProducts'
    if (-not $products.Count) { return }

    New-Section -Title (Get-Text 'Title.Security.AntivirusProductsRegisteredWith') -Row @(
        $products | ForEach-Object {
            [pscustomobject]@{
                Product      = $_.DisplayName
                ProductState = $_.ProductState
                Scanning     = (Test-AntivirusScanning -ProductState $_.ProductState)
            }
        }
    )

    $defender = Get-DataProperty $Data 'Defender'
    if ($defender) {
        New-Section -Title (Get-Text 'Title.Security.Defender') -Row @([pscustomobject]@{
            AMRunningMode             = Get-DataProperty $defender 'RunningMode'
            RealTimeProtectionEnabled = Get-DataProperty $defender 'RealTimeProtection'
            AntivirusEnabled          = Get-DataProperty $defender 'AntivirusEnabled'
            Scanning                  = (Test-DefenderScanning -Defender $defender)
        })
    }
}

function Test-AntivirusScanning {
    <#
    .SYNOPSIS
        Whether a Security Center product state says on-access scanning is running.
    .DESCRIPTION
        Read from the bitmask rather than from the display name, because the display name
        is a vendor's marketing string and says nothing about whether the product is on.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$ProductState)

    $state = ConvertTo-Number $ProductState
    if ($null -eq $state) { return $false }
    ([int]$state -band $script:SecurityRealtimeScanningBit) -ne 0
}

function New-AntivirusFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    # More than one scanner is the problem, so the threshold is a count of active products.
    $warnAbove = Get-Parameter $Parameters 'ActiveAntivirusWarnAbove' 1

    if (-not (Get-DataProperty $Data 'SecurityCentrePresent')) {
        return New-UnavailableFinding -Category Security -Check (Get-Text 'Check.Security.ActiveAntivirus') `
            -Hint (Get-Text 'Hint.Security.WindowsSecurityCenterDidNot')
    }

    # One product per name. Security Center keeps a registration per component and per
    # reinstall, so one scanner routinely appears several times - and four entries for the
    # same product are not four scanners competing for every file.
    $active = @((Get-DataCollection $Data 'AntivirusProducts') |
        Where-Object { Test-AntivirusScanning -ProductState $_.ProductState } |
        Group-Object { "$($_.DisplayName)".Trim() } | ForEach-Object { $_.Group[0] })

    # What Defender says of itself outranks what the register says of Defender: listed
    # as scanning and passive by its own account, it is not a second scanner.
    $defender     = Get-DataProperty $Data 'Defender'
    $defenderSays = Test-DefenderScanning -Defender $defender
    $pattern      = "$(Get-Parameter $Parameters 'DefenderNamePattern' $script:SecurityDefenderNamePattern)"
    $listed       = @($active | Where-Object { "$($_.DisplayName)" -match $pattern })
    $stoodDown    = $false
    if ($defenderSays -eq $false -and $listed.Count) {
        $active    = @($active | Where-Object { $listed -notcontains $_ })
        $stoodDown = $true
    }
    $itself = ''
    if ($null -ne $defenderSays -and ($listed.Count -or $stoodDown)) {
        $realtime = Get-Text 'Value.Security.Off'
        if ([bool](Get-DataProperty $defender 'RealTimeProtection')) { $realtime = Get-Text 'Value.Security.On' }
        $itself = (Get-Text 'Value.Security.DefenderSays') -f (Get-DataProperty $defender 'RunningMode'), $realtime
    }

    if (-not $active.Count) {
        # The script said nothing here, which in a Report is indistinguishable from a
        # machine that was never asked. A Windows machine with nothing scanning it has
        # either had its protection turned off or had it fail.
        $none = Get-Text 'Value.Security.NoneActive'
        if ($itself) { $none = '{0} | {1}' -f $none, $itself }
        return New-Finding -Category Security -Check (Get-Text 'Check.Security.ActiveAntivirus') -Severity WARN -Value $none `
            -Hint (Get-Text 'Hint.Security.NothingIsScanningThisMachine')
    }

    $severity = Get-Severity $active.Count $warnAbove ([double]::MaxValue)
    $value = ($active | ForEach-Object { $_.DisplayName }) -join ', '
    $hints = @(Get-Text 'Hint.Security.MoreThanOneActiveAV')
    # Said only where it matters: beside another product, or where it changed the count.
    if ($itself -and ($active.Count -gt 1 -or $stoodDown)) {
        $value = '{0} | {1}' -f $value, $itself
        if ($stoodDown) { $value = '{0} | {1}' -f $value, (Get-Text 'Value.Security.DefenderStoodDown') }
        elseif ($severity -ne 'OK') { $hints += Get-Text 'Hint.Security.DefenderConfirmed' }
    }

    New-Finding -Category Security -Check (Get-Text 'Check.Security.ActiveAntivirus') `
        -Severity $severity -Value $value -Hint ($hints -join ' | ')
}