diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index 3445d17c..dabf91fa 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -52,7 +52,7 @@
**新機能:**
-- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security)
+- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否します。未使用の空の NotConfigured コレクションによる誤った比較失敗を防ぎ、新しいルールの対象となる空のコレクションはマージ時に強制が有効になる可能性があるため拒否します。元の XML と未知・設定済みの内容を保持し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security)
- MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket)
- Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc7e05b9..e970095c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,7 +54,7 @@
**New Features:**
-- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; GP/CSP visibility and event-generation gaps remain explicit. (#400) (@Shirofune-Security)
+- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; unused empty NotConfigured placeholders no longer cause false comparison failures, while targeted placeholders remain blocked because merge can retain enforcement. Original XML and unknown/configured collection content stay preserved; GP/CSP visibility and event-generation gaps remain explicit. (#400) (@Shirofune-Security)
- Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket)
- Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket)
diff --git a/docs/applocker-readiness.md b/docs/applocker-readiness.md
index 26589a67..c5f3737b 100644
--- a/docs/applocker-readiness.md
+++ b/docs/applocker-readiness.md
@@ -19,6 +19,10 @@ Import accepts an **operator-supplied** native XML policy. Every included collec
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.
+An omitted **unused** collection and an empty `NotConfigured` placeholder are equivalent for initialization/readback comparisons. A placeholder must have exactly the unqualified `Type` and `EnforcementMode` attributes and no content except whitespace/comments. Empty `Enabled` or `AuditOnly` collections, rules, extensions, unknown attributes/elements/text and namespaces are **not** ignored. Unknown policy-level attributes/content also block import. Raw collections and XML remain in assessments and recovery journals, with `IsEmptyPlaceholder` and `EmptyPlaceholderCount` identifying only the recognized empty shells. Imported collections can therefore be verified alongside unused placeholders without false collection-count failures. Raw XML changes between recovery and the native write still stop the import.
+
+**An empty `NotConfigured` collection targeted for new rules remains a pre-import blocker.** Microsoft documents that a [merge into this shape can retain NotConfigured and start enforcing newly added rules](https://github.com/MicrosoftDocs/memdocs/blob/main/intune/device-configuration/endpoint-security/manage-app-control.md). WELA does not remove that collection, change its mode or assume that a serializer placeholder is safe to merge into. Review it through the existing policy authority before importing. For example, an Exe-only import can coexist with empty Dll/Msi/Script/Appx placeholders, but an existing empty Exe placeholder blocks that import. This precaution is separate from post-import readback, where unused placeholders cannot turn a verified AuditOnly Exe collection into a failure.
+
Microsoft's [Get-AppLockerPolicy documentation](https://learn.microsoft.com/en-us/powershell/module/applocker/get-applockerpolicy) limits that cmdlet to GP policies: **CSP policies are invisible**. Enrollment/provider observations are conservative blockers, not proof that CSP policy is absent. `CspPolicyState=Unknown` remains in every assessment; review other management mechanisms before choosing local import. The [merge semantics](https://learn.microsoft.com/en-us/powershell/module/applocker/set-applockerpolicy) preserve existing enforcement mode. Concurrent policy administration is not an atomic transaction with this workflow; keep the deployment window isolated and review the final readback. No automatic rollback overwrites newer policy.
Recovery: keep the backup directory outside temporary folders. `before.jsonl` contains the original local and GP policy XML, service/channel/management observations and desired policy. The prepared imported XML is retained as `appLocker-audit-import.xml`. Compare them with a fresh audit before recovery; use the existing policy authority or Local Security Policy to remove only the policy created by this run. Do not blindly restore stale effective domain policy or remove someone else's new rules. Use an isolated machine snapshot for integration tests.
@@ -30,3 +34,5 @@ Recovery: keep the backup directory outside temporary folders. `before.jsonl` co
[Microsoft WEF guidance](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection) recommends at least an audit-only policy. See Microsoft's [audit-only configuration](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/configure-an-applocker-policy-for-audit-only), [requirements](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/requirements-to-use-applocker) and [rule enforcement behavior](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/working-with-applocker-rules). Native Windows functionality only; Sysmon is out of scope.
Before closing issue #381, on an isolated patched Windows 11/member-server snapshot, export policy/service/channel state, import a reviewed audit-only policy, explicitly configure required service prerequisites, run a benign executable and script, and match the expected AppLocker event XML to their paths/user/rule collection. Confirm an enforced policy stays unchanged when this importer refuses it. Repeat for managed hosts and validate forwarding where required. CI only uses mocked mutations and actual read-only native policy/schema observations; it does not establish event generation or production deployment safety.
+
+Representation regressions cover initialization, readback, idempotence, recovery exports and final drift with empty placeholders. Windows CI additionally reads both equivalent XML shapes through `Test-AppLockerPolicy` without importing them. These tests resolve the collection-count ambiguity; they do not establish how every Windows build serializes a live merge, nor claim that a live policy import or event-generation lab has been performed.
diff --git a/scripts/AppLockerReadiness.ps1 b/scripts/AppLockerReadiness.ps1
index 9efca568..292554ff 100644
--- a/scripts/AppLockerReadiness.ps1
+++ b/scripts/AppLockerReadiness.ps1
@@ -10,6 +10,9 @@ function ConvertFrom-WelaAppLockerXml {
$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)) {
@@ -33,11 +36,18 @@ function ConvertFrom-WelaAppLockerXml {
if (@($rule.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.LocalName -notin @('Conditions', 'Exceptions') }).Count) { throw 'Unknown rule child element.' }
}
}
- $collections.Add([pscustomobject]@{ Type=$type; EnforcementMode=$mode; RuleCount=$rules.Count; PotentialEnforcement=($mode -eq 'Enabled' -or ($mode -eq 'NotConfigured' -and $rules.Count -gt 0)); Xml=$node.OuterXml })
+ # 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()); TotalRules=(@($collections.ToArray() | Measure-Object RuleCount -Sum)[0].Sum); HasEnforcement=(@($collections.ToArray() | Where-Object PotentialEnforcement).Count -gt 0) }
+ [pscustomobject]@{ Xml=$doc.OuterXml; Collections=@($collections.ToArray()); EmptyPlaceholderCount=@($collections.ToArray() | Where-Object IsEmptyPlaceholder).Count; HasUnknownPolicyData=[bool]$unknownPolicyData; TotalRules=(@($collections.ToArray() | Measure-Object RuleCount -Sum)[0].Sum); HasEnforcement=(@($collections.ToArray() | Where-Object PotentialEnforcement).Count -gt 0) }
}
function Get-WelaAppLockerHost {
@@ -136,9 +146,10 @@ function Test-WelaAppLockerPolicyMatch {
param($Snapshot, $Desired)
if ($Snapshot.LocalPolicy.Status -ne 'Observed') { return $false }
$current = $Snapshot.LocalPolicy.Policy
- if ($current.Collections.Count -ne $Desired.Collections.Count -or $current.HasEnforcement) { return $false }
+ $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 = @($current.Collections | Where-Object Type -eq $wanted.Type)
+ $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
@@ -150,8 +161,16 @@ function Assert-WelaAppLockerImportSafe {
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 }
- 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.' }
+ # -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 {
diff --git a/tests/AppLockerReadiness.Tests.ps1 b/tests/AppLockerReadiness.Tests.ps1
index 5840b61e..4e8e5d90 100644
--- a/tests/AppLockerReadiness.Tests.ps1
+++ b/tests/AppLockerReadiness.Tests.ps1
@@ -5,6 +5,10 @@ $count=0
function Assert($Condition,$Message) { if (-not $Condition) { throw $Message }; $script:count++ }
function Assert-Throws([scriptblock]$Action,$Pattern) { $message=''; try { & $Action | Out-Null } catch { $message=$_.Exception.Message }; Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'." }
$xml=''
+$placeholderNodes = @('Exe','Dll','Msi','Script','Appx') | ForEach-Object { '' }
+$placeholders = '' + ($placeholderNodes -join '') + ''
+$unusedPlaceholders = '' + (($placeholderNodes | Select-Object -Skip 1) -join '') + ''
+$readbackPlaceholders = $xml.Replace('', (($placeholderNodes | Select-Object -Skip 1) -join '') + '')
$desired=ConvertFrom-WelaAppLockerXml -Xml $xml -ForImport
Assert ($desired.TotalRules -eq 1 -and -not $desired.HasEnforcement) 'Audit-only rule must parse.'
Assert ((Get-WelaAppLockerXmlKey $xml) -ceq (Get-WelaAppLockerXmlKey ($xml.Replace('Type="Exe" EnforcementMode="AuditOnly"', 'EnforcementMode="AuditOnly" Type="Exe"')))) 'Attribute ordering cannot change compliance.'
@@ -16,11 +20,18 @@ Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace(' '
+Assert ($commented.EmptyPlaceholderCount -eq 1) 'Whitespace and comments do not turn an otherwise empty collection into policy content.'
+Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml $placeholders -ForImport } 'AuditOnly'
function Reset-Fixture {
$script:localXml=''; $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:tamper=$false; $script:validations=0
+ $script:withPlaceholders=$false
}
function Get-WelaAppLockerHost { [pscustomobject]@{Status='Candidate'; Is64BitProcess=$true; PartOfDomain=$script:domain} }
function Get-WelaAppLockerManagement { [pscustomobject]@{Status='Observed'; ManagementEntries=$script:managed; CspPolicyState='Unknown'} }
@@ -52,6 +63,7 @@ function Set-AppLockerPolicy {
$script:writes++
if ($script:reject) { throw 'native rejected policy' }
$script:localXml=[IO.File]::ReadAllText($XmlPolicy); $script:effectiveXml=$script:localXml
+ if ($script:withPlaceholders) { $script:localXml=$readbackPlaceholders; $script:effectiveXml=$script:localXml }
}
Reset-Fixture
$empty=Get-WelaAppLockerReadiness
@@ -74,18 +86,68 @@ Reset-Fixture; $script:managed=@('MDM provider')
Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'managed'
Reset-Fixture; $script:unknownPolicy=$true
Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'readable'
+Reset-Fixture; $script:localXml=$placeholders; $script:effectiveXml=$placeholders
+Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'NotConfigured.*merge may retain'
+Assert (-not (Test-WelaAppLockerPolicyMatch (Get-WelaAppLockerReadiness) $desired)) 'Empty placeholders do not satisfy a requested policy with rules.'
+Reset-Fixture; $script:localXml=$unusedPlaceholders; $script:effectiveXml=$unusedPlaceholders
+Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired
+Assert (-not (Test-WelaAppLockerPolicyMatch (Get-WelaAppLockerReadiness) $desired)) 'Untargeted empty placeholders permit initialization without pretending that requested rules already exist.'
+foreach ($scope in @('Local','Effective')) {
+ Reset-Fixture
+ if ($scope -eq 'Local') { $script:localXml=$placeholders } else { $script:effectiveXml=$placeholders }
+ Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'NotConfigured.*merge may retain'
+}
+Reset-Fixture; $script:localXml=$readbackPlaceholders; $script:effectiveXml=$readbackPlaceholders
+Assert (Test-WelaAppLockerPolicyMatch (Get-WelaAppLockerReadiness) $desired) 'One imported collection plus four empty placeholders matches the requested one-collection policy.'
+Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired
+# A RuleCount == 0 filter would incorrectly ignore each of these. Test both the
+# initial local/effective guards and the post-import comparison against real XML.
+$nonPlaceholders=@(
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ 'unknown content',
+ '',
+ '',
+ '',
+ '',
+ ($desired.Collections[0].Xml.Replace('Type="Exe"','Type="Dll"').Replace('AuditOnly','NotConfigured'))
+)
+foreach ($node in $nonPlaceholders) {
+ $policyXml='' + $node + ''
+ $parsed=ConvertFrom-WelaAppLockerXml -Xml $policyXml
+ Assert ($parsed.EmptyPlaceholderCount -eq 0 -and -not $parsed.Collections[0].IsEmptyPlaceholder) 'Configured/unknown collection content is never normalized away.'
+ foreach ($scope in @('Local','Effective')) {
+ Reset-Fixture
+ if ($scope -eq 'Local') { $script:localXml=$policyXml } else { $script:effectiveXml=$policyXml }
+ Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'preserved'
+ }
+ Reset-Fixture; $script:localXml=$xml.Replace('', $node + '')
+ Assert (-not (Test-WelaAppLockerPolicyMatch (Get-WelaAppLockerReadiness) $desired)) 'Unexpected configured/unknown collection fails readback even when the requested Exe rule matches.'
+}
+foreach ($policyXml in @($placeholders.Replace('Version="1"','Version="1" Future=""'), $placeholders.Replace('','unknown content'))) {
+ Reset-Fixture; $script:localXml=$policyXml
+ Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'Unknown policy'
+}
+Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('Version="1"','Version="1" Future=""')) -ForImport } 'Unknown policy'
$cleanup=@()
try {
- foreach ($scenario in @('apply','dry','race','failure','drift','existing','tamper')) {
+ foreach ($scenario in @('apply','dry','race','failure','drift','existing','tamper','placeholders','placeholder-dry','placeholder-drift','target-placeholder')) {
Reset-Fixture
+ if ($scenario -like 'placeholder*') { $script:localXml=$unusedPlaceholders; $script:effectiveXml=$unusedPlaceholders; $script:withPlaceholders=$true }
+ if ($scenario -eq 'target-placeholder') { $script:localXml=$placeholders; $script:effectiveXml=$placeholders }
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}
$path=Join-Path ([IO.Path]::GetTempPath()) ('wela-applocker-'+[guid]::NewGuid().ToString('N'));$cleanup+=$path
- $context=New-WelaConfigurationContext -Auto -DryRun:($scenario -eq 'dry') -BackupPath $path
+ $context=New-WelaConfigurationContext -Auto -DryRun:($scenario -in @('dry','placeholder-dry')) -BackupPath $path
Set-WelaAppLockerAuditPolicy -Context $context -Desired $desired
+ if ($scenario -eq 'placeholder-drift') { $script:localXml=$readbackPlaceholders.Replace('Type="Dll" EnforcementMode="NotConfigured"','Type="Dll" EnforcementMode="AuditOnly"') }
$result=Complete-WelaConfiguration -Context $context
switch ($scenario) {
'apply' {
@@ -100,6 +162,18 @@ try {
'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.' }
+ 'placeholders' {
+ Assert ($script:writes -eq 1 -and $result.ExitCode -eq 0 -and $result.Results[0].Status -eq 'Applied') 'Public runner imports from empty placeholders and verifies populated readback with remaining placeholders.'
+ $journal=Get-Content (Join-Path $path 'before.jsonl') | ConvertFrom-Json
+ Assert ($journal.Before.LocalPolicy.Policy.Collections.Count -eq 4 -and $journal.Before.LocalPolicy.Policy.EmptyPlaceholderCount -eq 4) 'Recovery journal preserves all original unused placeholder collection metadata.'
+ $export=$result | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ Assert ($export.Results[0].After.LocalPolicy.Policy.Collections.Count -eq 5 -and $export.Results[0].After.LocalPolicy.Policy.EmptyPlaceholderCount -eq 4) 'Result JSON distinguishes the configured collection from four retained placeholders.'
+ Set-WelaAppLockerAuditPolicy -Context $context -Desired $desired
+ Assert ($script:writes -eq 1 -and $context.Results[1].Status -eq 'AlreadyCompliant') 'Repeated import with placeholders performs no duplicate merge.'
+ }
+ 'placeholder-dry' { Assert ($script:writes -eq 0 -and -not (Test-Path $path) -and $result.Results[0].Status -eq 'Skipped') 'Placeholder normalization does not weaken dry-run guarantees.' }
+ 'placeholder-drift' { Assert ($script:writes -eq 1 -and $result.ExitCode -eq 1 -and $result.Results[0].Status -in @('Failed','Overridden')) 'Final drift from an empty placeholder into a configured empty collection remains a failure.' }
+ 'target-placeholder' { Assert ($script:writes -eq 0 -and $script:validations -eq 0 -and $result.ExitCode -eq 1 -and $result.Results[0].Diagnostic -match 'merge may retain NotConfigured') 'A targeted empty NotConfigured collection blocks before native import to avoid accidental enforcement.' }
}
}
$path=Join-Path ([IO.Path]::GetTempPath()) ('wela-applocker-existing-'+[guid]::NewGuid().ToString('N')+'.xml');$cleanup+=$path
diff --git a/tests/AppLockerReadiness.Windows.Tests.ps1 b/tests/AppLockerReadiness.Windows.Tests.ps1
index 8a1ca882..1feaa28c 100644
--- a/tests/AppLockerReadiness.Windows.Tests.ps1
+++ b/tests/AppLockerReadiness.Windows.Tests.ps1
@@ -11,6 +11,7 @@ foreach ($scope in @($report.LocalPolicy,$report.EffectiveGpPolicy)) {
# The native cmdlet parses the XML without installing it or executing the file.
if (Get-Command Test-AppLockerPolicy -ErrorAction SilentlyContinue) {
$path=Join-Path $env:TEMP ('wela-applocker-schema-'+[guid]::NewGuid().ToString('N')+'.xml')
+ $placeholderPath=$path.Replace('.xml','-placeholders.xml')
try {
$xml=''
$policy=ConvertFrom-WelaAppLockerXml -Xml $xml -ForImport
@@ -19,6 +20,18 @@ if (Get-Command Test-AppLockerPolicy -ErrorAction SilentlyContinue) {
$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 }
+ # In-memory native-readback representation: the one Exe collection plus
+ # empty NotConfigured shells for other types. Validate through the native
+ # reader only; this does not install or merge any policy.
+ $shells=(@('Dll','Msi','Script','Appx') | ForEach-Object { '' }) -join ''
+ $withPlaceholders=ConvertFrom-WelaAppLockerXml -Xml $policy.Xml.Replace('',$shells+'')
+ $snapshot=[pscustomobject]@{LocalPolicy=[pscustomobject]@{Status='Observed';Policy=$withPlaceholders}}
+ if (-not (Test-WelaAppLockerPolicyMatch $snapshot $policy) -or $withPlaceholders.EmptyPlaceholderCount -ne 4) { throw 'Placeholder readback representation did not match the requested collection.' }
+ $lock=New-WelaAppLockerImportReadLock -Path $placeholderPath -Xml $withPlaceholders.Xml
+ try {
+ $withShells=@(Test-AppLockerPolicy -XmlPolicy $placeholderPath -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop)
+ if ($withShells.Count -ne $validation.Count -or (($withShells.PolicyDecision -join ',') -cne ($validation.PolicyDecision -join ','))) { throw 'Native XML reader changed its decision with empty NotConfigured placeholders.' }
+ } finally { $lock.Dispose() }
+ } finally { Remove-Item -LiteralPath $path,$placeholderPath -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.'
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 6146fd3f..b3365dc7 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -55,7 +55,7 @@
**新機能:**
-- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security)
+- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否します。未使用の空の NotConfigured コレクションによる誤った比較失敗を防ぎ、新しいルールの対象となる空のコレクションはマージ時に強制が有効になる可能性があるため拒否します。元の XML と未知・設定済みの内容を保持し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security)
- MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket)
- Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index f4f3f065..f22e2b08 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -57,7 +57,7 @@
**New Features:**
-- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; GP/CSP visibility and event-generation gaps remain explicit. (#400) (@Shirofune-Security)
+- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; unused empty NotConfigured placeholders no longer cause false comparison failures, while targeted placeholders remain blocked because merge can retain enforcement. Original XML and unknown/configured collection content stay preserved; GP/CSP visibility and event-generation gaps remain explicit. (#400) (@Shirofune-Security)
- Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket)
- Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket)