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 } # The number is shown, and what it means before it: a code is never shown bare. New-Section -Title (Get-Text 'Title.Security.AntivirusProductsRegisteredWith') -Row @( $products | ForEach-Object { $read = ConvertFrom-AntivirusProductState -ProductState $_.ProductState $realtime = switch ($read.Realtime) { 'On' { Get-Text 'Value.Security.Realtime.On' } 'Off' { Get-Text 'Value.Security.Realtime.Off' } default { Get-Text 'Value.Security.Realtime.Unknown' } } $signatures = switch ($read.Signatures) { 'Current' { Get-Text 'Value.Security.Signatures.Current' } 'Outdated' { Get-Text 'Value.Security.Signatures.Outdated' } default { Get-Text 'Value.Security.Signatures.Unknown' } } $row = [ordered]@{} $row[(Get-Text 'Column.Security.Product')] = $_.DisplayName $row[(Get-Text 'Column.Security.State')] = (Get-Text 'Value.Security.State') -f $realtime, $signatures $row[(Get-Text 'Column.Security.Scanning')] = Get-TriStateText (Test-AntivirusScanning -ProductState $_.ProductState) $row[(Get-Text 'Column.Security.Code')] = $read.Code [pscustomobject]$row } ) $defender = Get-DataProperty $Data 'Defender' if ($defender) { $said = "$(Get-DataProperty $defender 'RunningMode')" $mode = switch -Regex ($said) { '^Normal$' { Get-Text 'Value.Security.Mode.Normal'; break } '^SxS' { Get-Text 'Value.Security.Mode.SxsPassive'; break } '^EDR' { Get-Text 'Value.Security.Mode.EdrBlock'; break } '^Passive' { Get-Text 'Value.Security.Mode.Passive'; break } default { Get-Text 'Value.Security.Mode.Other' } } $row = [ordered]@{} $row[(Get-Text 'Column.Security.Mode')] = $(if ($said) { (Get-Text 'Value.Security.Mode') -f $mode, $said } else { '' }) $row[(Get-Text 'Column.Security.RealTime')] = Get-TriStateText (Get-DataProperty $defender 'RealTimeProtection') $row[(Get-Text 'Column.Security.Enabled')] = Get-TriStateText (Get-DataProperty $defender 'AntivirusEnabled') $row[(Get-Text 'Column.Security.Scanning')] = Get-TriStateText (Test-DefenderScanning -Defender $defender) New-Section -Title (Get-Text 'Title.Security.Defender') -Row @([pscustomobject]$row) } } function ConvertFrom-AntivirusProductState { <# .SYNOPSIS What the number Security Center keeps of an antivirus product says. Pure. .DESCRIPTION Microsoft does not document productState. It is read here the way everybody who reads it does, as three bytes: the first says what kind of product it is, the second whether its on-access scanning is running - the bit 0x10 of it, which is the bit Test-AntivirusScanning goes by - and the third whether its signatures are current (0x00) or out of date (0x10). The low bit of the second byte is set by Defender on every healthy machine (0x061100) and is read as nothing: the tables that call 0x11 "snoozed" would call every Defender snoozed. A reading and not a specification, so the number is always shown beside what was read from it. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$ProductState) $state = ConvertTo-Number $ProductState if ($null -eq $state) { return [pscustomobject]@{ Realtime = 'Unknown'; Signatures = 'Unknown'; Code = "$ProductState" } } $number = [int]$state $realtime = $(if (($number -band $script:SecurityRealtimeScanningBit) -ne 0) { 'On' } else { 'Off' }) $signatures = switch ($number -band 0xFF) { 0x00 { 'Current' } 0x10 { 'Outdated' } default { 'Unknown' } } [pscustomobject]@{ Realtime = $realtime; Signatures = $signatures; Code = ('{0} (0x{0:X6})' -f $number) } } 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 ' | ') } |