Validate native AppLocker EXE event generation with an opt-in probe (#423)

* Add native AppLocker EXE event validation probe

* Reference PR 423 in changelogs

* Isolate AppLocker native fixture and preserve prerequisite diagnostics

* Report an integer zero for an empty AppLocker policy

* Prepare disposable AppLocker probe policy without bypassing production importer guards

* Retain bounded native AppLocker channel diagnostics on probe failure

* Require native policy application before the disposable AppLocker probe

* Preserve exact timestamp strings in native evidence fixtures

* Record actual runner session and AppLocker publication diagnostics

* Activate and restore the native policy converter on disposable AppLocker hosts

* Compare native task freshness without guessing its timestamp timezone

* Verify effective policy and borrowed converter inactivity during fixture cleanup

* Track the actual native policy-converter task instance instead of cached timestamps
This commit is contained in:
田中ザック Isaac Mathis authored and GitHub committed 2026-09-20 22:46:56 +09:00
1 parent ff0e5c1890
commit 5ba53fbcfb
17 files changed
+351 -7

No files matched your search

+3
View File
@@ -23,3 +23,6 @@
/modules/AuditCatalog.psm1 text eol=lf
# Fixed public pending-request fixture is pinned by its exact byte hash.
/tests/fixtures/adcs-pending-probe.csr text eol=lf
scripts/AppLockerProbe.ps1 text eol=lf
tests/AppLockerProbe*.ps1 text eol=lf
+34
View File
@@ -0,0 +1,34 @@
name: Native AppLocker EXE probe
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
applocker-probe:
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
engine: [powershell, pwsh]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Fixtures in Windows PowerShell 5.1
if: matrix.engine == 'powershell'
shell: powershell
run: ./tests/AppLockerProbe.Tests.ps1
- name: Native probe in Windows PowerShell 5.1
if: matrix.engine == 'powershell'
shell: powershell
run: ./tests/AppLockerProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite
- name: Fixtures in PowerShell 7
if: matrix.engine == 'pwsh'
shell: pwsh
run: ./tests/AppLockerProbe.Tests.ps1
- name: Native probe in PowerShell 7
if: matrix.engine == 'pwsh'
shell: pwsh
run: ./tests/AppLockerProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite
+1
View File
@@ -4,6 +4,7 @@
**改善:**
- 既存の監査専用ポリシーに対する固定ネイティブ EXE の実行と AppLocker イベントの厳密な照合、保護されたハッシュ付き証拠、変更検出を行うオプトインの `applocker-probe` を追加しました。ポリシー・サービス・チャネルの変更や Sigma の評価加算は行いません。使い捨て Windows CI は一時的な監査専用テストポリシーで実際の 8003 を収集し、管理状態の観測を保持して GP ポリシーとチャネル設定を復元します。 (#423) (@Shirofune-Security)
- 既存のローカルファイル・レジストリを明示的に選択して監査・計画・設定する`targeted-sacl`を追加しました。出典ごとの監査ACE、実効ポリシーの前提条件、継承の個別同意を確認し、対象ハンドルを使ってSACLだけを更新します。既存のセキュリティ記述子を保持し、変更前と検証済みの記録、最終状態の確認、特権の復元に対応します。使い捨てオブジェクトのネイティブテストを追加し、子孫全体・転送・Sigmaの利用可能性は別途検証が必要です。 (#422) (@Shirofune-Security)
- 既存CA向けにネイティブの`adcs-auditing`監査・計画・出典付き設定を追加しました。CAと証明書の識別、監査の前提条件、型付き復旧記録、変更直前と読戻しの検証を共有し、従来のCA設定も同じ処理を使用します。停止中のCAは起動せず、専用コマンドでのフィルター変更には再起動の明示指定を求めます。設定一致・再起動の観測・イベント証拠を区別し、Sigma利用可能数には加算しません。使い捨てのスタンドアロンCAテストで保留要求の4886/4889 XMLを関連付け、元の監査設定と作成した資源を復元・削除します。エンタープライズCA・DC・収集基盤の検証は別途必要です。 (#421) (@Shirofune-Security)
- `evtx-recovery` を追加し、検証済みのネイティブ Security プローブを EVTX に出力して Windows イベント API で再読込できるようにしました。実際の読取アカウントによる検証、入力・イベントの厳密な比較、新規出力の保護、ハッシュとドリフト検出で空または変更された記録を拒否します。使い捨て Windows テストで実際の出力・復旧を検証し、全体の保存期間、他アカウントのアクセス、Sigma 対応とは区別します。 (#420) (@Shirofune-Security)
+1
View File
@@ -4,6 +4,7 @@
**Improvements:**
- Added opt-in `applocker-probe` planning and fixed native EXE collection against existing audit-only policy, with exact AppLocker event correlation, private hashed evidence and drift checks. No policy/service/channel changes or Sigma credit; disposable Windows CI prepares one temporary audit-only fixture for real 8003 collection, preserving management observations and restoring GP policy/channel settings. (#423) (@Shirofune-Security)
- Added opt-in `targeted-sacl` auditing, reviewed plans and selective configuration for existing local file/registry targets. Source-specific audit ACEs, policy prerequisites, inheritance consent, handle-bound SACL-only writes, preserved security descriptors, pending/confirmed receipts and final checks keep target scope explicit. Privileges are restored; native disposable-object tests cover file/registry events without claiming descendant, forwarding or Sigma readiness. (#422) (@Shirofune-Security)
- Added dedicated native `adcs-auditing` audit, plan and source-profile configuration, with pinned CA/certificate identity, verified audit prerequisites, typed journals and fresh/readback guards. Legacy CA configuration uses the same engine; stopped CAs are preserved and dedicated filter changes require explicit restart consent. Policy matches, observed restarts and request events remain separate, with no Sigma credit. Disposable standalone-CA tests collect correlated pending-request 4886/4889 XML and restore policy/created resources; enterprise/DC/backend acceptance remains separate. (#421) (@Shirofune-Security)
- Added opt-in `evtx-recovery` to export one validated native Security probe and reopen its EVTX through Windows event APIs, with a separate verification action under the actual reader. Strict source/component checks, exact event comparison, protected new output, archive hashes and reader/context drift checks reject empty or changed evidence. Disposable Windows tests cover real export/recovery and empty archives; full retention, other-reader access and Sigma readiness remain separate. (#420) (@Shirofune-Security)
+13
View File
@@ -97,6 +97,9 @@
[string]$RecoveryOutputPath,
[string]$ArrivalProbePath,
[string]$ArrivalOutputPath,
[ValidateSet('Plan','Run')][string]$AppLockerProbeAction = 'Plan',
[string]$AppLockerProbeOutputPath,
[ValidateRange(1,30)][int]$AppLockerProbeTimeoutSeconds = 15,
[switch]$Help
)
@@ -122,6 +125,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/AuditNotifications.ps1")
. (Join-Path $ScriptRoot "scripts/AdObjectSacl.ps1")
. (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1")
. (Join-Path $ScriptRoot "scripts/AppLockerProbe.ps1")
. (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1")
. (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1")
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
@@ -1905,6 +1909,7 @@ Usage:
./WELA.ps1 adcs-auditing -Help # Dedicated local CA audit settings; restart requires explicit consent
./WELA.ps1 score -Help # Separate configuration compliance and evidence-qualified readiness
./WELA.ps1 intune-export -Help # Offline native audit OMA-URI/Graph artifacts; no tenant changes
./WELA.ps1 applocker-probe -Help # Collect a fixed native AppLocker EXE event
./WELA.ps1 wef-arrival -Help # Verify exact native probe presence on the local collector
./WELA.ps1 native-validation -Help # Collect a fixed native 4688 probe without changing policy
./WELA.ps1 version # Show the WELA version
@@ -1962,6 +1967,8 @@ if ($PSBoundParameters.ContainsKey('ProfileFile')) {
if ($Cmd -eq 'profiles' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ProfileFile','Help') }).Count) { throw 'profiles -ProfileFile lists the selected file and accepts no assessment/configuration options.' }
}
if ($Cmd -ne 'applocker-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('AppLockerProbeAction','AppLockerProbeOutputPath','AppLockerProbeTimeoutSeconds')}).Count) {throw 'AppLocker probe options require applocker-probe.'}
if ($Cmd -eq 'applocker-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','AppLockerProbeAction','AppLockerProbeOutputPath','AppLockerProbeTimeoutSeconds','Help')}).Count) {throw 'applocker-probe accepts only its dedicated options.'}
if ($Cmd -ne 'wef-arrival' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('ArrivalProbePath','ArrivalOutputPath')}).Count) {
throw 'Arrival options require wef-arrival. No command was run.'
}
@@ -2117,6 +2124,12 @@ switch ($Cmd.ToLower()) {
$report
if ($report.ExitCode) {exit $report.ExitCode}
}
'applocker-probe' {
if ($Help) {Write-Host 'Usage: applocker-probe [-AppLockerProbeAction Plan|Run] [-AppLockerProbeOutputPath new-private-directory] [-AppLockerProbeTimeoutSeconds 1..30]. Requires existing EXE audit-only policy, running AppIDSvc and enabled channel. Run launches a fixed native cmd.exe copy and collects one exact AppLocker event. See docs/applocker-probe.md.';return}
$report=Invoke-WelaAppLockerProbe -Action $AppLockerProbeAction -OutputPath $AppLockerProbeOutputPath -TimeoutSeconds $AppLockerProbeTimeoutSeconds
$report
if($report.ExitCode){exit $report.ExitCode}
}
'wef-arrival' {
if ($Help) {Write-Host 'Usage: ./WELA.ps1 wef-arrival -ArrivalProbePath existing-native-probe-directory -ArrivalOutputPath new-private-directory. Reads local ForwardedEvents and matches the exact original probe payload. No subscriptions, policy changes, latency or Sigma readiness claims. See docs/wef-arrival.md.'; return}
if (-not $ArrivalProbePath -or -not $ArrivalOutputPath) {throw 'ArrivalProbePath and ArrivalOutputPath are required.'}
+22
View File
@@ -0,0 +1,22 @@
# Native AppLocker EXE probe
Related to #381. `applocker-probe` tests one fixed benign executable against an **existing** effective Group Policy EXE audit-only collection. It changes no policy, service startup/state, channel configuration or audit settings. Sysmon is excluded.
```powershell
.\WELA.ps1 applocker-probe
.\WELA.ps1 applocker-probe -AppLockerProbeAction Run -AppLockerProbeOutputPath C:\Evidence\new-applocker-probe -AppLockerProbeTimeoutSeconds 30
```
Plan reads prerequisites without launching a process or writing files. Run requires a new private output directory on a local fixed drive; its parent must exist. The EXE collection must contain rules, AppIDSvc must already run and the EXE and DLL channel must already be enabled. Only actual 64-bit client/member-server observations are accepted. Domain controller testing is not supported.
Run copies native System32 `cmd.exe` into the protected output directory with a unique filename, verifies its SHA256 and executes only `/d /c echo WELA_APPLOCKER_<nonce>`. Input and copied executable are held read-locked during launch. The copied executable is retained as part of the evidence. No operator-supplied command is executed. Other application-control authorities may block the probe; that produces an unverified result. AppLocker CSP policy remains Unknown because Get-AppLockerPolicy observes Group Policy only.
A bounded query requires exactly one native AppLocker 8002 (allowed) or 8003 (allowed, would block under enforcement) with the expected provider, version, computer, EXE collection, actual user SID, owned process ID, exact file path and time window. The report retains the distinct event ID; 8002 does not demonstrate a would-block decision. Policy, service, channel, reader, host and executable bytes are checked before and after. Denied, absent, capped, duplicate or drifted results fail with a retained diagnostic. Component hashes establish consistency, not authenticity or a signature.
`NativeExeEventObserved` proves only this event in this local channel at this time. It grants **zero Sigma readiness credit**. Scripts, MSI, DLL, packaged applications, forwarding, translated queries and backend matches require separate evidence. Client builds and managed/CSP deployments require lab acceptance beyond hosted-server CI.
The Windows test explicitly opts into temporary changes on disposable GitHub-hosted Server 2022/2025 VMs under Windows PowerShell 5.1 and PowerShell 7. It accepts only a non-domain host with initially empty, understood local/effective GP policies, prepares one AuditOnly policy through the native cmdlet in the test fixture, temporarily enables/runs the existing verified native PolicyConverter task when disabled, runs a bounded computer Group Policy refresh and requires native 8001 policy-application evidence followed by a real 8003. Hosted images can contain enrollment/provider keys; these are recorded and preserved, and CSP policy remains Unknown. This fixture tests the probe, not production importer acceptance: the production importer continues to block observed management entries. It restores and refreshes the original local policy, verifies both local and effective GP snapshots, and restores channel enablement and the exact PolicyConverter task definition/enabled setting with no task invocation left running or queued, and leaves service startup mode untouched. If Windows refuses to stop its protected AppIDSvc, the test records that running-state boundary and relies on disposal of the VM; it does not claim service-state rollback. Never run that fixture on a production host.
The test-only GP refresh is never performed by the product command.
Microsoft references: [documented refresh and policy-applied verification](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/refresh-an-applocker-policy), [AppLocker event IDs](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/using-event-viewer-with-applocker), [Application Identity service and protected startup mode](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/configure-the-application-identity-service).
+104
View File
@@ -0,0 +1,104 @@
# Fixed benign EXE probe. Does not change AppLocker, services, channels or audit policy.
function Get-WelaAppLockerProbeState {
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess) {throw 'A native 64-bit Windows process is required.'}
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
try {$reader=[ordered]@{Name=$identity.Name;Sid=$identity.User.Value;Groups=@($identity.Groups.Value|Sort-Object);AuthenticationType=$identity.AuthenticationType}} finally {$identity.Dispose()}
$hostState=Get-WelaAppLockerHost
$computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
$channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/EXE and DLL')
try {$log=[ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode}} finally {$channel.Dispose()}
$source=Resolve-WelaArrivalPath (Join-Path ([Environment]::SystemDirectory) 'cmd.exe')
[pscustomobject][ordered]@{Host=$hostState;Computer=[Environment]::MachineName;Domain=[string]$computer.Domain;Reader=$reader;EffectivePolicy=(Get-WelaAppLockerPolicySnapshot Effective);Service=(Get-WelaAppLockerService);Channel=$log;Source=$source;SourceHash=(Get-FileHash -LiteralPath $source -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
}
function Get-WelaAppLockerProbeKey {
param($State)
if (-not $State.Host.Is64BitProcess -or $State.Host.Status -ne 'Candidate') {throw 'An observed supported client/member Windows host is required.'}
if ($State.EffectivePolicy.Status -ne 'Observed' -or $State.EffectivePolicy.Policy.HasUnknownPolicyData) {throw 'Effective Group Policy AppLocker settings must be readable and understood.'}
$collection=@($State.EffectivePolicy.Policy.Collections|Where-Object Type -eq Exe)
if ($collection.Count -ne 1 -or $collection[0].EnforcementMode -cne 'AuditOnly' -or $collection[0].RuleCount -lt 1) {throw 'A nonempty effective EXE AuditOnly collection is required.'}
if ($State.Service.Status -ne 'Observed' -or $State.Service.State -ne 'Running') {throw 'AppIDSvc must already be running.'}
if ($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or -not $State.Channel.SecurityDescriptor -or $State.Channel.Name -cne 'Microsoft-Windows-AppLocker/EXE and DLL') {throw 'The readable EXE and DLL channel must already be enabled.'}
if ($State.Reader.Sid -notmatch '^S-1-\d+(-\d+)+$' -or $State.SourceHash -cnotmatch '^[a-f0-9]{64}$' -or [string]::IsNullOrWhiteSpace($State.Computer)) {throw 'Incomplete reader, source or computer identity.'}
[ordered]@{Host=$State.Host;Computer=$State.Computer;Domain=$State.Domain;Reader=$State.Reader;Policy=(Get-WelaAppLockerXmlKey $State.EffectivePolicy.Policy.Xml);Service=$State.Service;Channel=$State.Channel;Source=$State.Source;SourceHash=$State.SourceHash}|ConvertTo-Json -Depth 12 -Compress
}
function Start-WelaAppLockerProbeProcess {
param([string]$Root,$State)
$nonce=[guid]::NewGuid().ToString('N');$path=Join-Path $Root ('wela-applocker-'+$nonce+'.exe')
$source=$null;$target=$null;$lock=$null;$process=$null
try {
$source=[IO.File]::Open($State.Source,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
$target=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
$source.CopyTo($target);$target.Flush();$target.Dispose();$target=$null
$lock=[IO.File]::Open($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $State.SourceHash) {throw 'Native executable bytes changed before launch.'}
$fresh=Get-WelaAppLockerProbeState
if ((Get-WelaAppLockerProbeKey $fresh) -cne (Get-WelaAppLockerProbeKey $State)) {throw 'AppLocker prerequisites changed before launch.'}
$arguments='/d /c echo WELA_APPLOCKER_'+$nonce
$info=New-Object Diagnostics.ProcessStartInfo;$info.FileName=$path;$info.Arguments=$arguments;$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.WorkingDirectory=$Root
$started=[DateTime]::UtcNow;$process=[Diagnostics.Process]::Start($info);$processId=$process.Id
if (-not $process.WaitForExit(10000)) {$process.Kill();throw 'The owned fixed probe exceeded its ten-second process limit.'}
$stdout=$process.StandardOutput.ReadToEnd();$stderr=$process.StandardError.ReadToEnd()
if ($process.ExitCode -ne 0 -or $stdout.Trim() -cne ('WELA_APPLOCKER_'+$nonce) -or $stderr) {throw 'The fixed native executable did not complete with its expected marker.'}
[pscustomobject]@{Executable=$path;ExecutableHash=$State.SourceHash;Arguments=$arguments;ProcessId=$processId;UserSid=$State.Reader.Sid;StartedUtc=$started.ToString('o');CompletedUtc=[DateTime]::UtcNow.ToString('o');ExitCode=$process.ExitCode;Marker=$stdout.Trim()}
} finally {foreach ($item in @($process,$lock,$target,$source)) {if ($item) {$item.Dispose()}}}
}
function Read-WelaAppLockerProbeEvents {
param([datetime]$StartUtc,[datetime]$EndUtc)
$query="*[System[Provider[@Name='Microsoft-Windows-AppLocker'] and (EventID=8002 or EventID=8003) and TimeCreated[@SystemTime>='$($StartUtc.ToString('o'))' and @SystemTime<='$($EndUtc.ToString('o'))']]]"
$records=@();$xml=@()
try {
try {$records=@(Get-WinEvent -LogName 'Microsoft-Windows-AppLocker/EXE and DLL' -FilterXPath $query -MaxEvents 512 -ErrorAction Stop)} catch {if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') {throw}}
foreach ($record in $records) {$xml+=[string]$record.ToXml()}
[pscustomobject]@{Xml=$xml;Capped=($records.Count -ge 512);Query=$query}
} finally {foreach ($record in $records) {$record.Dispose()}}
}
function Test-WelaAppLockerProbeEvent {
param([string]$Xml,$Process,$State,[datetime]$EndUtc)
$reader=$null
try {
$settings=New-Object Xml.XmlReaderSettings;$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=4194304
$reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.Load($reader)
$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event');$ns.AddNamespace('a','http://schemas.microsoft.com/schemas/event/Microsoft.Windows/1.0.0.0')
if ($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData/a:RuleAndFileData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count) {return $false}
$system=@{};foreach ($name in @('Provider','EventID','Version','EventRecordID','Channel','Computer','TimeCreated')) {$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]}
if ($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-AppLocker' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine 'cbda4dbf-8d5d-4f69-9578-be14aa540d22' -or $system.EventID.InnerText -cnotin @('8002','8003') -or $system.Version.InnerText -cne '0' -or $system.EventRecordID.InnerText -notmatch '^[1-9][0-9]*$' -or $system.Channel.InnerText -cne 'Microsoft-Windows-AppLocker/EXE and DLL') {return $false}
$computers=@($State.Computer);if($State.Host.PartOfDomain){$computers+=$State.Computer+'.'+$State.Domain};if($system.Computer.InnerText -notin $computers){return $false}
$time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime')
if($time.UtcDateTime -lt ([DateTimeOffset]::Parse($Process.StartedUtc)).UtcDateTime -or $time.UtcDateTime -gt $EndUtc){return $false}
$data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:UserData/a:RuleAndFileData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.NamespaceURI -cne $ns.LookupNamespace('a') -or $data.ContainsKey($node.LocalName) -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$data[$node.LocalName]=$node.InnerText}
if($data.PolicyName -cne 'EXE' -or $data.TargetUser -cne $Process.UserSid -or $data.TargetProcessId -notmatch '^[1-9][0-9]*$' -or [long]$data.TargetProcessId -ne $Process.ProcessId){return $false}
# AppLocker may render the exact Windows directory through this documented path variable.
$eventPath=$data.FilePath
if($eventPath -imatch '^%OSDRIVE%\\'){$eventPath=[IO.Path]::GetPathRoot($State.Source).TrimEnd('\')+$eventPath.Substring(9)}
return $eventPath -ieq $Process.Executable
} catch {return $false} finally {if($reader){$reader.Dispose()}}
}
function Invoke-WelaAppLockerProbe {
param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15)
if (($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))) {throw 'Run requires a new AppLockerProbeOutputPath; Plan does not write files.'}
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAppLockerExeProbe';Action=$Action;Status='Unverified';ExitCode=0;RecordedUtc=[DateTime]::UtcNow.ToString('o');Before=$null;After=$null;Process=$null;EventId=$null;Artifacts=@();Diagnostic='';OutputPath=$null;PolicyChanges=0;ReadyRuleCredit=0;CspPolicyState='Unknown';Scope='One fixed native EXE event only; scripts, MSI, DLL, packaged apps, forwarding and Sigma/backend validation are not tested. Sysmon excluded.'}
if($Action -eq 'Run'){$sourceDirectory=[Environment]::SystemDirectory;if(-not $sourceDirectory){$sourceDirectory=$PSScriptRoot};$report.OutputPath=New-WelaArrivalOutput -Path $OutputPath -SourcePath $sourceDirectory}
try {
$before=Get-WelaAppLockerProbeState;$report.Before=$before;$key=Get-WelaAppLockerProbeKey $before
if($Action -eq 'Plan'){$report.Status='PrerequisitesObserved';return $report}
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 20)
$process=Start-WelaAppLockerProbeProcess $report.OutputPath $before;$report.Process=$process
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'process.json' ($process|ConvertTo-Json -Depth 6)
$timer=[Diagnostics.Stopwatch]::StartNew();$matches=@()
do {
$end=[DateTime]::UtcNow;$batch=Read-WelaAppLockerProbeEvents ([DateTimeOffset]::Parse($process.StartedUtc)).UtcDateTime $end
if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'The 512-event query cap was reached or completeness is unknown.'}
$matches=@($batch.Xml|Where-Object {Test-WelaAppLockerProbeEvent $_ $process $before $end})
if($matches.Count -gt 1){throw 'Multiple exact AppLocker events make the result ambiguous.'}
if($matches.Count -eq 1){break}
Start-Sleep -Milliseconds 250
} while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds)
if($matches.Count -ne 1){foreach($xml in @($batch.Xml|Select-Object -First 4)){$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+[guid]::NewGuid().ToString('N')+'.xml') $xml};throw 'No exact AppLocker EXE event arrived within the timeout.'}
$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'event.xml' $matches[0]
$after=Get-WelaAppLockerProbeState;$report.After=$after;$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'after.json' ($after|ConvertTo-Json -Depth 20)
if((Get-WelaAppLockerProbeKey $after) -cne $key -or (Get-FileHash -LiteralPath $process.Executable -Algorithm SHA256).Hash.ToLowerInvariant() -cne $process.ExecutableHash){throw 'Host, policy, service, channel, reader or probe bytes changed during collection.'}
$event=[xml]$matches[0];$report.EventId=[int]$event.Event.System.EventID;$report.Status='NativeExeEventObserved'
} catch {$report.ExitCode=1;$report.Diagnostic=$_.Exception.Message}
if($report.OutputPath){$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' ($report|ConvertTo-Json -Depth 24)}
$report
}
+1 -1
View File
@@ -47,7 +47,7 @@ function ConvertFrom-WelaAppLockerXml {
}
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) }
[pscustomobject]@{ Xml=$doc.OuterXml; Collections=@($collections.ToArray()); EmptyPlaceholderCount=@($collections.ToArray() | Where-Object IsEmptyPlaceholder).Count; HasUnknownPolicyData=[bool]$unknownPolicyData; TotalRules=[int](@($collections.ToArray() | Measure-Object RuleCount -Sum)[0].Sum); HasEnforcement=(@($collections.ToArray() | Where-Object PotentialEnforcement).Count -gt 0) }
}
function Get-WelaAppLockerHost {
+36
View File
@@ -0,0 +1,36 @@
$ErrorActionPreference='Stop'
$repo=Split-Path $PSScriptRoot -Parent
. "$repo/scripts/AppLockerReadiness.ps1"
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/AppLockerProbe.ps1"
$count=0
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"}
$policy='<AppLockerPolicy Version="1"><RuleCollection Type="Exe" EnforcementMode="AuditOnly"><FilePathRule Id="12345678-1234-1234-1234-123456789abc" Name="Windows" UserOrGroupSid="S-1-1-0" Action="Allow"><Conditions><FilePathCondition Path="%WINDIR%\*" /></Conditions></FilePathRule></RuleCollection></AppLockerPolicy>'
$state=[pscustomobject]@{Host=[pscustomobject]@{Status='Candidate';Is64BitProcess=$true;PartOfDomain=$false};Computer='TEST';Domain='WORKGROUP';Reader=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1000'};EffectivePolicy=[pscustomobject]@{Status='Observed';Policy=(ConvertFrom-WelaAppLockerXml $policy)};Service=[pscustomobject]@{Status='Observed';State='Running';StartMode='Manual'};Channel=[pscustomobject]@{Name='Microsoft-Windows-AppLocker/EXE and DLL';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)'};Source='C:\Windows\System32\cmd.exe';SourceHash=('a'*64)}
$null=Get-WelaAppLockerProbeKey $state;Assert $true 'Valid audit-only prereqs'
foreach($mode in @('Enabled','NotConfigured')){$state.EffectivePolicy.Policy=ConvertFrom-WelaAppLockerXml ($policy.Replace('AuditOnly',$mode));Reject {Get-WelaAppLockerProbeKey $state} 'AuditOnly'}
$state.EffectivePolicy.Policy=ConvertFrom-WelaAppLockerXml $policy
$state.Service.State='Stopped';Reject {Get-WelaAppLockerProbeKey $state} 'already be running';$state.Service.State='Running'
$state.Channel.Enabled=$false;Reject {Get-WelaAppLockerProbeKey $state} 'enabled';$state.Channel.Enabled=$true
$process=[pscustomobject]@{Executable='C:\Temp\wela-owned.exe';ProcessId=1234;UserSid=$state.Reader.Sid;StartedUtc='2026-09-01T00:00:00.0000000Z'}
$event='<Event xmlns="http://schemas.microsoft.com/win/2004/08/events/event"><System><Provider Name="Microsoft-Windows-AppLocker" Guid="{cbda4dbf-8d5d-4f69-9578-be14aa540d22}"/><EventID>8003</EventID><Version>0</Version><EventRecordID>42</EventRecordID><TimeCreated SystemTime="2026-09-01T00:00:01.0000000Z"/><Channel>Microsoft-Windows-AppLocker/EXE and DLL</Channel><Computer>TEST</Computer></System><UserData><RuleAndFileData xmlns="http://schemas.microsoft.com/schemas/event/Microsoft.Windows/1.0.0.0"><PolicyName>EXE</PolicyName><TargetUser>S-1-5-21-1-2-3-1000</TargetUser><TargetProcessId>1234</TargetProcessId><FilePath>C:\Temp\wela-owned.exe</FilePath></RuleAndFileData></UserData></Event>'
$end=([DateTimeOffset]::Parse('2026-09-01T00:00:02Z')).UtcDateTime
Assert (Test-WelaAppLockerProbeEvent $event $process $state $end) 'Exact fixture must match'
Assert (Test-WelaAppLockerProbeEvent ($event.Replace('8003','8002')) $process $state $end) 'Allowed event matches but has distinct EventId'
$mutations=@(@('8003','8004'),@('1234','1235'),@('S-1-5-21-1-2-3-1000','S-1-5-21-1-2-3-1001'),@('C:\Temp\wela-owned.exe','C:\Temp\other.exe'),@('<PolicyName>EXE','<PolicyName>DLL'),@('<Computer>TEST','<Computer>OTHER'),@('cbda4dbf','abda4dbf'),@('<Version>0','<Version>1'),@('00:00:01.0000000Z','00:00:03.0000000Z'),@('</System>','<EventID>8003</EventID></System>'),@('</RuleAndFileData>','<TargetUser>S-1-1-0</TargetUser></RuleAndFileData>'))
foreach($pair in $mutations){$bad=$event.Replace($pair[0],$pair[1]);Assert ($bad -cne $event) 'Mutation changed fixture';Assert (-not(Test-WelaAppLockerProbeEvent $bad $process $state $end)) ('Reject '+$pair[0])}
Assert (-not(Test-WelaAppLockerProbeEvent ('<!DOCTYPE Event [<!ENTITY x SYSTEM "file:///etc/passwd">]>'+$event) $process $state $end)) 'DTD rejected'
Reject {Invoke-WelaAppLockerProbe -Action Run} 'requires'
Reject {Invoke-WelaAppLockerProbe -Action Plan -OutputPath ignored} 'requires'
# Exercise actual orchestration with mocked native boundaries, including duplicate/drift/cap failures.
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-applocker-test-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
$script:state=$state;$script:event=$event;$script:process=$process;$script:scenario='ok';$script:reads=0
function Get-WelaAppLockerProbeState {$script:reads++;if($script:scenario -eq 'drift' -and $script:reads -gt 1){$script:state.Service.StartMode='Auto'};$script:state}
function Start-WelaAppLockerProbeProcess {param($Root,$State);$path=Join-Path $Root 'fixed.exe';[IO.File]::WriteAllText($path,'fixed');$script:process|Add-Member NoteProperty ExecutableHash (Get-FileHash $path).Hash.ToLowerInvariant() -Force;$script:process.Executable=$path;$script:process}
function Read-WelaAppLockerProbeEvents {param($StartUtc,$EndUtc);[pscustomobject]@{Xml=if($script:scenario -eq 'duplicate'){@($script:event,$script:event)}else{@($script:event)};Capped=($script:scenario -eq 'cap')}}
function Test-WelaAppLockerProbeEvent {$true}
try {
foreach($scenario in @('ok','duplicate','drift','cap')){$script:scenario=$scenario;$script:reads=0;$state.Service.StartMode='Manual';$result=Invoke-WelaAppLockerProbe Run (Join-Path $root $scenario) 1;Assert ($result.ReadyRuleCredit -eq 0 -and $result.PolicyChanges -eq 0) 'No readiness or changes';Assert (($result.ExitCode -eq 0) -eq ($scenario -eq 'ok')) "Expected outcome $scenario : $($result.Diagnostic)";Assert (Test-Path (Join-Path $result.OutputPath 'manifest.json')) 'Failure/success manifest retained'}
} finally {Remove-Item -LiteralPath $root -Recurse -Force}
Write-Host "AppLocker probe tests passed: $count assertions."
+118
View File
@@ -0,0 +1,118 @@
param([switch]$AllowDisposablePolicyWrite)
$ErrorActionPreference='Stop'
if($env:OS -ne 'Windows_NT'){Write-Host 'Skipped: Windows required.';exit 0}
if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted policy-write opt-in required.'}
function Refresh-DisposableComputerPolicy {
$info=New-Object Diagnostics.ProcessStartInfo
$info.FileName=Join-Path ([Environment]::SystemDirectory) 'gpupdate.exe';$info.Arguments='/target:computer /force /wait:30';$info.UseShellExecute=$false
$process=[Diagnostics.Process]::Start($info)
try {if(-not $process.WaitForExit(60000)){$process.Kill();throw 'Disposable computer policy refresh exceeded 60 seconds.'};if($process.ExitCode -ne 0){throw ('Disposable computer policy refresh failed: '+$process.ExitCode)}} finally {$process.Dispose()}
}
function Stop-DisposablePolicyConverter {
$task=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop
if($task.State -in @('Running','Queued')) {Stop-ScheduledTask -InputObject $task -ErrorAction Stop}
$deadline=[DateTime]::UtcNow.AddSeconds(15)
do {$task=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop;if($task.State -in @('Ready','Disabled')){return};Start-Sleep -Milliseconds 200}while([DateTime]::UtcNow -lt $deadline)
throw 'The verified borrowed PolicyConverter task did not become idle.'
}
function Run-DisposablePolicyConverter {
# The Task Scheduler CIM provider can retain stale LastRunTime on Server2022.
# Follow the actual COM Run instance and native completion state instead.
$scheduler=$null;$folder=$null;$registered=$null;$instance=$null;$running=$null;$definition=$null;$settings=$null
try {
$scheduler=New-Object -ComObject 'Schedule.Service';$scheduler.Connect()
$folder=$scheduler.GetFolder('\Microsoft\Windows\AppID');$registered=$folder.GetTask('PolicyConverter')
$definition=$registered.Definition;$settings=$definition.Settings
if(-not $settings.AllowDemandStart){throw 'The verified PolicyConverter task does not allow an on-demand invocation.'}
$running=$registered.GetInstances(0)
if($running.Count -ne 0 -or $registered.State -ne 3){throw 'The verified borrowed PolicyConverter task must be idle before invocation.'}
$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($running);$running=$null
$instance=$registered.Run($null)
if($null -eq $instance -or [string]::IsNullOrWhiteSpace($instance.InstanceGuid)){throw 'Native PolicyConverter did not return a task instance identity.'}
$instanceId=[string]$instance.InstanceGuid;$deadline=[DateTime]::UtcNow.AddSeconds(30)
do {
$running=$registered.GetInstances(0)
try {$idle=$running.Count -eq 0 -and $registered.State -eq 3}finally{$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($running);$running=$null}
if($idle){if($registered.LastTaskResult -ne 0){throw ('Native policy conversion failed: '+$registered.LastTaskResult)};Write-Host ('Native PolicyConverter instance completed: '+$instanceId);return}
Start-Sleep -Milliseconds 200
}while([DateTime]::UtcNow -lt $deadline)
Stop-DisposablePolicyConverter
throw 'The owned native PolicyConverter instance did not complete within thirty seconds.'
}finally{foreach($item in @($running,$instance,$settings,$definition,$registered,$folder,$scheduler)){if($null -ne $item -and [Runtime.InteropServices.Marshal]::IsComObject($item)){$null=[Runtime.InteropServices.Marshal]::FinalReleaseComObject($item)}}}
}
$repo=Split-Path $PSScriptRoot -Parent
. "$repo/scripts/Configuration.ps1"
. "$repo/scripts/AppLockerReadiness.ps1"
. "$repo/scripts/WefArrival.ps1"
. "$repo/scripts/AppLockerProbe.ps1"
$root=Join-Path $env:RUNNER_TEMP ('wela-applocker-native-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
Write-Host ('Native fixture process session: '+[Diagnostics.Process]::GetCurrentProcess().SessionId)
$converter=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop
$converterBefore=Export-ScheduledTask -InputObject $converter -ErrorAction Stop
$converterDisabled=$converter.State -eq 'Disabled';$converterChanged=$false
$actions=@($converter.Actions)
if($converter.State -notin @('Disabled','Ready') -or $actions.Count -ne 1 -or [Environment]::ExpandEnvironmentVariables($actions[0].Execute).Trim('"') -ine (Join-Path ([Environment]::SystemDirectory) 'appidpolicyconverter.exe') -or $actions[0].Arguments){throw ('Only the unchanged native PolicyConverter action is permitted: '+($actions|ConvertTo-Json -Depth 8))}
$before=Get-WelaAppLockerReadiness
if($before.Host.PartOfDomain -or $before.Management.Status -ne 'Observed' -or $before.LocalPolicy.Status -ne 'Observed' -or $before.EffectiveGpPolicy.Status -ne 'Observed' -or $before.LocalPolicy.Policy.TotalRules -ne 0 -or $before.EffectiveGpPolicy.Policy.TotalRules -ne 0 -or $before.LocalPolicy.Policy.HasUnknownPolicyData -or $before.EffectiveGpPolicy.Policy.HasUnknownPolicyData){Write-Host ($before | ConvertTo-Json -Depth 16);throw 'Disposable test requires empty, understood local/effective policies on a non-domain disposable host.'}
$backup=Join-Path $root 'policy-before.xml';[IO.File]::WriteAllText($backup,$before.LocalPolicy.Policy.Xml)
[IO.File]::WriteAllText((Join-Path $root 'prerequisites-before.json'),($before | ConvertTo-Json -Depth 16))
$fixture='<AppLockerPolicy Version="1"><RuleCollection Type="Exe" EnforcementMode="AuditOnly"><FilePathRule Id="12345678-1234-1234-1234-123456789abc" Name="Disposable Windows path only" Description="Owned native event fixture" UserOrGroupSid="S-1-1-0" Action="Allow"><Conditions><FilePathCondition Path="%WINDIR%\*" /></Conditions></FilePathRule></RuleCollection></AppLockerPolicy>'
$policyPath=Join-Path $root 'fixture.xml';[IO.File]::WriteAllText($policyPath,$fixture)
$log=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/EXE and DLL');$enabled=$log.IsEnabled;$touched=$false;$cleanup=@();$primary=$null
try {
$touched=$true
# Test-only preparation under explicit disposable-host and empty-GP gates.
# Hosted images contain enrollment/provider keys: preserve them and CSP Unknown.
# The production importer must continue to reject those observations.
$preparedUtc=[DateTime]::UtcNow
Set-AppLockerPolicy -XmlPolicy $policyPath -ErrorAction Stop
if($before.Service.StartMode -eq 'Disabled'){throw 'Test will not change protected AppIDSvc startup mode.'}
if($before.Service.State -ne 'Running'){Start-Service AppIDSvc -ErrorAction Stop}
$log.IsEnabled=$true;$log.SaveChanges()
if($converterDisabled){$converterChanged=$true;$null=Enable-ScheduledTask -InputObject $converter -ErrorAction Stop}
Refresh-DisposableComputerPolicy
Run-DisposablePolicyConverter
$applied=$false;$applyDeadline=[DateTime]::UtcNow.AddSeconds(30)
do {
$records=@()
try {try{$records=@(Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-AppLocker/EXE and DLL';Id=8001;StartTime=$preparedUtc} -MaxEvents 10 -ErrorAction Stop)}catch{if($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*'){throw}};$applied=$records.Count -gt 0} finally {foreach($record in $records){$record.Dispose()}}
if($applied){break};Start-Sleep -Milliseconds 250
} while([DateTime]::UtcNow -lt $applyDeadline)
if(-not $applied){throw 'No native 8001 policy-applied event after disposable GP refresh.'}
Write-Host 'Native 8001 policy-applied evidence observed after disposable GP refresh.'
# Wait for actual effective audit-only policy, without treating elapsed time as success.
$deadline=[DateTime]::UtcNow.AddSeconds(30)
do {$state=Get-WelaAppLockerProbeState;$ready=$false;try{$null=Get-WelaAppLockerProbeKey $state;$ready=$true}catch{};if($ready){break};Start-Sleep -Milliseconds 500}while([DateTime]::UtcNow -lt $deadline)
$probe=Invoke-WelaAppLockerProbe -Action Run -OutputPath (Join-Path $root 'evidence') -TimeoutSeconds 30
if($probe.ExitCode -or $probe.Status -ne 'NativeExeEventObserved' -or $probe.EventId -ne 8003){throw ($probe|ConvertTo-Json -Depth 24)}
foreach($artifact in $probe.Artifacts){if((Get-FileHash (Join-Path $probe.OutputPath $artifact.Name)).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'Artifact hash mismatch'}}
Write-Host "Native AppLocker 8003 observed under PowerShell $($PSVersionTable.PSVersion), build $($probe.Before.Host.Build). Zero Sigma credit."
} catch {
$primary=$_;Write-Host $_
Get-ChildItem -LiteralPath $root -Recurse -Filter 'candidate-*.xml'|ForEach-Object {Write-Host ([IO.File]::ReadAllText($_.FullName))}
# Read-only diagnostic independent of the production XPath filter and parser.
try {
$recent=@(Get-WinEvent -LogName 'Microsoft-Windows-AppLocker/EXE and DLL' -MaxEvents 12 -ErrorAction Stop)
try {foreach($record in $recent){Write-Host ('Recent native channel XML: '+$record.ToXml())}} finally {foreach($record in $recent){$record.Dispose()}}
} catch {Write-Host ('Recent native channel read: '+$_.Exception.Message)}
Get-CimInstance Win32_SystemDriver -Filter "Name='AppID'" | Select-Object Name,State,StartMode | ConvertTo-Json | Write-Host
try {Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -ErrorAction Stop | Select-Object TaskName,State | ConvertTo-Json | Write-Host}catch{Write-Host ('AppID task read: '+$_.Exception.Message)}
try {$nativeLog=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/EXE and DLL');try{$nativeLog | Select-Object IsEnabled,LogType,ProviderLevel,ProviderKeywords,LogIsolation | ConvertTo-Json | Write-Host}finally{$nativeLog.Dispose()}}catch{Write-Host ('Channel metadata read: '+$_.Exception.Message)}
Write-Host ((Get-WelaAppLockerPolicySnapshot Effective) | ConvertTo-Json -Depth 12)
}
finally {
if($touched){
try {Stop-DisposablePolicyConverter}catch{$cleanup+=$_.Exception.Message}
try {Set-AppLockerPolicy -XmlPolicy $backup -ErrorAction Stop;Refresh-DisposableComputerPolicy;if($converterChanged -or -not $converterDisabled){Run-DisposablePolicyConverter};$restored=Get-WelaAppLockerPolicySnapshot Local;if($restored.Status -ne 'Observed' -or (Get-WelaAppLockerXmlKey $restored.Policy.Xml) -cne (Get-WelaAppLockerXmlKey $before.LocalPolicy.Policy.Xml)){throw 'Local policy restoration differs'};$effectiveRestored=Get-WelaAppLockerPolicySnapshot Effective;if($effectiveRestored.Status -ne 'Observed' -or (Get-WelaAppLockerXmlKey $effectiveRestored.Policy.Xml) -cne (Get-WelaAppLockerXmlKey $before.EffectiveGpPolicy.Policy.Xml)){throw 'Effective GP policy restoration differs'}}catch{$cleanup+=$_.Exception.Message}
try {Stop-DisposablePolicyConverter;if($converterChanged){$null=Disable-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop};$taskAfter=Get-ScheduledTask -TaskPath '\Microsoft\Windows\AppID\' -TaskName 'PolicyConverter' -ErrorAction Stop;if($taskAfter.State -ne $(if($converterDisabled){'Disabled'}else{'Ready'}) -or (Export-ScheduledTask -InputObject $taskAfter -ErrorAction Stop) -cne $converterBefore){throw 'Native PolicyConverter task definition was not restored'}}catch{$cleanup+=$_.Exception.Message}
try {$log.IsEnabled=$enabled;$log.SaveChanges();$verify=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($log.LogName);try{if($verify.IsEnabled -ne $enabled){throw 'Channel restoration differs'}}finally{$verify.Dispose()}}catch{$cleanup+=$_.Exception.Message}
if($before.Service.State -ne 'Running') {try {Stop-Service AppIDSvc -ErrorAction Stop}catch{Write-Host 'Protected AppIDSvc could not stop; startup mode was untouched. The disposable hosted VM is discarded after this job.'}}
$afterService=Get-WelaAppLockerService;if($afterService.StartMode -ne $before.Service.StartMode){$cleanup+='AppIDSvc startup mode changed'}
}
$log.Dispose()
}
if($cleanup.Count){throw ('Native cleanup failed: '+($cleanup -join '; '))}
if($primary){throw $primary}
Write-Host 'Original local policy and channel state restored; service startup mode preserved.'
$global:LASTEXITCODE=0
+2
View File
@@ -9,6 +9,8 @@ $placeholderNodes = @('Exe','Dll','Msi','Script','Appx') | ForEach-Object { '<Ru
$placeholders = '<AppLockerPolicy Version="1">' + ($placeholderNodes -join '') + '</AppLockerPolicy>'
$unusedPlaceholders = '<AppLockerPolicy Version="1">' + (($placeholderNodes | Select-Object -Skip 1) -join '') + '</AppLockerPolicy>'
$readbackPlaceholders = $xml.Replace('</AppLockerPolicy>', (($placeholderNodes | Select-Object -Skip 1) -join '') + '</AppLockerPolicy>')
$emptyPolicy=ConvertFrom-WelaAppLockerXml -Xml '<AppLockerPolicy Version="1" />'
Assert ($emptyPolicy.TotalRules -is [int] -and $emptyPolicy.TotalRules -eq 0) 'An understood empty policy reports zero rules, not an unknown null count.'
$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.'
+4
View File
@@ -25,6 +25,10 @@ try {
$fixture=New-WelaEvtxFixture (Join-Path $temp 'source')
$source=Import-WelaEvtxProbe $fixture.Directory
Assert ($source.Event.Computer -eq 'source01.lab.test' -and $source.Files.Count -eq 5) 'Completed native-shaped source bundle validates with all hashes'
$fractional=New-WelaEvtxFixture (Join-Path $temp 'fractional-zeroes') -Timestamp ([datetime]::SpecifyKind([datetime]'2025-01-02T03:04:05.1234500',[DateTimeKind]::Utc))
$fractionalSource=Import-WelaEvtxProbe $fractional.Directory
Assert ($fractionalSource.Manifest.BeforeState.capturedAtUtc -is [string] -and $fractionalSource.Manifest.BeforeState.capturedAtUtc.EndsWith('.1234500Z')) 'Fixture retains fractional timestamp zeroes as strings in embedded metadata'
Assert ($fractionalSource.Files.Count -eq 5) 'Deterministic fractional-zero fixture passes the unchanged strict bundle importer'
foreach($case in @('hash','extra','duplicate-json','bad-status','embedded','typed','missing-mask','source-drift','time','process-command','unknown-field','bad-kind','duplicate-artifact')) {
$dir=Join-Path $temp $case;Copy-Item $fixture.Directory $dir -Recurse
$m=Clone $fixture.Manifest
+4
View File
@@ -25,6 +25,10 @@ try {
$fixture=New-WelaArrivalFixture (Join-Path $temp 'source')
$source=Import-WelaArrivalProbe $fixture.Directory
Assert ($source.Event.Computer -eq 'source01.lab.test' -and $source.Files.Count -eq 5) 'Completed native-shaped source bundle validates with all hashes'
$fractional=New-WelaArrivalFixture (Join-Path $temp 'fractional-zeroes') -Timestamp ([datetime]::SpecifyKind([datetime]'2025-01-02T03:04:05.1234500',[DateTimeKind]::Utc))
$fractionalSource=Import-WelaArrivalProbe $fractional.Directory
Assert ($fractionalSource.Manifest.BeforeState.capturedAtUtc -is [string] -and $fractionalSource.Manifest.BeforeState.capturedAtUtc.EndsWith('.1234500Z')) 'Fixture retains fractional timestamp zeroes as strings in embedded metadata'
Assert ($fractionalSource.Files.Count -eq 5) 'Deterministic fractional-zero fixture passes the unchanged strict bundle importer'
foreach($case in @('hash','extra','duplicate-json','bad-status','embedded','typed','missing-mask','source-drift','time','process-command','unknown-field','bad-kind','duplicate-artifact')) {
$dir=Join-Path $temp $case;Copy-Item $fixture.Directory $dir -Recurse
$m=Clone $fixture.Manifest
+3 -3
View File
@@ -1,7 +1,7 @@
# Synthetic source/collector data only. No native event generation or telemetry claim.
function New-WelaEvtxFixture {
param([string]$Directory)
$now=[DateTime]::UtcNow.AddSeconds(-5)
param([string]$Directory,[datetime]$Timestamp=([DateTime]::UtcNow.AddSeconds(-5)))
$now=$Timestamp
$hostState=[pscustomobject][ordered]@{Status='Observed';Build=20348;UBR=4000;Edition='ServerDatacenter';ProductType=3;DomainRole=3;DomainJoined=$true;Domain='lab.test';Architecture='64-bit';ProcessorArchitecture=9;InstalledRoles=@('Web-Server');RolesStatus='Observed';Diagnostic=''}
$policies=@{};foreach ($p in (Import-WelaAuditProfiles).catalog) {$policies[$p.guid]=0};$policies['0cce922b-69ae-11d9-bed3-505054503030']=1
$state=[pscustomobject][ordered]@{capturedAtUtc=$now.AddSeconds(-2).ToString('o');context=[pscustomobject]@{computer='source01';role='MemberServer';build=20348;patch='20348.4000';domainJoined=$true;installedRoles=@('Web-Server')};hostObservation=$hostState;auditPolicies=$policies;auditPrecedence=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};commandLineCapture=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};securityChannelEnabled=$true}
@@ -13,7 +13,7 @@ function New-WelaEvtxFixture {
"@
$null=New-Item -ItemType Directory -Path $Directory
$artifacts=@();foreach ($entry in @(@('before-state.json',$before),@('after-state.json',$after),@('process.json',($process|ConvertTo-Json -Depth 6)),@('event.xml',$xml))) {$artifacts+=Write-WelaProbeArtifact $Directory $entry[0] $entry[1]}
$manifest=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaNativeProbeComponents';Probe='security-4688-command-line-v1';Action='Run';Status='NativeEventObserved';ExitCode=0;GeneratedUtc=$now.AddSeconds(-3).ToString('o');PolicyChanges=0;ReadyRuleCredit=0;Scope='Synthetic test fixture';RequiredEvidence=@('Reviewed complete rule and normalization','Backend ingestion','Translated query and successful query result');BeforeState=(ConvertFrom-Json $before);AfterState=(ConvertFrom-Json $after);Process=$process;Artifacts=$artifacts;Diagnostic='';OutputPath=$Directory}
$manifest=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaNativeProbeComponents';Probe='security-4688-command-line-v1';Action='Run';Status='NativeEventObserved';ExitCode=0;GeneratedUtc=$now.AddSeconds(-3).ToString('o');PolicyChanges=0;ReadyRuleCredit=0;Scope='Synthetic test fixture';RequiredEvidence=@('Reviewed complete rule and normalization','Backend ingestion','Translated query and successful query result');BeforeState=(ConvertFrom-WelaEvtxJson $before);AfterState=(ConvertFrom-WelaEvtxJson $after);Process=$process;Artifacts=$artifacts;Diagnostic='';OutputPath=$Directory}
$null=Write-WelaProbeArtifact $Directory 'manifest.json' ($manifest|ConvertTo-Json -Depth 20)
[pscustomobject]@{Directory=$Directory;Xml=$xml;Host=$hostState;Process=$process;Manifest=$manifest}
}
+3 -3
View File
@@ -1,7 +1,7 @@
# Synthetic source/collector data only. No native event generation or telemetry claim.
function New-WelaArrivalFixture {
param([string]$Directory)
$now=[DateTime]::UtcNow.AddSeconds(-5)
param([string]$Directory,[datetime]$Timestamp=([DateTime]::UtcNow.AddSeconds(-5)))
$now=$Timestamp
$hostState=[pscustomobject][ordered]@{Status='Observed';Build=20348;UBR=4000;Edition='ServerDatacenter';ProductType=3;DomainRole=3;DomainJoined=$true;Domain='lab.test';Architecture='64-bit';ProcessorArchitecture=9;InstalledRoles=@('Web-Server');RolesStatus='Observed';Diagnostic=''}
$policies=@{};foreach ($p in (Import-WelaAuditProfiles).catalog) {$policies[$p.guid]=0};$policies['0cce922b-69ae-11d9-bed3-505054503030']=1
$state=[pscustomobject][ordered]@{capturedAtUtc=$now.AddSeconds(-2).ToString('o');context=[pscustomobject]@{computer='source01';role='MemberServer';build=20348;patch='20348.4000';domainJoined=$true;installedRoles=@('Web-Server')};hostObservation=$hostState;auditPolicies=$policies;auditPrecedence=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};commandLineCapture=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};securityChannelEnabled=$true}
@@ -13,7 +13,7 @@ function New-WelaArrivalFixture {
"@
$null=New-Item -ItemType Directory -Path $Directory
$artifacts=@();foreach ($entry in @(@('before-state.json',$before),@('after-state.json',$after),@('process.json',($process|ConvertTo-Json -Depth 6)),@('event.xml',$xml))) {$artifacts+=Write-WelaProbeArtifact $Directory $entry[0] $entry[1]}
$manifest=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaNativeProbeComponents';Probe='security-4688-command-line-v1';Action='Run';Status='NativeEventObserved';ExitCode=0;GeneratedUtc=$now.AddSeconds(-3).ToString('o');PolicyChanges=0;ReadyRuleCredit=0;Scope='Synthetic test fixture';RequiredEvidence=@('Reviewed complete rule and normalization','Backend ingestion','Translated query and successful query result');BeforeState=(ConvertFrom-Json $before);AfterState=(ConvertFrom-Json $after);Process=$process;Artifacts=$artifacts;Diagnostic='';OutputPath=$Directory}
$manifest=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaNativeProbeComponents';Probe='security-4688-command-line-v1';Action='Run';Status='NativeEventObserved';ExitCode=0;GeneratedUtc=$now.AddSeconds(-3).ToString('o');PolicyChanges=0;ReadyRuleCredit=0;Scope='Synthetic test fixture';RequiredEvidence=@('Reviewed complete rule and normalization','Backend ingestion','Translated query and successful query result');BeforeState=(ConvertFrom-WelaArrivalJson $before);AfterState=(ConvertFrom-WelaArrivalJson $after);Process=$process;Artifacts=$artifacts;Diagnostic='';OutputPath=$Directory}
$null=Write-WelaProbeArtifact $Directory 'manifest.json' ($manifest|ConvertTo-Json -Depth 20)
[pscustomobject]@{Directory=$Directory;Xml=$xml;Host=$hostState;Process=$process;Manifest=$manifest}
}
+1
View File
@@ -7,6 +7,7 @@
**改善:**
- 既存の監査専用ポリシーに対する固定ネイティブ EXE の実行と AppLocker イベントの厳密な照合、保護されたハッシュ付き証拠、変更検出を行うオプトインの `applocker-probe` を追加しました。ポリシー・サービス・チャネルの変更や Sigma の評価加算は行いません。使い捨て Windows CI は一時的な監査専用テストポリシーで実際の 8003 を収集し、管理状態の観測を保持して GP ポリシーとチャネル設定を復元します。 (#423) (@Shirofune-Security)
- 既存のローカルファイル・レジストリを明示的に選択して監査・計画・設定する`targeted-sacl`を追加しました。出典ごとの監査ACE、実効ポリシーの前提条件、継承の個別同意を確認し、対象ハンドルを使ってSACLだけを更新します。既存のセキュリティ記述子を保持し、変更前と検証済みの記録、最終状態の確認、特権の復元に対応します。使い捨てオブジェクトのネイティブテストを追加し、子孫全体・転送・Sigmaの利用可能性は別途検証が必要です。 (#422) (@Shirofune-Security)
- 既存CA向けにネイティブの`adcs-auditing`監査・計画・出典付き設定を追加しました。CAと証明書の識別、監査の前提条件、型付き復旧記録、変更直前と読戻しの検証を共有し、従来のCA設定も同じ処理を使用します。停止中のCAは起動せず、専用コマンドでのフィルター変更には再起動の明示指定を求めます。設定一致・再起動の観測・イベント証拠を区別し、Sigma利用可能数には加算しません。使い捨てのスタンドアロンCAテストで保留要求の4886/4889 XMLを関連付け、元の監査設定と作成した資源を復元・削除します。エンタープライズCA・DC・収集基盤の検証は別途必要です。 (#421) (@Shirofune-Security)
- `evtx-recovery` を追加し、検証済みのネイティブ Security プローブを EVTX に出力して Windows イベント API で再読込できるようにしました。実際の読取アカウントによる検証、入力・イベントの厳密な比較、新規出力の保護、ハッシュとドリフト検出で空または変更された記録を拒否します。使い捨て Windows テストで実際の出力・復旧を検証し、全体の保存期間、他アカウントのアクセス、Sigma 対応とは区別します。 (#420) (@Shirofune-Security)
+1
View File
@@ -7,6 +7,7 @@
**Improvements:**
- Added opt-in `applocker-probe` planning and fixed native EXE collection against existing audit-only policy, with exact AppLocker event correlation, private hashed evidence and drift checks. No policy/service/channel changes or Sigma credit; disposable Windows CI prepares one temporary audit-only fixture for real 8003 collection, preserving management observations and restoring GP policy/channel settings. (#423) (@Shirofune-Security)
- Added opt-in `targeted-sacl` auditing, reviewed plans and selective configuration for existing local file/registry targets. Source-specific audit ACEs, policy prerequisites, inheritance consent, handle-bound SACL-only writes, preserved security descriptors, pending/confirmed receipts and final checks keep target scope explicit. Privileges are restored; native disposable-object tests cover file/registry events without claiming descendant, forwarding or Sigma readiness. (#422) (@Shirofune-Security)
- Added dedicated native `adcs-auditing` audit, plan and source-profile configuration, with pinned CA/certificate identity, verified audit prerequisites, typed journals and fresh/readback guards. Legacy CA configuration uses the same engine; stopped CAs are preserved and dedicated filter changes require explicit restart consent. Policy matches, observed restarts and request events remain separate, with no Sigma credit. Disposable standalone-CA tests collect correlated pending-request 4886/4889 XML and restore policy/created resources; enterprise/DC/backend acceptance remains separate. (#421) (@Shirofune-Security)
- Added opt-in `evtx-recovery` to export one validated native Security probe and reopen its EVTX through Windows event APIs, with a separate verification action under the actual reader. Strict source/component checks, exact event comparison, protected new output, archive hashes and reader/context drift checks reject empty or changed evidence. Disposable Windows tests cover real export/recovery and empty archives; full retention, other-reader access and Sigma readiness remain separate. (#420) (@Shirofune-Security)