From 4df3bb4c79c63437f319bd01615f09e2ede78698 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:16:44 +0900 Subject: [PATCH 01/11] Reject unknown CLI arguments before legacy command dispatch --- .github/workflows/cli-arguments.yml | 35 +++++++++++++ CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 7 +++ tests/CliArguments.Tests.ps1 | 70 ++++++++++++++++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 7 files changed, 120 insertions(+) create mode 100644 .github/workflows/cli-arguments.yml create mode 100644 tests/CliArguments.Tests.ps1 diff --git a/.github/workflows/cli-arguments.yml b/.github/workflows/cli-arguments.yml new file mode 100644 index 00000000..ba0108bd --- /dev/null +++ b/.github/workflows/cli-arguments.yml @@ -0,0 +1,35 @@ +name: Public CLI unknown argument rejection +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + cli-arguments: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Windows PowerShell 5.1 process and native state checks + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/CliArguments.Tests.ps1 + - name: PowerShell 7 process and native state checks + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/CliArguments.Tests.ps1 + - name: Retain native before and after observations + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cli-arguments-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-cli-arguments.json + if-no-files-found: error + retention-days: 7 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 4c13f51b..27bac3e5 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 従来の設定コマンドでも、未対応の `-WhatIf` や入力ミスなどの未認識引数を実行前に拒否するようにしました。正しい位置指定引数と文書化された `-DryRun` の動作は維持し、Windows PowerShell 5.1 と PowerShell 7 で公開CLIを検証します。 (@Shirofune-Security) + - `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security) - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31bfbecd..fefc6cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Reject unbound command-line arguments before dispatch, including unsupported `-WhatIf` and misspelled options on legacy configuration commands. Valid positional arguments and documented `-DryRun` behavior are preserved. Public CLI regressions cover both Windows PowerShell 5.1 and PowerShell 7. (@Shirofune-Security) + - Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security) - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 34d6797b..6aeead0f 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2188,6 +2188,13 @@ if ($Cmd -ne 'ldap-diagnostics' -and @($PSBoundParameters.Keys | Where-Object { throw 'LDAP options require the dedicated ldap-diagnostics command. No command was run.' } +# Plain scripts retain unknown named options in $args. Check them before every +# dispatch, including the profile shortcut, so an unsupported -WhatIf or typo +# cannot accidentally reach a writer. Keep dedicated option diagnostics above. +if ($args.Count -gt 0) { + throw 'Unsupported trailing arguments. Check -Help for documented options; no command was run.' +} + if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'configure') -and -not $Help) { Invoke-WelaProfileCommand -Command $Cmd.ToLower() return diff --git a/tests/CliArguments.Tests.ps1 b/tests/CliArguments.Tests.ps1 new file mode 100644 index 00000000..9f28df52 --- /dev/null +++ b/tests/CliArguments.Tests.ps1 @@ -0,0 +1,70 @@ +# Public process-boundary regression: no mocked dispatcher or Windows writers. +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent +$engine = (Get-Process -Id $PID).Path +$count = 0 +$root = Join-Path ([IO.Path]::GetTempPath()) ('wela-cli-arguments-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $root +function Assert($Value, $Message) { if (-not $Value) { throw $Message }; $script:count++ } +function Invoke-Case([string[]]$Arguments, [int]$Expected, [string]$Pattern) { + $prior = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = & $engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @Arguments 2>&1 | Out-String + $code = $LASTEXITCODE + } finally { $ErrorActionPreference = $prior } + Assert ($code -eq $Expected -and $output -match $Pattern) "Unexpected public CLI exit/output [$code]: $output" +} +$isWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT +function Read-NativeState { + $logs = @('Security','System','Application','ForwardedEvents','Microsoft-Windows-CAPI2/Operational') + $state = [ordered]@{ Audit = Get-WelaEffectiveAuditPolicy; Channels = @() } + foreach ($name in $logs) { $state.Channels += Get-WelaNativeChannel $name } + return ($state | ConvertTo-Json -Depth 12 -Compress) +} +try { + if ($isWindowsHost) { + Import-Module "$repo/modules/AuditProfiles.psm1" -Force + Import-Module "$repo/modules/NativeProviders.psm1" -Force + $before = Read-NativeState + } + # These previously reached legacy writers, including the profile fast path. + $commands = @( + @('configure','-Auto'), + @('configure','-Profile','wela-2.2.0','-Auto'), + @('configure-eventlogs','-LogProfile','asd-collector-archive-2021-10','-ApplyLogMode','-Auto'), + @('configure-sacl','-Auto'), + @('channel-settings','-ChannelAction','Configure','-GrantEventLogReaders','-Auto'), + @('powershell-transcription','-TranscriptionAction','Configure','-Auto'), + @('firewall-logging','-FirewallAction','Configure','-Auto'), + @('smb-auditing','-SmbAction','Configure','-Auto'), + @('audit-integrity','-IntegrityAction','Configure','-Auto'), + @('provider-packs','-ProviderAction','Configure','-Auto'), + @('wec-collector','-WefAction','Configure','-Auto'), + @('audit-settings','-Help') + ) + foreach ($command in $commands) { + foreach ($unknown in @('-WhatIf','-DryRnu')) { + Invoke-Case ($command + @('-BackupPath',"$root/journal",'-ResultsPath',"$root/result.json",$unknown)) 1 'Unsupported trailing arguments' + Assert (-not (Test-Path "$root/journal") -and -not (Test-Path "$root/result.json")) 'Rejected arguments must not create journals/results' + } + } + # Unknown argument values are deliberately omitted from WELA's diagnostic. + Invoke-Case @('configure','-Auto','-UnrecognizedOption','opaque-value') 1 'Unsupported trailing arguments' + Invoke-Case @('configure','-Help','-WhatIf:$false') 1 'Unsupported trailing arguments' + Invoke-Case @('-WhatIf','configure','-Auto') 1 'Unsupported trailing arguments' + # Preserve documented named/positional binding, help, abbreviations and DryRun. + Invoke-Case @('configure','-Help','-Auto','-DryRun') 0 'Read live state' + Invoke-Case @('-Cmd','configure','-Help') 0 'Usage:' + Invoke-Case @('configure','std','-Help') 0 'Usage:' + Invoke-Case @('configure','-Hel') 0 'Usage:' + Invoke-Case @('profiles') 0 'wela-2.2.0' + Invoke-Case @('failed-logon-probe','-FailedLogonAction','Run','-WhatIf') 1 'only dedicated' + if ($isWindowsHost) { + Assert ((Read-NativeState) -ceq $before) 'Actual audit masks and native channel settings must remain unchanged' + $evidence = [ordered]@{ Status='Passed'; Engine=$PSVersionTable.PSVersion.ToString(); OS=[Environment]::OSVersion.Version.ToString(); StateUnchanged=$true; Before=($before|ConvertFrom-Json); After=((Read-NativeState)|ConvertFrom-Json) } + if ($env:RUNNER_TEMP) { $evidence | ConvertTo-Json -Depth 16 | Set-Content (Join-Path $env:RUNNER_TEMP 'wela-cli-arguments.json') -Encoding UTF8 } + } + Write-Host "PASS: $count public CLI argument assertions." +} finally { Remove-Item -LiteralPath $root -Recurse -Force } +$global:LASTEXITCODE = 0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index de508ee6..2da25b19 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 従来の設定コマンドでも、未対応の `-WhatIf` や入力ミスなどの未認識引数を実行前に拒否するようにしました。正しい位置指定引数と文書化された `-DryRun` の動作は維持し、Windows PowerShell 5.1 と PowerShell 7 で公開CLIを検証します。 (@Shirofune-Security) + - `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security) - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 4ca9dbe7..79787a76 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Reject unbound command-line arguments before dispatch, including unsupported `-WhatIf` and misspelled options on legacy configuration commands. Valid positional arguments and documented `-DryRun` behavior are preserved. Public CLI regressions cover both Windows PowerShell 5.1 and PowerShell 7. (@Shirofune-Security) + - Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security) - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) From b9a6534424d436ef0e0f7095e8e3eb46cd06ea94 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:18:35 +0900 Subject: [PATCH 02/11] Exercise public channel configuration on disposable Windows --- .../workflows/native-channel-configure.yml | 47 ++++++++++ .../NativeChannelConfigure.Windows.Tests.ps1 | 94 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 .github/workflows/native-channel-configure.yml create mode 100644 tests/NativeChannelConfigure.Windows.Tests.ps1 diff --git a/.github/workflows/native-channel-configure.yml b/.github/workflows/native-channel-configure.yml new file mode 100644 index 00000000..ae344b83 --- /dev/null +++ b/.github/workflows/native-channel-configure.yml @@ -0,0 +1,47 @@ +name: Native channel configuration acceptance +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + native-channel-configure: + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Fixtures and public guards in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/NativeChannelAccess.Tests.ps1 + ./tests/NativeChannelAccess.Windows.Tests.ps1 + - name: Native channel configuration in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/NativeChannelConfigure.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Fixtures and public guards in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/NativeChannelAccess.Tests.ps1 + ./tests/NativeChannelAccess.Windows.Tests.ps1 + - name: Native channel configuration in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/NativeChannelConfigure.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: native-channel-configure-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-channel-configure-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/tests/NativeChannelConfigure.Windows.Tests.ps1 b/tests/NativeChannelConfigure.Windows.Tests.ps1 new file mode 100644 index 00000000..197e057c --- /dev/null +++ b/tests/NativeChannelConfigure.Windows.Tests.ps1 @@ -0,0 +1,94 @@ +param([switch]$AllowDisposableChannelWrite) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableChannelWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable hosted Windows opt-in required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +Import-Module "$repo/modules/EventLogSettings.psm1" -Force +Import-Module "$repo/modules/NativeProviders.psm1" -Force +Import-Module "$repo/modules/NativeChannelAccess.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/NativeChannelConfiguration.ps1" +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Binary($Value){$bytes=New-Object byte[] $Value.BinaryLength;$Value.GetBinaryForm($bytes,0);[Convert]::ToBase64String($bytes)} +function Read-Raw([string]$Name){ + $r=Invoke-WelaNative wevtutil.exe @('gl',$Name,'/f:xml') + $x=New-Object Xml.XmlDocument;$x.XmlResolver=$null;$x.LoadXml(($r.Output -join "`n"));return ,$x +} +function Guard-Raw($Xml){ + $x=$Xml.CloneNode($true);$x.DocumentElement.RemoveAttribute('enabled') + foreach($name in @('channelAccess','maxSize')){foreach($node in @($x.SelectNodes("//*[local-name()='$name']"))){$null=$node.ParentNode.RemoveChild($node)}} + return $x.OuterXml +} +$engine=(Get-Process -Id $PID).Path +$root=Join-Path $env:RUNNER_TEMP ('wela-channel-configure-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$profile=Get-WelaNativeChannelProfile +$before=@{};$raw=@{};$policies=Get-WelaEffectiveAuditPolicy +foreach($control in $profile.controls){$name=$control.channel;$before[$name]=Get-WelaNativeChannel $name;if(Test-WelaNativeChannelSnapshot $before[$name]){$raw[$name]=Read-Raw $name}} +$before|ConvertTo-Json -Depth 14|Set-Content "$root/before.json" -Encoding UTF8 +$missing=@($before.Values|Where-Object State -eq 'Not installed').Count +$expected=if($missing){1}else{0} +$capi='Microsoft-Windows-CAPI2/Operational';$app='Microsoft-Windows-AppLocker/EXE and DLL' +$primary=$null +function Run-Cli([string]$Name,[string[]]$Options){ + $prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" channel-settings @Options -ResultsPath "$root/$Name.json" 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + $lines|Out-String|Set-Content "$root/$Name.txt" -Encoding UTF8 + Assert ($code -eq $expected) "Public $Name exit $code expected $expected : $($lines -join ' ')" + $report=Get-Content "$root/$Name.json" -Raw|ConvertFrom-Json + Assert ($report.ExitCode -eq $code -and $report.ForwardingReadiness -eq 'Not verified') 'Report agrees with native command exit and makes no forwarding claim' + return $report +} +try{ + Assert ($raw.ContainsKey($capi) -and $raw.ContainsKey($app)) 'CAPI2 and AppLocker channels are required for this disposable fixture' + Assert (@($before.Values|Where-Object { $_.State -notin @('Enabled','Disabled','Not installed') }).Count -eq 0) 'Unreadable original metadata refuses fixture writes' + # Fixture-only remove this group read grant so the public opt-in must append it. + $descriptor=[Security.AccessControl.RawSecurityDescriptor]::new($before[$capi].SecurityDescriptor) + for($i=$descriptor.DiscretionaryAcl.Count-1;$i -ge 0;$i--){ + $ace=$descriptor.DiscretionaryAcl[$i] + if($ace -is [Security.AccessControl.CommonAce] -and $ace.AceQualifier -eq 'AccessAllowed' -and $ace.SecurityIdentifier.Value -eq 'S-1-5-32-573' -and ($ace.AccessMask -band 1)){$descriptor.DiscretionaryAcl.RemoveAce($i)} + } + $withoutRead=$descriptor.GetSddlForm('All');$access=Get-WelaChannelAccessPlan $withoutRead + Assert ($access.State -eq 'GrantRequired') 'Prepared actual descriptor supports one lossless read-only grant' + $null=Invoke-WelaNative wevtutil.exe @('sl',$capi,'/e:false','/ms:1048576',('/ca:'+$withoutRead)) + # A large existing log exposed numeric narrowing in older PowerShell planners. + $null=Invoke-WelaNative wevtutil.exe @('sl',$app,'/ms:2147483648') + $prepared=@{};foreach($name in $raw.Keys){$prepared[$name]=Get-WelaNativeChannel $name} + $null=Run-Cli 'plan' @('-ChannelAction','Plan','-GrantEventLogReaders') + $null=Run-Cli 'dry-run' @('-ChannelAction','Configure','-GrantEventLogReaders','-DryRun','-Auto','-BackupPath',"$root/unused") + Assert (-not (Test-Path "$root/unused")) 'DryRun creates no journal' + foreach($name in $raw.Keys){Assert (Test-WelaNativeChannelSnapshotEqual $prepared[$name] (Get-WelaNativeChannel $name)) 'Plan and DryRun preserve actual native channel settings'} + $plain=Run-Cli 'configure' @('-ChannelAction','Configure','-Auto','-BackupPath',"$root/plain-journal") + $plainCapi=Get-WelaNativeChannel $capi + Assert ($plainCapi.IsEnabled -and $plainCapi.MaximumSizeInBytes -eq 102432768 -and (Test-WelaChannelDescriptorEqual $plainCapi.SecurityDescriptor $withoutRead)) 'Public Configure enables/resizes CAPI2 and preserves its ACL without explicit grant' + Assert ((Get-WelaNativeChannel $app).MaximumSizeInBytes -eq 2147483648) 'A larger existing 2GiB buffer is preserved' + $granted=Run-Cli 'grant' @('-ChannelAction','Configure','-GrantEventLogReaders','-Auto','-BackupPath',"$root/grant-journal") + $actual=Get-WelaNativeChannel $capi + Assert (Test-WelaChannelDescriptorEqual $actual.SecurityDescriptor $access.ProposedDescriptor) 'Native readback matches the precise planned grant descriptor' + $afterAcl=[Security.AccessControl.RawSecurityDescriptor]::new($actual.SecurityDescriptor) + Assert ($afterAcl.DiscretionaryAcl.Count -eq $descriptor.DiscretionaryAcl.Count+1) 'Exactly one native DACL ACE is added' + $newAce=$afterAcl.DiscretionaryAcl[$access.AddedAceIndex] + Assert ($newAce.SecurityIdentifier.Value -eq 'S-1-5-32-573' -and $newAce.AccessMask -eq 1 -and $newAce.AceFlags -eq 0 -and -not $newAce.IsCallback) 'Added grant is unconditional read only' + $afterAcl.DiscretionaryAcl.RemoveAce($access.AddedAceIndex) + Assert ((Binary $afterAcl) -ceq (Binary $descriptor)) 'Owner/group/SACL/flags and every original ACE byte/order survive native application' + $again=Run-Cli 'idempotent' @('-ChannelAction','Configure','-GrantEventLogReaders','-Auto','-BackupPath',"$root/repeat-journal") + Assert (@($again.Results|Where-Object Status -eq 'Applied').Count -eq 0) 'Repeated native configuration does not apply another mutation' + Assert (-not (Test-Path "$root/repeat-journal/before.jsonl")) 'Idempotent invocation journals no write' + $journal=@(Get-Content "$root/grant-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json}) + Assert ($journal.Count -eq 1 -and $journal[0].Target.Channel -eq $capi -and (Test-WelaChannelDescriptorEqual $journal[0].Before.SecurityDescriptor $withoutRead)) 'Durable actual pre-grant journal preserves the original descriptor' + foreach($name in $raw.Keys){Assert ((Guard-Raw (Read-Raw $name)) -ceq (Guard-Raw $raw[$name])) 'Native channel path/retention/provider metadata remain unchanged'} + Write-Host "PASS: $count native public channel configuration assertions." +}catch{$primary=$_} +finally{ + $errors=@() + foreach($name in $raw.Keys){ + try{$s=$before[$name];$null=Invoke-WelaNative wevtutil.exe @('sl',$name,('/e:'+$s.IsEnabled.ToString().ToLowerInvariant()),('/ms:'+$s.MaximumSizeInBytes),('/ca:'+$s.SecurityDescriptor)) + if(-not (Test-WelaNativeChannelSnapshotEqual $s (Get-WelaNativeChannel $name)) -or (Read-Raw $name).OuterXml -cne $raw[$name].OuterXml){throw 'Original native channel configuration differs after cleanup'} + }catch{$errors+="$name : $($_.Exception.Message)"} + } + $now=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($policies[$guid] -ne $now[$guid]){$errors+='Audit mask changed: '+$guid}} + $after=@{};foreach($name in $before.Keys){$after[$name]=Get-WelaNativeChannel $name} + [ordered]@{CleanupVerified=($errors.Count -eq 0);Before=$before;After=$after;AuditMasksCompared=$policies.Count;Diagnostic=$errors;Assertions=$count;PrimaryError=[string]$primary}|ConvertTo-Json -Depth 14|Set-Content "$root/cleanup.json" -Encoding UTF8 + if($errors.Count){throw "Cleanup failed: $($errors -join '; '); primary: $primary"} + Write-Host 'Exact original channel metadata and all audit masks verified after cleanup; existing event records/retention duration not claimed.' +} +if($primary){throw $primary};$global:LASTEXITCODE=0 From cba6162cb537da4abc8ece574c6b33223c9a6289 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:22:20 +0900 Subject: [PATCH 03/11] Compare configured channel XML with explicit enabled-field allowance --- tests/NativeChannelConfigure.Windows.Tests.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/NativeChannelConfigure.Windows.Tests.ps1 b/tests/NativeChannelConfigure.Windows.Tests.ps1 index 197e057c..18db6ae3 100644 --- a/tests/NativeChannelConfigure.Windows.Tests.ps1 +++ b/tests/NativeChannelConfigure.Windows.Tests.ps1 @@ -17,7 +17,7 @@ function Read-Raw([string]$Name){ } function Guard-Raw($Xml){ $x=$Xml.CloneNode($true);$x.DocumentElement.RemoveAttribute('enabled') - foreach($name in @('channelAccess','maxSize')){foreach($node in @($x.SelectNodes("//*[local-name()='$name']"))){$null=$node.ParentNode.RemoveChild($node)}} + foreach($name in @('enabled','channelAccess','maxSize')){foreach($node in @($x.SelectNodes("//*[local-name()='$name']"))){$null=$node.ParentNode.RemoveChild($node)}} return $x.OuterXml } $engine=(Get-Process -Id $PID).Path @@ -25,6 +25,7 @@ $root=Join-Path $env:RUNNER_TEMP ('wela-channel-configure-'+[guid]::NewGuid().To $profile=Get-WelaNativeChannelProfile $before=@{};$raw=@{};$policies=Get-WelaEffectiveAuditPolicy foreach($control in $profile.controls){$name=$control.channel;$before[$name]=Get-WelaNativeChannel $name;if(Test-WelaNativeChannelSnapshot $before[$name]){$raw[$name]=Read-Raw $name}} +$rawEvidence=@{};foreach($name in $raw.Keys){$rawEvidence[$name]=$raw[$name].OuterXml};$rawEvidence|ConvertTo-Json -Depth 4|Set-Content "$root/raw-before.json" -Encoding UTF8 $before|ConvertTo-Json -Depth 14|Set-Content "$root/before.json" -Encoding UTF8 $missing=@($before.Values|Where-Object State -eq 'Not installed').Count $expected=if($missing){1}else{0} @@ -75,6 +76,7 @@ try{ Assert (-not (Test-Path "$root/repeat-journal/before.jsonl")) 'Idempotent invocation journals no write' $journal=@(Get-Content "$root/grant-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json}) Assert ($journal.Count -eq 1 -and $journal[0].Target.Channel -eq $capi -and (Test-WelaChannelDescriptorEqual $journal[0].Before.SecurityDescriptor $withoutRead)) 'Durable actual pre-grant journal preserves the original descriptor' + $rawAfter=@{};foreach($name in $raw.Keys){$rawAfter[$name]=(Read-Raw $name).OuterXml};$rawAfter|ConvertTo-Json -Depth 4|Set-Content "$root/raw-configured.json" -Encoding UTF8 foreach($name in $raw.Keys){Assert ((Guard-Raw (Read-Raw $name)) -ceq (Guard-Raw $raw[$name])) 'Native channel path/retention/provider metadata remain unchanged'} Write-Host "PASS: $count native public channel configuration assertions." }catch{$primary=$_} From 8ac4c45653618559d7d5e814f5925d3ae5fc73a5 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:22:21 +0900 Subject: [PATCH 04/11] Add fixed native AppLocker Script probe and disposable evidence matrix --- .github/workflows/applocker-script-probe.yml | 46 +++++ WELA.ps1 | 13 ++ scripts/AppLockerScriptNative.cs | 79 +++++++ scripts/AppLockerScriptProbe.ps1 | 206 +++++++++++++++++++ scripts/AppLockerScriptWorker.ps1 | 7 + tests/AppLockerScriptProbe.Cli.Tests.ps1 | 25 +++ tests/AppLockerScriptProbe.Tests.ps1 | 67 ++++++ tests/AppLockerScriptProbe.Windows.Tests.ps1 | 134 ++++++++++++ 8 files changed, 577 insertions(+) create mode 100644 .github/workflows/applocker-script-probe.yml create mode 100644 scripts/AppLockerScriptNative.cs create mode 100644 scripts/AppLockerScriptProbe.ps1 create mode 100644 scripts/AppLockerScriptWorker.ps1 create mode 100644 tests/AppLockerScriptProbe.Cli.Tests.ps1 create mode 100644 tests/AppLockerScriptProbe.Tests.ps1 create mode 100644 tests/AppLockerScriptProbe.Windows.Tests.ps1 diff --git a/.github/workflows/applocker-script-probe.yml b/.github/workflows/applocker-script-probe.yml new file mode 100644 index 00000000..87a8a1a3 --- /dev/null +++ b/.github/workflows/applocker-script-probe.yml @@ -0,0 +1,46 @@ +name: Native AppLocker Script probe +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + applocker-script-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/AppLockerScriptProbe.Tests.ps1 + ./tests/AppLockerScriptProbe.Cli.Tests.ps1 + - name: Native probe in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/AppLockerScriptProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Fixtures in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/AppLockerScriptProbe.Tests.ps1 + ./tests/AppLockerScriptProbe.Cli.Tests.ps1 + - name: Native probe in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/AppLockerScriptProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Retain bounded native evidence and cleanup receipt + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: applocker-script-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-applocker-script-native-*/** + retention-days: 7 + if-no-files-found: warn diff --git a/WELA.ps1 b/WELA.ps1 index 26475edf..4eedd022 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -144,6 +144,9 @@ [switch]$AllowDnsTraceReset, [string[]]$WecRuntimeId, [ValidateRange(1,512)][int]$WecRuntimeMaximumSources=128, + [ValidateSet('Plan','Run')][string]$AppLockerScriptAction = 'Plan', + [string]$AppLockerScriptOutputPath, + [ValidateRange(1,30)][int]$AppLockerScriptTimeoutSeconds = 15, [ValidateSet('Plan','Run')][string]$AppLockerProbeAction = 'Plan', [string]$AppLockerProbeOutputPath, [ValidateRange(1,30)][int]$AppLockerProbeTimeoutSeconds = 15, @@ -182,6 +185,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/AdObjectSacl.ps1") . (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1") . (Join-Path $ScriptRoot "scripts/AppLockerProbe.ps1") +. (Join-Path $ScriptRoot "scripts/AppLockerScriptProbe.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") . (Join-Path $ScriptRoot "scripts/WmiProbe.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") @@ -1989,6 +1993,7 @@ Usage: ./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription ./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription ./WELA.ps1 wmi-probe -Help # Fixed local read and matched namespace Security4662 evidence + ./WELA.ps1 applocker-script-probe -Help # Collect a fixed native Script8005/8006 event ./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 @@ -2079,6 +2084,8 @@ if ($Cmd -eq 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -no } if ($Cmd -ne 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WmiProbe*'}).Count) {throw 'WmiProbe options require wmi-probe.'} if ($Cmd -eq 'wmi-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WmiProbeAction','WmiProbeNamespace','WmiProbeOutputPath','WmiProbeTimeoutSeconds','Help')}).Count) {throw 'wmi-probe accepts only dedicated probe options.'} +if ($Cmd -ne 'applocker-script-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'AppLockerScript*'}).Count) {throw 'AppLockerScript options require applocker-script-probe.'} +if ($Cmd -eq 'applocker-script-probe' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','AppLockerScriptAction','AppLockerScriptOutputPath','AppLockerScriptTimeoutSeconds','Help')}).Count)) {throw 'applocker-script-probe accepts only its dedicated 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) { @@ -2302,6 +2309,12 @@ switch ($Cmd.ToLower()) { $report if($report.ExitCode){exit $report.ExitCode} } + 'applocker-script-probe' { + if ($Help) {Write-Host 'Usage: applocker-script-probe [-AppLockerScriptAction Plan|Run] [-AppLockerScriptOutputPath new-private-directory] [-AppLockerScriptTimeoutSeconds 1..30]. Requires existing Script AuditOnly policy, running AppIDSvc and enabled MSI and Script channel. Fixed native Windows PowerShell5.1 script, no policy changes or Sigma credit. See docs/applocker-script-probe.md.';return} + $report=Invoke-WelaAppLockerScriptProbe -Action $AppLockerScriptAction -OutputPath $AppLockerScriptOutputPath -TimeoutSeconds $AppLockerScriptTimeoutSeconds + $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 diff --git a/scripts/AppLockerScriptNative.cs b/scripts/AppLockerScriptNative.cs new file mode 100644 index 00000000..6ba554b1 --- /dev/null +++ b/scripts/AppLockerScriptNative.cs @@ -0,0 +1,79 @@ +// Read-only process-token observations. No privilege or authorization changes. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +namespace Wela.AppLockerScript { + public sealed class Group { public string Sid; public uint Attributes; } + public sealed class Privilege { public string Luid; public uint Attributes; } + public sealed class Token { + public string Sid, Name, TokenId, ModifiedId, AuthenticationId, AuthenticationType, ImpersonationLevel, TokenSource; + public Group[] Groups; public Privilege[] Privileges; + } + public static class Native { + public const string SourceSha256="__WELA_SOURCE_SHA256__"; + [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value); + public static DateTime UtcNow() {long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} + [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low; public int High;} + [StructLayout(LayoutKind.Sequential)] struct Statistics {public Luid TokenId,AuthenticationId;public long Expiration;public int Type,Level;public uint Charged,Available,Groups,Privileges;public Luid Modified;} + [StructLayout(LayoutKind.Sequential)] struct SidAndAttributes {public IntPtr Sid;public uint Attributes;} + [StructLayout(LayoutKind.Sequential)] struct TokenGroups {public uint Count;public SidAndAttributes First;} + [StructLayout(LayoutKind.Sequential)] struct LuidAndAttributes {public Luid Luid;public uint Attributes;} + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr h); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr p,uint access,out IntPtr t); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr p,uint access,bool self,out IntPtr t); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr t,int cls,IntPtr data,int length,out int needed); + static string Hex(Luid id) {return "0x"+(((ulong)(uint)id.High<<32)|id.Low).ToString("x");} + static IntPtr Read(IntPtr token,int cls,out int length) { + GetTokenInformation(token,cls,IntPtr.Zero,0,out length); + if(Marshal.GetLastWin32Error()!=122||length<4||length>65536)throw new InvalidOperationException("Unknown or oversized token information."); + IntPtr data=Marshal.AllocHGlobal(length); + if(!GetTokenInformation(token,cls,data,length,out length)){int error=Marshal.GetLastWin32Error();Marshal.FreeHGlobal(data);throw new Win32Exception(error);} + return data; + } + static Token ReadToken(IntPtr token,string source) { + Token result=new Token();result.TokenSource=source;using(WindowsIdentity identity=new WindowsIdentity(token)){result.Sid=identity.User.Value;result.Name=identity.Name;result.AuthenticationType=identity.AuthenticationType;result.ImpersonationLevel=identity.ImpersonationLevel.ToString();} + int length;IntPtr p=Read(token,10,out length); + try {if(length4096||offset+(long)count*size>length)throw new InvalidOperationException("Invalid token groups.");List groups=new List();for(int i=0;iString.CompareOrdinal(a.Sid,b.Sid));result.Groups=groups.ToArray();}finally{Marshal.FreeHGlobal(p);} + p=Read(token,3,out length); + try {int count=Marshal.ReadInt32(p),size=Marshal.SizeOf(typeof(LuidAndAttributes));if(count<0||count>4096||4+(long)count*size>length)throw new InvalidOperationException("Invalid token privileges.");List privileges=new List();for(int i=0;iString.CompareOrdinal(a.Luid,b.Luid));result.Privileges=privileges.ToArray();}finally{Marshal.FreeHGlobal(p);} + return result; + } + public static Token Child(IntPtr handle) { + IntPtr token=IntPtr.Zero; + if(!OpenProcessToken(handle,8,out token))throw new Win32Exception(Marshal.GetLastWin32Error()); + try {if(IsTokenRestricted(token))throw new InvalidOperationException("Restricted child token unsupported.");return ReadToken(token,"ChildProcess");}finally{CloseHandle(token);} + } + [DllImport("advapi32.dll")] static extern bool IsTokenRestricted(IntPtr token); + public static Token Snapshot() { + IntPtr thread=IntPtr.Zero,process=IntPtr.Zero; + if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated callers are unsupported.");} + int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error); + if(!OpenProcessToken(GetCurrentProcess(),8,out process))throw new Win32Exception(Marshal.GetLastWin32Error()); + try {if(IsTokenRestricted(process))throw new InvalidOperationException("Restricted caller unsupported.");return ReadToken(process,"Process");}finally{CloseHandle(process);} + } + [StructLayout(LayoutKind.Sequential)] struct FileInformation { + public uint Attributes;public System.Runtime.InteropServices.ComTypes.FILETIME Creation,Access,Write; + public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow; + } + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr file,out FileInformation info); + public static string FileId(IntPtr handle) { + FileInformation info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error()); + if((info.Attributes&0x410)!=0)throw new InvalidOperationException("One ordinary non-reparse file identity is required."); + return info.Volume.ToString("x8")+":"+info.IndexHigh.ToString("x8")+info.IndexLow.ToString("x8"); + } + public static async System.Threading.Tasks.Task ReadLineBoundedAsync(System.IO.StreamReader reader,int limit) { + char[] buffer=new char[1];System.Text.StringBuilder text=new System.Text.StringBuilder(); + while(true){int count=await reader.ReadAsync(buffer,0,1).ConfigureAwait(false);if(count==0)throw new InvalidOperationException("Owned child ended before its ready marker.");if(buffer[0]=='\n')return text.ToString().TrimEnd('\r');if(text.Length>=limit)throw new InvalidOperationException("Owned child line exceeds the bound.");text.Append(buffer[0]);} + } + public static async System.Threading.Tasks.Task ReadBoundedAsync(System.IO.StreamReader reader,int limit) { + char[] buffer=new char[256];System.Text.StringBuilder text=new System.Text.StringBuilder(); + while(true){int count=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false);if(count==0)return text.ToString();if(text.Length+count>limit)throw new InvalidOperationException("Owned child output exceeds the bound.");text.Append(buffer,0,count);} + } + } +} diff --git a/scripts/AppLockerScriptProbe.ps1 b/scripts/AppLockerScriptProbe.ps1 new file mode 100644 index 00000000..2925b0fa --- /dev/null +++ b/scripts/AppLockerScriptProbe.ps1 @@ -0,0 +1,206 @@ +# One fixed native Windows PowerShell script. Never prepares policy/services/channels. +function Get-WelaAppLockerScriptKey { param($Value) ConvertTo-Json -InputObject $Value -Depth 30 -Compress } +function Get-WelaAppLockerScriptSources { + $sources=[ordered]@{} + foreach($path in @('WELA.ps1','scripts/AppLockerScriptProbe.ps1','scripts/AppLockerScriptNative.cs','scripts/AppLockerScriptWorker.ps1','scripts/AppLockerReadiness.ps1','scripts/WefArrival.ps1')) { + $sources[$path]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + [pscustomobject]$sources +} +function Initialize-WelaAppLockerScriptNative { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'A native 64-bit Windows process is required.'} + $bytes=[IO.File]::ReadAllBytes((Join-Path $script:ScriptRoot 'scripts/AppLockerScriptNative.cs')) + $hash=Get-WelaArrivalHash $bytes + if(-not ('Wela.AppLockerScript.Native' -as [type])) { + $text=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff) + if(([regex]::Matches($text,'__WELA_SOURCE_SHA256__')).Count -ne 1){throw 'Unexpected native source fingerprint placeholder.'} + Add-Type -TypeDefinition $text.Replace('__WELA_SOURCE_SHA256__',$hash) -ErrorAction Stop + } + if([Wela.AppLockerScript.Native]::SourceSha256 -cne $hash){throw 'Loaded helper differs from current source; start a fresh PowerShell process.'} +} +function Get-WelaAppLockerScriptReader { [Wela.AppLockerScript.Native]::Snapshot() } +function Get-WelaAppLockerScriptUtcNow { [Wela.AppLockerScript.Native]::UtcNow() } +function Get-WelaAppLockerScriptExecutionPolicy { + $values=[ordered]@{InheritedProcessValue=$env:PSExecutionPolicyPreference;Machine=@();User=@()} + foreach($scope in @('Machine','User')) { + $base=if($scope -eq 'Machine'){[Microsoft.Win32.Registry]::LocalMachine}else{[Microsoft.Win32.Registry]::CurrentUser} + foreach($path in @('SOFTWARE\Policies\Microsoft\Windows\PowerShell','SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell')) { + $key=$base.OpenSubKey($path,$false) + try { + foreach($name in @('EnableScripts','ExecutionPolicy')) { + $present=$null -ne $key -and $name -cin @($key.GetValueNames()) + $values[$scope]+=[pscustomobject]@{Path=$path;Name=$name;Present=[bool]$present;Kind=$(if($present){[string]$key.GetValueKind($name)}else{$null});Value=$(if($present){$key.GetValue($name,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}else{$null})} + } + }finally{if($key){$key.Dispose()}} + } + } + [pscustomobject]$values +} +function Get-WelaAppLockerScriptState { + $hostState=Get-WelaAppLockerHost + $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop + $version=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop + $machine=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop + $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/MSI and Script') + try {$log=[ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;LogFilePath=$channel.LogFilePath}}finally{$channel.Dispose()} + $source=Resolve-WelaArrivalPath (Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe') + [pscustomobject][ordered]@{Host=$hostState;MachineGuid=$machine.MachineGuid;UBR=$version.UBR;Computer=[Environment]::MachineName;Domain=[string]$computer.Domain;LocalPolicy=(Get-WelaAppLockerPolicySnapshot Local);EffectivePolicy=(Get-WelaAppLockerPolicySnapshot Effective);Management=(Get-WelaAppLockerManagement);Service=(Get-WelaAppLockerService);Channel=$log;ExecutionPolicy=(Get-WelaAppLockerScriptExecutionPolicy);Source=$source;SourceHash=(Get-FileHash -LiteralPath $source -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} +} +function Get-WelaAppLockerScriptStateKey { + param($State) + if($State.Host.Status -cne 'Candidate' -or $State.Host.Is64BitProcess -isnot [bool] -or -not $State.Host.Is64BitProcess -or $State.Host.ProductType -notin @(1,3) -or ($State.Host.ProductType -eq 1 -and $State.Host.Build -notin @(22000,22621,22631,26100,26200)) -or ($State.Host.ProductType -eq 3 -and $State.Host.Build -notin @(20348,26100))){throw 'A reviewed native Windows 11 or Server 2022/2025 member host is required; DCs are excluded.'} + foreach($policy in @($State.LocalPolicy,$State.EffectivePolicy)){if($policy.Status -cne 'Observed' -or $policy.Policy.HasUnknownPolicyData -isnot [bool] -or $policy.Policy.HasUnknownPolicyData){throw 'Local and effective GP policy must be readable and understood.'}} + $collection=@($State.EffectivePolicy.Policy.Collections|Where-Object Type -CEQ 'Script') + if($collection.Count -ne 1 -or $collection[0].EnforcementMode -cne 'AuditOnly' -or $collection[0].RuleCount -lt 1){throw 'An existing nonempty effective Script AuditOnly collection is required.'} + if($State.Service.Status -cne 'Observed' -or $State.Service.State -cne 'Running'){throw 'AppIDSvc must already be running.'} + if($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.Name -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or -not $State.Channel.SecurityDescriptor){throw 'The native MSI and Script channel must already be enabled and readable.'} + if($State.Management.Status -cne 'Observed' -or $State.SourceHash -cnotmatch '^[a-f0-9]{64}$' -or -not $State.MachineGuid -or -not $State.Computer){throw 'Incomplete management, machine or source observation.'} + Get-WelaAppLockerScriptKey $State +} +function Get-WelaAppLockerScriptAuthorizationKey { + param($Token) + if($Token.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^0x[0-9a-f]+$' -or $null -eq $Token.Groups -or $null -eq $Token.Privileges){throw 'Incomplete actual token evidence.'} + Get-WelaAppLockerScriptKey ([ordered]@{Sid=$Token.Sid;AuthenticationId=$Token.AuthenticationId;Groups=$Token.Groups;Privileges=$Token.Privileges}) +} +function New-WelaAppLockerScriptText { + param([string]$Template,[string]$Nonce) + if($Nonce -cnotmatch '^[a-f0-9]{32}$' -or ([regex]::Matches($Template,'__WELA_SCRIPT_NONCE__')).Count -ne 3){throw 'Unexpected fixed worker template or nonce.'} + $Template.Replace('__WELA_SCRIPT_NONCE__',$Nonce) +} +function Read-WelaAppLockerScriptBoundary { + $reader=$null;$record=$null + try { + $query=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-AppLocker/MSI and Script',[Diagnostics.Eventing.Reader.PathType]::LogName,'*');$query.ReverseDirection=$true;$query.TolerateQueryErrors=$false + $reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=1 + $record=$reader.ReadEvent([TimeSpan]::FromSeconds(5)) + $status=@($reader.LogStatus) + if($status.Count -ne 1 -or $status[0].LogName -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or $status[0].StatusCode -ne 0){throw 'Incomplete native channel query status.'} + if($null -eq $record){return [long]0} + if($record.LogName -cne $status[0].LogName -or $record.RecordId -le 0){throw 'Invalid native record boundary.'} + [long]$record.RecordId + }finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}} +} +function Start-WelaAppLockerScriptProcess { + param([string]$Root,$State,$Reader,[string]$SourcesKey) + $nonce=[guid]::NewGuid().ToString('N');$path=Join-Path $Root ('wela-script-'+$nonce+'.ps1') + $template=[IO.File]::ReadAllText((Join-Path $script:ScriptRoot 'scripts/AppLockerScriptWorker.ps1')) + $scriptBytes=[Text.UTF8Encoding]::new($false).GetBytes((New-WelaAppLockerScriptText $template $nonce)) + $artifact=Write-WelaArrivalArtifact $Root ([IO.Path]::GetFileName($path)) ([Text.UTF8Encoding]::new($false).GetString($scriptBytes)) + $source=$null;$scriptFile=$null;$process=$null;$started=$false + try { + $source=[IO.File]::Open($State.Source,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + $scriptFile=[IO.File]::Open($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + $sourceId=[Wela.AppLockerScript.Native]::FileId($source.SafeFileHandle.DangerousGetHandle());$scriptId=[Wela.AppLockerScript.Native]::FileId($scriptFile.SafeFileHandle.DangerousGetHandle()) + if((Get-FileHash -LiteralPath $State.Source -Algorithm SHA256).Hash.ToLowerInvariant() -cne $State.SourceHash -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256){throw 'The held native source or generated script differs before launch.'} + if((Get-WelaAppLockerScriptKey (Get-WelaAppLockerScriptSources)) -cne $SourcesKey){throw 'Source changed before script launch.'} + $readerKey=Get-WelaAppLockerScriptKey $Reader + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Caller token changed before launch.'} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Source;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$path+'"';$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardInput=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true;$info.WorkingDirectory=$Root + $process=[Diagnostics.Process]::new();$process.StartInfo=$info + $start=(Get-WelaAppLockerScriptUtcNow) + $started=$process.Start();if(-not $started){throw 'The fixed script process did not start.'} + $stderr=[Wela.AppLockerScript.Native]::ReadBoundedAsync($process.StandardError,4096) + $ready=[Wela.AppLockerScript.Native]::ReadLineBoundedAsync($process.StandardOutput,256) + if(-not $ready.Wait(30000)){throw 'The fixed script did not reach its ready marker within thirty seconds.'} + $line=$ready.GetAwaiter().GetResult() + if($line -cnotmatch ('^WELA_SCRIPT_READY_'+$nonce+'\|(FullLanguage|ConstrainedLanguage)\|5\.1\.[0-9.]+$')){throw ('Unexpected fixed script ready marker: '+$line)} + $child=[Wela.AppLockerScript.Native]::Child($process.Handle) + if((Get-WelaAppLockerScriptAuthorizationKey $child) -cne (Get-WelaAppLockerScriptAuthorizationKey $Reader)){throw 'The actual child primary/logon authorization differs from the caller.'} + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Caller token changed while the fixed child started.'} + $stdout=[Wela.AppLockerScript.Native]::ReadBoundedAsync($process.StandardOutput,4096) + $process.StandardInput.WriteLine('WELA_SCRIPT_GO_'+$nonce);$process.StandardInput.Close() + if(-not $process.WaitForExit(10000)){throw 'The fixed child did not complete within ten seconds of release.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'The fixed child output did not complete within five seconds.'} + $out=$stdout.GetAwaiter().GetResult();$err=$stderr.GetAwaiter().GetResult();$end=(Get-WelaAppLockerScriptUtcNow) + if($process.ExitCode -ne 0 -or $out.TrimEnd("`r","`n") -cne ('WELA_SCRIPT_COMPLETE_'+$nonce) -or $err){throw ('The fixed script did not complete correctly. Exit='+$process.ExitCode+' Error='+$err)} + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey -or [Wela.AppLockerScript.Native]::FileId($source.SafeFileHandle.DangerousGetHandle()) -cne $sourceId -or [Wela.AppLockerScript.Native]::FileId($scriptFile.SafeFileHandle.DangerousGetHandle()) -cne $scriptId){throw 'Reader or held file identity changed during script execution.'} + [pscustomobject][ordered]@{ProcessId=$process.Id;UserSid=$Reader.Sid;ChildToken=$child;NativePowerShell=$State.Source;NativePowerShellSha256=$State.SourceHash;NativePowerShellFileId=$sourceId;ScriptPath=$path;ScriptSha256=$artifact.Sha256;ScriptFileId=$scriptId;ScriptArtifact=$artifact;Nonce=$nonce;Arguments=$info.Arguments;StartedUtc=$start.ToString('o');CompletedUtc=$end.ToString('o');Clock='GetSystemTimePreciseAsFileTime';Ready=$line;Marker=$out.TrimEnd("`r","`n");ExitCode=$process.ExitCode} + }finally{ + if($process){try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned script process termination is unconfirmed.'}}}finally{$process.Dispose()}} + if($scriptFile){$scriptFile.Dispose()};if($source){$source.Dispose()} + } +} +function Read-WelaAppLockerScriptEvents { + param([long]$Boundary) + $query="*[System[Provider[@Name='Microsoft-Windows-AppLocker'] and (EventID=8005 or EventID=8006) and EventRecordID>$Boundary]]" + $reader=$null;$record=$null;$xml=@();$bytes=0 + try { + $nativeQuery=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Microsoft-Windows-AppLocker/MSI and Script',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$nativeQuery.ReverseDirection=$false;$nativeQuery.TolerateQueryErrors=$false + $reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($nativeQuery);$reader.BatchSize=16 + while($null -ne ($record=$reader.ReadEvent([TimeSpan]::FromSeconds(2)))) { + try{$text=$record.ToXml();$bytes+=[Text.Encoding]::UTF8.GetByteCount($text);if($xml.Count -ge 255 -or $bytes -gt 1048576){throw 'Native script query reached its 256-event/one-MiB cap.'};$xml+=$text}finally{$record.Dispose();$record=$null} + } + $status=@($reader.LogStatus);if($status.Count -ne 1 -or $status[0].LogName -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or $status[0].StatusCode -ne 0){throw 'Incomplete native script query status.'} + [pscustomobject]@{Xml=$xml;Query=$query;Bytes=$bytes;Complete=$true} + }finally{if($record){$record.Dispose()};if($reader){$reader.Dispose()}} +} + +function Test-WelaAppLockerScriptEvent { + param([string]$Xml,$Process,$State,[long]$Boundary) + $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 @('8005','8006') -or $system.Version.InnerText -cne '0' -or $system.EventRecordID.InnerText -notmatch '^[1-9][0-9]*$' -or $system.Channel.InnerText -cne 'Microsoft-Windows-AppLocker/MSI and Script') {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 (ConvertTo-WelaArrivalUtc $Process.CompletedUtc).UtcDateTime -or [long]$system.EventRecordID.InnerText -le $Boundary){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 'SCRIPT' -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.ScriptPath + } catch {return $false} finally {if($reader){$reader.Dispose()}} +} +function Invoke-WelaAppLockerScriptProbe { + 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 AppLockerScriptOutputPath; Plan does not write files.'} + Initialize-WelaAppLockerScriptNative + $sources=Get-WelaAppLockerScriptSources;$sourceKey=Get-WelaAppLockerScriptKey $sources + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaAppLockerScriptProbe';Action=$Action;Status='Unverified';ExitCode=1;RecordedUtc=(Get-WelaAppLockerScriptUtcNow).ToString('o');Sources=$sources;Before=$null;After=$null;ReaderBefore=$null;ReaderAfter=$null;ReaderInterval='After output/context preparation, through child execution and actual event queries; final metadata is checked separately';Boundary=$null;Process=$null;EventId=$null;Decision=$null;Artifacts=@();Diagnostic='';OutputPath=$null;PolicyChanges=0;ReadyRuleCredit=0;CspPolicyState='Unknown';Scope='One fixed native Windows PowerShell5.1 Script-collection event. Other engines, collections, forwarding and Sigma/backend validation are not tested. Sysmon excluded.'} + try { + $before=Get-WelaAppLockerScriptState;$report.Before=$before;$key=Get-WelaAppLockerScriptStateKey $before + if($Action -eq 'Plan'){$report.ReaderBefore=(Get-WelaAppLockerScriptReader);$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report} + $report.OutputPath=New-WelaArrivalOutput -Path $OutputPath -SourcePath $script:ScriptRoot + if((Get-WelaAppLockerScriptStateKey (Get-WelaAppLockerScriptState)) -cne $key){throw 'Context changed during output preparation.'} + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'before.json' ($before|ConvertTo-Json -Depth 24) + $reader=(Get-WelaAppLockerScriptReader);$report.ReaderBefore=$reader;$readerKey=Get-WelaAppLockerScriptKey $reader + $report.Boundary=Read-WelaAppLockerScriptBoundary + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed during the actual boundary query.'} + $process=Start-WelaAppLockerScriptProcess -Root $report.OutputPath -State $before -Reader $reader -SourcesKey $sourceKey;$report.Process=$process + $report.Artifacts+= $process.ScriptArtifact + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'process.json' ($process|ConvertTo-Json -Depth 16) + $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@();$batch=$null + do { + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed before script event query.'} + $batch=Read-WelaAppLockerScriptEvents $report.Boundary + if($batch.Complete -isnot [bool] -or -not $batch.Complete){throw 'Script query completeness is unknown.'} + if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey){throw 'Reader changed during script event query.'} + $matches=@($batch.Xml|Where-Object{Test-WelaAppLockerScriptEvent $_ $process $before $report.Boundary}) + if($matches.Count -gt 1){throw 'Multiple exact script records make the result ambiguous.'} + if($matches.Count -eq 1){break} + Start-Sleep -Milliseconds 250 + }while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) + $report.ReaderAfter=(Get-WelaAppLockerScriptReader) + if((Get-WelaAppLockerScriptKey $report.ReaderAfter) -cne $readerKey){throw 'Reader changed before completion of the actual query interval.'} + if($matches.Count -ne 1){ + # Retain only bounded candidates bearing the owned unique script name. + $owned=@($batch.Xml|Where-Object{$_ -like ('*wela-script-'+$process.Nonce+'.ps1*')}|Select-Object -First 4) + for($i=0;$i -lt $owned.Count;$i++){$report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath ('candidate-'+$i+'.xml') $owned[$i]} + throw 'No exact native Script8005/8006 event arrived within the timeout.' + } + $report.After=Get-WelaAppLockerScriptState + if((Get-WelaAppLockerScriptStateKey $report.After) -cne $key -or (Get-WelaAppLockerScriptKey (Get-WelaAppLockerScriptSources)) -cne $sourceKey -or (Get-FileHash -LiteralPath $process.ScriptPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $process.ScriptSha256){throw 'Host, policy, service, channel, execution-policy observation or implementation changed.'} + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'after.json' ($report.After|ConvertTo-Json -Depth 24) + $report.Artifacts+=Write-WelaArrivalArtifact $report.OutputPath 'event.xml' $matches[0] + $event=[xml]$matches[0];$report.EventId=[int]$event.Event.System.EventID + $report.Decision=if($report.EventId -eq 8005){'Allowed'}else{'AllowedWouldBlockIfEnforced'} + $report.Status='NativeScriptEventObserved';$report.ExitCode=0 + }catch{$report.Diagnostic=$_.Exception.Message} + if($report.OutputPath){$json=$report|ConvertTo-Json -Depth 32;if([Text.Encoding]::UTF8.GetByteCount($json) -gt 2097152){throw 'The script probe report exceeded its two-MiB bound.'};$null=Write-WelaArrivalArtifact $report.OutputPath 'manifest.json' $json} + $report +} diff --git a/scripts/AppLockerScriptWorker.ps1 b/scripts/AppLockerScriptWorker.ps1 new file mode 100644 index 00000000..2ebb46fe --- /dev/null +++ b/scripts/AppLockerScriptWorker.ps1 @@ -0,0 +1,7 @@ +# Fixed locally generated script; no external inputs or configuration writes. +$ErrorActionPreference = 'Stop' +[Console]::Out.WriteLine(('WELA_SCRIPT_READY___WELA_SCRIPT_NONCE__|' + $ExecutionContext.SessionState.LanguageMode + '|' + $PSVersionTable.PSVersion)) +$release = [Console]::In.ReadLine() +if ($release -cne 'WELA_SCRIPT_GO___WELA_SCRIPT_NONCE__') { exit 17 } +[Console]::Out.WriteLine('WELA_SCRIPT_COMPLETE___WELA_SCRIPT_NONCE__') +exit 0 diff --git a/tests/AppLockerScriptProbe.Cli.Tests.ps1 b/tests/AppLockerScriptProbe.Cli.Tests.ps1 new file mode 100644 index 00000000..fc7a575d --- /dev/null +++ b/tests/AppLockerScriptProbe.Cli.Tests.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +$engine=Join-Path $PSHOME $(if($PSEdition -eq 'Core'){if($env:OS -eq 'Windows_NT'){'pwsh.exe'}else{'pwsh'}}else{'powershell.exe'}) +$count=0 +function Check-Command([string]$Arguments,[int]$ExpectedExit,[string]$Pattern) { + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments='-NoProfile -File "'+(Join-Path $repo 'WELA.ps1')+'" '+$Arguments;$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false + try { + $started=$process.Start();$out=$process.StandardOutput.ReadToEndAsync();$err=$process.StandardError.ReadToEndAsync() + if(-not $process.WaitForExit(30000)){throw 'CLI timeout'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($out,$err),5000)){throw 'CLI output timeout'} + $text=$out.GetAwaiter().GetResult()+$err.GetAwaiter().GetResult() + if(($process.ExitCode -eq 0) -ne ($ExpectedExit -eq 0) -or $text -notmatch $Pattern){throw "CLI mismatch: $Arguments ; Exit=$($process.ExitCode) ; $text"};$script:count++ + }finally{if($started -and -not $process.HasExited){$process.Kill();$null=$process.WaitForExit(5000)};$process.Dispose()} +} +Check-Command 'applocker-script-probe -Help' 0 'existing Script AuditOnly' +Check-Command 'help' 0 'applocker-script-probe' +Check-Command 'version -AppLockerScriptAction Run' 1 'AppLockerScript options require' +Check-Command 'applocker-script-probe -AppLockerScriptAction Run -WhatIf' 1 'only its dedicated' +Check-Command 'applocker-script-probe -AppLockerScriptAction Run -DryRun' 1 'only its dedicated' +Check-Command 'applocker-script-probe -Auto' 1 'only its dedicated' +Check-Command 'applocker-script-probe -AppLockerProbeAction Run' 1 'only its dedicated' +Check-Command 'applocker-script-probe -AppLockerScriptAction Run' 1 'Run requires' +Check-Command 'applocker-script-probe -AppLockerScriptAction Plan -AppLockerScriptOutputPath ignored' 1 'Run requires' +Write-Host "AppLocker Script public CLI fixtures passed: $count checks." diff --git a/tests/AppLockerScriptProbe.Tests.ps1 b/tests/AppLockerScriptProbe.Tests.ps1 new file mode 100644 index 00000000..971e915d --- /dev/null +++ b/tests/AppLockerScriptProbe.Tests.ps1 @@ -0,0 +1,67 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +. "$repo/scripts/AppLockerReadiness.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/AppLockerScriptProbe.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='' +$state=[pscustomobject]@{Host=[pscustomobject]@{Status='Candidate';Is64BitProcess=$true;PartOfDomain=$false;ProductType=3;Build=20348};MachineGuid='11111111-1111-1111-1111-111111111111';Management=[pscustomobject]@{Status='Observed'};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/MSI and Script';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)'};Source='C:\Windows\System32\cmd.ps1';SourceHash=('a'*64)} +$state|Add-Member NoteProperty LocalPolicy ($state.EffectivePolicy|ConvertTo-Json -Depth 20|ConvertFrom-Json) +$null=Get-WelaAppLockerScriptStateKey $state;Assert $true 'Valid audit-only prereqs' +foreach($mode in @('Enabled','NotConfigured')){$state.EffectivePolicy.Policy=ConvertFrom-WelaAppLockerXml ($policy.Replace('AuditOnly',$mode));Reject {Get-WelaAppLockerScriptStateKey $state} 'AuditOnly'} +$state.EffectivePolicy.Policy=ConvertFrom-WelaAppLockerXml $policy +$state.Service.State='Stopped';Reject {Get-WelaAppLockerScriptStateKey $state} 'already be running';$state.Service.State='Running' +$state.Channel.Enabled=$false;Reject {Get-WelaAppLockerScriptStateKey $state} 'enabled';$state.Channel.Enabled=$true +$process=[pscustomobject]@{ScriptPath='C:\Temp\wela-owned.ps1';ProcessId=1234;UserSid=$state.Reader.Sid;StartedUtc='2026-09-01T00:00:00.0000000Z';CompletedUtc='2026-09-01T00:00:02.0000000Z'} +$event='8006042Microsoft-Windows-AppLocker/MSI and ScriptTESTSCRIPTS-1-5-21-1-2-3-10001234C:\Temp\wela-owned.ps1' +$end=[long]41 +Assert (Test-WelaAppLockerScriptEvent $event $process $state $end) 'Exact fixture must match' +Assert (Test-WelaAppLockerScriptEvent ($event.Replace('8006','8005')) $process $state $end) 'Allowed event matches but has distinct EventId' +$mutations=@(@('8006','8007'),@('1234','1235'),@('S-1-5-21-1-2-3-1000','S-1-5-21-1-2-3-1001'),@('C:\Temp\wela-owned.ps1','C:\Temp\other.ps1'),@('SCRIPT','DLL'),@('TEST','OTHER'),@('cbda4dbf','abda4dbf'),@('0','1'),@('00:00:01.0000000Z','00:00:03.0000000Z'),@('','8006'),@('','S-1-1-0')) +foreach($pair in $mutations){$bad=$event.Replace($pair[0],$pair[1]);Assert ($bad -cne $event) 'Mutation changed fixture';Assert (-not(Test-WelaAppLockerScriptEvent $bad $process $state $end)) ('Reject '+$pair[0])} +Assert (-not(Test-WelaAppLockerScriptEvent (']>'+$event) $process $state $end)) 'DTD rejected' +Reject {Invoke-WelaAppLockerScriptProbe -Action Run} 'requires' +Reject {Invoke-WelaAppLockerScriptProbe -Action Plan -OutputPath ignored} 'requires' + +Assert (-not(Test-WelaAppLockerScriptEvent $event $process $state 42)) 'Previously observed record is rejected' +Assert (-not(Test-WelaAppLockerScriptEvent ($event.Replace('00:00:01.0000000Z','00:00:02.0000001Z')) $process $state $end)) 'A 100ns late record is rejected without clock padding' +Assert (Test-WelaAppLockerScriptEvent ($event.Replace('00:00:01.0000000Z','00:00:00.0000000Z')) $process $state $end) 'Exact inclusive start is accepted' +Assert (Test-WelaAppLockerScriptEvent ($event.Replace('00:00:01.0000000Z','00:00:02.0000000Z')) $process $state $end) 'Exact inclusive completion is accepted' +$worker=[IO.File]::ReadAllText("$repo/scripts/AppLockerScriptWorker.ps1") +$text=New-WelaAppLockerScriptText $worker ('a'*32) +Assert ($text -notlike '*__WELA_SCRIPT_NONCE__*') 'All three fixed nonce placeholders replaced' +Reject {New-WelaAppLockerScriptText $worker ('a'*31+"'" )} 'nonce' +Reject {New-WelaAppLockerScriptText ($worker+'__WELA_SCRIPT_NONCE__') ('a'*32)} 'template' +$tokens=$null;$errors=$null;$null=[Management.Automation.Language.Parser]::ParseInput($text,[ref]$tokens,[ref]$errors) +Assert (-not $errors.Count) 'Generated worker parses' +# Use the production orchestration with mocked native read/launch boundaries. +# These fixtures never stand in for actual native success; the Windows matrix does that. +$script:ScriptRoot=$repo;$script:scenario='ok';$script:reads=0;$script:state=$state;$script:event=$event +$script:token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1000';AuthenticationId='0x123';Groups=@();Privileges=@();TokenId='0x456';ModifiedId='0x789'} +function Initialize-WelaAppLockerScriptNative {} +function Get-WelaAppLockerScriptReader {if($script:scenario -eq 'reader-drift' -and $script:reads -gt 2){$script:token.ModifiedId='0xabc'};$script:token|ConvertTo-Json -Depth 8|ConvertFrom-Json} +function Get-WelaAppLockerScriptUtcNow {([DateTimeOffset]::Parse('2026-09-01T00:00:00Z')).UtcDateTime} +function Get-WelaAppLockerScriptState {$script:reads++;if($script:scenario -eq 'drift' -and $script:reads -gt 2){$script:state.Service.StartMode='Auto'};$script:state|ConvertTo-Json -Depth 20|ConvertFrom-Json} +function Read-WelaAppLockerScriptBoundary {41} +function Start-WelaAppLockerScriptProcess { + param($Root,$State,$Reader,$SourcesKey) + if($script:scenario -eq 'reader-drift'){$script:token.ModifiedId='0xabc'} + $artifact=Write-WelaArrivalArtifact $Root 'fixed.ps1' 'fixed' + [pscustomobject]@{ScriptPath=(Join-Path $Root 'fixed.ps1');ScriptSha256=$artifact.Sha256;ScriptArtifact=$artifact;Nonce=('a'*32)} +} +function Read-WelaAppLockerScriptEvents {param($Boundary);if($script:scenario -eq 'denied'){throw [UnauthorizedAccessException]::new('Native query denied')};[pscustomobject]@{Xml=if($script:scenario -eq 'duplicate'){@($script:event,$script:event)}elseif($script:scenario -eq 'absent'){@()}else{@($script:event)};Complete=($script:scenario -ne 'cap')}} +function Test-WelaAppLockerScriptEvent {$true} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-applocker-script-test-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +try { + foreach($scenario in @('ok','duplicate','drift','cap','denied','absent','reader-drift')){ + $script:scenario=$scenario;$script:reads=0;$state.Service.StartMode='Manual';$script:token.ModifiedId='0x789' + $result=Invoke-WelaAppLockerScriptProbe Run (Join-Path $root $scenario) 1 + Assert ($result.ReadyRuleCredit -eq 0 -and $result.PolicyChanges -eq 0) 'No readiness or configuration credit' + Assert (($result.ExitCode -eq 0) -eq ($scenario -eq 'ok')) "Expected outcome $scenario : $($result.Diagnostic)" + Assert (($result.Status -ceq 'NativeScriptEventObserved') -eq ($scenario -eq 'ok')) 'Only complete success receives observed status' + Assert (Test-Path (Join-Path $result.OutputPath 'manifest.json')) 'Success/failure manifest retained' + } +}finally{Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "AppLocker Script fixtures passed: $count assertions." diff --git a/tests/AppLockerScriptProbe.Windows.Tests.ps1 b/tests/AppLockerScriptProbe.Windows.Tests.ps1 new file mode 100644 index 00000000..8f0eaef4 --- /dev/null +++ b/tests/AppLockerScriptProbe.Windows.Tests.ps1 @@ -0,0 +1,134 @@ +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/AppLockerScriptProbe.ps1" +$script:ScriptRoot=$repo +$root=Join-Path $env:RUNNER_TEMP ('wela-applocker-script-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='' +$policyPath=Join-Path $root 'fixture.xml';[IO.File]::WriteAllText($policyPath,$fixture) +$log=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/MSI and Script');$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-WelaAppLockerScriptState;$ready=$false;try{$null=Get-WelaAppLockerScriptStateKey $state;$ready=$true}catch{};if($ready){break};Start-Sleep -Milliseconds 500}while([DateTime]::UtcNow -lt $deadline) + foreach($decision in @('WouldBlock','Allowed')) { + if($decision -eq 'Allowed'){ + [IO.File]::WriteAllText($policyPath,$fixture.Replace('%WINDIR%\*','*')) + Set-AppLockerPolicy -XmlPolicy $policyPath -ErrorAction Stop + Refresh-DisposableComputerPolicy;Run-DisposablePolicyConverter + $expected=ConvertFrom-WelaAppLockerXml ([IO.File]::ReadAllText($policyPath)) + $actual=Get-WelaAppLockerPolicySnapshot Effective + if($actual.Status -ne 'Observed' -or (Get-WelaAppLockerXmlKey $actual.Policy.Xml) -cne (Get-WelaAppLockerXmlKey $expected.Xml)){throw 'Actual allowed Script policy differs from the disposable fixture.'} + } + $probe=& "$repo/WELA.ps1" applocker-script-probe -AppLockerScriptAction Run -AppLockerScriptOutputPath (Join-Path $root ('evidence-'+$decision)) -AppLockerScriptTimeoutSeconds 30 + $expectedId=if($decision -eq 'Allowed'){8005}else{8006} + if($probe.ExitCode -or $probe.Status -cne 'NativeScriptEventObserved' -or $probe.EventId -ne $expectedId){throw ($probe|ConvertTo-Json -Depth 32)} + if($probe.ReadyRuleCredit -ne 0 -or $probe.PolicyChanges -ne 0){throw 'Unsupported policy/credit claim.'} + 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 $expectedId observed via public CLI 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/MSI and Script' -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/MSI and Script');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} +$cleanupReceipt=[pscustomobject]@{Head=$env:GITHUB_SHA;Engine=[string]$PSVersionTable.PSVersion;PolicyRestored=$true;ChannelRestored=$true;TaskRestored=$true;ServiceBefore=$before.Service;ServiceAfter=(Get-WelaAppLockerService);ServiceStateRestored=($before.Service.State -ceq (Get-WelaAppLockerService).State);ServiceStartupPreserved=($before.Service.StartMode -ceq (Get-WelaAppLockerService).StartMode);ProtectedServiceBoundary='If AppIDSvc refuses Stop, its running state is left for disposable VM teardown; no full service-state rollback claim.'} +[IO.File]::WriteAllText((Join-Path $root 'cleanup.json'),($cleanupReceipt|ConvertTo-Json -Depth 8)) +Write-Host 'Original local/effective GP policy, channel enablement and converter task restored; service startup mode preserved.' +$global:LASTEXITCODE=0 From 7bfd8f65a6ad0466770b353f2dbc0e15cc8872de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E4=B8=AD=E3=82=B6=E3=83=83=E3=82=AF=20Isaac=20Ma?= =?UTF-8?q?this?= <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:24:21 +0900 Subject: [PATCH 05/11] Add reviewed event-log size and retention recovery (#445) * Add reviewed recovery of completed event-log size and retention changes * Select 64-bit size comparison on Windows PowerShell 5.1 * Require typed completion and identity fields in recovery evidence * Preserve supported UTC timestamp parsing in recovery history * Bind the reviewed native host gate and context dependency * Refuse unknown recovery options before native restoration --- .github/workflows/eventlog-recovery.yml | 47 +++++++ .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 21 ++++ docs/eventlog-recovery.md | 32 +++++ scripts/EventLogRecovery.ps1 | 148 +++++++++++++++++++++++ tests/EventLogRecovery.Cli.Tests.ps1 | 16 +++ tests/EventLogRecovery.Tests.ps1 | 79 ++++++++++++ tests/EventLogRecovery.Windows.Tests.ps1 | 72 +++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 12 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/eventlog-recovery.yml create mode 100644 docs/eventlog-recovery.md create mode 100644 scripts/EventLogRecovery.ps1 create mode 100644 tests/EventLogRecovery.Cli.Tests.ps1 create mode 100644 tests/EventLogRecovery.Tests.ps1 create mode 100644 tests/EventLogRecovery.Windows.Tests.ps1 diff --git a/.github/workflows/eventlog-recovery.yml b/.github/workflows/eventlog-recovery.yml new file mode 100644 index 00000000..ee305b7d --- /dev/null +++ b/.github/workflows/eventlog-recovery.yml @@ -0,0 +1,47 @@ +name: Guarded event-log size and mode recovery +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + eventlog-recovery: + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Fixtures and public guards in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/EventLogRecovery.Tests.ps1 + ./tests/EventLogRecovery.Cli.Tests.ps1 + - name: Native channel restoration in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/EventLogRecovery.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Fixtures and public guards in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/EventLogRecovery.Tests.ps1 + ./tests/EventLogRecovery.Cli.Tests.ps1 + - name: Native channel restoration in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/EventLogRecovery.Windows.Tests.ps1 -AllowDisposableChannelWrite + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: eventlog-recovery-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-event-recovery-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48b16419..665e5c5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/failed-logon-probe.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/eventlog-recovery.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md, ./docs/failed-logon-probe.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 4c13f51b..2c3a40be 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@Shirofune-Security) + - `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security) - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31bfbecd..a7b5aa94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@Shirofune-Security) + - Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security) - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 34d6797b..196ed72e 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -109,6 +109,15 @@ [string]$EvtxProbePath, [string]$EvtxArchivePath, [string]$EvtxOutputPath, + [ValidateSet('Plan','Restore')][string]$EventRecoveryAction = 'Plan', + [string]$EventRecoveryJournalPath, + [string]$EventRecoveryOriginalResultsPath, + [string]$EventRecoveryLog, + [string]$EventRecoveryPlanPath, + [string]$EventRecoveryPlanHash, + [string]$EventRecoveryOutputPath, + [switch]$EventRecoveryAllowShrink, + [switch]$EventRecoveryAllowRetentionChange, [ValidateSet('Plan','Restore')][string]$RecoveryAction = 'Plan', [string]$RecoveryJournalPath, [string]$RecoveryOriginalResultsPath, @@ -195,6 +204,7 @@ Import-Module (Join-Path $ScriptRoot "modules/AuditCatalog.psm1") -ErrorAction S Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1") +. (Join-Path $ScriptRoot "scripts/EventLogRecovery.ps1") Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/NativeChannelConfiguration.ps1") . (Join-Path $ScriptRoot "scripts/ChannelRead.ps1") @@ -1989,6 +1999,7 @@ Usage: ./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 adcs-resume -Help # Review a pending CA auditing restart + ./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write ./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation ./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription ./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription @@ -2070,6 +2081,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 'eventlog-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'EventRecovery*'}).Count) {throw 'EventRecovery options require eventlog-recovery.'} +if ($Cmd -eq 'eventlog-recovery' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EventRecoveryAction','EventRecoveryJournalPath','EventRecoveryOriginalResultsPath','EventRecoveryLog','EventRecoveryPlanPath','EventRecoveryPlanHash','EventRecoveryOutputPath','EventRecoveryAllowShrink','EventRecoveryAllowRetentionChange','Help')}).Count)) {throw 'eventlog-recovery accepts only dedicated options.'} if ($Cmd -ne 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecIngress*'}).Count) {throw 'WecIngress options require wec-ingress.'} if ($Cmd -eq 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecIngressAction','WecIngressName','WecIngressLocalAddress','WecIngressRemoteAddress','WecIngressPlanPath','WecIngressPlanHash','WecIngressOutputPath','Help')}).Count) {throw 'wec-ingress accepts only dedicated options.'} if ($Cmd -ne 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecState*'}).Count) {throw 'WecState options require wec-state.'} @@ -2277,6 +2290,14 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'eventlog-recovery' { + if ($Help) {Write-Host 'Usage: eventlog-recovery [-EventRecoveryAction Plan] -EventRecoveryJournalPath before.jsonl -EventRecoveryOriginalResultsPath results.json -EventRecoveryLog channel -EventRecoveryOutputPath new-directory; then Restore with -EventRecoveryPlanPath plan.json -EventRecoveryPlanHash SHA256 -EventRecoveryOutputPath new-directory and applicable -EventRecoveryAllowShrink / -EventRecoveryAllowRetentionChange. See docs/eventlog-recovery.md.';return} + $arguments=@{Action=$EventRecoveryAction;OutputPath=$EventRecoveryOutputPath;AllowShrink=$EventRecoveryAllowShrink;AllowRetentionChange=$EventRecoveryAllowRetentionChange} + $map=@{EventRecoveryJournalPath='JournalPath';EventRecoveryOriginalResultsPath='OriginalResultsPath';EventRecoveryLog='Log';EventRecoveryPlanPath='PlanPath';EventRecoveryPlanHash='PlanHash'} + foreach($name in $map.Keys){if($PSBoundParameters.ContainsKey($name)){$arguments[$map[$name]]=$PSBoundParameters[$name]}} + $report=Invoke-WelaEventLogRecovery @arguments;$report + if($report.ExitCode){exit $report.ExitCode} + } 'wec-ingress' { if ($Help) {Write-Host 'Usage: wec-ingress [-WecIngressAction Plan] -WecIngressName WELA-WEC-name -WecIngressLocalAddress IPv4 -WecIngressRemoteAddress IPv4/CIDR -WecIngressOutputPath new-directory; then Apply with -WecIngressPlanPath plan.json -WecIngressPlanHash SHA256 -WecIngressOutputPath new-directory. Creates one new Domain TCP5985 rule. See docs/wec-ingress.md.';return} $arguments=@{Action=$WecIngressAction;OutputPath=$WecIngressOutputPath} diff --git a/docs/eventlog-recovery.md b/docs/eventlog-recovery.md new file mode 100644 index 00000000..4337b580 --- /dev/null +++ b/docs/eventlog-recovery.md @@ -0,0 +1,32 @@ +# Reviewed event-log size and retention recovery + +`eventlog-recovery` restores the size and retention mode immediately before one completed WELA profile operation. It supports administrative and operational channels in the bundled event-log profiles on reviewed Windows 11 / Server 2022 and 2025 builds. This is part of issues #379 and #365; it does not recover records already lost. + +Use the original `before.jsonl` and final results from `configure-eventlogs` or the same profile helper used by `configure`. The selected result must be `Applied`, with both the initial and `ImmediatePreWrite` journal entries and matching final `BeforeWrite`. Failed, overridden, incomplete, legacy scalar writes and unexplained changes require manual investigation. Other journaled controls are not restored. + +```powershell +./WELA.ps1 eventlog-recovery -EventRecoveryJournalPath C:\Evidence\original\before.jsonl ` + -EventRecoveryOriginalResultsPath C:\Evidence\original-results.json ` + -EventRecoveryLog ForwardedEvents -EventRecoveryOutputPath C:\Evidence\recovery-plan + +# Inspect plan.json: current and original sizes, modes, channel guard and consent flags. +# Supply the exact PlanHash shown by Plan after reviewing that file. +./WELA.ps1 eventlog-recovery -EventRecoveryAction Restore ` + -EventRecoveryPlanPath C:\Evidence\recovery-plan\plan.json ` + -EventRecoveryPlanHash '' -EventRecoveryOutputPath C:\Evidence\recovery-run ` + -EventRecoveryAllowShrink -EventRecoveryAllowRetentionChange +``` + +The last two switches are separate consent for the effects actually identified by the plan. Omit them when inapplicable. **Shrinking can discard existing events.** Changing to Circular allows older records to be overwritten; changing to Retain can discard incoming records when full; leaving AutoBackup stops automatic archival. Review storage and recovery requirements before consenting. Plan writes review evidence but changes no Windows settings. Restore does not export or clear logs, restore an archive, alter channel enablement/ACL/path/provider settings or restart services. + +Each output must be a fresh directory on a local fixed drive, with an existing parent. Evidence is protected for the current operator, Administrators and SYSTEM. The plan is bound to the actual current host/MachineGuid, operator logon, original input bytes and implementation/catalog hashes. Use the same checkout and elevated operator logon for Restore. Winmgmt and EventLog must already be running; host observations use the existing reviewed-build gate. The original version-1 journal records only historical ComputerName: current host bindings and hashes do not authenticate that history. + +The plan is rebuilt from original evidence on Restore. Minimum-size writes are checked against the immediate-prewrite size so an independent increase during prompting is preserved. An unexplained larger final size is refused. Current size/mode/enable state must match the confirmed post-configuration state. Current channel path, ACL, isolation, type, owning provider and classic-log flag are captured when planning and must remain unchanged. Live event count and EVTX file allocation are intentionally not treated as configuration guards. + +Restore flushes a Pending receipt before one fixed local `wevtutil sl` operation, rechecks the inputs and current channel, changes only the required size/mode arguments, and records final native readback. There is no atomic compare-and-set in this interface. A concurrent policy refresh or writer can still intervene, and verified values do not prove persistence. + +`RestoredAndVerified` means the requested size/mode and preserved configuration matched during readback. `Refused` means no native write was attempted. `RestoreAttemptedUnverified` means a write may have partly succeeded; inspect the Pending receipt and any after-state evidence before further action. Automatic rollback and replay against the already-restored state are refused. A fatal evidence-write error may leave only a Pending receipt; keep it for investigation. + +The Windows fixture uses genuine public configuration of the disposable runner's ForwardedEvents channel, then public planning, consent refusal, actual drift refusal, restoration and replay refusal. It restores the original channel configuration and compares every original audit mask. Matrices cover Server 2022/2025 and PowerShell 5.1/7; the test proves configuration behavior, not historical record preservation, achieved retention, forwarding or Sigma readiness. Sysmon is excluded. + +Reference: [Microsoft wevtutil size, retention and auto-backup options](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wevtutil). diff --git a/scripts/EventLogRecovery.ps1 b/scripts/EventLogRecovery.ps1 new file mode 100644 index 00000000..1bceaeb4 --- /dev/null +++ b/scripts/EventLogRecovery.ps1 @@ -0,0 +1,148 @@ +# Restore one completed profile size/mode write; never replay arbitrary wevtutil arguments. +function Get-WelaEventRecoverySources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/EventLogRecovery.ps1','scripts/EventLogConfiguration.ps1','modules/EventLogSettings.psm1','config/eventlog_profiles.json','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/AuditRecovery.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){ + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + $sources|ConvertTo-Json -Compress +} +function Get-WelaEventRecoveryContext { + foreach($name in @('Winmgmt','EventLog')){if((Get-Service -Name $name -ErrorAction Stop).Status -ne 'Running'){throw 'Native observation services must already be running.'}} + $reader=Get-WelaChannelReader + if(-not $reader.ElevatedAdministrator){throw 'An elevated native Windows operator is required.'} + [pscustomobject][ordered]@{Host=(Get-WelaRecoveryHost);ReviewedHost=(Get-WelaChannelReadHost);Reader=[ordered]@{Sid=$reader.UserSid;Logon=$reader.AuthenticationId;Groups=$reader.GroupSids}} +} +function Read-WelaEventRecoveryChannel { + param([string]$Log) + $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new($Log) + try { + if($channel.LogName -cne $Log -or [string]$channel.LogType -notin @('Administrative','Operational')){throw 'An exact administrative or operational channel is required.'} + [pscustomobject][ordered]@{ + Log=$channel.LogName;MaximumSizeInBytes=[long]$channel.MaximumSizeInBytes;LogMode=[string]$channel.LogMode + Guard=[ordered]@{IsEnabled=[bool]$channel.IsEnabled;LogType=[string]$channel.LogType;Isolation=[string]$channel.LogIsolation;Path=[string]$channel.LogFilePath;SecurityDescriptor=[string]$channel.SecurityDescriptor;Provider=[string]$channel.OwningProviderName;Classic=[bool]$channel.IsClassicLog} + } + }finally{$channel.Dispose()} +} +function Assert-WelaEventRecoveryText { + param($Value,[string[]]$Names) + foreach($name in $Names){if($Value.$name -isnot [string]){throw ('Missing or mistyped recovery text field: '+$name)}} +} +function Assert-WelaEventRecoveryState { + param($State,[string]$Log) + Assert-WelaEventRecoveryText $State @('Log','ReadStatus','Diagnostic','LogMode') + if($State.Log -cne $Log -or $State.ReadStatus -cne 'Available' -or $State.Diagnostic -cne '' -or $State.IsEnabled -isnot [bool] -or + ($State.MaximumSizeInBytes -isnot [int] -and $State.MaximumSizeInBytes -isnot [long]) -or $State.MaximumSizeInBytes -lt 1048576 -or $State.MaximumSizeInBytes -gt 2199023255552 -or $State.MaximumSizeInBytes % 65536 -ne 0 -or $State.LogMode -cnotin @('Circular','Retain','AutoBackup')){throw 'Original channel state is unavailable, mistyped or unsupported.'} +} +function Get-WelaEventRecoveryPair {param($Value) [pscustomobject][ordered]@{MaximumSizeInBytes=[long]$Value.MaximumSizeInBytes;LogMode=[string]$Value.LogMode}} +function Get-WelaEventRecoveryDefinition { + param([string]$JournalPath,[string]$ResultsPath,[string]$Log) + $catalog=Import-WelaEventLogProfiles + if($Log -cnotin @($catalog.profiles.controls.log)){throw 'Select an exact channel in the bundled event-log profiles.'} + $context=Get-WelaEventRecoveryContext + $journal=Read-WelaWecUpdateFile $JournalPath;$resultFile=Read-WelaWecUpdateFile $ResultsPath + $entries=@($journal.Text -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaArrivalJson $_}) + if($entries.Count -lt 1 -or $entries.Count -gt 1024){throw 'Expected 1-1024 bounded journal entries.'} + $result=ConvertFrom-WelaArrivalJson $resultFile.Text + Assert-WelaEventRecoveryText $result @('Scope') + if($result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -gt 2048 -or $result.Scope -cnotin @('native-windows-configuration','event-log-size-and-mode-only')){throw 'Expected original non-dry-run event-log configuration results.'} + $id='EventLog/'+$Log+'/ProfileSettings' + $rows=@($result.Results|Where-Object Id -eq $id);$matching=@($entries|Where-Object Id -eq $id) + if($rows.Count -ne 1 -or $matching.Count -ne 2){throw 'Exactly one result and its original/immediate-prewrite journal pair are required.'} + $row=$rows[0];$initial=$matching[0];$fresh=$matching[1] + Assert-WelaEventRecoveryText $row @('Status','Kind','Id') + Assert-WelaEventRecoveryText $fresh @('Phase') + if($initial.PSObject.Properties['Phase'] -or $fresh.Phase -cne 'ImmediatePreWrite' -or $row.Status -cne 'Applied' -or $row.Kind -cne 'EventLog' -or $row.Id -cne $id){throw 'Only completed Applied profile writes with ordered immediate-prewrite evidence are supported.'} + foreach($entry in $matching){ + Assert-WelaEventRecoveryText $entry @('ComputerName','Kind','Id') + if(($entry.Version -isnot [int] -and $entry.Version -isnot [long]) -or $entry.Version -ne 1 -or $entry.ComputerName -ine $context.Host.Computer -or $entry.Kind -cne 'EventLog' -or $entry.Id -cne $id){throw 'Unknown or wrong-host event-log journal.'} + $time=ConvertTo-WelaArrivalUtc $entry.RecordedUtc;if($time -gt [DateTimeOffset]::UtcNow.AddMinutes(1)){throw 'Future journal timestamp.'} + } + if((ConvertTo-WelaArrivalUtc $fresh.RecordedUtc) -lt (ConvertTo-WelaArrivalUtc $initial.RecordedUtc)){throw 'Journal times are reversed.'} + foreach($field in @('Before','Target','Desired')){if((Get-WelaRecoveryKey $initial.$field) -cne (Get-WelaRecoveryKey $row.$field)){throw "Original/result $field differs."}} + Assert-WelaArrivalObject $initial.Target @('Log','Profile');Assert-WelaArrivalObject $fresh.Target @('Log') + Assert-WelaEventRecoveryText $initial.Target @('Log','Profile');Assert-WelaEventRecoveryText $fresh.Target @('Log') + if($initial.Target.Log -cne $Log -or $fresh.Target.Log -cne $Log -or $initial.Target.Profile -isnot [string]){throw 'Contradictory channel identity.'} + $profile=Get-WelaEventLogProfile $initial.Target.Profile;$control=@($profile.controls|Where-Object log -ceq $Log) + if($control.Count -ne 1){throw 'Channel is not selected by the original bundled profile.'} + Assert-WelaArrivalObject $initial.Desired @('MaximumSizeInBytes','SizeMode','LogMode') + Assert-WelaEventRecoveryText $initial.Desired @('SizeMode');Assert-WelaEventRecoveryText $fresh.Desired @('SizeMode') + if($null -ne $initial.Desired.LogMode){Assert-WelaEventRecoveryText $initial.Desired @('LogMode')} + if((Get-WelaRecoveryKey $initial.Desired) -cne (Get-WelaRecoveryKey $fresh.Desired) -or $initial.Desired.SizeMode -cnotin @('Exact','Minimum') -or ($null -ne $initial.Desired.LogMode -and $initial.Desired.LogMode -cne $control[0].mode) -or + ($initial.Desired.MaximumSizeInBytes -isnot [int] -and $initial.Desired.MaximumSizeInBytes -isnot [long]) -or $initial.Desired.MaximumSizeInBytes -ne (ConvertTo-WelaEventLogBytes $control[0].minimumBytes)){throw 'Desired configuration differs from the canonical profile operation.'} + foreach($state in @($initial.Before,$fresh.Before,$row.After)){Assert-WelaEventRecoveryState $state $Log} + if((Get-WelaRecoveryKey $fresh.Before) -cne (Get-WelaRecoveryKey $row.BeforeWrite)){throw 'Immediate prewrite and final BeforeWrite evidence differ.'} + if($row.After.IsEnabled -ne $fresh.Before.IsEnabled){throw 'Channel enable state changed during original operation.'} + $bytes=if($initial.Desired.SizeMode -ceq 'Exact'){$initial.Desired.MaximumSizeInBytes}else{[math]::Max([long]$fresh.Before.MaximumSizeInBytes,[long]$initial.Desired.MaximumSizeInBytes)} + $mode=if($null -ne $initial.Desired.LogMode){$initial.Desired.LogMode}else{$fresh.Before.LogMode} + if($row.After.MaximumSizeInBytes -ne $bytes -or $row.After.LogMode -cne $mode){throw 'Final state includes unexplained drift beyond the original size/mode write.'} + $expected=Get-WelaEventRecoveryPair $row.After;$recover=Get-WelaEventRecoveryPair $fresh.Before + if((Get-WelaRecoveryKey $expected) -ceq (Get-WelaRecoveryKey $recover)){throw 'No completed size/mode change exists to recover.'} + [pscustomobject][ordered]@{ + Log=$Log;Profile=$profile.id;Journal=[ordered]@{Path=$journal.Path;Hash=$journal.Hash};OriginalResults=[ordered]@{Path=$resultFile.Path;Hash=$resultFile.Hash} + Expected=$expected;RecoverTo=$recover;ExpectedEnabled=$row.After.IsEnabled + RequiresShrinkConsent=($recover.MaximumSizeInBytes -lt $expected.MaximumSizeInBytes);RequiresModeConsent=($recover.LogMode -cne $expected.LogMode) + HistoricalIdentity='Version1 records bind historical ComputerName only. Current host/logon and source hashes do not authenticate historical ownership or configuration.' + } +} +function Assert-WelaEventRecoveryCurrent { + param($Definition,$Observed,$Guard) + if($Observed.Log -cne $Definition.Log -or $Observed.Guard.IsEnabled -ne $Definition.ExpectedEnabled -or + (Get-WelaRecoveryKey (Get-WelaEventRecoveryPair $Observed)) -cne (Get-WelaRecoveryKey $Definition.Expected) -or + ($null -ne $Guard -and (Get-WelaRecoveryKey $Observed.Guard) -cne (Get-WelaRecoveryKey $Guard))){throw 'Current channel size, mode, identity or preserved properties differ from reviewed post-configuration state.'} +} +function Set-WelaEventRecoveryChannel { + param($Definition) + $arguments=@('sl',$Definition.Log) + if($Definition.RecoverTo.MaximumSizeInBytes -ne $Definition.Expected.MaximumSizeInBytes){$arguments+='/ms:'+ $Definition.RecoverTo.MaximumSizeInBytes} + if($Definition.RecoverTo.LogMode -cne $Definition.Expected.LogMode){ + switch($Definition.RecoverTo.LogMode){'Circular'{$arguments+=@('/rt:false','/ab:false')};'Retain'{$arguments+=@('/rt:true','/ab:false')};'AutoBackup'{$arguments+=@('/rt:true','/ab:true')};default{throw 'Unsupported recovery mode.'}} + } + if($arguments.Count -le 2){throw 'No fixed recovery argument was selected.'} + $null=Invoke-WelaNative -FilePath (Join-Path ([Environment]::GetFolderPath('System')) 'wevtutil.exe') -Arguments $arguments +} +function Invoke-WelaEventLogRecovery { + param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$JournalPath,[string]$OriginalResultsPath,[string]$Log,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath,[switch]$AllowShrink,[switch]$AllowRetentionChange) + $ErrorActionPreference='Stop' + if($Action -eq 'Plan'){ + if(-not $JournalPath -or -not $OriginalResultsPath -or -not $Log -or $PlanPath -or $PlanHash -or $AllowShrink -or $AllowRetentionChange){throw 'Plan requires original journal/results, exact channel and new output; restore-only options are not accepted.'} + $source=Read-WelaWecUpdateFile $JournalPath + }else{ + if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $JournalPath -or $OriginalResultsPath -or $Log){throw 'Restore requires only reviewed plan path/hash, new output and applicable explicit loss/retention consent.'} + $source=Read-WelaWecUpdateFile $PlanPath + } + $output=New-WelaArrivalOutput $OutputPath $source.Path + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaEventLogRecovery';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeWriteAttempted=$false;After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='One completed profile size/mode operation. Shrinking or changing retention may discard events or stop archival; existing records and sustained retention are not proven. Sysmon excluded.'} + try{ + $context=Get-WelaEventRecoveryContext;$contextKey=Get-WelaRecoveryKey $context;$sources=Get-WelaEventRecoverySources + if($Action -eq 'Plan'){ + $definition=Get-WelaEventRecoveryDefinition $JournalPath $OriginalResultsPath $Log + $observed=Read-WelaEventRecoveryChannel $Log;Assert-WelaEventRecoveryCurrent $definition $observed $null + $plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaEventLogRecoveryPlan';Definition=$definition;ContextKey=$contextKey;Sources=$sources;Guard=$observed.Guard} + if((Get-WelaRecoveryKey (Get-WelaEventRecoveryDefinition $JournalPath $OriginalResultsPath $Log)) -cne (Get-WelaRecoveryKey $definition) -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaEventRecoverySources) -cne $sources){throw 'Input, host or code changed while planning.'} + Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $Log) $plan.Guard + $artifact=Write-WelaWecUpdateArtifact $output 'plan.json' ($plan|ConvertTo-Json -Depth 20);$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0 + }else{ + if($source.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'} + $plan=ConvertFrom-WelaArrivalJson $source.Text;Assert-WelaArrivalObject $plan @('SchemaVersion','Kind','Definition','ContextKey','Sources','Guard') + Assert-WelaEventRecoveryText $plan @('Kind','ContextKey','Sources') + if(($plan.SchemaVersion -isnot [int] -and $plan.SchemaVersion -isnot [long]) -or $plan.SchemaVersion -ne 1 -or $plan.Kind -cne 'WelaEventLogRecoveryPlan' -or $plan.ContextKey -cne $contextKey -or $plan.Sources -cne $sources){throw 'Reviewed plan schema, context or code differs.'} + $definition=Get-WelaEventRecoveryDefinition $plan.Definition.Journal.Path $plan.Definition.OriginalResults.Path $plan.Definition.Log + if((Get-WelaRecoveryKey $definition) -cne (Get-WelaRecoveryKey $plan.Definition)){throw 'Recovery plan differs from independently rebuilt original evidence.'} + if($definition.RequiresShrinkConsent -and -not $AllowShrink){throw 'Restoring the original smaller buffer requires explicit AllowShrink; existing events may be discarded.'} + if($definition.RequiresModeConsent -and -not $AllowRetentionChange){throw 'Restoring a different retention mode requires explicit AllowRetentionChange.'} + Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $definition.Log) $plan.Guard + $report.PlanHash=$PlanHash;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $source.Text + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-restore.json' ([ordered]@{Status='Pending';Definition=$definition;Guard=$plan.Guard;Context=$context;AllowShrink=[bool]$AllowShrink;AllowRetentionChange=[bool]$AllowRetentionChange;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 20) + if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaEventRecoverySources) -cne $sources -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryDefinition $definition.Journal.Path $definition.OriginalResults.Path $definition.Log)) -cne (Get-WelaRecoveryKey $definition)){throw 'Plan, source, context or original evidence changed immediately before restore.'} + foreach($artifact in $report.Artifacts){if((Read-WelaWecUpdateFile (Join-Path $output $artifact.Name)).Hash -cne $artifact.Sha256){throw 'Saved recovery evidence changed before write.'}} + Assert-WelaEventRecoveryCurrent $definition (Read-WelaEventRecoveryChannel $definition.Log) $plan.Guard + $report.NativeWriteAttempted=$true;Set-WelaEventRecoveryChannel $definition + $report.After=Read-WelaEventRecoveryChannel $definition.Log + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.json' ($report.After|ConvertTo-Json -Depth 12) + if((Get-WelaRecoveryKey (Get-WelaEventRecoveryPair $report.After)) -cne (Get-WelaRecoveryKey $definition.RecoverTo) -or (Get-WelaRecoveryKey $report.After.Guard) -cne (Get-WelaRecoveryKey $plan.Guard) -or (Get-WelaRecoveryKey (Get-WelaEventRecoveryContext)) -cne $contextKey -or (Get-WelaEventRecoverySources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Restored size/mode or preserved properties, context or sources differ.'} + $report.Status='RestoredAndVerified';$report.ExitCode=0 + } + }catch{$report.Status=if($report.NativeWriteAttempted){'RestoreAttemptedUnverified'}else{'Refused'};$report.Diagnostic=$_.Exception.Message} + $null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24) + $report +} diff --git a/tests/EventLogRecovery.Cli.Tests.ps1 b/tests/EventLogRecovery.Cli.Tests.ps1 new file mode 100644 index 00000000..290214ed --- /dev/null +++ b/tests/EventLogRecovery.Cli.Tests.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('eventlog-recovery','-EventRecoveryAction','Restore','-WhatIf');Code=1;Pattern='only dedicated'}, + @{Args=@('eventlog-recovery','-Help');Code=0;Pattern='AllowShrink'}, + @{Args=@('configure','-EventRecoveryAction','Restore','-Auto');Code=1;Pattern='require eventlog-recovery'}, + @{Args=@('eventlog-recovery','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'}, + @{Args=@('eventlog-recovery','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated'}, + @{Args=@('eventlog-recovery','-Help','-Auto');Code=1;Pattern='only dedicated'}, + @{Args=@('eventlog-recovery','-Help','-DryRun');Code=1;Pattern='only dedicated'}, + @{Args=@('eventlog-recovery','-EventRecoveryAction','Restore','-EventRecoveryOutputPath','not-created');Code=1;Pattern='reviewed plan'}, + @{Args=@('eventlog-recovery','-EventRecoveryOutputPath','not-created');Code=1;Pattern='Plan requires'} +) +foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($case.Code -eq 0 -and $code -ne 0) -or ($case.Code -ne 0 -and $code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++} +Write-Host "Event-log recovery CLI: $count checks passed." +$global:LASTEXITCODE=0 diff --git a/tests/EventLogRecovery.Tests.ps1 b/tests/EventLogRecovery.Tests.ps1 new file mode 100644 index 00000000..08b92b9b --- /dev/null +++ b/tests/EventLogRecovery.Tests.ps1 @@ -0,0 +1,79 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +Import-Module "$repo/modules/EventLogSettings.psm1" -Force +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/AuditRecovery.ps1" +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/EventLogConfiguration.ps1" +. "$repo/scripts/EventLogRecovery.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"} +$sources=ConvertFrom-WelaArrivalJson (Get-WelaEventRecoverySources) +Assert ($sources.'scripts/ControlApplicability.ps1' -ceq (Get-FileHash "$repo/scripts/ControlApplicability.ps1").Hash.ToLowerInvariant()) 'Actual host identity/context implementation is fingerprinted.' +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-event-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$oldComputer=$env:COMPUTERNAME;$env:COMPUTERNAME='TEST' +function Get-WelaEventRecoveryContext {[pscustomobject][ordered]@{Host=[ordered]@{Computer='TEST';MachineGuid='1'};Reader='S-1-5-21-fixture'}} +function Get-WelaEventLogState {param($Log);[pscustomobject]@{Log=$Log;ReadStatus='Available';MaximumSizeInBytes=$script:bytes;LogMode=$script:mode;FileSize=123;IsEnabled=$false;Diagnostic=''}} +function Invoke-WelaNative {param($FilePath,$Arguments);foreach($arg in $Arguments){if($arg -like '/ms:*'){$script:bytes=[long]$arg.Substring(4)}};if($Arguments -contains '/ab:true'){$script:mode='AutoBackup'}} +function Read-WelaEventRecoveryChannel {param($Log);$script:reads++;if($script:scenario -eq 'fresh-drift' -and $script:reads -eq 2){$script:bytes+=65536};[pscustomobject]@{Log=$Log;MaximumSizeInBytes=$script:bytes;LogMode=$script:mode;Guard=[ordered]@{IsEnabled=$false;Path='Original';SecurityDescriptor=$script:acl}}} +function Set-WelaEventRecoveryChannel {param($Definition);Assert (Test-Path $script:pending) 'Pending receipt precedes write';$script:writes++;if($script:scenario -eq 'native-fail'){throw 'native failure'};if($script:scenario -ne 'false-success'){$script:bytes=$Definition.RecoverTo.MaximumSizeInBytes;$script:mode=$Definition.RecoverTo.LogMode};if($script:scenario -eq 'preservation'){$script:acl='changed'}} +try { + foreach($case in @('ok','no-shrink','no-mode','hash','tamper','duplicate','drift','fresh-drift','source','native-fail','false-success','preservation','historical-drift')){ + $script:scenario='';$script:bytes=33554432L;$script:mode='Retain';$script:acl='Original';$script:writes=0;$script:reads=0 + $dir=Join-Path $root $case;$null=New-Item -ItemType Directory $dir + $context=New-WelaConfigurationContext -Auto -BackupPath "$dir/journal" + Set-WelaEventLogProfileControls -Context $context -Profile 'asd-collector-archive-2021-10' -ApplyLogMode + $result=Complete-WelaConfiguration -Context $context -Scope 'event-log-size-and-mode-only' -ResultsPath "$dir/original.json" + Assert ($result.ExitCode -eq 0 -and $result.Results[0].Status -eq 'Applied') 'Genuine configuration callback creates completed evidence' + $plan=Invoke-WelaEventLogRecovery Plan -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Log ForwardedEvents -OutputPath "$dir/plan" + Assert ($plan.Status -eq 'ReviewRequired' -and $plan.ExitCode -eq 0) "Plan $case : $($plan.Diagnostic)" + Assert ($script:writes -eq 0) 'Plan never restores' + $planPath="$dir/plan/plan.json";$hash=$plan.PlanHash + if($case -eq 'hash'){$hash='a'*64} + if($case -in @('tamper','duplicate')){ + $text=[IO.File]::ReadAllText($planPath) + if($case -eq 'tamper'){$text=$text.Replace('33554432','67108864')}else{$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')} + [IO.File]::WriteAllText($planPath,$text);$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant() + } + if($case -eq 'source'){[IO.File]::AppendAllText("$dir/original.json",' ')} + if($case -eq 'historical-drift'){ + $original=Get-Content "$dir/original.json" -Raw|ConvertFrom-Json;$original.Results[0].After.MaximumSizeInBytes+=65536;$original|ConvertTo-Json -Depth 15|Set-Content "$dir/original.json" + $bad=Invoke-WelaEventLogRecovery Plan -JournalPath "$dir/journal/before.jsonl" -OriginalResultsPath "$dir/original.json" -Log ForwardedEvents -OutputPath "$dir/bad-plan" + Assert ($bad.Status -eq 'Refused' -and $bad.Diagnostic -match 'unexplained drift') 'Independent postwrite buffer growth cannot be undone as WELA-owned change' + } + $script:scenario=$case;$script:reads=0;$script:pending="$dir/restore/before-restore.json" + if($case -eq 'drift'){$script:bytes+=65536} + $restore=Invoke-WelaEventLogRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath "$dir/restore" -AllowShrink:($case -ne 'no-shrink') -AllowRetentionChange:($case -ne 'no-mode') + Assert (($restore.ExitCode -eq 0) -eq ($case -eq 'ok')) "Restore $case : $($restore.Diagnostic)" + Assert (Test-Path "$dir/restore/manifest.json") 'Manifest retained' + if($case -eq 'ok'){ + Assert ($script:bytes -eq 33554432 -and $script:mode -eq 'Retain' -and $restore.Status -eq 'RestoredAndVerified' -and $restore.ReadyRuleCredit -eq 0) 'Original immediate-prewrite size/mode restored' + $replay=Invoke-WelaEventLogRecovery Restore -PlanPath $planPath -PlanHash $hash -OutputPath "$dir/replay" -AllowShrink -AllowRetentionChange + Assert ($replay.Status -eq 'Refused' -and $script:writes -eq 1) 'Old post-configuration plan is not replayed' + }elseif($case -in @('native-fail','false-success','preservation')){Assert ($restore.Status -eq 'RestoreAttemptedUnverified' -and $script:writes -eq 1) 'Partial failure explicit'} + else{Assert ($script:writes -eq 0 -and -not $restore.NativeWriteAttempted) 'Refusal occurs before write'} + } + # Reject PowerShell boolean-to-string comparison coercion in completed evidence. + $goodResult=[IO.File]::ReadAllText("$root/ok/original.json");$goodJournal=[IO.File]::ReadAllText("$root/ok/journal/before.jsonl") + foreach($field in @('Status','Kind','Id','Scope','ComputerName','Phase','StateLog','ReadStatus','TargetLog','DesiredMode')){ + $r=ConvertFrom-WelaArrivalJson $goodResult;$j=@($goodJournal -split '\r?\n'|Where-Object {$_ -match '\S'}|ForEach-Object {ConvertFrom-WelaArrivalJson $_}) + switch($field){ + Status {$r.Results[0].Status=$true} + Kind {$r.Results[0].Kind=$true} + Id {$r.Results[0].Id=$true} + Scope {$r.Scope=$true} + ComputerName {$j[0].ComputerName=$true} + Phase {$j[1].Phase=$true} + StateLog {$r.Results[0].After.Log=$true} + ReadStatus {$r.Results[0].After.ReadStatus=$true} + TargetLog {$j[0].Target.Log=$true;$r.Results[0].Target.Log=$true} + DesiredMode {$j[0].Desired.SizeMode=$true;$r.Results[0].Desired.SizeMode=$true} + } + $r|ConvertTo-Json -Depth 20|Set-Content "$root/typed-result.json" + @($j|ForEach-Object {$_|ConvertTo-Json -Depth 20 -Compress})|Set-Content "$root/typed-journal.jsonl" + Reject {Get-WelaEventRecoveryDefinition "$root/typed-journal.jsonl" "$root/typed-result.json" ForwardedEvents} 'mistyped recovery text|Exactly one result' + } +}finally{$env:COMPUTERNAME=$oldComputer;Remove-Item $root -Recurse -Force} +Write-Host "Event-log recovery passed: $count assertions." diff --git a/tests/EventLogRecovery.Windows.Tests.ps1 b/tests/EventLogRecovery.Windows.Tests.ps1 new file mode 100644 index 00000000..67934da7 --- /dev/null +++ b/tests/EventLogRecovery.Windows.Tests.ps1 @@ -0,0 +1,72 @@ +param([switch]$AllowDisposableChannelWrite) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableChannelWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable hosted Windows opt-in required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +Import-Module "$repo/modules/EventLogSettings.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/ControlApplicability.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +. "$repo/scripts/AuditRecovery.ps1" +. "$repo/scripts/ChannelRead.ps1" +. "$repo/scripts/EventLogRecovery.ps1" +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +$engine=(Get-Process -Id $PID).Path +function Invoke-RecoveryFixtureCli {param([string[]]$Arguments,[int]$Expected=0) + $prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + if(($Expected -eq 0 -and $code -ne 0) -or ($Expected -ne 0 -and $code -eq 0)){throw "Public CLI $code : $($lines -join ' ')"} +} +$log='ForwardedEvents';$before=Read-WelaEventRecoveryChannel $log;$policies=Get-WelaEffectiveAuditPolicy +$root=Join-Path $env:RUNNER_TEMP ('wela-event-recovery-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$primary=$null +try{ + $null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:33554432','/rt:true','/ab:false') + $prepared=Read-WelaEventRecoveryChannel $log + Assert ((Get-WelaRecoveryKey $prepared.Guard) -ceq (Get-WelaRecoveryKey $before.Guard)) 'Preparation preserves enable/path/ACL/provider fields' + Invoke-RecoveryFixtureCli @('configure-eventlogs','-LogProfile','asd-collector-archive-2021-10','-ApplyLogMode','-Auto','-BackupPath',"$root/journal",'-ResultsPath',"$root/original.json") + $original=Get-Content "$root/original.json" -Raw|ConvertFrom-Json + Assert ($original.Results.Count -eq 1 -and $original.Results[0].Status -eq 'Applied') 'Genuine public Configure evidence' + $configured=Read-WelaEventRecoveryChannel $log + Assert ($configured.MaximumSizeInBytes -eq 2147483648 -and $configured.LogMode -eq 'AutoBackup') 'Native configured size/mode observed' + Invoke-RecoveryFixtureCli @('eventlog-recovery','-EventRecoveryJournalPath',"$root/journal/before.jsonl",'-EventRecoveryOriginalResultsPath',"$root/original.json",'-EventRecoveryLog',$log,'-EventRecoveryOutputPath',"$root/plan") + $plan=Get-Content "$root/plan/manifest.json" -Raw|ConvertFrom-Json + Assert ($plan.Status -eq 'ReviewRequired' -and (Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $configured)) 'Public Plan makes no channel changes' + $apply=@('eventlog-recovery','-EventRecoveryAction','Restore','-EventRecoveryPlanPath',"$root/plan/plan.json",'-EventRecoveryPlanHash',$plan.PlanHash) + Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/unknown-option",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange','-WhatIf')) 1 + Assert (-not (Test-Path "$root/unknown-option") -and (Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $configured)) 'Unknown WhatIf refuses before output or native restoration' + Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/without-consent")) 1 + $refused=Get-Content "$root/without-consent/manifest.json" -Raw|ConvertFrom-Json + Assert ($refused.Status -eq 'Refused' -and -not $refused.NativeWriteAttempted) 'Shrinking requires independent explicit consent' + # Actual concurrent-size drift, then exact fixture restoration, exercises public refusal. + $null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:2147549184') + Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/drift",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange')) 1 + $drift=Get-Content "$root/drift/manifest.json" -Raw|ConvertFrom-Json + Assert ($drift.Status -eq 'Refused' -and -not $drift.NativeWriteAttempted) 'Actual native size drift refuses restoration' + $null=Invoke-WelaNative wevtutil.exe @('sl',$log,'/ms:2147483648') + Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/restored",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange')) + $restored=Get-Content "$root/restored/manifest.json" -Raw|ConvertFrom-Json + Assert ($restored.Status -eq 'RestoredAndVerified' -and $restored.NativeWriteAttempted -and $restored.ReadyRuleCredit -eq 0) 'Native public restoration verified' + Assert ((Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -ceq (Get-WelaRecoveryKey $prepared)) 'Exact prepared size/mode and all preserved fields restored' + Invoke-RecoveryFixtureCli ($apply+@('-EventRecoveryOutputPath',"$root/replay",'-EventRecoveryAllowShrink','-EventRecoveryAllowRetentionChange')) 1 + $replay=Get-Content "$root/replay/manifest.json" -Raw|ConvertFrom-Json + Assert ($replay.Status -eq 'Refused' -and -not $replay.NativeWriteAttempted) 'Consumed plan cannot overwrite recovered state' + Write-Host "Native event-log recovery passed $count assertions; no record preservation or sustained retention claim." +}catch{$primary=$_} +finally{ + $errorText='' + try{ + $arguments=@('sl',$log,('/ms:'+$before.MaximumSizeInBytes)) + switch($before.LogMode){'Circular'{$arguments+=@('/rt:false','/ab:false')};'Retain'{$arguments+=@('/rt:true','/ab:false')};'AutoBackup'{$arguments+=@('/rt:true','/ab:true')}} + $null=Invoke-WelaNative wevtutil.exe $arguments + if((Get-WelaRecoveryKey (Read-WelaEventRecoveryChannel $log)) -cne (Get-WelaRecoveryKey $before)){throw 'Original channel configuration differs after cleanup.'} + $now=Get-WelaEffectiveAuditPolicy;foreach($guid in $policies.Keys){if($now[$guid] -ne $policies[$guid]){throw 'Original audit mask changed.'}} + }catch{$errorText=$_.Exception.Message} + $cleanup=[ordered]@{CleanupVerified=($errorText -eq '');Before=$before;After=(Read-WelaEventRecoveryChannel $log);AuditMasksCompared=$policies.Count;Diagnostic=$errorText} + $cleanup|ConvertTo-Json -Depth 12|Set-Content "$root/cleanup.json" -Encoding UTF8 + if($errorText){throw "Cleanup failed: $errorText; primary: $primary"} + Write-Host 'Original channel size/mode, enable/path/ACL/provider fields and all audit masks restored.' +} +if($primary){throw $primary} +$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index de508ee6..3b120383 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 完了したログ容量・保持モード設定を1件ずつ戻す `eventlog-recovery` を追加しました。元の記録と変更直前の記録、現在のチャネル・実行環境・コードを照合し、縮小と保持モード変更には個別の明示指定を必要とします。永続記録とネイティブ読戻しで無関係な設定を保持し、状態変化や再適用を拒否します。失われたイベント、長期保持、Sigma 利用可能性の証明は加算しません。 (@Shirofune-Security) + - `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security) - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 4ca9dbe7..af48e8a8 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added reviewed `eventlog-recovery` for one completed profile size/retention write. Matched original and immediate-prewrite evidence, current channel/context/source guards, separate shrink/retention consent, durable pending receipts and native readback preserve unrelated channel settings and refuse drift or replay. Windows fixtures restore original settings; lost events, sustained retention and Sigma readiness are not inferred. (@Shirofune-Security) + - Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security) - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) From 3919ba5d4a54cfcd894266f4c72418b9f9271109 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:25:02 +0900 Subject: [PATCH 06/11] Document supported CLI preview options --- docs/configuration-results.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/configuration-results.md b/docs/configuration-results.md index a3551c61..0f1575c8 100644 --- a/docs/configuration-results.md +++ b/docs/configuration-results.md @@ -18,6 +18,8 @@ or result-file error also exits with status 1. .\WELA.ps1 configure -Auto -ResultsPath .\results.json ``` +Unknown named options and other arguments left unbound by PowerShell are rejected before command dispatch. This includes unsupported `-WhatIf`, `-Confirm` and misspelled `-DryRun` options, even with `-Auto`. Use each command's `-Help` for its supported preview options; `-DryRun` is accepted only where documented. Valid positional binding and PowerShell's unambiguous parameter abbreviations remain supported. + Keep the complete WELA directory, including `scripts/Configuration.ps1`. Choose a recovery path whose parent directory is writable only by the operators who manage these settings. The backup directory must not already exist. Without `-BackupPath`, From f05f852286caa5d1e0a01ab4aa2219ea290d627b Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:26:15 +0900 Subject: [PATCH 07/11] Retain native channel preservation evidence and document acceptance limits --- CHANGELOG-Japanese.md | 2 ++ CHANGELOG.md | 2 ++ docs/native-channel-access.md | 6 ++++-- tests/NativeChannelConfigure.Windows.Tests.ps1 | 9 ++++++--- website/docs/resources/changelog.ja.md | 2 ++ website/docs/resources/changelog.md | 2 ++ 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 4c13f51b..f1c90a99 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- Windows PowerShell 5.1 と PowerShell 7、使い捨ての Server 2022/2025 で標準チャネル設定の公開CLIを検証するテストを追加しました。有効化・サイズ・CAPI2読み取り専用権限の適用、既存記述子と大きいバッファーの保持、変更前記録、DryRun、再実行時の無変更、元設定への復元を確認し、ハッシュ付きの証拠を保存します。転送・保存期間・Sigmaの検証は別途必要です。 (@Shirofune-Security) + - `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security) - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31bfbecd..3ce8d30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added disposable Server 2022/2025 validation of public native channel configuration under Windows PowerShell 5.1 and PowerShell 7. Tests apply enable/size controls and the explicit CAPI2 read-only grant, verify descriptor preservation, journals, larger buffers, DryRun and idempotence, and retain hashed native evidence with exact fixture cleanup. Forwarding, retention-duration and Sigma validation remain separate. (@Shirofune-Security) + - Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security) - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) diff --git a/docs/native-channel-access.md b/docs/native-channel-access.md index a15b53cf..877b29ff 100644 --- a/docs/native-channel-access.md +++ b/docs/native-channel-access.md @@ -36,9 +36,11 @@ Recovery is manual: review each journal `Before` against the current settings, i The checked-in inventory maps source query IDs to 12 baseline channels and 8 suspect channels (18 unique combined). Baseline queries 12 (EMET) and 39 (Sysmon) are explicitly excluded. Native-only scope excludes external agents; channel settings do not create subscriptions, add service identities to groups or configure WinRM/collectors. The Microsoft [source prerequisite guidance and sample queries](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection) remain a starting point for reviewing role applicability. The report always lists token/group membership, producer configuration, representative event generation and forwarding/ingestion as unverified; required disabled or missing channels remain visible. Other inventoried channels are not automatically enabled. -Safe tests exercise the actual command/JSON/runner with mocked Windows setters. Windows PowerShell 5.1 and PowerShell 7 CI additionally exercise real descriptor serialization and read-only CLI inspection. Release packaging already includes the whole `config`, `modules` and `scripts` directories. +Safe tests exercise the actual command/JSON/runner with mocked Windows setters. Windows PowerShell 5.1 and PowerShell 7 CI also exercise real descriptor serialization and read-only CLI inspection. A separate four-way disposable Server 2022/2025 suite exercises the public Plan, Configure with DryRun, Configure without a reader grant, explicit read-only grant, and repeated idempotent configuration. It verifies exact native descriptor bytes, recovery journal contents, preservation of an existing 2 GiB buffer, all other channel XML fields, and restoration of the original channel settings and all 59 audit masks. Hashed artifacts retain original/configured XML, reports, journal and cleanup evidence. -**Isolated Windows acceptance evidence is still pending; related to issue #367, not sufficient to close it.** On patched Windows 11, member server, DC and ADCS snapshots where the channels exist: +That fixture changes only the three declared channels on explicitly opted-in GitHub-hosted disposable VMs. Restoring the original smaller sizes can discard events generated during the test; it does not restore event records or prove retention duration. It never supplies production forwarding-token access, event generation, collector arrival, policy-refresh persistence or Sigma evidence. Do not run the mutating fixture on ordinary machines. Release packaging already includes the whole `config`, `modules` and `scripts` directories. + +**Broader Windows acceptance remains pending; related to issue #367, not sufficient to close it.** Hosted server configuration checks do not cover Windows 11 or domain-specific access and forwarding behavior. On patched Windows 11, member server, DC and ADCS snapshots where the channels exist: 1. Save the plan, channel metadata, descriptor and policy context. Review capacity and the intended forwarding identity. Capture the actual identity/token memberships separately. 2. Apply the opt-in profile, retain the journal/results, then independently read enablement, exact bytes and full SDDL. Compare all original ACEs plus owner/group/SACL/flags and repeat after policy refresh. diff --git a/tests/NativeChannelConfigure.Windows.Tests.ps1 b/tests/NativeChannelConfigure.Windows.Tests.ps1 index 18db6ae3..870b60e0 100644 --- a/tests/NativeChannelConfigure.Windows.Tests.ps1 +++ b/tests/NativeChannelConfigure.Windows.Tests.ps1 @@ -16,8 +16,8 @@ function Read-Raw([string]$Name){ $x=New-Object Xml.XmlDocument;$x.XmlResolver=$null;$x.LoadXml(($r.Output -join "`n"));return ,$x } function Guard-Raw($Xml){ - $x=$Xml.CloneNode($true);$x.DocumentElement.RemoveAttribute('enabled') - foreach($name in @('enabled','channelAccess','maxSize')){foreach($node in @($x.SelectNodes("//*[local-name()='$name']"))){$null=$node.ParentNode.RemoveChild($node)}} + $x=$Xml.CloneNode($true);$x.DocumentElement.RemoveAttribute('enabled');$x.DocumentElement.RemoveAttribute('channelAccess') + foreach($node in @($x.SelectNodes("/*/*[local-name()='logging']/*[local-name()='maxSize']"))){$null=$node.ParentNode.RemoveChild($node)} return $x.OuterXml } $engine=(Get-Process -Id $PID).Path @@ -51,7 +51,7 @@ try{ $withoutRead=$descriptor.GetSddlForm('All');$access=Get-WelaChannelAccessPlan $withoutRead Assert ($access.State -eq 'GrantRequired') 'Prepared actual descriptor supports one lossless read-only grant' $null=Invoke-WelaNative wevtutil.exe @('sl',$capi,'/e:false','/ms:1048576',('/ca:'+$withoutRead)) - # A large existing log exposed numeric narrowing in older PowerShell planners. + # Existing sizes above the signed 32-bit range must not be narrowed. $null=Invoke-WelaNative wevtutil.exe @('sl',$app,'/ms:2147483648') $prepared=@{};foreach($name in $raw.Keys){$prepared[$name]=Get-WelaNativeChannel $name} $null=Run-Cli 'plan' @('-ChannelAction','Plan','-GrantEventLogReaders') @@ -93,4 +93,7 @@ finally{ if($errors.Count){throw "Cleanup failed: $($errors -join '; '); primary: $primary"} Write-Host 'Exact original channel metadata and all audit masks verified after cleanup; existing event records/retention duration not claimed.' } +$artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object {[ordered]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash}}) +$sources=@('WELA.ps1','scripts/Configuration.ps1','scripts/NativeChannelConfiguration.ps1','modules/NativeChannelAccess.psm1','modules/NativeProviders.psm1','modules/EventLogSettings.psm1','tests/NativeChannelConfigure.Windows.Tests.ps1')|ForEach-Object {[ordered]@{Path=$_;Sha256=(Get-FileHash -LiteralPath (Join-Path $repo $_) -Algorithm SHA256).Hash}} +[ordered]@{Status=$(if($primary){'Failed'}else{'Passed'});Commit=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$count;Artifacts=$artifacts;Sources=@($sources);EventGenerationVerified=$false;ForwardingVerified=$false;ReadyRuleCredit=0}|ConvertTo-Json -Depth 8|Set-Content "$root/manifest.json" -Encoding UTF8 if($primary){throw $primary};$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index de508ee6..e28b4916 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- Windows PowerShell 5.1 と PowerShell 7、使い捨ての Server 2022/2025 で標準チャネル設定の公開CLIを検証するテストを追加しました。有効化・サイズ・CAPI2読み取り専用権限の適用、既存記述子と大きいバッファーの保持、変更前記録、DryRun、再実行時の無変更、元設定への復元を確認し、ハッシュ付きの証拠を保存します。転送・保存期間・Sigmaの検証は別途必要です。 (@Shirofune-Security) + - `failed-logon-probe` を追加しました。存在しないことを確認したランダムなローカル SAM アカウントに対し、固定のネイティブログオン種別・プロバイダーで一度だけ認証を試行し、正確な時刻・プロセス・アカウント情報で Security4625 を照合します。監査設定を変更せず、保護された証跡を保存します。実際の資格情報、ドメインコントローラー、リモート認証、Sigma 対応率の加算は対象外です。使い捨て Windows 環境で公開コマンドと設定復元を検証します。(@Shirofune-Security) - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 4ca9dbe7..54027f34 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added disposable Server 2022/2025 validation of public native channel configuration under Windows PowerShell 5.1 and PowerShell 7. Tests apply enable/size controls and the explicit CAPI2 read-only grant, verify descriptor preservation, journals, larger buffers, DryRun and idempotence, and retain hashed native evidence with exact fixture cleanup. Forwarding, retention-duration and Sigma validation remain separate. (@Shirofune-Security) + - Added opt-in `failed-logon-probe` for one generated, confirmed nonexistent local SAM account attempt with fixed native logon type/provider, precise worker timing and exact Security4625 correlation. Protected receipts preserve raw evidence and unchanged audit/channel/token context; real credentials, domain controllers, remote authentication and Sigma credit are excluded. Disposable Windows tests cover native public runs and exact fixture cleanup. (@Shirofune-Security) - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) From ef64b08bd2cb33b932f3dc59db394f8d8582520c Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:26:34 +0900 Subject: [PATCH 08/11] Document Script evidence boundaries and retain bounded startup diagnostics --- .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 ++ CHANGELOG.md | 2 ++ docs/applocker-probe.md | 2 +- docs/applocker-script-probe.md | 33 ++++++++++++++++++++++++++ scripts/AppLockerScriptProbe.ps1 | 10 +++++--- website/docs/resources/changelog.ja.md | 2 ++ website/docs/resources/changelog.md | 2 ++ 8 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 docs/applocker-script-probe.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c74297b3..472b3159 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/ipsec-prerequisites.md, ./docs/wec-ingress.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index c85e4a21..9ba629c1 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 既存の Script AuditOnly ポリシーに対し、固定の Windows PowerShell5.1 スクリプトを実行する `applocker-script-probe` を追加しました。実行者と子プロセスのログオン、保持したファイル、高精度 UTC とイベント記録境界を確認し、Script8005 の許可と8006 の監査専用ブロック判定を区別します。拒否・上限・重複・状態変化は未検証とし、設定変更や Sigma の評価加算は行いません。使い捨て Windows の4構成で両イベントとポリシー・チャネル・タスクの復元を検証し、保護された AppIDSvc を停止できない場合は明記します。 (関連 #381) (@Shirofune-Security) + - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) - `smb-runtime` を追加し、Windows 標準の SMB 監査スイッチ6個を明示的に有効化します。モジュール・ビルド・ADMX、型付きポリシーの競合、設定全体の変化を確認し、各変更の意図と確認結果を永続的に記録します。署名・暗号化・ゲスト接続・サービス設定を保持し、現在の有効化とイベント・永続性・Sigma の証明を区別します。使い捨て Server 2025 の有効化と復元、Server 2022 の拒否を PowerShell 5.1/7 で検証します。 (#441) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2933c992..e1d6184b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added opt-in `applocker-script-probe` for a fixed native Windows PowerShell5.1 script under an existing Script AuditOnly policy. Actual caller/child logon context, held file bytes, precise UTC and native record boundaries distinguish exact Script8005 allowed and8006 would-block events; denied, capped, ambiguous or drifted runs remain unverified. The disposable four-way Windows suite requires both native decisions and policy/channel/task cleanup, with the protected-AppIDSvc stopping boundary recorded. No configuration changes or Sigma credit. (Related #381) (@Shirofune-Security) + - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) - Added explicit `smb-runtime` activation of six native SMB audit switches, with reviewed module/build/ADMX capabilities, typed policy conflicts, complete configuration drift guards and durable per-switch receipts. Signing, encryption, guest access and service settings are preserved; runtime verification stays separate from events, persistence and Sigma credit. Disposable Server 2025 activation/restoration and Server 2022 refusal tests cover PowerShell 5.1/7. (#441) (@Shirofune-Security) diff --git a/docs/applocker-probe.md b/docs/applocker-probe.md index ef99242f..431d5174 100644 --- a/docs/applocker-probe.md +++ b/docs/applocker-probe.md @@ -13,7 +13,7 @@ Run copies native System32 `cmd.exe` into the protected output directory with a 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. +`NativeExeEventObserved` proves only this event in this local channel at this time. It grants **zero Sigma readiness credit**. The [separate Script probe](applocker-script-probe.md) collects Windows PowerShell5.1 Script8005/8006 evidence. 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. diff --git a/docs/applocker-script-probe.md b/docs/applocker-script-probe.md new file mode 100644 index 00000000..33311d7d --- /dev/null +++ b/docs/applocker-script-probe.md @@ -0,0 +1,33 @@ +# Native AppLocker Script probe + +Related to #381. `applocker-script-probe` runs one fixed, locally generated `.ps1` file through native 64-bit Windows PowerShell 5.1 and looks for its actual AppLocker Script-collection decision. WELA itself can run in Windows PowerShell 5.1 or PowerShell 7. This command adds no AppLocker policy, starts no service, changes no execution policy or channel, and grants no Sigma readiness credit. Sysmon is out of scope. + +```powershell +.\WELA.ps1 applocker-script-probe +.\WELA.ps1 applocker-script-probe -AppLockerScriptAction Run -AppLockerScriptOutputPath C:\Evidence\new-script-probe -AppLockerScriptTimeoutSeconds 30 +``` + +Plan reads prerequisites without launching a child or writing files. Run requires a new private directory on a local fixed drive, with an existing parent. Only reviewed Windows 11 builds and Server 2022/2025 member hosts are accepted; domain controllers are excluded. Client and managed-environment acceptance remains separate from hosted-server CI. + +The effective Group Policy Script collection must already contain rules in `AuditOnly` mode. Local and effective GP policy, management observations, AppIDSvc state and MSI and Script channel configuration must be readable. AppIDSvc must already run and the channel must already be enabled. AppLocker CSP policy remains **Unknown**: the native GP cmdlets do not enumerate that authority. Existing enforcement in other collections is preserved and can prevent the fixed native host from starting. + +Run creates only the reviewed worker template with a fresh filename and nonce. It launches System32's `WindowsPowerShell\v1.0\powershell.exe` with `-NoLogo -NoProfile -NonInteractive -File`; there is no operator-supplied command, profile loading or execution-policy override. The existing execution policy must permit that locally generated unsigned file. For example, Restricted or AllSigned may prevent completion; that is an unverified result. The report records existing native execution-policy registry values and the inherited process preference, without equating these observations to all application-control authorities. + +The worker emits its fixed ready marker, actual Windows PowerShell 5.1 version and language mode, waits for its fixed release marker, emits completion and exits. Before releasing it, WELA observes the real child primary token and compares its user, logon LUID, group attributes and privilege attributes with the actual current caller. Impersonated or restricted callers are refused. Caller token identity and modification state are checked throughout child execution and event queries. Metadata and output preparation precede that interval; final configuration observations are checked separately. + +Native PowerShell and generated script files are held read-locked during execution, with SHA256 and volume/file identity checks. Source fingerprints bind the compiled helper to its exact source bytes and are rechecked before launch and after collection. These are consistency observations, not a signature or protection from a local administrator. The generated file is retained with the evidence. + +The query starts from an actual current record boundary in `Microsoft-Windows-AppLocker/MSI and Script`. A match requires the reviewed provider GUID, event version, channel, computer, Script collection, actual user SID, child PID, exact unique script path and a native precise-UTC timestamp within the actual process interval. It accepts exactly one of these separate outcomes: + +| Event | Report decision | Meaning | +|---|---|---| +| 8005 | `Allowed` | A Script rule allowed this file. | +| 8006 | `AllowedWouldBlockIfEnforced` | The audit-only Script policy would block this file if enforced. | + +8005 does not prove a would-block decision. 8007, MSI events sharing the channel, other processes, older records, stale paths, duplicates, unknown versions and timestamps outside the exact interval are rejected. Missing, denied, incomplete, capped or drifted results remain `Unverified` with a nonzero exit code. The bounded query refuses its 256-event or one-MiB cap; only matched XML or at most four candidates containing the owned filename are exported. Child startup is bounded to thirty seconds, completion to ten seconds after release, output drain to five seconds and event polling to the selected 1–30 seconds; individual native event reads have finite timeouts. Owned child termination is attempted and checked on failure. Raw process output is bounded. + +`NativeScriptEventObserved` means only that this one local Windows PowerShell 5.1 script generated the retained event under the observed context. It does not prove PowerShell 7 script behavior, other Script formats, MSI/DLL/packaged-app coverage, enforcement behavior, forwarding or backend rule matches. The [separate EXE probe](applocker-probe.md) covers EXE events. + +The dedicated Windows workflow requires explicit opt-in on disposable non-domain GitHub-hosted Server 2022/2025 VMs, each under both WELA host engines. It requires initially empty and understood local/effective GP policies. The fixture prepares one Script AuditOnly policy at a time, invokes the verified native PolicyConverter task and a bounded computer-policy refresh, then requires genuine public-command 8006 and 8005 records, source/receipt hashes and unchanged product context. Mocked fixtures never substitute for those events. It restores original local/effective GP policy, channel enablement and the exact PolicyConverter task definition/enabled state and preserves service startup mode. If Windows refuses to stop protected AppIDSvc, the cleanup receipt explicitly records the remaining running state and relies on disposal of that VM; it does not claim full service-state restoration. This fixture must not be run on production hosts. + +Microsoft references: [Script rule formats and host enforcement semantics](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/script-rules-in-applocker), [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), [native policy refresh and verification](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/refresh-an-applocker-policy), [Application Identity service](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/configure-the-application-identity-service). diff --git a/scripts/AppLockerScriptProbe.ps1 b/scripts/AppLockerScriptProbe.ps1 index 2925b0fa..6b03283b 100644 --- a/scripts/AppLockerScriptProbe.ps1 +++ b/scripts/AppLockerScriptProbe.ps1 @@ -86,7 +86,7 @@ function Start-WelaAppLockerScriptProcess { $template=[IO.File]::ReadAllText((Join-Path $script:ScriptRoot 'scripts/AppLockerScriptWorker.ps1')) $scriptBytes=[Text.UTF8Encoding]::new($false).GetBytes((New-WelaAppLockerScriptText $template $nonce)) $artifact=Write-WelaArrivalArtifact $Root ([IO.Path]::GetFileName($path)) ([Text.UTF8Encoding]::new($false).GetString($scriptBytes)) - $source=$null;$scriptFile=$null;$process=$null;$started=$false + $source=$null;$scriptFile=$null;$process=$null;$started=$false;$stderr=$null try { $source=[IO.File]::Open($State.Source,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) $scriptFile=[IO.File]::Open($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) @@ -115,9 +115,13 @@ function Start-WelaAppLockerScriptProcess { if($process.ExitCode -ne 0 -or $out.TrimEnd("`r","`n") -cne ('WELA_SCRIPT_COMPLETE_'+$nonce) -or $err){throw ('The fixed script did not complete correctly. Exit='+$process.ExitCode+' Error='+$err)} if((Get-WelaAppLockerScriptKey ((Get-WelaAppLockerScriptReader))) -cne $readerKey -or [Wela.AppLockerScript.Native]::FileId($source.SafeFileHandle.DangerousGetHandle()) -cne $sourceId -or [Wela.AppLockerScript.Native]::FileId($scriptFile.SafeFileHandle.DangerousGetHandle()) -cne $scriptId){throw 'Reader or held file identity changed during script execution.'} [pscustomobject][ordered]@{ProcessId=$process.Id;UserSid=$Reader.Sid;ChildToken=$child;NativePowerShell=$State.Source;NativePowerShellSha256=$State.SourceHash;NativePowerShellFileId=$sourceId;ScriptPath=$path;ScriptSha256=$artifact.Sha256;ScriptFileId=$scriptId;ScriptArtifact=$artifact;Nonce=$nonce;Arguments=$info.Arguments;StartedUtc=$start.ToString('o');CompletedUtc=$end.ToString('o');Clock='GetSystemTimePreciseAsFileTime';Ready=$line;Marker=$out.TrimEnd("`r","`n");ExitCode=$process.ExitCode} + }catch{ + $message=$_.Exception.Message + if($stderr -and $stderr.Status -eq [Threading.Tasks.TaskStatus]::RanToCompletion){$message+=' Native stderr: '+$stderr.GetAwaiter().GetResult()} + throw $message }finally{ - if($process){try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned script process termination is unconfirmed.'}}}finally{$process.Dispose()}} - if($scriptFile){$scriptFile.Dispose()};if($source){$source.Dispose()} + try{if($process){try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Owned script process termination is unconfirmed.'}}}finally{$process.Dispose()}}} + finally{if($scriptFile){$scriptFile.Dispose()};if($source){$source.Dispose()}} } } function Read-WelaAppLockerScriptEvents { diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 0857b29e..fad42c8a 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 既存の Script AuditOnly ポリシーに対し、固定の Windows PowerShell5.1 スクリプトを実行する `applocker-script-probe` を追加しました。実行者と子プロセスのログオン、保持したファイル、高精度 UTC とイベント記録境界を確認し、Script8005 の許可と8006 の監査専用ブロック判定を区別します。拒否・上限・重複・状態変化は未検証とし、設定変更や Sigma の評価加算は行いません。使い捨て Windows の4構成で両イベントとポリシー・チャネル・タスクの復元を検証し、保護された AppIDSvc を停止できない場合は明記します。 (関連 #381) (@Shirofune-Security) + - `wec-ingress` の Plan/Apply を追加し、明示した IPv4 範囲から Domain プロファイルの TCP5985 を許可する新規ルールを作成します。実ホスト・ログオン・プロファイル・コードと計画ハッシュ、変更前の永続記録、両ストアとフィルターの読戻しで変更や既存名を拒否します。既存の収集サーバー前提条件も、範囲を広げずに同等のIPv4ネットマスク表記・IPv6表記を照合します。使い捨て Windows テストは作成・名前衝突・再実行拒否・既存前提条件との連携・削除を検証し、リスナー・配送・Sigma の証明は加算しません。 (@Shirofune-Security) - `smb-runtime` を追加し、Windows 標準の SMB 監査スイッチ6個を明示的に有効化します。モジュール・ビルド・ADMX、型付きポリシーの競合、設定全体の変化を確認し、各変更の意図と確認結果を永続的に記録します。署名・暗号化・ゲスト接続・サービス設定を保持し、現在の有効化とイベント・永続性・Sigma の証明を区別します。使い捨て Server 2025 の有効化と復元、Server 2022 の拒否を PowerShell 5.1/7 で検証します。 (#441) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index a4d80dc1..da5c9d15 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added opt-in `applocker-script-probe` for a fixed native Windows PowerShell5.1 script under an existing Script AuditOnly policy. Actual caller/child logon context, held file bytes, precise UTC and native record boundaries distinguish exact Script8005 allowed and8006 would-block events; denied, capped, ambiguous or drifted runs remain unverified. The disposable four-way Windows suite requires both native decisions and policy/channel/task cleanup, with the protected-AppIDSvc stopping boundary recorded. No configuration changes or Sigma credit. (Related #381) (@Shirofune-Security) + - Added explicit `wec-ingress` Plan/Apply for one new, narrowly scoped Domain TCP5985 collector firewall rule. Actual host/logon/profile/source guards, reviewed hashes, flushed pending evidence and both-store/filter readback refuse drift and existing names; partial creation remains explicit. The existing collector prerequisite recognizes equivalent native dotted netmasks and IPv6 spellings without broadening accepted scopes. Disposable native tests cover creation, collision, replay, collector integration and cleanup without listener, delivery or Sigma claims. (@Shirofune-Security) - Added explicit `smb-runtime` activation of six native SMB audit switches, with reviewed module/build/ADMX capabilities, typed policy conflicts, complete configuration drift guards and durable per-switch receipts. Signing, encryption, guest access and service settings are preserved; runtime verification stays separate from events, persistence and Sigma credit. Disposable Server 2025 activation/restoration and Server 2022 refusal tests cover PowerShell 5.1/7. (#441) (@Shirofune-Security) From bfbdc6c476f867b7a89b3478b5542423645c5475 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:29:03 +0900 Subject: [PATCH 09/11] Read the owned release pipe with encoding-preamble detection --- scripts/AppLockerScriptWorker.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/AppLockerScriptWorker.ps1 b/scripts/AppLockerScriptWorker.ps1 index 2ebb46fe..4f7be74b 100644 --- a/scripts/AppLockerScriptWorker.ps1 +++ b/scripts/AppLockerScriptWorker.ps1 @@ -1,7 +1,10 @@ # Fixed locally generated script; no external inputs or configuration writes. $ErrorActionPreference = 'Stop' [Console]::Out.WriteLine(('WELA_SCRIPT_READY___WELA_SCRIPT_NONCE__|' + $ExecutionContext.SessionState.LanguageMode + '|' + $PSVersionTable.PSVersion)) -$release = [Console]::In.ReadLine() +# .NET Framework's redirected-input writer may emit an encoding preamble. +# Read the owned pipe through a BOM-aware reader, without changing console state. +$pipeReader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false, $true), $true, 128, $true) +try { $release = $pipeReader.ReadLine() } finally { $pipeReader.Dispose() } if ($release -cne 'WELA_SCRIPT_GO___WELA_SCRIPT_NONCE__') { exit 17 } [Console]::Out.WriteLine('WELA_SCRIPT_COMPLETE___WELA_SCRIPT_NONCE__') exit 0 From 37e93e10ebc52d8a289ac4173eccd78ed277a539 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:34:09 +0900 Subject: [PATCH 10/11] Refuse stopped service dependencies before AppLocker CIM observation --- docs/applocker-script-probe.md | 2 +- scripts/AppLockerScriptProbe.ps1 | 19 +++++++++++++++++-- tests/AppLockerScriptProbe.Tests.ps1 | 21 ++++++++++++++++++++- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/applocker-script-probe.md b/docs/applocker-script-probe.md index 33311d7d..ab3ddf09 100644 --- a/docs/applocker-script-probe.md +++ b/docs/applocker-script-probe.md @@ -9,7 +9,7 @@ Related to #381. `applocker-script-probe` runs one fixed, locally generated `.ps Plan reads prerequisites without launching a child or writing files. Run requires a new private directory on a local fixed drive, with an existing parent. Only reviewed Windows 11 builds and Server 2022/2025 member hosts are accepted; domain controllers are excluded. Client and managed-environment acceptance remains separate from hosted-server CI. -The effective Group Policy Script collection must already contain rules in `AuditOnly` mode. Local and effective GP policy, management observations, AppIDSvc state and MSI and Script channel configuration must be readable. AppIDSvc must already run and the channel must already be enabled. AppLocker CSP policy remains **Unknown**: the native GP cmdlets do not enumerate that authority. Existing enforcement in other collections is preserved and can prevent the fixed native host from starting. +The effective Group Policy Script collection must already contain rules in `AuditOnly` mode. Local and effective GP policy, management observations, AppIDSvc state and MSI and Script channel configuration must be readable. Direct service observations require Winmgmt, EventLog and AppIDSvc to be running before any CIM connection; the channel must already be enabled. AppLocker CSP policy remains **Unknown**: the native GP cmdlets do not enumerate that authority. Existing enforcement in other collections is preserved and can prevent the fixed native host from starting. Run creates only the reviewed worker template with a fresh filename and nonce. It launches System32's `WindowsPowerShell\v1.0\powershell.exe` with `-NoLogo -NoProfile -NonInteractive -File`; there is no operator-supplied command, profile loading or execution-policy override. The existing execution policy must permit that locally generated unsigned file. For example, Restricted or AllSigned may prevent completion; that is an unverified result. The report records existing native execution-policy registry values and the inherited process preference, without equating these observations to all application-control authorities. diff --git a/scripts/AppLockerScriptProbe.ps1 b/scripts/AppLockerScriptProbe.ps1 index 6b03283b..a12953b2 100644 --- a/scripts/AppLockerScriptProbe.ps1 +++ b/scripts/AppLockerScriptProbe.ps1 @@ -36,7 +36,19 @@ function Get-WelaAppLockerScriptExecutionPolicy { } [pscustomobject]$values } +function Get-WelaAppLockerScriptServices { + # Direct SCM observations precede every CIM connection; observation must not + # implicitly start stopped WMI or AppLocker generation dependencies. + $rows=@() + foreach($name in @('Winmgmt','EventLog','AppIDSvc')) { + $service=Get-Service -Name $name -ErrorAction Stop + if($null -eq $service -or $service.Name -ine $name -or $service.Status -ne [ServiceProcess.ServiceControllerStatus]::Running){throw ($name+' must already be running; no CIM connection or child was started.')} + $rows+=[pscustomobject]@{Name=$name;Status=[string]$service.Status} + } + $rows +} function Get-WelaAppLockerScriptState { + $services=@(Get-WelaAppLockerScriptServices) $hostState=Get-WelaAppLockerHost $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop $version=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop @@ -44,14 +56,17 @@ function Get-WelaAppLockerScriptState { $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Microsoft-Windows-AppLocker/MSI and Script') try {$log=[ordered]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;LogFilePath=$channel.LogFilePath}}finally{$channel.Dispose()} $source=Resolve-WelaArrivalPath (Join-Path ([Environment]::SystemDirectory) 'WindowsPowerShell\v1.0\powershell.exe') - [pscustomobject][ordered]@{Host=$hostState;MachineGuid=$machine.MachineGuid;UBR=$version.UBR;Computer=[Environment]::MachineName;Domain=[string]$computer.Domain;LocalPolicy=(Get-WelaAppLockerPolicySnapshot Local);EffectivePolicy=(Get-WelaAppLockerPolicySnapshot Effective);Management=(Get-WelaAppLockerManagement);Service=(Get-WelaAppLockerService);Channel=$log;ExecutionPolicy=(Get-WelaAppLockerScriptExecutionPolicy);Source=$source;SourceHash=(Get-FileHash -LiteralPath $source -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + [pscustomobject][ordered]@{Services=$services;Host=$hostState;MachineGuid=$machine.MachineGuid;UBR=$version.UBR;Computer=[Environment]::MachineName;Domain=[string]$computer.Domain;LocalPolicy=(Get-WelaAppLockerPolicySnapshot Local);EffectivePolicy=(Get-WelaAppLockerPolicySnapshot Effective);Management=(Get-WelaAppLockerManagement);Service=(Get-WelaAppLockerService);Channel=$log;ExecutionPolicy=(Get-WelaAppLockerScriptExecutionPolicy);Source=$source;SourceHash=(Get-FileHash -LiteralPath $source -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} } function Get-WelaAppLockerScriptStateKey { param($State) + foreach($status in @($State.Host.Status,$State.LocalPolicy.Status,$State.EffectivePolicy.Status,$State.Service.Status,$State.Service.State,$State.Management.Status)){if($status -isnot [string]){throw 'Native context status must be a typed string.'}} + $services=@($State.Services) + if($services.Count -ne 3 -or @($services|Where-Object{$_.Name -isnot [string] -or $_.Status -isnot [string] -or $_.Status -cne 'Running'}).Count -or (($services.Name -join ',') -cne 'Winmgmt,EventLog,AppIDSvc')){throw 'Complete running-service preflight evidence is required.'} if($State.Host.Status -cne 'Candidate' -or $State.Host.Is64BitProcess -isnot [bool] -or -not $State.Host.Is64BitProcess -or $State.Host.ProductType -notin @(1,3) -or ($State.Host.ProductType -eq 1 -and $State.Host.Build -notin @(22000,22621,22631,26100,26200)) -or ($State.Host.ProductType -eq 3 -and $State.Host.Build -notin @(20348,26100))){throw 'A reviewed native Windows 11 or Server 2022/2025 member host is required; DCs are excluded.'} foreach($policy in @($State.LocalPolicy,$State.EffectivePolicy)){if($policy.Status -cne 'Observed' -or $policy.Policy.HasUnknownPolicyData -isnot [bool] -or $policy.Policy.HasUnknownPolicyData){throw 'Local and effective GP policy must be readable and understood.'}} $collection=@($State.EffectivePolicy.Policy.Collections|Where-Object Type -CEQ 'Script') - if($collection.Count -ne 1 -or $collection[0].EnforcementMode -cne 'AuditOnly' -or $collection[0].RuleCount -lt 1){throw 'An existing nonempty effective Script AuditOnly collection is required.'} + if($collection.Count -ne 1 -or $collection[0].EnforcementMode -isnot [string] -or $collection[0].EnforcementMode -cne 'AuditOnly' -or ($collection[0].RuleCount -isnot [int] -and $collection[0].RuleCount -isnot [long]) -or $collection[0].RuleCount -lt 1){throw 'An existing nonempty effective Script AuditOnly collection is required.'} if($State.Service.Status -cne 'Observed' -or $State.Service.State -cne 'Running'){throw 'AppIDSvc must already be running.'} if($State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.Name -cne 'Microsoft-Windows-AppLocker/MSI and Script' -or -not $State.Channel.SecurityDescriptor){throw 'The native MSI and Script channel must already be enabled and readable.'} if($State.Management.Status -cne 'Observed' -or $State.SourceHash -cnotmatch '^[a-f0-9]{64}$' -or -not $State.MachineGuid -or -not $State.Computer){throw 'Incomplete management, machine or source observation.'} diff --git a/tests/AppLockerScriptProbe.Tests.ps1 b/tests/AppLockerScriptProbe.Tests.ps1 index 971e915d..48c4d86d 100644 --- a/tests/AppLockerScriptProbe.Tests.ps1 +++ b/tests/AppLockerScriptProbe.Tests.ps1 @@ -6,10 +6,29 @@ $repo=Split-Path $PSScriptRoot -Parent $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"} +# Prove stopped service refusal happens before the actual state reader reaches CIM. +$script:missingService='';$script:scm=@();$script:cimCalls=0 +function Get-Service {param($Name);$script:scm+=$Name;[pscustomobject]@{Name=$Name;Status=$(if($Name -ceq $script:missingService){[ServiceProcess.ServiceControllerStatus]::Stopped}else{[ServiceProcess.ServiceControllerStatus]::Running})}} +function Get-WelaAppLockerHost {$script:cimCalls++;throw 'CIM boundary reached after SCM checks'} +foreach($name in @('Winmgmt','EventLog','AppIDSvc')){ + $script:missingService=$name;$script:scm=@();$script:cimCalls=0 + Reject {Get-WelaAppLockerScriptState} ($name+' must already be running') + Assert ($script:cimCalls -eq 0) 'Stopped service refusal precedes all CIM observations' +} +$script:missingService='';$script:scm=@();$script:cimCalls=0 +Reject {Get-WelaAppLockerScriptState} 'CIM boundary reached after SCM checks' +Assert (($script:scm -join ',') -ceq 'Winmgmt,EventLog,AppIDSvc' -and $script:cimCalls -eq 1) 'All direct SCM checks precede the first CIM observation' $policy='' -$state=[pscustomobject]@{Host=[pscustomobject]@{Status='Candidate';Is64BitProcess=$true;PartOfDomain=$false;ProductType=3;Build=20348};MachineGuid='11111111-1111-1111-1111-111111111111';Management=[pscustomobject]@{Status='Observed'};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/MSI and Script';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)'};Source='C:\Windows\System32\cmd.ps1';SourceHash=('a'*64)} +$state=[pscustomobject]@{Services=@([pscustomobject]@{Name='Winmgmt';Status='Running'},[pscustomobject]@{Name='EventLog';Status='Running'},[pscustomobject]@{Name='AppIDSvc';Status='Running'});Host=[pscustomobject]@{Status='Candidate';Is64BitProcess=$true;PartOfDomain=$false;ProductType=3;Build=20348};MachineGuid='11111111-1111-1111-1111-111111111111';Management=[pscustomobject]@{Status='Observed'};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/MSI and Script';Enabled=$true;SecurityDescriptor='O:SYG:SYD:(A;;0x1;;;SY)'};Source='C:\Windows\System32\cmd.ps1';SourceHash=('a'*64)} $state|Add-Member NoteProperty LocalPolicy ($state.EffectivePolicy|ConvertTo-Json -Depth 20|ConvertFrom-Json) $null=Get-WelaAppLockerScriptStateKey $state;Assert $true 'Valid audit-only prereqs' +foreach($parent in @('Host','LocalPolicy','EffectivePolicy','Service','Management')){ + $saved=$state.$parent.Status;$state.$parent.Status=$true + Reject {Get-WelaAppLockerScriptStateKey $state} 'typed string' + $state.$parent.Status=$saved +} +$state.Services[0].Status=$true;Reject {Get-WelaAppLockerScriptStateKey $state} 'preflight';$state.Services[0].Status='Running' + foreach($mode in @('Enabled','NotConfigured')){$state.EffectivePolicy.Policy=ConvertFrom-WelaAppLockerXml ($policy.Replace('AuditOnly',$mode));Reject {Get-WelaAppLockerScriptStateKey $state} 'AuditOnly'} $state.EffectivePolicy.Policy=ConvertFrom-WelaAppLockerXml $policy $state.Service.State='Stopped';Reject {Get-WelaAppLockerScriptStateKey $state} 'already be running';$state.Service.State='Running' From eb1b9989a17092d1fbb1345256ad37a1bc4b6a41 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:38:22 +0900 Subject: [PATCH 11/11] Load the service enum for isolated Windows PowerShell fixtures --- tests/AppLockerScriptProbe.Tests.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/AppLockerScriptProbe.Tests.ps1 b/tests/AppLockerScriptProbe.Tests.ps1 index 48c4d86d..f05edf4b 100644 --- a/tests/AppLockerScriptProbe.Tests.ps1 +++ b/tests/AppLockerScriptProbe.Tests.ps1 @@ -3,6 +3,8 @@ $repo=Split-Path $PSScriptRoot -Parent . "$repo/scripts/AppLockerReadiness.ps1" . "$repo/scripts/WefArrival.ps1" . "$repo/scripts/AppLockerScriptProbe.ps1" +# Mocking Get-Service skips the cmdlet's normal .NET Framework assembly load. +if(-not ('System.ServiceProcess.ServiceControllerStatus' -as [type])){Add-Type -AssemblyName System.ServiceProcess -ErrorAction Stop} $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"}