diff --git a/CHANGELOG.MD b/CHANGELOG.MD
index d56c414..f21d091 100644
--- a/CHANGELOG.MD
+++ b/CHANGELOG.MD
@@ -1,5 +1,12 @@
# GPOZaurr Release History
+## Unreleased
+- Fix `WindowsDefender` content detection for newer ADMX categories by supporting both:
+ - `Windows Components/Windows Defender*`
+ - `Windows Components/Microsoft Defender Antivirus*`
+- Fix `WindowsDefenderExploitGuard` detection for legacy/new category naming variants.
+- Add Defender registry fallback parsing (`SOFTWARE\Microsoft\Windows Defender*`) so Defender settings that land in `RegistrySettings` are still surfaced in `WindowsDefender` report output. [#81](https://github.com/EvotecIT/GPOZaurr/issues/81)
+
## 1.1.9 - 2024.12.02
- Fixes `Invoke-GPOZaurr` when using SplitReports without path [#58](https://github.com/EvotecIT/GPOZaurr/issues/58)
diff --git a/Private/ConvertTo-XMLGenericPolicy.ps1 b/Private/ConvertTo-XMLGenericPolicy.ps1
index e247531..3dac113 100644
--- a/Private/ConvertTo-XMLGenericPolicy.ps1
+++ b/Private/ConvertTo-XMLGenericPolicy.ps1
@@ -92,7 +92,26 @@
#>
if ($Value.Value.Element) {
- $Settings["$SubName"] = $Value.Value.Element.Data -join '; '
+ [Array] $ElementValues = foreach ($Element in @($Value.Value.Element)) {
+ if (-not $Element) {
+ continue
+ }
+ $ElementName = [string] $Element.Name
+ $ElementData = [string] $Element.Data
+
+ if (-not [string]::IsNullOrWhiteSpace($ElementName) -and ($ElementData -eq '' -or $ElementData -eq '0')) {
+ $ElementName
+ } elseif (-not [string]::IsNullOrWhiteSpace($ElementName) -and -not [string]::IsNullOrWhiteSpace($ElementData) -and $ElementName -ne $ElementData) {
+ "$ElementName ($ElementData)"
+ } elseif (-not [string]::IsNullOrWhiteSpace($ElementData)) {
+ $ElementData
+ } elseif (-not [string]::IsNullOrWhiteSpace($ElementName)) {
+ $ElementName
+ }
+ }
+ if ($ElementValues.Count -gt 0) {
+ $Settings["$SubName"] = $ElementValues -join '; '
+ }
} elseif ($null -eq $Value.Value.Name) {
# Shouldn't happen but lets see
Write-Verbose "Tracking $Value"
@@ -174,7 +193,26 @@
#>
if ($Value.Value.Element) {
- $CreateGPO["$SubName"] = $Value.Value.Element.Data -join '; '
+ [Array] $ElementValues = foreach ($Element in @($Value.Value.Element)) {
+ if (-not $Element) {
+ continue
+ }
+ $ElementName = [string] $Element.Name
+ $ElementData = [string] $Element.Data
+
+ if (-not [string]::IsNullOrWhiteSpace($ElementName) -and ($ElementData -eq '' -or $ElementData -eq '0')) {
+ $ElementName
+ } elseif (-not [string]::IsNullOrWhiteSpace($ElementName) -and -not [string]::IsNullOrWhiteSpace($ElementData) -and $ElementName -ne $ElementData) {
+ "$ElementName ($ElementData)"
+ } elseif (-not [string]::IsNullOrWhiteSpace($ElementData)) {
+ $ElementData
+ } elseif (-not [string]::IsNullOrWhiteSpace($ElementName)) {
+ $ElementName
+ }
+ }
+ if ($ElementValues.Count -gt 0) {
+ $CreateGPO["$SubName"] = $ElementValues -join '; '
+ }
} elseif ($null -eq $Value.Value.Name) {
# Shouldn't happen but lets see
Write-Verbose "Tracking $Value"
@@ -204,4 +242,4 @@
#}
}
}
-}
\ No newline at end of file
+}
diff --git a/Private/ConvertTo-XMLRegistryDefenderOnReport.ps1 b/Private/ConvertTo-XMLRegistryDefenderOnReport.ps1
new file mode 100644
index 0000000..139f49d
--- /dev/null
+++ b/Private/ConvertTo-XMLRegistryDefenderOnReport.ps1
@@ -0,0 +1,79 @@
+function ConvertTo-XMLRegistryDefenderOnReport {
+ <#
+ .SYNOPSIS
+ Converts Defender-related raw registry settings from the RegistrySettings report.
+
+ .DESCRIPTION
+ This function is used as a fallback for Defender settings that are not exposed as
+ policy categories but are still present in RegistrySettings output.
+
+ .PARAMETER GPO
+ RegistrySettings report object generated by ConvertTo-XMLRegistrySettings -SingleObject.
+ #>
+ [cmdletBinding()]
+ param(
+ [PSCustomObject] $GPO
+ )
+
+ [Array] $RegistrySettings = @()
+ if ($GPO.Settings) {
+ $RegistrySettings = $GPO.Settings
+ } elseif ($GPO.DataSet) {
+ # This path supports direct use from dictionary code where RegistrySettings is not requested explicitly.
+ [Array] $DataSet = $GPO.DataSet
+ if ($DataSet.Count -gt 0 -and (
+ $DataSet[0].PSObject.Properties.Name -contains 'Properties' -or
+ $DataSet[0].PSObject.Properties.Name -contains 'Registry' -or
+ $DataSet[0].PSObject.Properties.Name -contains 'Collection'
+ )
+ ) {
+ $RegistrySettings = Get-XMLNestedRegistry -GPO $GPO -DataSet $GPO.DataSet
+ }
+ }
+
+ $UsedNames = [System.Collections.Generic.List[string]]::new()
+ $CreateGPO = [ordered] @{
+ DisplayName = $GPO.DisplayName
+ DomainName = $GPO.DomainName
+ GUID = $GPO.GUID
+ GpoType = $GPO.GpoType
+ }
+ $DefenderSettings = 0
+
+ foreach ($Registry in $RegistrySettings) {
+ if ($Registry.Key -like 'SOFTWARE\Microsoft\Windows Defender*') {
+ $DefenderSettings++
+ $SettingName = if ($Registry.Name) { $Registry.Name } else { $Registry.Key }
+ $PropertyName = Format-ToTitleCase -Text $SettingName -RemoveWhiteSpace -RemoveChar ',', '-', "'", '\(', '\)', ':'
+
+ if ($PropertyName -in $UsedNames) {
+ $UsedNames.Add($PropertyName)
+ $TimesUsed = ($UsedNames | Group-Object | Where-Object { $_.Name -eq $PropertyName }).Count
+ $PropertyName = -join ($PropertyName, "$TimesUsed")
+ } else {
+ $UsedNames.Add($PropertyName)
+ }
+
+ $SettingValue = $Registry.Value
+ if ($null -eq $SettingValue -or "$SettingValue" -eq '') {
+ $SettingValue = $Registry.Name
+ }
+ $CreateGPO[$PropertyName] = $SettingValue
+
+ # Some Defender data can be encoded in either value or value-name depending on setting type.
+ if ($Registry.Name -and "$SettingValue" -ne "$($Registry.Name)") {
+ $CreateGPO["$($PropertyName)ValueName"] = $Registry.Name
+ }
+ if ($Registry.Key) {
+ $CreateGPO["$($PropertyName)RegistryKey"] = $Registry.Key
+ }
+ }
+ }
+
+ if ($DefenderSettings -gt 0) {
+ $CreateGPO['Linked'] = $GPO.Linked
+ $CreateGPO['LinksCount'] = $GPO.LinksCount
+ $CreateGPO['Links'] = $GPO.Links
+ [PSCustomObject] $CreateGPO
+ }
+}
diff --git a/Private/Script.GPODictionary.ps1 b/Private/Script.GPODictionary.ps1
index 5c1ace7..a774ccf 100644
--- a/Private/Script.GPODictionary.ps1
+++ b/Private/Script.GPODictionary.ps1
@@ -1169,13 +1169,26 @@
Category = 'RegistrySettings'
Settings = 'Policy'
}
+ @{
+ Category = 'RegistrySettings'
+ Settings = 'RegistrySettings'
+ }
+ )
+ GPOPath = @(
+ 'Policies -> Administrative Templates -> Windows Components/Windows Defender'
+ 'Policies -> Administrative Templates -> Windows Components/Microsoft Defender Antivirus'
)
- GPOPath = 'Policies -> Administrative Templates -> Windows Components/Windows Defender'
Code = {
- ConvertTo-XMLGenericPolicy -GPO $GPO -Category 'Windows Components/Windows Defender*'
+ @(
+ ConvertTo-XMLGenericPolicy -GPO $GPO -Category 'Windows Components/Windows Defender*', 'Windows Components/Microsoft Defender Antivirus*'
+ ConvertTo-XMLRegistryDefenderOnReport -GPO $GPO
+ ) | Where-Object { $_ }
}
CodeSingle = {
- ConvertTo-XMLGenericPolicy -GPO $GPO -Category 'Windows Components/Windows Defender*' -SingleObject
+ @(
+ ConvertTo-XMLGenericPolicy -GPO $GPO -Category 'Windows Components/Windows Defender*', 'Windows Components/Microsoft Defender Antivirus*' -SingleObject
+ ConvertTo-XMLRegistryDefenderOnReport -GPO $GPO
+ ) | Where-Object { $_ }
}
}
WindowsDefenderExploitGuard = @{
@@ -1186,12 +1199,15 @@
Settings = 'Policy'
}
)
- GPOPath = 'Policies -> Administrative Templates -> Windows Components/Microsoft Defender Antivirus/Microsoft Defender Exploit Guard'
+ GPOPath = @(
+ 'Policies -> Administrative Templates -> Windows Components/Windows Defender/Windows Defender Exploit Guard'
+ 'Policies -> Administrative Templates -> Windows Components/Microsoft Defender Antivirus/Microsoft Defender Exploit Guard'
+ )
Code = {
- ConvertTo-XMLGenericPolicy -GPO $GPO -Category 'Windows Components/Microsoft Defender Antivirus/Microsoft Defender Exploit Guard*'
+ ConvertTo-XMLGenericPolicy -GPO $GPO -Category 'Windows Components/Windows Defender*/Windows Defender Exploit Guard*', 'Windows Components/Microsoft Defender Antivirus/Microsoft Defender Exploit Guard*', 'Windows Components/Microsoft Defender Antivirus/Windows Defender Exploit Guard*'
}
CodeSingle = {
- ConvertTo-XMLGenericPolicy -GPO $GPO -Category 'Windows Components/Microsoft Defender Antivirus/Microsoft Defender Exploit Guard*' -SingleObject
+ ConvertTo-XMLGenericPolicy -GPO $GPO -Category 'Windows Components/Windows Defender*/Windows Defender Exploit Guard*', 'Windows Components/Microsoft Defender Antivirus/Microsoft Defender Exploit Guard*', 'Windows Components/Microsoft Defender Antivirus/Windows Defender Exploit Guard*' -SingleObject
}
}
# WindowsFirewall = @{
@@ -1428,4 +1444,4 @@
ConvertTo-XMLGenericPolicy -GPO $GPO -Category 'Windows Components/Windows Update*', 'Windows Components/Delivery Optimization*' -SingleObject
}
}
-}
\ No newline at end of file
+}
diff --git a/Public/Invoke-GPOZaurrContent.ps1 b/Public/Invoke-GPOZaurrContent.ps1
index f17b80d..1a1197d 100644
--- a/Public/Invoke-GPOZaurrContent.ps1
+++ b/Public/Invoke-GPOZaurrContent.ps1
@@ -287,6 +287,39 @@
}
}
}
+ if ($Output['Reports']['WindowsDefender']) {
+ # Defender can be represented by policy categories and raw registry settings.
+ # Merge entries per GPO so fallback fields do not create duplicated-looking rows.
+ $MergedWindowsDefender = [System.Collections.Generic.List[PSCustomObject]]::new()
+ $GroupedWindowsDefender = $Output['Reports']['WindowsDefender'] | Group-Object -Property DisplayName, DomainName, GUID, GpoType
+ foreach ($Group in $GroupedWindowsDefender) {
+ $MergedObject = [ordered] @{}
+ foreach ($Entry in $Group.Group) {
+ foreach ($Property in $Entry.PSObject.Properties) {
+ if (-not $MergedObject.Contains($Property.Name)) {
+ $MergedObject[$Property.Name] = $Property.Value
+ } else {
+ $CurrentValue = $MergedObject[$Property.Name]
+ $CurrentEmpty = $null -eq $CurrentValue -or ($CurrentValue -is [string] -and $CurrentValue -eq '')
+ $NewEmpty = $null -eq $Property.Value -or ($Property.Value -is [string] -and $Property.Value -eq '')
+ if ($CurrentEmpty -and -not $NewEmpty) {
+ $MergedObject[$Property.Name] = $Property.Value
+ } elseif (-not $CurrentEmpty -and -not $NewEmpty -and "$CurrentValue" -ne "$($Property.Value)") {
+ $PropertyCounter = 2
+ $AlternativeName = -join ($Property.Name, "$PropertyCounter")
+ while ($MergedObject.Contains($AlternativeName)) {
+ $PropertyCounter++
+ $AlternativeName = -join ($Property.Name, "$PropertyCounter")
+ }
+ $MergedObject[$AlternativeName] = $Property.Value
+ }
+ }
+ }
+ }
+ $MergedWindowsDefender.Add([PSCustomObject] $MergedObject)
+ }
+ $Output['Reports']['WindowsDefender'] = $MergedWindowsDefender
+ }
# Normalize - meaning that before we return each GPO report we make sure that each entry has the same column names regardless which one is first.
# Normally if you would have a GPO with just 2 entries for given subject (say LAPS), and then another GPO having 5 settings for the same type
# and you would display them one after another - all entries would be shown using first object which has less properties then 2nd or 3rd object
@@ -352,4 +385,4 @@
$Script:GPODitionary.Keys | Sort-Object | Where-Object { $_ -like "*$wordToComplete*" }
}
-Register-ArgumentCompleter -CommandName Invoke-GPOZaurrContent -ParameterName Type -ScriptBlock $SourcesAutoCompleter
\ No newline at end of file
+Register-ArgumentCompleter -CommandName Invoke-GPOZaurrContent -ParameterName Type -ScriptBlock $SourcesAutoCompleter
diff --git a/Tests/DefenderContent.Tests.ps1 b/Tests/DefenderContent.Tests.ps1
new file mode 100644
index 0000000..05398db
--- /dev/null
+++ b/Tests/DefenderContent.Tests.ps1
@@ -0,0 +1,134 @@
+Describe 'Defender content detection' {
+ BeforeAll {
+ Import-Module $PSScriptRoot\..\*.psd1 -Force
+ }
+
+ It 'WindowsDefender dictionary supports old and new categories' {
+ InModuleScope GPOZaurr {
+ $Entry = $Script:GPODitionary['WindowsDefender']
+ $Entry.GPOPath | Should -Contain 'Policies -> Administrative Templates -> Windows Components/Windows Defender'
+ $Entry.GPOPath | Should -Contain 'Policies -> Administrative Templates -> Windows Components/Microsoft Defender Antivirus'
+ ($Entry.Types | Where-Object { $_.Category -eq 'RegistrySettings' -and $_.Settings -eq 'RegistrySettings' }).Count | Should -BeGreaterOrEqual 1
+ $Entry.Code.ToString() | Should -Match 'ConvertTo-XMLRegistryDefenderOnReport'
+ }
+ }
+
+ It 'WindowsDefenderExploitGuard supports category variants' {
+ InModuleScope GPOZaurr {
+ $Entry = $Script:GPODitionary['WindowsDefenderExploitGuard']
+ $Entry.GPOPath | Should -Contain 'Policies -> Administrative Templates -> Windows Components/Windows Defender/Windows Defender Exploit Guard'
+ $Entry.GPOPath | Should -Contain 'Policies -> Administrative Templates -> Windows Components/Microsoft Defender Antivirus/Microsoft Defender Exploit Guard'
+ $Entry.Code.ToString() | Should -Match 'Windows Components/Microsoft Defender Antivirus/Windows Defender Exploit Guard\*'
+ }
+ }
+
+ It 'ConvertTo-XMLRegistryDefenderOnReport returns only Defender registry settings' {
+ InModuleScope GPOZaurr {
+ $GPO = [PSCustomObject] @{
+ DisplayName = 'Test Defender GPO'
+ DomainName = 'contoso.com'
+ GUID = '11111111-1111-1111-1111-111111111111'
+ GpoType = 'Computer'
+ Linked = $true
+ LinksCount = 1
+ Links = @('OU=Workstations,DC=contoso,DC=com')
+ Settings = @(
+ [PSCustomObject] @{
+ Hive = 'HKEY_LOCAL_MACHINE'
+ Key = 'SOFTWARE\Microsoft\Windows Defender\MpEngine'
+ Name = 'MpFolderScanThreadCount'
+ Type = 'REG_DWORD'
+ Value = '4'
+ Changed = [datetime] '2026-02-18T11:15:00'
+ Filters = $null
+ }
+ [PSCustomObject] @{
+ Hive = 'HKEY_LOCAL_MACHINE'
+ Key = 'SOFTWARE\Contoso\Other'
+ Name = 'Setting'
+ Type = 'REG_SZ'
+ Value = 'Value'
+ Changed = [datetime] '2026-02-18T11:15:00'
+ Filters = $null
+ }
+ )
+ }
+
+ [Array] $Result = ConvertTo-XMLRegistryDefenderOnReport -GPO $GPO
+ $Result.Count | Should -Be 1
+ $Result[0].MpFolderScanThreadCount | Should -Be '4'
+ $Result[0].MpFolderScanThreadCountRegistryKey | Should -Be 'SOFTWARE\Microsoft\Windows Defender\MpEngine'
+ }
+ }
+
+ It 'ConvertTo-XMLRegistryDefenderOnReport supports raw DataSet input' {
+ InModuleScope GPOZaurr {
+ $GPO = [PSCustomObject] @{
+ DisplayName = 'Test Defender GPO'
+ DomainName = 'contoso.com'
+ GUID = '11111111-1111-1111-1111-111111111111'
+ GpoType = 'Computer'
+ Linked = $true
+ LinksCount = 1
+ Links = @('OU=Workstations,DC=contoso,DC=com')
+ DataSet = ([xml] @"
+
+
+
+
+
+"@).Root.Registry
+ }
+
+ [Array] $Result = ConvertTo-XMLRegistryDefenderOnReport -GPO $GPO
+ $Result.Count | Should -Be 1
+ $Result[0].MpFolderScanThreadCount | Should -Be '4'
+ $Result[0].MpFolderScanThreadCountRegistryKey | Should -Be 'SOFTWARE\Microsoft\Windows Defender\MpEngine'
+ }
+ }
+
+ It 'ConvertTo-XMLGenericPolicy prefers list item names over zero-only data' {
+ InModuleScope GPOZaurr {
+ [xml] $ListValue = @"
+
+
+ C:\PathOne
+ 0
+
+
+ D:\PathTwo
+ 0
+
+
+"@
+
+ $GPO = [PSCustomObject] @{
+ DisplayName = 'Test Defender GPO'
+ DomainName = 'contoso.com'
+ GUID = '11111111-1111-1111-1111-111111111111'
+ GpoType = 'Computer'
+ Linked = $true
+ LinksCount = 1
+ Links = @('OU=Workstations,DC=contoso,DC=com')
+ DataSet = @(
+ [PSCustomObject] @{
+ Category = 'Windows Components/Microsoft Defender Antivirus/Exclusions'
+ Name = 'Path Exclusions'
+ State = 'Enabled'
+ ListBox = @(
+ [PSCustomObject] @{
+ Name = 'Path Exclusions'
+ Value = $ListValue.Value
+ }
+ )
+ }
+ )
+ }
+
+ $Result = ConvertTo-XMLGenericPolicy -GPO $GPO -Category 'Windows Components/Microsoft Defender Antivirus*'
+ $Result.PathExclusionsPathExclusions | Should -Match 'C:\\PathOne'
+ $Result.PathExclusionsPathExclusions | Should -Match 'D:\\PathTwo'
+ $Result.PathExclusionsPathExclusions | Should -Not -Be '0; 0'
+ }
+ }
+}