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 c79f4a86..53aed27c 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -52,6 +52,8 @@ **新機能:** +- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (issue #381) (@Shirofune-Security) + - MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket) - Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket) - Defender for Identityの必要なログに対応した。 (#114) (@fukusuket) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab71489f..99e9d483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ **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. (issue #381) (@Shirofune-Security) + - Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket) - Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket) - Support for Defender for Identity required logs. (#114) (@fukusuket) diff --git a/WELA.ps1 b/WELA.ps1 index c8196e0a..ff50a3c5 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -22,6 +22,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, [switch]$Help ) @@ -38,6 +40,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") Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop @@ -1673,6 +1676,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 @@ -1703,10 +1708,10 @@ Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" # 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')) { - throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure and smb-auditing -SmbAction 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, and applocker-readiness -AppLockerAction Import. No command was run." } if ($Profile -and $Cmd -in @('eventlog-profiles', 'audit-filesize', 'configure-eventlogs')) { throw '-Profile selects advanced audit policy only. Use -LogProfile for event-log size/mode settings.' @@ -1750,6 +1755,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..ff3c93ec --- /dev/null +++ b/docs/applocker-readiness.md @@ -0,0 +1,30 @@ +# 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 + +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. + +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. + +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. diff --git a/scripts/AppLockerReadiness.ps1 b/scripts/AppLockerReadiness.ps1 new file mode 100644 index 00000000..89753bc0 --- /dev/null +++ b/scripts/AppLockerReadiness.ps1 @@ -0,0 +1,213 @@ +# 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.' } + $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.' } + } + } + $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 }) + } + 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) } +} + +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 + if ($current.Collections.Count -ne $Desired.Collections.Count -or $current.HasEnforcement) { return $false } + foreach ($wanted in $Desired.Collections) { + $actual = @($current.Collections | 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 (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.' } +} + +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' + [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) + 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 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..b805578e --- /dev/null +++ b/tests/AppLockerReadiness.Tests.ps1 @@ -0,0 +1,96 @@ +$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='' +$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.' +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 +} +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)} +} +function Test-AppLockerPolicy { [CmdletBinding()]param($XmlPolicy,$Path,$User) [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 +} +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' +$cleanup=@() +try { + foreach ($scenario in @('apply','dry','race','failure','drift','existing')) { + Reset-Fixture + if ($scenario -eq 'race') {$script:race=$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 + Set-WelaAppLockerAuditPolicy -Context $context -Desired $desired + $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.' } + '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.' } + } + } +} 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..ce75184a --- /dev/null +++ b/tests/AppLockerReadiness.Windows.Tests.ps1 @@ -0,0 +1,22 @@ +$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') + try { + $xml='' + $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.' } + } 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.' diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 4d03a568..68fa9ae3 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -55,6 +55,8 @@ **新機能:** +- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (issue #381) (@Shirofune-Security) + - MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket) - Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket) - Defender for Identityの必要なログに対応した。 (#114) (@fukusuket) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 50139bca..0c942e93 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -57,6 +57,8 @@ **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. (issue #381) (@Shirofune-Security) + - Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket) - Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket) - Support for Defender for Identity required logs. (#114) (@fukusuket)