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 ' | ') } |