diff --git a/.gitattributes b/.gitattributes index 41a83846..7f0dff24 100644 --- a/.gitattributes +++ b/.gitattributes @@ -39,6 +39,10 @@ modules/WecSubscriptionXml.cs text eol=lf scripts/AppLockerProbe.ps1 text eol=lf tests/AppLockerProbe*.ps1 text eol=lf +/scripts/ChannelRead.ps1 text eol=lf +/scripts/ChannelReadNative.cs text eol=lf +/config/native_channel_profile.json text eol=lf +/tests/ChannelRead*.ps1 text eol=lf # Pending AD CS restart plans bind exact implementation bytes. /scripts/AdcsRestartResume.ps1 text eol=lf /scripts/AdcsAuditing.ps1 text eol=lf diff --git a/.github/workflows/channel-read.yml b/.github/workflows/channel-read.yml new file mode 100644 index 00000000..6a882f90 --- /dev/null +++ b/.github/workflows/channel-read.yml @@ -0,0 +1,47 @@ +name: Native current-token channel reads +on: + push: + paths: ['WELA.ps1', 'scripts/ChannelRead*', 'scripts/WefArrival.ps1', 'modules/NativeProviders.psm1', 'config/native_channel_profile.json', 'tests/ChannelRead*', '.github/workflows/channel-read.yml'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + actual-reader: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Safe refusal and drift fixtures (powershell) + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/ChannelRead.Tests.ps1 + - name: Disposable owned account and CAPI2 ACE proof (powershell) + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/ChannelRead.Windows.Tests.ps1 -AllowDisposableAccount -TestEngine powershell + - name: Safe refusal and drift fixtures (pwsh) + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/ChannelRead.Tests.ps1 + - name: Disposable owned account and CAPI2 ACE proof (pwsh) + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/ChannelRead.Windows.Tests.ps1 -AllowDisposableAccount -TestEngine pwsh + - name: Retain native metadata and cleanup evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: channel-reader-${{ matrix.os }}-${{ matrix.engine }} + path: | + ${{ runner.temp }}/wela-channel-reader-*/acceptance.json + ${{ runner.temp }}/wela-channel-reader-*/channel-before.json + ${{ runner.temp }}/wela-channel-reader-*/reader/ + ${{ runner.temp }}/wela-channel-reader-*/admin/ + if-no-files-found: warn + retention-days: 7 diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 2e99f5ae..fbb5f432 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) + - 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security) - `event-measurement`を追加。明示した組み込みの管理・運用チャネル1つを一定時間だけ観測し、単調時計によるコールバック到着時間、元のXML・ブックマーク、非公開の証拠、ネイティブEVTXサンプルの厳密な再読み取りを記録します。上限超過、欠落・古い記録、状態変化、不完全なエクスポートは未検証とし、サンプルのバイト数からログ増加量・保持容量・バックエンド到達・Sigma検知可能性を推定しません。 (#430) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4285b21..5f47af61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) + - Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security) - Added opt-in `event-measurement` for bounded local callback-delivery windows on one explicit built-in Administrative/Operational channel, with monotonic timing, original XML/bookmarks, private evidence and exact native EVTX sample reopening. Caps, missing/stale records, source drift and incomplete exports remain unverified; sample-file bytes do not imply channel growth, retention capacity, backend ingestion or Sigma readiness. (#430) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 83f82160..fb027456 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -33,6 +33,8 @@ [string]$ChannelProfile = 'microsoft-wef-appendix-c', [ValidateSet('Baseline', 'Suspect', 'Both')][string]$WefQuerySet = 'Both', [switch]$GrantEventLogReaders, + [string[]]$ChannelReadName, + [string]$ChannelReadOutputPath, [ValidateSet('Audit', 'Plan', 'Configure')][string]$WefAction = 'Audit', [string]$WefConfigPath, [string]$RetentionConfigPath, @@ -174,6 +176,7 @@ Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorActi . (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1") Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/NativeChannelConfiguration.ps1") +. (Join-Path $ScriptRoot "scripts/ChannelRead.ps1") . (Join-Path $ScriptRoot "scripts/NativeProviderPacks.ps1") . (Join-Path $ScriptRoot "scripts/DnsAnalytical.ps1") Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorAction Stop @@ -1897,6 +1900,7 @@ Usage: ./WELA.ps1 ldap-diagnostics -LdapAction Audit ./WELA.ps1 ldap-diagnostics -LdapAction Plan -LdapMode Diagnostic -LdapSearchTimeMs 100 ./WELA.ps1 ldap-diagnostics -LdapAction Configure -LdapMode Diagnostic -LdapSearchTimeMs 100 -DryRun + ./WELA.ps1 channel-read -Help # Actual current-token local read access ./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 @@ -1973,6 +1977,9 @@ Write-Host "" Write-Host "WELA v$WELAVersion - $WELAReleaseName" Write-Host "" +if ($Cmd -ne 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'ChannelRead*' }).Count) { throw 'ChannelRead options require channel-read. No command was run.' } +if ($Cmd -eq 'channel-read' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ChannelReadName','ChannelReadOutputPath','Help') }).Count) { throw 'channel-read accepts only dedicated channel/output options. No command was run.' } + if ($Cmd -ne 'event-measurement' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Measurement*'}).Count) {throw 'Measurement options require event-measurement. No command was run.'} if ($Cmd -eq 'event-measurement' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','MeasurementAction','MeasurementChannel','MeasurementSeconds','MeasurementMaximumEvents','MeasurementOutputPath','MeasurementExportEvtx','Help')}).Count) {throw 'event-measurement accepts only its dedicated options. No command was run.'} if ($Cmd -ne 'dns-analytical' -and @($PSBoundParameters.Keys | Where-Object { $_ -like 'Dns*' -or $_ -eq 'AllowDnsTraceReset' }).Count) { @@ -2362,6 +2369,13 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) { exit $report.ExitCode } } + 'channel-read' { + if ($Help) { Write-Host 'Usage: ./WELA.ps1 channel-read -ChannelReadName Security,Microsoft-Windows-CAPI2/Operational -ChannelReadOutputPath new-local-directory. Read-only current primary-token query; no credentials or configuration changes. See docs/channel-read.md.'; return } + $report=Invoke-WelaChannelRead -Channels $ChannelReadName -OutputPath $ChannelReadOutputPath + $report.Results | Select-Object Channel,AccessVerified,@{n='QueryStatus';e={$_.Query.Status}} | Format-Table -AutoSize | Out-Host + Write-Host ('Channel read evidence: '+(Join-Path $report.OutputPath 'result.json')) + if ($report.ExitCode) { exit $report.ExitCode } + } '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]' diff --git a/docs/channel-read.md b/docs/channel-read.md new file mode 100644 index 00000000..def0544e --- /dev/null +++ b/docs/channel-read.md @@ -0,0 +1,35 @@ +# Actual current-token channel reads + +`channel-read` queries selected built-in local logs under the primary token that actually runs WELA. It records the query result separately from the channel settings shown by `channel-settings`. Launch WELA through your established account/service procedure under the identity you want to test; this command accepts no credentials or impersonation options. + +```powershell +.\WELA.ps1 channel-read -ChannelReadName Security,Microsoft-Windows-CAPI2/Operational -ChannelReadOutputPath C:\WELA-Evidence\reader-001 +``` + +Use an existing local fixed-drive parent writable by that account and a **new** output directory outside the WELA source tree. The result is `result.json`, with inheritance disabled and access for the current user, SYSTEM and Administrators. Existing output, reparse paths, remote/device paths and alternate streams are rejected. These checks are not an atomic defense against a concurrent administrator replacing filesystem objects. Treat the result as sensitive operational evidence. + +One to eight exact channel names from `config/native_channel_profile.json` are accepted. The inventory contains Security, System, Application, Windows PowerShell, CAPI2 and other built-in WEF channels. Sysmon, ForwardedEvents, arbitrary files, remote sessions and caller-supplied XPath are excluded. Supported host context is Windows 11 builds 22000/22621/22631/26100/26200 and Server 2022/2025 builds 20348/26100, including observed member/DC roles. Role support does not mean those roles have all been acceptance-tested. + +The native [`EventLogReader`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.eventing.reader.eventlogreader.readevent?view=netframework-4.8.1) uses a local `LogName` query, XPath `*`, reverse direction, a batch of one and a five-second read timeout. Query setup and separate metadata calls do not have an overall five-second deadline. It verifies the single query status and, when an event is returned, checks its channel and records only bounded System metadata: record ID, provider, ID/version, computer and creation time. No rendered messages, event payloads or raw XML are exported, and no logs are cleared. The report is capped at one MiB. + +| Query status | What it establishes | +|---|---| +| `EventObserved` | This token read one event from this channel at the recorded query time. | +| `ReadAllowedEmpty` | The native query completed successfully with no record returned. Access was allowed; event generation is unproven. | +| `Denied` | Windows rejected the actual query with access denied. | +| `Absent` | Windows reported a missing local channel/path. | +| `Unknown` | A timeout, other native error, incomplete status or invalid event provenance prevented a conclusion. | + +Native error codes are retained when the runtime exposes them; .NET Framework may provide only a typed exception and diagnostic. Localized message text is not parsed to infer status. + +A completed observation returns exit 0 only when every selected channel is `EventObserved` or `ReadAllowedEmpty`. Denied, absent and unknown queries return 1 while retaining the report. A changed reader, host or source fingerprint produces `Unverified`, exit 1, and clears every `AccessVerified` conclusion. Raw query observations remain available for diagnosis. Output/setup failures also return nonzero and may leave a partial directory without a result. + +Channel metadata is an independent observation: inability to read configuration or SDDL does not invalidate a successful actual event query. Conversely, a readable descriptor or Event Log Readers ACE is never substituted for a query. The report captures the actual user, group SID inventory, administrator membership, process, token ID, logon authentication LUID and [`TOKEN_STATISTICS.ModifiedId`](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-token_statistics). It checks these before/after queries, rejecting token changes and impersonation. Loaded native helper types must match the exact source bytes compiled in the current process; source changes require a fresh PowerShell session. Group SID inventory is not an access calculation or a group-attribute/privilege dump. The event-query adapter does not enable privileges or change groups, channel ACLs, policy, subscriptions or services. Output-directory ACL and metadata preparation occurs before the query-token interval because Windows/.NET may temporarily adjust available privileges while preparing evidence storage or inspecting configuration. + +Evidence applies only to the observed local process token and time. It does not prove a different service token can read, that a producer generates the desired events, that a WEF subscription delivers them, or that a backend executes Sigma rules. `ReadyRuleCredit` remains zero. An administrator's success is not evidence for the intended forwarding identity. Run under that identity and retain corresponding source/collector evidence separately. + +## Validation and remaining acceptance + +Safe fixtures cover status/exit semantics, independent metadata denial, token/host/source drift, overwritten outputs, channel scope and CLI refusal. A separate gated GitHub-hosted Server 2022/2025 × Windows PowerShell 5.1/PowerShell 7 fixture creates one owned standard account, temporarily adds a CAPI2 read-deny ACE, and observes an actual denial in a fresh logon. It replaces only that fixture ACE with a read-only allow and checks another fresh standard-user query, plus one actual administrator Application event. It verifies complete original channel settings restoration and deletes only the SID-matched owned account. The product has no fixture override. The fixture does not clear or enable CAPI2, and the allowed query may legitimately be empty. Failure stops the acceptance claim and preserves cleanup evidence; owned filesystem evidence is retained for the ephemeral runner lifecycle. + +Windows 11, member-domain/DC/ADCS, intended forwarding service-token, policy-refresh and multi-host arrival acceptance remain separate. This advances issue #367 without closing its full WEF acceptance requirements. diff --git a/docs/native-channel-access.md b/docs/native-channel-access.md index 2a86baa1..a15b53cf 100644 --- a/docs/native-channel-access.md +++ b/docs/native-channel-access.md @@ -26,7 +26,7 @@ The appended ACE grants SID `S-1-5-32-573` (Event Log Readers) **read only**, ac 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. +`GrantPresent` describes an unconditional group read ACE in the descriptor. It **does not establish effective read access** for any user or service token. Use the separate [`channel-read`](channel-read.md) command under the intended actual primary token for a bounded local query observation. 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. @@ -42,5 +42,5 @@ Safe tests exercise the actual command/JSON/runner with mocked Windows setters. 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. +3. Using the intended forwarding identity's actual token, run `channel-read` for CAPI2 (see [the reader guide](channel-read.md)) and retain its query results. 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/scripts/ChannelRead.ps1 b/scripts/ChannelRead.ps1 new file mode 100644 index 00000000..30379df5 --- /dev/null +++ b/scripts/ChannelRead.ps1 @@ -0,0 +1,141 @@ +# Queries the actual current primary token. No credential, impersonation or configuration adapters. +function Get-WelaChannelReadSources { + $sources=[ordered]@{} + foreach($path in @('WELA.ps1','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','modules/NativeProviders.psm1','config/native_channel_profile.json')) { + $sources[$path]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + [pscustomobject]$sources +} +function Get-WelaChannelReadKey { param($Value) ConvertTo-Json -InputObject $Value -Depth 20 -Compress } +function Get-WelaChannelReader { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'channel-read requires native 64-bit Windows.'} + $nativeBytes=[IO.File]::ReadAllBytes((Join-Path $script:ScriptRoot 'scripts/ChannelReadNative.cs')) + $nativeHash=Get-WelaArrivalHash $nativeBytes + if(-not ('Wela.ChannelRead.Token' -as [type])){ + Add-Type -TypeDefinition ([Text.UTF8Encoding]::new($false,$true).GetString($nativeBytes).TrimStart([char]0xfeff)) -ErrorAction Stop + [Wela.ChannelRead.Token]::SourceSha256=$nativeHash + } + if([Wela.ChannelRead.Token]::SourceSha256 -cne $nativeHash){throw 'Loaded channel-reader helper differs from current source; start a fresh PowerShell process.'} + $threadIdentity=[Security.Principal.WindowsIdentity]::GetCurrent($true) + if($null -ne $threadIdentity){$threadIdentity.Dispose();throw 'Impersonated readers are unsupported; launch WELA under the intended primary token.'} + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try { + $stats=[Wela.ChannelRead.Token]::Read($identity.Token) + [pscustomobject][ordered]@{ + Computer=[Environment]::MachineName;ProcessId=$PID;UserSid=$identity.User.Value;UserName=$identity.Name + TokenId=$stats.TokenId.ToString();AuthenticationId=$stats.AuthenticationId.ToString();ModifiedId=$stats.ModifiedId.ToString() + GroupSids=@($identity.Groups|ForEach-Object Value|Sort-Object) + GroupCount=$stats.GroupCount;PrivilegeCount=$stats.PrivilegeCount + ElevatedAdministrator=([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + TokenType='Primary';Impersonation='Absent' + } + } finally {$identity.Dispose()} +} +function Get-WelaChannelReadHost { + $os=Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop + $build=[int]$os.BuildNumber + if(($os.ProductType -eq 1 -and $build -notin @(22000,22621,22631,26100,26200)) -or ($os.ProductType -in @(2,3) -and $build -notin @(20348,26100)) -or $os.ProductType -notin @(1,2,3)){throw 'Host is outside reviewed Windows 11 / Server 2022 and 2025 builds.'} + $version=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;Build=$build;UBR=$version.UBR;Edition=$version.EditionID;ProductType=[int]$os.ProductType;DomainRole=[int]$computer.DomainRole;DomainJoined=[bool]$computer.PartOfDomain;Domain=[string]$computer.Domain} +} +function Get-WelaChannelReadSelection { + param([string[]]$Channels) + $profile=Get-Content -LiteralPath (Join-Path $script:ScriptRoot 'config/native_channel_profile.json') -Raw -ErrorAction Stop|ConvertFrom-Json + $allowed=@($profile.querySets.Baseline.channels.name)+@($profile.querySets.Suspect.channels.name) + if(-not $Channels -or $Channels.Count -gt 8){throw 'Select between one and eight reviewed built-in channels.'} + $seen=@{} + foreach($channel in $Channels){ + if($channel -cnotin $allowed -or $seen.ContainsKey($channel)){throw 'Unknown, mis-cased or duplicate channel; only exact native WEF inventory names are accepted.'} + $seen[$channel]=$true + $channel + } +} +function Get-WelaChannelReadFailure { + param([Exception]$Exception) + $current=$Exception;$code=$null;$absent=$false + while($current){ + if($current -is [UnauthorizedAccessException]){$code=5;break} + if($current.PSObject.Properties['ErrorCode']){$code=[int]$current.ErrorCode} + if($current -is [ComponentModel.Win32Exception]){$code=$current.NativeErrorCode} + if($current -is [Diagnostics.Eventing.Reader.EventLogNotFoundException]){$absent=$true} + # Modern EventLogException stores Win32 codes in HRESULT; .NET Framework + # does not reliably expose its private native code. Never parse localized text. + if($current -is [Diagnostics.Eventing.Reader.EventLogException]){ + $hr=([long]$current.HResult -band 0xffffffffL) + if(($hr -band 0xffff0000L) -eq 0x80070000L){$code=[int]($hr -band 0xffffL)} + } + $current=$current.InnerException + } + $state=if($code -eq 5){'Denied'}elseif($absent -or $code -in @(2,3,15007)){'Absent'}else{'Unknown'} + [pscustomobject]@{Status=$state;NativeError=$code;Diagnostic=$Exception.Message} +} +function Assert-WelaChannelQueryStatus { + param([string]$Channel,[object[]]$LogStatus) + # A native status is attributable only to the one exact channel queried. + if($LogStatus.Count -ne 1 -or $LogStatus[0].LogName -cne $Channel -or $LogStatus[0].StatusCode -isnot [int]){throw 'Query status is incomplete, mismatched or mistyped.'} + if($LogStatus[0].StatusCode -ne 0){throw [ComponentModel.Win32Exception]::new($LogStatus[0].StatusCode)} +} +function Read-WelaChannelLatest { + param([string]$Channel) + $reader=$null;$event=$null + $result=[pscustomobject][ordered]@{Channel=$Channel;PathType='LogName';Session='Local';XPath='*';ReverseDirection=$true;MaximumEvents=1;ReadTimeoutMs=5000;StartedUtc=[DateTime]::UtcNow.ToString('o');CompletedUtc=$null;Status='Unknown';NativeError=$null;LogStatus=@();Event=$null;Diagnostic=''} + try { + $query=[Diagnostics.Eventing.Reader.EventLogQuery]::new($Channel,[Diagnostics.Eventing.Reader.PathType]::LogName,'*') + $query.ReverseDirection=$true;$query.TolerateQueryErrors=$false + $reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($query);$reader.BatchSize=1 + $event=$reader.ReadEvent([TimeSpan]::FromMilliseconds(5000)) + $result.LogStatus=@($reader.LogStatus|ForEach-Object{[pscustomobject]@{LogName=$_.LogName;StatusCode=$_.StatusCode}}) + Assert-WelaChannelQueryStatus -Channel $Channel -LogStatus $result.LogStatus + if($null -eq $event){$result.Status='ReadAllowedEmpty'}else{ + if($event.LogName -cne $Channel -or $null -eq $event.RecordId -or $event.RecordId -le 0 -or -not $event.ProviderName -or $event.ProviderName.Length -gt 512 -or -not $event.MachineName -or $event.MachineName.Length -gt 255){throw 'Returned event provenance is incomplete or mismatches the selected local channel.'} + # Only bounded System metadata is exported. Message, payload and raw XML remain unexported. + $result.Event=[pscustomobject][ordered]@{Channel=$event.LogName;RecordId=[long]$event.RecordId;Provider=$event.ProviderName;EventId=[int]$event.Id;Version=$event.Version;Computer=$event.MachineName;TimeCreatedUtc=$(if($event.TimeCreated){$event.TimeCreated.ToUniversalTime().ToString('o')}else{$null})} + $result.Status='EventObserved' + } + }catch{ + $failure=Get-WelaChannelReadFailure $_.Exception + $result.Status=$failure.Status;$result.NativeError=$failure.NativeError;$result.Diagnostic=$failure.Diagnostic;$result.Event=$null + }finally{if($event){$event.Dispose()};if($reader){$reader.Dispose()};$result.CompletedUtc=[DateTime]::UtcNow.ToString('o')} + $result +} +function Invoke-WelaChannelRead { + param([string[]]$Channels,[string]$OutputPath) + $sources=Get-WelaChannelReadSources;$sourceKey=Get-WelaChannelReadKey $sources + $selected=@(Get-WelaChannelReadSelection $Channels) + if(-not $OutputPath){throw 'channel-read requires a new ChannelReadOutputPath.'} + $hostState=Get-WelaChannelReadHost;$hostKey=Get-WelaChannelReadKey $hostState + $output=New-WelaArrivalOutput -Path $OutputPath -SourcePath $script:ScriptRoot + # Filesystem ACL and metadata APIs may temporarily adjust available privileges. + # Finish preparation before capturing the token used by the actual event queries. + $metadataByName=@{} + foreach($channel in $selected){$metadataByName[$channel]=Get-WelaNativeChannel -Name $channel} + $before=Get-WelaChannelReader;$readerKey=Get-WelaChannelReadKey $before + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaNativeChannelRead';RecordedUtc=[DateTime]::UtcNow.ToString('o');ExitCode=1;Status='Unverified';Host=$hostState;ReaderBefore=$before;ReaderAfter=$null;ReaderInterval='After output/metadata preparation, before first query through final checks';Sources=$sources;Results=@();Diagnostic='';ReadyRuleCredit=0;ConfigurationChanges=0;Scope='Actual current primary-token local query access at observation time only';EventGeneration='Not tested';Forwarding='Not tested';OutputPath=$output} + try { + foreach($channel in $selected){ + if((Get-WelaChannelReadKey (Get-WelaChannelReader)) -cne $readerKey){throw 'Reader token changed before query.'} + # Channel configuration may require rights the actual event query does not. + $metadata=$metadataByName[$channel] + $query=Read-WelaChannelLatest $channel + $readerAfter=Get-WelaChannelReader + $row=[pscustomobject]@{Channel=$channel;ConfigurationObservation=$metadata;Query=$query;AccessVerified=$false;ReaderStable=$false} + $report.Results+= $row + if((Get-WelaChannelReadKey $readerAfter) -cne $readerKey){throw 'Reader token changed during query.'} + $row.ReaderStable=$true + $row.AccessVerified=$query.Status -in @('ReadAllowedEmpty','EventObserved') + } + $finalHostKey=Get-WelaChannelReadKey (Get-WelaChannelReadHost);$finalSourceKey=Get-WelaChannelReadKey (Get-WelaChannelReadSources) + $report.ReaderAfter=Get-WelaChannelReader + if((Get-WelaChannelReadKey $report.ReaderAfter) -cne $readerKey -or $finalHostKey -cne $hostKey -or $finalSourceKey -cne $sourceKey){throw 'Reader, host or implementation changed during observation.'} + $report.Status='Completed' + $report.ExitCode=if(@($report.Results|Where-Object{-not $_.AccessVerified}).Count){1}else{0} + }catch{ + $report.Diagnostic=$_.Exception.Message + foreach($row in $report.Results){$row.AccessVerified=$false} + } + $json=$report|ConvertTo-Json -Depth 20 + if([Text.Encoding]::UTF8.GetByteCount($json) -gt 1048576){throw 'Channel-read report exceeds the one MiB bound; no successful evidence was written.'} + $null=Write-WelaArrivalArtifact $output 'result.json' $json + $report +} diff --git a/scripts/ChannelReadNative.cs b/scripts/ChannelReadNative.cs new file mode 100644 index 00000000..150bcda1 --- /dev/null +++ b/scripts/ChannelReadNative.cs @@ -0,0 +1,28 @@ +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +namespace Wela.ChannelRead { + public static class Token { + public static string SourceSha256; + [StructLayout(LayoutKind.Sequential)] public struct Luid { + public UInt32 Low; public Int32 High; + public override string ToString() { return ((UInt32)High).ToString("x8") + Low.ToString("x8"); } + } + [StructLayout(LayoutKind.Sequential)] public struct Statistics { + public Luid TokenId, AuthenticationId; + public Int64 ExpirationTime; + public Int32 TokenType, ImpersonationLevel; + public UInt32 DynamicCharged, DynamicAvailable, GroupCount, PrivilegeCount; + public Luid ModifiedId; + } + [DllImport("advapi32.dll", SetLastError=true)] + private static extern bool GetTokenInformation(IntPtr token, int informationClass, out Statistics information, int size, out int returned); + public static Statistics Read(IntPtr token) { + Statistics value; int returned; + int size = Marshal.SizeOf(typeof(Statistics)); + if (!GetTokenInformation(token, 10, out value, size, out returned)) throw new Win32Exception(Marshal.GetLastWin32Error()); + if (returned != size || value.TokenType != 1) throw new InvalidOperationException("Expected complete primary-token statistics."); + return value; + } + } +} diff --git a/tests/ChannelRead.Tests.ps1 b/tests/ChannelRead.Tests.ps1 new file mode 100644 index 00000000..df37f3f7 --- /dev/null +++ b/tests/ChannelRead.Tests.ps1 @@ -0,0 +1,78 @@ +$ErrorActionPreference='Stop' +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +. (Join-Path $script:ScriptRoot 'scripts/WefArrival.ps1') +. (Join-Path $script:ScriptRoot 'scripts/ChannelRead.ps1') +$script:passed=0 +function Assert($value,$message){if(-not $value){throw $message};$script:passed++} +function Refuses([scriptblock]$action){$caught=$false;try{&$action|Out-Null}catch{$caught=$true};Assert $caught 'Expected refusal'} +foreach($channels in @(@(),@('Sysmon'),@('ForwardedEvents'),@('Security','Security'),@('security'),@('Security','System','Application','Windows PowerShell','Microsoft-Windows-CAPI2/Operational','Microsoft-Windows-DNS-Client/Operational','Microsoft-Windows-LSA/Operational','Microsoft-Windows-PowerShell/Operational','Microsoft-Windows-SMBClient/Operational'))){Refuses {Get-WelaChannelReadSelection $channels}} +Assert (@(Get-WelaChannelReadSelection @('Security','System')).Count -eq 2) 'Reviewed channels accepted' +foreach($case in @(@(5,'Denied'),@(15007,'Absent'),@(2,'Absent'),@(87,'Unknown'),@(1460,'Unknown'))){$failure=Get-WelaChannelReadFailure ([ComponentModel.Win32Exception]::new($case[0]));Assert ($failure.Status -eq $case[1]) 'Native numeric error classification'} +# Exercise the production LogStatus guard and classifier together; no native query mocking. +Assert-WelaChannelQueryStatus 'Security' @([pscustomobject]@{LogName='Security';StatusCode=0}) +Assert $true 'One successful matching query status is accepted' +foreach($case in @(@(5,'Denied'),@(2,'Absent'),@(3,'Absent'),@(15007,'Absent'),@(1460,'Unknown'),@(87,'Unknown'))){ + $failure=$null + try{Assert-WelaChannelQueryStatus 'Security' @([pscustomobject]@{LogName='Security';StatusCode=$case[0]})}catch{$failure=Get-WelaChannelReadFailure $_.Exception} + Assert ($null -ne $failure -and $failure.Status -eq $case[1] -and $failure.NativeError -eq $case[0]) 'Matching nonzero LogStatus preserves exact native classification' +} +foreach($status in @( + @{Rows=@()}, + @{Rows=@([pscustomobject]@{LogName='System';StatusCode=5})}, + @{Rows=@([pscustomobject]@{LogName='security';StatusCode=5})}, + @{Rows=@([pscustomobject]@{LogName='Security';StatusCode=5},[pscustomobject]@{LogName='Security';StatusCode=5})}, + @{Rows=@([pscustomobject]@{LogName='Security'})}, + @{Rows=@([pscustomobject]@{LogName='Security';StatusCode=$null})}, + @{Rows=@([pscustomobject]@{LogName='Security';StatusCode='5'})} +)){ + $failure=$null + try{Assert-WelaChannelQueryStatus 'Security' $status.Rows}catch{$failure=Get-WelaChannelReadFailure $_.Exception} + Assert ($null -ne $failure -and $failure.Status -eq 'Unknown' -and $null -eq $failure.NativeError) 'Unattributable or malformed query status stays unknown' +} +if([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT){ + Assert ((Get-WelaChannelReadFailure ([Diagnostics.Eventing.Reader.EventLogNotFoundException]::new('synthetic absent channel'))).Status -eq 'Absent') 'Actual EventLogNotFoundException classification' + Assert ((Get-WelaChannelReadFailure ([Diagnostics.Eventing.Reader.EventLogException]::new('native code unexposed'))).Status -eq 'Unknown') 'EventLogException without exposed native code stays unknown' +} +Assert ((Get-WelaChannelReadFailure ([InvalidOperationException]::new('outer',[UnauthorizedAccessException]::new('inner')))).Status -eq 'Denied') 'Wrapped access denial' +$script:prepared=0;$script:counter=0;$script:driftAt=0;$script:queryState='EventObserved';$script:hostDrift=$false;$script:hostReads=0;$script:sourceDrift=$false;$script:sourceReads=0 +function Get-WelaChannelReader {$script:counter++;[pscustomobject][ordered]@{UserSid='S-1-5-21-1-2-3-1001';TokenId='01';AuthenticationId='02';ModifiedId=($script:prepared.ToString()+':'+$(if($script:driftAt -and $script:counter -ge $script:driftAt){'04'}else{'03'}))}} +function Get-WelaChannelReadHost {$script:hostReads++;[pscustomobject]@{Build=$(if($script:hostDrift -and $script:hostReads -gt 1){26100}else{20348})}} +function Get-WelaChannelReadSources {$script:sourceReads++;[pscustomobject]@{Hash=$(if($script:sourceDrift -and $script:sourceReads -gt 1){'b'}else{'a'})}} +# Metadata preparation deliberately changes the synthetic ModifiedId, as Windows APIs can. +function Get-WelaNativeChannel {param($Name)$script:prepared++;[pscustomobject]@{Name=$Name;State='Unknown';Diagnostic='Metadata denied'}} +function Read-WelaChannelLatest {param($Channel)[pscustomobject]@{Channel=$Channel;Status=$script:queryState;Event=$(if($script:queryState -eq 'EventObserved'){[pscustomobject]@{RecordId=42}}else{$null})}} +$fixture=Join-Path ([IO.Path]::GetTempPath()) ('wela-channel-read-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $fixture +function RunFixture { + $script:counter=0;$script:hostReads=0;$script:sourceReads=0 + Invoke-WelaChannelRead @('Security') (Join-Path $fixture ([guid]::NewGuid().ToString('N'))) +} +try{ + foreach($status in @('EventObserved','ReadAllowedEmpty','Denied','Absent','Unknown')){ + $script:queryState=$status;$report=RunFixture + Assert ($report.Status -eq 'Completed') 'Completed query observation' + Assert ($report.Results[0].AccessVerified -eq ($status -in @('EventObserved','ReadAllowedEmpty'))) 'Access conclusion follows actual query' + Assert ($report.ExitCode -eq $(if($status -in @('EventObserved','ReadAllowedEmpty')){0}else{1})) 'Exit follows query proof' + Assert ($report.ReadyRuleCredit -eq 0 -and $report.ConfigurationChanges -eq 0) 'No inferred readiness or mutation' + Assert ($report.Results[0].ConfigurationObservation.State -eq 'Unknown') 'Metadata access is independent' + Assert (Test-Path (Join-Path $report.OutputPath 'result.json')) 'Saved bounded report' + Refuses {Invoke-WelaChannelRead @('Security') $report.OutputPath} + } + $script:queryState='EventObserved' + foreach($at in @(2,3,4)){$script:driftAt=$at;$report=RunFixture;Assert ($report.Status -eq 'Unverified' -and $report.ExitCode -eq 1) 'Token drift cannot prove access';Assert (@($report.Results|Where-Object AccessVerified).Count -eq 0) 'All positive conclusions invalidated'} + $script:driftAt=0;$script:hostDrift=$true;$report=RunFixture;Assert ($report.Status -eq 'Unverified' -and -not $report.Results[0].AccessVerified) 'Host drift invalidates access' + $script:hostDrift=$false;$script:sourceDrift=$true;$report=RunFixture;Assert ($report.Status -eq 'Unverified' -and -not $report.Results[0].AccessVerified) 'Implementation drift invalidates access' +}finally{Remove-Item -LiteralPath $fixture -Recurse -Force} +$engine=(Get-Process -Id $PID).Path +foreach($case in @( + @{Args=@('channel-read','-Help');Exit=0}, + @{Args=@('channel-read','-Help','-Auto');Exit=1}, + @{Args=@('channel-read','-Help','-GrantEventLogReaders');Exit=1}, + @{Args=@('channel-read','-Help','-Role','DomainController');Exit=1}, + @{Args=@('help','-ChannelReadName','Security');Exit=1} +)){ + $old=$ErrorActionPreference;$ErrorActionPreference='Continue' + try{$text=&$engine -NoProfile -File (Join-Path $script:ScriptRoot 'WELA.ps1') @($case.Args) 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old} + Assert ($code -eq $case.Exit) ('CLI boundary '+($case.Args -join ' ')+': '+($text|Out-String)) +} +$global:LASTEXITCODE=0 +Write-Host "Channel read fixtures passed: $script:passed" diff --git a/tests/ChannelRead.Windows.Tests.ps1 b/tests/ChannelRead.Windows.Tests.ps1 new file mode 100644 index 00000000..c5c0dcfb --- /dev/null +++ b/tests/ChannelRead.Windows.Tests.ps1 @@ -0,0 +1,99 @@ +param([switch]$AllowDisposableAccount,[ValidateSet('powershell','pwsh')][string]$TestEngine='powershell') +$ErrorActionPreference='Stop' +if(-not $AllowDisposableAccount -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit disposable account/ACL test on a GitHub-hosted Windows runner required.'} +$computer=Get-CimInstance Win32_ComputerSystem;$os=Get-CimInstance Win32_OperatingSystem +if($computer.PartOfDomain -or $computer.DomainRole -ne 2 -or $os.ProductType -ne 3 -or [int]$os.BuildNumber -notin @(20348,26100)){throw 'Refusing domain, DC or unsupported runner.'} +$root=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$root +Import-Module (Join-Path $root 'modules/NativeProviders.psm1') -Force +. (Join-Path $root 'scripts/WefArrival.ps1') +. (Join-Path $root 'scripts/ChannelRead.ps1') +$missing=Read-WelaChannelLatest ('WELA-absent-'+[guid]::NewGuid().ToString('N')) +if($missing.Status -ne 'Absent'){throw ('Actual native missing-channel query was not classified Absent: '+($missing|ConvertTo-Json -Depth 6))} +$channel='Microsoft-Windows-CAPI2/Operational';$before=Get-WelaNativeChannel $channel +if($before.State -notin @('Enabled','Disabled') -or -not $before.SecurityDescriptor){throw 'CAPI2 full settings unavailable.'} +$nonce=[guid]::NewGuid().ToString('N');$username='WelaR'+$nonce.Substring(0,12) +$fixture=Join-Path $env:RUNNER_TEMP ('wela-channel-reader-'+$nonce);$null=New-Item -ItemType Directory $fixture +$codeRoot=Join-Path $fixture 'code';$null=New-Item -ItemType Directory $codeRoot +foreach($path in @('WELA.ps1','scripts','modules','config')){Copy-Item -LiteralPath (Join-Path $root $path) -Destination $codeRoot -Recurse} +$readerHome=Join-Path $fixture 'reader';$null=New-Item -ItemType Directory $readerHome +$engine=(Get-Command $TestEngine -ErrorAction Stop).Source +$ownedSid=$null;$aclChanged=$false;$passed=$false +$before|ConvertTo-Json -Depth 12|Set-Content -LiteralPath (Join-Path $fixture 'channel-before.json') -Encoding UTF8 +function NativeSettingsKey($value){Get-WelaChannelReadKey ([pscustomobject][ordered]@{Name=$value.Name;IsEnabled=$value.IsEnabled;MaximumSizeInBytes=$value.MaximumSizeInBytes;LogMode=$value.LogMode;SecurityDescriptor=$value.SecurityDescriptor})} +function Set-FixtureDescriptor([string]$Descriptor){& wevtutil.exe sl $channel ('/ca:'+$Descriptor);if($LASTEXITCODE -ne 0){throw 'Fixture channel ACL setter failed.'};$global:LASTEXITCODE=0;if((Get-WelaNativeChannel $channel).SecurityDescriptor -cne $Descriptor){throw 'Fixture channel descriptor readback differs.'}} +function Read-AsOwnedUser([string]$Label,[int]$ExpectedExit){ + $output=Join-Path $readerHome $Label + # Credentials are passed as a SecureString through the process API, never command-line text. + $arguments='-NoProfile -ExecutionPolicy Bypass -File "'+(Join-Path $codeRoot 'WELA.ps1')+'" channel-read -ChannelReadName "'+$channel+'" -ChannelReadOutputPath "'+$output+'"' + # Own the process handle directly: Windows PowerShell's Start-Process can lose + # ExitCode for alternate-credential children after they exit. + $start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$engine;$start.Arguments=$arguments + $start.UseShellExecute=$false;$start.CreateNoWindow=$true;$start.WorkingDirectory=$readerHome + $start.UserName=$username;$start.Domain=[Environment]::MachineName;$start.Password=$password;$start.LoadUserProfile=$true + $start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true + $start.EnvironmentVariables['TEMP']=$readerHome;$start.EnvironmentVariables['TMP']=$readerHome + $process=[Diagnostics.Process]::new();$process.StartInfo=$start;$started=$false + try{ + if(-not $process.Start()){throw 'Native reader process did not start.'};$started=$true + $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() + if(-not $process.WaitForExit(90000)){$process.Kill();$null=$process.WaitForExit(5000);throw 'Reader child exceeded 90 seconds.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Reader output pipes did not close within five seconds of process exit.'} + $exitCode=$process.ExitCode + [IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stdout')),$stdout.GetAwaiter().GetResult()) + [IO.File]::WriteAllText((Join-Path $readerHome ($Label+'.stderr')),$stderr.GetAwaiter().GetResult()) + }finally{ + try{if($started -and -not $process.HasExited){$process.Kill();if(-not $process.WaitForExit(5000)){throw 'Reader child termination was not confirmed; no acceptance claim.'}}}finally{$process.Dispose()} + } + if($exitCode -ne $ExpectedExit){Get-Content -LiteralPath (Join-Path $readerHome ($Label+'.stderr'));throw "Reader exit $exitCode expected $ExpectedExit"} + $report=Get-Content -LiteralPath (Join-Path $output 'result.json') -Raw|ConvertFrom-Json + if($report.ReaderBefore.UserSid -cne $ownedSid -or $report.ReaderBefore.ElevatedAdministrator -or $report.ReaderBefore.GroupSids -contains 'S-1-5-32-544' -or $report.ReaderBefore.GroupSids -contains 'S-1-5-32-573' -or $report.ReaderBefore.TokenType -cne 'Primary'){throw 'Query did not use the owned standard-user primary token.'} + if($report.Status -ne 'Completed' -or $report.ReadyRuleCredit -ne 0 -or $report.ConfigurationChanges -ne 0){throw 'Incomplete or overclaimed native report.'} + $report +} +try{ + $password=ConvertTo-SecureString ('Wela!7'+[guid]::NewGuid().ToString('N')+'zA#') -AsPlainText -Force + $user=New-LocalUser -Name $username -Password $password -Description ('WELA read '+$nonce) -AccountNeverExpires + $ownedSid=$user.SID.Value + Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $user + # Only the owned fixture tree is made readable/writable by the owned test account. + $acl=Get-Acl $fixture;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'ReadAndExecute','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl $fixture $acl + $acl=Get-Acl $readerHome;$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($user.SID,'FullControl','ContainerInherit,ObjectInherit','None','Allow'));Set-Acl $readerHome $acl + $deny=[Security.AccessControl.RawSecurityDescriptor]::new($before.SecurityDescriptor) + if(-not $deny.DiscretionaryAcl){throw 'Fixture requires an existing DACL.'} + $deny.DiscretionaryAcl.InsertAce(0,[Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]::None,[Security.AccessControl.AceQualifier]::AccessDenied,1,$user.SID,$false,$null)) + $denySddl=$deny.GetSddlForm([Security.AccessControl.AccessControlSections]::All) + if((NativeSettingsKey (Get-WelaNativeChannel $channel)) -cne (NativeSettingsKey $before)){throw 'Channel changed before owned fixture ACL.'} + $aclChanged=$true;Set-FixtureDescriptor $denySddl + $denied=Read-AsOwnedUser 'denied' 1 + if($denied.Results[0].Query.Status -ne 'Denied' -or $denied.Results[0].AccessVerified){throw 'Actual owned read-deny token query was not denied.'} + if((Get-WelaNativeChannel $channel).SecurityDescriptor -cne $denySddl){throw 'Fixture ACL drift before grant.'} + $allow=[Security.AccessControl.RawSecurityDescriptor]::new($before.SecurityDescriptor) + $index=0;while($index -lt $allow.DiscretionaryAcl.Count -and -not $allow.DiscretionaryAcl[$index].IsInherited){$index++} + $allow.DiscretionaryAcl.InsertAce($index,[Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]::None,[Security.AccessControl.AceQualifier]::AccessAllowed,1,$user.SID,$false,$null)) + $allowSddl=$allow.GetSddlForm([Security.AccessControl.AccessControlSections]::All);Set-FixtureDescriptor $allowSddl + $allowed=Read-AsOwnedUser 'allowed' 0 + if(-not $allowed.Results[0].AccessVerified -or $allowed.Results[0].Query.Status -notin @('ReadAllowedEmpty','EventObserved')){throw 'Actual owned read-only ACE failed to authorize the fresh standard-user query.'} + if($denied.ReaderBefore.AuthenticationId -ceq $allowed.ReaderBefore.AuthenticationId){throw 'Expected independent fresh logon tokens.'} + if((Get-WelaNativeChannel $channel).SecurityDescriptor -cne $allowSddl){throw 'Read-only command changed or raced fixture ACL.'} + # A populated built-in Application log is queried under the actual administrator too. + $admin=Invoke-WelaChannelRead @('Application') (Join-Path $fixture 'admin') + if($admin.ExitCode -ne 0 -or $admin.Results[0].Query.Status -ne 'EventObserved'){throw 'Expected one real Application event without payload export.'} + # A loaded helper cannot silently stand in for subsequently changed source bytes. + $ownedHelper=Join-Path $codeRoot 'scripts/ChannelReadNative.cs';$originalHelper=[IO.File]::ReadAllBytes($ownedHelper) + try{ + $script:ScriptRoot=$codeRoot + $null=Get-WelaChannelReader + [IO.File]::AppendAllText($ownedHelper,"`n// owned source-drift fixture`n") + $refused=$false;try{$null=Get-WelaChannelReader}catch{if($_.Exception.Message -notlike '*fresh PowerShell process*'){throw};$refused=$true} + if(-not $refused){throw 'Loaded token helper accepted changed native source bytes.'} + }finally{[IO.File]::WriteAllBytes($ownedHelper,$originalHelper);$script:ScriptRoot=$root} + $passed=$true +}finally{ + $errors=@() + if($aclChanged){try{Set-FixtureDescriptor $before.SecurityDescriptor;if((NativeSettingsKey (Get-WelaNativeChannel $channel)) -cne (NativeSettingsKey $before)){throw 'Full channel settings differ after restoration.'}}catch{$errors+=[string]$_}} + if($ownedSid){try{$current=Get-LocalUser -Name $username -ErrorAction Stop;if($current.SID.Value -cne $ownedSid){throw 'Owned account identity changed; refusing deletion.'};Remove-LocalUser -SID $ownedSid -ErrorAction Stop;if(Get-LocalUser -SID $ownedSid -ErrorAction SilentlyContinue){throw 'Owned account remains.'}}catch{$errors+=[string]$_}} + [pscustomobject]@{Passed=$passed;CleanupErrors=$errors;AccountSid=$ownedSid;ChannelRestored=($errors.Count -eq 0);EventGeneration='Not tested';Scope='Real fresh local standard-user CAPI2 query denial/read permission plus admin Application read; no WEF/service-token or Sigma claim'}|ConvertTo-Json -Depth 5|Set-Content -LiteralPath (Join-Path $fixture 'acceptance.json') -Encoding UTF8 + if($errors.Count){throw ($errors -join '; ')} + Write-Host "Native channel-read evidence: $fixture" +} +if(-not $passed){throw 'Native channel-read acceptance incomplete.'} diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 26523c72..b91d1c7d 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 読み取り専用の `channel-read` を追加し、実際の実行トークンで選択したWindows標準ローカルログを確認します。メタデータやACLとは独立して拒否・不在・空・イベント読取を区別し、トークンと環境の変化を検出して保護された限定レポートに記録します。Windows設定の変更やSigma利用可能数への加算は行いません。使い捨て標準ユーザーでServer 2022/2025・PowerShell 5.1/7の拒否と読取を検証します。 (#432) (@Shirofune-Security) + - 専用 CA 監査設定の再起動待ちを確認し、明示的に再開する `adcs-resume` を追加。元の記録・結果、出典、現在の CA と実行者、永続的な意図記録、変更直前・最終確認により古い計画の再実行を拒否し、観測した設定を保持します。ドライランと失敗を区別し、イベントや Sigma の証明は加算しません。使い捨て CA テストでは最初の再起動拒否のみを注入し、公開 CLI による実際の再起動と要求イベントを検証します。既存の専用 CA Configure のドライラン引数制限も修正しました。 (#431) (@Shirofune-Security) - `event-measurement`を追加。明示した組み込みの管理・運用チャネル1つを一定時間だけ観測し、単調時計によるコールバック到着時間、元のXML・ブックマーク、非公開の証拠、ネイティブEVTXサンプルの厳密な再読み取りを記録します。上限超過、欠落・古い記録、状態変化、不完全なエクスポートは未検証とし、サンプルのバイト数からログ増加量・保持容量・バックエンド到達・Sigma検知可能性を推定しません。 (#430) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 1e5f53e8..ccaa1794 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added read-only `channel-read` to test actual current-token access to selected built-in local logs. Native query outcomes distinguish denied, missing, empty and observed-event reads independently of metadata/ACL inspection; token/context checks and bounded private reports preserve uncertainty without changing Windows settings or granting Sigma credit. Disposable standard-user denial/read tests cover Server 2022/2025 and PowerShell 5.1/7. (#432) (@Shirofune-Security) + - Added `adcs-resume` to review and explicitly resume a dedicated pending CA auditing restart. Original journal/results, source and current CA/operator fingerprints, durable intent receipts and fresh/final checks prevent stale-plan replay and preserve observed settings. Dry-run and failed attempts remain explicit, with no event/Sigma credit. Disposable CA tests inject the initial refusal then verify an actual public-CLI restart and request events. Also fixed the existing dedicated CA Configure dry-run CLI guard. (#431) (@Shirofune-Security) - Added opt-in `event-measurement` for bounded local callback-delivery windows on one explicit built-in Administrative/Operational channel, with monotonic timing, original XML/bookmarks, private evidence and exact native EVTX sample reopening. Caps, missing/stale records, source drift and incomplete exports remain unverified; sample-file bytes do not imply channel growth, retention capacity, backend ingestion or Sigma readiness. (#430) (@Shirofune-Security)