Public/Test-DefenderDcPolicyXml.ps1
|
function Test-DefenderDcPolicyXml { <# .SYNOPSIS Validate a Defender Device Control policy XML before deploying it. .DESCRIPTION Three-layer validation for a Groups or Rules policy XML: 1. Structural parse - is it valid XML, with the expected root element? 2. Format constraints - no UTF-8 BOM, no <?xml ... ?> declaration, Name as child element (not attribute) on PolicyRule, Options bitmask in 0..3. 3. Engine-side - MpCmdRun.exe -DeviceControl -TestPolicyXml (skipped silently if MpCmdRun.exe is not on this box, e.g. CI runners). Each layer fails with a specific, named error so authors of custom XML know exactly which constraint they violated. Returns $true on full pass, $false on any failure (plus a Write-Error describing which constraint). .PARAMETER Path Path to the XML file to validate. .PARAMETER Kind Groups (for a PolicyGroups XML) or Rules (for a PolicyRules XML). .EXAMPLE Test-DefenderDcPolicyXml -Path .\MyGroups.xml -Kind Groups Validate a custom Groups XML. .EXAMPLE Test-DefenderDcPolicyXml -Path .\MyRules.xml -Kind Rules Validate a custom Rules XML, including engine-side check on Windows. .LINK https://lukeevanstech.github.io/defender-device-control-unmanaged/howto/validate-custom-xml/ #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][ValidateSet('Groups','Rules')][string] $Kind ) Set-StrictMode -Version Latest if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { Write-Error "Test-DefenderDcPolicyXml: file not found: $Path" return $false } # Layer 2a: BOM check $bytes = [System.IO.File]::ReadAllBytes($Path) if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { Write-Error "Test-DefenderDcPolicyXml: file starts with a UTF-8 BOM. MpCmdRun rejects BOM-prefixed XML. Save the file as UTF-8 without BOM." return $false } $content = Get-Content -LiteralPath $Path -Raw # Layer 2b: <?xml ... ?> declaration check if ($content.TrimStart() -match '^<\?xml') { Write-Error "Test-DefenderDcPolicyXml: file begins with an xml declaration (<?xml ... ?>). MpCmdRun rejects XML files that include the declaration; remove it and start the file with the root element directly." return $false } # Layer 1: structural parse try { [xml]$doc = $content } catch { Write-Error "Test-DefenderDcPolicyXml: XML failed to parse: $($_.Exception.Message)" return $false } $rootName = $doc.DocumentElement.LocalName $expectedRoot = if ($Kind -eq 'Groups') { 'Groups' } else { 'PolicyRules' } if ($rootName -ne $expectedRoot) { Write-Error "Test-DefenderDcPolicyXml: expected root element <$expectedRoot> for -Kind $Kind, found <$rootName>." return $false } # Layer 2c: Rules-specific format constraints if ($Kind -eq 'Rules') { $rules = @($doc.PolicyRules.PolicyRule) foreach ($r in $rules) { $nameAttr = $r.Attributes['Name'] if ($null -ne $nameAttr) { Write-Error "Test-DefenderDcPolicyXml: PolicyRule Id='$($r.Id)' has Name as an attribute. MpCmdRun requires Name as a child element (<Name>...</Name>)." return $false } $entries = @($r.SelectNodes('Entry')) foreach ($e in $entries) { $opt = $e.SelectSingleNode('Options') if ($null -ne $opt) { $v = [int]$opt.InnerText if ($v -lt 0 -or $v -gt 3) { Write-Error "Test-DefenderDcPolicyXml: Entry Id='$($e.Id)' has <Options>$v</Options>. The Options bitmask is 2-bit; valid values are 0, 1, 2, or 3." return $false } } } } } # Layer 3: engine-side via MpCmdRun (skipped silently if MpCmdRun absent) try { Test-DcXmlWithMpCmdRun -XmlPath $Path -Kind $Kind } catch { Write-Error "Test-DefenderDcPolicyXml: engine-side validation failed: $($_.Exception.Message)" return $false } return $true } |