From 610d27e8ff61b0e9e0a8013026e30d65290678be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E4=B8=AD=E3=82=B6=E3=83=83=E3=82=AF=20Isaac=20Ma?= =?UTF-8?q?this?= <43838376+Shirofune-Security@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:51:01 +0900 Subject: [PATCH] Review and apply query updates to existing disabled WEC subscriptions (#426) * Add reviewed existing-only updates for disabled WEC subscriptions * Pin loaded updater and flush locked recovery artifacts; reference PR 426 * Compare formatted WEC queries semantically before pinning raw native state * Read native WEC subscription XML with bounded explicit Unicode pipes * Use explicit Unicode reads for reviewed update and native cleanup * Keep timestamp strings exact in inherited native evidence fixtures * Reject XML-invalid descriptions before any native save * Decode native WEC XML BOMs without unsupported Unicode switch * Describe native XML byte decoding accurately * Reference System.Xml explicitly when compiling under Windows PowerShell --- .gitattributes | 5 + .github/workflows/wec-update.yml | 40 ++++++++ CHANGELOG-Japanese.md | 1 + CHANGELOG.md | 1 + WELA.ps1 | 21 ++++ docs/wec-update.md | 28 ++++++ scripts/WecUpdate.ps1 | 129 +++++++++++++++++++++++++ scripts/WecUpdateNative.cs | 65 +++++++++++++ tests/WecUpdate.Cli.Tests.ps1 | 15 +++ tests/WecUpdate.Tests.ps1 | 74 ++++++++++++++ tests/WecUpdate.Windows.Tests.ps1 | 72 ++++++++++++++ website/docs/resources/changelog.ja.md | 1 + website/docs/resources/changelog.md | 1 + 13 files changed, 453 insertions(+) create mode 100644 .github/workflows/wec-update.yml create mode 100644 docs/wec-update.md create mode 100644 scripts/WecUpdate.ps1 create mode 100644 scripts/WecUpdateNative.cs create mode 100644 tests/WecUpdate.Cli.Tests.ps1 create mode 100644 tests/WecUpdate.Tests.ps1 create mode 100644 tests/WecUpdate.Windows.Tests.ps1 diff --git a/.gitattributes b/.gitattributes index 5fbbcc74..4e7ac376 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,6 +24,11 @@ # Fixed public pending-request fixture is pinned by its exact byte hash. /tests/fixtures/adcs-pending-probe.csr text eol=lf +scripts/WecUpdate.ps1 text eol=lf +scripts/WecUpdateNative.cs text eol=lf +modules/WefSubscriptions.psm1 text eol=lf +tests/WecUpdate*.ps1 text eol=lf +modules/WecSubscriptionXml.cs text eol=lf # DNS analytical receipts pin the catalog and lifecycle implementation bytes. /config/native_provider_packs.json text eol=lf /scripts/NativeProviderPacks.ps1 text eol=lf diff --git a/.github/workflows/wec-update.yml b/.github/workflows/wec-update.yml new file mode 100644 index 00000000..5cb48e6e --- /dev/null +++ b/.github/workflows/wec-update.yml @@ -0,0 +1,40 @@ +name: Reviewed disabled WEC subscription updates +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + wec-update: + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Guard and failure fixtures in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/WecUpdate.Tests.ps1 + ./tests/WecUpdate.Cli.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + - name: Actual disabled-subscription updates in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: ./tests/WecUpdate.Windows.Tests.ps1 -AllowDisposableSubscription + - name: Guard and failure fixtures in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/WecUpdate.Tests.ps1 + ./tests/WecUpdate.Cli.Tests.ps1 + ./tests/WecSubscriptionXml.Tests.ps1 + - name: Actual disabled-subscription updates in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: ./tests/WecUpdate.Windows.Tests.ps1 -AllowDisposableSubscription diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 6102b171..406734b9 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,7 @@ **改善:** +- 既に無効なネイティブ購読のクエリと説明だけを変更する `wec-update` を追加。定義・実ホスト・コードの指紋、レビュー済み計画のハッシュ、永続レシート、直前確認と変更後の読み戻しにより、再作成や有効化をせずに変更を検証します。使い捨て Windows テストは実更新・復元と古い計画の拒否を確認します。稼働中ソースのブックマーク・配送・Sigma 準備状態は未検証です。 (#426) (@Shirofune-Security) - 任意実行の`dns-analytical`を追加し、DNS Serverの分析ログを監査・計画・明示選択で設定できるようにしました。トレース再設定への個別同意、永続的な変更前記録、容量を制限したネイティブETLの退避とハッシュ検証に対応し、ACL・パス・既存の大きいバッファを保持します。退避失敗で停止した状態を失敗として報告します。使い捨てDNS環境のループバックイベント257と設定復元のテストを追加し、転送・Sigmaの利用可能性は未検証のままです。 (#425) (@Shirofune-Security) - 読み取り専用の`wec-runtime`を追加し、WEC標準APIの稼働状態、数値エラー、UTC時刻と件数を制限した送信元別の観測結果を取得します。実行者・ホスト・定義の変更、不明な値と上限到達を明示し、既存のWEF・保存状態レポートでは元の文字列も保持します。過去の送信元一覧を現在の接続数とは扱わず、Activeからイベント到着やSigma利用可能性を推定しません。無効な使い捨てサブスクリプションで検証し、サービス状態とテスト対象を復元します。WEF・EVTXの合成テスト資料で時刻の末尾ゼロが失われる問題も修正しました。 (#424) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index 093cad4a..c8b12b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Improvements:** +- Added `wec-update` to review and apply query/description changes to one already disabled native subscription through existing-only WEC handles. Complete definition/context/code fingerprints, a separately reviewed plan hash, durable receipts, fresh checks and preserved-property readback reject drift without recreation or activation. Disposable Windows tests cover actual updates/restoration and stale plans; active-source bookmarks, delivery and Sigma readiness remain unverified. (#426) (@Shirofune-Security) - Added opt-in `dns-analytical` auditing, planning and selective DNS Server channel configuration with explicit trace-reset consent, durable state records and bounded native ETL archives verified before resets. Preserve ACLs, paths and larger buffers; report stopped partial failures honestly. Added disposable standalone-DNS tests for loopback event 257 and exact configuration restoration; forwarding and Sigma readiness remain unverified. (#425) (@Shirofune-Security) - Added read-only `wec-runtime` with typed native WEC activity, numeric errors, UTC timestamps and bounded per-source observations. Actual reader/context and definition checks keep partial reads, caps and drift explicit; existing WEF/retention inventories retain raw text alongside typed fields. Historical source lists are not connection counts and Active grants no arrival or Sigma credit. Disposable disabled-subscription tests restore service state and remove only their owned fixture. Also fixed synthetic WEF/EVTX fixture timestamp roundtrips without weakening bundle validation. (#424) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index ded0ee9e..4b939c0c 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -97,6 +97,14 @@ [string]$RecoveryOutputPath, [string]$ArrivalProbePath, [string]$ArrivalOutputPath, + [ValidateSet('Plan','Apply')][string]$WecUpdateAction = 'Plan', + [string]$WecUpdateId, + [string[]]$WecUpdateSourceSid, + [string]$WecUpdateQueryPath, + [AllowEmptyString()][string]$WecUpdateDescription, + [string]$WecUpdatePlanPath, + [string]$WecUpdatePlanHash, + [string]$WecUpdateOutputPath, [ValidateSet('Audit','Plan','Configure')][string]$DnsAction = 'Audit', [ValidateSet('Enabled','Disabled')][string]$DnsState, [ValidateSet('Preserve','Circular','Retain')][string]$DnsRetention = 'Preserve', @@ -149,6 +157,7 @@ Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorA . (Join-Path $ScriptRoot "scripts/DnsAnalytical.ps1") Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorAction Stop . (Join-Path $ScriptRoot "scripts/WefDeployment.ps1") +. (Join-Path $ScriptRoot "scripts/WecUpdate.ps1") . (Join-Path $ScriptRoot "scripts/RetentionHealth.ps1") . (Join-Path $ScriptRoot "scripts/AuditScoring.ps1") . (Join-Path $ScriptRoot "scripts/TargetedSaclPlanning.ps1") @@ -1921,6 +1930,7 @@ Usage: ./WELA.ps1 adcs-auditing -Help # Dedicated local CA audit settings; restart requires explicit consent ./WELA.ps1 score -Help # Separate configuration compliance and evidence-qualified readiness ./WELA.ps1 intune-export -Help # Offline native audit OMA-URI/Graph artifacts; no tenant changes + ./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription ./WELA.ps1 applocker-probe -Help # Collect a fixed native AppLocker EXE event ./WELA.ps1 wef-arrival -Help # Verify exact native probe presence on the local collector ./WELA.ps1 native-validation -Help # Collect a fixed native 4688 probe without changing policy @@ -1985,6 +1995,8 @@ if ($PSBoundParameters.ContainsKey('ProfileFile')) { if ($Cmd -eq 'profiles' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @('Cmd','ProfileFile','Help') }).Count) { throw 'profiles -ProfileFile lists the selected file and accepts no assessment/configuration options.' } } +if ($Cmd -ne 'wec-update' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecUpdate*'}).Count) {throw 'WecUpdate options require wec-update.'} +if ($Cmd -eq 'wec-update' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecUpdateAction','WecUpdateId','WecUpdateSourceSid','WecUpdateQueryPath','WecUpdateDescription','WecUpdatePlanPath','WecUpdatePlanHash','WecUpdateOutputPath','Help')}).Count) {throw 'wec-update accepts only dedicated options.'} if ($Cmd -ne 'wec-runtime' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecRuntime*'}).Count) { throw 'WecRuntime options require wec-runtime. No command was run.' } @@ -2161,6 +2173,15 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'wec-update' { + if ($Help) {Write-Host 'Usage: wec-update [-WecUpdateAction Plan] -WecUpdateId ID -WecUpdateSourceSid SID -WecUpdateQueryPath query.xml -WecUpdateDescription text -WecUpdateOutputPath new-directory; then Apply with -WecUpdatePlanPath reviewed-plan.json -WecUpdatePlanHash SHA256 -WecUpdateOutputPath new-directory. Only query/description on already disabled subscriptions. See docs/wec-update.md.';return} + $arguments=@{Action=$WecUpdateAction;OutputPath=$WecUpdateOutputPath} + $map=@{WecUpdateId='Id';WecUpdateSourceSid='SourceSids';WecUpdateQueryPath='QueryPath';WecUpdateDescription='Description';WecUpdatePlanPath='PlanPath';WecUpdatePlanHash='PlanHash'} + foreach($name in $map.Keys){if($PSBoundParameters.ContainsKey($name)){$arguments[$map[$name]]=$PSBoundParameters[$name]}} + $report=Invoke-WelaWecUpdate @arguments + $report + if($report.ExitCode){exit $report.ExitCode} + } 'applocker-probe' { if ($Help) {Write-Host 'Usage: applocker-probe [-AppLockerProbeAction Plan|Run] [-AppLockerProbeOutputPath new-private-directory] [-AppLockerProbeTimeoutSeconds 1..30]. Requires existing EXE audit-only policy, running AppIDSvc and enabled channel. Run launches a fixed native cmd.exe copy and collects one exact AppLocker event. See docs/applocker-probe.md.';return} $report=Invoke-WelaAppLockerProbe -Action $AppLockerProbeAction -OutputPath $AppLockerProbeOutputPath -TimeoutSeconds $AppLockerProbeTimeoutSeconds diff --git a/docs/wec-update.md b/docs/wec-update.md new file mode 100644 index 00000000..80d1b598 --- /dev/null +++ b/docs/wec-update.md @@ -0,0 +1,28 @@ +# Reviewed changes to a disabled WEC subscription + +Related to #368. The existing `wec-collector` create workflow continues to preserve every different existing subscription. The separate `wec-update` command can change only the query and description of one **already disabled**, source-initiated HTTP/native-event subscription using Windows Event Collector APIs. It never creates, replaces, deletes, enables or pauses a subscription. Sysmon is excluded. + +```powershell +.\WELA.ps1 wec-update -WecUpdateId 'Reviewed native subscription' ` + -WecUpdateSourceSid 'S-1-5-21-111111111-222222222-333333333-1234' ` + -WecUpdateQueryPath C:\Review\query.xml -WecUpdateDescription 'Reviewed account events' ` + -WecUpdateOutputPath C:\Evidence\new-plan +# Review plan.json: full original XML, desired query/description, host/reader and implementation hashes. +# Record the PlanHash from this planning result after reviewing the exact file bytes. +.\WELA.ps1 wec-update -WecUpdateAction Apply ` + -WecUpdatePlanPath C:\Evidence\new-plan\plan.json ` + -WecUpdatePlanHash '' ` + -WecUpdateOutputPath C:\Evidence\new-apply +``` + +Both actions require new private directories on local fixed drives with existing parents. Description is mandatory for planning; an explicitly empty string clears it. Source SIDs must match the existing narrow authorization exactly. The query parser accepts explicit built-in Windows channels and structural QueryList syntax; Windows validates the saved query. Queries are limited to 262,144 characters and plans to four MiB. Native XML is read as bounded raw bytes with strict XML Unicode decoding and a timeout, preserving non-ASCII descriptions across PowerShell versions. The actual local Server 2022/2025 collector, reader/token and running Wecsvc are observed. Standalone servers can review disabled subscriptions; no source connectivity is implied. No host/build overrides, service changes or remote collectors are supported. + +The reviewed plan binds the complete original XML, explicit desired values, actual collector/reader/service context and implementation hashes. Apply verifies the separately supplied plan SHA256, strict JSON schema, native channel/query scope, complete original definition and fresh context. A protected pending receipt is flushed and read back before mutation. The native handle uses `EC_OPEN_EXISTING`, reads disabled/query/description again, sets only `EcSubscriptionQuery` and `EcSubscriptionDescription`, then saves. Final readback checks the requested values and all remaining observed XML. Already matching values cause no save. + +Concurrent changes, enabled subscriptions, unsupported definitions, denied reads and changed plans fail rather than broadening scope. If save is attempted but fails or readback differs, the manifest says `SaveAttemptedUnverified`; no automatic rollback can overwrite an intervening administrator change. Preserve the receipt and inspect the actual subscription. Restoring original values requires a fresh plan against its current state using the original recorded query/description. Windows exposes no compare-and-swap or subscription lock here: the pre-save checks narrow but cannot eliminate a concurrent administrative write between observation and save. Coordinate a maintenance window; hashes are consistency checks, not signatures or authenticated approval. + +The subscription remains disabled, and authorization, destination, delivery, locale, transport, ReadExistingEvents and other observed settings must remain unchanged. This first version deliberately requires disabled state: Microsoft documents that saving an enabled subscription activates it. Active-source delivery and bookmark continuity require separate lab acceptance before extending that scope. A successful disabled update grants **zero Sigma readiness credit** and proves neither delivery nor retention. + +Tests include malformed/duplicate JSON, stale plans, changed context, unexpected enablement, preservation failure, native error, false success, idempotence and pending receipt ordering. Disposable Server 2022/2025 × Windows PowerShell 5.1/PowerShell 7 CI creates one unique disabled subscription with no real source, changes and restores query/description through the public command, rejects the stale plan, verifies other properties and restores subscription inventory plus original Wecsvc state/startup. It does not validate active sources or bookmarks. + +References: [existing-only handles](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecopensubscription), [subscription properties](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/ne-evcoll-ec_subscription_property_id), [save/activation semantics](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecsavesubscription), [Microsoft SDK ABI definitions](https://github.com/microsoft/win32metadata/blob/main/generation/WinSDK/RecompiledIdlHeaders/um/EvColl.h). diff --git a/scripts/WecUpdate.ps1 b/scripts/WecUpdate.ps1 new file mode 100644 index 00000000..5a8cb80f --- /dev/null +++ b/scripts/WecUpdate.ps1 @@ -0,0 +1,129 @@ +# Reviewed, existing-only updates of disabled source-initiated subscriptions. +function Write-WelaWecUpdateArtifact { + param([string]$Root,[string]$Name,[string]$Text) + $null=Resolve-WelaArrivalPath $Root + $bytes=[Text.UTF8Encoding]::new($false).GetBytes($Text);$path=Join-Path $Root $Name + $stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None) + try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true);$stream.Position=0;$sha=[Security.Cryptography.SHA256]::Create();try{$readback=([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','').ToLowerInvariant()}finally{$sha.Dispose()}}finally{$stream.Dispose()} + $hash=Get-WelaArrivalHash $bytes + if($readback -cne $hash){throw 'Update artifact readback differs.'} + [pscustomobject]@{Name=$Name;Sha256=$hash;Bytes=$bytes.Length} +} +function Get-WelaWecUpdateContext { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw '64-bit native Windows is required.'} + $hostState=Get-WelaDefaultContext + if(-not(Test-WelaDefaultContextComplete $hostState) -or $hostState.ProductType -ne 3 -or $hostState.Build -notin @(20348,26100) -or $hostState.DomainRole -notin @(2,3)){throw 'An observed Server 2022/2025 member or standalone collector is required.'} + $identity=[Security.Principal.WindowsIdentity]::GetCurrent() + try{$reader=[ordered]@{Name=$identity.Name;Sid=$identity.User.Value;Groups=@($identity.Groups.Value|Sort-Object)}}finally{$identity.Dispose()} + $service=Get-CimInstance Win32_Service -Filter "Name='Wecsvc'" -ErrorAction Stop + if(-not $service -or $service.State -ne 'Running'){throw 'Wecsvc must already be running; no service changes are made.'} + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;HostKey=(Get-WelaDefaultContextKey $hostState);Reader=$reader;Service=[ordered]@{State=[string]$service.State;StartMode=[string]$service.StartMode}} +} +function Get-WelaWecUpdateSources { + $root=Split-Path $PSScriptRoot -Parent + $sources=[ordered]@{} + foreach($name in @('scripts/WecUpdate.ps1','scripts/WecUpdateNative.cs','modules/WefSubscriptions.psm1','modules/WecSubscriptionXml.cs','scripts/Configuration.ps1','scripts/ControlApplicability.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $root $name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()} + $sources|ConvertTo-Json -Compress +} +function Read-WelaWecUpdateFile { + param([string]$Path,[int]$MaximumBytes=4194304) + $full=Resolve-WelaArrivalPath $Path;$stream=[IO.File]::Open($full,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) + try { + if($stream.Length -gt $MaximumBytes){throw 'Update input exceeds its size limit.'} + $bytes=New-Object byte[] ([int]$stream.Length);$offset=0 + while($offset -lt $bytes.Length){$read=$stream.Read($bytes,$offset,$bytes.Length-$offset);if($read -eq 0){throw 'Update input was truncated.'};$offset+=$read} + $text=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xFEFF) + [pscustomobject]@{Path=$full;Text=$text;Hash=(Get-WelaArrivalHash $bytes)} + }finally{$stream.Dispose()} +} +function Get-WelaWecUpdateDefinition { + param([string]$Xml,[string[]]$SourceSids) + $model=ConvertFrom-WelaWefSubscription -Xml $Xml -SourceSids $SourceSids -Observed + if($model.Definition.Enabled -ne $false){throw 'Only an already disabled subscription can be updated; no activation or pause is performed.'} + $doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement;$whole=Get-WelaWefXmlKey $root + $ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('s',$root.NamespaceURI) + $query=$root.SelectSingleNode('s:Query',$ns).InnerText + $description=$root.SelectSingleNode('s:Description',$ns);$descriptionText=if($description){[string]$description.InnerText}else{''} + foreach($node in @($root.SelectNodes('s:Query|s:Description',$ns))){$null=$root.RemoveChild($node)} + [pscustomobject]@{Id=$model.Id;Xml=$Xml;WholeKey=$whole;PreservedKey=(Get-WelaWefXmlKey $root);QueryXml=$query;QueryKey=$model.Query.Key;Description=$descriptionText} +} +function Read-WelaWecUpdateDefinition { + param([string]$Id,[string[]]$SourceSids) + if($Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$'){throw 'Invalid exact subscription ID.'} + $definition=Get-WelaWecUpdateDefinition (Read-WelaWecSubscriptionXml $Id) $SourceSids + if($definition.Id -cne $Id){throw 'Native subscription identity differs from the selected ID.'} + $definition +} +function New-WelaWecUpdateEdit { + param($Before) + $path=Join-Path $PSScriptRoot 'WecUpdateNative.cs';$hash=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash + if(-not('Wela.WecUpdate.Edit' -as [type])){Add-Type -Path $path -ErrorAction Stop;$script:WelaWecUpdateNativeHash=$hash} + if($script:WelaWecUpdateNativeHash -cne $hash){throw 'Loaded native updater differs from its current source; start a fresh process.'} + $edit=[Wela.WecUpdate.Edit]::new($Before.Id) + try { + # wecutil formats the embedded query XML. Compare its validated semantic + # key here; the native handle keeps exact raw strings for its fresh save guard. + if((ConvertFrom-WelaWefQuery $edit.OriginalQuery).Key -cne $Before.QueryKey -or $edit.OriginalDescription -cne $Before.Description){throw 'Native handle query/description differ from the reviewed definition.'} + return $edit + }catch{$edit.Dispose();throw} +} +function Assert-WelaWecUpdatePlan { + param($Plan) + Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Id','SourceSids','ContextKey','Sources','BeforeXml','QueryXml','Description','RecordedUtc') + if($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]){throw 'Plan version must be an integer.'} + if($Plan.SchemaVersion -ne 1 -or $Plan.Kind -cne 'WelaDisabledWecUpdatePlan' -or $Plan.Id -isnot [string] -or $Plan.SourceSids -isnot [array] -or $Plan.ContextKey -isnot [string] -or $Plan.Sources -isnot [string] -or $Plan.BeforeXml -isnot [string] -or $Plan.QueryXml -isnot [string] -or $Plan.QueryXml.Length -gt 262144 -or $Plan.Description -isnot [string] -or $Plan.Description.Length -gt 4096 -or $Plan.Description -match '[\x00-\x08\x0b\x0c\x0e-\x1f]'){throw 'Unknown or mistyped update plan.'} + $null=[Xml.XmlConvert]::VerifyXmlChars($Plan.Description) + $null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc + foreach($sid in $Plan.SourceSids){if($sid -isnot [string]){throw 'Source SID must be a string.'}} + $null=Get-WelaWefAuthorization $Plan.SourceSids + $before=Get-WelaWecUpdateDefinition $Plan.BeforeXml $Plan.SourceSids + if($before.Id -cne $Plan.Id){throw 'Plan identity contradicts its original subscription.'} + $null=ConvertFrom-WelaWefQuery $Plan.QueryXml +} +function Invoke-WelaWecUpdate { + param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$Id,[string[]]$SourceSids,[string]$QueryPath,[AllowEmptyString()][string]$Description,[string]$PlanPath,[string]$PlanHash,[Parameter(Mandatory)][string]$OutputPath) + if($Action -eq 'Plan'){ + if(-not $Id -or -not $SourceSids -or -not $QueryPath -or -not $PSBoundParameters.ContainsKey('Description') -or $PlanPath -or $PlanHash){throw 'Plan requires ID, explicit source SIDs, query path, description and new output; no prior plan.'} + }elseif(-not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or $Id -or $SourceSids -or $QueryPath -or $PSBoundParameters.ContainsKey('Description')){throw 'Apply accepts only a reviewed plan path, its SHA256 and new output.'} + $sourceInput=if($Action -eq 'Plan'){Read-WelaWecUpdateFile $QueryPath 524288}else{Read-WelaWecUpdateFile $PlanPath} + $output=New-WelaArrivalOutput $OutputPath $sourceInput.Path + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaDisabledWecUpdate';Action=$Action;Status='Failed';ExitCode=1;RecordedUtc=[DateTime]::UtcNow.ToString('o');OutputPath=$output;PlanHash=$null;NativeSaveAttempted=$false;After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='Query and description of one disabled subscription only. Delivery, bookmarks, retention and Sigma readiness are unverified. Sysmon excluded.'} + $edit=$null + try { + $context=Get-WelaWecUpdateContext;$contextKey=$context|ConvertTo-Json -Depth 16 -Compress;$sources=Get-WelaWecUpdateSources + if($Action -eq 'Plan'){ + $null=ConvertFrom-WelaWefQuery $sourceInput.Text + $before=Read-WelaWecUpdateDefinition $Id $SourceSids + $plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaDisabledWecUpdatePlan';Id=$Id;SourceSids=@($SourceSids);ContextKey=$contextKey;Sources=$sources;BeforeXml=$before.Xml;QueryXml=$sourceInput.Text;Description=$Description;RecordedUtc=[DateTime]::UtcNow.ToString('o')} + Assert-WelaWecUpdatePlan $plan + if((Read-WelaWecUpdateFile $QueryPath 524288).Hash -cne $sourceInput.Hash){throw 'Desired query input changed during planning.'} + if((Read-WelaWecUpdateDefinition $Id $SourceSids).WholeKey -cne $before.WholeKey -or ((Get-WelaWecUpdateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey)){throw 'Host or subscription drift during planning.'} + $planText=$plan|ConvertTo-Json -Depth 20 + if([Text.Encoding]::UTF8.GetByteCount($planText) -gt 4194304){throw 'The reviewed plan exceeds the four-MiB apply input limit.'} + $artifact=Write-WelaWecUpdateArtifact $output 'plan.json' $planText;$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired';$report.ExitCode=0 + }else{ + if($sourceInput.Hash -cne $PlanHash){throw 'Reviewed plan hash differs from the selected file bytes.'} + $plan=ConvertFrom-WelaArrivalJson $sourceInput.Text;Assert-WelaWecUpdatePlan $plan;$report.PlanHash=$sourceInput.Hash + if($plan.ContextKey -cne $contextKey -or $plan.Sources -cne $sources){throw 'Actual host/reader/service or implementation sources differ from the reviewed plan.'} + $before=Get-WelaWecUpdateDefinition $plan.BeforeXml $plan.SourceSids + $current=Read-WelaWecUpdateDefinition $plan.Id $plan.SourceSids + if($current.WholeKey -cne $before.WholeKey){throw 'Current subscription differs from the reviewed complete definition.'} + $query=ConvertFrom-WelaWefQuery $plan.QueryXml + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $sourceInput.Text + if($current.QueryKey -ceq $query.Key -and $current.Description -ceq $plan.Description){$report.Status='AlreadyMatches';$report.ExitCode=0}else{ + $edit=New-WelaWecUpdateEdit $before + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-save.json' ([ordered]@{Status='Pending';RecordedUtc=[DateTime]::UtcNow.ToString('o');Context=$context;BeforeXml=$before.Xml;DesiredQuery=$plan.QueryXml;DesiredDescription=$plan.Description;PlanHash=$sourceInput.Hash}|ConvertTo-Json -Depth 20) + if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaWecUpdateSources) -cne $sources -or ((Get-WelaWecUpdateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey) -or (Read-WelaWecUpdateDefinition $plan.Id $plan.SourceSids).WholeKey -cne $before.WholeKey){throw 'Plan, code, context or complete subscription changed immediately before save.'} + $report.NativeSaveAttempted=$true;$edit.Save($plan.QueryXml,$plan.Description) + $report.Status='SavedAwaitingReadback' + } + $after=Read-WelaWecUpdateDefinition $plan.Id $plan.SourceSids;$report.After=$after + $report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.xml' $after.Xml + if($after.QueryKey -cne $query.Key -or $after.Description -cne $plan.Description -or $after.PreservedKey -cne $before.PreservedKey -or ((Get-WelaWecUpdateContext|ConvertTo-Json -Depth 16 -Compress) -cne $contextKey) -or (Get-WelaWecUpdateSources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Readback, preserved configuration, context, plan or implementation differs after operation.'} + if($report.NativeSaveAttempted){$report.Status='UpdatedAndVerified'};$report.ExitCode=0 + } + }catch{$report.Status=if($report.NativeSaveAttempted){'SaveAttemptedUnverified'}else{'Refused'};$report.ExitCode=1;$report.Diagnostic=$_.Exception.Message} + finally{if($edit){$edit.Dispose()}} + $null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24) + $report +} diff --git a/scripts/WecUpdateNative.cs b/scripts/WecUpdateNative.cs new file mode 100644 index 00000000..55b9369f --- /dev/null +++ b/scripts/WecUpdateNative.cs @@ -0,0 +1,65 @@ +// Narrow existing-only WEC setter. No create/delete, enable or source-property setter. +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +namespace Wela.WecUpdate { + public sealed class Edit : IDisposable { + [StructLayout(LayoutKind.Explicit,Size=16)] struct Variant { + [FieldOffset(0)] public IntPtr Text; [FieldOffset(8)] public uint Count; [FieldOffset(12)] public uint Type; + } + [DllImport("wecapi.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern IntPtr EcOpenSubscription(string name,uint access,uint flags); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcGetSubscriptionProperty(IntPtr handle,int property,uint flags,uint size,IntPtr value,out uint used); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSetSubscriptionProperty(IntPtr handle,int property,uint flags,ref Variant value); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcSaveSubscription(IntPtr handle,uint flags); + [DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle); + IntPtr handle; readonly string name,oldQuery,oldDescription; + public string OriginalQuery {get{return oldQuery;}} + public string OriginalDescription {get{return oldDescription;}} + static object Read(IntPtr h,int property) { + uint size=16; + for(int attempt=0;attempt<3;attempt++) { + IntPtr buffer=Marshal.AllocHGlobal((int)size); + try { + uint used;bool ok=EcGetSubscriptionProperty(h,property,0,size,buffer,out used);int error=Marshal.GetLastWin32Error(); + if(!ok) {if(error!=122)throw new Win32Exception(error);if(used<=size||used>1048576)throw new InvalidOperationException("Invalid native property buffer size.");size=used;continue;} + if(used<16||used>size)throw new InvalidOperationException("Invalid native property length."); + int type=Marshal.ReadInt32(buffer,12); + if(property==0) {if(type!=1)throw new InvalidOperationException("Enabled property is not Boolean.");int value=Marshal.ReadInt32(buffer);if(value!=0&&value!=1)throw new InvalidOperationException("Invalid native Boolean.");return value==1;} + if(type==0&&property==6)return ""; + if(type!=4)throw new InvalidOperationException("Expected scalar native string."); + IntPtr pointer=Marshal.ReadIntPtr(buffer);long offset=pointer.ToInt64()-buffer.ToInt64(); + if(pointer==IntPtr.Zero||offset<16||offset>used-2)throw new InvalidOperationException("Native string pointer is outside its buffer."); + StringBuilder text=new StringBuilder(); + for(int i=0;i<524288&&offset+2L*i+2<=used;i++){char c=(char)(ushort)Marshal.ReadInt16(pointer,2*i);if(c==0)return text.ToString();text.Append(c);} + throw new InvalidOperationException("Unterminated native string."); + } finally {Marshal.FreeHGlobal(buffer);} + } + throw new InvalidOperationException("Native property changed repeatedly."); + } + void Check(IntPtr h) { + if((bool)Read(h,0))throw new InvalidOperationException("Subscription must remain disabled."); + if(!String.Equals((string)Read(h,10),oldQuery,StringComparison.Ordinal)||!String.Equals((string)Read(h,6),oldDescription,StringComparison.Ordinal))throw new InvalidOperationException("Native query/description changed since review."); + } + public Edit(string id) { + if(String.IsNullOrWhiteSpace(id)||id.Length>128||id.IndexOf('\0')>=0)throw new ArgumentException("Invalid subscription ID."); + name=id; + handle=EcOpenSubscription(name,3,2);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error()); + try{oldQuery=(string)Read(handle,10);oldDescription=(string)Read(handle,6);Check(handle);}catch{Dispose();throw;} + } + void Set(int property,string text) { + if(text==null||text.Length>262144||text.IndexOf('\0')>=0)throw new ArgumentException("Invalid bounded string property."); + IntPtr value=Marshal.StringToHGlobalUni(text); + try{Variant v=new Variant {Text=value,Type=4,Count=0};if(!EcSetSubscriptionProperty(handle,property,0,ref v))throw new Win32Exception(Marshal.GetLastWin32Error());}finally{Marshal.FreeHGlobal(value);} + } + public void Save(string query,string description) { + if(handle==IntPtr.Zero)throw new ObjectDisposedException("Edit"); + // A separately opened view detects changes since the staged handle was opened. + IntPtr fresh=EcOpenSubscription(name,1,2);if(fresh==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error()); + try{Check(fresh);}finally{EcClose(fresh);} + Set(10,query);Set(6,description); + if(!EcSaveSubscription(handle,0))throw new Win32Exception(Marshal.GetLastWin32Error()); + } + public void Dispose(){if(handle!=IntPtr.Zero){EcClose(handle);handle=IntPtr.Zero;}} + } +} diff --git a/tests/WecUpdate.Cli.Tests.ps1 b/tests/WecUpdate.Cli.Tests.ps1 new file mode 100644 index 00000000..6c2b4c30 --- /dev/null +++ b/tests/WecUpdate.Cli.Tests.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent +$engine=(Get-Process -Id $PID).Path;$count=0 +$cases=@( + @{Args=@('wec-update','-Help');Code=0;Pattern='already disabled'}, + @{Args=@('configure','-WecUpdateAction','Apply','-Auto');Code=1;Pattern='require wec-update'}, + @{Args=@('wec-update','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-update','-Help','-WefAction','Configure');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-update','-Help','-Auto');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-update','-Help','-DryRun');Code=1;Pattern='only dedicated'}, + @{Args=@('wec-update','-WecUpdateAction','Apply','-WecUpdateOutputPath','not-created');Code=1;Pattern='reviewed plan'}, + @{Args=@('wec-update','-WecUpdateOutputPath','not-created');Code=1;Pattern='Plan requires'} +) +foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($case.Code -eq 0 -and $code -ne 0) -or ($case.Code -ne 0 -and $code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$count++} +Write-Host "WEC update CLI: $count checks passed." +$global:LASTEXITCODE=0 diff --git a/tests/WecUpdate.Tests.ps1 b/tests/WecUpdate.Tests.ps1 new file mode 100644 index 00000000..898153d5 --- /dev/null +++ b/tests/WecUpdate.Tests.ps1 @@ -0,0 +1,74 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +Add-Type -Path "$repo/scripts/WecUpdateNative.cs" -ErrorAction Stop +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{&$Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern; got $message"} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-wec-update-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +$sid='S-1-5-21-11-22-33-1001';$id='WELA Native Security Example' +$base=[IO.File]::ReadAllText("$repo/config/wef-examples/native-security.xml").Replace('true','false').Replace('',(''+(Get-WelaWefAuthorization @($sid))+'')) +$query='' +$queryPath=Join-Path $root 'query.xml';[IO.File]::WriteAllText($queryPath,$query) +$script:xml=$base;$script:saves=0;$script:reads=0;$script:mode='ok';$script:journal='' +function Get-WelaWecUpdateContext {[pscustomobject][ordered]@{Computer='TEST';HostKey='20348';Reader='S-1-5-21-1-2-3-1000';Service='Running'}} +function Read-WelaWecUpdateDefinition {param($Id,$SourceSids);$script:reads++;if($script:mode -eq 'drift' -and $script:reads -eq 2){$script:xml=$script:xml.Replace('MinLatency','Normal')};Get-WelaWecUpdateDefinition $script:xml $SourceSids} +function New-WelaWecUpdateEdit { + param($Before) + $edit=New-Object psobject + $edit|Add-Member ScriptMethod Save {param($Query,$Description) + Assert (Test-Path -LiteralPath $script:journal) 'Durable before-save record precedes mutation' + $script:saves++ + if($script:mode -eq 'failure'){throw 'native save failed'} + if($script:mode -eq 'false-success'){return} + $doc=Read-WelaWefXml $script:xml;$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('s',$doc.DocumentElement.NamespaceURI);$doc.SelectSingleNode('/s:Subscription/s:Query',$ns).InnerText=[string]$Query;$doc.SelectSingleNode('/s:Subscription/s:Description',$ns).InnerText=[string]$Description + if($script:mode -eq 'preservation'){$doc.Subscription.ReadExistingEvents='true'} + $script:xml=$doc.OuterXml + } + $edit|Add-Member ScriptMethod Dispose {} + $edit +} +try { + $before=Get-WelaWecUpdateDefinition $base @($sid);Assert ($before.Id -ceq $id) 'Original parsed' + $formatted=$query.Replace('><',">`r`n <") + Assert ((ConvertFrom-WelaWefQuery $formatted).Key -ceq (ConvertFrom-WelaWefQuery $query).Key) 'Formatted native query XML preserves semantic selection' + Assert ((ConvertFrom-WelaWefQuery $formatted.Replace('4625','4624')).Key -cne (ConvertFrom-WelaWefQuery $query).Key) 'Different event selection changes the semantic key' + Reject {Get-WelaWecUpdateDefinition ($base.Replace('false','true')) @($sid)} 'disabled' + Reject {Get-WelaWecUpdateDefinition $base @('S-1-1-0')} 'SID' + Reject {ConvertFrom-WelaWefQuery ($query.Replace('Security','Microsoft-Windows-Sysmon/Operational'))} 'Sysmon' + Reject {Invoke-WelaWecUpdate -Action Plan -Id $id -SourceSids @($sid) -QueryPath $queryPath -OutputPath (Join-Path $root 'invalid')} 'description' + Reject {Invoke-WelaWecUpdate -Action Apply -PlanPath missing -PlanHash ('a'*64) -Description accidental -OutputPath (Join-Path $root 'invalid')} 'only' + foreach($scenario in @('ok','drift','failure','false-success','preservation','hash','stale','context','duplicate-key','enabled')){ + $script:xml=$base;$script:mode='ok';$script:reads=0;$script:saves=0 + $planResult=Invoke-WelaWecUpdate Plan -Id $id -SourceSids @($sid) -QueryPath $queryPath -Description 'Reviewed change' -OutputPath (Join-Path $root ($scenario+'-plan')) + Assert ($planResult.ExitCode -eq 0 -and $planResult.Status -eq 'ReviewRequired') "Plan created: $($planResult.Diagnostic)" + Assert ($script:saves -eq 0) 'Plan never saves' + if($scenario -eq 'ok'){$badPlan=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText((Join-Path $planResult.OutputPath 'plan.json')));$badPlan.Description=[string][char]0xfffe;Reject {Assert-WelaWecUpdatePlan $badPlan} 'invalid'} + $planPath=Join-Path $planResult.OutputPath 'plan.json';$hash=$planResult.PlanHash + $script:mode=$scenario;$script:reads=0 + if($scenario -eq 'hash'){$hash='b'*64} + if($scenario -eq 'stale'){$script:xml=$base.Replace('MinLatency','Normal')} + if($scenario -eq 'enabled'){$script:xml=$base.Replace('false','true')} + if($scenario -in @('context','duplicate-key')){$text=[IO.File]::ReadAllText($planPath);if($scenario -eq 'context'){$text=$text.Replace('TEST','OTHER')}else{$text=$text.Replace('"SchemaVersion":','"SchemaVersion": 1, "SchemaVersion":')};[IO.File]::WriteAllText($planPath,$text);$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant()} + $out=Join-Path $root ($scenario+'-apply');$script:journal=Join-Path $out 'before-save.json' + $result=Invoke-WelaWecUpdate Apply -PlanPath $planPath -PlanHash $hash -OutputPath $out + Assert (($result.ExitCode -eq 0) -eq ($scenario -eq 'ok')) "Scenario $scenario : $($result.Diagnostic)" + Assert ($result.ReadyRuleCredit -eq 0) 'No Sigma credit' + Assert (Test-Path (Join-Path $out 'manifest.json')) 'Result retained' + if($scenario -in @('drift','hash','stale','context','duplicate-key','enabled')){Assert ($script:saves -eq 0) 'Rejected before save'} + if($scenario -in @('failure','false-success','preservation')){Assert ($result.Status -eq 'SaveAttemptedUnverified' -and $result.NativeSaveAttempted) 'Partial failure reported honestly'} + if($scenario -eq 'ok'){$after=Get-WelaWecUpdateDefinition $script:xml @($sid);Assert ($after.PreservedKey -ceq $before.PreservedKey -and $after.Description -ceq 'Reviewed change' -and $after.QueryKey -ceq (ConvertFrom-WelaWefQuery $query).Key) 'Only two properties changed'} + } + # Replanning an already matching disabled subscription produces no mutation. + $script:mode='ok';$script:xml=$base;$script:saves=0;$old=Get-WelaWecUpdateDefinition $base @($sid) + [IO.File]::WriteAllText($queryPath,$old.QueryXml) + $planResult=Invoke-WelaWecUpdate Plan -Id $id -SourceSids @($sid) -QueryPath $queryPath -Description $old.Description -OutputPath (Join-Path $root 'same-plan') + $result=Invoke-WelaWecUpdate Apply -PlanPath (Join-Path $planResult.OutputPath 'plan.json') -PlanHash $planResult.PlanHash -OutputPath (Join-Path $root 'same-apply') + Assert ($result.Status -eq 'AlreadyMatches' -and $result.ExitCode -eq 0 -and $script:saves -eq 0) 'Idempotence is read only' + Reject {Invoke-WelaWecUpdate Plan -Id $id -SourceSids @($sid) -QueryPath $queryPath -Description '' -OutputPath $root} 'new directory' +}finally{Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "WEC update tests passed: $count assertions." diff --git a/tests/WecUpdate.Windows.Tests.ps1 b/tests/WecUpdate.Windows.Tests.ps1 new file mode 100644 index 00000000..ecb9192f --- /dev/null +++ b/tests/WecUpdate.Windows.Tests.ps1 @@ -0,0 +1,72 @@ +param([switch]$AllowDisposableSubscription) +$ErrorActionPreference='Stop' +if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableSubscription -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows subscription opt-in required.'} +$repo=Split-Path $PSScriptRoot -Parent +Import-Module "$repo/modules/WefSubscriptions.psm1" -Force +Import-Module "$repo/modules/AuditProfiles.psm1" -Force +. "$repo/scripts/Configuration.ps1" +. "$repo/scripts/ControlApplicability.ps1" +. "$repo/scripts/WefArrival.ps1" +. "$repo/scripts/WecUpdate.ps1" +$count=0 +function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++} +function Key($Value){ConvertTo-Json -InputObject $Value -Depth 16 -Compress} +function ServiceState {Get-CimInstance Win32_Service -Filter "Name='Wecsvc'"|Select-Object Name,State,StartMode} +function Subscriptions {@((Invoke-WelaNative 'wecutil.exe' @('es')).Output|ForEach-Object {$_.ToString().Trim()}|Where-Object {$_})} +$beforeService=ServiceState;$serviceKey='HKLM:\SYSTEM\CurrentControlSet\Services\Wecsvc';$beforeDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart +if($beforeService.State -notin @('Running','Stopped') -or $beforeService.StartMode -notin @('Auto','Manual','Disabled')){throw 'Stable Wecsvc state required.'} +$nonce=[guid]::NewGuid().ToString('N');$id='WELA-Update-Test-'+$nonce;$description='Owned original '+$nonce;$changedDescription='Owned reviewed '+([string][char]0x65e5)+([string][char]0x672c)+([string][char]0x8a9e)+' '+$nonce +$sid='S-1-5-21-111111111-222222222-333333333-1234' +$root=Join-Path $env:RUNNER_TEMP ('wela-wec-update-'+$nonce);$null=New-Item -ItemType Directory $root +$created=$false;$beforeIds=$null;$primary=$null +try { + if($beforeService.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Manual} + if($beforeService.State -eq 'Stopped'){Start-Service Wecsvc} + $beforeIds=@(Subscriptions);if($beforeIds -contains $id){throw 'Unique ID already exists.'} + $query='' + $xml=@" +$idSourceInitiated$descriptionfalsehttp://schemas.microsoft.com/wbem/wsman/1/windows/EventLogNormalfalseHTTPEventsForwardedEvents$(Get-WelaWefAuthorization @($sid)) +"@ + $xmlPath=Join-Path $root 'owned.xml';[IO.File]::WriteAllText($xmlPath,$xml);$created=$true;$null=Invoke-WelaNative 'wecutil.exe' @('cs',$xmlPath) + $before=Read-WelaWecUpdateDefinition $id @($sid);$duringService=ServiceState + $queryPath=Join-Path $root 'query.xml';[IO.File]::WriteAllText($queryPath,$query.Replace('EventID=1','EventID=2')) + $plan=Invoke-WelaWecUpdate Plan -Id $id -SourceSids @($sid) -QueryPath $queryPath -Description $changedDescription -OutputPath (Join-Path $root 'plan') + Assert ($plan.ExitCode -eq 0) "Native plan: $($plan.Diagnostic)" + Assert ((Read-WelaWecUpdateDefinition $id @($sid)).WholeKey -ceq $before.WholeKey) 'Planning is read only' + $planPath=Join-Path $plan.OutputPath 'plan.json' + $apply=Invoke-WelaWecUpdate Apply -PlanPath $planPath -PlanHash $plan.PlanHash -OutputPath (Join-Path $root 'apply') + if($apply.ExitCode){throw ($apply|ConvertTo-Json -Depth 24)} + Assert ($apply.Status -eq 'UpdatedAndVerified' -and $apply.NativeSaveAttempted) 'Native existing-only save succeeded' + $after=Read-WelaWecUpdateDefinition $id @($sid) + Assert ($after.PreservedKey -ceq $before.PreservedKey -and $after.Description -ceq $changedDescription -and $after.QueryKey -ceq (ConvertFrom-WelaWefQuery ([IO.File]::ReadAllText($queryPath))).Key) 'Only query/description changed; complete remaining XML preserved' + Assert ($apply.ReadyRuleCredit -eq 0) 'No delivery/Sigma credit' + $stale=Invoke-WelaWecUpdate Apply -PlanPath $planPath -PlanHash $plan.PlanHash -OutputPath (Join-Path $root 'stale') + Assert ($stale.ExitCode -eq 1 -and -not $stale.NativeSaveAttempted -and $stale.Diagnostic -match 'differs') 'Stale actual definition refuses another native save' + Assert ((Read-WelaWecUpdateDefinition $id @($sid)).WholeKey -ceq $after.WholeKey) 'Stale refusal preserves native definition' + $samePlan=Invoke-WelaWecUpdate Plan -Id $id -SourceSids @($sid) -QueryPath $queryPath -Description $changedDescription -OutputPath (Join-Path $root 'same-plan') + $same=Invoke-WelaWecUpdate Apply -PlanPath (Join-Path $samePlan.OutputPath 'plan.json') -PlanHash $samePlan.PlanHash -OutputPath (Join-Path $root 'same') + Assert ($same.ExitCode -eq 0 -and $same.Status -eq 'AlreadyMatches' -and -not $same.NativeSaveAttempted) 'Native idempotence does not save' + [IO.File]::WriteAllText($queryPath,$before.QueryXml) + $restorePlan=Invoke-WelaWecUpdate Plan -Id $id -SourceSids @($sid) -QueryPath $queryPath -Description $description -OutputPath (Join-Path $root 'restore-plan') + $restore=Invoke-WelaWecUpdate Apply -PlanPath (Join-Path $restorePlan.OutputPath 'plan.json') -PlanHash $restorePlan.PlanHash -OutputPath (Join-Path $root 'restore') + Assert ($restore.ExitCode -eq 0 -and (Read-WelaWecUpdateDefinition $id @($sid)).WholeKey -ceq $before.WholeKey) "Original query/description restored: $($restore.Diagnostic)" + Assert ((Key (ServiceState)) -ceq (Key $duringService)) 'Production updater did not change Wecsvc' + Write-Host "Native WEC update passed $count assertions on $([Environment]::OSVersion.Version), PowerShell $($PSVersionTable.PSVersion). Disabled subscription only; no delivery/bookmark claim." +}catch{$primary=$_} +finally { + $errors=@() + try { + if($created -and @(Subscriptions) -contains $id){$raw=Read-WelaWecSubscriptionXml $id;$doc=Read-WelaWefXml $raw;$ns=New-Object Xml.XmlNamespaceManager($doc.NameTable);$ns.AddNamespace('s',$doc.DocumentElement.NamespaceURI);$observed=$doc.SelectSingleNode('/s:Subscription/s:Description',$ns).InnerText;if($observed -cnotin @($description,$changedDescription)){throw 'Fixture ownership changed; refusing deletion.'};$null=Invoke-WelaNative 'wecutil.exe' @('ds',$id)} + if($null -ne $beforeIds -and (Key @($beforeIds|Sort-Object)) -cne (Key @(Subscriptions|Sort-Object))){throw 'Subscription inventory differs after cleanup.'} + }catch{$errors+=$_.Exception.Message} + try { + if($beforeService.State -eq 'Stopped' -and (Get-Service Wecsvc).Status -ne 'Stopped'){Stop-Service Wecsvc} + if($beforeService.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Disabled} + if((Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){if($beforeDelayed.ValueExists){$null=New-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -Value $beforeDelayed.Value -PropertyType $beforeDelayed.Type -Force}else{Remove-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -ErrorAction Stop}} + if((Key (ServiceState)) -cne (Key $beforeService) -or (Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){throw 'Original Wecsvc state/startup differs.'} + }catch{$errors+=$_.Exception.Message} + if($errors.Count){throw "Fixture cleanup failed; retained $root : $($errors -join '; '); primary failure: $primary"} + Write-Host 'Original subscription inventory and Wecsvc state/startup restored.' +} +if($primary){throw $primary} +$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index faff024b..dee1d640 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,7 @@ **改善:** +- 既に無効なネイティブ購読のクエリと説明だけを変更する `wec-update` を追加。定義・実ホスト・コードの指紋、レビュー済み計画のハッシュ、永続レシート、直前確認と変更後の読み戻しにより、再作成や有効化をせずに変更を検証します。使い捨て Windows テストは実更新・復元と古い計画の拒否を確認します。稼働中ソースのブックマーク・配送・Sigma 準備状態は未検証です。 (#426) (@Shirofune-Security) - 任意実行の`dns-analytical`を追加し、DNS Serverの分析ログを監査・計画・明示選択で設定できるようにしました。トレース再設定への個別同意、永続的な変更前記録、容量を制限したネイティブETLの退避とハッシュ検証に対応し、ACL・パス・既存の大きいバッファを保持します。退避失敗で停止した状態を失敗として報告します。使い捨てDNS環境のループバックイベント257と設定復元のテストを追加し、転送・Sigmaの利用可能性は未検証のままです。 (#425) (@Shirofune-Security) - 読み取り専用の`wec-runtime`を追加し、WEC標準APIの稼働状態、数値エラー、UTC時刻と件数を制限した送信元別の観測結果を取得します。実行者・ホスト・定義の変更、不明な値と上限到達を明示し、既存のWEF・保存状態レポートでは元の文字列も保持します。過去の送信元一覧を現在の接続数とは扱わず、Activeからイベント到着やSigma利用可能性を推定しません。無効な使い捨てサブスクリプションで検証し、サービス状態とテスト対象を復元します。WEF・EVTXの合成テスト資料で時刻の末尾ゼロが失われる問題も修正しました。 (#424) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index e6776f76..3fd49756 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,7 @@ **Improvements:** +- Added `wec-update` to review and apply query/description changes to one already disabled native subscription through existing-only WEC handles. Complete definition/context/code fingerprints, a separately reviewed plan hash, durable receipts, fresh checks and preserved-property readback reject drift without recreation or activation. Disposable Windows tests cover actual updates/restoration and stale plans; active-source bookmarks, delivery and Sigma readiness remain unverified. (#426) (@Shirofune-Security) - Added opt-in `dns-analytical` auditing, planning and selective DNS Server channel configuration with explicit trace-reset consent, durable state records and bounded native ETL archives verified before resets. Preserve ACLs, paths and larger buffers; report stopped partial failures honestly. Added disposable standalone-DNS tests for loopback event 257 and exact configuration restoration; forwarding and Sigma readiness remain unverified. (#425) (@Shirofune-Security) - Added read-only `wec-runtime` with typed native WEC activity, numeric errors, UTC timestamps and bounded per-source observations. Actual reader/context and definition checks keep partial reads, caps and drift explicit; existing WEF/retention inventories retain raw text alongside typed fields. Historical source lists are not connection counts and Active grants no arrival or Sigma credit. Disposable disabled-subscription tests restore service state and remove only their owned fixture. Also fixed synthetic WEF/EVTX fixture timestamp roundtrips without weakening bundle validation. (#424) (@Shirofune-Security)