From 1bf6bc26b3bff71515d9bd2507d66737f8343c32 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:36:29 +0900 Subject: [PATCH 1/4] Add opt-in native WEF channel settings and preserved CAPI2 read access --- .github/workflows/native-channel-access.yml | 25 +++ CHANGELOG-Japanese.md | 2 + CHANGELOG.md | 2 + WELA.ps1 | 35 +++- config/native_channel_profile.json | 206 ++++++++++++++++++++ docs/native-channel-access.md | 46 +++++ modules/NativeChannelAccess.psm1 | 120 ++++++++++++ scripts/Configuration.ps1 | 2 +- scripts/NativeChannelConfiguration.ps1 | 142 ++++++++++++++ tests/NativeChannelAccess.Tests.ps1 | 159 +++++++++++++++ tests/NativeChannelAccess.Windows.Tests.ps1 | 86 ++++++++ website/docs/resources/changelog.ja.md | 2 + website/docs/resources/changelog.md | 2 + 13 files changed, 826 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/native-channel-access.yml create mode 100644 config/native_channel_profile.json create mode 100644 docs/native-channel-access.md create mode 100644 modules/NativeChannelAccess.psm1 create mode 100644 scripts/NativeChannelConfiguration.ps1 create mode 100644 tests/NativeChannelAccess.Tests.ps1 create mode 100644 tests/NativeChannelAccess.Windows.Tests.ps1 diff --git a/.github/workflows/native-channel-access.yml b/.github/workflows/native-channel-access.yml new file mode 100644 index 00000000..7404a681 --- /dev/null +++ b/.github/workflows/native-channel-access.yml @@ -0,0 +1,25 @@ +name: Native channel access regressions +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + native-channel-access: + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Safe command and runner fixtures in Windows PowerShell 5.1 + shell: powershell + run: ./tests/NativeChannelAccess.Tests.ps1 + - name: Safe command and runner fixtures in PowerShell 7 + shell: pwsh + run: ./tests/NativeChannelAccess.Tests.ps1 + - name: Real descriptor and read-only CLI smoke in Windows PowerShell 5.1 + shell: powershell + run: ./tests/NativeChannelAccess.Windows.Tests.ps1 + - name: Real descriptor and read-only CLI smoke in PowerShell 7 + shell: pwsh + run: ./tests/NativeChannelAccess.Windows.Tests.ps1 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index c79f4a86..f896b363 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (issue #367) (@Shirofune-Security) + - ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security) - イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab71489f..0e5145e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (issue #367) (@Shirofune-Security) + - Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security) - Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index c8196e0a..10f7977f 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -22,6 +22,10 @@ [ValidateRange(16384, 32767)][int]$FirewallMinimumSizeKiB = 16384, [string]$HtmlPath, [ValidateSet('Audit', 'Plan', 'Configure')][string]$SmbAction = 'Audit', + [ValidateSet('Audit', 'Plan', 'Configure')][string]$ChannelAction = 'Audit', + [string]$ChannelProfile = 'microsoft-wef-appendix-c', + [ValidateSet('Baseline', 'Suspect', 'Both')][string]$WefQuerySet = 'Both', + [switch]$GrantEventLogReaders, [switch]$Help ) @@ -42,6 +46,8 @@ Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1") +Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorAction Stop +. (Join-Path $ScriptRoot "scripts/NativeChannelConfiguration.ps1") # 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。 $PowerShellPolicyRoots = @( @@ -1666,6 +1672,10 @@ function Get-WelaUserProfiles { $usage = @" Usage: + ./WELA.ps1 channel-settings -ChannelAction Audit -WefQuerySet Both -ResultsPath channels.json + ./WELA.ps1 channel-settings -ChannelAction Plan -GrantEventLogReaders + ./WELA.ps1 channel-settings -ChannelAction Configure -GrantEventLogReaders -DryRun + # Native channels only; ACL changes require -GrantEventLogReaders. Forwarding identity access needs a separate test. ./WELA.ps1 firewall-logging -FirewallAction Audit -ResultsPath firewall.json ./WELA.ps1 firewall-logging -FirewallAction Plan -FirewallPathMode CisV4 ./WELA.ps1 firewall-logging -FirewallAction Configure -DryRun @@ -1705,8 +1715,9 @@ Write-Host "" # Reject unsupported dry-run requests before reaching any command's mutation path. if ($DryRun -and $Cmd -notin @('configure', 'configure-eventlogs') -and -not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and - -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure')) { - throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure and smb-auditing -SmbAction Configure. No command was run." + -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and + -not ($Cmd -eq 'channel-settings' -and $ChannelAction -eq 'Configure')) { + throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure and channel-settings -ChannelAction Configure. No command was run." } if ($Profile -and $Cmd -in @('eventlog-profiles', 'audit-filesize', 'configure-eventlogs')) { throw '-Profile selects advanced audit policy only. Use -LogProfile for event-log size/mode settings.' @@ -1718,12 +1729,32 @@ if (($ResizeLogs -or $ApplyLogMode) -and $Cmd -ne 'configure-eventlogs') { throw '-ResizeLogs and -ApplyLogMode require configure-eventlogs. No command was run.' } +if (($PSBoundParameters.ContainsKey('ChannelAction') -or $PSBoundParameters.ContainsKey('ChannelProfile') -or + $PSBoundParameters.ContainsKey('WefQuerySet') -or $GrantEventLogReaders) -and $Cmd -ne 'channel-settings') { + throw 'Channel options require channel-settings. No command was run.' +} + if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'configure') -and -not $Help) { Invoke-WelaProfileCommand -Command $Cmd.ToLower() return } switch ($Cmd.ToLower()) { + 'channel-settings' { + if ($Help) { + Write-Host 'Usage: ./WELA.ps1 channel-settings [-ChannelAction Audit|Plan|Configure] [-ChannelProfile microsoft-wef-appendix-c] [-WefQuerySet Baseline|Suspect|Both] [-GrantEventLogReaders] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' + Write-Host 'Audits CAPI2/native WEF prerequisites. Configure enables/grows declared channels; only -GrantEventLogReaders permits adding the CAPI2 read ACE. Existing descriptor entries and retention are preserved. See docs/native-channel-access.md.' + return + } + if ($Profile -or $Baseline) { throw 'channel-settings uses -ChannelProfile; -Profile and -Baseline select Security audit settings.' } + if ($HtmlPath) { throw 'channel-settings exports JSON through -ResultsPath; -HtmlPath is not supported.' } + if ($ChannelAction -eq 'Configure' -and -not (TestAdministrator)) { throw 'channel-settings Configure requires Administrator privileges.' } + try { + $report = Invoke-WelaNativeChannelCommand -Action $ChannelAction -Profile $ChannelProfile -QuerySet $WefQuerySet -GrantEventLogReaders:$GrantEventLogReaders -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath + $report + if ($report.ExitCode) { exit $report.ExitCode } + } catch { Write-Host "[Failed] Native channel settings: $_" -ForegroundColor Red; exit 1 } + } 'firewall-logging' { if ($Help) { Write-Host 'Usage: ./WELA.ps1 firewall-logging [-FirewallAction Audit|Plan|Configure] [-FirewallPathMode Preserve|CisV4] [-FirewallMinimumSizeKiB 16384..32767] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]' diff --git a/config/native_channel_profile.json b/config/native_channel_profile.json new file mode 100644 index 00000000..8dcee5d2 --- /dev/null +++ b/config/native_channel_profile.json @@ -0,0 +1,206 @@ +{ + "schemaVersion": 1, + "id": "microsoft-wef-appendix-c", + "scope": "native-channel-settings-only", + "source": "https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection", + "reviewed": "2026-09-19", + "controls": [ + { + "channel": "Microsoft-Windows-CAPI2/Operational", + "enabled": true, + "sourceExampleBytes": 102432768, + "readerSid": "S-1-5-32-573", + "readerMask": 1 + }, + { + "channel": "Microsoft-Windows-AppLocker/EXE and DLL", + "enabled": null, + "sourceExampleBytes": 102432768, + "readerSid": null, + "readerMask": null + }, + { + "channel": "Microsoft-Windows-DriverFrameworks-UserMode/Operational", + "enabled": true, + "sourceExampleBytes": 52432896, + "readerSid": null, + "readerMask": null + } + ], + "querySets": { + "Baseline": { + "channels": [ + { + "name": "Application", + "queryIds": [ + "15", + "37", + "40" + ] + }, + { + "name": "Microsoft-Windows-AppLocker/EXE and DLL", + "queryIds": [ + "1" + ] + }, + { + "name": "Microsoft-Windows-AppLocker/MSI and Script", + "queryIds": [ + "1" + ] + }, + { + "name": "Microsoft-Windows-AppLocker/Packaged app-Deployment", + "queryIds": [ + "11" + ] + }, + { + "name": "Microsoft-Windows-AppLocker/Packaged app-Execution", + "queryIds": [ + "10" + ] + }, + { + "name": "Microsoft-Windows-SMBClient/Operational", + "queryIds": [ + "36" + ] + }, + { + "name": "Microsoft-Windows-SmartCard-Audit/Authentication", + "queryIds": [ + "35" + ] + }, + { + "name": "Microsoft-Windows-TaskScheduler/Operational", + "queryIds": [ + "3" + ] + }, + { + "name": "Microsoft-Windows-TerminalServices-RDPClient/Operational", + "queryIds": [ + "31" + ] + }, + { + "name": "Microsoft-Windows-Windows Defender/Operational", + "queryIds": [ + "41" + ] + }, + { + "name": "Security", + "queryIds": [ + "2", + "5", + "6", + "7", + "8", + "14", + "16", + "18", + "19", + "20", + "21", + "22", + "23", + "26", + "27", + "28", + "29", + "30", + "32", + "34", + "42" + ] + }, + { + "name": "System", + "queryIds": [ + "0", + "3", + "4", + "5", + "9", + "13", + "17" + ] + } + ], + "excludedQueries": [ + { + "queryId": "12", + "reason": "EMET is not built in" + }, + { + "queryId": "39", + "reason": "Sysmon is out of scope" + } + ] + }, + "Suspect": { + "channels": [ + { + "name": "Microsoft-Windows-CAPI2/Operational", + "queryIds": [ + "2" + ] + }, + { + "name": "Microsoft-Windows-DNS-Client/Operational", + "queryIds": [ + "7" + ] + }, + { + "name": "Microsoft-Windows-DriverFrameworks-UserMode/Operational", + "queryIds": [ + "13" + ] + }, + { + "name": "Microsoft-Windows-LSA/Operational", + "queryIds": [ + "4" + ] + }, + { + "name": "Microsoft-Windows-PowerShell/Operational", + "queryIds": [ + "12" + ] + }, + { + "name": "Security", + "queryIds": [ + "0", + "3", + "5", + "6", + "8", + "9", + "10", + "11" + ] + }, + { + "name": "System", + "queryIds": [ + "1" + ] + }, + { + "name": "Windows PowerShell", + "queryIds": [ + "14" + ] + } + ], + "excludedQueries": [] + } + } +} diff --git a/docs/native-channel-access.md b/docs/native-channel-access.md new file mode 100644 index 00000000..2a86baa1 --- /dev/null +++ b/docs/native-channel-access.md @@ -0,0 +1,46 @@ +# Native channel settings and CAPI2 access + +`channel-settings` audits, plans and optionally applies the native channel examples in Microsoft's WEF Appendix C. Its separate query inventory identifies the channels required by the selected Appendix E/F queries. This is an opt-in command; ordinary `configure` does not change channel ACLs. + +```powershell +.\WELA.ps1 channel-settings -ChannelAction Audit -WefQuerySet Baseline -ResultsPath channels.json +.\WELA.ps1 channel-settings -ChannelAction Plan -WefQuerySet Both -GrantEventLogReaders -ResultsPath plan.json +.\WELA.ps1 channel-settings -ChannelAction Configure -GrantEventLogReaders -DryRun -ResultsPath preview.json +# Elevated Windows shell, after reviewing the plan; prompts unless -Auto is supplied: +.\WELA.ps1 channel-settings -ChannelAction Configure -GrantEventLogReaders -BackupPath C:\WELA-Recovery\channels-run1 -ResultsPath result.json +``` + +The named `-ChannelProfile microsoft-wef-appendix-c` is the only profile. `-WefQuerySet Baseline|Suspect|Both` selects **inventory**, not which Appendix C controls are applied. Audit and Plan never modify Windows. Configure always requests the three declared enable/size controls; adding the CAPI2 reader ACE additionally requires `-GrantEventLogReaders`. Without it, the existing descriptor is preserved and a missing read grant remains an unmet prerequisite. JSON exports include the full current/proposed descriptor, source bytes, native read failures, query IDs and unverified prerequisites. Access failures stay unknown; unregistered channels stay not installed and require role/query review. + +| Channel | Enabled setting | Source example bytes | Rounded minimum applied | Access request | +|---|---|---:|---:|---| +| Microsoft-Windows-CAPI2/Operational | Enable | 102432768 | 102432768 | Event Log Readers read, explicit opt-in | +| Microsoft-Windows-AppLocker/EXE and DLL | Preserve | 102432768 | 102432768 | Preserve | +| Microsoft-Windows-DriverFrameworks-UserMode/Operational | Enable | 52432896 | 52494336 | Preserve | + +The source examples are **not** 100 MiB and 50 MiB. Larger existing limits and retention modes are preserved. These are channel buffer examples, not promised retention duration. Source: [Microsoft WEF Appendix C](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection#appendix-c---event-channel-settings-enable-and-channel-access-methods). Applied limits round upward to a 64 KiB unit as required by [wevtutil](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wevtutil). + +## Permission and mutation boundaries + +The appended ACE grants SID `S-1-5-32-573` (Event Log Readers) **read only**, access mask `0x1`. Existing ACEs are not broadened or removed; even an existing write-only grant is retained and a separate read ACE is appended. WELA does not copy Microsoft's complete example descriptor over the host descriptor. Event Log read, write and clear are separate [Windows access constants](https://learn.microsoft.com/en-us/windows/win32/wes/windows-event-log-constants). + +The planner uses [RawSecurityDescriptor](https://learn.microsoft.com/en-us/dotnet/api/system.security.accesscontrol.rawsecuritydescriptor?view=netframework-4.8.1), clones its binary representation, inserts an explicit allow before the first inherited ACE and verifies an exact binary round trip through the proposed SDDL. Owner, group, SACL, control flags and every existing ACE byte/order must survive. No ACL canonicalization occurs. Absent/null DACLs, any applicable read-deny ACE, unknown ACEs and descriptors that cannot round-trip losslessly require manual review and are left unchanged. Recognized object/callback ACEs are retained only if lossless serialization succeeds. This conservative rule may decline descriptors that an administrator can safely edit manually. + +`GrantPresent` describes an unconditional group read ACE in the descriptor. It **does not establish effective read access** for any user or service token. Group membership, denied groups, privileges, actual event reads and forwarding remain separate. Read permission also does not establish AppLocker policy, provider generation readiness, or Sigma rule usability. + +The shared configuration runner writes `before.jsonl` before each native mutation, capturing the original enabled state, exact size, full descriptor and retention mode. A fresh read must match both the plan and the journal snapshot before `wevtutil sl` executes. Only changed `/e:true`, `/ms:...` and explicitly authorized `/ca:...` arguments are sent. Native failure, failed readback, descriptor mismatch and final drift produce a nonzero result. There is no atomic Windows compare-and-set; another writer can still race the final check. Re-run after policy refresh to check persistence. No automatic rollback occurs. + +Recovery is manual: review each journal `Before` against the current settings, identify the affected channel, and restore only the intended previous values using `wevtutil sl "CHANNEL" /e:true|false /ms:ORIGINAL_BYTES /ca:"ORIGINAL_SDDL"`. Pass the descriptor as one argument in PowerShell, for example `& wevtutil.exe sl $entry.Target.Channel ("/ca:" + $entry.Before.SecurityDescriptor)` after loading and reviewing the relevant JSONL entry. Restoring a smaller limit can discard events. Existing retention is not intentionally modified; investigate any changed mode before choosing recovery actions. Use a new backup directory for each run. + +## Native WEF prerequisites and validation + +The checked-in inventory maps source query IDs to 12 baseline channels and 8 suspect channels (18 unique combined). Baseline queries 12 (EMET) and 39 (Sysmon) are explicitly excluded. Native-only scope excludes external agents; channel settings do not create subscriptions, add service identities to groups or configure WinRM/collectors. The Microsoft [source prerequisite guidance and sample queries](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection) remain a starting point for reviewing role applicability. The report always lists token/group membership, producer configuration, representative event generation and forwarding/ingestion as unverified; required disabled or missing channels remain visible. Other inventoried channels are not automatically enabled. + +Safe tests exercise the actual command/JSON/runner with mocked Windows setters. Windows PowerShell 5.1 and PowerShell 7 CI additionally exercise real descriptor serialization and read-only CLI inspection. Release packaging already includes the whole `config`, `modules` and `scripts` directories. + +**Isolated Windows acceptance evidence is still pending; related to issue #367, not sufficient to close it.** On patched Windows 11, member server, DC and ADCS snapshots where the channels exist: + +1. Save the plan, channel metadata, descriptor and policy context. Review capacity and the intended forwarding identity. Capture the actual identity/token memberships separately. +2. Apply the opt-in profile, retain the journal/results, then independently read enablement, exact bytes and full SDDL. Compare all original ACEs plus owner/group/SACL/flags and repeat after policy refresh. +3. Using the intended forwarding identity's actual token, read CAPI2 event records. An administrator's successful query or a matching group ACE is insufficient evidence. Record denied/missing cases explicitly. +4. Generate a benign native event appropriate to the isolated role, retain its XML and verify matching collector ingestion under the intended subscription. WELA does not perform this test or claim any measured Sigma coverage increase. diff --git a/modules/NativeChannelAccess.psm1 b/modules/NativeChannelAccess.psm1 new file mode 100644 index 00000000..2e1942c4 --- /dev/null +++ b/modules/NativeChannelAccess.psm1 @@ -0,0 +1,120 @@ +# Native channel metadata and lossless, read-only ACL planning. Windows PowerShell 5.1. +function Get-WelaNativeChannelProfile { + param([string]$Id = 'microsoft-wef-appendix-c') + $profile = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/native_channel_profile.json') -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($Id -ne $profile.id -or $profile.schemaVersion -ne 1 -or $profile.scope -ne 'native-channel-settings-only') { throw "Unknown/invalid native channel profile '$Id'." } + $names = @{} + foreach ($control in $profile.controls) { + if (-not $control.channel -or $control.channel -match '[*?\[\]\r\n]' -or $names.ContainsKey($control.channel)) { throw 'Invalid/duplicate native channel name.' } + $names[$control.channel] = $true + if ($null -ne $control.enabled -and ($control.enabled -isnot [bool] -or -not $control.enabled)) { throw 'Native channel profiles may only enable a channel or preserve its enabled state.' } + if ($control.sourceExampleBytes -isnot [int] -and $control.sourceExampleBytes -isnot [long]) { throw 'Channel size must be an integer byte count.' } + $null = ConvertTo-WelaEventLogBytes $control.sourceExampleBytes + if ($control.readerSid -and ($control.readerSid -ne 'S-1-5-32-573' -or $control.readerMask -ne 1)) { throw 'Only the Event Log Readers read grant is supported.' } + } + foreach ($name in @('Baseline', 'Suspect')) { + if (@($profile.querySets.$name.channels).Count -eq 0) { throw "Empty native WEF query inventory: $name" } + foreach ($channel in $profile.querySets.$name.channels) { + if (-not $channel.name -or $channel.name -match '[*?\[\]\r\n]|Sysmon' -or @($channel.queryIds).Count -eq 0) { throw 'Invalid native WEF query inventory.' } + } + } + return $profile +} + +function Get-WelaDescriptorBytes { + param($Descriptor) + $bytes = New-Object byte[] $Descriptor.BinaryLength + $Descriptor.GetBinaryForm($bytes, 0) + return ,$bytes +} + +function Test-WelaChannelDescriptorEqual { + param([string]$First, [string]$Second) + if (-not $First -or -not $Second) { return $false } + try { + $a = [System.Security.AccessControl.RawSecurityDescriptor]::new($First) + $b = [System.Security.AccessControl.RawSecurityDescriptor]::new($Second) + return [Convert]::ToBase64String((Get-WelaDescriptorBytes $a)) -ceq [Convert]::ToBase64String((Get-WelaDescriptorBytes $b)) + } catch { return $false } +} + +function Get-WelaChannelAccessPlan { + param([string]$SecurityDescriptor) + $result = [ordered]@{ + State = 'Unknown'; Sid = 'S-1-5-32-573'; AccessMask = 1 + ProposedDescriptor = $null; ExistingAceCount = $null; AddedAceIndex = $null + EffectiveReadAccess = 'Not tested'; Diagnostic = '' + } + try { + if (-not $SecurityDescriptor) { throw 'Channel security descriptor was not readable.' } + $original = [System.Security.AccessControl.RawSecurityDescriptor]::new($SecurityDescriptor) + if (-not ($original.ControlFlags -band [System.Security.AccessControl.ControlFlags]::DiscretionaryAclPresent) -or $null -eq $original.DiscretionaryAcl) { + throw 'Absent/null DACL requires manual review; adding a DACL would change unrelated access.' + } + $result.ExistingAceCount = $original.DiscretionaryAcl.Count + $grant = $false; $deny = $false; $unknownAce = $false + foreach ($ace in $original.DiscretionaryAcl) { + if ($ace -isnot [System.Security.AccessControl.KnownAce]) { $unknownAce = $true; continue } + if ($ace.AceFlags -band [System.Security.AccessControl.AceFlags]::InheritOnly) { continue } + $readMask = ($ace.AccessMask -band 1) -or ($ace.AccessMask -band 268435456) -or ($ace.AccessMask -band [int]::MinValue) + if ($readMask -and $ace.AceQualifier -eq [System.Security.AccessControl.AceQualifier]::AccessDenied) { $deny = $true } + if ($ace -is [System.Security.AccessControl.CommonAce] -and -not $ace.IsCallback -and + $ace.AceQualifier -eq [System.Security.AccessControl.AceQualifier]::AccessAllowed -and + $ace.SecurityIdentifier.Value -eq $result.Sid -and ($ace.AccessMask -band 1)) { $grant = $true } + } + if ($unknownAce) { throw 'An unknown ACE requires manual review; the descriptor is preserved without mutation.' } + if ($deny) { throw 'A read-deny ACE may affect the forwarding token; the descriptor is preserved for manual review.' } + if ($grant) { $result.State = 'GrantPresent'; return [pscustomobject]$result } + $copy = [System.Security.AccessControl.RawSecurityDescriptor]::new((Get-WelaDescriptorBytes $original), 0) + $newAce = [System.Security.AccessControl.CommonAce]::new( + [System.Security.AccessControl.AceFlags]::None, + [System.Security.AccessControl.AceQualifier]::AccessAllowed, 1, + [System.Security.Principal.SecurityIdentifier]::new($result.Sid), $false, $null) + # Retain every existing ACE, including callback/object ACEs, in original order. + # Place the explicit allow before inherited entries; do not canonicalize others. + $index = $copy.DiscretionaryAcl.Count + for ($i = 0; $i -lt $copy.DiscretionaryAcl.Count; $i++) { + if ($copy.DiscretionaryAcl[$i].AceFlags -band [System.Security.AccessControl.AceFlags]::Inherited) { $index = $i; break } + } + $copy.DiscretionaryAcl.InsertAce($index, $newAce) + $sddl = $copy.GetSddlForm([System.Security.AccessControl.AccessControlSections]::All) + $roundTrip = [System.Security.AccessControl.RawSecurityDescriptor]::new($sddl) + if ([Convert]::ToBase64String((Get-WelaDescriptorBytes $copy)) -cne [Convert]::ToBase64String((Get-WelaDescriptorBytes $roundTrip))) { + throw 'SDDL conversion was not lossless; refusing to replace the channel descriptor.' + } + $result.State = 'GrantRequired'; $result.ProposedDescriptor = $sddl; $result.AddedAceIndex = $index + } catch { + $result.State = 'ManualReview'; $result.Diagnostic = $_.Exception.Message + } + [pscustomobject]$result +} + +function Get-WelaNativeChannelInventory { + param($Profile, [ValidateSet('Baseline', 'Suspect', 'Both')][string]$QuerySet = 'Both') + $selected = if ($QuerySet -eq 'Both') { @('Baseline', 'Suspect') } else { @($QuerySet) } + $entries = @{} + foreach ($set in $selected) { + foreach ($channel in $Profile.querySets.$set.channels) { + if (-not $entries.ContainsKey($channel.name)) { $entries[$channel.name] = @() } + $entries[$channel.name] += [pscustomobject]@{ QuerySet = $set; QueryIds = @($channel.queryIds) } + } + } + foreach ($name in @($entries.Keys | Sort-Object)) { + $channel = Get-WelaNativeChannel -Name $name + $unmet = @() + if ($channel.State -eq 'Not installed') { $unmet += 'Channel not installed; review role/query applicability.' } + elseif ($channel.State -ne 'Enabled') { $unmet += "Channel enabled state is $($channel.State)." } + if (-not $channel.SecurityDescriptor) { $unmet += 'Channel security descriptor unreadable.' } + # A channel ACE does not establish group membership, token access, producer + # configuration or WEF ingestion. No usable-rule credit is derived here. + $unmet += @('Event producer/audit policy and representative event generation not verified.', + 'Intended forwarding identity token and actual event read not tested.', + 'WEF subscription/transport and collector ingestion not verified.') + [pscustomobject]@{ + Channel = $channel; Queries = @($entries[$name]); Prerequisites = $unmet + EffectiveReadAccess = 'Not tested'; EventGeneration = 'Not tested'; Forwarding = 'Not tested' + } + } +} + +Export-ModuleMember -Function Get-WelaNativeChannelProfile, Test-WelaChannelDescriptorEqual, Get-WelaChannelAccessPlan, Get-WelaNativeChannelInventory diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1 index b0fe1121..bd200c68 100644 --- a/scripts/Configuration.ps1 +++ b/scripts/Configuration.ps1 @@ -94,7 +94,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")] + [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")] [string]$Scope = "native-windows-configuration", [string]$SuccessMessage = 'Configuration completed; all requested controls verified.') # A second read detects a value that was compliant earlier but changed during diff --git a/scripts/NativeChannelConfiguration.ps1 b/scripts/NativeChannelConfiguration.ps1 new file mode 100644 index 00000000..ef10f8b7 --- /dev/null +++ b/scripts/NativeChannelConfiguration.ps1 @@ -0,0 +1,142 @@ +# Uses the shared configuration runner; no live writes occur in Audit or Plan. +function Test-WelaNativeChannelSnapshot { + param($Snapshot) + return $Snapshot.State -in @('Enabled', 'Disabled') -and $Snapshot.IsEnabled -is [bool] -and + $null -ne $Snapshot.MaximumSizeInBytes -and $Snapshot.MaximumSizeInBytes -gt 0 -and + $Snapshot.LogMode -in @('Circular', 'AutoBackup', 'Retain') -and + -not [string]::IsNullOrWhiteSpace($Snapshot.SecurityDescriptor) +} + +function Test-WelaNativeChannelSnapshotEqual { + param($First, $Second) + if (-not (Test-WelaNativeChannelSnapshot $First) -or -not (Test-WelaNativeChannelSnapshot $Second)) { return $false } + return $First.Name -eq $Second.Name -and $First.IsEnabled -eq $Second.IsEnabled -and + $First.MaximumSizeInBytes -eq $Second.MaximumSizeInBytes -and $First.LogMode -eq $Second.LogMode -and + (Test-WelaChannelDescriptorEqual $First.SecurityDescriptor $Second.SecurityDescriptor) +} + +function Get-WelaNativeChannelPlan { + param($Profile, [switch]$GrantEventLogReaders) + foreach ($control in $Profile.controls) { + $before = Get-WelaNativeChannel -Name $control.channel + $access = if ($control.readerSid) { Get-WelaChannelAccessPlan -SecurityDescriptor $before.SecurityDescriptor } else { $null } + $minimum = ConvertTo-WelaEventLogBytes $control.sourceExampleBytes + $valid = Test-WelaNativeChannelSnapshot $before + $desiredAcl = $before.SecurityDescriptor + if ($GrantEventLogReaders -and $control.readerSid -and $access.State -eq 'GrantRequired') { $desiredAcl = $access.ProposedDescriptor } + $status = if (-not $valid) { if ($before.State -eq 'Not installed') { 'NotInstalled' } else { 'Unknown' } } + elseif ($GrantEventLogReaders -and $access -and $access.State -notin @('GrantPresent', 'GrantRequired')) { 'ManualReview' } + elseif (($null -ne $control.enabled -and $before.IsEnabled -ne $control.enabled) -or $before.MaximumSizeInBytes -lt $minimum -or + ($GrantEventLogReaders -and $access -and $access.State -eq 'GrantRequired')) { 'ChangeRequired' } else { 'RequestedSettingsMatch' } + [pscustomobject][ordered]@{ + Definition = $control; Before = $before; Status = $status; Access = $access + Desired = [pscustomobject]@{ + IsEnabled = $(if ($null -eq $control.enabled) { $before.IsEnabled } else { $control.enabled }) + SourceExampleBytes = [long]$control.sourceExampleBytes; RoundedMinimumBytes = $minimum + MaximumSizeInBytes = $(if ($valid) { [math]::Max([long]$before.MaximumSizeInBytes, $minimum) } else { $null }) + LogMode = $before.LogMode; SecurityDescriptor = $desiredAcl + AccessChangeRequested = [bool]($GrantEventLogReaders -and $control.readerSid) + } + Prerequisites = @($(if ($access -and $access.State -ne 'GrantPresent') { "Event Log Readers read ACE: $($access.State). Use -GrantEventLogReaders only after reviewing the proposed descriptor; manual-review states cannot be changed automatically." }), + 'Effective forwarding identity read access and actual event/forwarding evidence remain unverified.') | Where-Object { $_ } + } + } +} + +function Set-WelaNativeChannelControls { + param($Context, [array]$Plan, [string]$Profile) + foreach ($entry in $Plan) { + $channel = $entry.Definition.channel + $id = "NativeChannel/$channel/Settings" + if ($entry.Status -in @('NotInstalled', 'Unknown', 'ManualReview')) { + $Context.Results.Add([pscustomobject]@{ + Id = $id; Kind = 'NativeChannel'; Target = @{ Channel = $channel; Profile = $Profile } + Desired = $entry.Desired; Before = $entry.Before; After = $null; Status = 'Failed' + Diagnostic = "$($entry.Status): channel metadata/ACL cannot safely be configured. $($entry.Access.Diagnostic)" + }) + continue + } + $state = @{ Entry = $entry; InitialRead = $true; Snapshot = $null } + $read = { + param($state) + $current = Get-WelaNativeChannel -Name $state.Entry.Definition.channel + if (-not (Test-WelaNativeChannelSnapshot $current)) { throw 'Channel settings became unreadable; no assumed defaults are used.' } + if ($state.InitialRead) { + # The plan may outlive another writer. Never apply an ACL based on + # an old descriptor, even before the shared runner's first read. + if (-not (Test-WelaNativeChannelSnapshotEqual $state.Entry.Before $current)) { throw 'Channel settings changed after planning; review a fresh plan before retrying.' } + $state.Snapshot = $current; $state.InitialRead = $false + } + return $current + } + $test = { + param($current, $state) + $desired = $state.Entry.Desired + return $current.IsEnabled -eq $desired.IsEnabled -and $current.MaximumSizeInBytes -eq $desired.MaximumSizeInBytes -and + $current.LogMode -eq $desired.LogMode -and (Test-WelaChannelDescriptorEqual $current.SecurityDescriptor $desired.SecurityDescriptor) + } + $apply = { + param($state) + $entry = $state.Entry + $fresh = Get-WelaNativeChannel -Name $entry.Definition.channel + if (-not (Test-WelaNativeChannelSnapshotEqual $state.Snapshot $fresh)) { throw 'Channel settings changed after the recovery snapshot; no channel write was attempted.' } + $arguments = @('sl', $entry.Definition.channel) + if ($fresh.IsEnabled -ne $entry.Desired.IsEnabled) { $arguments += '/e:true' } + if ($fresh.MaximumSizeInBytes -ne $entry.Desired.MaximumSizeInBytes) { $arguments += "/ms:$($entry.Desired.MaximumSizeInBytes)" } + if (-not (Test-WelaChannelDescriptorEqual $fresh.SecurityDescriptor $entry.Desired.SecurityDescriptor)) { + if (-not $entry.Desired.AccessChangeRequested -or $entry.Access.State -ne 'GrantRequired') { throw 'An ACL difference has no explicit, validated read-grant request.' } + $arguments += "/ca:$($entry.Desired.SecurityDescriptor)" + } + if ($arguments.Count -gt 2) { Invoke-WelaNative -FilePath 'wevtutil.exe' -Arguments $arguments } + } + Invoke-WelaConfigurationControl -Context $Context -Id $id -Kind NativeChannel -Target @{ Channel = $channel; Profile = $Profile } ` + -Desired $entry.Desired -Read $read -Compliant $test -Apply $apply -CallbackState $state ` + -Description "Apply declared enable/minimum-size settings; preserve larger buffers, retention and existing ACEs. Add only the Event Log Readers read ACE when explicitly requested." + } +} + +function Invoke-WelaNativeChannelCommand { + param([ValidateSet('Audit', 'Plan', 'Configure')][string]$Action = 'Audit', + [string]$Profile = 'microsoft-wef-appendix-c', + [ValidateSet('Baseline', 'Suspect', 'Both')][string]$QuerySet = 'Both', + [switch]$GrantEventLogReaders, [switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath) + if ($env:OS -ne 'Windows_NT') { throw 'Native channel settings require Windows.' } + if ($DryRun -and $Action -ne 'Configure') { throw '-DryRun requires ChannelAction Configure; Audit and Plan are read-only.' } + $selected = Get-WelaNativeChannelProfile -Id $Profile + $plan = @(Get-WelaNativeChannelPlan -Profile $selected -GrantEventLogReaders:$GrantEventLogReaders) + if ($Action -eq 'Configure') { + $context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath + Set-WelaNativeChannelControls -Context $context -Plan $plan -Profile $selected.id + $report = Complete-WelaConfiguration -Context $context -Scope 'native-channel-settings-only' ` + -SuccessMessage 'Requested channel settings verified. Forwarding identity read access and event/ingestion evidence remain unverified.' + } else { + $report = [pscustomobject]@{ Scope = 'native-channel-settings-only'; ExitCode = $(if (@($plan | Where-Object Status -in @('Unknown', 'NotInstalled', 'ManualReview')).Count) { 1 } else { 0 }) } + } + # Read inventory after configuration so exports do not show only stale pre-state. + $inventory = @(Get-WelaNativeChannelInventory -Profile $selected -QuerySet $QuerySet) + $current = if ($Action -eq 'Configure') { @(Get-WelaNativeChannelPlan -Profile $selected -GrantEventLogReaders:$GrantEventLogReaders) } else { $plan } + $excluded = @() + foreach ($set in @('Baseline', 'Suspect')) { + if ($QuerySet -eq 'Both' -or $QuerySet -eq $set) { + foreach ($query in $selected.querySets.$set.excludedQueries) { $excluded += [pscustomobject]@{ QuerySet = $set; QueryId = $query.queryId; Reason = $query.reason } } + } + } + $report | Add-Member NoteProperty Action $Action + $report | Add-Member NoteProperty ChannelProfile $selected.id + $report | Add-Member NoteProperty Source $selected.source + $report | Add-Member NoteProperty WefQuerySet $QuerySet + $report | Add-Member NoteProperty GrantEventLogReadersRequested ([bool]$GrantEventLogReaders) + $report | Add-Member NoteProperty Controls $current + $report | Add-Member NoteProperty QueryInventory $inventory + $report | Add-Member NoteProperty ExcludedQueries $excluded + $report | Add-Member NoteProperty ForwardingReadiness 'Not verified' + $report | Add-Member NoteProperty UnverifiedPrerequisites @('Forwarding token/group membership (including Network Service where applicable)', 'WinRM and collector/subscription configuration', 'Representative native events, identity read access and collector ingestion') + Write-Host 'Native query inventory and channel settings are observations only. Forwarding access, event generation and ingestion are not verified; no Sigma coverage increase is claimed.' -ForegroundColor Yellow + $current | Select-Object @{n='Channel';e={$_.Definition.channel}}, Status, @{n='ReaderAce';e={$_.Access.State}}, @{n='SourceBytes';e={$_.Desired.SourceExampleBytes}}, @{n='MinimumBytes';e={$_.Desired.RoundedMinimumBytes}} | Format-Table -AutoSize | Out-Host + $inventory | Select-Object @{n='RequiredChannel';e={$_.Channel.Name}}, @{n='State';e={$_.Channel.State}}, EffectiveReadAccess | Format-Table -AutoSize | Out-Host + if ($ResultsPath) { + try { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop } + catch { $report.ExitCode = 1; Write-Host "[Failed] Writing channel results: $_" -ForegroundColor Red } + } + return $report +} diff --git a/tests/NativeChannelAccess.Tests.ps1 b/tests/NativeChannelAccess.Tests.ps1 new file mode 100644 index 00000000..1788d7b7 --- /dev/null +++ b/tests/NativeChannelAccess.Tests.ps1 @@ -0,0 +1,159 @@ +# Safe fixtures through the public command/report and shared runner. No Windows writes. +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $repo 'modules/EventLogSettings.psm1') -Force +Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force +Import-Module (Join-Path $repo 'modules/NativeChannelAccess.psm1') -Force +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/NativeChannelConfiguration.ps1') +$script:ScriptRoot = $repo +$script:assertions = 0 +$script:cleanup = New-Object 'System.Collections.Generic.List[string]' +function Assert($Condition, [string]$Message) { + if (-not $Condition) { throw "FAIL: $Message" }; $script:assertions++ +} +function New-FixtureState([string]$Name) { + [pscustomobject]@{ Name = $Name; State = 'Disabled'; IsEnabled = $false; LogMode = 'Retain'; SecurityDescriptor = 'fixture-original'; MaximumSizeInBytes = [long]1048576; MetadataErrors = @{}; Error = $null } +} +function Reset-Fixture { + $script:profile = Get-WelaNativeChannelProfile + $global:WelaChannelFixture = @{ States = @{}; Reads = @{}; Writes = (New-Object 'System.Collections.Generic.List[object]'); DriftRead = 0; Failure = ''; Prompt = 'Y' } + foreach ($control in $script:profile.controls) { $global:WelaChannelFixture.States[$control.channel] = New-FixtureState $control.channel } + $script:capi = $script:profile.controls[0].channel + $script:app = $script:profile.controls[1].channel + $script:driver = $script:profile.controls[2].channel + $script:backup = Join-Path ([IO.Path]::GetTempPath()) ('wela-channel-' + [guid]::NewGuid().ToString('N')) + $global:WelaChannelFixture.Backup = $script:backup + $script:cleanup.Add($script:backup) +} +function Get-WelaNativeChannel { + param($Name) + $f = $global:WelaChannelFixture + if (-not $f.States.ContainsKey($Name)) { return New-FixtureState $Name } + if (-not $f.Reads.ContainsKey($Name)) { $f.Reads[$Name] = 0 } + $f.Reads[$Name]++ + if ($f.DriftRead -eq $f.Reads[$Name] -and $Name -eq $script:capi) { $f.States[$Name].SecurityDescriptor = 'fixture-concurrent' } + return $f.States[$Name].PSObject.Copy() +} +# Windows ACL serialization is tested separately against the real .NET APIs. These +# token descriptors let the command/runner fail-path tests execute safely on Linux. +function Test-WelaChannelDescriptorEqual { param($First, $Second) return $First -and $Second -and $First -ceq $Second } +function Get-WelaChannelAccessPlan { + param($SecurityDescriptor) + [pscustomobject]@{ + State = $(if ($SecurityDescriptor -eq 'fixture-granted') { 'GrantPresent' } elseif ($SecurityDescriptor -eq 'fixture-original') { 'GrantRequired' } else { 'ManualReview' }) + ProposedDescriptor = 'fixture-granted'; EffectiveReadAccess = 'Not tested'; Diagnostic = 'Fixture ACL planner' + } +} +function Read-Host { param($Prompt) return $global:WelaChannelFixture.Prompt } +function Invoke-WelaNative { + param($FilePath, $Arguments) + $f = $global:WelaChannelFixture + Assert ($FilePath -eq 'wevtutil.exe' -and $Arguments[0] -eq 'sl') 'Only wevtutil channel settings are written' + $journal = Join-Path $f.Backup 'before.jsonl' + Assert (Test-Path -LiteralPath $journal) 'Recovery journal exists before native write' + $record = @(Get-Content -LiteralPath $journal | ForEach-Object { $_ | ConvertFrom-Json })[-1] + Assert ($record.Target.Channel -eq $Arguments[1] -and $record.Before.SecurityDescriptor -eq $f.States[$Arguments[1]].SecurityDescriptor) 'Journal holds the fresh original descriptor for this channel' + $f.Writes.Add(@($Arguments)) + if ($f.Failure -eq 'native') { throw 'fixture native failure' } + if ($f.Failure -eq 'false-success') { return } + foreach ($argument in $Arguments) { + if ($argument -eq '/e:true') { $f.States[$Arguments[1]].IsEnabled = $true; $f.States[$Arguments[1]].State = 'Enabled' } + if ($argument -like '/ms:*') { $f.States[$Arguments[1]].MaximumSizeInBytes = [long]$argument.Substring(4) } + if ($argument -like '/ca:*') { $f.States[$Arguments[1]].SecurityDescriptor = $argument.Substring(4) } + } +} +$module = Get-Module NativeChannelAccess +& $module { + function script:Get-WelaNativeChannel { + param($Name) + if ($global:WelaChannelFixture.States.ContainsKey($Name)) { return $global:WelaChannelFixture.States[$Name].PSObject.Copy() } + [pscustomobject]@{ Name = $Name; State = 'Not installed'; IsEnabled = $null; LogMode = $null; SecurityDescriptor = $null; MaximumSizeInBytes = $null; MetadataErrors = @{}; Error = @{ Message = 'fixture missing registration' } } + } +} +$savedOS = $env:OS +try { + $env:OS = 'Windows_NT' # Only mocked readers/setters are reachable in this suite. + Reset-Fixture + Assert ($script:profile.controls.Count -eq 3) 'Profile declares exactly the three Appendix C channel examples' + Assert ($script:profile.controls[0].sourceExampleBytes -eq 102432768 -and $script:profile.controls[1].sourceExampleBytes -eq 102432768) 'CAPI2/AppLocker preserve the exact source byte values' + Assert ($script:profile.controls[2].sourceExampleBytes -eq 52432896) 'DriverFrameworks source is not approximated as 50 MiB' + Assert ((ConvertTo-WelaEventLogBytes 52432896) -eq 52494336) 'Applied minimum rounds upward to Windows 64 KiB units' + $caught = $false; try { Get-WelaNativeChannelProfile -Id 'unknown' } catch { $caught = $true } + Assert $caught 'Unknown channel profile is rejected' + $out = $script:backup + '.json'; $script:cleanup.Add($out) + $report = Invoke-WelaNativeChannelCommand -Action Plan -GrantEventLogReaders -ResultsPath $out + $json = Get-Content -LiteralPath $out -Raw | ConvertFrom-Json + Assert ($json.Controls[0].Desired.AccessChangeRequested -and $json.Controls[0].Desired.SecurityDescriptor -eq 'fixture-granted') 'Public JSON contains explicit proposed CAPI2 ACL' + Assert ($json.QueryInventory.Count -eq 18 -and $json.ExcludedQueries.Count -eq 2) 'Both queries inventory 18 unique native channels and exclude EMET/Sysmon' + Assert (@($json.QueryInventory | Where-Object { $_.Channel.Name -like '*Sysmon*' }).Count -eq 0) 'Sysmon is outside native inventory' + Assert (($json.QueryInventory | Where-Object { $_.Channel.Name -eq 'Microsoft-Windows-CAPI2/Operational' }).Queries[0].QueryIds[0] -eq '2') 'Inventory preserves source query IDs' + Assert ($json.ForwardingReadiness -eq 'Not verified' -and @($json.QueryInventory | Where-Object EffectiveReadAccess -ne 'Not tested').Count -eq 0) 'Public export does not infer identity access or forwarding from ACEs' + Assert (($json.QueryInventory | Where-Object { $_.Channel.Name -eq 'Security' }).Channel.State -eq 'Not installed') 'Inventory retains absent channel evidence' + Assert ($global:WelaChannelFixture.Writes.Count -eq 0 -and -not (Test-Path $script:backup)) 'Plan performs no mutation or journal creation' + $report = Invoke-WelaNativeChannelCommand -Action Audit -QuerySet Baseline + Assert ($report.QueryInventory.Count -eq 12) 'Baseline query selection inventories its twelve native channels' + $report = Invoke-WelaNativeChannelCommand -Action Audit -QuerySet Suspect + Assert ($report.QueryInventory.Count -eq 8 -and $report.ExcludedQueries.Count -eq 0) 'Suspect selection remains distinct' + + Reset-Fixture + $report = Invoke-WelaNativeChannelCommand -Action Configure -GrantEventLogReaders -Auto -BackupPath $script:backup + Assert ($report.ExitCode -eq 0 -and $report.Scope -eq 'native-channel-settings-only') 'Configure succeeds only for requested channel settings' + Assert ($global:WelaChannelFixture.Writes.Count -eq 3) 'Configure changes only three declared channels' + Assert ($global:WelaChannelFixture.States[$script:capi].IsEnabled -and $global:WelaChannelFixture.States[$script:capi].SecurityDescriptor -eq 'fixture-granted') 'CAPI2 enablement and ACL are read back' + Assert (-not $global:WelaChannelFixture.States[$script:app].IsEnabled -and $global:WelaChannelFixture.States[$script:app].SecurityDescriptor -eq 'fixture-original') 'AppLocker size control preserves disabled state and ACL' + Assert ($global:WelaChannelFixture.States[$script:driver].MaximumSizeInBytes -eq 52494336) 'DriverFrameworks applied size matches rounded source bytes' + Assert (@($global:WelaChannelFixture.Writes | Where-Object { ($_ -join ' ') -match '/[ar][bt]:' }).Count -eq 0) 'No retention settings are modified' + Assert ($report.Controls[0].Access.State -eq 'GrantPresent' -and $report.Controls[0].Access.EffectiveReadAccess -eq 'Not tested') 'Structural readback never becomes an effective-access claim' + $json = @(Get-Content (Join-Path $script:backup 'before.jsonl') | ForEach-Object { $_ | ConvertFrom-Json }) + Assert ($json[0].Before.MaximumSizeInBytes -eq 1048576 -and $json[0].Before.SecurityDescriptor -eq 'fixture-original' -and $json[0].Before.LogMode -eq 'Retain') 'Journal includes original bytes, full descriptor and retention mode' + + Reset-Fixture + $global:WelaChannelFixture.States[$script:capi].MaximumSizeInBytes = [long]4294967296 + $report = Invoke-WelaNativeChannelCommand -Action Configure -Auto -BackupPath $script:backup + Assert ($global:WelaChannelFixture.States[$script:capi].MaximumSizeInBytes -eq 4294967296) 'Existing larger buffer is preserved' + Assert (@($global:WelaChannelFixture.Writes | Where-Object { ($_ -join ' ') -like '*/ca:*' }).Count -eq 0) 'No ACL change without separate opt-in' + Assert ($report.Controls[0].Access.State -eq 'GrantRequired' -and $report.Controls[0].Prerequisites.Count -ge 2) 'Omitted ACL opt-in remains an unmet profile prerequisite' + + Reset-Fixture + $report = Invoke-WelaNativeChannelCommand -Action Configure -GrantEventLogReaders -DryRun -BackupPath $script:backup + Assert ($report.DryRun -and $report.Skipped -eq 3 -and $global:WelaChannelFixture.Writes.Count -eq 0 -and -not (Test-Path $script:backup)) 'Dry run does no native writes and creates no backup directory' + Reset-Fixture + $global:WelaChannelFixture.Prompt = 'n' + $report = Invoke-WelaNativeChannelCommand -Action Configure -GrantEventLogReaders -BackupPath $script:backup + Assert ($report.Skipped -eq 3 -and $global:WelaChannelFixture.Writes.Count -eq 0) 'Declining prompts preserves every channel' + + foreach ($driftRead in @(2, 3, 5)) { + Reset-Fixture; $global:WelaChannelFixture.DriftRead = $driftRead + $report = Invoke-WelaNativeChannelCommand -Action Configure -GrantEventLogReaders -Auto -QuerySet Baseline -BackupPath $script:backup + Assert ($report.ExitCode -eq 1) "Drift at observation $driftRead cannot report success" + $writes = @($global:WelaChannelFixture.Writes | Where-Object { $_[1] -eq $script:capi }) + Assert ($writes.Count -eq $(if ($driftRead -eq 5) { 1 } else { 0 })) "Plan-to-initial/prewrite drift rejects stale ACL; final drift is detected ($driftRead)" + } + foreach ($failure in @('native', 'false-success', 'denied', 'missing', 'acl')) { + Reset-Fixture; $global:WelaChannelFixture.Failure = $failure + if ($failure -eq 'denied') { $global:WelaChannelFixture.States[$script:capi].State = 'Unknown'; $global:WelaChannelFixture.States[$script:capi].SecurityDescriptor = $null } + if ($failure -eq 'missing') { $global:WelaChannelFixture.States[$script:capi].State = 'Not installed' } + if ($failure -eq 'acl') { $global:WelaChannelFixture.States[$script:capi].SecurityDescriptor = 'fixture-deny' } + $report = Invoke-WelaNativeChannelCommand -Action Configure -GrantEventLogReaders -Auto -BackupPath $script:backup + Assert ($report.ExitCode -eq 1 -and $report.Results[0].Status -eq 'Failed') "Failure $failure remains explicit and nonzero" + if ($failure -in @('denied', 'missing', 'acl')) { Assert (@($global:WelaChannelFixture.Writes | Where-Object { $_[1] -eq $script:capi }).Count -eq 0) "$failure never writes CAPI2" } + } + Reset-Fixture + $plan = @(Get-WelaNativeChannelPlan -Profile $script:profile -GrantEventLogReaders) + $context = New-WelaConfigurationContext -Auto -BackupPath $script:backup + New-Item -ItemType Directory -Path (Join-Path $script:backup 'before.jsonl') | Out-Null + Set-WelaNativeChannelControls -Context $context -Plan $plan -Profile $script:profile.id + Assert ($global:WelaChannelFixture.Writes.Count -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Journal failure blocks all writes' + $caught = $false; try { Invoke-WelaNativeChannelCommand -Action Audit -DryRun } catch { $caught = $true } + Assert $caught 'Unsupported dry-run action is rejected before reads/writes' + Reset-Fixture + $report = Invoke-WelaNativeChannelCommand -Action Plan -ResultsPath (Join-Path $script:backup 'missing/results.json') + Assert ($report.ExitCode -eq 1) 'Failed report export has a nonzero result' + Write-Host "PASS: $script:assertions native channel command/runner assertions. No Windows settings were changed." +} finally { + $env:OS = $savedOS + & $module { Remove-Item Function:script:Get-WelaNativeChannel } + Remove-Variable -Name WelaChannelFixture -Scope Global -ErrorAction SilentlyContinue + foreach ($path in $script:cleanup) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } } +} diff --git a/tests/NativeChannelAccess.Windows.Tests.ps1 b/tests/NativeChannelAccess.Windows.Tests.ps1 new file mode 100644 index 00000000..9ad1d6ca --- /dev/null +++ b/tests/NativeChannelAccess.Windows.Tests.ps1 @@ -0,0 +1,86 @@ +# Real Windows descriptor API tests and read-only metadata/CLI smoke. No channel writes. +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $repo 'modules/EventLogSettings.psm1') -Force +Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force +Import-Module (Join-Path $repo 'modules/NativeChannelAccess.psm1') -Force +$script:assertions = 0 +function Assert($Condition, [string]$Message) { + if (-not $Condition) { throw "FAIL: $Message" }; $script:assertions++ +} +function Binary($Value) { + $bytes = New-Object byte[] $Value.BinaryLength + $Value.GetBinaryForm($bytes, 0) + [Convert]::ToBase64String($bytes) +} +function Check-Preservation([string]$Sddl) { + $before = [System.Security.AccessControl.RawSecurityDescriptor]::new($Sddl) + $plan = Get-WelaChannelAccessPlan -SecurityDescriptor $Sddl + Assert ($plan.State -eq 'GrantRequired') "Descriptor supports lossless append: $($plan.Diagnostic)" + $after = [System.Security.AccessControl.RawSecurityDescriptor]::new($plan.ProposedDescriptor) + Assert ($before.Owner -eq $after.Owner -and $before.Group -eq $after.Group) 'Owner/group are retained' + Assert ($before.ControlFlags -eq $after.ControlFlags -and $before.ResourceManagerControl -eq $after.ResourceManagerControl) 'Control flags are retained' + Assert (($null -eq $before.SystemAcl -and $null -eq $after.SystemAcl) -or ((Binary $before.SystemAcl) -ceq (Binary $after.SystemAcl))) 'Complete SACL bytes are retained' + Assert ($after.DiscretionaryAcl.Count -eq $before.DiscretionaryAcl.Count + 1) 'Exactly one DACL ACE is added' + $j = 0 + for ($i = 0; $i -lt $after.DiscretionaryAcl.Count; $i++) { + if ($i -eq $plan.AddedAceIndex) { + $ace = $after.DiscretionaryAcl[$i] + Assert ($ace.SecurityIdentifier.Value -eq 'S-1-5-32-573' -and $ace.AccessMask -eq 1 -and $ace.AceFlags -eq 0 -and -not $ace.IsCallback) 'New ACE is precisely unconditional Event Log Readers read (no write/clear)' + } else { + Assert ((Binary $before.DiscretionaryAcl[$j]) -ceq (Binary $after.DiscretionaryAcl[$i])) 'Every preexisting ACE stays byte-identical and in order' + $j++ + } + } + Assert ($plan.EffectiveReadAccess -eq 'Not tested') 'Structural grant does not prove effective token access' + $second = Get-WelaChannelAccessPlan -SecurityDescriptor $plan.ProposedDescriptor + Assert ($second.State -eq 'GrantPresent' -and -not $second.ProposedDescriptor) 'Repeated planning does not duplicate the ACE' + Assert (Test-WelaChannelDescriptorEqual $plan.ProposedDescriptor $after.GetSddlForm('All')) 'Binary descriptor comparison handles Windows SDDL formatting' + Assert (-not (Test-WelaChannelDescriptorEqual $Sddl $plan.ProposedDescriptor)) 'Descriptor comparison detects the added ACE' +} + +Check-Preservation 'O:BAG:SYD:PAI(A;;0x7;;;BA)(A;;0x2;;;AU)(A;ID;0x1;;;SY)S:AI(AU;SAFA;0x1;;;WD)' +Check-Preservation 'O:BAG:SYD:(OA;;0x2;00112233-4455-6677-8899-aabbccddeeff;;AU)(A;;0x7;;;BA)' +Check-Preservation 'O:BAG:SYD:(A;;0x2;;;S-1-5-32-573)(A;;0x7;;;BA)' +foreach ($sddl in @('O:BAG:SYD:(A;;0x1;;;S-1-5-32-573)', 'O:BAG:SYD:(A;;0x7;;;S-1-5-32-573)')) { + $result = Get-WelaChannelAccessPlan $sddl + Assert ($result.State -eq 'GrantPresent' -and -not $result.ProposedDescriptor -and $result.EffectiveReadAccess -eq 'Not tested') 'Existing read/superset permission is preserved without claiming event access' +} +foreach ($sddl in @('', 'invalid', 'O:BAG:SY', 'O:BAG:SYD:NO_ACCESS_CONTROL', 'O:BAG:SYD:(D;;0x1;;;WD)(A;;0x7;;;BA)', 'O:BAG:SYD:(D;;GR;;;WD)(A;;0x7;;;BA)')) { + $result = Get-WelaChannelAccessPlan $sddl + Assert ($result.State -eq 'ManualReview' -and -not $result.ProposedDescriptor) 'Missing, invalid, null and denied descriptors refuse automatic modification' +} +# The original unknown ACE bytes must never be discarded. SDDL has no representation +# for arbitrary custom ACEs; parsing/planning must refuse instead of replacing them. +$raw = [System.Security.AccessControl.RawSecurityDescriptor]::new('O:BAG:SYD:(A;;0x7;;;BA)') +$raw.DiscretionaryAcl.InsertAce(1, [System.Security.AccessControl.CustomAce]::new([System.Security.AccessControl.AceType]127, [System.Security.AccessControl.AceFlags]::None, [byte[]]@(0, 0, 0, 0))) +$binaryBefore = Binary $raw +$refused = $false +try { + $sddl = $raw.GetSddlForm('All') + $refused = (Get-WelaChannelAccessPlan $sddl).State -eq 'ManualReview' +} catch { $refused = $true } +Assert ($refused -and (Binary $raw) -ceq $binaryBefore) 'Unsupported unknown ACEs are retained and mutation is refused' + +$profile = Get-WelaNativeChannelProfile +$before = @{} +foreach ($control in $profile.controls) { $before[$control.channel] = Get-WelaNativeChannel -Name $control.channel } +# Exercise actual CLI dispatch and JSON export using live Windows read APIs. +$out = Join-Path ([IO.Path]::GetTempPath()) ('wela-native-channel-live-' + [guid]::NewGuid().ToString('N') + '.json') +$shell = (Get-Process -Id $PID).Path +try { + & $shell -NoProfile -File (Join-Path $repo 'WELA.ps1') channel-settings -ChannelAction Plan -GrantEventLogReaders -ResultsPath $out + $cliExit = $LASTEXITCODE + $report = Get-Content -LiteralPath $out -Raw -ErrorAction Stop | ConvertFrom-Json + Assert ($cliExit -eq $report.ExitCode -and $cliExit -in @(0, 1)) 'Read-only CLI exit code agrees with its report (missing/unknown channels may return 1)' + Assert ($report.Action -eq 'Plan' -and $report.QueryInventory.Count -eq 18 -and $report.ForwardingReadiness -eq 'Not verified') 'Real CLI plan exports channel inventory without a forwarding claim' + Assert ($report.ExcludedQueries.Count -eq 2 -and @($report.QueryInventory | Where-Object { $_.Channel.Name -like '*Sysmon*' }).Count -eq 0) 'Live public output excludes non-native queries' + foreach ($control in $profile.controls) { + $first = $before[$control.channel]; $last = Get-WelaNativeChannel -Name $control.channel + Assert ($first.State -eq $last.State -and $first.MaximumSizeInBytes -eq $last.MaximumSizeInBytes -and $first.LogMode -eq $last.LogMode -and $first.SecurityDescriptor -ceq $last.SecurityDescriptor) 'Live plan leaves channel metadata unchanged (or detects concurrent external drift)' + $row = @($report.Controls | Where-Object { $_.Definition.channel -eq $control.channel })[0] + Assert ($row.Before.MaximumSizeInBytes -eq $first.MaximumSizeInBytes -and $row.Before.SecurityDescriptor -ceq $first.SecurityDescriptor) 'Live exported metadata matches the actual native reader' + } + Write-Host "PASS: $script:assertions real Windows ACL and read-only CLI assertions. Identity access, event generation and forwarding were not tested." + $global:LASTEXITCODE = 0 # A reported missing/manual-review channel is valid smoke evidence. +} finally { Remove-Item -LiteralPath $out -Force -ErrorAction SilentlyContinue } diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 4d03a568..b996309e 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (issue #367) (@Shirofune-Security) + - ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security) - イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 50139bca..af54d2ca 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (issue #367) (@Shirofune-Security) + - Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security) - Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security) From c89a28190a3e3c4ff7906354d1a44919c5e1d760 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:38:28 +0900 Subject: [PATCH 2/4] Bind Windows descriptor byte overload explicitly and link PR 401 --- CHANGELOG-Japanese.md | 2 +- CHANGELOG.md | 2 +- modules/NativeChannelAccess.psm1 | 6 ++++-- website/docs/resources/changelog.ja.md | 2 +- website/docs/resources/changelog.md | 2 +- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index f896b363..ba439640 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,7 +4,7 @@ **改善:** -- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (issue #367) (@Shirofune-Security) +- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security) - ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security) - イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5145e2..c2f85f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ **Improvements:** -- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (issue #367) (@Shirofune-Security) +- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security) - Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security) - Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security) diff --git a/modules/NativeChannelAccess.psm1 b/modules/NativeChannelAccess.psm1 index 2e1942c4..5af21829 100644 --- a/modules/NativeChannelAccess.psm1 +++ b/modules/NativeChannelAccess.psm1 @@ -65,7 +65,9 @@ function Get-WelaChannelAccessPlan { if ($unknownAce) { throw 'An unknown ACE requires manual review; the descriptor is preserved without mutation.' } if ($deny) { throw 'A read-deny ACE may affect the forwarding token; the descriptor is preserved for manual review.' } if ($grant) { $result.State = 'GrantPresent'; return [pscustomobject]$result } - $copy = [System.Security.AccessControl.RawSecurityDescriptor]::new((Get-WelaDescriptorBytes $original), 0) + # Bind the binary overload explicitly on Windows PowerShell 5.1. + [byte[]]$originalBytes = Get-WelaDescriptorBytes $original + $copy = [System.Security.AccessControl.RawSecurityDescriptor]::new($originalBytes, 0) $newAce = [System.Security.AccessControl.CommonAce]::new( [System.Security.AccessControl.AceFlags]::None, [System.Security.AccessControl.AceQualifier]::AccessAllowed, 1, @@ -84,7 +86,7 @@ function Get-WelaChannelAccessPlan { } $result.State = 'GrantRequired'; $result.ProposedDescriptor = $sddl; $result.AddedAceIndex = $index } catch { - $result.State = 'ManualReview'; $result.Diagnostic = $_.Exception.Message + $result.State = 'ManualReview'; $result.Diagnostic = $_.Exception.Message + ' [' + $_.InvocationInfo.ScriptLineNumber + ']' } [pscustomobject]$result } diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index b996309e..18e19399 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,7 +7,7 @@ **改善:** -- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (issue #367) (@Shirofune-Security) +- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security) - ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security) - イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index af54d2ca..ee24dc67 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,7 +7,7 @@ **Improvements:** -- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (issue #367) (@Shirofune-Security) +- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security) - Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security) - Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security) From 75fd28a3d543ce146929a6f841f180b4a7e1265b Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:40:00 +0900 Subject: [PATCH 3/4] Use integer masks for byte-backed ACE flags on PowerShell 5.1 --- modules/NativeChannelAccess.psm1 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/NativeChannelAccess.psm1 b/modules/NativeChannelAccess.psm1 index 5af21829..0721a649 100644 --- a/modules/NativeChannelAccess.psm1 +++ b/modules/NativeChannelAccess.psm1 @@ -55,7 +55,8 @@ function Get-WelaChannelAccessPlan { $grant = $false; $deny = $false; $unknownAce = $false foreach ($ace in $original.DiscretionaryAcl) { if ($ace -isnot [System.Security.AccessControl.KnownAce]) { $unknownAce = $true; continue } - if ($ace.AceFlags -band [System.Security.AccessControl.AceFlags]::InheritOnly) { continue } + # PowerShell 5.1 cannot bitwise-cast byte-backed AceFlags enums. + if ([int]$ace.AceFlags -band [int][System.Security.AccessControl.AceFlags]::InheritOnly) { continue } $readMask = ($ace.AccessMask -band 1) -or ($ace.AccessMask -band 268435456) -or ($ace.AccessMask -band [int]::MinValue) if ($readMask -and $ace.AceQualifier -eq [System.Security.AccessControl.AceQualifier]::AccessDenied) { $deny = $true } if ($ace -is [System.Security.AccessControl.CommonAce] -and -not $ace.IsCallback -and @@ -76,7 +77,7 @@ function Get-WelaChannelAccessPlan { # Place the explicit allow before inherited entries; do not canonicalize others. $index = $copy.DiscretionaryAcl.Count for ($i = 0; $i -lt $copy.DiscretionaryAcl.Count; $i++) { - if ($copy.DiscretionaryAcl[$i].AceFlags -band [System.Security.AccessControl.AceFlags]::Inherited) { $index = $i; break } + if ([int]$copy.DiscretionaryAcl[$i].AceFlags -band [int][System.Security.AccessControl.AceFlags]::Inherited) { $index = $i; break } } $copy.DiscretionaryAcl.InsertAce($index, $newAce) $sddl = $copy.GetSddlForm([System.Security.AccessControl.AccessControlSections]::All) @@ -86,7 +87,7 @@ function Get-WelaChannelAccessPlan { } $result.State = 'GrantRequired'; $result.ProposedDescriptor = $sddl; $result.AddedAceIndex = $index } catch { - $result.State = 'ManualReview'; $result.Diagnostic = $_.Exception.Message + ' [' + $_.InvocationInfo.ScriptLineNumber + ']' + $result.State = 'ManualReview'; $result.Diagnostic = $_.Exception.Message } [pscustomobject]$result } From 38bceb3de158a46483537326e0cdf17d8f5209b7 Mon Sep 17 00:00:00 2001 From: Shirofune-Security <43838376+Shirofune-Security@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:42:06 +0900 Subject: [PATCH 4/4] Construct unknown ACE fixture without PowerShell enum validation --- tests/NativeChannelAccess.Windows.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/NativeChannelAccess.Windows.Tests.ps1 b/tests/NativeChannelAccess.Windows.Tests.ps1 index 9ad1d6ca..d669b60b 100644 --- a/tests/NativeChannelAccess.Windows.Tests.ps1 +++ b/tests/NativeChannelAccess.Windows.Tests.ps1 @@ -53,7 +53,7 @@ foreach ($sddl in @('', 'invalid', 'O:BAG:SY', 'O:BAG:SYD:NO_ACCESS_CONTROL', 'O # The original unknown ACE bytes must never be discarded. SDDL has no representation # for arbitrary custom ACEs; parsing/planning must refuse instead of replacing them. $raw = [System.Security.AccessControl.RawSecurityDescriptor]::new('O:BAG:SYD:(A;;0x7;;;BA)') -$raw.DiscretionaryAcl.InsertAce(1, [System.Security.AccessControl.CustomAce]::new([System.Security.AccessControl.AceType]127, [System.Security.AccessControl.AceFlags]::None, [byte[]]@(0, 0, 0, 0))) +$raw.DiscretionaryAcl.InsertAce(1, [System.Security.AccessControl.CustomAce]::new(([Enum]::ToObject([System.Security.AccessControl.AceType], 127)), [System.Security.AccessControl.AceFlags]::None, [byte[]]@(0, 0, 0, 0))) $binaryBefore = Binary $raw $refused = $false try {