From 8af856ffa6cd2dc038b74c6faae8909fc6bc6511 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:40:56 +0900 Subject: [PATCH 01/10] Validate public provider pack configuration on native Windows --- .gitattributes | 3 + .../workflows/native-provider-configure.yml | 43 +++++ .github/workflows/release.yml | 2 +- CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + docs/native-provider-acceptance.md | 26 +++ docs/native-provider-packs.md | 2 +- .../NativeProviderConfigure.Windows.Tests.ps1 | 161 ++++++++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 10 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/native-provider-configure.yml create mode 100644 docs/native-provider-acceptance.md create mode 100644 tests/NativeProviderConfigure.Windows.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index d4d2d2bb..7b3c814e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -73,3 +73,6 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf # Existing-file read receipts bind identical native/worker source bytes. /scripts/FileAccessProbe* text eol=lf /tests/FileAccessProbe* text eol=lf + +# Disposable native provider configuration fixture +tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf diff --git a/.github/workflows/native-provider-configure.yml b/.github/workflows/native-provider-configure.yml new file mode 100644 index 00000000..0112f9ef --- /dev/null +++ b/.github/workflows/native-provider-configure.yml @@ -0,0 +1,43 @@ +name: Native provider configuration acceptance +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + native-provider-configure: + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Focused provider regressions in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/NativeProviderPacks.Tests.ps1 + - name: Public native provider configuration in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/NativeProviderConfigure.Windows.Tests.ps1 -AllowDisposableProviderWrite + - name: Focused provider regressions in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/NativeProviderPacks.Tests.ps1 + - name: Public native provider configuration in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/NativeProviderConfigure.Windows.Tests.ps1 -AllowDisposableProviderWrite + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: native-provider-configure-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-provider-configure-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce92a16a..8ae204ac 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/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.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/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.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/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-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 83edb0b6..e265c313 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- Server 2022/2025 の Windows PowerShell 5.1/PowerShell 7 で、公開 provider-packs コマンドの Plan、DryRun、Configure、冪等性、前提不足・手動対象の拒否、部分適用を実機検証する使い捨て CI を追加。DNS Client、CAPI2、WinRM、RDP Client の設定と復元記録・ハッシュを確認し、完全な ACL、保持モード、大きい既存バッファ、他チャネル、サービス、全監査マスクの保持とテスト後の正確な復元を検証。イベント生成や Sigma 対応の証明は含みません。(@Shirofune-Security) + - カスタム監査プロファイルの公開 Plan、DryRun、Configure、任意項目、再実行、Audit を Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で検証します。実際の優先設定、厳密値・最小値・維持の動作、変更前ジャーナル、全59監査マスクと復元を確認します。 (@Shirofune-Security) - `wec-listener` の Plan/Apply を追加し、割り当て済みIPv4に限定した新規HTTP5985リスナーを作成できるようにしました。ホスト・実行ユーザー・ソース・WinRMとファイアウォールの状態、計画ハッシュ、実行前記録とネイティブ再読取で変更を検証します。両PowerShellホストから固定のWindows PowerShell 5.1ワーカーを使用し、既存リスナーや状態変化を検出した場合は拒否します。転送到着やSigma対応は別途検証が必要です。 (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index d83efff5..b3ecb2f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added disposable native acceptance for public provider-pack Plan, DryRun, Configure, idempotence, missing/manual refusal and partial outcomes on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Actual DNS Client, CAPI2, WinRM and RDP Client configuration preserves descriptors, retention, larger buffers, unrelated channels, services and all audit masks, with journal/hash evidence and exact fixture cleanup; no event or Sigma credit. (@Shirofune-Security) + - Add disposable native acceptance for public custom-profile Plan, DryRun, Configure, optional controls, idempotence and Audit on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Verify exact/minimum/preserve semantics, real precedence, original journals and all 59 masks, with exact fixture restoration. (@Shirofune-Security) - Added opt-in `wec-listener` Plan/Apply for one new assigned-IPv4 HTTP5985 listener. Reviewed host/operator/source and WinRM/firewall snapshots, explicit plan hashes, pending evidence and native readback guard creation and preserve existing settings. A fixed native Windows PowerShell 5.1 worker provides the creation adapter under both PowerShell host versions. Existing listeners and drift require review; forwarding arrival and Sigma readiness are not inferred. (@Shirofune-Security) diff --git a/docs/native-provider-acceptance.md b/docs/native-provider-acceptance.md new file mode 100644 index 00000000..2df6c226 --- /dev/null +++ b/docs/native-provider-acceptance.md @@ -0,0 +1,26 @@ +# Native provider configuration acceptance + +The dedicated `Native provider configuration acceptance` workflow tests the public `provider-packs` command on disposable GitHub-hosted Windows Server 2022 and 2025, separately under Windows PowerShell 5.1 and PowerShell 7. It complements the read-only manifest inventory and mocked failure tests described in [the provider-pack guide](native-provider-packs.md). + +This fixture is destructive to the selected channels' temporary configuration and can discard records when restoring smaller buffers. It requires `-AllowDisposableProviderWrite`, `GITHUB_ACTIONS=true` and `RUNNER_ENVIRONMENT=github-hosted`; do not run it on ordinary machines. It makes no production configuration changes outside the existing public command's declared scope. + +## Actual public behavior checked + +- The real provider/channel registrations and expected event schemas must permit all four explicitly selected client-side packs: `dns-client`, `capi2`, `winrm` and `rdp-client`. Missing or incompatible metadata fails the fixture; it is never replaced with a mock or skipped success. +- Plan and Configure with `-DryRun` preserve prepared native settings. Unsupported preview and reader-grant options are refused before a recovery directory is created. +- Configure actually enables the four channels and applies their exact minimum buffers. A prepared 2 GiB WinRM buffer stays larger, and a prepared CAPI2 `Retain` mode stays intact. The complete descriptor is preserved; provider packs never request an Event Log Readers grant. +- Every Applied result and its original journal entry are compared with independent native before/after observations. Repeated Configure is idempotent and creates no write journal. +- Both manual DNS packs refuse configuration. The hosted image must genuinely lack the DNS Server service, and `dns-server-audit` must refuse that missing prerequisite. No DNS role is installed or removed to manufacture the result. +- A mixed CAPI2/manual-DNS invocation performs one real selected change and reports the other failure with a nonzero overall exit and exactly one journal entry. Partial application is explicit. + +The fixture does not issue DNS queries, RDP connections or WinRM sessions, change service configuration, or intentionally generate test events. Ordinary background Windows events may occur while the channels are enabled. All rules retain zero Ready credit; enabling a source does not establish event fields, effective reader access, ingestion or matching backend queries. + +## Preservation, cleanup and evidence + +Before preparation, the fixture captures native settings and complete `wevtutil gl /f:xml` configuration for registered catalog channels and additional unselected Security, System, Application, AppLocker and DriverFrameworks controls. During public configuration it compares every selected XML field except the permitted enabled flag and maximum size; unselected registered channels must remain byte-for-byte equivalent at the XML level. It also compares the state/start type of EventLog, Winmgmt, WinRM, TermService and DNS, and all 59 effective audit masks. + +Each selected channel has independent cleanup that restores original enablement, exact byte limit, descriptor and retention/backup mode. A failure restoring one channel does not skip the remaining channels. Final observations compare original full XML, service state and audit masks. Cleanup failure prevents a passing result. Owned child commands have bounded execution and output, and termination failures remain in the cleanup receipt. + +`original.json`, public JSON reports, command output, actual journals, `completed.json`, `cleanup.json` and a SHA256 manifest are retained for seven days by the workflow. The manifest binds the fixture, product helpers, catalog, corpus and full reviewed rule-source bytes. Event records are not restored, and no retention-duration, Windows 11, domain/DC/ADCS, positive installed-DNS, forwarding or Sigma acceptance is implied. This advances issues #386 and #366 without closing their broader acceptance work. + +The underlying enablement, size, retention and backup options follow Microsoft's [wevtutil command reference](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wevtutil). The product's existing channel floors and schema gates remain unchanged. diff --git a/docs/native-provider-packs.md b/docs/native-provider-packs.md index 1061ba35..0af10746 100644 --- a/docs/native-provider-packs.md +++ b/docs/native-provider-packs.md @@ -47,7 +47,7 @@ Successful channel configuration says nothing about benign operation generation, Every attempted native write first records the original enabled flag, exact buffer size, retention and complete descriptor in `before.jsonl`. Restore only the recorded selected channel values using an elevated `wevtutil sl` after reviewing concurrent GPO/administrator changes; do not replace an entire descriptor with an example. No automatic rollback overwrites later changes. Event loss/volume and long-term storage requirements require a measured deployment plan. -The mocked regression suite exercises missing fields/providers, unsupported types/builds, role/service gates, journal-before-write, dry-run, decline, idempotence, preserved ACL/retention/larger buffers, native failure/false success, prompt races and final schema drift. Windows Server 2022/2025 CI on PowerShell 5.1/7 reads real provider manifests and the public CLI plan and checks that channel settings stay unchanged. It creates no DNS queries, log entries, services or subscriptions. Windows 11/DC/CA event-generation and actual backend/collector validation remain pending acceptance work for issue #386. +The mocked regression suite exercises missing fields/providers, unsupported types/builds, role/service gates, journal-before-write, dry-run, decline, idempotence, preserved ACL/retention/larger buffers, native failure/false success, prompt races and final schema drift. Windows Server 2022/2025 CI on PowerShell 5.1/7 reads real provider manifests and the public CLI plan and checks that channel settings stay unchanged. It creates no DNS queries, log entries, services or subscriptions. Windows 11/DC/CA event-generation and actual backend/collector validation remain pending acceptance work for issue #386. A separate [disposable native configuration acceptance suite](native-provider-acceptance.md) now exercises actual public Configure, dry-run, idempotence, refusal, partial outcomes, journals and exact cleanup for the four client-side packs. It supplies configuration proof only. Primary references: [Microsoft WEF Appendix C/F](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection), [DNS logging and diagnostics](https://learn.microsoft.com/en-us/windows-server/networking/dns/dns-logging-and-diagnostics), [EventMetadata](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventmetadata?view=windowsdesktop-10.0), [EventLogLink](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventloglink?view=windowsdesktop-10.0), [Windows 11 release families](https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information), and [Windows Server release families](https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info). The WEF sample identifies event/channel candidates; it does not validate these rule definitions or this implementation on every build. diff --git a/tests/NativeProviderConfigure.Windows.Tests.ps1 b/tests/NativeProviderConfigure.Windows.Tests.ps1 new file mode 100644 index 00000000..aa66f979 --- /dev/null +++ b/tests/NativeProviderConfigure.Windows.Tests.ps1 @@ -0,0 +1,161 @@ +param([switch]$AllowDisposableProviderWrite) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableProviderWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows provider-write 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" +. "$repo/scripts/NativeProviderPacks.ps1" +$count=0;$errors=@();$primary=$null;$mutated=@() +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 30|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Read-Raw([string]$Name){$r=Invoke-WelaNative wevtutil.exe @('gl',$Name,'/f:xml');$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.LoadXml(($r.Output -join "`n"));return ,$doc} +function Guard-Raw($Xml){$copy=$Xml.CloneNode($true);$copy.DocumentElement.RemoveAttribute('enabled');foreach($node in @($copy.SelectNodes("/*/*[local-name()='logging']/*[local-name()='maxSize']"))){$null=$node.ParentNode.RemoveChild($node)};$copy.OuterXml} +function Services { + foreach($name in @('EventLog','Winmgmt','WinRM','TermService','DNS')){ + $state=Get-WelaNativeService $name + if($state.State -eq 'Unknown'){throw "Service $name is unreadable"} + [pscustomobject][ordered]@{Name=$name;State=$state.State;Start=$(if($state.State -ne 'Not installed'){[string](Get-Service -Name $name -ErrorAction Stop).StartType}else{$null})} + } +} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 20 -Compress} +Add-Type -TypeDefinition @' +using System; using System.IO; using System.Text; using System.Threading.Tasks; +public static class WelaProviderConfigureFixturePipe { + public static async Task Read(TextReader reader) { + var text=new StringBuilder(); var buffer=new char[1024]; + while(true) { int n=await reader.ReadAsync(buffer,0,buffer.Length).ConfigureAwait(false); if(n==0)return text.ToString(); + if(n>1048576-text.Length)throw new InvalidDataException("Fixture output exceeded 1Mi characters.");text.Append(buffer,0,n); } + } +} +'@ +$engine=(Get-Process -Id $PID).Path +$root=Join-Path $env:RUNNER_TEMP ('wela-provider-configure-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$wrapper=Join-Path $root 'public.ps1' +@' +param([string]$InputPath) +$ErrorActionPreference='Stop';$global:LASTEXITCODE=0 +$p=Get-Content -LiteralPath $InputPath -Raw|ConvertFrom-Json +$a=@{ProviderAction=[string]$p.Action;ProviderPack=[string[]]$p.Names;ResultsPath=[string]$p.ResultsPath} +if($p.Action -ceq 'Configure'){$a.Auto=$true;$a.BackupPath=[string]$p.BackupPath} +if($p.DryRun){$a.DryRun=$true} +$extra=[string[]]$p.Extra +& ([string]$p.Script) provider-packs @a @extra +exit $global:LASTEXITCODE +'@ | Set-Content -LiteralPath $wrapper -Encoding UTF8 +function Public([string]$Name,[string]$Action,[string[]]$Names,[switch]$DryRun,[int]$Expected=0,[string[]]$Extra=@()){ + $inputPath=Join-Path $root ($Name+'-input.json');$resultPath=Join-Path $root ($Name+'.json');$backup=Join-Path $root ($Name+'-journal') + Save ($Name+'-input.json') @{Script="$repo/WELA.ps1";Action=$Action;Names=$Names;DryRun=[bool]$DryRun;ResultsPath=$resultPath;BackupPath=$backup;Extra=$Extra} + $all=@('-NoLogo','-NoProfile','-NonInteractive','-File',$wrapper,'-InputPath',$inputPath) + foreach($a in $all){if($a.Contains('"') -or $a.EndsWith('\') -or $a -match '[\x00-\x1f]'){throw 'Unsupported fixture argument.'}} + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$engine;$info.Arguments=(@($all|ForEach-Object {'"'+$_+'"'}) -join ' ');$info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $process=[Diagnostics.Process]::new();$process.StartInfo=$info;$started=$false + try { + if(-not $process.Start()){throw 'Public process did not start'};$started=$true + $stdout=[WelaProviderConfigureFixturePipe]::Read($process.StandardOutput);$stderr=[WelaProviderConfigureFixturePipe]::Read($process.StandardError) + if(-not $process.WaitForExit(180000)){throw 'Public command exceeded three minutes.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Public command output drain timed out.'} + $text=$stdout.Result+"`n"+$stderr.Result;[IO.File]::WriteAllText((Join-Path $root ($Name+'.txt')),$text) + Assert ($process.ExitCode -eq $Expected) "Public $Name exit $($process.ExitCode) expected $Expected : $text" + }finally{ + if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:errors+=$_.Exception.Message};if(-not $exited){try{$process.Kill()}catch{$script:errors+=$_.Exception.Message};try{$exited=$process.WaitForExit(5000)}catch{$script:errors+=$_.Exception.Message}};if(-not $exited){$script:errors+='Owned public process termination unconfirmed'}} + try{$process.Dispose()}catch{$script:errors+=$_.Exception.Message} + } + if(Test-Path $resultPath){$r=Get-Content $resultPath -Raw|ConvertFrom-Json;Assert ($r.ExitCode -eq $Expected -and $r.ReadyRules -eq 0 -and $r.UnverifiedEvidence.Count -eq 4) 'Public result agrees with process exit and grants no readiness credit.';return $r} + Assert ($Expected -eq 1 -and -not(Test-Path $backup)) 'Invalid CLI refuses before report or recovery directory creation.' +} +$catalog=Get-WelaProviderPackCatalog +$names=@('dns-client','capi2','winrm','rdp-client');$selected=@($catalog.packs|Where-Object {$names -contains $_.id}) +$channels=@(@($catalog.packs.channel)+@('Security','System','Application','Microsoft-Windows-AppLocker/EXE and DLL','Microsoft-Windows-DriverFrameworks-UserMode/Operational')|Sort-Object -Unique) +$before=@{};$raw=@{};$prepared=@{};$preparedRaw=@{};$services=@(Services);$policies=Get-WelaEffectiveAuditPolicy +foreach($channel in $channels){$before[$channel]=Get-WelaNativeChannel $channel;if(Test-WelaNativeChannelSnapshot $before[$channel]){$raw[$channel]=Read-Raw $channel}} +$rawText=@{};foreach($channel in $raw.Keys){$rawText[$channel]=$raw[$channel].OuterXml} +Save 'original.json' @{Channels=$before;RawXml=$rawText;Services=$services;AuditMasks=$policies;Engine=$PSVersionTable.PSVersion.ToString()} +function Preserved { + foreach($channel in $channels){ + $now=Get-WelaNativeChannel $channel + if($selected.channel -contains $channel){Assert ((Guard-Raw (Read-Raw $channel)) -ceq (Guard-Raw $preparedRaw[$channel])) 'Selected channel preserves complete descriptor, retention, path and publisher settings.'} + elseif($raw.ContainsKey($channel)){Assert ((Read-Raw $channel).OuterXml -ceq $raw[$channel].OuterXml) 'Unselected registered channel retains every configuration field.'} + else{Assert ((Key $now) -ceq (Key $before[$channel])) 'Uninstalled/unreadable nonselected channel observation remains unchanged.'} + } + Assert ((Key @(Services)) -ceq (Key $services)) 'EventLog, Winmgmt, WinRM, RDP and DNS service state/start types remain unchanged.' + $nowMasks=Get-WelaEffectiveAuditPolicy;Assert ($nowMasks.Count -eq 59 -and $policies.Count -eq 59) 'All59 native audit masks are present.' + foreach($guid in $policies.Keys){if($nowMasks[$guid] -ne $policies[$guid]){throw "Audit mask changed: $guid"}};$script:count++ +} +try { + Assert (@($services|Where-Object {$_.Name -in @('Winmgmt','EventLog') -and $_.State -ne 'Running'}).Count -eq 0) 'Metadata dependencies must already be running; fixture never starts services.' + $os=Get-CimInstance Win32_OperatingSystem + Assert ($os.ProductType -eq 3 -and $os.BuildNumber -in @('20348','26100')) 'Reviewed disposable Server2022/2025 required.' + Assert (@($services|Where-Object {$_.Name -eq 'DNS' -and $_.State -eq 'Not installed'}).Count -eq 1) 'Fixture requires genuine DNS Server absence; no role is installed/removed for acceptance.' + foreach($pack in $selected){Assert ($raw.ContainsKey($pack.channel)) "Actual selected channel required: $($pack.id)"} + # First real public observation must support all four reviewed manifest gates. + $initial=Public 'initial' 'Plan' $names + foreach($entry in $initial.ControlsPlan){Assert ($entry.ProviderEvidence.CanConfigure -and $entry.ProviderEvidence.Schema.State -ceq 'Observed' -and $entry.ProviderEvidence.Schema.Provider -ceq $entry.Pack.provider) 'Exact actual provider/schema permits the selected pack.'} + Assert ($initial.ControlsPlan.Count -eq 4) 'Exactly four explicit packs are observed.' + foreach($pack in $selected){ + $channel=$pack.channel;$mutated+=,$channel + $size=if($pack.id -ceq 'winrm'){2147483648L}else{1048576L} + $nativeArguments=@('sl',$channel,'/e:false',('/ms:'+$size));if($pack.id -ceq 'capi2'){$nativeArguments+=@('/rt:true','/ab:false')} + $null=Invoke-WelaNative wevtutil.exe $nativeArguments + $prepared[$channel]=Get-WelaNativeChannel $channel;$preparedRaw[$channel]=Read-Raw $channel + } + Save 'prepared.json' $prepared + $planned=Public 'plan' 'Plan' $names + Assert (@($planned.ControlsPlan|Where-Object Status -cne 'ChangeRequired').Count -eq 0) 'Actual disabled/small prepared channels require change.' + $dry=Public 'dry' 'Configure' $names -DryRun + Assert ($dry.DryRun -and $dry.Results.Count -eq 4 -and @($dry.Results|Where-Object Status -cne 'Skipped').Count -eq 0 -and -not(Test-Path "$root/dry-journal")) 'Public DryRun skips all selected writes and creates no journal.' + $null=Public 'whatif' 'Configure' $names -Expected 1 -Extra @('-WhatIf') + $null=Public 'grant-option' 'Configure' $names -Expected 1 -Extra @('-GrantEventLogReaders') + foreach($channel in $selected.channel){Assert (Test-WelaNativeChannelSnapshotEqual $prepared[$channel] (Get-WelaNativeChannel $channel)) 'Plan, DryRun and invalid options preserve prepared actual state.'} + Preserved + $applied=Public 'configure' 'Configure' $names + Assert ($applied.Action -ceq 'Configure' -and $applied.Scope -ceq 'native-channel-settings-only' -and $applied.Results.Count -eq 4 -and @($applied.Results|Where-Object Status -cne 'Applied').Count -eq 0) 'All four explicit configurations are actually Applied.' + $journal=@(Get-Content "$root/configure-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json}) + Assert ($journal.Count -eq 4 -and @($journal|Where-Object {$selected.channel -notcontains $_.Target.Channel}).Count -eq 0) 'Exactly four selected changes have durable original journals.' + foreach($pack in $selected){ + $channel=$pack.channel;$entry=@($applied.Results|Where-Object {$_.Target.Channel -ceq $channel});$j=@($journal|Where-Object {$_.Target.Channel -ceq $channel});$now=Get-WelaNativeChannel $channel + $minimum=if($pack.id -ceq 'capi2'){102432768L}elseif($pack.id -ceq 'winrm'){2147483648L}else{33554432L} + Assert ($entry.Count -eq 1 -and $j.Count -eq 1 -and (Test-WelaNativeChannelSnapshotEqual $j[0].Before $prepared[$channel]) -and (Test-WelaNativeChannelSnapshotEqual $entry[0].Before $prepared[$channel])) 'Native journal and result retain exact prepared before-state.' + Assert ($now.IsEnabled -and $now.MaximumSizeInBytes -eq $minimum -and (Test-WelaNativeChannelSnapshotEqual $entry[0].After $now)) 'Exact native enable/floor/larger-buffer readback matches Applied after-state.' + Assert ((Test-WelaChannelDescriptorEqual $now.SecurityDescriptor $prepared[$channel].SecurityDescriptor) -and $now.LogMode -ceq $prepared[$channel].LogMode -and -not $entry[0].Desired.AccessChangeRequested) 'Every descriptor byte and retention mode is preserved without a read grant.' + } + Assert ((Get-WelaNativeChannel 'Microsoft-Windows-CAPI2/Operational').LogMode -ceq 'Retain') 'An actual nondefault Retain setting survives provider configuration.' + Preserved + $repeat=Public 'repeat' 'Configure' $names + Assert (@($repeat.Results|Where-Object Status -cne 'AlreadyCompliant').Count -eq 0 -and -not(Test-Path "$root/repeat-journal/before.jsonl")) 'Native repeat is idempotent and journals no write.' + $manual=Public 'manual' 'Configure' @('dns-server-analytical','dns-server-classic') -Expected 1 + Assert ($manual.Results.Count -eq 2 -and @($manual.Results|Where-Object Status -cne 'Failed').Count -eq 0 -and -not(Test-Path "$root/manual-journal/before.jsonl")) 'Both actual manual-only selections fail without channel mutation or journal.' + $missing=Public 'missing-dns' 'Configure' @('dns-server-audit') -Expected 1 + Assert ($missing.Results[0].Status -ceq 'Failed' -and $missing.ControlsPlan[0].ProviderEvidence.Service.State -ceq 'Not installed' -and -not(Test-Path "$root/missing-dns-journal/before.jsonl")) 'Missing actual DNS service cannot be replaced by an assumed server role.' + # A genuine partial public run must retain one success and one manual refusal. + $capi='Microsoft-Windows-CAPI2/Operational';$null=Invoke-WelaNative wevtutil.exe @('sl',$capi,'/e:false');$partialBefore=Get-WelaNativeChannel $capi + $partial=Public 'partial' 'Configure' @('capi2','dns-server-analytical') -Expected 1 + Assert (@($partial.Results|Where-Object Status -ceq 'Applied').Count -eq 1 -and @($partial.Results|Where-Object Status -ceq 'Failed').Count -eq 1 -and (Get-WelaNativeChannel $capi).IsEnabled) 'Actual partial configuration retains one verified change and explicit nonzero failure.' + $partialJournal=@(Get-Content "$root/partial-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json}) + Assert ($partialJournal.Count -eq 1 -and $partialJournal[0].Target.Channel -ceq $capi -and (Test-WelaNativeChannelSnapshotEqual $partialJournal[0].Before $partialBefore)) 'Partial run journals only its actual selected write.' + Preserved + Save 'completed.json' @{Status='Passed';Assertions=$count;ActualAppliedControls=5;IdempotentControls=4;ManualRefusals=3;MissingServiceRefusals=1;ReadyRuleCredit=0} +}catch{$primary=$_} +finally { + foreach($channel in $mutated){ + try { + $s=$before[$channel];$retention=if($s.LogMode -ceq 'Circular'){'false'}else{'true'};$backup=if($s.LogMode -ceq 'AutoBackup'){'true'}else{'false'} + $null=Invoke-WelaNative wevtutil.exe @('sl',$channel,('/e:'+$s.IsEnabled.ToString().ToLowerInvariant()),('/ms:'+$s.MaximumSizeInBytes),('/ca:'+$s.SecurityDescriptor),('/rt:'+$retention),('/ab:'+$backup)) + if(-not(Test-WelaNativeChannelSnapshotEqual $s (Get-WelaNativeChannel $channel)) -or (Read-Raw $channel).OuterXml -cne $raw[$channel].OuterXml){throw 'Exact original channel configuration differs after cleanup.'} + }catch{$errors+="$channel : $($_.Exception.Message)"} + } + $after=@{};foreach($channel in $channels){try{$after[$channel]=Get-WelaNativeChannel $channel;if($raw.ContainsKey($channel)){if((Read-Raw $channel).OuterXml -cne $raw[$channel].OuterXml){throw 'Original channel XML differs'}}elseif((Key $after[$channel]) -cne (Key $before[$channel])){throw 'Original unavailable observation differs'}}catch{$errors+="$channel : $($_.Exception.Message)"}} + $serviceAfter=$null;try{$serviceAfter=@(Services);if((Key $serviceAfter) -cne (Key $services)){throw 'Service state/start type differs'}}catch{$errors+=$_.Exception.Message} + $maskAfter=$null;try{$maskAfter=Get-WelaEffectiveAuditPolicy;if($maskAfter.Count -ne $policies.Count){throw 'Audit mask count differs'};foreach($guid in $policies.Keys){if($maskAfter[$guid] -ne $policies[$guid]){throw "Audit mask differs: $guid"}}}catch{$errors+=$_.Exception.Message} + Save 'cleanup.json' @{CleanupVerified=($errors.Count -eq 0);Original=$before;After=$after;ServicesBefore=$services;ServicesAfter=$serviceAfter;AuditMasksCompared=$policies.Count;AuditMasksAfter=$maskAfter;Errors=$errors;PrimaryError=[string]$primary;Assertions=$count} +} +$artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object {[ordered]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash}}) +$sourcePaths=@('WELA.ps1','scripts/Configuration.ps1','scripts/NativeChannelConfiguration.ps1','scripts/NativeProviderPacks.ps1','modules/AuditProfiles.psm1','modules/EventLogSettings.psm1','modules/NativeProviders.psm1','modules/NativeChannelAccess.psm1','config/native_channel_profile.json','config/native_provider_packs.json','config/security_rules.json','tests/NativeProviderConfigure.Windows.Tests.ps1')+@($catalog.ruleReviews|ForEach-Object {'config/'+$_.localPath}) +$sources=@($sourcePaths|ForEach-Object {[ordered]@{Path=$_;Sha256=(Get-FileHash -LiteralPath (Join-Path $repo $_) -Algorithm SHA256).Hash}}) +Save 'manifest.json' @{Status=$(if($primary -or $errors.Count){'Failed'}else{'Passed'});Commit=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Assertions=$count;Artifacts=$artifacts;Sources=$sources;EventGenerationVerified=$false;ForwardingVerified=$false;ReadyRuleCredit=0} +if($errors.Count){throw "Fixture cleanup failed: $($errors -join '; '); primary=$primary"};if($primary){throw $primary} +Write-Host "PASS: $count native public provider-pack assertions and exact channel/service/audit cleanup. No event generation or Sigma proof." +exit 0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index b4281717..eb592ed2 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- Server 2022/2025 の Windows PowerShell 5.1/PowerShell 7 で、公開 provider-packs コマンドの Plan、DryRun、Configure、冪等性、前提不足・手動対象の拒否、部分適用を実機検証する使い捨て CI を追加。DNS Client、CAPI2、WinRM、RDP Client の設定と復元記録・ハッシュを確認し、完全な ACL、保持モード、大きい既存バッファ、他チャネル、サービス、全監査マスクの保持とテスト後の正確な復元を検証。イベント生成や Sigma 対応の証明は含みません。(@Shirofune-Security) + - カスタム監査プロファイルの公開 Plan、DryRun、Configure、任意項目、再実行、Audit を Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で検証します。実際の優先設定、厳密値・最小値・維持の動作、変更前ジャーナル、全59監査マスクと復元を確認します。 (@Shirofune-Security) - `wec-listener` の Plan/Apply を追加し、割り当て済みIPv4に限定した新規HTTP5985リスナーを作成できるようにしました。ホスト・実行ユーザー・ソース・WinRMとファイアウォールの状態、計画ハッシュ、実行前記録とネイティブ再読取で変更を検証します。両PowerShellホストから固定のWindows PowerShell 5.1ワーカーを使用し、既存リスナーや状態変化を検出した場合は拒否します。転送到着やSigma対応は別途検証が必要です。 (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index dcd52f80..a7531fd8 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added disposable native acceptance for public provider-pack Plan, DryRun, Configure, idempotence, missing/manual refusal and partial outcomes on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Actual DNS Client, CAPI2, WinRM and RDP Client configuration preserves descriptors, retention, larger buffers, unrelated channels, services and all audit masks, with journal/hash evidence and exact fixture cleanup; no event or Sigma credit. (@Shirofune-Security) + - Add disposable native acceptance for public custom-profile Plan, DryRun, Configure, optional controls, idempotence and Audit on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Verify exact/minimum/preserve semantics, real precedence, original journals and all 59 masks, with exact fixture restoration. (@Shirofune-Security) - Added opt-in `wec-listener` Plan/Apply for one new assigned-IPv4 HTTP5985 listener. Reviewed host/operator/source and WinRM/firewall snapshots, explicit plan hashes, pending evidence and native readback guard creation and preserve existing settings. A fixed native Windows PowerShell 5.1 worker provides the creation adapter under both PowerShell host versions. Existing listeners and drift require review; forwarding arrival and Sigma readiness are not inferred. (@Shirofune-Security) From 558ba6f2a85844ef7aa701fcfbb2b924e15b261b Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:41:04 +0900 Subject: [PATCH 02/10] Verify public SMB audit policy configuration on native Windows --- .github/workflows/native-smb-policy.yml | 47 +++++++++++ CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + docs/smb-auditing.md | 6 ++ tests/SmbPolicyConfigure.Windows.Tests.ps1 | 96 ++++++++++++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 7 files changed, 157 insertions(+) create mode 100644 .github/workflows/native-smb-policy.yml create mode 100644 tests/SmbPolicyConfigure.Windows.Tests.ps1 diff --git a/.github/workflows/native-smb-policy.yml b/.github/workflows/native-smb-policy.yml new file mode 100644 index 00000000..7221014a --- /dev/null +++ b/.github/workflows/native-smb-policy.yml @@ -0,0 +1,47 @@ +name: Native public SMB policy configuration +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + native-smb-policy: + 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/SmbAuditing.Tests.ps1 + ./tests/SmbAuditing.Windows.Tests.ps1 + - name: Native public SMB policy configuration in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/SmbPolicyConfigure.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Fixtures and public guards in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/SmbAuditing.Tests.ps1 + ./tests/SmbAuditing.Windows.Tests.ps1 + - name: Native public SMB policy configuration in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/SmbPolicyConfigure.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: native-smb-policy-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-smb-policy-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index ef9b8bad..24a6bdcd 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security) + - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) - Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d66a87..80e261c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@Shirofune-Security) + - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) - Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security) diff --git a/docs/smb-auditing.md b/docs/smb-auditing.md index 153fa76f..fe2a4484 100644 --- a/docs/smb-auditing.md +++ b/docs/smb-auditing.md @@ -72,3 +72,9 @@ On isolated supported client/server snapshots, retain OS build/revision, PowerSh Microsoft documents the policy-to-registry mappings and the SMB configuration cmdlets, but the cited pages do not establish synchronous propagation of a direct policy-registry write into the getter or promise that refreshing Group Policy resolves any discrepancy. WELA makes neither assumption. Sources: [LanmanServer Policy CSP mappings](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-lanmanserver), [LanmanWorkstation Policy CSP mappings](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-lanmanworkstation), [SMB signing and encryption auditing](https://learn.microsoft.com/en-us/windows-server/storage/file-server/smb-signing-overview), [SMB feature availability](https://learn.microsoft.com/en-us/windows-server/storage/file-server/file-server-smb-overview), [SMB configuration getter](https://learn.microsoft.com/en-us/powershell/module/smbshare/get-smbclientconfiguration?view=windowsserver2025-ps), [SMB server audit parameters](https://learn.microsoft.com/en-us/powershell/module/smbshare/set-smbserverconfiguration?view=windowsserver2025-ps), and [issue #377](https://github.com/Yamato-Security/WELA/issues/377). + +## Native public configuration acceptance + +The separately opted-in `SmbPolicyConfigure.Windows.Tests.ps1` fixture runs public Plan, DryRun and Configure on disposable, unjoined Server 2022/2025 hosts with PowerShell 5.1/7. Server 2022 must skip all six unsupported controls without policy writes. On Server 2025, exact local ADMX and runtime observations must qualify before preparing six DWORD 0 values. Public Configure then writes six DWORD 1 values, preserves full native SMB configuration, siblings, access descriptors, service state and all 59 audit masks, records exact typed original journals, and repeats without writes. Cleanup restores the original values and removes only fixture-created empty policy keys. Native results retain the actual build/UBR and PowerShell version. + +This acceptance establishes policy registry behavior only. It generates no SMB traffic, performs no runtime activation or policy refresh, and does not establish Windows client/DC/AD CS, event, forwarding or Sigma readiness. diff --git a/tests/SmbPolicyConfigure.Windows.Tests.ps1 b/tests/SmbPolicyConfigure.Windows.Tests.ps1 new file mode 100644 index 00000000..8571d19f --- /dev/null +++ b/tests/SmbPolicyConfigure.Windows.Tests.ps1 @@ -0,0 +1,96 @@ +param([switch]$AllowDisposablePolicyWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on a disposable GitHub-hosted Windows runner is required.'} +$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/SmbAuditing.ps1') +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +$os=Get-CimInstance Win32_OperatingSystem;$computer=Get-CimInstance Win32_ComputerSystem +if($os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100) -or $computer.DomainRole -ne 2 -or $computer.PartOfDomain){throw 'An unjoined disposable Server 2022/2025 is required.'} +$root=Join-Path $env:RUNNER_TEMP ('wela-smb-policy-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$engine=(Get-Process -Id $PID).Path;$definitions=@(Get-WelaSmbAuditDefinitions);$count=0;$failure=$null;$errors=@() +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Masks { $m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';' } +function Runtime { + foreach($side in @('Server','Client')){ + $cmd="Get-Smb${side}Configuration";$c=& $cmd -ErrorAction Stop + [pscustomobject][ordered]@{Side=$side;Properties=@($c.CimInstanceProperties|Sort-Object Name|ForEach-Object{[pscustomobject][ordered]@{Name=$_.Name;Type=$_.CimType.ToString();Value=$_.Value}})} + } +} +function Policies {foreach($d in $definitions){[pscustomobject]@{Definition=$d;Policy=Get-WelaRegistryState $d.Path $d.Name}}} +function Keys { + foreach($component in @('LanmanServer','LanmanWorkstation')){ + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$k=$null + try{ + $k=$base.OpenSubKey("SOFTWARE\Policies\Microsoft\Windows\$component") + if(-not $k){[pscustomobject][ordered]@{Component=$component;Exists=$false;Values=@();Children=@();Access=$null};continue} + $acl=if($PSVersionTable.PSVersion.Major -ge 6){[Microsoft.Win32.RegistryAclExtensions]::GetAccessControl($k)}else{$k.GetAccessControl()} + [pscustomobject][ordered]@{Component=$component;Exists=$true;Values=@($k.GetValueNames()|Sort-Object|ForEach-Object{[pscustomobject][ordered]@{Name=$_;Type=$k.GetValueKind($_).ToString();Value=$k.GetValue($_,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}});Children=@($k.GetSubKeyNames()|Sort-Object);Access=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group)} + }finally{if($k){$k.Dispose()};$base.Dispose()} + } +} +function OtherKeys { + $all=@(Keys) + foreach($k in $all){$names=@($definitions|Where-Object Component -eq $k.Component|ForEach-Object Name);$k.Values=@($k.Values|Where-Object Name -NotIn $names)} + return $all +} +function Public([string]$Name,[string[]]$Arguments,[int]$Expected=0){ + $prior=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') smb-auditing @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + $output|Set-Content -LiteralPath (Join-Path $root ($Name+'.txt')) -Encoding UTF8 + Assert ($code -eq $Expected) "Public $Name exited $code : $output" + Get-Content -Raw -LiteralPath (Join-Path $root ($Name+'.json'))|ConvertFrom-Json +} +$before=@(Policies);$keys=@(Keys);$runtime=@(Runtime);$masks=Masks +$services=@(Get-Service LanmanServer,LanmanWorkstation|Sort-Object Name|Select-Object Name,Status) +Save 'original.json' @{Policies=$before;Keys=$keys;Runtime=$runtime;Masks=$masks;Services=$services;Build=[int]$os.BuildNumber;UBR=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR;Engine=$PSVersionTable.PSVersion.ToString();PartOfDomain=$computer.PartOfDomain;DomainRole=$computer.DomainRole} +try{ + $initial=@(Get-WelaSmbAuditPlan) + if([int]$os.BuildNumber -eq 20348){Assert (@($initial|Where-Object Status -ne NotApplicable).Count -eq 0) 'All six policies are genuinely not applicable on Server 2022.'} + else{ + Assert (@($initial|Where-Object {$_.Status -notin @('ChangeRequired','PolicyConfigured')}).Count -eq 0) 'All six policies require exact local ADMX and readable native runtime before fixture writes.' + foreach($d in $definitions){New-WelaRegistryKey $d.Path;$null=New-ItemProperty -LiteralPath $d.Path -Name $d.Name -Value 0 -PropertyType DWord -Force} + } + $prepared=@(Policies);$other=@(OtherKeys);Save 'prepared.json' $prepared + $plan=Public plan @('-SmbAction','Plan','-ResultsPath',(Join-Path $root 'plan.json')) + Assert ($plan.Controls.Count -eq 6) 'Public Plan accounts for exactly six controls.' + $dry=Public dry @('-SmbAction','Configure','-DryRun','-BackupPath',(Join-Path $root 'dry-backup'),'-ResultsPath',(Join-Path $root 'dry.json')) + Assert ($dry.DryRun -and @($dry.Results|Where-Object Status -eq Applied).Count -eq 0 -and -not(Test-Path (Join-Path $root 'dry-backup'))) 'Dry run does not change policy or create original journals.' + Assert ((Key @(Policies)) -ceq (Key $prepared) -and (Key @(Runtime)) -ceq (Key $runtime)) 'Plan and DryRun preserve exact typed policy and full native runtime.' + $applied=Public apply @('-SmbAction','Configure','-Auto','-BackupPath',(Join-Path $root 'apply-backup'),'-ResultsPath',(Join-Path $root 'apply.json')) + Assert ($applied.Scope -ceq 'smb-audit-policies-only' -and $applied.Results.Count -eq 6) 'Public Configure retains narrow scope and all six outcomes.' + if([int]$os.BuildNumber -eq 20348){ + Assert (@($applied.Results|Where-Object Status -ne Skipped).Count -eq 0 -and -not(Test-Path (Join-Path $root 'apply-backup/before.jsonl'))) 'Unsupported Server 2022 has six skipped controls and no policy writes.' + }else{ + Assert (@($applied.Results|Where-Object Status -ne Applied).Count -eq 0) 'Server 2025 actually applied all six policy DWORDs.' + $journal=@(Get-Content (Join-Path $root 'apply-backup/before.jsonl')|ConvertFrom-Json);Assert ($journal.Count -eq 6) 'Every actual write has an original journal entry.' + foreach($row in $applied.Results){ + $j=@($journal|Where-Object Id -eq $row.Id);$p=@($prepared|Where-Object {$_.Definition.Path -ceq $row.Target.Path -and $_.Definition.Name -ceq $row.Target.Name}) + Assert ($j.Count -eq 1 -and $p.Count -eq 1 -and (Key $j[0].Before.Policy) -ceq (Key $p[0].Policy)) 'Each journal matches the actual typed original policy.' + Assert ($row.After.Policy.Type -ceq 'DWord' -and $row.After.Policy.Value -eq 1 -and $row.After.PolicyRegistryConfigured) 'Actual native readback verifies each DWORD without inferring runtime state.' + } + $repeat=Public repeat @('-SmbAction','Configure','-Auto','-BackupPath',(Join-Path $root 'repeat-backup'),'-ResultsPath',(Join-Path $root 'repeat.json')) + Assert (@($repeat.Results|Where-Object Status -ne AlreadyCompliant).Count -eq 0 -and -not(Test-Path (Join-Path $root 'repeat-backup/before.jsonl'))) 'Repeated public Configure is idempotent without another journal.' + } + Assert ((Key @(OtherKeys)) -ceq (Key $other) -and (Key @(Runtime)) -ceq (Key $runtime) -and (Masks) -ceq $masks) 'Sibling values, access descriptors, children, complete SMB runtime and all59 audit masks are preserved.' + Save 'completed.json' @{Status='Passed';Assertions=$count;ActualPolicyWrites=$(if([int]$os.BuildNumber -eq 26100){6}else{0});Scope='Policy registry only; no SMB traffic, activation, GPO refresh, event generation or Sigma proof.'} +}catch{$failure=$_.ToString();throw}finally{ + foreach($row in $before){try{ + $d=$row.Definition;$old=$row.Policy;$now=Get-WelaRegistryState $d.Path $d.Name + if($old.ValueExists){$null=New-ItemProperty -LiteralPath $d.Path -Name $d.Name -Value $old.Value -PropertyType $old.Type -Force} + elseif($now.ValueExists){Remove-ItemProperty -LiteralPath $d.Path -Name $d.Name -ErrorAction Stop} + }catch{$errors+=$_.ToString()}} + foreach($k in $keys|Where-Object {-not $_.Exists}){try{ + $path="HKLM:\SOFTWARE\Policies\Microsoft\Windows\$($k.Component)" + if(Test-Path -LiteralPath $path){$item=Get-Item -LiteralPath $path;if($item.ValueCount -ne 0 -or $item.SubKeyCount -ne 0){throw 'A fixture-created key is not empty; it was preserved.'};Remove-Item -LiteralPath $path -ErrorAction Stop} + }catch{$errors+=$_.ToString()}} + $checks=[ordered]@{} + foreach($pair in @(@('Policies',{(Key @(Policies)) -ceq (Key $before)}),@('Keys',{(Key @(Keys)) -ceq (Key $keys)}),@('Runtime',{(Key @(Runtime)) -ceq (Key $runtime)}),@('AuditMasks',{(Masks) -ceq $masks}),@('Services',{(Key @(Get-Service LanmanServer,LanmanWorkstation|Sort-Object Name|Select-Object Name,Status)) -ceq (Key $services)}))){try{$checks[$pair[0]]=& $pair[1]}catch{$checks[$pair[0]]=$false;$errors+=$_.ToString()}} + $complete=$errors.Count -eq 0 -and @($checks.Values|Where-Object {-not $_}).Count -eq 0 + Save 'cleanup.json' @{Complete=$complete;Checks=$checks;Errors=$errors;Failure=$failure;Assertions=$count} + if(-not $complete){throw 'SMB native policy fixture cleanup failed; inspect retained receipts.'} +} +Write-Host "PASS: $count native public SMB policy assertions and exact cleanup." +exit 0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index b4f75fc6..afda0843 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security) + - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) - Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 1ba91895..d65ae69a 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@Shirofune-Security) + - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) - Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security) From af88b87e015c1bed07cc605400a308db6c3bcdb3 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:46:28 +0900 Subject: [PATCH 03/10] Fix native collector subscription inventory and Unicode readback --- .gitattributes | 5 + .github/workflows/release.yml | 2 +- .../workflows/wec-collector-observation.yml | 51 ++++++++++ CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 2 +- docs/wec-collector-observation.md | 22 +++++ docs/wef-deployment.md | 4 +- modules/WecSubscriptionInventory.cs | 52 ++++++++++ modules/WefSubscriptions.psm1 | 23 ++++- scripts/WefDeployment.ps1 | 5 +- .../WecCollectorObservation.Windows.Tests.ps1 | 99 +++++++++++++++++++ tests/WecSubscriptionInventory.Tests.ps1 | 34 +++++++ tests/WefDeployment.Tests.ps1 | 28 +++++- website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 16 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/wec-collector-observation.yml create mode 100644 docs/wec-collector-observation.md create mode 100644 modules/WecSubscriptionInventory.cs create mode 100644 tests/WecCollectorObservation.Windows.Tests.ps1 create mode 100644 tests/WecSubscriptionInventory.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index 893c0ce4..c603e6d0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -81,3 +81,8 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf # Reviewed channel restoration binds exact installed source bytes. /scripts/ChannelRecovery.ps1 text eol=lf /tests/ChannelRecovery*.ps1 text eol=lf + +# Collector inventory reads native UTF16 names and bounded Unicode XML. +/modules/WecSubscriptionInventory.cs text eol=lf +/tests/WecCollectorObservation* text eol=lf +/tests/WecSubscriptionInventory* text eol=lf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e0460fa..73c54cb1 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/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.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/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.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/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/wec-collector-observation.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/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/.github/workflows/wec-collector-observation.yml b/.github/workflows/wec-collector-observation.yml new file mode 100644 index 00000000..b352765b --- /dev/null +++ b/.github/workflows/wec-collector-observation.yml @@ -0,0 +1,51 @@ +name: Native collector subscription observation +on: + push: + paths: ['WELA.ps1', 'modules/WefSubscriptions.psm1', 'modules/WecSubscription*', 'scripts/WefDeployment.ps1', 'tests/WecCollectorObservation*', 'tests/WecSubscriptionInventory*', '.github/workflows/wec-collector-observation.yml'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + collector-observation: + 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: Native observation regressions in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/WecSubscriptionInventory.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + ./tests/WefDeployment.Tests.ps1 + ./tests/WefDeployment.Cli.Tests.ps1 + - name: Native observation regressions in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/WecSubscriptionInventory.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + ./tests/WefDeployment.Tests.ps1 + ./tests/WefDeployment.Cli.Tests.ps1 + - name: Actual public collector observations in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/WecCollectorObservation.Windows.Tests.ps1 -AllowDisposableSubscription + - name: Actual public collector observations in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/WecCollectorObservation.Windows.Tests.ps1 -AllowDisposableSubscription + - name: Retain native observations and exact cleanup evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: collector-observation-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-wec-observation-* + if-no-files-found: warn + retention-days: 7 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index ef9b8bad..71ea0a6e 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- コレクターのサブスクリプション観測で、コンソール経由の文字変換を、上限付きの完全なネイティブ名前列挙と厳密なUnicode XML読取に置き換えました。空の一覧・読取失敗・実際の無効状態を区別し、Unicodeの説明とXPathを保持します。Server 2022/2025と両PowerShellで公開Audit/Planおよび正確な後処理を検証し、ドメイン展開・転送・Sigma対応は主張しません。(関連 #368) (@Shirofune-Security) + - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) - Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d66a87..c4af2f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Fixed collector subscription observations to use complete bounded native name enumeration and strict Unicode XML reads instead of console decoding. Empty inventories, failed reads and actual disabled state remain distinct; Unicode descriptions and XPath are preserved. Disposable public Audit/Plan tests cover both Server 2022/2025 and PowerShell engines with exact cleanup, without domain deployment, forwarding or Sigma claims. (Related #368) (@Shirofune-Security) + - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) - Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 7cbe077d..0b92c262 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2561,7 +2561,7 @@ switch ($Cmd.ToLower()) { { $_ -in @('wef-source','wec-collector') } { if ($Help) { Write-Host 'Usage: ./WELA.ps1 wef-source|wec-collector -WefConfigPath operator.json [-WefAction Audit|Plan|Configure] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' - Write-Host 'Native domain/Kerberos HTTP source configuration and create-only collector subscriptions. Existing collector listener and explicit scoped ingress are prerequisites. Optional ASD hardening is explicit in JSON. See docs/wef-deployment.md; forwarding/event arrival remain unverified.' + Write-Host 'Native domain/Kerberos HTTP source configuration and create-only collector subscriptions. Existing collector listener and explicit scoped ingress are prerequisites. Optional ASD hardening is explicit in JSON. See docs/wef-deployment.md and docs/wec-collector-observation.md; native inventory/XML read failures stay unknown, and forwarding/event arrival remain unverified.' return } if ($Profile -or $Baseline -or $HtmlPath) { throw 'WEF commands require their own explicit JSON config and use -ResultsPath; -Profile, -Baseline and -HtmlPath are unsupported.' } diff --git a/docs/wec-collector-observation.md b/docs/wec-collector-observation.md new file mode 100644 index 00000000..281a2253 --- /dev/null +++ b/docs/wec-collector-observation.md @@ -0,0 +1,22 @@ +# Native collector subscription observations + +The existing `wec-collector` Audit and Plan commands now enumerate subscription names through the local Windows Event Collector API and read selected XML through the shared bounded Unicode reader. This avoids treating PowerShell console output, including a BOM-only empty result, as subscription identity. Non-ASCII descriptions and XPath literals remain intact in the report. + +```powershell +.\WELA.ps1 wec-collector -WefAction Audit ` + -WefConfigPath C:\Reviewed\collector.json -ResultsPath C:\Evidence\collector-audit.json +.\WELA.ps1 wec-collector -WefAction Plan ` + -WefConfigPath C:\Reviewed\collector.json -ResultsPath C:\Evidence\collector-plan.json +``` + +Use the explicit collector configuration described in [WEF deployment](wef-deployment.md). These commands observe the selected local subscriptions and prerequisites. They do not create, save, enable or delete subscriptions. Existing Configure remains create-only and retains its domain, listener, ingress and hardening prerequisites. + +A successful complete enumeration can establish `ObservedSubscription.Exists: false`; its `ObservedEnabled` remains null. An enumeration error, cap, duplicate/invalid native name, vanished or unreadable selected definition, mismatched XML identity or unsupported authorization remains unknown, with `ObservationError` and an `Unknown` control. Failed observations never authorize creation. A valid disabled definition is reported as disabled even when the requested XML says enabled. A readable difference requires manual review rather than a replacement. + +Enumeration preserves exact native UTF-16 names, including Unicode and whitespace; it does not trim names or parse localized command output. It is limited to 4,096 names, 1,023 UTF-16 characters per name and 1,048,576 total characters including terminators. Exceeding a bound fails the observation instead of returning a partial list. Selected subscription IDs continue to use the existing supported ASCII ID syntax, and native XML reads retain their ten-MiB and thirty-second bounds. Native API errors are preserved as failures. The loaded enumeration helper is bound to its implementation bytes. + +Enumeration and XML readback are sequential observations, not a transaction or protection against another administrator. A disappearing subscription is unknown for that observation; retry with a fresh audit. A complete configuration match still does not establish source identity, effective source access, runtime health, event arrival, bookmark continuity or Sigma coverage. Collector-local channel observations describe only the collector. + +The disposable native suite exercises the actual public commands on Server 2022/2025 with Windows PowerShell 5.1 and PowerShell 7. It uses one uniquely owned disabled subscription with Unicode description and XPath, verifies absence, exact observation, requested/observed state separation, changed-description review and authorization-mismatch uncertainty, then removes only the owned subscription and restores original service startup/state. It preserves the destination channel and original subscription inventory. The real standalone fixture remains `Incomplete` with exit 1 for domain deployment prerequisites; those checks are neither mocked nor counted as domain or forwarding proof. Native name-buffer, cap, duplicate and read-failure regressions supplement that Windows acceptance. + +Microsoft references: [subscription enumeration](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecenumnextsubscription), [enumeration handles](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecopensubscriptionenum), and [wecutil XML/read-only commands](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wecutil). diff --git a/docs/wef-deployment.md b/docs/wef-deployment.md index 3614569b..108558e7 100644 --- a/docs/wef-deployment.md +++ b/docs/wef-deployment.md @@ -50,6 +50,8 @@ ForwardedEvents enablement preserves its size, retention mode and security descr ## Subscription XML and evidence +[Native collector observations](wec-collector-observation.md) use complete bounded WEC name enumeration and strict Unicode XML reads. Failed or partial observations remain unknown; they never become permission to create a subscription. Raw Unicode descriptions/filters and actual disabled state are retained independently of the requested settings. + The supported input is the native Subscription namespace, SourceInitiated type, native EventLog URI, HTTP transport, ForwardedEvents destination and Normal/MinLatency/MinBandwidth delivery preset. Enabled, ReadExistingEvents, content format and locale must be explicit. QueryList uses unique numeric Query IDs, exact native channel paths and nonempty Select/Suppress XPath expressions. Wildcard/provider channel names, external-provider channels, Sysmon/EMET, DTDs, unknown settings and custom delivery are rejected. This checks supported structure, not Windows XPath execution; native `cs` remains the final syntax validator. An empty `AllowedSourceDomainComputers` input is filled from the explicit `SourceSids`; a nonempty value must match that authorization exactly. No empty authorization reaches `wecutil`, avoiding Windows' broader default authorization. Non-domain/certificate authorization is not supported. The example Security 4740 filter is illustrative and is not a complete baseline or a recommendation to lock an account for testing. @@ -64,7 +66,7 @@ Use the separate [reviewed authorization update](wec-authorization.md) to change `before.jsonl` is written before each mutation. Review its exact Target/Before/Desired and the results before recovery. For a newly created subscription, it records absence and stores the prepared XML; remove that exact ID only after verifying its current definition still belongs to this run. Existing subscriptions are never edited. For the new SubscriptionManager value, compare the current value with Desired before removing only that value; keep other list entries and parent keys. Restore WSMan values and service start/running states only after verifying their present state and current policy authority. Remove only the newly added group SID after comparing the full membership snapshot; DC membership is never changed by this workflow. For channel restoration, use the channel journal and descriptor-preservation guidance. Recovery is deliberately manual so a newer operator/GPO change is not overwritten. -Safe fixture tests exercise the public command/report, journals, readback failures, occupied slots, explicit authorization, native create failures, configuration drift, DC group protection and blocked prerequisites. Windows PowerShell 5.1/PowerShell 7 CI adds real **read-only** channel, service, WSMan, firewall and ADMX assessment. These tests do not deploy subscriptions or prove forwarding. +Safe fixture tests exercise the public command/report, journals, readback failures, occupied slots, explicit authorization, native create failures, configuration drift, DC group protection and blocked prerequisites. Windows PowerShell 5.1/PowerShell 7 CI adds real **read-only** channel, service, WSMan, firewall and ADMX assessment. Those read-only smoke tests do not deploy subscriptions or prove forwarding. The separate [native observation fixture](wec-collector-observation.md) now exercises public Audit/Plan against one owned disabled subscription on standalone Server 2022/2025 runners, preserving real unmet domain prerequisites and exact fixture cleanup; it does not test domain deployment or delivery. Before closing issue #368, an isolated domain lab must configure a dedicated collector and Windows 11/member-server/DC/AD CS sources, verify source identity/token read access (including any required token/service refresh), preserve runtime status, and demonstrate native events matching each selected query arriving with the expected source identity/timestamps. Include disabled-query, denied-source, absent-channel, GPO refresh, idempotence, drift and recovery cases. Use a deliberately chosen benign native Application/System event or a controlled test account/object relevant to the query; record actual events, not merely a successful command or ACE. Forwarded Sigma coverage remains unassessed until those events and the processing pipeline are validated. diff --git a/modules/WecSubscriptionInventory.cs b/modules/WecSubscriptionInventory.cs new file mode 100644 index 00000000..7c1654a0 --- /dev/null +++ b/modules/WecSubscriptionInventory.cs @@ -0,0 +1,52 @@ +// Read-only WEC names, returned only after complete bounded native enumeration. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +namespace Wela.WecInventory { + public static class Reader { + public const string SourceSha256="__WELA_SOURCE_SHA256__"; + const uint Capacity=1024; + [DllImport("wecapi.dll",SetLastError=true)] static extern IntPtr EcOpenSubscriptionEnum(uint flags); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcEnumNextSubscription(IntPtr enumeration,uint size,IntPtr name,out uint used); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle); + // Public only for safe allocated-buffer ABI and boundary regression tests. + public static string DecodeName(IntPtr buffer,uint used,uint capacity) { + if(buffer==IntPtr.Zero||capacity<2||capacity>Capacity||used<2||used>capacity)throw new InvalidDataException("Invalid native subscription-name buffer."); + if(Marshal.ReadInt16(buffer,checked((int)(used-1)*2))!=0)throw new InvalidDataException("Native subscription name is not terminated."); + byte[] bytes=new byte[checked((int)(used-1)*2)];Marshal.Copy(buffer,bytes,0,bytes.Length); + string name=new UnicodeEncoding(false,false,true).GetString(bytes); + if(name.IndexOf('\0')>=0)throw new InvalidDataException("Native subscription name contains an embedded terminator."); + return name; + } + // Public so duplicate, count and aggregate bounds can be tested without Windows. + public static string[] ValidateNames(string[] names) { + if(names==null||names.Length>4096)throw new InvalidDataException("Native subscription inventory exceeds 4096 entries."); + var unique=new HashSet(StringComparer.OrdinalIgnoreCase);long characters=0; + foreach(string name in names) { + if(String.IsNullOrEmpty(name)||name.Length>=Capacity||name.IndexOf('\0')>=0||!unique.Add(name))throw new InvalidDataException("Native subscription inventory contains an invalid or duplicate name."); + new UnicodeEncoding(false,false,true).GetBytes(name); + characters+=name.Length+1;if(characters>1048576)throw new InvalidDataException("Native subscription inventory exceeds its total character bound."); + } + string[] result=(string[])names.Clone();Array.Sort(result,StringComparer.Ordinal);return result; + } + public static string[] ReadNames() { + IntPtr handle=EcOpenSubscriptionEnum(0);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error()); + try { + IntPtr buffer=Marshal.AllocHGlobal(checked((int)Capacity*2)); + try { + var names=new List();long characters=0; + while(true) { + uint used; + if(!EcEnumNextSubscription(handle,Capacity,buffer,out used)) {int error=Marshal.GetLastWin32Error();if(error==259)return ValidateNames(names.ToArray());throw new Win32Exception(error);} + string name=DecodeName(buffer,used,Capacity);characters+=name.Length+1; + if(names.Count>=4096||characters>1048576)throw new InvalidDataException("Native subscription inventory exceeded its bounds; no absence is established."); + names.Add(name); + } + }finally {Marshal.FreeHGlobal(buffer);} + }finally {EcClose(handle);} + } + } +} diff --git a/modules/WefSubscriptions.psm1 b/modules/WefSubscriptions.psm1 index 7f40d9d6..46ab3492 100644 --- a/modules/WefSubscriptions.psm1 +++ b/modules/WefSubscriptions.psm1 @@ -194,6 +194,27 @@ function Import-WelaWefConfig { [pscustomobject]@{ Config=$config; Path=$full; Subscriptions=$subscriptions } } +function Initialize-WelaWecSubscriptionInventory { + $path=Join-Path $PSScriptRoot 'WecSubscriptionInventory.cs' + $bytes=[IO.File]::ReadAllBytes($path);if($bytes.Length -gt 65536){throw 'Native inventory source exceeds its bound.'} + $sha=[Security.Cryptography.SHA256]::Create();try{$hash=([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()} + if(-not ('Wela.WecInventory.Reader' -as [type])){ + $source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff) + if([regex]::Matches($source,'__WELA_SOURCE_SHA256__').Count -ne 1){throw 'Native inventory source binding marker is missing or ambiguous.'} + $compile=@{TypeDefinition=$source.Replace('__WELA_SOURCE_SHA256__',$hash);ErrorAction='Stop'} + if($PSVersionTable.PSEdition -eq 'Desktop'){$compile.ReferencedAssemblies=@('System.dll','System.Core.dll')} + Add-Type @compile + } + if([Wela.WecInventory.Reader]::SourceSha256 -cne $hash){throw 'Loaded native inventory differs from its source; start a fresh process.'} +} + +function Get-WelaWecSubscriptionIds { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native WEC inventory requires 64-bit Windows.'} + Initialize-WelaWecSubscriptionInventory + # The native method returns nothing until enumeration has completed successfully. + [Wela.WecInventory.Reader]::ReadNames() +} + function Read-WelaWecSubscriptionXml { param([Parameter(Mandatory)][string]$Id) if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Native WEC XML reads require 64-bit Windows.'} @@ -207,4 +228,4 @@ function Read-WelaWecSubscriptionXml { [Wela.WecXml.Reader]::ReadXml($Id) } -Export-ModuleMember -Function ConvertTo-WelaWefFirewallAddressKey, Test-WelaWefFirewallAddressSet, Read-WelaWecSubscriptionXml, Read-WelaWefXml, Get-WelaWefXmlKey, ConvertFrom-WelaWefQuery, Get-WelaWefAuthorization, ConvertFrom-WelaWefSubscription, Import-WelaWefConfig +Export-ModuleMember -Function Get-WelaWecSubscriptionIds, ConvertTo-WelaWefFirewallAddressKey, Test-WelaWefFirewallAddressSet, Read-WelaWecSubscriptionXml, Read-WelaWefXml, Get-WelaWefXmlKey, ConvertFrom-WelaWefQuery, Get-WelaWefAuthorization, ConvertFrom-WelaWefSubscription, Import-WelaWefConfig diff --git a/scripts/WefDeployment.ps1 b/scripts/WefDeployment.ps1 index 39d9597b..bb0f78cc 100644 --- a/scripts/WefDeployment.ps1 +++ b/scripts/WefDeployment.ps1 @@ -30,13 +30,14 @@ function Get-WelaWefControlState { 'SubscriptionManager' { return Get-WelaRegistryState -Path $Target.Path -Name $Target.Name } 'ForwardedEvents' { return Get-WelaNativeChannel -Name 'ForwardedEvents' } 'Subscription' { - $ids = @((Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('es')).Output | ForEach-Object { $_.ToString().Trim() } | Where-Object { $_ }) + $ids = @(Get-WelaWecSubscriptionIds) if ($ids -notcontains $Target.Id) { return [pscustomobject]@{ Exists=$false; Xml=$null; Key=$null; Definition=$null } } # Keep evidence as a plain string. Windows PowerShell 5.1's JSON # serializer expands ETS properties on strings (for example a test # reader's PSDrive/PSProvider graph), unlike modern PowerShell. - $xml = [string]::Concat((Invoke-WelaNative -FilePath 'wecutil.exe' -Arguments @('gs',$Target.Id,'/f:xml')).Diagnostic) + $xml = [string]::Concat((Read-WelaWecSubscriptionXml -Id $Target.Id)) $model = ConvertFrom-WelaWefSubscription -Xml $xml -SourceSids $Target.SourceSids -Observed + if($model.Id -cne $Target.Id){throw 'Native subscription identity differs from the selected ID.'} return [pscustomobject]@{ Exists=$true; Xml=$xml; Key=$model.Key; Definition=$model.Definition } } default { throw "Unsupported WEF control kind: $Kind" } diff --git a/tests/WecCollectorObservation.Windows.Tests.ps1 b/tests/WecCollectorObservation.Windows.Tests.ps1 new file mode 100644 index 00000000..8e2be1f6 --- /dev/null +++ b/tests/WecCollectorObservation.Windows.Tests.ps1 @@ -0,0 +1,99 @@ +param([switch]$AllowDisposableSubscription) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableSubscription -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows subscription opt-in required.'} +$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/ChannelRead.ps1" +$count=0;$engine=(Get-Process -Id $PID).Path +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Services {@(Get-CimInstance Win32_Service -Filter "Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog' OR Name='WinRM'"|Sort-Object Name|Select-Object Name,State,StartMode)} +function Channel {$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{[pscustomobject]@{Name=$c.LogName;Enabled=$c.IsEnabled;Mode=[string]$c.LogMode;MaximumBytes=$c.MaximumSizeInBytes;Path=$c.LogFilePath;SecurityDescriptor=$c.SecurityDescriptor}}finally{$c.Dispose()}} +function Inventory {$ids=@(Get-WelaWecSubscriptionIds);if($ids.Count -gt 64){throw 'Disposable fixture inventory exceeds 64 entries.'};@($ids|ForEach-Object {[pscustomobject]@{Id=$_;Xml=Read-WelaWecSubscriptionXml $_}})} +$hostState=Get-WelaChannelReadHost +Assert ($hostState.Build -in @(20348,26100) -and $hostState.UBR -gt 0 -and $hostState.ProductType -eq 3 -and $hostState.DomainRole -eq 2 -and -not $hostState.DomainJoined) 'Actual patched standalone Server2022/2025 fixture; no invented domain identity' +$nonce=[guid]::NewGuid().ToString('N');$id='WELA-Observe-'+$nonce;$unicode=([string][char]0x65e5)+([string][char]0x672c) +$description='Owned observation '+$nonce+' '+$unicode;$sid='S-1-5-21-111111111-222222222-333333333-1234' +$root=Join-Path $env:RUNNER_TEMP ('wela-wec-observation-'+$nonce);$null=New-Item -ItemType Directory $root +function Save($Name,$Value){$text=ConvertTo-Json -InputObject $Value -Depth 30;[IO.File]::WriteAllText((Join-Path $root $Name),$text,[Text.UTF8Encoding]::new($false))} +$beforeServices=Services;$beforeChannel=Channel;$serviceKey='HKLM:\SYSTEM\CurrentControlSet\Services\Wecsvc';$beforeDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart +$original=$null;$created=$false;$failure=$null;$errors=@();$inventoryOk=$false;$servicesOk=$false;$channelOk=$false;$reports=@() +$sources=[ordered]@{};foreach($p in @('WELA.ps1','scripts/WefDeployment.ps1','modules/WefSubscriptions.psm1','modules/WecSubscriptionInventory.cs','modules/WecSubscriptionXml.cs')){$sources[$p]=(Get-FileHash (Join-Path $repo $p)).Hash.ToLowerInvariant()} +Save 'before-fixture.json' @{Host=$hostState;Services=$beforeServices;Channel=$beforeChannel;DelayedAutoStart=$beforeDelayed;Sources=$sources} +$config=Get-Content "$repo/config/wef-examples/collector.json" -Raw|ConvertFrom-Json +# Existing Audit/Plan deliberately remain incomplete on this real standalone runner. +# The example collector identity is never resolved, contacted or asserted as local. +$config.SourceSids=@($sid);$config.SubscriptionFiles=@('requested.xml');$config.IngressRuleName='WELA-Absent-'+$nonce +$path=Join-Path $root 'requested.xml';$configPath=Join-Path $root 'collector.json';Save 'collector.json' $config +$query='' +$xml=@" +$idSourceInitiated$descriptionfalsehttp://schemas.microsoft.com/wbem/wsman/1/windows/EventLogNormalfalseHTTPEventsForwardedEvents +"@ +[IO.File]::WriteAllText($path,$xml,[Text.UTF8Encoding]::new($false)) +function Public([string]$Action,[string]$Name){ + $out=Join-Path $root ($Name+'.json');$prior=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" wec-collector -WefAction $Action -WefConfigPath $configPath -ResultsPath $out 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + [IO.File]::WriteAllText((Join-Path $root ($Name+'.log')),($text -join "`n"),[Text.UTF8Encoding]::new($false)) + Assert ($code -eq 1 -and (Test-Path $out)) 'Public collector reports incomplete real standalone prerequisites with exit1' + $r=Get-Content -LiteralPath $out -Raw -Encoding UTF8|ConvertFrom-Json + Assert ($r.Action -ceq $Action -and $r.Role -ceq 'Collector' -and $r.LocalConfigurationStatus -ceq 'Incomplete' -and -not $r.HostIdentity.DomainJoined) 'Public report preserves actual role and incomplete domain prerequisites' + Assert ($r.Subscriptions.Count -eq 1 -and $r.Subscriptions[0].Id -ceq $id -and $r.Subscriptions[0].EventArrival -ceq 'Not tested' -and $r.Subscriptions[0].ForwardedSigmaCoverage -ceq 'Not assessed' -and $r.Subscriptions[0].ChannelObservationLocation -like 'Collector only*') 'No source authentication, remote channel or forwarded coverage claim' + $script:reports+=($Name+'.json');$r +} +function SubscriptionControl($Report){@($Report.Controls|Where-Object Kind -eq Subscription)[0]} +try { + $wec=@($beforeServices|Where-Object Name -eq Wecsvc);Assert ($wec.Count -eq 1 -and $wec[0].State -in @('Running','Stopped') -and $wec[0].StartMode -in @('Auto','Manual','Disabled')) 'Stable original collector service state required' + if($wec[0].StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Manual};if($wec[0].State -eq 'Stopped'){Start-Service Wecsvc} + $original=@(Inventory);Save 'original-inventory.json' $original;Assert (@(Get-WelaWecSubscriptionIds) -notcontains $id) 'Unique owned subscription initially absent' + Save 'console-enumeration-before.json' (Invoke-WelaNative 'wecutil.exe' @('es')) + $duringServices=Services + $absent=Public Audit 'absent-before' + Assert ($absent.Subscriptions[0].ObservedSubscription.Exists -eq $false -and $null -eq $absent.Subscriptions[0].ObservedEnabled -and -not $absent.Subscriptions[0].ObservationError -and (SubscriptionControl $absent).Status -ceq 'ChangeRequired') 'Complete native enumeration establishes selected absence' + $model=Import-WelaWefConfig $configPath Collector;$ownedPath=Join-Path $root 'owned.xml';[IO.File]::WriteAllText($ownedPath,$model.Subscriptions[0].Xml,[Text.UTF8Encoding]::new($false)) + $created=$true;$null=Invoke-WelaNative 'wecutil.exe' @('cs',$ownedPath) + $before=Read-WelaWecSubscriptionXml $id;[IO.File]::WriteAllText((Join-Path $root 'original-owned.xml'),$before,[Text.UTF8Encoding]::new($false)) + Assert (@(Get-WelaWecSubscriptionIds) -ccontains $id) 'Actual native enumeration returns exact owned ID' + foreach($action in @('Audit','Plan')){ + $r=Public $action ('present-'+$action.ToLowerInvariant());$o=$r.Subscriptions[0] + Assert ($o.ObservedSubscription.Exists -and $o.ObservedEnabled -eq $false -and -not $o.ObservationError -and (SubscriptionControl $r).Status -ceq 'RequestedSettingsMatch') 'Existing disabled native definition is observed and matched' + Assert ($o.ObservedSubscription.Xml -ceq $before -and $o.ObservedSubscription.Definition.Description -ceq $description -and $o.Filters[0].XPath -ceq ('*[System[(EventID=1)] and EventData[Data='''+$unicode+''']]')) 'Public JSON preserves exact Unicode native XML, description and selected XPath' + Assert ((Read-WelaWecSubscriptionXml $id) -ceq $before) 'Public Audit/Plan does not save or alter existing subscription' + } + [IO.File]::WriteAllText($path,$xml.Replace('false','true'),[Text.UTF8Encoding]::new($false)) + $r=Public Plan 'requested-enabled' + Assert ($r.Subscriptions[0].RequestedEnabled -and $r.Subscriptions[0].ObservedEnabled -eq $false -and (SubscriptionControl $r).Status -ceq 'ManualReview') 'Actual disabled state is not replaced with requested enabled state' + [IO.File]::WriteAllText($path,$xml,[Text.UTF8Encoding]::new($false)) + try { + $null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+($description+' drift'))) + $r=Public Audit 'description-drift' + Assert ((SubscriptionControl $r).Status -ceq 'ManualReview' -and $r.Subscriptions[0].ObservedSubscription.Definition.Description -ceq ($description+' drift')) 'Native Unicode drift is retained and does not become a match' + }finally{$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+$description))} + $config.SourceSids=@($sid.Replace('-1234','-1235'));Save 'collector.json' $config + $r=Public Audit 'authorization-mismatch' + Assert ((SubscriptionControl $r).Status -ceq 'Unknown' -and $null -eq $r.Subscriptions[0].ObservedSubscription -and $null -eq $r.Subscriptions[0].ObservedEnabled -and $r.Subscriptions[0].ObservationError) 'Unsupported observed authorization remains unknown, never absent' + $config.SourceSids=@($sid);Save 'collector.json' $config + Assert ((Read-WelaWecSubscriptionXml $id) -ceq $before) 'All public observations and fixture drift restoration preserve original raw XML' + [IO.File]::WriteAllText((Join-Path $root 'restored-owned.xml'),(Read-WelaWecSubscriptionXml $id),[Text.UTF8Encoding]::new($false)) + $null=Invoke-WelaNative 'wecutil.exe' @('ds',$id);$created=$false + $r=Public Audit 'absent-after';Assert ($r.Subscriptions[0].ObservedSubscription.Exists -eq $false -and -not $r.Subscriptions[0].ObservationError) 'Actual removed owned subscription returns confirmed absence' + Assert ((Key (Services)) -ceq (Key $duringServices) -and (Key (Channel)) -ceq (Key $beforeChannel)) 'Read-only public commands preserve services and complete destination configuration' + Write-Host "PASS: $count actual collector observation assertions on $($PSVersionTable.PSVersion). No domain/forwarding proof." +}catch{$failure=$_.ToString();Write-Host $failure}finally{ + try { + if($created -and @(Get-WelaWecSubscriptionIds) -contains $id){$raw=Read-WelaWecSubscriptionXml $id;$doc=Read-WelaWefXml $raw;if($doc.Subscription.Description -cne $description -and $doc.Subscription.Description -cne ($description+' drift')){throw 'Fixture ownership differs; do not delete subscription.'};$null=Invoke-WelaNative 'wecutil.exe' @('ds',$id)} + $restored=@(Inventory);Save 'restored-inventory.json' $restored;$inventoryOk=$null -ne $original -and (Key $restored) -ceq (Key $original) + }catch{$errors+=$_.ToString()} + try{$channelOk=(Key (Channel)) -ceq (Key $beforeChannel)}catch{$errors+=$_.ToString()} + try { + $wec=@($beforeServices|Where-Object Name -eq Wecsvc)[0] + if($wec.State -eq 'Stopped' -and (Get-Service Wecsvc).Status -ne 'Stopped'){Stop-Service Wecsvc} + if($wec.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Disabled} + if((Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){if($beforeDelayed.ValueExists){$null=New-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -Value $beforeDelayed.Value -PropertyType $beforeDelayed.Type -Force}else{Remove-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -ErrorAction Stop}} + $servicesOk=(Key (Services)) -ceq (Key $beforeServices) -and (Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -ceq (Key $beforeDelayed) + }catch{$errors+=$_.ToString()} + $artifacts=@(Get-ChildItem $root -File|ForEach-Object {[pscustomobject]@{Name=$_.Name;Bytes=$_.Length;Sha256=(Get-FileHash $_.FullName).Hash.ToLowerInvariant()}}) + Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$errors;SubscriptionsRestored=$inventoryOk;ServicesRestored=$servicesOk;ChannelPreserved=$channelOk;Complete=($inventoryOk -and $servicesOk -and $channelOk -and -not $errors.Count);Assertions=$count;Engine=$PSVersionTable.PSVersion.ToString();Host=$hostState;Sources=$sources;Artifacts=$artifacts;PublicReports=$reports;Scope='Native local collector observation only; real standalone prerequisites remain incomplete.'} +} +if($failure -or -not $inventoryOk -or -not $servicesOk -or -not $channelOk -or $errors.Count){throw "Native observation or fixture cleanup failed; inspect $root"} +exit 0 diff --git a/tests/WecSubscriptionInventory.Tests.ps1 b/tests/WecSubscriptionInventory.Tests.ps1 new file mode 100644 index 00000000..87e04847 --- /dev/null +++ b/tests/WecSubscriptionInventory.Tests.ps1 @@ -0,0 +1,34 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +& (Get-Module WefSubscriptions) {Initialize-WelaWecSubscriptionInventory} +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Reject([scriptblock]$Action){$failed=$false;try{&$Action|Out-Null}catch{$failed=$true};Assert $failed 'Malformed, duplicate, partial or oversized native inventory must be rejected'} +Assert ([Wela.WecInventory.Reader]::SourceSha256 -ceq (Get-FileHash "$repo/modules/WecSubscriptionInventory.cs").Hash.ToLowerInvariant()) 'Loaded inventory binds exact source bytes' +Assert ([Wela.WecInventory.Reader]::ValidateNames([string[]]@()).Length -eq 0) 'Completed empty inventory is distinct from an error' +$unicode='Name '+[char]0x65e5+[char]0x672c +$names=[string[]]@('z',$unicode,'A',' leading ',([string][char]0xfeff)) +$observed=[Wela.WecInventory.Reader]::ValidateNames($names) +Assert ($observed.Length -eq 5 -and $observed -ccontains $unicode -and $observed -ccontains ' leading ' -and $observed -ccontains ([string][char]0xfeff)) 'Actual Unicode and whitespace names are retained, never console-trimmed' +Assert ($names[0] -ceq 'z') 'Validation does not mutate caller inventory' +Reject {[Wela.WecInventory.Reader]::ValidateNames($null)} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@('same','SAME'))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(''))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@("ab`0cd"))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@('x'*1024))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@([string][char]0xd800))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(1..4097|ForEach-Object {"id-$_"}))} +Reject {[Wela.WecInventory.Reader]::ValidateNames([string[]]@(1..1025|ForEach-Object {$prefix=[string]$_;$prefix+('x'*(1023-$prefix.Length))}))} +$buffer=[Runtime.InteropServices.Marshal]::AllocHGlobal(64) +try { + for($i=0;$i -lt 64;$i++){[Runtime.InteropServices.Marshal]::WriteByte($buffer,$i,0)} + $bytes=[Text.Encoding]::Unicode.GetBytes($unicode+[char]0);[Runtime.InteropServices.Marshal]::Copy($bytes,0,$buffer,$bytes.Length) + Assert ([Wela.WecInventory.Reader]::DecodeName($buffer,($unicode.Length+1),32) -ceq $unicode) 'Native used length counts UTF16 characters including terminator' + foreach($used in @(0,1,33)){Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,$used,32)}} + Reject {[Wela.WecInventory.Reader]::DecodeName([IntPtr]::Zero,2,32)} + Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,2,1025)} + Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,$unicode.Length,32)} + [Runtime.InteropServices.Marshal]::WriteInt16($buffer,2,0);Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,($unicode.Length+1),32)} + [Runtime.InteropServices.Marshal]::WriteInt16($buffer,0,[int16]-10240);Reject {[Wela.WecInventory.Reader]::DecodeName($buffer,2,32)} +}finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)} +Write-Host "PASS: $count native subscription inventory buffer/boundary assertions." diff --git a/tests/WefDeployment.Tests.ps1 b/tests/WefDeployment.Tests.ps1 index fba71b03..95b8509b 100644 --- a/tests/WefDeployment.Tests.ps1 +++ b/tests/WefDeployment.Tests.ps1 @@ -80,6 +80,15 @@ function Get-NetFirewallRule { param($Name,$PolicyStore) Assert ($PolicyStore -e function Get-NetFirewallPortFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ Protocol='TCP'; LocalPort='5985'; RemotePort='Any' } } } function Get-NetFirewallAddressFilter { [CmdletBinding()]param([Parameter(ValueFromPipeline)]$Rule) process { [pscustomobject]@{ LocalAddress=@('192.0.2.10/255.255.255.255'); RemoteAddress=@('192.0.2.0/255.255.255.0') } } } function Read-Host { param($Prompt) return $global:WelaWefFixture.Prompt } +function Get-WelaWecSubscriptionIds { + if($global:WelaWefFixture.Fail -eq 'Inventory'){throw 'Incomplete native inventory'} + @($global:WelaWefFixture.Subs.Keys) +} +function Read-WelaWecSubscriptionXml { + param($Id) + if($global:WelaWefFixture.Fail -eq 'ReadXml' -or -not $global:WelaWefFixture.Subs.ContainsKey($Id)){throw 'Native definition is no longer readable'} + $global:WelaWefFixture.Subs[$Id] +} function Invoke-WelaNative { param($FilePath,$Arguments) $f=$global:WelaWefFixture @@ -90,8 +99,7 @@ function Invoke-WelaNative { } Assert ($FilePath -eq 'wecutil.exe') 'Only native wecutil subscription API is called' switch ($Arguments[0]) { - 'es' { return [pscustomobject]@{ ExitCode=0; Output=@($f.Subs.Keys); Diagnostic=(@($f.Subs.Keys) -join "`n") } } - 'gs' { if (-not $f.Subs.ContainsKey($Arguments[1])) { throw 'No fixture subscription' }; return [pscustomobject]@{ ExitCode=0; Output=@($f.Subs[$Arguments[1]]); Diagnostic=$f.Subs[$Arguments[1]] } } + {$_ -in @('es','gs')} {throw 'Subscription inventory and XML must bypass console decoding.'} 'gr' { return [pscustomobject]@{ ExitCode=0; Output=@('Localized runtime fixture'); Diagnostic='Localized runtime fixture' } } 'cs' { Record-Write Subscription $Arguments @@ -215,6 +223,22 @@ try { Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'An existing disabled/different subscription is never silently updated' Assert ($report.Subscriptions[0].RequestedEnabled -and $report.Subscriptions[0].ObservedEnabled -eq $false) 'Inventory distinguishes an observed disabled subscription from the requested enabled definition' Reset-Fixture + foreach($failure in @('Inventory','ReadXml')) { + Reset-Fixture + $model=Import-WelaWefConfig (Join-Path $temp 'collector.json') Collector + $global:WelaWefFixture.Subs[$model.Subscriptions[0].Id]=$model.Subscriptions[0].Xml + $global:WelaWefFixture.Fail=$failure + $report=Invoke-Collector + Assert ($report.ExitCode -eq 1 -and $global:WelaWefFixture.Writes.Count -eq 0) 'Incomplete enumeration or disappearing/unreadable XML cannot authorize creation' + Assert ($null -eq $report.Subscriptions[0].ObservedSubscription -and $null -eq $report.Subscriptions[0].ObservedEnabled -and $report.Subscriptions[0].ObservationError) 'Read failure stays unknown rather than absent or disabled' + Assert (@($report.Controls|Where-Object {$_.Kind -eq 'Subscription' -and $_.Status -eq 'Unknown'}).Count -eq 1) 'Partial observation remains an unknown control' + } + Reset-Fixture + $model=Import-WelaWefConfig (Join-Path $temp 'collector.json') Collector + $global:WelaWefFixture.Subs[$model.Subscriptions[0].Id]=$model.Subscriptions[0].Xml.Replace($model.Subscriptions[0].Id,'Different native ID') + $report=Invoke-Collector + Assert ($report.ExitCode -eq 1 -and $report.Subscriptions[0].ObservationError -match 'identity differs' -and $global:WelaWefFixture.Writes.Count -eq 0) 'Mismatched native XML identity cannot become selected subscription evidence' + Reset-Fixture $global:WelaWefFixture.Fail='false-subscription' $report=Invoke-Collector Assert ($report.ExitCode -eq 1) 'A successful native exit without matching subscription readback fails' diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index b4f75fc6..55417124 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- コレクターのサブスクリプション観測で、コンソール経由の文字変換を、上限付きの完全なネイティブ名前列挙と厳密なUnicode XML読取に置き換えました。空の一覧・読取失敗・実際の無効状態を区別し、Unicodeの説明とXPathを保持します。Server 2022/2025と両PowerShellで公開Audit/Planおよび正確な後処理を検証し、ドメイン展開・転送・Sigma対応は主張しません。(関連 #368) (@Shirofune-Security) + - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) - Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 1ba91895..61846c33 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Fixed collector subscription observations to use complete bounded native name enumeration and strict Unicode XML reads instead of console decoding. Empty inventories, failed reads and actual disabled state remain distinct; Unicode descriptions and XPath are preserved. Disposable public Audit/Plan tests cover both Server 2022/2025 and PowerShell engines with exact cleanup, without domain deployment, forwarding or Sigma claims. (Related #368) (@Shirofune-Security) + - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) - Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security) From 0777a5d0f80e2781b606f4259617135dfcf85a56 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:09:37 +0900 Subject: [PATCH 04/10] Add scoped outgoing NTLM audit configuration with native acceptance --- .github/workflows/release.yml | 2 +- .github/workflows/scoped-outgoing-ntlm.yml | 47 +++++++++++++ CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 18 ++++- docs/outgoing-ntlm.md | 22 +++++++ scripts/Configuration.ps1 | 2 +- scripts/OutgoingNtlmAudit.ps1 | 77 ++++++++++++++++++++++ tests/OutgoingNtlmAudit.Cli.Tests.ps1 | 18 +++++ tests/OutgoingNtlmAudit.Tests.ps1 | 58 ++++++++++++++++ tests/OutgoingNtlmAudit.Windows.Tests.ps1 | 76 +++++++++++++++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 13 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/scoped-outgoing-ntlm.yml create mode 100644 docs/outgoing-ntlm.md create mode 100644 scripts/OutgoingNtlmAudit.ps1 create mode 100644 tests/OutgoingNtlmAudit.Cli.Tests.ps1 create mode 100644 tests/OutgoingNtlmAudit.Tests.ps1 create mode 100644 tests/OutgoingNtlmAudit.Windows.Tests.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e0460fa..c76e9667 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/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.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/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.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/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.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/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/.github/workflows/scoped-outgoing-ntlm.yml b/.github/workflows/scoped-outgoing-ntlm.yml new file mode 100644 index 00000000..2353c399 --- /dev/null +++ b/.github/workflows/scoped-outgoing-ntlm.yml @@ -0,0 +1,47 @@ +name: Scoped outgoing NTLM auditing +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + scoped-outgoing-ntlm: + 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/OutgoingNtlmAudit.Tests.ps1 + ./tests/OutgoingNtlmAudit.Cli.Tests.ps1 + - name: Scoped outgoing NTLM auditing in Windows PowerShell5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/OutgoingNtlmAudit.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Fixtures and public guards in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/OutgoingNtlmAudit.Tests.ps1 + ./tests/OutgoingNtlmAudit.Cli.Tests.ps1 + - name: Scoped outgoing NTLM auditing in PowerShell7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/OutgoingNtlmAudit.Windows.Tests.ps1 -AllowDisposableAuditWrite + - name: Retain owned fixture evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: scoped-outgoing-ntlm-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-outgoing-audit-*/ + if-no-files-found: warn + retention-days: 7 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index ef9b8bad..a58bfd14 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- `outgoing-ntlm` のAudit/Plan/Configureを追加し、送信NTLM監査DWORDを個別に設定できます。既存の拒否設定は既定で維持し、置換には明示的なAuditを要求します。不明な型・値や書込直前の変化を拒否し、元の型付き記録と再読取を保持します。Server 2022/2025のテストで範囲と復元を確認し、認証・イベント生成は未検証として報告します。(関連 #362) (@Shirofune-Security) + - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) - Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d66a87..d59feaf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Add `outgoing-ntlm` Audit/Plan/Configure to manage the outgoing audit DWORD independently of broad configuration. Preserve existing deny by default, require explicit Audit to replace it, refuse unknown types/values and pre-write drift, and retain typed original journals plus native readback. Native Server 2022/2025 tests verify narrow scope and exact cleanup; authentication/event generation remain unverified. (Related #362) (@Shirofune-Security) + - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) - Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 7cbe077d..6e51edb3 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -16,6 +16,7 @@ [switch]$Auto, [ValidateSet("PreserveOrAudit", "Audit", "Deny")] [string]$OutgoingNtlmMode = "PreserveOrAudit", + [ValidateSet("Audit","Plan","Configure")][string]$NtlmAction = "Audit", [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath, @@ -225,6 +226,7 @@ $EidMappingPath = Join-Path $ScriptRoot "config/eid_subcategory_mapping.csv" $AuditpolTxtPath = Join-Path $ScriptRoot "auditpol.txt" $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/Configuration.ps1") +. (Join-Path $ScriptRoot "scripts/OutgoingNtlmAudit.ps1") . (Join-Path $ScriptRoot "scripts/AdcsAuditing.ps1") . (Join-Path $ScriptRoot "scripts/AdcsRestartResume.ps1") . (Join-Path $ScriptRoot "scripts/AuditIntegrity.ps1") @@ -2064,6 +2066,7 @@ Usage: ./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write ./WELA.ps1 wec-listener -Help # Review one fixed-address native HTTP5985 listener ./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation + ./WELA.ps1 outgoing-ntlm -Help # Configure outgoing NTLM auditing independently ./WELA.ps1 wec-authorization -Help # Review source SID authorization on a disabled subscription ./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 @@ -2161,6 +2164,12 @@ if ($Cmd -ne 'wec-listener' -and @($PSBoundParameters.Keys | Where-Object {$_ -l if ($Cmd -eq 'wec-listener' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecListenerAction','WecListenerComputerName','WecListenerLocalAddress','WecListenerPlanPath','WecListenerPlanHash','WecListenerOutputPath','Help')}).Count)) {throw 'wec-listener 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 'outgoing-ntlm' -and $PSBoundParameters.ContainsKey('NtlmAction')) {throw 'NtlmAction requires outgoing-ntlm.'} +if ($Cmd -eq 'outgoing-ntlm') { + if (@($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','NtlmAction','OutgoingNtlmMode','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count) {throw 'outgoing-ntlm accepts only its dedicated options.'} + if ($OutgoingNtlmMode -eq 'Deny') {throw 'outgoing-ntlm configures auditing only; Deny enforcement is not accepted.'} + if ($NtlmAction -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require NtlmAction Configure.'} +} if ($Cmd -ne 'wec-authorization' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecAuthorization*'}).Count) {throw 'WecAuthorization options require wec-authorization.'} if ($Cmd -eq 'wec-authorization' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecAuthorizationAction','WecAuthorizationId','WecAuthorizationSourceSid','WecAuthorizationPlanPath','WecAuthorizationPlanHash','WecAuthorizationOutputPath','Help')}).Count)) {throw 'wec-authorization accepts only dedicated options.'} if ($Cmd -ne 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecState*'}).Count) {throw 'WecState options require wec-state.'} @@ -2249,7 +2258,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object { }).Count) { throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.' } -if ($DryRun -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and +if ($DryRun -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and -not ($Cmd -eq 'firewall-recovery' -and $FirewallRecoveryAction -eq 'Restore') -and @@ -2428,6 +2437,13 @@ switch ($Cmd.ToLower()) { $report=Invoke-WelaWecIngress @arguments;$report if($report.ExitCode){exit $report.ExitCode} } + 'outgoing-ntlm' { + if ($Help) {Write-Host 'Usage: outgoing-ntlm [-NtlmAction Audit|Plan|Configure] [-OutgoingNtlmMode PreserveOrAudit|Audit] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath report.json]. Changes only the outgoing audit DWORD. Existing deny is preserved by default; explicit Audit authorizes replacing it. See docs/outgoing-ntlm.md.';return} + if ($NtlmAction -eq 'Configure' -and -not (TestAdministrator)) {throw 'Outgoing NTLM configuration requires Administrator privileges.'} + $report=Invoke-WelaOutgoingAuditCommand -Action $NtlmAction -Mode $OutgoingNtlmMode -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + $report + if ($report.ExitCode) {exit $report.ExitCode} + } 'wec-authorization' { if ($Help) {Write-Host 'Usage: wec-authorization [-WecAuthorizationAction Plan] -WecAuthorizationId ID -WecAuthorizationSourceSid desired-SID1,desired-SID2 -WecAuthorizationOutputPath new-directory; then Apply with -WecAuthorizationPlanPath plan.json -WecAuthorizationPlanHash SHA256 -WecAuthorizationOutputPath new-directory. Only the explicit source SID authorization of one already disabled subscription. No SID resolution or forwarding proof. See docs/wec-authorization.md.';return} $arguments=@{Action=$WecAuthorizationAction;OutputPath=$WecAuthorizationOutputPath} diff --git a/docs/outgoing-ntlm.md b/docs/outgoing-ntlm.md new file mode 100644 index 00000000..8ba701c7 --- /dev/null +++ b/docs/outgoing-ntlm.md @@ -0,0 +1,22 @@ +# Scoped outgoing NTLM auditing + +`outgoing-ntlm` audits or configures only `HKLM\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0\RestrictSendingNTLMTraffic`. The separate broad `configure` workflow retains its existing behavior. Use elevated 64-bit PowerShell for Configure on reviewed Windows 11 builds (22000/22621/22631/26100/26200) or Server 2022/2025 (20348/26100); observed role/build and the existing key are required. Role overrides are refused. + +```powershell +./WELA.ps1 outgoing-ntlm -NtlmAction Audit -ResultsPath audit.json +./WELA.ps1 outgoing-ntlm -NtlmAction Plan -ResultsPath plan.json +./WELA.ps1 outgoing-ntlm -NtlmAction Configure -DryRun -ResultsPath preview.json +./WELA.ps1 outgoing-ntlm -NtlmAction Configure -Auto -BackupPath ./before -ResultsPath result.json +# Explicitly replace a previously reviewed Deny all value with auditing: +./WELA.ps1 outgoing-ntlm -NtlmAction Configure -OutgoingNtlmMode Audit -BackupPath ./before-reviewed -ResultsPath reviewed.json +``` + +The default `PreserveOrAudit` mode sets only DWORD **1 (Audit all)** when absent or DWORD0. Existing DWORD1 is already compliant. Existing DWORD **2 (Deny all)** is reported as `PreservedEnforcement` and skipped; exit0 for this preserved case does not mean auditing was enabled. Explicit `Audit` authorizes replacing a known DWORD2 with1. Unknown types/values fail without writes in either mode. `Deny` is refused by this scoped command. It never changes incoming/domain NTLM policy, exceptions, audit subcategories, channel settings, services, or authentication restrictions other than the explicit conversion of a known outgoing deny to audit. + +Plan is a live read-only assessment, not an importable authorization file. Audit/Plan reject mutation options. Configure re-reads the actual host and typed value, journals before mutation, refuses pre-write drift, and verifies immediate/final readback. A race after the final pre-write read remains possible; these observations are not atomic with GPO or another administrator. RSoP is explicitly last-applied and potentially stale, never proof of the current registry writer. Skipped, Failed and Overridden results remain distinct. No automatic rollback occurs. + +For manual recovery, inspect the selected successful result and its original `before.jsonl` entry. The original typed registry state is `Before.Policy`; preserve current policy ownership and review drift before restoring that one value/type or removing that value if it was originally absent. Never remove the parent MSV1_0 key or replay another journal kind. Failed/partial attempts require individual inspection. Keep the original journal and result together. + +Native acceptance uses disposable unjoined Server2022/2025 hosts under PowerShell5.1/7, exercises actual absence/allow→audit, original journals, dry run, repeat, readback and exact cleanup. Existing enforcement and malformed values are never installed on a native runner merely for testing; portable regressions verify those preservation/refusal paths, prompt-time drift and failures. Native tests preserve incoming/domain policy, siblings/access descriptor, channels, service and all59 audit masks. Windows11/DC/ADCS acceptance, authentication behavior, representative NTLM events, GPO persistence and collector delivery remain separate work for #362. No Sigma credit is inferred. Built-in Windows only; Sysmon is excluded. + +Microsoft distinguishes outgoing audit from deny, describes GPO precedence and identifies the NTLM Operational log for validation: [outgoing NTLM policy](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/network-security-restrict-ntlm-outgoing-ntlm-traffic-to-remote-servers). diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index 6e5297fa..c44d2de1 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -108,7 +108,7 @@ function Invoke-WelaConfigurationControl { function Complete-WelaConfiguration { param($Context, [string]$ResultsPath, $Plan, - [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] + [ValidateSet("native-windows-configuration", "outgoing-ntlm-audit-policy-only", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")] [string]$Scope = "native-windows-configuration", [string]$SuccessMessage = 'Configuration completed; all requested controls verified.') if ($Context.PSObject.Properties['CustomProfileGuard']) { diff --git a/scripts/OutgoingNtlmAudit.ps1 b/scripts/OutgoingNtlmAudit.ps1 new file mode 100644 index 00000000..4bbe8e67 --- /dev/null +++ b/scripts/OutgoingNtlmAudit.ps1 @@ -0,0 +1,77 @@ +# Explicit outgoing audit policy; does not invoke the broad configure workflow. +function Get-WelaOutgoingAuditSnapshot { + if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) { throw 'Use 64-bit PowerShell on Windows.' } + $os=Get-CimInstance Win32_OperatingSystem -Property BuildNumber,ProductType -ErrorAction Stop + $computer=Get-CimInstance Win32_ComputerSystem -Property DomainRole,PartOfDomain -ErrorAction Stop + $build=[int]$os.BuildNumber;$product=[int]$os.ProductType + if (-not (($product -eq 1 -and $build -in @(22000,22621,22631,26100,26200)) -or ($product -in @(2,3) -and $build -in @(20348,26100)))) { throw 'This Windows role/build has not been reviewed for the scoped command.' } + if ($computer.DomainRole -notin @(0,1,2,3,4,5) -or $computer.PartOfDomain -isnot [bool]) {throw 'Computer role/join context is unavailable.'} + $policy=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' RestrictSendingNTLMTraffic + if (-not $policy.KeyExists) {throw 'The existing MSV1_0 policy key is required; no parent key will be created.'} + [pscustomobject][ordered]@{Host=[pscustomobject][ordered]@{Build=$build;ProductType=$product;DomainRole=[int]$computer.DomainRole;PartOfDomain=$computer.PartOfDomain};Policy=$policy} +} + +function Get-WelaOutgoingAuditDisposition { + param($Snapshot,[ValidateSet('PreserveOrAudit','Audit')][string]$Mode) + $p=$Snapshot.Policy + if ($p.ValueExists -and ($p.Type -cne 'DWord' -or $p.Value -notin @(0,1,2))) {return 'Unknown'} + if ($p.ValueExists -and $p.Value -eq 1) {return 'AlreadyCompliant'} + if ($p.ValueExists -and $p.Value -eq 2 -and $Mode -eq 'PreserveOrAudit') {return 'PreservedEnforcement'} + return 'ChangeRequired' +} + +function Get-WelaOutgoingAuditPlan { + param([ValidateSet('PreserveOrAudit','Audit')][string]$Mode='PreserveOrAudit') + try { + $snapshot=Get-WelaOutgoingAuditSnapshot + $status=Get-WelaOutgoingAuditDisposition $snapshot $Mode + $diagnostic=switch($status){ + Unknown {'Unknown registry type/value is preserved; investigate it before configuration.'} + PreservedEnforcement {'Deny all (2) is authentication enforcement, preserved by default. Explicit -OutgoingNtlmMode Audit authorizes replacing it with Audit all (1).'} + AlreadyCompliant {'Audit all (1) is configured; authentication, events and policy persistence are unverified.'} + default {'Set only outgoing NTLM Audit all (DWORD 1).'} + } + [pscustomobject]@{Status=$status;Mode=$Mode;Desired=1;Before=$snapshot;Diagnostic=$diagnostic;PolicySource=Get-WelaOutgoingNtlmPolicySource} + }catch{[pscustomobject]@{Status='Unknown';Mode=$Mode;Desired=1;Before=$null;Diagnostic=$_.ToString();PolicySource='Unknown'}} +} + +function Invoke-WelaOutgoingAuditCommand { + param([ValidateSet('Audit','Plan','Configure')][string]$Action='Audit',[ValidateSet('PreserveOrAudit','Audit')][string]$Mode='PreserveOrAudit',[switch]$Auto,[switch]$DryRun,[string]$BackupPath,[string]$ResultsPath) + if ($Action -ne 'Configure' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Consent, dry-run and backup options require Configure.'} + $plan=Get-WelaOutgoingAuditPlan $Mode + if ($Action -eq 'Configure') { + $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + $path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0';$name='RestrictSendingNTLMTraffic' + if ($plan.Status -eq 'Unknown') { + $context.Results.Add([pscustomobject]@{Id="Registry/$path/$name";Kind='Registry';Target=@{Path=$path;Name=$name};Desired=@{Value=1;Type='DWord'};Before=$plan.Before;After=$null;Status='Failed';Diagnostic=$plan.Diagnostic}) + }else{ + $state=@{Observed=$null;PlannedHost=($plan.Before.Host|ConvertTo-Json -Compress);Mode=$Mode;Path=$path;Name=$name} + $read={param($s) + $snapshot=Get-WelaOutgoingAuditSnapshot + if (($snapshot.Host|ConvertTo-Json -Compress) -cne $s.PlannedHost) {throw 'Observed host context changed; review a new plan.'} + if ((Get-WelaOutgoingAuditDisposition $snapshot $s.Mode) -eq 'Unknown') {throw 'Unknown registry type/value is preserved.'} + $s.Observed=$snapshot + return $snapshot + } + $test={param($snapshot) $snapshot.Policy.ValueExists -and $snapshot.Policy.Type -ceq 'DWord' -and $snapshot.Policy.Value -eq 1} + $preserve=if($Mode -eq 'PreserveOrAudit'){{param($snapshot) if($snapshot.Policy.ValueExists -and $snapshot.Policy.Type -ceq 'DWord' -and $snapshot.Policy.Value -eq 2){'Preserved Deny all enforcement; explicit Audit mode is required to replace it.'}}}else{$null} + $apply={param($s) + $fresh=Get-WelaOutgoingAuditSnapshot + if (($fresh|ConvertTo-Json -Depth 8 -Compress) -cne ($s.Observed|ConvertTo-Json -Depth 8 -Compress)) {throw 'Outgoing NTLM state changed after the original journal snapshot; no write was attempted.'} + if ((Get-WelaOutgoingAuditDisposition $fresh $s.Mode) -ne 'ChangeRequired') {throw 'The current state no longer authorizes this write.'} + Set-ItemProperty -LiteralPath $s.Path -Name $s.Name -Value 1 -Type DWord -ErrorAction Stop + 'Only outgoing NTLM Audit all (1) was requested; no authentication or event-generation test was performed.' + } + Invoke-WelaConfigurationControl -Context $context -Id "Registry/$path/$name" -Kind Registry -Target @{Path=$path;Name=$name} -Desired @{Value=1;Type='DWord'} -Read $read -Compliant $test -PreserveWhen $preserve -Apply $apply -CallbackState $state -Description $plan.Diagnostic + } + $report=Complete-WelaConfiguration -Context $context -Scope 'outgoing-ntlm-audit-policy-only' -SuccessMessage 'Outgoing NTLM configuration results recorded; inspect preserved/skipped controls separately.' + $report|Add-Member NoteProperty Plan $plan + }else{$report=[pscustomobject]@{ExitCode=$(if($plan.Status -eq 'Unknown'){1}else{0});Scope='outgoing-ntlm-audit-policy-only';Action=$Action;Plan=$plan}} + $report|Add-Member NoteProperty EventGeneration 'Not verified; registry compliance does not establish authentication, NTLM events, forwarding, GPO persistence or Sigma readiness.' + $report|Add-Member NoteProperty ReadyRuleCredit 0 + if ($ResultsPath) { + try {$report|ConvertTo-Json -Depth 16|Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop} + catch {$report.ExitCode=1;Write-Host "[Failed] Writing outgoing NTLM results: $_" -ForegroundColor Red} + } + return $report +} diff --git a/tests/OutgoingNtlmAudit.Cli.Tests.ps1 b/tests/OutgoingNtlmAudit.Cli.Tests.ps1 new file mode 100644 index 00000000..c8195578 --- /dev/null +++ b/tests/OutgoingNtlmAudit.Cli.Tests.ps1 @@ -0,0 +1,18 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0 +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-cli-'+[guid]::NewGuid().ToString('N')) +$cases=@( + @{Args=@('outgoing-ntlm','-Help');Code=0;Pattern='Changes only'}, + @{Args=@('configure','-NtlmAction','Configure');Code=1;Pattern='requires outgoing-ntlm'}, + @{Args=@('outgoing-ntlm','-OutgoingNtlmMode','Deny');Code=1;Pattern='enforcement is not accepted'}, + @{Args=@('outgoing-ntlm','-Role','Client');Code=1;Pattern='dedicated options'}, + @{Args=@('outgoing-ntlm','-Profile','wela-2.2.0');Code=1;Pattern='dedicated options'}, + @{Args=@('outgoing-ntlm','-Auto');Code=1;Pattern='require NtlmAction Configure'}, + @{Args=@('outgoing-ntlm','-DryRun');Code=1;Pattern='require NtlmAction Configure'}, + @{Args=@('outgoing-ntlm','-BackupPath',$root);Code=1;Pattern='require NtlmAction Configure'}, + @{Args=@('outgoing-ntlm','-Help','-ProviderAction','Configure');Code=1;Pattern='dedicated options'}, + @{Args=@('outgoing-ntlm','-NtlmAction','Configure','-Typo');Code=1;Pattern='Unsupported trailing arguments'} +) +foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($code -eq 0) -ne ($case.Code -eq 0) -or ($text -join "`n") -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') -> $code / $($text -join ' ')"};$count++} +if(Test-Path $root){throw 'Refused CLI input created unexpected output.'} +Write-Host "PASS: $count scoped outgoing NTLM CLI guards." +exit 0 diff --git a/tests/OutgoingNtlmAudit.Tests.ps1 b/tests/OutgoingNtlmAudit.Tests.ps1 new file mode 100644 index 00000000..95c5c577 --- /dev/null +++ b/tests/OutgoingNtlmAudit.Tests.ps1 @@ -0,0 +1,58 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/OutgoingNtlmAudit.ps1') +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-test-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$count=0;$sequence=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 12 -Compress} +function Reset($Value,$Type='DWord'){ + $script:policy=[pscustomobject][ordered]@{KeyExists=$true;ValueExists=($null -ne $Value);Value=$Value;Type=$(if($null -ne $Value){$Type}else{$null})} + $script:writes=0;$script:reads=0;$script:failRead=$false;$script:failWrite=$false;$script:ignoreWrite=$false;$script:promptChange=$null;$script:onRead=$null +} +function Get-WelaOutgoingAuditSnapshot { + $script:reads++;if($script:onRead){& $script:onRead};if($script:failRead){throw 'Access denied'} + [pscustomobject][ordered]@{Host=[pscustomobject][ordered]@{Build=26100;ProductType=3;DomainRole=2;PartOfDomain=$false};Policy=($script:policy|ConvertTo-Json|ConvertFrom-Json)} +} +function Get-WelaOutgoingNtlmPolicySource {'Unknown (fixture has no RSoP ownership evidence)'} +function Set-ItemProperty {param($LiteralPath,$Name,$Value,$Type,$ErrorAction) + Assert ($LiteralPath -ceq 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' -and $Name -ceq 'RestrictSendingNTLMTraffic' -and $Value -eq 1 -and $Type -ceq 'DWord') 'Only the one exact audit-only target may be written.' + $script:writes++;if($script:failWrite){throw 'Write denied'};if(-not $script:ignoreWrite){$script:policy.ValueExists=$true;$script:policy.Value=1;$script:policy.Type='DWord'} +} +function Read-Host {param($Prompt) if($script:promptChange){& $script:promptChange};return 'Y'} +function Configure([string]$Mode='PreserveOrAudit',[switch]$DryRun,[switch]$Prompt){ + $script:sequence++;$script:backup=Join-Path $root ('case-'+$script:sequence) + Invoke-WelaOutgoingAuditCommand -Action Configure -Mode $Mode -Auto:(-not $Prompt) -DryRun:$DryRun -BackupPath $script:backup +} +try{ + foreach($initial in @($null,0,1)){ + Reset $initial;$old=Key $script:policy;$r=Configure + Assert ($r.ExitCode -eq 0 -and $r.Scope -ceq 'outgoing-ntlm-audit-policy-only' -and $r.ReadyRuleCredit -eq 0) 'Public report scopes success to one policy, without detection credit.' + Assert ($script:policy.Value -eq 1 -and $script:writes -eq $(if($initial -eq 1){0}else{1})) 'Absent/allow are audited; existing audit is idempotent.' + if($initial -ne 1){$j=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json);Assert ($j.Count -eq 1 -and (Key $j[0].Before.Policy) -ceq $old) 'Typed original snapshot is durable before the one write.'} + else{Assert (-not(Test-Path (Join-Path $backup 'before.jsonl'))) 'Already configured mode does not journal a write.'} + } + Reset 2;$r=Configure;Assert ($script:writes -eq 0 -and $r.Results[0].Status -ceq 'Skipped' -and $r.Plan.Status -ceq 'PreservedEnforcement' -and $script:policy.Value -eq 2) 'Default mode preserves and identifies authentication enforcement.' + Reset 2;$r=Configure Audit;Assert ($script:writes -eq 1 -and $script:policy.Value -eq 1 -and $r.Results[0].Status -ceq 'Applied') 'Explicit audit mode authorizes replacing deny with auditing.' + foreach($value in @(42,'1')){foreach($mode in @('PreserveOrAudit','Audit')){ + Reset $value $(if($value -is [string]){'String'}else{'DWord'});$r=Configure $mode + Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0 -and $r.Results[0].Status -ceq 'Failed') 'Unknown values/types remain untouched even in explicit Audit mode.' + }} + Reset 0;$r=Configure -DryRun;Assert ($script:writes -eq 0 -and $r.DryRun -and -not(Test-Path $backup)) 'Dry run has no policy or journal-directory mutation.' + Reset 0;$script:failRead=$true;$r=Configure;Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0) 'An unreadable policy fails closed.' + foreach($kind in @('failWrite','ignoreWrite')){ + Reset 0;Set-Variable -Scope Script -Name $kind -Value $true;$r=Configure + Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Failed') 'Native failure and ignored-write readback cannot report success.' + } + foreach($changed in @(1,2,42)){ + Reset 0;$script:changed=$changed;$script:promptChange={$script:policy.Value=$script:changed};$r=Configure -Prompt + Assert ($r.ExitCode -eq 1 -and $script:writes -eq 0 -and $script:policy.Value -eq $changed) 'Prompt-time drift refuses writes after preserving the exact original receipt.' + } + Reset 0;$script:onRead={if($script:reads -eq 5){$script:policy.Value=0}};$r=Configure + Assert ($script:writes -eq 1 -and $r.ExitCode -eq 1 -and $r.Results[0].Status -ceq 'Overridden') 'A later policy change fails final verification.' + Reset 0;$r=Invoke-WelaOutgoingAuditCommand -Action Plan;Assert ($r.Plan.Status -ceq 'ChangeRequired' -and $script:writes -eq 0) 'Plan is current-host assessment and does not mutate policy.' + foreach($action in @('Audit','Plan')){foreach($option in @('Auto','DryRun','BackupPath')){ + $a=@{Action=$action};$a[$option]=$(if($option -eq 'BackupPath'){'unused'}else{$true});$threw=$false;try{Invoke-WelaOutgoingAuditCommand @a}catch{$threw=$true};Assert $threw 'Read-only actions reject mutation-only options.' + }} +}finally{Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "PASS: $count scoped outgoing NTLM assertions." +exit 0 diff --git a/tests/OutgoingNtlmAudit.Windows.Tests.ps1 b/tests/OutgoingNtlmAudit.Windows.Tests.ps1 new file mode 100644 index 00000000..f544ce04 --- /dev/null +++ b/tests/OutgoingNtlmAudit.Windows.Tests.ps1 @@ -0,0 +1,76 @@ +param([switch]$AllowDisposableAuditWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposableAuditWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on a disposable GitHub-hosted Windows runner is required.'} +$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/OutgoingNtlmAudit.ps1') +Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force +Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force +$engine=(Get-Process -Id $PID).Path;$count=0;$failure=$null;$errors=@() +$root=Join-Path $env:RUNNER_TEMP ('wela-outgoing-audit-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0';$name='RestrictSendingNTLMTraffic' +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8} +function Masks {$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'} +function Other { + $base=[Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::Registry64);$k=$null + try{ + $k=$base.OpenSubKey('SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0');if(-not $k){throw 'Existing MSV1_0 key required.'} + $values=@($k.GetValueNames()|Sort-Object|Where-Object {$_ -ine $name}|ForEach-Object{[pscustomobject][ordered]@{Name=$_;Type=$k.GetValueKind($_).ToString();Value=$k.GetValue($_,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)}}) + $children=@($k.GetSubKeyNames()|Sort-Object) + $security=if($PSVersionTable.PSVersion.Major -ge 6){[Microsoft.Win32.RegistryAclExtensions]::GetAccessControl($k)}else{$k.GetAccessControl()} + $acl=$security.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group) + }finally{if($k){$k.Dispose()};$base.Dispose()} + [pscustomobject][ordered]@{Values=$values;Children=$children;Access=$acl;DomainPolicy=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' AuditNTLMInDomain;NtlmChannel=Get-WelaNativeChannel 'Microsoft-Windows-NTLM/Operational';SecurityChannel=Get-WelaNativeChannel Security;NetlogonService=[string](Get-Service Netlogon).Status} +} +function Public([string]$Label,[string[]]$Arguments){ + $prior=$ErrorActionPreference + try{$ErrorActionPreference='Continue';$output=& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') outgoing-ntlm @Arguments 2>&1|Out-String;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior} + $output|Set-Content -LiteralPath (Join-Path $root ($Label+'.txt')) -Encoding UTF8 + Assert ($code -eq 0) "Public $Label exited $code : $output" + Get-Content -Raw -LiteralPath (Join-Path $root ($Label+'.json'))|ConvertFrom-Json +} +$original=Get-WelaOutgoingAuditSnapshot +Assert ($original.Host.ProductType -eq 3 -and $original.Host.DomainRole -eq 2 -and -not $original.Host.PartOfDomain) 'Actual unjoined disposable Server is required.' +Assert (-not $original.Policy.ValueExists -or ($original.Policy.Type -ceq 'DWord' -and $original.Policy.Value -in @(0,1))) 'Fixture never replaces pre-existing enforcement or an unknown policy.' +$other=Other;$masks=Masks +Save 'original.json' @{Snapshot=$original;Unselected=$other;Masks=$masks;UBR=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR;Engine=$PSVersionTable.PSVersion.ToString();Commit=$env:GITHUB_SHA} +try{ + foreach($case in @('absent','allow')){ + if((Get-WelaRegistryState $path $name).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop} + if($case -eq 'allow'){$null=New-ItemProperty -LiteralPath $path -Name $name -PropertyType DWord -Value 0} + $prepared=Get-WelaOutgoingAuditSnapshot + $plan=Public ($case+'-plan') @('-NtlmAction','Plan','-ResultsPath',(Join-Path $root ($case+'-plan.json'))) + Assert ($plan.Plan.Status -ceq 'ChangeRequired' -and (Key $plan.Plan.Before) -ceq (Key $prepared)) 'Public plan retains the exact native absence/allow state and actual host.' + $dryBackup=Join-Path $root ($case+'-dry-backup') + $dry=Public ($case+'-dry') @('-NtlmAction','Configure','-Auto','-DryRun','-BackupPath',$dryBackup,'-ResultsPath',(Join-Path $root ($case+'-dry.json'))) + Assert ($dry.DryRun -and $dry.Results[0].Status -ceq 'Skipped' -and -not(Test-Path $dryBackup) -and (Key (Get-WelaOutgoingAuditSnapshot)) -ceq (Key $prepared)) 'Dry run preserves policy and creates no journal directory.' + $backup=Join-Path $root ($case+'-backup') + $report=Public $case @('-NtlmAction','Configure','-Auto','-BackupPath',$backup,'-ResultsPath',(Join-Path $root ($case+'.json'))) + $after=Get-WelaOutgoingAuditSnapshot + Assert ($report.Scope -ceq 'outgoing-ntlm-audit-policy-only' -and $report.Results.Count -eq 1 -and $report.Results[0].Status -ceq 'Applied') 'Exactly one native outgoing policy is applied through public CLI.' + Assert ($after.Policy.Type -ceq 'DWord' -and $after.Policy.Value -eq 1 -and (Key $report.Results[0].After) -ceq (Key $after)) 'Native audit-only readback matches the public result.' + $journal=@(Get-Content (Join-Path $backup 'before.jsonl')|ConvertFrom-Json) + Assert ($journal.Count -eq 1 -and (Key $journal[0].Before) -ceq (Key $prepared) -and $journal[0].Target.Path -ceq $path -and $journal[0].Target.Name -ceq $name) 'One original journal retains the actual typed policy and native context.' + $repeatBackup=Join-Path $root ($case+'-repeat-backup') + $repeat=Public ($case+'-repeat') @('-NtlmAction','Configure','-Auto','-BackupPath',$repeatBackup,'-ResultsPath',(Join-Path $root ($case+'-repeat.json'))) + Assert ($repeat.Results[0].Status -ceq 'AlreadyCompliant' -and -not(Test-Path (Join-Path $repeatBackup 'before.jsonl'))) 'Repeated configuration is idempotent without another original journal.' + $audit=Public ($case+'-audit') @('-NtlmAction','Audit','-ResultsPath',(Join-Path $root ($case+'-audit.json'))) + Assert ($audit.Plan.Status -ceq 'AlreadyCompliant' -and $audit.ReadyRuleCredit -eq 0 -and $audit.EventGeneration -like 'Not verified*') 'Audit distinguishes registry compliance from event or authentication proof.' + Assert ((Key (Other)) -ceq (Key $other) -and (Masks) -ceq $masks) 'Incoming/domain policies, siblings, access descriptor, channels, service and all59 masks remain unchanged.' + } + Save 'completed.json' @{Status='Passed';Assertions=$count;NativeWrites=2;Scope='Only outgoing audit DWORD1. No network authentication attempt, enforcement, event generation, GPO refresh or Sigma proof.'} +}catch{$failure=$_.ToString();throw}finally{ + try{ + if((Get-WelaRegistryState $path $name).ValueExists){Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop} + if($original.Policy.ValueExists){$null=New-ItemProperty -LiteralPath $path -Name $name -Value $original.Policy.Value -PropertyType $original.Policy.Type} + }catch{$errors+=$_.ToString()} + $checks=[ordered]@{} + foreach($pair in @(@('Policy',{(Key (Get-WelaOutgoingAuditSnapshot)) -ceq (Key $original)}),@('Unselected',{(Key (Other)) -ceq (Key $other)}),@('All59Masks',{(Masks) -ceq $masks}))){try{$checks[$pair[0]]=& $pair[1]}catch{$checks[$pair[0]]=$false;$errors+=$_.ToString()}} + $complete=$errors.Count -eq 0 -and @($checks.Values|Where-Object {-not $_}).Count -eq 0 + Save 'cleanup.json' @{Complete=$complete;Checks=$checks;Errors=$errors;Failure=$failure;Assertions=$count} + if(-not $complete){throw 'Outgoing NTLM native fixture cleanup failed.'} +} +Write-Host "PASS: $count native public outgoing NTLM assertions and exact cleanup." +exit 0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index b4f75fc6..eeb7d40a 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- `outgoing-ntlm` のAudit/Plan/Configureを追加し、送信NTLM監査DWORDを個別に設定できます。既存の拒否設定は既定で維持し、置換には明示的なAuditを要求します。不明な型・値や書込直前の変化を拒否し、元の型付き記録と再読取を保持します。Server 2022/2025のテストで範囲と復元を確認し、認証・イベント生成は未検証として報告します。(関連 #362) (@Shirofune-Security) + - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) - Server 2022/2025 と両 PowerShell エンジンで、公開 `targeted-sacl` のレジストリ操作を検証する使い捨てテストを追加しました。テスト専用の新規ハイブをマウントし、対象選択、DryRun、監査 ACE の追加、古い計画・前提条件不足の拒否、冪等性と厳密に対応付けた Security4657 を確認します。無関係な ACE・型付き値の保持、監査ポリシー・トークンの復元、所有ハイブのアンロードと削除の証跡を保存します。製品側のハイブ読み込みや Sigma 準備完了の判定は追加しません。(関連 #373) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 1ba91895..589feb83 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Add `outgoing-ntlm` Audit/Plan/Configure to manage the outgoing audit DWORD independently of broad configuration. Preserve existing deny by default, require explicit Audit to replace it, refuse unknown types/values and pre-write drift, and retain typed original journals plus native readback. Native Server 2022/2025 tests verify narrow scope and exact cleanup; authentication/event generation remain unverified. (Related #362) (@Shirofune-Security) + - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) - Added disposable public `targeted-sacl` registry lifecycle validation on Server 2022/2025 and both PowerShell engines. A fixture-owned mounted hive exercises reviewed selection, DryRun, additive configuration, stale/prerequisite refusal and idempotence, followed by one precisely attributed Security4657. Retained native receipts verify unrelated ACE/value preservation and exact audit-policy, token and owned-hive cleanup; production does not load hives or gain Sigma credit. (Related #373) (@Shirofune-Security) From b61a3c6bcaf4811169eced0c78a21dd5c71f8dd4 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:10:01 +0900 Subject: [PATCH 05/10] Handle native WinRM manifest IDs without Int32 overflow --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- docs/native-provider-acceptance.md | 4 +++- scripts/NativeProviderPacks.ps1 | 7 +++++-- tests/NativeProviderConfigure.Windows.Tests.ps1 | 9 ++++++++- tests/NativeProviderPacks.Tests.ps1 | 9 +++++++++ website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 8 files changed, 29 insertions(+), 8 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 9b931f9a..f7ce3054 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- Server 2022/2025 の Windows PowerShell 5.1/PowerShell 7 で、公開 provider-packs コマンドの Plan、DryRun、Configure、冪等性、前提不足・手動対象の拒否、部分適用を実機検証する使い捨て CI を追加。DNS Client、CAPI2、WinRM、RDP Client の設定と復元記録・ハッシュを確認し、完全な ACL、保持モード、大きい既存バッファ、他チャネル、サービス、全監査マスクの保持とテスト後の正確な復元を検証。イベント生成や Sigma 対応の証明は含みません。(@Shirofune-Security) +- Server 2022/2025 の Windows PowerShell 5.1/PowerShell 7 で、公開 provider-packs コマンドの Plan、DryRun、Configure、冪等性、前提不足・手動対象の拒否、部分適用を実機検証する使い捨て CI を追加。DNS Client、CAPI2、WinRM、RDP Client の設定と復元記録・ハッシュを確認し、完全な ACL、保持モード、大きい既存バッファ、他チャネル、サービス、全監査マスクの保持とテスト後の正確な復元を検証。イベント生成や Sigma 対応の証明は含みません。 WinRM のマニフェスト ID をネイティブの Int64 として比較し、対象外の大きい ID により必要なイベントの確認が失敗する不具合も修正。 (@Shirofune-Security) - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd86000a..754e712d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- Added disposable native acceptance for public provider-pack Plan, DryRun, Configure, idempotence, missing/manual refusal and partial outcomes on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Actual DNS Client, CAPI2, WinRM and RDP Client configuration preserves descriptors, retention, larger buffers, unrelated channels, services and all audit masks, with journal/hash evidence and exact fixture cleanup; no event or Sigma credit. (@Shirofune-Security) +- Added disposable native acceptance for public provider-pack Plan, DryRun, Configure, idempotence, missing/manual refusal and partial outcomes on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Actual DNS Client, CAPI2, WinRM and RDP Client configuration preserves descriptors, retention, larger buffers, unrelated channels, services and all audit masks, with journal/hash evidence and exact fixture cleanup; no event or Sigma credit. Fixed WinRM manifest inspection to preserve native Int64 event IDs, so unrelated large IDs no longer block the selected event schema. (@Shirofune-Security) - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) diff --git a/docs/native-provider-acceptance.md b/docs/native-provider-acceptance.md index 2df6c226..53441d85 100644 --- a/docs/native-provider-acceptance.md +++ b/docs/native-provider-acceptance.md @@ -2,7 +2,7 @@ The dedicated `Native provider configuration acceptance` workflow tests the public `provider-packs` command on disposable GitHub-hosted Windows Server 2022 and 2025, separately under Windows PowerShell 5.1 and PowerShell 7. It complements the read-only manifest inventory and mocked failure tests described in [the provider-pack guide](native-provider-packs.md). -This fixture is destructive to the selected channels' temporary configuration and can discard records when restoring smaller buffers. It requires `-AllowDisposableProviderWrite`, `GITHUB_ACTIONS=true` and `RUNNER_ENVIRONMENT=github-hosted`; do not run it on ordinary machines. It makes no production configuration changes outside the existing public command's declared scope. +This fixture is destructive to the selected channels' temporary configuration and can discard records when restoring smaller buffers. It requires `-AllowDisposableProviderWrite`, `GITHUB_ACTIONS=true` and `RUNNER_ENVIRONMENT=github-hosted`; do not run it on ordinary machines. Production behavior is unchanged; only this opted-in disposable fixture prepares and restores the temporary test settings. ## Actual public behavior checked @@ -24,3 +24,5 @@ Each selected channel has independent cleanup that restores original enablement, `original.json`, public JSON reports, command output, actual journals, `completed.json`, `cleanup.json` and a SHA256 manifest are retained for seven days by the workflow. The manifest binds the fixture, product helpers, catalog, corpus and full reviewed rule-source bytes. Event records are not restored, and no retention-duration, Windows 11, domain/DC/ADCS, positive installed-DNS, forwarding or Sigma acceptance is implied. This advances issues #386 and #366 without closing their broader acceptance work. The underlying enablement, size, retention and backup options follow Microsoft's [wevtutil command reference](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wevtutil). The product's existing channel floors and schema gates remain unchanged. + +The first native run exposed a WinRM manifest bug: an unrelated event ID `3221734403` overflowed the reader's signed 32-bit cast and made the whole provider schema unknown. The reader now compares [EventMetadata.Id in its native Int64 domain](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventmetadata.id), then parses only the exact reviewed event/channel templates. Focused tests also require refusal when only unrelated large IDs exist; schema gates are unchanged. diff --git a/scripts/NativeProviderPacks.ps1 b/scripts/NativeProviderPacks.ps1 index 1ba0a64d..744d7d26 100644 --- a/scripts/NativeProviderPacks.ps1 +++ b/scripts/NativeProviderPacks.ps1 @@ -53,12 +53,15 @@ function Get-WelaProviderPackSchema { if ([guid]$provider.Id -eq [guid]::Empty) { throw 'Provider GUID is unknown.' } if (@($logs[0].ProviderNames) -notcontains $Pack.provider -or @($provider.LogLinks.LogName) -notcontains $Pack.channel) { throw 'Provider/channel links disagree.' } $events = @() + # EventMetadata.Id is Int64; WinRM includes unrelated IDs above Int32.MaxValue. + # Compare before parsing selected templates, without narrowing the native ID. + $expectedIds = @($Pack.events | ForEach-Object { [long]$_.id }) foreach ($event in $provider.Events) { - if (@($Pack.events.id) -contains [int]$event.Id -and $event.LogLink.LogName -eq $Pack.channel) { + if ($expectedIds -contains [long]$event.Id -and $event.LogLink.LogName -eq $Pack.channel) { $fields = @(Get-WelaProviderTemplateFields -Template $event.Template) $sha = [Security.Cryptography.SHA256]::Create() try { $templateHash = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes([string]$event.Template)))).Replace('-','').ToLowerInvariant() } finally { $sha.Dispose() } - $events += [pscustomobject]@{ Id=[int]$event.Id; Version=[int]$event.Version; Channel=[string]$event.LogLink.LogName; Fields=$fields; TemplateSha256=$templateHash } + $events += [pscustomobject]@{ Id=[long]$event.Id; Version=[int]$event.Version; Channel=[string]$event.LogLink.LogName; Fields=$fields; TemplateSha256=$templateHash } } } [pscustomobject]@{ State='Observed'; Provider=[string]$provider.Name; ProviderGuid=[string]$provider.Id; ChannelType=[string]$logs[0].LogType; Events=$events; Diagnostic=$null } diff --git a/tests/NativeProviderConfigure.Windows.Tests.ps1 b/tests/NativeProviderConfigure.Windows.Tests.ps1 index aa66f979..be7ba299 100644 --- a/tests/NativeProviderConfigure.Windows.Tests.ps1 +++ b/tests/NativeProviderConfigure.Windows.Tests.ps1 @@ -74,6 +74,7 @@ $before=@{};$raw=@{};$prepared=@{};$preparedRaw=@{};$services=@(Services);$polic foreach($channel in $channels){$before[$channel]=Get-WelaNativeChannel $channel;if(Test-WelaNativeChannelSnapshot $before[$channel]){$raw[$channel]=Read-Raw $channel}} $rawText=@{};foreach($channel in $raw.Keys){$rawText[$channel]=$raw[$channel].OuterXml} Save 'original.json' @{Channels=$before;RawXml=$rawText;Services=$services;AuditMasks=$policies;Engine=$PSVersionTable.PSVersion.ToString()} +function Stable-Selected {foreach($channel in $selected.channel){Assert (Test-WelaNativeChannelSnapshotEqual $configured[$channel] (Get-WelaNativeChannel $channel)) 'Idempotent/refused/partial invocation preserves the expected complete selected-channel tuple.'}} function Preserved { foreach($channel in $channels){ $now=Get-WelaNativeChannel $channel @@ -95,6 +96,7 @@ try { $initial=Public 'initial' 'Plan' $names foreach($entry in $initial.ControlsPlan){Assert ($entry.ProviderEvidence.CanConfigure -and $entry.ProviderEvidence.Schema.State -ceq 'Observed' -and $entry.ProviderEvidence.Schema.Provider -ceq $entry.Pack.provider) 'Exact actual provider/schema permits the selected pack.'} Assert ($initial.ControlsPlan.Count -eq 4) 'Exactly four explicit packs are observed.' + foreach($channel in $raw.Keys){Assert ((Read-Raw $channel).OuterXml -ceq $raw[$channel].OuterXml) 'Initial public Plan preserves every registered channel configuration.'} foreach($pack in $selected){ $channel=$pack.channel;$mutated+=,$channel $size=if($pack.id -ceq 'winrm'){2147483648L}else{1048576L} @@ -111,12 +113,13 @@ try { $null=Public 'grant-option' 'Configure' $names -Expected 1 -Extra @('-GrantEventLogReaders') foreach($channel in $selected.channel){Assert (Test-WelaNativeChannelSnapshotEqual $prepared[$channel] (Get-WelaNativeChannel $channel)) 'Plan, DryRun and invalid options preserve prepared actual state.'} Preserved + $configured=@{} $applied=Public 'configure' 'Configure' $names Assert ($applied.Action -ceq 'Configure' -and $applied.Scope -ceq 'native-channel-settings-only' -and $applied.Results.Count -eq 4 -and @($applied.Results|Where-Object Status -cne 'Applied').Count -eq 0) 'All four explicit configurations are actually Applied.' $journal=@(Get-Content "$root/configure-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json}) Assert ($journal.Count -eq 4 -and @($journal|Where-Object {$selected.channel -notcontains $_.Target.Channel}).Count -eq 0) 'Exactly four selected changes have durable original journals.' foreach($pack in $selected){ - $channel=$pack.channel;$entry=@($applied.Results|Where-Object {$_.Target.Channel -ceq $channel});$j=@($journal|Where-Object {$_.Target.Channel -ceq $channel});$now=Get-WelaNativeChannel $channel + $channel=$pack.channel;$entry=@($applied.Results|Where-Object {$_.Target.Channel -ceq $channel});$j=@($journal|Where-Object {$_.Target.Channel -ceq $channel});$now=Get-WelaNativeChannel $channel;$configured[$channel]=$now $minimum=if($pack.id -ceq 'capi2'){102432768L}elseif($pack.id -ceq 'winrm'){2147483648L}else{33554432L} Assert ($entry.Count -eq 1 -and $j.Count -eq 1 -and (Test-WelaNativeChannelSnapshotEqual $j[0].Before $prepared[$channel]) -and (Test-WelaNativeChannelSnapshotEqual $entry[0].Before $prepared[$channel])) 'Native journal and result retain exact prepared before-state.' Assert ($now.IsEnabled -and $now.MaximumSizeInBytes -eq $minimum -and (Test-WelaNativeChannelSnapshotEqual $entry[0].After $now)) 'Exact native enable/floor/larger-buffer readback matches Applied after-state.' @@ -126,16 +129,20 @@ try { Preserved $repeat=Public 'repeat' 'Configure' $names Assert (@($repeat.Results|Where-Object Status -cne 'AlreadyCompliant').Count -eq 0 -and -not(Test-Path "$root/repeat-journal/before.jsonl")) 'Native repeat is idempotent and journals no write.' + Stable-Selected $manual=Public 'manual' 'Configure' @('dns-server-analytical','dns-server-classic') -Expected 1 Assert ($manual.Results.Count -eq 2 -and @($manual.Results|Where-Object Status -cne 'Failed').Count -eq 0 -and -not(Test-Path "$root/manual-journal/before.jsonl")) 'Both actual manual-only selections fail without channel mutation or journal.' + Stable-Selected $missing=Public 'missing-dns' 'Configure' @('dns-server-audit') -Expected 1 Assert ($missing.Results[0].Status -ceq 'Failed' -and $missing.ControlsPlan[0].ProviderEvidence.Service.State -ceq 'Not installed' -and -not(Test-Path "$root/missing-dns-journal/before.jsonl")) 'Missing actual DNS service cannot be replaced by an assumed server role.' + Stable-Selected # A genuine partial public run must retain one success and one manual refusal. $capi='Microsoft-Windows-CAPI2/Operational';$null=Invoke-WelaNative wevtutil.exe @('sl',$capi,'/e:false');$partialBefore=Get-WelaNativeChannel $capi $partial=Public 'partial' 'Configure' @('capi2','dns-server-analytical') -Expected 1 Assert (@($partial.Results|Where-Object Status -ceq 'Applied').Count -eq 1 -and @($partial.Results|Where-Object Status -ceq 'Failed').Count -eq 1 -and (Get-WelaNativeChannel $capi).IsEnabled) 'Actual partial configuration retains one verified change and explicit nonzero failure.' $partialJournal=@(Get-Content "$root/partial-journal/before.jsonl"|ForEach-Object {$_|ConvertFrom-Json}) Assert ($partialJournal.Count -eq 1 -and $partialJournal[0].Target.Channel -ceq $capi -and (Test-WelaNativeChannelSnapshotEqual $partialJournal[0].Before $partialBefore)) 'Partial run journals only its actual selected write.' + Stable-Selected Preserved Save 'completed.json' @{Status='Passed';Assertions=$count;ActualAppliedControls=5;IdempotentControls=4;ManualRefusals=3;MissingServiceRefusals=1;ReadyRuleCredit=0} }catch{$primary=$_} diff --git a/tests/NativeProviderPacks.Tests.ps1 b/tests/NativeProviderPacks.Tests.ps1 index 84cc23c7..6e51e5fd 100644 --- a/tests/NativeProviderPacks.Tests.ps1 +++ b/tests/NativeProviderPacks.Tests.ps1 @@ -43,6 +43,10 @@ function Get-WinEvent { $channel=if($f.TemplateMode -eq 'wrongchannel'){'Other/Operational'}else{$p.channel} $events+= [pscustomobject]@{Id=$e.id;Version=0;LogLink=[pscustomobject]@{LogName=$channel};Template=$template} } + if($f.LargeIds){ + if($f.LargeOnly){$events=@()} + foreach($large in @([long]3221734403,[long]4294967295)){$events+=[pscustomobject]@{Id=$large;Version=0;LogLink=[pscustomobject]@{LogName=$p.channel};Template='unselected template is never parsed'}} + } [pscustomobject]@{Name=$ListProvider;Id='11111111-1111-1111-1111-111111111111';LogLinks=@([pscustomobject]@{LogName=$p.channel});Events=$events} } function Read-Host {param($Prompt) if($f.PromptSchemaDrift){$f.TemplateMode='missing'};$f.Prompt} @@ -95,6 +99,11 @@ try { Assert (@($entry.RuleReviews|Where-Object Eligibility -ne 'Conditional').Count -eq 0 -and $report.ReadyRules -eq 0) 'Provider settings never convert incomplete rule evidence into Ready.' Assert ($entry.ProviderEvidence.Schema.Events[0].Fields[0].InType -eq 'win:UnicodeString' -and $entry.ProviderEvidence.Schema.Events[0].TemplateSha256.Length -eq 64) 'Report retains runtime version, native field types and template fingerprint.' Assert ($f.Writes.Count -eq 0 -and -not(Test-Path $backup)) 'Read-only plan creates no journal and makes no channel changes.' + Reset;$f.LargeIds=$true;$r=Invoke-WelaProviderPackCommand -Action Plan -Names winrm + Assert ($r.ExitCode -eq 0 -and $r.ControlsPlan[0].ProviderEvidence.CanConfigure) 'Actual WinRM Int64 event IDs above Int32 do not invalidate unrelated selected event6.' + Assert ($r.ControlsPlan[0].ProviderEvidence.Schema.Events.Count -eq 1 -and $r.ControlsPlan[0].ProviderEvidence.Schema.Events[0].Id -eq 6) 'Only the exact reviewed event6 enters schema evidence; large unselected IDs/templates are excluded.' + Reset;$f.LargeIds=$true;$f.LargeOnly=$true;$r=Invoke-WelaProviderPackCommand -Action Configure -Names winrm -Auto -BackupPath $backup + Assert ($r.ExitCode -eq 1 -and -not $r.ControlsPlan[0].ProviderEvidence.CanConfigure -and $f.Writes.Count -eq 0) 'Unrelated large native IDs cannot substitute for a missing selected event6.' Reset;$f.States['Microsoft-Windows-DNS-Client/Operational'].State='Not installed';$f.States['Microsoft-Windows-DNS-Client/Operational'].IsEnabled=$null $r=Invoke-WelaProviderPackCommand -Action Configure -Names dns-client -Auto -BackupPath $backup Assert ($r.ExitCode -eq 1 -and $f.Writes.Count -eq 0) 'Missing actual channel metadata cannot be replaced by provider-manifest availability.' diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 9a2a9696..b85eb593 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- Server 2022/2025 の Windows PowerShell 5.1/PowerShell 7 で、公開 provider-packs コマンドの Plan、DryRun、Configure、冪等性、前提不足・手動対象の拒否、部分適用を実機検証する使い捨て CI を追加。DNS Client、CAPI2、WinRM、RDP Client の設定と復元記録・ハッシュを確認し、完全な ACL、保持モード、大きい既存バッファ、他チャネル、サービス、全監査マスクの保持とテスト後の正確な復元を検証。イベント生成や Sigma 対応の証明は含みません。(@Shirofune-Security) +- Server 2022/2025 の Windows PowerShell 5.1/PowerShell 7 で、公開 provider-packs コマンドの Plan、DryRun、Configure、冪等性、前提不足・手動対象の拒否、部分適用を実機検証する使い捨て CI を追加。DNS Client、CAPI2、WinRM、RDP Client の設定と復元記録・ハッシュを確認し、完全な ACL、保持モード、大きい既存バッファ、他チャネル、サービス、全監査マスクの保持とテスト後の正確な復元を検証。イベント生成や Sigma 対応の証明は含みません。 WinRM のマニフェスト ID をネイティブの Int64 として比較し、対象外の大きい ID により必要なイベントの確認が失敗する不具合も修正。 (@Shirofune-Security) - Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で、Securityログ警告設定の公開CLIを検証します。未設定・0・高いしきい値、早い警告値の維持、DryRun、再実行、不正型の拒否と完全な復元を確認し、無関係な設定は保持します。ログ枯渇や警告イベント生成は検証範囲外です。 (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 86a00012..15a2ee40 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- Added disposable native acceptance for public provider-pack Plan, DryRun, Configure, idempotence, missing/manual refusal and partial outcomes on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Actual DNS Client, CAPI2, WinRM and RDP Client configuration preserves descriptors, retention, larger buffers, unrelated channels, services and all audit masks, with journal/hash evidence and exact fixture cleanup; no event or Sigma credit. (@Shirofune-Security) +- Added disposable native acceptance for public provider-pack Plan, DryRun, Configure, idempotence, missing/manual refusal and partial outcomes on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Actual DNS Client, CAPI2, WinRM and RDP Client configuration preserves descriptors, retention, larger buffers, unrelated channels, services and all audit masks, with journal/hash evidence and exact fixture cleanup; no event or Sigma credit. Fixed WinRM manifest inspection to preserve native Int64 event IDs, so unrelated large IDs no longer block the selected event schema. (@Shirofune-Security) - Verify public Security-log warning configuration on disposable Server 2022/2025 hosts under Windows PowerShell 5.1/PowerShell 7: absent/zero/higher thresholds, earlier-threshold preservation, dry run, idempotence, wrong-type refusal and exact cleanup. Preserve unrelated registry values, channel configuration, audit masks and CrashOnAuditFail; no log-exhaustion or warning-event claim. (@Shirofune-Security) From 9caf23aff834481b9918498d498259ffd8b241fe Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:11:09 +0900 Subject: [PATCH 06/10] Verify actual SMB audit runtime observations separately from preserved security settings --- docs/smb-auditing.md | 4 +++- tests/SmbPolicyConfigure.Windows.Tests.ps1 | 23 +++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/smb-auditing.md b/docs/smb-auditing.md index fe2a4484..dc726dca 100644 --- a/docs/smb-auditing.md +++ b/docs/smb-auditing.md @@ -75,6 +75,8 @@ Sources: [LanmanServer Policy CSP mappings](https://learn.microsoft.com/en-us/wi ## Native public configuration acceptance -The separately opted-in `SmbPolicyConfigure.Windows.Tests.ps1` fixture runs public Plan, DryRun and Configure on disposable, unjoined Server 2022/2025 hosts with PowerShell 5.1/7. Server 2022 must skip all six unsupported controls without policy writes. On Server 2025, exact local ADMX and runtime observations must qualify before preparing six DWORD 0 values. Public Configure then writes six DWORD 1 values, preserves full native SMB configuration, siblings, access descriptors, service state and all 59 audit masks, records exact typed original journals, and repeats without writes. Cleanup restores the original values and removes only fixture-created empty policy keys. Native results retain the actual build/UBR and PowerShell version. +The separately opted-in `SmbPolicyConfigure.Windows.Tests.ps1` fixture runs public Plan, DryRun and Configure on disposable, unjoined Server 2022/2025 hosts with PowerShell 5.1/7. Server 2022 must skip all six unsupported controls without policy writes. On Server 2025, exact local ADMX and runtime observations must qualify before preparing six DWORD 0 values. Public Configure then writes six DWORD 1 values, preserves every unrelated native SMB configuration property, siblings, access descriptors, service state and all 59 audit masks, records exact typed original journals, and repeats without writes. Cleanup restores the original values and removes only fixture-created empty policy keys. Native results retain the actual build/UBR and PowerShell version. This acceptance establishes policy registry behavior only. It generates no SMB traffic, performs no runtime activation or policy refresh, and does not establish Windows client/DC/AD CS, event, forwarding or Sigma readiness. + +On the measured Server2025 CI images, the six getter audit Booleans changed from False to True after registry configuration and returned to their original values after cleanup. The fixture records these separately and compares them with the public report; it preserves all other runtime properties. This observed result does not establish synchronous activation on other builds or after future policy refresh, and no SMB setter/restart or traffic is invoked. diff --git a/tests/SmbPolicyConfigure.Windows.Tests.ps1 b/tests/SmbPolicyConfigure.Windows.Tests.ps1 index 8571d19f..60c4cae7 100644 --- a/tests/SmbPolicyConfigure.Windows.Tests.ps1 +++ b/tests/SmbPolicyConfigure.Windows.Tests.ps1 @@ -19,6 +19,13 @@ function Runtime { [pscustomobject][ordered]@{Side=$side;Properties=@($c.CimInstanceProperties|Sort-Object Name|ForEach-Object{[pscustomobject][ordered]@{Name=$_.Name;Type=$_.CimType.ToString();Value=$_.Value}})} } } +function UnselectedRuntime($Snapshot) { + foreach($side in $Snapshot){ + $component=if($side.Side -eq 'Server'){'LanmanServer'}else{'LanmanWorkstation'} + $selected=@($definitions|Where-Object Component -eq $component|ForEach-Object Name) + [pscustomobject][ordered]@{Side=$side.Side;Properties=@($side.Properties|Where-Object Name -NotIn $selected)} + } +} function Policies {foreach($d in $definitions){[pscustomobject]@{Definition=$d;Policy=Get-WelaRegistryState $d.Path $d.Name}}} function Keys { foreach($component in @('LanmanServer','LanmanWorkstation')){ @@ -53,12 +60,12 @@ try{ Assert (@($initial|Where-Object {$_.Status -notin @('ChangeRequired','PolicyConfigured')}).Count -eq 0) 'All six policies require exact local ADMX and readable native runtime before fixture writes.' foreach($d in $definitions){New-WelaRegistryKey $d.Path;$null=New-ItemProperty -LiteralPath $d.Path -Name $d.Name -Value 0 -PropertyType DWord -Force} } - $prepared=@(Policies);$other=@(OtherKeys);Save 'prepared.json' $prepared + $prepared=@(Policies);$other=@(OtherKeys);$preparedRuntime=@(Runtime);Save 'prepared.json' $prepared;Save 'prepared-runtime.json' $preparedRuntime $plan=Public plan @('-SmbAction','Plan','-ResultsPath',(Join-Path $root 'plan.json')) Assert ($plan.Controls.Count -eq 6) 'Public Plan accounts for exactly six controls.' $dry=Public dry @('-SmbAction','Configure','-DryRun','-BackupPath',(Join-Path $root 'dry-backup'),'-ResultsPath',(Join-Path $root 'dry.json')) Assert ($dry.DryRun -and @($dry.Results|Where-Object Status -eq Applied).Count -eq 0 -and -not(Test-Path (Join-Path $root 'dry-backup'))) 'Dry run does not change policy or create original journals.' - Assert ((Key @(Policies)) -ceq (Key $prepared) -and (Key @(Runtime)) -ceq (Key $runtime)) 'Plan and DryRun preserve exact typed policy and full native runtime.' + Assert ((Key @(Policies)) -ceq (Key $prepared) -and (Key @(Runtime)) -ceq (Key $preparedRuntime)) 'Plan and DryRun preserve exact typed policy and full native runtime.' $applied=Public apply @('-SmbAction','Configure','-Auto','-BackupPath',(Join-Path $root 'apply-backup'),'-ResultsPath',(Join-Path $root 'apply.json')) Assert ($applied.Scope -ceq 'smb-audit-policies-only' -and $applied.Results.Count -eq 6) 'Public Configure retains narrow scope and all six outcomes.' if([int]$os.BuildNumber -eq 20348){ @@ -74,7 +81,17 @@ try{ $repeat=Public repeat @('-SmbAction','Configure','-Auto','-BackupPath',(Join-Path $root 'repeat-backup'),'-ResultsPath',(Join-Path $root 'repeat.json')) Assert (@($repeat.Results|Where-Object Status -ne AlreadyCompliant).Count -eq 0 -and -not(Test-Path (Join-Path $root 'repeat-backup/before.jsonl'))) 'Repeated public Configure is idempotent without another journal.' } - Assert ((Key @(OtherKeys)) -ceq (Key $other) -and (Key @(Runtime)) -ceq (Key $runtime) -and (Masks) -ceq $masks) 'Sibling values, access descriptors, children, complete SMB runtime and all59 audit masks are preserved.' + $afterRuntime=@(Runtime);$afterOther=@(OtherKeys);Save 'after-runtime.json' $afterRuntime;Save 'after-other-keys.json' $afterOther;Save 'prepared-other-keys.json' $other + Assert ((Key $afterOther) -ceq (Key $other)) 'Sibling values, access descriptors and child keys are preserved.' + Assert ((Key @(UnselectedRuntime $afterRuntime)) -ceq (Key @(UnselectedRuntime $runtime))) 'Every unrelated native SMB runtime property is preserved.' + Assert ((Masks) -ceq $masks) 'All59 audit masks are preserved.' + if([int]$os.BuildNumber -eq 26100){ + foreach($row in $applied.Results){ + $side=if($row.Target.Path -like '*LanmanServer'){'Server'}else{'Client'} + $observed=@(($afterRuntime|Where-Object Side -eq $side).Properties|Where-Object Name -eq $row.Target.Name) + Assert ($observed.Count -eq 1 -and $observed[0].Type -ceq 'Boolean' -and $row.After.Runtime.Value -ceq $observed[0].Value) 'Reported audit runtime observation matches a separate native getter; activation is observed, not assumed.' + } + } Save 'completed.json' @{Status='Passed';Assertions=$count;ActualPolicyWrites=$(if([int]$os.BuildNumber -eq 26100){6}else{0});Scope='Policy registry only; no SMB traffic, activation, GPO refresh, event generation or Sigma proof.'} }catch{$failure=$_.ToString();throw}finally{ foreach($row in $before){try{ From d167ff39a4dcbdcf017c4a33c5b0e9b489340cd6 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:15:37 +0900 Subject: [PATCH 07/10] Retain independent native XML for provider configuration evidence --- tests/NativeProviderConfigure.Windows.Tests.ps1 | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/NativeProviderConfigure.Windows.Tests.ps1 b/tests/NativeProviderConfigure.Windows.Tests.ps1 index be7ba299..2147690b 100644 --- a/tests/NativeProviderConfigure.Windows.Tests.ps1 +++ b/tests/NativeProviderConfigure.Windows.Tests.ps1 @@ -104,7 +104,8 @@ try { $null=Invoke-WelaNative wevtutil.exe $nativeArguments $prepared[$channel]=Get-WelaNativeChannel $channel;$preparedRaw[$channel]=Read-Raw $channel } - Save 'prepared.json' $prepared + $preparedText=@{};foreach($channel in $preparedRaw.Keys){$preparedText[$channel]=$preparedRaw[$channel].OuterXml} + Save 'prepared.json' $prepared;Save 'prepared-xml.json' $preparedText $planned=Public 'plan' 'Plan' $names Assert (@($planned.ControlsPlan|Where-Object Status -cne 'ChangeRequired').Count -eq 0) 'Actual disabled/small prepared channels require change.' $dry=Public 'dry' 'Configure' $names -DryRun @@ -125,6 +126,7 @@ try { Assert ($now.IsEnabled -and $now.MaximumSizeInBytes -eq $minimum -and (Test-WelaNativeChannelSnapshotEqual $entry[0].After $now)) 'Exact native enable/floor/larger-buffer readback matches Applied after-state.' Assert ((Test-WelaChannelDescriptorEqual $now.SecurityDescriptor $prepared[$channel].SecurityDescriptor) -and $now.LogMode -ceq $prepared[$channel].LogMode -and -not $entry[0].Desired.AccessChangeRequested) 'Every descriptor byte and retention mode is preserved without a read grant.' } + $configuredText=@{};foreach($channel in $selected.channel){$configuredText[$channel]=(Read-Raw $channel).OuterXml};Save 'configured-xml.json' $configuredText Assert ((Get-WelaNativeChannel 'Microsoft-Windows-CAPI2/Operational').LogMode -ceq 'Retain') 'An actual nondefault Retain setting survives provider configuration.' Preserved $repeat=Public 'repeat' 'Configure' $names @@ -154,10 +156,10 @@ finally { if(-not(Test-WelaNativeChannelSnapshotEqual $s (Get-WelaNativeChannel $channel)) -or (Read-Raw $channel).OuterXml -cne $raw[$channel].OuterXml){throw 'Exact original channel configuration differs after cleanup.'} }catch{$errors+="$channel : $($_.Exception.Message)"} } - $after=@{};foreach($channel in $channels){try{$after[$channel]=Get-WelaNativeChannel $channel;if($raw.ContainsKey($channel)){if((Read-Raw $channel).OuterXml -cne $raw[$channel].OuterXml){throw 'Original channel XML differs'}}elseif((Key $after[$channel]) -cne (Key $before[$channel])){throw 'Original unavailable observation differs'}}catch{$errors+="$channel : $($_.Exception.Message)"}} + $after=@{};$afterRaw=@{};foreach($channel in $channels){try{$after[$channel]=Get-WelaNativeChannel $channel;if($raw.ContainsKey($channel)){$afterRaw[$channel]=(Read-Raw $channel).OuterXml;if($afterRaw[$channel] -cne $raw[$channel].OuterXml){throw 'Original channel XML differs'}}elseif((Key $after[$channel]) -cne (Key $before[$channel])){throw 'Original unavailable observation differs'}}catch{$errors+="$channel : $($_.Exception.Message)"}} $serviceAfter=$null;try{$serviceAfter=@(Services);if((Key $serviceAfter) -cne (Key $services)){throw 'Service state/start type differs'}}catch{$errors+=$_.Exception.Message} $maskAfter=$null;try{$maskAfter=Get-WelaEffectiveAuditPolicy;if($maskAfter.Count -ne $policies.Count){throw 'Audit mask count differs'};foreach($guid in $policies.Keys){if($maskAfter[$guid] -ne $policies[$guid]){throw "Audit mask differs: $guid"}}}catch{$errors+=$_.Exception.Message} - Save 'cleanup.json' @{CleanupVerified=($errors.Count -eq 0);Original=$before;After=$after;ServicesBefore=$services;ServicesAfter=$serviceAfter;AuditMasksCompared=$policies.Count;AuditMasksAfter=$maskAfter;Errors=$errors;PrimaryError=[string]$primary;Assertions=$count} + Save 'cleanup.json' @{CleanupVerified=($errors.Count -eq 0);Original=$before;After=$after;AfterRawXml=$afterRaw;ServicesBefore=$services;ServicesAfter=$serviceAfter;AuditMasksCompared=$policies.Count;AuditMasksAfter=$maskAfter;Errors=$errors;PrimaryError=[string]$primary;Assertions=$count} } $artifacts=@(Get-ChildItem -LiteralPath $root -File -Recurse|ForEach-Object {[ordered]@{Path=$_.FullName.Substring($root.Length+1);Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash}}) $sourcePaths=@('WELA.ps1','scripts/Configuration.ps1','scripts/NativeChannelConfiguration.ps1','scripts/NativeProviderPacks.ps1','modules/AuditProfiles.psm1','modules/EventLogSettings.psm1','modules/NativeProviders.psm1','modules/NativeChannelAccess.psm1','config/native_channel_profile.json','config/native_provider_packs.json','config/security_rules.json','tests/NativeProviderConfigure.Windows.Tests.ps1')+@($catalog.ruleReviews|ForEach-Object {'config/'+$_.localPath}) From 43e5479fbc3c523df4877d6ce6f8bb95de4e6f1b Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:15:48 +0900 Subject: [PATCH 08/10] Retain independent cleanup snapshots and exercise native read-only smoke --- .github/workflows/wec-collector-observation.yml | 3 +++ tests/WecCollectorObservation.Windows.Tests.ps1 | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wec-collector-observation.yml b/.github/workflows/wec-collector-observation.yml index b352765b..d43580c3 100644 --- a/.github/workflows/wec-collector-observation.yml +++ b/.github/workflows/wec-collector-observation.yml @@ -33,6 +33,9 @@ jobs: ./tests/WecSubscriptionXml.Tests.ps1 ./tests/WefDeployment.Tests.ps1 ./tests/WefDeployment.Cli.Tests.ps1 + - name: Existing read-only source and collector service preservation + shell: ${{ matrix.engine }} + run: ./tests/WefDeployment.Windows.Tests.ps1 - name: Actual public collector observations in Windows PowerShell 5.1 if: matrix.engine == 'powershell' shell: powershell diff --git a/tests/WecCollectorObservation.Windows.Tests.ps1 b/tests/WecCollectorObservation.Windows.Tests.ps1 index 8e2be1f6..21ab6ff8 100644 --- a/tests/WecCollectorObservation.Windows.Tests.ps1 +++ b/tests/WecCollectorObservation.Windows.Tests.ps1 @@ -18,7 +18,7 @@ $description='Owned observation '+$nonce+' '+$unicode;$sid='S-1-5-21-111111111-2 $root=Join-Path $env:RUNNER_TEMP ('wela-wec-observation-'+$nonce);$null=New-Item -ItemType Directory $root function Save($Name,$Value){$text=ConvertTo-Json -InputObject $Value -Depth 30;[IO.File]::WriteAllText((Join-Path $root $Name),$text,[Text.UTF8Encoding]::new($false))} $beforeServices=Services;$beforeChannel=Channel;$serviceKey='HKLM:\SYSTEM\CurrentControlSet\Services\Wecsvc';$beforeDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart -$original=$null;$created=$false;$failure=$null;$errors=@();$inventoryOk=$false;$servicesOk=$false;$channelOk=$false;$reports=@() +$original=$null;$created=$false;$failure=$null;$errors=@();$inventoryOk=$false;$servicesOk=$false;$channelOk=$false;$reports=@();$afterServices=$null;$afterChannel=$null;$afterDelayed=$null $sources=[ordered]@{};foreach($p in @('WELA.ps1','scripts/WefDeployment.ps1','modules/WefSubscriptions.psm1','modules/WecSubscriptionInventory.cs','modules/WecSubscriptionXml.cs')){$sources[$p]=(Get-FileHash (Join-Path $repo $p)).Hash.ToLowerInvariant()} Save 'before-fixture.json' @{Host=$hostState;Services=$beforeServices;Channel=$beforeChannel;DelayedAutoStart=$beforeDelayed;Sources=$sources} $config=Get-Content "$repo/config/wef-examples/collector.json" -Raw|ConvertFrom-Json @@ -84,14 +84,16 @@ try { if($created -and @(Get-WelaWecSubscriptionIds) -contains $id){$raw=Read-WelaWecSubscriptionXml $id;$doc=Read-WelaWefXml $raw;if($doc.Subscription.Description -cne $description -and $doc.Subscription.Description -cne ($description+' drift')){throw 'Fixture ownership differs; do not delete subscription.'};$null=Invoke-WelaNative 'wecutil.exe' @('ds',$id)} $restored=@(Inventory);Save 'restored-inventory.json' $restored;$inventoryOk=$null -ne $original -and (Key $restored) -ceq (Key $original) }catch{$errors+=$_.ToString()} - try{$channelOk=(Key (Channel)) -ceq (Key $beforeChannel)}catch{$errors+=$_.ToString()} + try{$afterChannel=Channel;$channelOk=(Key $afterChannel) -ceq (Key $beforeChannel)}catch{$errors+=$_.ToString()} try { $wec=@($beforeServices|Where-Object Name -eq Wecsvc)[0] if($wec.State -eq 'Stopped' -and (Get-Service Wecsvc).Status -ne 'Stopped'){Stop-Service Wecsvc} if($wec.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Disabled} if((Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){if($beforeDelayed.ValueExists){$null=New-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -Value $beforeDelayed.Value -PropertyType $beforeDelayed.Type -Force}else{Remove-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -ErrorAction Stop}} - $servicesOk=(Key (Services)) -ceq (Key $beforeServices) -and (Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -ceq (Key $beforeDelayed) + $afterServices=Services;$afterDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart + $servicesOk=(Key $afterServices) -ceq (Key $beforeServices) -and (Key $afterDelayed) -ceq (Key $beforeDelayed) }catch{$errors+=$_.ToString()} + Save 'after-fixture.json' @{Services=$afterServices;Channel=$afterChannel;DelayedAutoStart=$afterDelayed} $artifacts=@(Get-ChildItem $root -File|ForEach-Object {[pscustomobject]@{Name=$_.Name;Bytes=$_.Length;Sha256=(Get-FileHash $_.FullName).Hash.ToLowerInvariant()}}) Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$errors;SubscriptionsRestored=$inventoryOk;ServicesRestored=$servicesOk;ChannelPreserved=$channelOk;Complete=($inventoryOk -and $servicesOk -and $channelOk -and -not $errors.Count);Assertions=$count;Engine=$PSVersionTable.PSVersion.ToString();Host=$hostState;Sources=$sources;Artifacts=$artifacts;PublicReports=$reports;Scope='Native local collector observation only; real standalone prerequisites remain incomplete.'} } From 180ecaddbf9524b0caa701352c761c0131bbe037 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:22:44 +0900 Subject: [PATCH 09/10] Use explicit supported shells for native collector smoke --- .github/workflows/wec-collector-observation.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wec-collector-observation.yml b/.github/workflows/wec-collector-observation.yml index d43580c3..dc1b3aa4 100644 --- a/.github/workflows/wec-collector-observation.yml +++ b/.github/workflows/wec-collector-observation.yml @@ -33,8 +33,13 @@ jobs: ./tests/WecSubscriptionXml.Tests.ps1 ./tests/WefDeployment.Tests.ps1 ./tests/WefDeployment.Cli.Tests.ps1 - - name: Existing read-only source and collector service preservation - shell: ${{ matrix.engine }} + - name: Existing read-only service preservation in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/WefDeployment.Windows.Tests.ps1 + - name: Existing read-only service preservation in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh run: ./tests/WefDeployment.Windows.Tests.ps1 - name: Actual public collector observations in Windows PowerShell 5.1 if: matrix.engine == 'powershell' From 8639bbd750ea4270d45fa80d0c7783bb59b26c71 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:23:04 +0900 Subject: [PATCH 10/10] Pass literal named switches in negative public provider cases --- tests/NativeProviderConfigure.Windows.Tests.ps1 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/NativeProviderConfigure.Windows.Tests.ps1 b/tests/NativeProviderConfigure.Windows.Tests.ps1 index 2147690b..52019079 100644 --- a/tests/NativeProviderConfigure.Windows.Tests.ps1 +++ b/tests/NativeProviderConfigure.Windows.Tests.ps1 @@ -42,8 +42,12 @@ $p=Get-Content -LiteralPath $InputPath -Raw|ConvertFrom-Json $a=@{ProviderAction=[string]$p.Action;ProviderPack=[string[]]$p.Names;ResultsPath=[string]$p.ResultsPath} if($p.Action -ceq 'Configure'){$a.Auto=$true;$a.BackupPath=[string]$p.BackupPath} if($p.DryRun){$a.DryRun=$true} -$extra=[string[]]$p.Extra -& ([string]$p.Script) provider-packs @a @extra +# Array-splatted strings are positional values, not named PowerShell switches. +# Fixed literal branches exercise the public parameter parser exactly. +if(@($p.Extra).Count -eq 0){& ([string]$p.Script) provider-packs @a} +elseif(@($p.Extra).Count -eq 1 -and $p.Extra[0] -ceq '-WhatIf'){& ([string]$p.Script) provider-packs @a -WhatIf} +elseif(@($p.Extra).Count -eq 1 -and $p.Extra[0] -ceq '-GrantEventLogReaders'){& ([string]$p.Script) provider-packs @a -GrantEventLogReaders} +else{throw 'Unreviewed fixture option.'} exit $global:LASTEXITCODE '@ | Set-Content -LiteralPath $wrapper -Encoding UTF8 function Public([string]$Name,[string]$Action,[string[]]$Names,[switch]$DryRun,[int]$Expected=0,[string[]]$Extra=@()){