Verify locked AppLocker import bytes and reject ignored options

This commit is contained in:
Shirofune-Security committed 2026-09-19 05:40:08 +09:00
1 parent 6fbef0ff6b
commit 5f240d8062
5 files changed
+70 -9

No files matched your search

+6
View File
@@ -1707,6 +1707,12 @@ Write-Host ""
Write-Host "WELA v$WELAVersion - $WELAReleaseName"
Write-Host ""
if (($PSBoundParameters.ContainsKey('AppLockerAction') -or $AppLockerPolicyPath) -and $Cmd -ne 'applocker-readiness') {
throw '-AppLockerAction and -AppLockerPolicyPath require applocker-readiness. No command was run.'
}
if ($Cmd -eq 'applocker-readiness' -and ($Profile -or $Baseline)) {
throw 'applocker-readiness uses its own operator-supplied policy, not -Profile or -Baseline. No command was run.'
}
# Reject unsupported dry-run requests before reaching any command's mutation path.
if ($DryRun -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
+3 -1
View File
@@ -13,7 +13,9 @@ The default is read-only Audit. Windows 11 clients and member servers running Se
## Scope and import safeguards
Import accepts an **operator-supplied** native XML policy. Every included collection must explicitly be AuditOnly and contain rules. XML DTDs, namespaces, unknown collection types, duplicate IDs and policy extensions are rejected. The native `Test-AppLockerPolicy` cmdlet validates the prepared XML before it can be installed; it does not execute the test file. There are no generated blanket allow rules or default policy assumptions.
AppLocker-specific options are rejected on unrelated commands; `-Profile` and `-Baseline` do not select AppLocker policy.
Import accepts an **operator-supplied** native XML policy. Every included collection must explicitly be AuditOnly and contain rules. XML DTDs, namespaces, unknown collection types, duplicate IDs and policy extensions are rejected. The prepared file is created without overwriting existing files, locked against writes, and compared byte-for-byte (length and SHA-256) with the reviewed in-memory XML before native validation. The native `Test-AppLockerPolicy` cmdlet validates that same locked file before it can be installed; it does not execute the test file. There are no generated blanket allow rules or default policy assumptions.
Import only initializes an empty local/GP policy, or verifies an identical previously imported policy. Existing configured collections, existing enforcement (including NotConfigured collections with rules), unreadable policy, domain membership, observed enrollment/provider entries or unknown management state block import. Use the organization's policy authority to manage those hosts. The workflow uses `Set-AppLockerPolicy -Merge`, retains original policy XML in the recovery journal, rechecks state before writing, and verifies local collection content again after writing and at completion. It does not replace an existing policy. An import failure is reported with a nonzero exit code. Dry-run makes no policy or recovery-file changes.
+25 -2
View File
@@ -154,6 +154,19 @@ function Assert-WelaAppLockerImportSafe {
if ($Snapshot.LocalPolicy.Policy.Collections.Count -or $Snapshot.EffectiveGpPolicy.Policy.Collections.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 }
@@ -167,12 +180,22 @@ function Set-WelaAppLockerAuditPolicy {
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'
[IO.File]::WriteAllText($path, $state.Desired.Xml, (New-Object Text.UTF8Encoding($false)))
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 = [IO.File]::Open($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
$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
+31 -3
View File
@@ -20,7 +20,7 @@ function Reset-Fixture {
$script:localXml='<AppLockerPolicy Version="1" />'; $script:effectiveXml=$script:localXml
$script:serviceState='Running'; $script:serviceMode='Auto'; $script:channelEnabled=$true
$script:domain=$false; $script:managed=@(); $script:unknownPolicy=$false; $script:writes=0; $script:readCount=0
$script:race=$false; $script:reject=$false; $script:drift=$false
$script:race=$false; $script:reject=$false; $script:drift=$false; $script:tamper=$false; $script:validations=0
}
function Get-WelaAppLockerHost { [pscustomobject]@{Status='Candidate'; Is64BitProcess=$true; PartOfDomain=$script:domain} }
function Get-WelaAppLockerManagement { [pscustomobject]@{Status='Observed'; ManagementEntries=$script:managed; CspPolicyState='Unknown'} }
@@ -37,7 +37,15 @@ function Get-WelaAppLockerPolicySnapshot {
$value=if ($Scope -eq 'Local') {$script:localXml} else {$script:effectiveXml}
[pscustomobject]@{Status='Observed'; Policy=(ConvertFrom-WelaAppLockerXml -Xml $value)}
}
function Test-AppLockerPolicy { [CmdletBinding()]param($XmlPolicy,$Path,$User) [pscustomobject]@{PolicyDecision='Allowed'} }
$script:originalImportFile = ${function:New-WelaAppLockerImportReadLock}
function New-WelaAppLockerImportReadLock {
param($Path,$Xml)
if (-not $script:tamper) { return & $script:originalImportFile -Path $Path -Xml $Xml }
# Simulate a file replaced before the read lock, without races or native policy calls.
[IO.File]::WriteAllText($Path, $Xml.Replace('AuditOnly', 'Enabled').Replace('<Conditions>', '<Conditions> '), (New-Object Text.UTF8Encoding($false)))
return [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
}
function Test-AppLockerPolicy { [CmdletBinding()]param($XmlPolicy,$Path,$User) $script:validations++; [pscustomobject]@{PolicyDecision='Allowed'} }
function Set-AppLockerPolicy {
[CmdletBinding()]param($XmlPolicy,[switch]$Merge)
if (-not $Merge) { throw 'Import must never replace a policy.' }
@@ -68,9 +76,10 @@ Reset-Fixture; $script:unknownPolicy=$true
Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'readable'
$cleanup=@()
try {
foreach ($scenario in @('apply','dry','race','failure','drift','existing')) {
foreach ($scenario in @('apply','dry','race','failure','drift','existing','tamper')) {
Reset-Fixture
if ($scenario -eq 'race') {$script:race=$true}
if ($scenario -eq 'tamper') {$script:tamper=$true}
if ($scenario -eq 'failure') {$script:reject=$true}
if ($scenario -eq 'drift') {$script:drift=$true}
if ($scenario -eq 'existing') {$script:localXml=$xml;$script:effectiveXml=$xml}
@@ -86,11 +95,30 @@ try {
Assert ($script:writes -eq 1 -and $context.Results[1].Status -eq 'AlreadyCompliant') 'Reapplying same policy should not write.'
}
'dry' { Assert ($script:writes -eq 0 -and -not (Test-Path $path)) 'Dry-run must not write policy or recovery files.' }
'tamper' { Assert ($script:writes -eq 0 -and $script:validations -eq 0 -and $result.ExitCode -eq 1 -and $result.Results[0].Diagnostic -match 'changed before its read lock') 'Altered prepared XML must fail before native validation or import, including equal-length mode tampering.' }
'race' { Assert ($script:writes -eq 0 -and $result.ExitCode -eq 1) 'Concurrent enforcement must block merge.' }
'failure' { Assert ($result.ExitCode -eq 1) 'Native write failure must propagate.' }
'drift' { Assert ($result.ExitCode -eq 1) 'Final readback must detect policy drift.' }
'existing' { Assert ($script:writes -eq 0 -and $result.ExitCode -eq 0) 'Identical policy stays unchanged.' }
}
}
$path=Join-Path ([IO.Path]::GetTempPath()) ('wela-applocker-existing-'+[guid]::NewGuid().ToString('N')+'.xml');$cleanup+=$path
[IO.File]::WriteAllText($path,'Existing unrelated file')
$rejected=$false
try { $stream=& $script:originalImportFile -Path $path -Xml $xml; $stream.Dispose() } catch { $rejected=$true }
Assert ($rejected -and [IO.File]::ReadAllText($path) -eq 'Existing unrelated file') 'Prepared import creation cannot overwrite a pre-existing file/link.'
# Execute only actual top-level option guards; no command dispatcher/mutator.
$tokens=$null;$parseErrors=$null
$ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../WELA.ps1'),[ref]$tokens,[ref]$parseErrors)
Assert ($parseErrors.Count -eq 0) 'CLI option guards parse.'
$guard=$ast.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.IfStatementAst] -and $_.Extent.Text.StartsWith("if ((`$PSBoundParameters.ContainsKey('AppLockerAction')") } | Select-Object -First 1
Assert ($null -ne $guard) 'Explicit AppLocker options must be guarded before dispatch.'
$exercise=[scriptblock]::Create('param($AppLockerAction,$AppLockerPolicyPath,$Cmd)' + [Environment]::NewLine + $guard.Extent.Text)
Assert-Throws { & $exercise -AppLockerAction Plan -Cmd configure } 'require applocker-readiness'
Assert-Throws { & $exercise -AppLockerPolicyPath 'operator.xml' -Cmd configure-sacl } 'require applocker-readiness'
& $exercise -AppLockerAction Plan -Cmd applocker-readiness
$guard=$ast.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.IfStatementAst] -and $_.Extent.Text.StartsWith("if (`$Cmd -eq 'applocker-readiness' -and (`$Profile") } | Select-Object -First 1
$Cmd='applocker-readiness';$Profile='wela-2.2.0';$Baseline=$null
Assert-Throws { & ([scriptblock]::Create($guard.Extent.Text)) } 'not -Profile or -Baseline'
} finally { foreach ($path in $cleanup) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } }
Write-Host "PASS: $count AppLocker readiness/import assertions; no Windows policies changed."
+5 -3
View File
@@ -14,9 +14,11 @@ if (Get-Command Test-AppLockerPolicy -ErrorAction SilentlyContinue) {
try {
$xml='<AppLockerPolicy Version="1"><RuleCollection Type="Exe" EnforcementMode="AuditOnly"><FilePathRule Id="12345678-1234-1234-1234-123456789abc" Name="Read-only test" Description="" UserOrGroupSid="S-1-1-0" Action="Allow"><Conditions><FilePathCondition Path="%WINDIR%\*" /></Conditions></FilePathRule></RuleCollection></AppLockerPolicy>'
$policy=ConvertFrom-WelaAppLockerXml -Xml $xml -ForImport
[IO.File]::WriteAllText($path,$policy.Xml)
$validation=@(Test-AppLockerPolicy -XmlPolicy $path -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop)
if (-not $validation.Count) { throw 'Native schema validation returned no decision.' }
$lock=New-WelaAppLockerImportReadLock -Path $path -Xml $policy.Xml
try {
$validation=@(Test-AppLockerPolicy -XmlPolicy $path -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop)
if (-not $validation.Count) { throw 'Native schema validation returned no decision.' }
} finally { $lock.Dispose() }
} finally { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue }
} else { Write-Host 'Native policy validation unavailable in this PowerShell session; importer will refuse.' }
Write-Host 'PASS: native read-only AppLocker observations. No Set-AppLockerPolicy or service changes.'