diff --git a/.github/workflows/applocker-readiness.yml b/.github/workflows/applocker-readiness.yml new file mode 100644 index 00000000..04e05238 --- /dev/null +++ b/.github/workflows/applocker-readiness.yml @@ -0,0 +1,25 @@ +name: AppLocker readiness tests +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + applocker-readiness: + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Safety and readiness fixtures on Windows PowerShell 5.1 + shell: powershell + run: ./tests/AppLockerReadiness.Tests.ps1 + - name: Native read-only observations on Windows PowerShell 5.1 + shell: powershell + run: ./tests/AppLockerReadiness.Windows.Tests.ps1 + - name: Safety and readiness fixtures on PowerShell 7 + shell: pwsh + run: ./tests/AppLockerReadiness.Tests.ps1 + - name: Native read-only observations on PowerShell 7 + shell: pwsh + run: ./tests/AppLockerReadiness.Windows.Tests.ps1 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index ab5e4e16..b54485f8 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -53,6 +53,7 @@ **新機能:** +- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否します。未使用の空の NotConfigured コレクションによる誤った比較失敗を防ぎ、新しいルールの対象となる空のコレクションはマージ時に強制が有効になる可能性があるため拒否します。元の XML と未知・設定済みの内容を保持し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security) - プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。ユーザーファイルの対象は、そのユーザーの AppData または Startup 既知フォルダー配下の相対パスを保持し、未対応・曖昧なパスは未解決として扱います。SACL の書き込みや未検証の検知率向上は行いません。 (#398) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index c84ab65a..f918475d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,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; 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) - Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. User-file targets retain their configured suffix under the user's AppData or Startup known folder; unsupported or ambiguous paths remain unresolved. No SACL writes or unverified detection uplift are implied. (#398) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 5e9aa819..87bd4f3c 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -23,6 +23,8 @@ [ValidateRange(16384, 32767)][int]$FirewallMinimumSizeKiB = 16384, [string]$HtmlPath, [ValidateSet('Audit', 'Plan', 'Configure')][string]$SmbAction = 'Audit', + [ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit', + [string]$AppLockerPolicyPath, [ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List', [string[]]$WmiNamespace, [switch]$WmiIncludeChildren, @@ -42,6 +44,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/Configuration.ps1") . (Join-Path $ScriptRoot "scripts/FirewallLogging.ps1") . (Join-Path $ScriptRoot "scripts/SmbAuditing.ps1") +. (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop @@ -1690,6 +1693,8 @@ Usage: ./WELA.ps1 smb-auditing -SmbAction Audit -ResultsPath smb-audit.json ./WELA.ps1 smb-auditing -SmbAction Plan ./WELA.ps1 smb-auditing -SmbAction Configure -DryRun + ./WELA.ps1 applocker-readiness -ResultsPath applocker.json + ./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml # SMB auditing is opt-in and never changes signing/encryption requirements or guest access. ./WELA.ps1 profiles # List versioned advanced audit-policy profiles ./WELA.ps1 plan -Profile wela-2.2.0 -Role Client -Build 26100 -PlanPath plan.json @@ -1721,6 +1726,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.' +} # SaclMode belongs only to the read-only profile companion plan. In particular, # configure-sacl must never silently ignore an explicit request to Skip. if ($PSBoundParameters.ContainsKey('SaclMode') -and @@ -1728,11 +1739,11 @@ if ($PSBoundParameters.ContainsKey('SaclMode') -and throw '-SaclMode requires -Profile with plan, audit, audit-settings or configure. It does not control configure-sacl. No command was run.' } # Reject unsupported dry-run requests before reaching any command's mutation path. -if ($DryRun -and $Cmd -notin @('configure', 'configure-eventlogs') -and +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 -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and -not ($Cmd -eq 'wmi-auditing' -and $WmiAction -eq 'Configure')) { - throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure smb-auditing -SmbAction Configure and wmi-auditing -WmiAction Configure. No command was run." + throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure, wmi-auditing -WmiAction Configure, and applocker-readiness -AppLockerAction Import. No command was run." } if (($WmiNamespace -or $WmiIncludeChildren -or $PSBoundParameters.ContainsKey('WmiAction')) -and $Cmd -ne 'wmi-auditing') { throw '-WmiAction, -WmiNamespace and -WmiIncludeChildren require wmi-auditing. No command was run.' @@ -1792,6 +1803,20 @@ switch ($Cmd.ToLower()) { if ($report.ExitCode) { exit $report.ExitCode } } catch { Write-Host "[Failed] SMB auditing: $_" -ForegroundColor Red; exit 1 } } + "applocker-readiness" { + if ($Help) { + Write-Host 'Usage: ./WELA.ps1 applocker-readiness [-AppLockerAction Audit|Plan|Import] [-AppLockerPolicyPath operator.xml] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' + return + } + if ($AppLockerAction -eq 'Import' -and -not (TestAdministrator)) { throw 'AppLocker policy import requires Administrator privileges.' } + $report = Invoke-WelaAppLockerCommand -Action $AppLockerAction -PolicyPath $AppLockerPolicyPath -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + if ($report.PSObject.Properties['Assessment']) { + $report.Assessment.Collections | Format-Table Type, EnforcementMode, RuleCount, PrerequisiteState, GenerationReadiness -AutoSize + Write-Host 'GP observations only; CSP policies and actual event generation remain unverified.' -ForegroundColor Yellow + if ($report.ImportBlocker) { Write-Host "Import blocked: $($report.ImportBlocker)" -ForegroundColor Yellow } + } else { $report.Results | Format-Table Id, Status, Diagnostic -AutoSize } + if ($report.ExitCode -ne 0) { throw 'AppLocker assessment/import failed; see structured results.' } + } "profiles" { (Import-WelaAuditProfiles).profiles | Select-Object id, version, scope, appliesTo | Format-List } diff --git a/docs/applocker-readiness.md b/docs/applocker-readiness.md new file mode 100644 index 00000000..c5f3737b --- /dev/null +++ b/docs/applocker-readiness.md @@ -0,0 +1,38 @@ +# Native AppLocker readiness + +`applocker-readiness` reports local and GP effective policy XML, each of the five rule collections, enforcement modes, rule counts, Application Identity (`AppIDSvc`) state/start mode and relevant AppLocker channel observations. A host with enabled channels but no rules reports `MissingGpPolicy`. Stopped/disabled services, missing channels, unavailable cmdlets and read errors remain explicit. `NotConfigured` with rules is treated as potential enforcement, never as disabled. + +```powershell +./WELA.ps1 applocker-readiness -ResultsPath applocker.json +./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml -ResultsPath plan.json +./WELA.ps1 applocker-readiness -AppLockerAction Import -AppLockerPolicyPath operator-audit.xml -DryRun +./WELA.ps1 applocker-readiness -AppLockerAction Import -AppLockerPolicyPath operator-audit.xml -BackupPath C:\WelaBackups\applocker-001 -ResultsPath imported.json +``` + +The default is read-only Audit. Windows 11 clients and member servers running Server 2016 or later are candidates; availability is checked through the actual native cmdlets and service. Edition names alone do not establish capability. Import requires a 64-bit elevated session. Ordinary `configure` does not invoke this workflow. No service, channel, application control enforcement or forwarding settings are automatically changed. + +## Scope and import safeguards + +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. + +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. + +## What readiness means + +`Conditional` means GP rules, a running service and enabled channels were observed. All collections still report `GenerationReadiness=Unverified` and zero usable-rule credit. A policy can omit rule collections, contain rules that do not match the relevant user/application, or be superseded later. Missing GP rules do not prove no CSP rules exist. A successful import verifies local policy content only; it does not start the service, validate an actual executable/script event or verify collector ingestion. + +[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 new file mode 100644 index 00000000..292554ff --- /dev/null +++ b/scripts/AppLockerReadiness.ps1 @@ -0,0 +1,255 @@ +# 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=(@($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 +} diff --git a/tests/AppLockerReadiness.Tests.ps1 b/tests/AppLockerReadiness.Tests.ps1 new file mode 100644 index 00000000..4e8e5d90 --- /dev/null +++ b/tests/AppLockerReadiness.Tests.ps1 @@ -0,0 +1,198 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '../scripts/Configuration.ps1') +. (Join-Path $PSScriptRoot '../scripts/AppLockerReadiness.ps1') +$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.' +Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('AuditOnly','Enabled')) -ForImport } 'AuditOnly' +Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('AuditOnly','NotConfigured')) -ForImport } 'AuditOnly' +Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml '' -ForImport } 'empty' +Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml (']>'+ $xml) -ForImport } 'DTD' +Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('', '')) -ForImport } 'extensions' +Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('12345678-1234-1234-1234-123456789abc','not-a-guid')) -ForImport } 'IDs' +$implicit=ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('AuditOnly','NotConfigured')) +Assert ($implicit.HasEnforcement) 'NotConfigured with rules may enforce; never call it disabled.' +$parsedPlaceholders=ConvertFrom-WelaAppLockerXml -Xml $placeholders +Assert ($parsedPlaceholders.Collections.Count -eq 5 -and $parsedPlaceholders.EmptyPlaceholderCount -eq 5) 'Raw placeholder collections remain in assessment XML/metadata while all five are recognized as empty.' +Assert ($parsedPlaceholders.Xml -match 'NotConfigured' -and -not $parsedPlaceholders.HasEnforcement) 'Normalization never deletes the original policy evidence or fabricates enforcement.' +$commented=ConvertFrom-WelaAppLockerXml -Xml ' ' +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'} } +function Get-WelaAppLockerService { [pscustomobject]@{Status='Observed'; State=$script:serviceState; StartMode=$script:serviceMode} } +function Get-WelaAppLockerChannels { foreach ($name in @('EXE and DLL','MSI and Script','Packaged app-Execution','Packaged app-Deployment')) { [pscustomobject]@{Channel="Microsoft-Windows-AppLocker/$name"; Status='Observed'; Enabled=$script:channelEnabled} } } +function Get-WelaAppLockerPolicySnapshot { + param($Scope) + if ($Scope -eq 'Local') { + $script:readCount++ + if ($script:race -and $script:readCount -eq 2) { $script:localXml=$xml.Replace('AuditOnly','Enabled') } + if ($script:drift -and $script:readCount -ge 5) { $script:localXml='' } + } + if ($script:unknownPolicy) { return [pscustomobject]@{Status='Unknown'; Policy=$null} } + $value=if ($Scope -eq 'Local') {$script:localXml} else {$script:effectiveXml} + [pscustomobject]@{Status='Observed'; Policy=(ConvertFrom-WelaAppLockerXml -Xml $value)} +} +$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('', ' '), (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.' } + $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 +Assert (@($empty.Collections | Where-Object PrerequisiteState -ne MissingGpPolicy).Count -eq 0) 'Enabled channels without rules must retain a missing GP policy prerequisite.' +Assert ($empty.CspPolicyState -eq 'Unknown' -and $empty.UsableRuleCredit -eq 0) 'GP readback never establishes CSP or detection readiness.' +$script:localXml=$xml; $script:effectiveXml=$xml +$ready=Get-WelaAppLockerReadiness +Assert ($ready.Collections[0].PrerequisiteState -eq 'Conditional' -and $ready.Collections[0].GenerationReadiness -eq 'Unverified') 'Audit policy plus service/channel is only conditional.' +$script:serviceState='Stopped'; $script:serviceMode='Disabled' +Assert ((Get-WelaAppLockerReadiness).Collections[0].PrerequisiteState -eq 'ServiceNotRunning') 'Disabled service must be explicit.' +$script:serviceState='Running';$script:serviceMode='Auto';$script:channelEnabled=$false +Assert ((Get-WelaAppLockerReadiness).Collections[0].PrerequisiteState -eq 'ChannelDisabled') 'Disabled channel must be explicit.' +Reset-Fixture; $script:effectiveXml=$xml.Replace('AuditOnly','Enabled') +Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'enforcement' +Reset-Fixture; $script:localXml=$xml.Replace('AuditOnly','NotConfigured') +Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'enforcement' +Reset-Fixture; $script:domain=$true +Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'domain-joined' +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','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 -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' { + Assert ($script:writes -eq 1 -and $result.ExitCode -eq 0) 'Verified initial audit-only merge should pass.' + Assert (Test-Path (Join-Path $path 'before.jsonl')) 'Recovery journal must precede merge.' + Set-WelaAppLockerAuditPolicy -Context $context -Desired $desired + 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.' } + '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 + [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." diff --git a/tests/AppLockerReadiness.Windows.Tests.ps1 b/tests/AppLockerReadiness.Windows.Tests.ps1 new file mode 100644 index 00000000..1feaa28c --- /dev/null +++ b/tests/AppLockerReadiness.Windows.Tests.ps1 @@ -0,0 +1,37 @@ +$ErrorActionPreference='Stop' +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Windows required.' } +. (Join-Path $PSScriptRoot '../scripts/AppLockerReadiness.ps1') +$report=Get-WelaAppLockerReadiness +if ($report.Collections.Count -ne 5 -or $report.CspPolicyState -ne 'Unknown' -or $report.UsableRuleCredit -ne 0) { throw 'Native report lost collection/CSP uncertainty.' } +if ($report.Host.Status -eq 'Unknown') { throw ($report.Host | ConvertTo-Json) } +foreach ($scope in @($report.LocalPolicy,$report.EffectiveGpPolicy)) { + if ($scope.Status -eq 'Observed' -and -not $scope.Policy.Xml) { throw 'Observed policy must retain XML evidence.' } + if ($scope.Status -ne 'Observed') { Write-Host "Policy read limitation: $($scope.Status) $($scope.Diagnostic)" } +} +# 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 + $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() } + # 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 e4103a76..d1b72d9d 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -56,6 +56,7 @@ **新機能:** +- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否します。未使用の空の NotConfigured コレクションによる誤った比較失敗を防ぎ、新しいルールの対象となる空のコレクションはマージ時に強制が有効になる可能性があるため拒否します。元の XML と未知・設定済みの内容を保持し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security) - プロファイルの plan/audit/configure に対象を限定した SACL の読み取り専用計画を追加しました。オブジェクト監査ポリシー、ユーザーハイブ・フォルダーリダイレクトの未確認箇所、WEF Run/RunOnce の監査エントリを表示し、`-SaclMode Skip` による省略も明示します。ユーザーファイルの対象は、そのユーザーの AppData または Startup 既知フォルダー配下の相対パスを保持し、未対応・曖昧なパスは未解決として扱います。SACL の書き込みや未検証の検知率向上は行いません。 (#398) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 6d8640a8..523b9451 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -58,6 +58,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; 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) - Profile plan/audit/configure now include read-only targeted SACL prerequisites with object policy masks, per-user hive and redirected-folder gaps, exact WEF Run/RunOnce audit entries, and an explicit `-SaclMode Skip`. User-file targets retain their configured suffix under the user's AppData or Startup known folder; unsupported or ambiguous paths remain unresolved. No SACL writes or unverified detection uplift are implied. (#398) (@Shirofune-Security)