# Native AppLocker observations and a deliberately narrow local audit-only import. function ConvertFrom-WelaAppLockerXml { param([Parameter(Mandatory)][string]$Xml, [switch]$ForImport) $settings = New-Object Xml.XmlReaderSettings $settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit; $settings.XmlResolver = $null $settings.MaxCharactersInDocument = 10485760 $reader = [Xml.XmlReader]::Create((New-Object IO.StringReader($Xml)), $settings) try { $doc = New-Object Xml.XmlDocument; $doc.XmlResolver = $null $doc.Load($reader) } finally { $reader.Dispose() } if ($doc.DocumentElement.LocalName -cne 'AppLockerPolicy' -or $doc.DocumentElement.NamespaceURI -or $doc.DocumentElement.GetAttribute('Version') -ne '1') { throw 'Expected unqualified AppLockerPolicy Version=1.' } $unknownPolicyData = @($doc.DocumentElement.Attributes | Where-Object { $_.NamespaceURI -or $_.Name -cne 'Version' }).Count -gt 0 -or @($doc.DocumentElement.ChildNodes | Where-Object { $_.NodeType -notin @('Element', 'Whitespace', 'SignificantWhitespace', 'Comment') }).Count -gt 0 if ($ForImport -and $unknownPolicyData) { throw 'Unknown policy attributes/content are not accepted for import.' } $collections = New-Object 'System.Collections.Generic.List[object]' $types = @{}; $ids = @{} foreach ($node in @($doc.DocumentElement.ChildNodes | Where-Object NodeType -eq Element)) { if ($node.LocalName -ne 'RuleCollection') { throw "Unsupported AppLocker policy element: $($node.LocalName)" } $type = $node.GetAttribute('Type'); $mode = $node.GetAttribute('EnforcementMode') if ($type -notin @('Exe', 'Dll', 'Msi', 'Script', 'Appx') -or $types.ContainsKey($type)) { throw "Unknown/duplicate rule collection: $type" } if ($mode -notin @('Enabled', 'AuditOnly', 'NotConfigured')) { throw "Unknown enforcement mode: $mode" } $types[$type] = $true $rules = @($node.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.LocalName -in @('FilePathRule', 'FilePublisherRule', 'FileHashRule') }) if ($ForImport) { if ($mode -ne 'AuditOnly' -or -not $rules.Count) { throw 'Every imported collection must explicitly be AuditOnly and contain rules.' } if (@($node.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.LocalName -notin @('FilePathRule', 'FilePublisherRule', 'FileHashRule') }).Count) { throw 'Policy extensions/unknown rule elements are not accepted for import.' } foreach ($rule in $rules) { $guid = [guid]::Empty if (-not [guid]::TryParse($rule.GetAttribute('Id'), [ref]$guid) -or $ids.ContainsKey($guid.ToString())) { throw 'Rule IDs must be valid and globally unique.' } $ids[$guid.ToString()] = $true if ($rule.GetAttribute('Action') -notin @('Allow', 'Deny') -or $rule.GetAttribute('UserOrGroupSid') -notmatch '^S-1-\d+(-\d+)+$' -or -not $rule.GetAttribute('Name')) { throw 'Invalid rule action, SID or name.' } if (@($rule.SelectNodes('./Conditions')).Count -ne 1 -or -not $rule.SelectSingleNode('./Conditions/*')) { throw 'Each rule must have conditions.' } # Reject hidden extension nodes and namespaces; Windows validates the # complete native rule schema before applying the prepared snapshot. if (@($rule.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.LocalName -notin @('Conditions', 'Exceptions') }).Count) { throw 'Unknown rule child element.' } } } # Some serializers can include empty NotConfigured collection shells. # Only this exact shape is ignorable; zero rules alone is insufficient. $placeholder = $node.LocalName -ceq 'RuleCollection' -and -not $node.NamespaceURI -and $type -cin @('Exe', 'Dll', 'Msi', 'Script', 'Appx') -and $mode -ceq 'NotConfigured' -and $node.Attributes.Count -eq 2 -and @($node.Attributes | Where-Object { $_.NamespaceURI -or $_.Name -cnotin @('Type', 'EnforcementMode') }).Count -eq 0 -and @($node.ChildNodes | Where-Object { $_.NodeType -notin @('Whitespace', 'SignificantWhitespace', 'Comment') }).Count -eq 0 $collections.Add([pscustomobject]@{ Type=$type; EnforcementMode=$mode; RuleCount=$rules.Count; IsEmptyPlaceholder=[bool]$placeholder; PotentialEnforcement=($mode -eq 'Enabled' -or ($mode -eq 'NotConfigured' -and $rules.Count -gt 0)); Xml=$node.OuterXml }) } if ($ForImport -and -not $collections.Count) { throw 'An empty policy cannot supply AppLocker generation prerequisites.' } if ($ForImport -and @($doc.SelectNodes('//*') | Where-Object { $_.NamespaceURI -or @($_.Attributes | Where-Object { $_.NamespaceURI }).Count }).Count) { throw 'Namespaced policy elements/attributes are not accepted for import.' } [pscustomobject]@{ Xml=$doc.OuterXml; Collections=@($collections.ToArray()); EmptyPlaceholderCount=@($collections.ToArray() | Where-Object IsEmptyPlaceholder).Count; HasUnknownPolicyData=[bool]$unknownPolicyData; TotalRules=[int](@($collections.ToArray() | Measure-Object RuleCount -Sum)[0].Sum); HasEnforcement=(@($collections.ToArray() | Where-Object PotentialEnforcement).Count -gt 0) } } function Get-WelaAppLockerHost { try { $os = Get-CimInstance -ClassName Win32_OperatingSystem -Property BuildNumber, ProductType, Caption -ErrorAction Stop $computer = Get-CimInstance -ClassName Win32_ComputerSystem -Property PartOfDomain -ErrorAction Stop if (-not $os -or $os.BuildNumber -notmatch '^\d+$' -or $null -eq $computer -or $computer.PartOfDomain -isnot [bool]) { throw 'Host applicability or management state is unknown.' } $eligible = ($os.ProductType -eq 1 -and [int]$os.BuildNumber -ge 22000) -or ($os.ProductType -eq 3 -and [int]$os.BuildNumber -ge 14393) $state = if ($eligible) { 'Candidate' } else { 'NotApplicable' } [pscustomobject]@{ Status=$state; Build=[int]$os.BuildNumber; ProductType=[int]$os.ProductType; Caption=[string]$os.Caption; PartOfDomain=$computer.PartOfDomain; Is64BitProcess=[Environment]::Is64BitProcess; Diagnostic='Native cmdlet/service observations determine capability; no edition-only inference. Import scope is local client/member server.' } } catch { [pscustomobject]@{ Status='Unknown'; Diagnostic=$_.Exception.Message } } } function Get-WelaAppLockerPolicySnapshot { param([ValidateSet('Local', 'Effective')][string]$Scope) try { if (-not (Get-Command Get-AppLockerPolicy -ErrorAction SilentlyContinue)) { return [pscustomobject]@{ Status='CmdletUnavailable'; Policy=$null; Diagnostic='Get-AppLockerPolicy is unavailable in this PowerShell session; capability is unverified.' } } $arguments = @{ Xml=$true; ErrorAction='Stop' }; $arguments[$Scope] = $true $xml = [string](Get-AppLockerPolicy @arguments) [pscustomobject]@{ Status='Observed'; Policy=(ConvertFrom-WelaAppLockerXml -Xml $xml); Diagnostic='GP policy only. AppLocker CSP policy is not visible to this cmdlet.' } } catch { [pscustomobject]@{ Status='Unknown'; Policy=$null; Diagnostic=$_.Exception.Message } } } function Get-WelaAppLockerService { try { $service = Get-CimInstance -ClassName Win32_Service -Filter "Name='AppIDSvc'" -ErrorAction Stop if (-not $service) { return [pscustomobject]@{ Status='NotInstalled'; State=$null; StartMode=$null; Diagnostic='Application Identity service was not found.' } } [pscustomobject]@{ Status='Observed'; State=[string]$service.State; StartMode=[string]$service.StartMode; Diagnostic='Service state observed; no service changes were made.' } } catch { [pscustomobject]@{ Status='Unknown'; State=$null; StartMode=$null; Diagnostic=$_.Exception.Message } } } function Get-WelaAppLockerChannels { foreach ($name in @('EXE and DLL', 'MSI and Script', 'Packaged app-Execution', 'Packaged app-Deployment')) { $channel = "Microsoft-Windows-AppLocker/$name" try { $log = Get-WinEvent -ListLog $channel -ErrorAction Stop if (-not $log -or $log.LogName -ne $channel) { throw 'Channel read did not return the requested channel.' } [pscustomobject]@{ Channel=$channel; Status='Observed'; Enabled=[bool]$log.IsEnabled; Diagnostic='Channel enablement is not proof of event generation.' } } catch { $state = if ($_.FullyQualifiedErrorId -like 'NoMatchingLogsFound*') { 'NotInstalled' } else { 'Unknown' } [pscustomobject]@{ Channel=$channel; Status=$state; Enabled=$null; Diagnostic=$_.Exception.Message } } } } function Get-WelaAppLockerManagement { # These are blockers, not an assertion that CSP policy is absent. The native # cmdlets cannot read CSP; import is confined to apparently unmanaged hosts. try { $present = @() foreach ($path in @('HKLM:\SOFTWARE\Microsoft\Enrollments', 'HKLM:\SOFTWARE\Microsoft\PolicyManager\Providers')) { if (Test-Path -LiteralPath $path -ErrorAction Stop) { $present += @(Get-ChildItem -LiteralPath $path -ErrorAction Stop | Where-Object { $_.PSChildName -match '^\{?[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\}?$' } | ForEach-Object { $_.Name }) } } [pscustomobject]@{ Status='Observed'; ManagementEntries=$present; CspPolicyState='Unknown'; Diagnostic='No CSP policy completeness claim. Any observed enrollment/provider blocks local import.' } } catch { [pscustomobject]@{ Status='Unknown'; ManagementEntries=@(); CspPolicyState='Unknown'; Diagnostic=$_.Exception.Message } } } function Get-WelaAppLockerReadiness { $hostState = Get-WelaAppLockerHost $local = Get-WelaAppLockerPolicySnapshot Local; $effective = Get-WelaAppLockerPolicySnapshot Effective $service = Get-WelaAppLockerService; $channels = @(Get-WelaAppLockerChannels) $rows = foreach ($type in @('Exe', 'Dll', 'Msi', 'Script', 'Appx')) { $collection = @($effective.Policy.Collections | Where-Object Type -eq $type) | Select-Object -First 1 $names = switch ($type) { 'Exe' { 'EXE and DLL' } 'Dll' { 'EXE and DLL' } 'Msi' { 'MSI and Script' } 'Script' { 'MSI and Script' } 'Appx' { 'Packaged app-Execution'; 'Packaged app-Deployment' } } $logs = @($channels | Where-Object { $_.Channel.Substring('Microsoft-Windows-AppLocker/'.Length) -in $names }) $state = if ($hostState.Status -eq 'NotApplicable') { 'NotApplicable' } elseif ($hostState.Status -ne 'Candidate' -or $effective.Status -ne 'Observed' -or $service.Status -eq 'Unknown') { 'Unknown' } elseif (-not $collection -or $collection.RuleCount -eq 0) { 'MissingGpPolicy' } elseif ($service.Status -eq 'NotInstalled') { 'NotInstalled' } elseif ($service.StartMode -eq 'Disabled' -or $service.State -ne 'Running') { 'ServiceNotRunning' } elseif (@($logs | Where-Object Status -ne 'Observed').Count) { 'ChannelUnknown' } elseif (@($logs | Where-Object { -not $_.Enabled }).Count) { 'ChannelDisabled' } else { 'Conditional' } [pscustomobject]@{ Type=$type; EnforcementMode=if ($collection) {$collection.EnforcementMode} else {$null}; RuleCount=if ($collection) {$collection.RuleCount} else {0}; PotentialEnforcement=if ($collection) {$collection.PotentialEnforcement} else {$false}; PrerequisiteState=$state; Channels=$logs; GenerationReadiness='Unverified'; Diagnostic='Local/GP observations only; CSP policies and actual executable/script event XML require separate verification.' } } [pscustomobject]@{ Scope='native-applocker-readiness'; Host=$hostState; LocalPolicy=$local; EffectiveGpPolicy=$effective; Service=$service; Collections=@($rows); Management=(Get-WelaAppLockerManagement); CspPolicyState='Unknown'; UsableRuleCredit=0; GenerationReadiness='Unverified' } } function Get-WelaAppLockerXmlKey { param([string]$Xml) # Compare policy meaning without treating native XML formatting/attribute # ordering as a failed write. Rule IDs are unique, so rule order is immaterial. $document = New-Object Xml.XmlDocument; $document.XmlResolver=$null; $document.LoadXml($Xml) function Convert-WelaAppLockerNodeKey($Node) { $attributes = @($Node.Attributes | Where-Object { -not ($_.LocalName -eq 'Description' -and $_.Value -eq '') } | Sort-Object Name | ForEach-Object { @($_.Name, $_.Value) -join '=' }) $children = @($Node.ChildNodes | Where-Object NodeType -eq Element | ForEach-Object { Convert-WelaAppLockerNodeKey $_ } | Sort-Object) # JSON arrays delimit values so attribute/condition text cannot collide. return ConvertTo-Json -InputObject @($Node.LocalName, $attributes, $children) -Depth 20 -Compress } Convert-WelaAppLockerNodeKey $document.DocumentElement } function Test-WelaAppLockerPolicyMatch { param($Snapshot, $Desired) if ($Snapshot.LocalPolicy.Status -ne 'Observed') { return $false } $current = $Snapshot.LocalPolicy.Policy $currentCollections = @($current.Collections | Where-Object { -not $_.IsEmptyPlaceholder }) if ($currentCollections.Count -ne $Desired.Collections.Count -or $current.HasEnforcement -or $current.HasUnknownPolicyData) { return $false } foreach ($wanted in $Desired.Collections) { $actual = @($currentCollections | Where-Object Type -eq $wanted.Type) if ($actual.Count -ne 1 -or (Get-WelaAppLockerXmlKey $actual[0].Xml) -cne (Get-WelaAppLockerXmlKey $wanted.Xml)) { return $false } } return $true } function Assert-WelaAppLockerImportSafe { param($Snapshot, $Desired) if ($Snapshot.Host.Status -ne 'Candidate' -or -not $Snapshot.Host.Is64BitProcess) { throw 'Local import requires a supported 64-bit Windows client/member-server session.' } if ($Snapshot.Host.PartOfDomain -or $Snapshot.Management.Status -ne 'Observed' -or @($Snapshot.Management.ManagementEntries).Count) { throw 'Local import is blocked on domain-joined, managed or unknown-management hosts. Deploy through the existing policy authority.' } if ($Snapshot.LocalPolicy.Status -ne 'Observed' -or $Snapshot.EffectiveGpPolicy.Status -ne 'Observed') { throw 'Both local and GP effective policies must be readable.' } if ($Snapshot.LocalPolicy.Policy.HasEnforcement -or $Snapshot.EffectiveGpPolicy.Policy.HasEnforcement) { throw 'Existing enforcement (including NotConfigured collections with rules) is preserved; audit-only import is blocked.' } if ($Snapshot.LocalPolicy.Policy.HasUnknownPolicyData -or $Snapshot.EffectiveGpPolicy.Policy.HasUnknownPolicyData) { throw 'Unknown policy attributes/content are preserved; audit-only import is blocked.' } if (Test-WelaAppLockerPolicyMatch -Snapshot $Snapshot -Desired $Desired) { return } # -Merge preserves target enforcement settings. A currently empty # NotConfigured target can enforce the new rules once merged. Only unused # placeholders are safe to ignore before an import; do not remove/change them. $targetPlaceholders = @(@($Snapshot.LocalPolicy.Policy.Collections) + @($Snapshot.EffectiveGpPolicy.Policy.Collections) | Where-Object { $_.IsEmptyPlaceholder -and $_.Type -in $Desired.Collections.Type }) if ($targetPlaceholders.Count) { throw ('Empty NotConfigured collection(s) targeted by this import are preserved: ' + (($targetPlaceholders.Type | Select-Object -Unique) -join ', ') + '. A merge may retain NotConfigured and enforce newly added rules; review these collections through the existing policy authority before importing.') } if (@($Snapshot.LocalPolicy.Policy.Collections | Where-Object { -not $_.IsEmptyPlaceholder }).Count -or @($Snapshot.EffectiveGpPolicy.Policy.Collections | Where-Object { -not $_.IsEmptyPlaceholder }).Count) { throw 'Existing policy is preserved. Import only initializes an empty local/GP policy; it never replaces a configured policy.' } } function New-WelaAppLockerImportReadLock { param([string]$Path, [string]$Xml) # CreateNew refuses a pre-existing file/link in the backup directory. Native # readers generally require that the writer handle has already been closed. $writer = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) try { $bytes = [Text.Encoding]::UTF8.GetBytes($Xml) $writer.Write($bytes, 0, $bytes.Length) $writer.Flush() } finally { $writer.Dispose() } return [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) } function Set-WelaAppLockerAuditPolicy { param($Context, $Desired) $state = @{ Desired=$Desired; Before=$null; Context=$Context } $read = { param($state) $snapshot = Get-WelaAppLockerReadiness; Assert-WelaAppLockerImportSafe $snapshot $state.Desired; $state.Before=$snapshot; return $snapshot } $test = { param($snapshot, $state) Test-WelaAppLockerPolicyMatch $snapshot $state.Desired } $apply = { param($state) $fresh = Get-WelaAppLockerReadiness Assert-WelaAppLockerImportSafe $fresh $state.Desired if ($fresh.LocalPolicy.Policy.Xml -cne $state.Before.LocalPolicy.Policy.Xml -or $fresh.EffectiveGpPolicy.Policy.Xml -cne $state.Before.EffectiveGpPolicy.Policy.Xml) { throw 'AppLocker policy changed after the recovery snapshot; no policy was imported.' } if (-not (Get-Command Set-AppLockerPolicy -ErrorAction SilentlyContinue)) { throw 'Set-AppLockerPolicy is unavailable in this session.' } # Import the validated in-memory snapshot, not a mutable operator source file. $path = Join-Path $state.Context.BackupPath 'appLocker-audit-import.xml' if (-not (Get-Command Test-AppLockerPolicy -ErrorAction SilentlyContinue)) { throw 'Test-AppLockerPolicy is unavailable; native schema validation is required before import.' } # Deny concurrent modification/deletion of the prepared XML while both # native cmdlets consume it; they need only read access. $lock = New-WelaAppLockerImportReadLock -Path $path -Xml $state.Desired.Xml try { # The file can be replaced between writer-close and read-lock-open. # Validate the locked bytes against the already reviewed snapshot, # since native schema validation alone also accepts enforcing XML. $expectedBytes = [Text.Encoding]::UTF8.GetBytes($state.Desired.Xml) if ($lock.Length -ne $expectedBytes.Length) { throw 'Prepared AppLocker XML changed before its read lock; no policy was imported.' } $hasher = [Security.Cryptography.SHA256]::Create() try { $expectedHash = [Convert]::ToBase64String($hasher.ComputeHash($expectedBytes)) $actualHash = [Convert]::ToBase64String($hasher.ComputeHash($lock)) if ($actualHash -cne $expectedHash) { throw 'Prepared AppLocker XML changed before its read lock; no policy was imported.' } } finally { $hasher.Dispose() } $validation = @(Test-AppLockerPolicy -XmlPolicy $path -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop) if (-not $validation.Count) { throw 'Native policy validation returned no result; no policy was imported.' } $immediate = Get-WelaAppLockerReadiness Assert-WelaAppLockerImportSafe $immediate $state.Desired if ($immediate.LocalPolicy.Policy.Xml -cne $state.Before.LocalPolicy.Policy.Xml -or $immediate.EffectiveGpPolicy.Policy.Xml -cne $state.Before.EffectiveGpPolicy.Policy.Xml) { throw 'Policy changed during native validation; no policy was imported.' } Set-AppLockerPolicy -XmlPolicy $path -Merge -ErrorAction Stop } finally { $lock.Dispose() } 'Audit-only local policy merged. Service, event generation, CSP state and future policy refresh are not configured or verified.' } Invoke-WelaConfigurationControl -Context $Context -Id 'AppLocker/LocalAuditOnlyPolicy' -Kind AppLocker -Target 'Local GPO' -Desired $Desired ` -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Initialize empty local AppLocker policy from this operator-supplied audit-only XML; preserve existing policies.' } function Invoke-WelaAppLockerCommand { param([ValidateSet('Audit','Plan','Import')][string]$Action='Audit', [string]$PolicyPath, [switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath) if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'AppLocker readiness requires Windows.' } if ($DryRun -and $Action -ne 'Import') { throw '-DryRun applies only to AppLockerAction Import.' } if ($Action -eq 'Import' -and -not $PolicyPath) { throw '-AppLockerPolicyPath is required for Import.' } $desired = $null if ($PolicyPath) { $desired = ConvertFrom-WelaAppLockerXml -Xml (Get-Content -LiteralPath $PolicyPath -Raw -ErrorAction Stop) -ForImport } if ($Action -eq 'Import') { $context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath Set-WelaAppLockerAuditPolicy -Context $context -Desired $desired $report = Complete-WelaConfiguration -Context $context -Scope 'native-windows-configuration' -SuccessMessage 'Requested local audit-only policy verified; AppLocker event generation remains unverified.' $report | Add-Member NoteProperty VerificationScope 'Local audit-only policy readback only; no service changes, CSP assessment, event-generation or forwarding verification.' } else { $assessment = Get-WelaAppLockerReadiness $blocker = $null if ($desired) { try { Assert-WelaAppLockerImportSafe $assessment $desired } catch { $blocker=$_.Exception.Message } } $report = [pscustomobject]@{ Scope='native-applocker-readiness'; Action=$Action; Assessment=$assessment; ProposedAuditPolicy=$desired; ImportBlocker=$blocker; ExitCode=0 } if ($assessment.Host.Status -eq 'Unknown' -or $assessment.LocalPolicy.Status -in @('Unknown','CmdletUnavailable') -or $assessment.EffectiveGpPolicy.Status -in @('Unknown','CmdletUnavailable')) { $report.ExitCode=1 } } if ($ResultsPath) { try { $report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } catch { $report.ExitCode=1; Write-Host "[Failed] Writing AppLocker results: $_" -ForegroundColor Red } } return $report }