mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 15:25:24 +02:00
Merge pull request #455 from Shirofune-Security/feat/368-reviewed-wec-authorization
feat: add reviewed WEC source authorization updates
This commit is contained in:
16 files changed
+590
-1
No files matched your search
@@ -73,6 +73,9 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf
|
||||
# Existing-file read receipts bind identical native/worker source bytes.
|
||||
/scripts/FileAccessProbe* text eol=lf
|
||||
/tests/FileAccessProbe* text eol=lf
|
||||
|
||||
/scripts/WecAuthorization* text eol=lf
|
||||
/tests/WecAuthorization* text eol=lf
|
||||
# Reviewed channel restoration binds exact installed source bytes.
|
||||
/scripts/ChannelRecovery.ps1 text eol=lf
|
||||
/tests/ChannelRecovery*.ps1 text eol=lf
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
Copy-Item -Recurse -Path ./scripts -Destination release-binaries/
|
||||
Copy-Item -Recurse -Path ./modules -Destination release-binaries/
|
||||
New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null
|
||||
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/channel-recovery.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md -Destination release-binaries/docs/
|
||||
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-authorization.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/channel-recovery.md -Destination release-binaries/docs/
|
||||
|
||||
- name: Set Artifact Name
|
||||
if: contains(matrix.info.os, 'windows') == true
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Reviewed WEC source authorization
|
||||
on:
|
||||
push:
|
||||
paths: ['WELA.ps1', 'scripts/WecAuthorization*', 'tests/WecAuthorization*', '.github/workflows/wec-authorization.yml']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
wec-authorization:
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-2022, windows-2025]
|
||||
engine: [powershell, pwsh]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
- name: Portable guards in Windows PowerShell 5.1
|
||||
if: matrix.engine == 'powershell'
|
||||
shell: powershell
|
||||
run: |
|
||||
./tests/WecAuthorization.Tests.ps1
|
||||
./tests/WecAuthorization.Cli.Tests.ps1
|
||||
./tests/WecSubscriptionXml.Tests.ps1
|
||||
- name: Actual owned source authorization updates in Windows PowerShell 5.1
|
||||
if: matrix.engine == 'powershell'
|
||||
shell: powershell
|
||||
run: ./tests/WecAuthorization.Windows.Tests.ps1 -AllowDisposableSubscription
|
||||
- name: Portable guards in PowerShell 7
|
||||
if: matrix.engine == 'pwsh'
|
||||
shell: pwsh
|
||||
run: |
|
||||
./tests/WecAuthorization.Tests.ps1
|
||||
./tests/WecAuthorization.Cli.Tests.ps1
|
||||
./tests/WecSubscriptionXml.Tests.ps1
|
||||
- name: Actual owned source authorization updates in PowerShell 7
|
||||
if: matrix.engine == 'pwsh'
|
||||
shell: pwsh
|
||||
run: ./tests/WecAuthorization.Windows.Tests.ps1 -AllowDisposableSubscription
|
||||
- name: Retain native authorization evidence and cleanup receipt
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: wec-authorization-${{ matrix.os }}-${{ matrix.engine }}
|
||||
path: ${{ runner.temp }}/wela-wec-authorization-*
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- 既存の無効なネイティブサブスクリプション1件の明示的なソースSID一覧を変更する、オプトインの `wec-authorization` Plan/Apply を追加しました。レビュー済みハッシュ、ホスト・トークン・実装・定義全体の照合、永続化した変更前証跡、認可プロパティのみのネイティブ更新と読み戻しにより他の設定を保持し、変更不要と部分失敗を区別します。使い捨てWindowsテストで追加・削除・復元・拒否・後処理を検証しますが、SID解決、ドメイン認証、転送、Sigmaの有効性は主張しません。(@Shirofune-Security)
|
||||
|
||||
- カスタム監査プロファイルの公開 Plan、DryRun、Configure、任意項目、再実行、Audit を Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で検証します。実際の優先設定、厳密値・最小値・維持の動作、変更前ジャーナル、全59監査マスクと復元を確認します。 (@Shirofune-Security)
|
||||
- `wec-listener` の Plan/Apply を追加し、割り当て済みIPv4に限定した新規HTTP5985リスナーを作成できるようにしました。ホスト・実行ユーザー・ソース・WinRMとファイアウォールの状態、計画ハッシュ、実行前記録とネイティブ再読取で変更を検証します。両PowerShellホストから固定のWindows PowerShell 5.1ワーカーを使用し、既存リスナーや状態変化を検出した場合は拒否します。転送到着やSigma対応は別途検証が必要です。 (@Shirofune-Security)
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added opt-in `wec-authorization` Plan/Apply for the explicit source SID list of one existing disabled native subscription. Reviewed hashes, host/token/source and full-definition guards, durable pending evidence, one native authorization setter and readback preserve other settings; no-op and partial-save outcomes stay explicit. Native disposable tests cover add/remove/restore, refusals and cleanup without SID-resolution, domain authentication, forwarding or Sigma claims. (@Shirofune-Security)
|
||||
|
||||
- Add disposable native acceptance for public custom-profile Plan, DryRun, Configure, optional controls, idempotence and Audit on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Verify exact/minimum/preserve semantics, real precedence, original journals and all 59 masks, with exact fixture restoration. (@Shirofune-Security)
|
||||
- Added opt-in `wec-listener` Plan/Apply for one new assigned-IPv4 HTTP5985 listener. Reviewed host/operator/source and WinRM/firewall snapshots, explicit plan hashes, pending evidence and native readback guard creation and preserve existing settings. A fixed native Windows PowerShell 5.1 worker provides the creation adapter under both PowerShell host versions. Existing listeners and drift require review; forwarding arrival and Sigma readiness are not inferred. (@Shirofune-Security)
|
||||
|
||||
|
||||
@@ -178,6 +178,12 @@
|
||||
[string]$WecUpdatePlanPath,
|
||||
[string]$WecUpdatePlanHash,
|
||||
[string]$WecUpdateOutputPath,
|
||||
[ValidateSet('Plan','Apply')][string]$WecAuthorizationAction = 'Plan',
|
||||
[string]$WecAuthorizationId,
|
||||
[string[]]$WecAuthorizationSourceSid,
|
||||
[string]$WecAuthorizationPlanPath,
|
||||
[string]$WecAuthorizationPlanHash,
|
||||
[string]$WecAuthorizationOutputPath,
|
||||
[ValidateSet('Plan','Apply')][string]$WecStateAction = 'Plan',
|
||||
[string]$WecStateId,
|
||||
[string[]]$WecStateSourceSid,
|
||||
@@ -261,6 +267,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi
|
||||
. (Join-Path $ScriptRoot "scripts/WecIngress.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/WecListener.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/WecState.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/WecAuthorization.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/RetentionHealth.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/AuditScoring.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/TargetedSaclPlanning.ps1")
|
||||
@@ -2057,6 +2064,7 @@ Usage:
|
||||
./WELA.ps1 eventlog-recovery -Help # Review restoration of one completed log size/mode write
|
||||
./WELA.ps1 wec-listener -Help # Review one fixed-address native HTTP5985 listener
|
||||
./WELA.ps1 wec-ingress -Help # Review scoped collector firewall rule creation
|
||||
./WELA.ps1 wec-authorization -Help # Review source SID authorization on a disabled subscription
|
||||
./WELA.ps1 wec-state -Help # Review enable/disable of one existing subscription
|
||||
./WELA.ps1 wec-update -Help # Review query/description updates on a disabled subscription
|
||||
./WELA.ps1 capi2-probe -Help # Fixed offline chain and matched CAPI2 event 11 evidence
|
||||
@@ -2153,6 +2161,8 @@ if ($Cmd -ne 'wec-listener' -and @($PSBoundParameters.Keys | Where-Object {$_ -l
|
||||
if ($Cmd -eq 'wec-listener' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecListenerAction','WecListenerComputerName','WecListenerLocalAddress','WecListenerPlanPath','WecListenerPlanHash','WecListenerOutputPath','Help')}).Count)) {throw 'wec-listener accepts only dedicated options.'}
|
||||
if ($Cmd -ne 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecIngress*'}).Count) {throw 'WecIngress options require wec-ingress.'}
|
||||
if ($Cmd -eq 'wec-ingress' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecIngressAction','WecIngressName','WecIngressLocalAddress','WecIngressRemoteAddress','WecIngressPlanPath','WecIngressPlanHash','WecIngressOutputPath','Help')}).Count) {throw 'wec-ingress accepts only dedicated options.'}
|
||||
if ($Cmd -ne 'wec-authorization' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecAuthorization*'}).Count) {throw 'WecAuthorization options require wec-authorization.'}
|
||||
if ($Cmd -eq 'wec-authorization' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecAuthorizationAction','WecAuthorizationId','WecAuthorizationSourceSid','WecAuthorizationPlanPath','WecAuthorizationPlanHash','WecAuthorizationOutputPath','Help')}).Count)) {throw 'wec-authorization accepts only dedicated options.'}
|
||||
if ($Cmd -ne 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecState*'}).Count) {throw 'WecState options require wec-state.'}
|
||||
if ($Cmd -eq 'wec-state' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WecStateAction','WecStateId','WecStateSourceSid','WecStateDesired','WecStatePlanPath','WecStatePlanHash','WecStateOutputPath','Help')}).Count) {throw 'wec-state accepts only dedicated options.'}
|
||||
if ($Cmd -ne 'wec-update' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'WecUpdate*'}).Count) {throw 'WecUpdate options require wec-update.'}
|
||||
@@ -2418,6 +2428,13 @@ switch ($Cmd.ToLower()) {
|
||||
$report=Invoke-WelaWecIngress @arguments;$report
|
||||
if($report.ExitCode){exit $report.ExitCode}
|
||||
}
|
||||
'wec-authorization' {
|
||||
if ($Help) {Write-Host 'Usage: wec-authorization [-WecAuthorizationAction Plan] -WecAuthorizationId ID -WecAuthorizationSourceSid desired-SID1,desired-SID2 -WecAuthorizationOutputPath new-directory; then Apply with -WecAuthorizationPlanPath plan.json -WecAuthorizationPlanHash SHA256 -WecAuthorizationOutputPath new-directory. Only the explicit source SID authorization of one already disabled subscription. No SID resolution or forwarding proof. See docs/wec-authorization.md.';return}
|
||||
$arguments=@{Action=$WecAuthorizationAction;OutputPath=$WecAuthorizationOutputPath}
|
||||
foreach($pair in @(@('WecAuthorizationId','Id'),@('WecAuthorizationSourceSid','SourceSids'),@('WecAuthorizationPlanPath','PlanPath'),@('WecAuthorizationPlanHash','PlanHash'))){if($PSBoundParameters.ContainsKey($pair[0])){$arguments[$pair[1]]=$PSBoundParameters[$pair[0]]}}
|
||||
$report=Invoke-WelaWecAuthorization @arguments;$report
|
||||
if($report.ExitCode -ne 0){exit $report.ExitCode}
|
||||
}
|
||||
'wec-state' {
|
||||
if ($Help) {Write-Host 'Usage: wec-state [-WecStateAction Plan] -WecStateId ID -WecStateSourceSid SID -WecStateDesired Enabled|Disabled -WecStateOutputPath new-directory; then Apply with -WecStatePlanPath reviewed-plan.json -WecStatePlanHash SHA256 -WecStateOutputPath new-directory. Only Enabled on an existing subscription. Disable interrupts collection; enable/save activates it. See docs/wec-state.md.';return}
|
||||
$arguments=@{Action=$WecStateAction;OutputPath=$WecStateOutputPath}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Reviewed WEC source authorization
|
||||
|
||||
`wec-authorization` plans and applies the explicit source SID allow list of one **already disabled**, existing source-initiated HTTP/native-event subscription. This supplies the authorization update missing from the create-only collector command, query/description updater and separate Enabled transition. Related to #368; built-in Windows only, with no Sysmon.
|
||||
|
||||
```powershell
|
||||
# Invoke the PowerShell script directly when supplying an array of SIDs.
|
||||
.\WELA.ps1 wec-authorization -WecAuthorizationId 'Reviewed subscription' `
|
||||
-WecAuthorizationSourceSid 'S-1-5-21-111-222-333-1234','S-1-5-21-111-222-333-1235' `
|
||||
-WecAuthorizationOutputPath C:\Evidence\authorization-plan
|
||||
|
||||
# Review plan.json, including its complete original XML and desired source list.
|
||||
# Retain its PlanHash from manifest.json before applying those exact bytes.
|
||||
.\WELA.ps1 wec-authorization -WecAuthorizationAction Apply `
|
||||
-WecAuthorizationPlanPath C:\Evidence\authorization-plan\plan.json `
|
||||
-WecAuthorizationPlanHash '<reviewed SHA256>' `
|
||||
-WecAuthorizationOutputPath C:\Evidence\authorization-apply
|
||||
```
|
||||
|
||||
Plan reads native configuration and writes review artifacts. Apply takes the subscription and desired list only from the reviewed plan. Both require a new private evidence directory on a local fixed drive. The actual elevated, non-impersonated reader, supported patched Server 2022/2025 standalone/member host, running Wecsvc/WMI/EventLog services, destination channel settings and implementation sources are observed and bound. No service is started, subscription enabled, channel changed or AD membership modified. Unknown options, mixed Plan/Apply inputs, `-Auto`, `-DryRun` and `-WhatIf` are refused; use Plan for review.
|
||||
|
||||
The desired list contains 1–32 unique canonical `S-1-5-21-A-B-C-RID` strings with native-range subauthorities. Order is normalized; duplicate SIDs, aliases, arbitrary SDDL, null/empty/default authorization and non-domain/certificate settings are refused. The existing descriptor must already be the same supported explicit allow-list form. WELA neither resolves these strings nor verifies that they identify domain computer accounts or groups. Obtain and independently verify intended identities and group membership before review. Adding a SID can broaden future authorization; removing one entry does not establish that a machine lacks access through another allowed group.
|
||||
|
||||
Apply checks the complete original definition and current context, flushes a pending receipt, opens only an existing native subscription and uses one `EcSubscriptionAllowedSourceDomainComputers` setter followed by save. The native adapter rechecks disabled/source-initiated state and selected native fields through a fresh handle. Native readback must match the desired list while all other observed XML fields, actual token, services and destination settings stay unchanged. Matching authorization returns `AlreadyMatches` without a save. Successful changes report `AuthorizationChangedAndVerified`; failures before save report `Refused`. Once save has been attempted, incomplete readback or preservation reports `SaveAttemptedUnverified` and retains available native after-XML.
|
||||
|
||||
An enabled subscription is refused, including a no-op request. Use the separately reviewed [Enabled transition](wec-state.md) when an intentional interruption or activation is required. To restore an authorization list, make a fresh plan against the current disabled definition using the original retained SIDs. There is no automatic rollback or native compare-and-swap: another administrator can race the pre-save observations. Coordinate changes and inspect retained evidence after partial results. Plan hashes check consistency and do not authenticate an untrusted evidence author.
|
||||
|
||||
The native CI fixture creates one uniquely named disabled subscription with inert SIDs, exercises public no-op/add/remove/restore, wrong-hash/stale/enabled refusals and a native fresh-handle drift check, then verifies original subscription inventory, service startup/state and complete channel restoration. Temporary service/channel changes belong only to explicitly opted-in disposable fixtures. These tests do not resolve or authenticate a source, change AD groups, verify forwarding or bookmarks, or award Sigma readiness credit.
|
||||
|
||||
Microsoft documents the [authorization property](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/ne-evcoll-ec_subscription_property_id), [source-initiated subscription settings](https://learn.microsoft.com/en-us/windows/win32/wec/creating-a-source-initiated-subscription), [existing-only open flags](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecopensubscription) and [activation on saving enabled subscriptions](https://learn.microsoft.com/en-us/windows/win32/api/evcoll/nf-evcoll-ecsavesubscription).
|
||||
@@ -58,6 +58,8 @@ Readback equality covers ID, enabled state, selected delivery preset, ReadExisti
|
||||
|
||||
JSON retains exact filters, disabled flags, local channel enablement/mode/ACL, local configuration results, native `wecutil gr` output and its errors, and unverified prerequisites. A separate [`TypedRuntime`](wec-runtime.md) object adds native activity/error/time fields and bounded per-source observations; its Unknown/Partial status stays independent of local configuration success. On collectors, local channel metadata is explicitly labeled **collector only**; it does not describe remote source states. Localized runtime text is preserved as evidence without inferring connected-source counts or arrival success. `LocalConfigurationStatus: RequestedSettingsMatch` describes the selected local settings only. A non-dry-run with unmet prerequisites, failed writes or mismatched final settings exits nonzero and is incomplete.
|
||||
|
||||
Use the separate [reviewed authorization update](wec-authorization.md) to change an explicit source SID list on an already disabled existing subscription. It preserves the other observed fields and supplies no SID-resolution or forwarding proof.
|
||||
|
||||
## Recovery and lab acceptance
|
||||
|
||||
`before.jsonl` is written before each mutation. Review its exact Target/Before/Desired and the results before recovery. For a newly created subscription, it records absence and stores the prepared XML; remove that exact ID only after verifying its current definition still belongs to this run. Existing subscriptions are never edited. For the new SubscriptionManager value, compare the current value with Desired before removing only that value; keep other list entries and parent keys. Restore WSMan values and service start/running states only after verifying their present state and current policy authority. Remove only the newly added group SID after comparing the full membership snapshot; DC membership is never changed by this workflow. For channel restoration, use the channel journal and descriptor-preservation guidance. Recovery is deliberately manual so a newer operator/GPO change is not overwritten.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Reviewed authorization only; an enabled subscription is never edited or paused.
|
||||
function Get-WelaWecAuthorizationKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
|
||||
function Get-WelaWecAuthorizationSids {
|
||||
param([object[]]$SourceSids)
|
||||
if($SourceSids.Count -lt 1 -or $SourceSids.Count -gt 32){throw 'Select 1 to 32 explicit domain-format source SIDs.'}
|
||||
$seen=@{};$result=@()
|
||||
foreach($sid in $SourceSids){
|
||||
if($sid -isnot [string] -or $sid -cnotmatch '^S-1-5-21-(0|[1-9][0-9]{0,9})-(0|[1-9][0-9]{0,9})-(0|[1-9][0-9]{0,9})-(0|[1-9][0-9]{0,9})$'){throw 'Source SIDs must be canonical explicit domain-format strings.'}
|
||||
foreach($part in @($sid.Split('-')|Select-Object -Skip 4)){$value=[uint32]0;if(-not [uint32]::TryParse($part,[ref]$value)){throw 'Source SID subauthority exceeds the native range.'}}
|
||||
if($seen.ContainsKey($sid)){throw 'Duplicate source SID.'};$seen[$sid]=$true;$result+=$sid
|
||||
}
|
||||
[string[]]$ordered=$result;[Array]::Sort($ordered,[StringComparer]::Ordinal);$ordered
|
||||
}
|
||||
function Get-WelaWecAuthorizationDefinition {
|
||||
param([string]$Xml)
|
||||
if(-not $Xml -or $Xml.Length -gt 1048576){throw 'Native subscription XML is absent or oversized.'}
|
||||
$doc=Read-WelaWefXml $Xml;$root=$doc.DocumentElement;$ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('s','http://schemas.microsoft.com/2006/03/windows/events/subscription')
|
||||
$nodes=@($root.SelectNodes('s:AllowedSourceDomainComputers',$ns));if($nodes.Count -ne 1){throw 'One explicit source authorization is required.'}
|
||||
$authorization=[string]$nodes[0].InnerText
|
||||
if($authorization.Length -gt 4096 -or $authorization -cnotmatch '^O:NSG:NSD:(?:\(A;;GA;;;S-1-5-21-[0-9]+-[0-9]+-[0-9]+-[0-9]+\)){1,32}$'){throw 'Only the explicit standard domain-source allow list is supported; no arbitrary/default SDDL.'}
|
||||
$sids=@(Get-WelaWecAuthorizationSids @([regex]::Matches($authorization,'S-1-5-21-[0-9]+-[0-9]+-[0-9]+-[0-9]+')|ForEach-Object Value))
|
||||
if((Get-WelaWefAuthorization $sids) -cne $authorization){throw 'Observed authorization is not the canonical explicit SID list.'}
|
||||
$model=ConvertFrom-WelaWefSubscription -Xml $Xml -SourceSids $sids -Observed
|
||||
if($model.Definition.Enabled -ne $false){throw 'Only an already disabled source-initiated subscription can change authorization.'}
|
||||
$whole=Get-WelaWefXmlKey $root;$null=$root.RemoveChild($nodes[0])
|
||||
[pscustomobject][ordered]@{Id=$model.Id;Xml=$Xml;SourceSids=$sids;Authorization=$authorization;WholeKey=$whole;PreservedKey=(Get-WelaWefXmlKey $root);QueryKey=$model.Query.Key;Description=$model.Definition.Description}
|
||||
}
|
||||
function Read-WelaWecAuthorizationDefinition {
|
||||
param([string]$Id)
|
||||
if($Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$'){throw 'Select one exact subscription ID.'}
|
||||
$definition=Get-WelaWecAuthorizationDefinition (Read-WelaWecSubscriptionXml $Id)
|
||||
if($definition.Id -cne $Id){throw 'Native subscription identity differs.'};$definition
|
||||
}
|
||||
function Get-WelaWecAuthorizationContext {
|
||||
$reader=Get-WelaChannelReader
|
||||
if(-not $reader.ElevatedAdministrator){throw 'Actual non-impersonated elevated administrator required.'}
|
||||
$required=@(Get-Service -Name Wecsvc,Winmgmt,EventLog -ErrorAction Stop)
|
||||
if($required.Count -ne 3 -or @($required|Where-Object Status -ne Running).Count){throw 'Wecsvc, Winmgmt and EventLog must already be running; no services are started.'}
|
||||
$hostState=Get-WelaChannelReadHost
|
||||
if($hostState.ProductType -ne 3 -or $hostState.DomainRole -notin @(2,3) -or $hostState.Build -notin @(20348,26100) -or $null -eq $hostState.UBR -or $hostState.UBR -lt 1){throw 'Observed patched Server 2022/2025 member or standalone collector required.'}
|
||||
$services=@(Get-CimInstance Win32_Service -Filter "Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog'" -ErrorAction Stop|Sort-Object Name|Select-Object Name,State,StartMode)
|
||||
if($services.Count -ne 3 -or @($services|Where-Object {$_.State -ne 'Running' -or $_.StartMode -notin @('Auto','Manual')}).Count){throw 'Stable running service observations required.'}
|
||||
$log=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents')
|
||||
try{$channel=[pscustomobject][ordered]@{Name=$log.LogName;Enabled=$log.IsEnabled;Mode=[string]$log.LogMode;MaximumBytes=$log.MaximumSizeInBytes;Path=$log.LogFilePath;SecurityDescriptor=$log.SecurityDescriptor}}finally{$log.Dispose()}
|
||||
if((Get-WelaWecAuthorizationKey (Get-WelaChannelReader)) -cne (Get-WelaWecAuthorizationKey $reader)){throw 'Actual token changed during observations.'}
|
||||
[pscustomobject][ordered]@{Host=$hostState;Reader=$reader;Services=$services;Destination=$channel}
|
||||
}
|
||||
function Get-WelaWecAuthorizationReviewKey {
|
||||
param($Context)
|
||||
$copy=Get-WelaWecAuthorizationKey $Context|ConvertFrom-Json
|
||||
$copy.Reader.ProcessId=$null;$copy.Reader.TokenId=$null;$copy.Reader.ModifiedId=$null
|
||||
Get-WelaWecAuthorizationKey $copy
|
||||
}
|
||||
function Get-WelaWecAuthorizationSources {
|
||||
$sources=[ordered]@{}
|
||||
foreach($path in @('WELA.ps1','scripts/WecAuthorization.ps1','scripts/WecAuthorizationNative.cs','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/WecUpdate.ps1','modules/WefSubscriptions.psm1','modules/WecSubscriptionXml.cs','modules/AuditProfiles.psm1','scripts/CustomAuditProfiles.ps1')){$sources[$path]=(Get-FileHash -LiteralPath (Join-Path $script:ScriptRoot $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()}
|
||||
Get-WelaWecAuthorizationKey $sources
|
||||
}
|
||||
function Initialize-WelaWecAuthorizationNative {
|
||||
$bytes=[IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'WecAuthorizationNative.cs'));if($bytes.Length -gt 65536){throw 'Native authorization source exceeds bound.'}
|
||||
$hash=Get-WelaArrivalHash $bytes
|
||||
if(-not ('Wela.WecAuthorization.Edit' -as [type])){
|
||||
$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
|
||||
if([regex]::Matches($source,'__WELA_SOURCE_SHA256__').Count -ne 1){throw 'Native source binding marker is missing or ambiguous.'}
|
||||
Add-Type -TypeDefinition $source.Replace('__WELA_SOURCE_SHA256__',$hash) -ErrorAction Stop
|
||||
}
|
||||
if([Wela.WecAuthorization.Edit]::SourceSha256 -cne $hash){throw 'Loaded authorization setter differs from source; start a fresh process.'}
|
||||
}
|
||||
function New-WelaWecAuthorizationEdit {
|
||||
param($Before)
|
||||
Initialize-WelaWecAuthorizationNative;$edit=[Wela.WecAuthorization.Edit]::new($Before.Id)
|
||||
try{if($edit.OriginalAuthorization -cne $Before.Authorization -or $edit.OriginalDescription -cne $Before.Description -or (ConvertFrom-WelaWefQuery $edit.OriginalQuery).Key -cne $Before.QueryKey){throw 'Native handle differs from reviewed subscription.'};$edit}catch{$edit.Dispose();throw}
|
||||
}
|
||||
function Assert-WelaWecAuthorizationPlan {
|
||||
param($Plan)
|
||||
Assert-WelaArrivalObject $Plan @('SchemaVersion','Kind','Id','DesiredSourceSids','ContextKey','Sources','BeforeXml','RecordedUtc')
|
||||
if(($Plan.SchemaVersion -isnot [int] -and $Plan.SchemaVersion -isnot [long]) -or $Plan.SchemaVersion -ne 1 -or $Plan.Kind -isnot [string] -or $Plan.Kind -cne 'WelaWecAuthorizationPlan' -or $Plan.Id -isnot [string] -or $Plan.Id -cnotmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$' -or $Plan.DesiredSourceSids -isnot [array] -or $Plan.ContextKey -isnot [string] -or -not $Plan.ContextKey -or $Plan.Sources -isnot [string] -or -not $Plan.Sources -or $Plan.BeforeXml -isnot [string]){throw 'Unknown or mistyped authorization plan.'}
|
||||
$desired=@(Get-WelaWecAuthorizationSids $Plan.DesiredSourceSids)
|
||||
if((Get-WelaWecAuthorizationKey $desired) -cne (Get-WelaWecAuthorizationKey $Plan.DesiredSourceSids)){throw 'Desired SID list is not canonical.'}
|
||||
$null=ConvertTo-WelaArrivalUtc $Plan.RecordedUtc
|
||||
$before=Get-WelaWecAuthorizationDefinition $Plan.BeforeXml;if($before.Id -cne $Plan.Id){throw 'Plan identity contradicts original subscription.'}
|
||||
}
|
||||
function Assert-WelaWecAuthorizationArtifacts {
|
||||
param([string]$Output,$Artifacts)
|
||||
foreach($a in $Artifacts){if((Get-FileHash -LiteralPath (Join-Path $Output $a.Name) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $a.Sha256){throw 'Retained authorization evidence changed.'}}
|
||||
}
|
||||
function Invoke-WelaWecAuthorization {
|
||||
param([ValidateSet('Plan','Apply')][string]$Action='Plan',[string]$Id,[object[]]$SourceSids,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($args.Count){throw 'Unknown authorization arguments are not supported.'}
|
||||
if($Action -eq 'Plan'){
|
||||
if(-not $Id -or -not $SourceSids -or -not $OutputPath -or $PSBoundParameters.ContainsKey('PlanPath') -or $PSBoundParameters.ContainsKey('PlanHash')){throw 'Plan requires exact ID, desired source SIDs and new output only.'}
|
||||
$desired=@(Get-WelaWecAuthorizationSids $SourceSids);$reviewed=$null;$source=Join-Path $script:ScriptRoot 'scripts'
|
||||
}else{
|
||||
if(-not $PlanPath -or $PlanHash -cnotmatch '^[a-fA-F0-9]{64}$' -or -not $OutputPath -or $PSBoundParameters.ContainsKey('Id') -or $PSBoundParameters.ContainsKey('SourceSids')){throw 'Apply accepts only a reviewed plan, SHA256 and new output.'}
|
||||
$PlanHash=$PlanHash.ToLowerInvariant();$reviewed=Read-WelaWecUpdateFile $PlanPath;$source=$reviewed.Path
|
||||
}
|
||||
$output=New-WelaArrivalOutput $OutputPath $source
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecAuthorization';Action=$Action;Status='Refused';ExitCode=1;OutputPath=$output;PlanHash=$null;NativeSaveAttempted=$false;NativeErrorCode=$null;BeforeSourceSids=@();DesiredSourceSids=@();After=$null;Artifacts=@();Diagnostic='';ReadyRuleCredit=0;Scope='Only the explicit source-domain SID authorization of one existing disabled native subscription. No SID resolution, AD membership, authentication, forwarding, bookmark or Sigma proof; Sysmon excluded.'}
|
||||
$edit=$null;$plan=$null
|
||||
try {
|
||||
$context=Get-WelaWecAuthorizationContext;$contextKey=Get-WelaWecAuthorizationKey $context;$sources=Get-WelaWecAuthorizationSources
|
||||
if($Action -eq 'Plan'){
|
||||
$before=Read-WelaWecAuthorizationDefinition $Id
|
||||
$plan=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaWecAuthorizationPlan';Id=$Id;DesiredSourceSids=$desired;ContextKey=(Get-WelaWecAuthorizationReviewKey $context);Sources=$sources;BeforeXml=$before.Xml;RecordedUtc=[DateTime]::UtcNow.ToString('o')}
|
||||
Assert-WelaWecAuthorizationPlan $plan
|
||||
if((Read-WelaWecAuthorizationDefinition $Id).WholeKey -cne $before.WholeKey -or (Get-WelaWecAuthorizationKey (Get-WelaWecAuthorizationContext)) -cne $contextKey -or (Get-WelaWecAuthorizationSources) -cne $sources){throw 'Context, subscription or sources changed during planning.'}
|
||||
$text=$plan|ConvertTo-Json -Depth 24;if([Text.Encoding]::UTF8.GetByteCount($text) -gt 4194304){throw 'Reviewed plan exceeds four MiB.'}
|
||||
$artifact=Write-WelaWecUpdateArtifact $output 'plan.json' $text;$report.Artifacts+=$artifact;$report.PlanHash=$artifact.Sha256;$report.Status='ReviewRequired'
|
||||
}else{
|
||||
if($reviewed.Hash -cne $PlanHash){throw 'Reviewed plan hash differs.'};$plan=ConvertFrom-WelaArrivalJson $reviewed.Text;Assert-WelaWecAuthorizationPlan $plan;$report.PlanHash=$PlanHash
|
||||
if($plan.ContextKey -cne (Get-WelaWecAuthorizationReviewKey $context) -or $plan.Sources -cne $sources){throw 'Reviewed actual host/operator/service/channel or sources differ.'}
|
||||
$before=Get-WelaWecAuthorizationDefinition $plan.BeforeXml;$desired=@($plan.DesiredSourceSids);$desiredAuthorization=Get-WelaWefAuthorization $desired
|
||||
if((Read-WelaWecAuthorizationDefinition $plan.Id).WholeKey -cne $before.WholeKey){throw 'Current subscription differs from reviewed complete definition.'}
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'reviewed-plan.json' $reviewed.Text
|
||||
if($before.Authorization -ceq $desiredAuthorization){$report.Status='AlreadyMatches'}else{
|
||||
$edit=New-WelaWecAuthorizationEdit $before
|
||||
$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'before-save.json' ([ordered]@{Status='Pending';PlanHash=$PlanHash;Context=$context;BeforeXml=$before.Xml;DesiredSourceSids=$desired;DesiredAuthorization=$desiredAuthorization;RecordedUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json -Depth 24)
|
||||
Assert-WelaWecAuthorizationArtifacts $output $report.Artifacts
|
||||
if((Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash -or (Get-WelaWecAuthorizationSources) -cne $sources -or (Get-WelaWecAuthorizationKey (Get-WelaWecAuthorizationContext)) -cne $contextKey -or (Read-WelaWecAuthorizationDefinition $plan.Id).WholeKey -cne $before.WholeKey){throw 'Plan, code, context or complete subscription changed immediately before save.'}
|
||||
try{$edit.Save($desiredAuthorization)}finally{$report.NativeSaveAttempted=[bool]$edit.SaveAttempted}
|
||||
}
|
||||
$after=Read-WelaWecAuthorizationDefinition $plan.Id;$report.After=$after;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'after.xml' $after.Xml
|
||||
if($after.Authorization -cne $desiredAuthorization -or $after.PreservedKey -cne $before.PreservedKey -or (Get-WelaWecAuthorizationKey (Get-WelaWecAuthorizationContext)) -cne $contextKey -or (Get-WelaWecAuthorizationSources) -cne $sources -or (Read-WelaWecUpdateFile $PlanPath).Hash -cne $PlanHash){throw 'Authorization readback, preserved definition, context, source or plan differs after operation.'}
|
||||
if($report.NativeSaveAttempted){$report.Status='AuthorizationChangedAndVerified'}
|
||||
}
|
||||
$report.BeforeSourceSids=@($before.SourceSids);$report.DesiredSourceSids=@($plan.DesiredSourceSids)
|
||||
Assert-WelaWecAuthorizationArtifacts $output $report.Artifacts;$report.ExitCode=0
|
||||
}catch{
|
||||
$report.Status=if($report.NativeSaveAttempted){'SaveAttemptedUnverified'}else{'Refused'};$report.ExitCode=1;$report.Diagnostic=$_.Exception.Message
|
||||
$exception=$_.Exception;while($exception){if($exception -is [ComponentModel.Win32Exception]){$report.NativeErrorCode=$exception.NativeErrorCode;break};$exception=$exception.InnerException}
|
||||
if($report.NativeSaveAttempted -and $plan){try{$xml=Read-WelaWecSubscriptionXml $plan.Id;$report.Artifacts+=Write-WelaWecUpdateArtifact $output 'failed-after.xml' $xml}catch{$report.Diagnostic+=' Final native definition unavailable: '+$_.Exception.Message}}
|
||||
}finally{if($edit){try{$edit.Dispose()}catch{$report.ExitCode=1;$report.Status=if($report.NativeSaveAttempted){'SaveAttemptedUnverified'}else{'Refused'};$report.Diagnostic+=' Native handle cleanup failed: '+$_.Exception.Message}}}
|
||||
$null=Write-WelaWecUpdateArtifact $output 'manifest.json' ($report|ConvertTo-Json -Depth 24);$report
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Existing-only native WEC authorization setter. No creation, deletion, activation or other setters.
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
namespace Wela.WecAuthorization {
|
||||
public sealed class Edit : IDisposable {
|
||||
public const string SourceSha256="__WELA_SOURCE_SHA256__";
|
||||
[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,oldAuthorization;
|
||||
public string OriginalQuery {get{return oldQuery;}}
|
||||
public string OriginalDescription {get{return oldDescription;}}
|
||||
public string OriginalAuthorization {get{return oldAuthorization;}}
|
||||
public bool SaveAttempted {get;private set;}
|
||||
public static void ValidateAuthorization(string value) {
|
||||
if(String.IsNullOrEmpty(value)||value.Length>4096||!value.StartsWith("O:NSG:NSD:",StringComparison.Ordinal))throw new ArgumentException("Explicit canonical domain-source authorization required.");
|
||||
var matches=System.Text.RegularExpressions.Regex.Matches(value,@"\(A;;GA;;;(S-1-5-21-[0-9]+-[0-9]+-[0-9]+-[0-9]+)\)");
|
||||
if(matches.Count<1||matches.Count>32)throw new ArgumentException("Select 1 to 32 source SIDs.");
|
||||
var expected=new StringBuilder("O:NSG:NSD:");string prior=null;
|
||||
foreach(System.Text.RegularExpressions.Match match in matches) {
|
||||
string sid=match.Groups[1].Value;string[] parts=sid.Split('-');
|
||||
for(int i=4;i<parts.Length;i++){uint number;if(!UInt32.TryParse(parts[i],System.Globalization.NumberStyles.None,System.Globalization.CultureInfo.InvariantCulture,out number)||number.ToString(System.Globalization.CultureInfo.InvariantCulture)!=parts[i])throw new ArgumentException("Noncanonical SID subauthority.");}
|
||||
if(prior!=null&&StringComparer.Ordinal.Compare(prior,sid)>=0)throw new ArgumentException("SIDs must be unique and sorted.");prior=sid;expected.Append(match.Value);
|
||||
}
|
||||
if(!String.Equals(expected.ToString(),value,StringComparison.Ordinal))throw new ArgumentException("Unsupported authorization descriptor.");
|
||||
}
|
||||
// Public only for allocated-buffer ABI/type regression tests; performs no native calls.
|
||||
public static object Decode(IntPtr buffer,uint size,int property) {
|
||||
if(buffer==IntPtr.Zero||size<16||size>1048576||property!=0&&property!=6&&property!=7&&property!=10&&property!=11&&property!=19&&property!=27&&property!=31)throw new InvalidOperationException("Invalid native property buffer or selection.");
|
||||
int type=Marshal.ReadInt32(buffer,12);
|
||||
if(property==27){if(type!=2)throw new InvalidOperationException("Subscription type is not UInt32.");return unchecked((uint)Marshal.ReadInt32(buffer));}
|
||||
if(property==0){if(type!=1)throw new InvalidOperationException("Enabled is not a scalar 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>size-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<=size;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.");
|
||||
}
|
||||
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.");
|
||||
return Decode(buffer,used,property);
|
||||
}finally{Marshal.FreeHGlobal(buffer);}
|
||||
}
|
||||
throw new InvalidOperationException("Native property changed repeatedly.");
|
||||
}
|
||||
void Check(IntPtr h) {
|
||||
if((bool)Read(h,0)||(uint)Read(h,27)!=0||!String.Equals((string)Read(h,7),"http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog",StringComparison.Ordinal)||!String.Equals((string)Read(h,11),"HTTP",StringComparison.Ordinal)||!String.Equals((string)Read(h,19),"ForwardedEvents",StringComparison.Ordinal)||!String.Equals((string)Read(h,10),oldQuery,StringComparison.Ordinal)||!String.Equals((string)Read(h,6),oldDescription,StringComparison.Ordinal)||!String.Equals((string)Read(h,31),oldAuthorization,StringComparison.Ordinal))throw new InvalidOperationException("Native enabled/query/description/authorization 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);oldAuthorization=(string)Read(handle,31);ValidateAuthorization(oldAuthorization);Check(handle);}catch{Dispose();throw;}
|
||||
}
|
||||
public void Save(string authorization) {
|
||||
if(handle==IntPtr.Zero)throw new ObjectDisposedException("Edit");
|
||||
if(SaveAttempted)throw new InvalidOperationException("A native edit may be saved only once.");
|
||||
ValidateAuthorization(authorization);
|
||||
if(String.Equals(authorization,oldAuthorization,StringComparison.Ordinal))throw new InvalidOperationException("Idempotent authorization must not save.");
|
||||
IntPtr fresh=EcOpenSubscription(name,1,2);if(fresh==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try{Check(fresh);}finally{EcClose(fresh);}
|
||||
IntPtr text=Marshal.StringToHGlobalUni(authorization);
|
||||
try {
|
||||
Variant value=new Variant{Text=text,Count=0,Type=4};
|
||||
if(!EcSetSubscriptionProperty(handle,31,0,ref value))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
SaveAttempted=true;
|
||||
if(!EcSaveSubscription(handle,0))throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}finally{Marshal.FreeHGlobal(text);}
|
||||
}
|
||||
public void Dispose(){if(handle!=IntPtr.Zero){EcClose(handle);handle=IntPtr.Zero;}}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path;$count=0
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-auth-cli-'+[guid]::NewGuid().ToString('N'))
|
||||
$cases=@(
|
||||
@{Args=@('wec-authorization','-Help');Code=0;Pattern='already disabled'},
|
||||
@{Args=@('configure','-WecAuthorizationAction','Apply','-Auto');Code=1;Pattern='require wec-authorization'},
|
||||
@{Args=@('wec-authorization','-Auto');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-DryRun');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-WhatIf');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-Typo');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-Help','-WecStateDesired','Enabled');Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-ResultsPath',$root);Code=1;Pattern='only dedicated'},
|
||||
@{Args=@('wec-authorization','-WecAuthorizationOutputPath',$root);Code=1;Pattern='Plan requires'},
|
||||
@{Args=@('wec-authorization','-WecAuthorizationId','test','-WecAuthorizationSourceSid','S-1-5-21-1-2-3-4','-WecAuthorizationPlanPath','missing','-WecAuthorizationOutputPath',$root);Code=1;Pattern='Plan requires'},
|
||||
@{Args=@('wec-authorization','-WecAuthorizationAction','Apply','-WecAuthorizationOutputPath',$root);Code=1;Pattern='reviewed plan'},
|
||||
@{Args=@('wec-authorization','-WecAuthorizationAction','Apply','-WecAuthorizationPlanPath','missing','-WecAuthorizationPlanHash',('a'*64),'-WecAuthorizationId','test','-WecAuthorizationOutputPath',$root);Code=1;Pattern='only'}
|
||||
)
|
||||
foreach($case in $cases){$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File "$repo/WELA.ps1" @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior};if(($code -eq 0) -ne ($case.Code -eq 0) -or ($text -join "`n") -notmatch $case.Pattern){throw "CLI failed: $($case.Args -join ' ') -> $code / $($text -join ' ')"};$count++}
|
||||
if(Test-Path $root){throw 'Refused CLI input unexpectedly created output.'}
|
||||
Write-Host "PASS: $count WEC authorization public CLI guards."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,108 @@
|
||||
$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/WecAuthorization.ps1"
|
||||
Initialize-WelaWecAuthorizationNative
|
||||
$count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Reject([scriptblock]$Action,[string]$Pattern='.'){$m='';try{&$Action|Out-Null}catch{$m=$_.Exception.Message};Assert ($m -match $Pattern) "Expected $Pattern; got $m; value=$bad; action=$Action"}
|
||||
function Copy-Auth($Value){Get-WelaWecAuthorizationKey $Value|ConvertFrom-Json}
|
||||
$sidA='S-1-5-21-11-22-33-1001';$sidB='S-1-5-21-11-22-33-1002';$id='WELA Native Security Example'
|
||||
$authA=Get-WelaWefAuthorization @($sidA);$authB=Get-WelaWefAuthorization @($sidB);$authAB=Get-WelaWefAuthorization @($sidA,$sidB)
|
||||
$base=[IO.File]::ReadAllText("$repo/config/wef-examples/native-security.xml").Replace('<Enabled>true</Enabled>','<Enabled>false</Enabled>').Replace('<AllowedSourceDomainComputers></AllowedSourceDomainComputers>',('<AllowedSourceDomainComputers>'+$authA+'</AllowedSourceDomainComputers>'))
|
||||
foreach($bad in @('S-1-1-0','S-1-5-20','s-1-5-21-11-22-33-1001','S-1-5-21-011-22-33-1001','S-1-5-21-4294967296-22-33-1001','S-1-5-21-11-22-33','S-1-5-21-11-22-33-1001 ',1,$true,$null)){Reject {Get-WelaWecAuthorizationSids @($bad)}}
|
||||
Reject {Get-WelaWecAuthorizationSids @()};Reject {Get-WelaWecAuthorizationSids @($sidA,$sidA)} 'Duplicate';Reject {Get-WelaWecAuthorizationSids @($sidA*33)}
|
||||
Assert ((Get-WelaWecAuthorizationKey @(Get-WelaWecAuthorizationSids @($sidB,$sidA))) -ceq (Get-WelaWecAuthorizationKey @($sidA,$sidB))) 'SID order is canonical'
|
||||
foreach($good in @($authA,$authAB)){[Wela.WecAuthorization.Edit]::ValidateAuthorization($good);$count++}
|
||||
foreach($bad in @('',$authA.Replace('GA','GR'),$authA.Replace('NSG:NS','SYG:SY'),($authA+$authA),$authA.Replace('11-22','011-22'),$authA.Replace('11-22','4294967296-22'),$authAB.Replace($sidB,$sidA),('O:NSG:NSD:(A;;GA;;;'+$sidB+')(A;;GA;;;'+$sidA+')'),($authA+'S:(AU;SA;GA;;;WD)'))){Reject {[Wela.WecAuthorization.Edit]::ValidateAuthorization($bad)}}
|
||||
Assert ([Wela.WecAuthorization.Edit]::SourceSha256 -ceq (Get-WelaArrivalHash ([IO.File]::ReadAllBytes("$repo/scripts/WecAuthorizationNative.cs")))) 'Compiled native helper binds the exact source bytes'
|
||||
# Allocated EC_VARIANT buffers exercise the WEC ABI rather than EVT_VARIANT values.
|
||||
$buffer=[Runtime.InteropServices.Marshal]::AllocHGlobal(64)
|
||||
try {
|
||||
for($i=0;$i -lt 64;$i++){[Runtime.InteropServices.Marshal]::WriteByte($buffer,$i,0)}
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,2)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,27) -eq [uint32]0) 'EcVarTypeUInt32 is 2 and source-initiated value is zero'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,1)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,27) -eq [uint32]1) 'Collector-initiated scalar remains distinguishable'
|
||||
foreach($type in @(0,1,4,8,130)){[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,$type);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,27)} 'UInt32'}
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,1)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,0) -eq $true) 'Native scalar Boolean decodes true'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,0)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,0) -eq $false) 'Native scalar Boolean decodes false'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,0,2);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,0)} 'Boolean'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,4)
|
||||
[Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,16));[Runtime.InteropServices.Marshal]::WriteInt16($buffer,16,65)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,20,31) -ceq 'A') 'String data is decoded within returned bounds'
|
||||
Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,18,31)} 'Unterminated'
|
||||
[Runtime.InteropServices.Marshal]::WriteIntPtr($buffer,[IntPtr]::Add($buffer,64));Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,20,31)} 'outside'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,132);Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,20,31)} 'scalar'
|
||||
Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,15,31)} 'buffer';Reject {[Wela.WecAuthorization.Edit]::Decode([IntPtr]::Zero,16,31)} 'buffer'
|
||||
Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,1)} 'selection'
|
||||
[Runtime.InteropServices.Marshal]::WriteInt32($buffer,12,0)
|
||||
Assert ([Wela.WecAuthorization.Edit]::Decode($buffer,16,6) -ceq '') 'Absent description normalizes to empty'
|
||||
Reject {[Wela.WecAuthorization.Edit]::Decode($buffer,16,31)} 'scalar'
|
||||
}finally{[Runtime.InteropServices.Marshal]::FreeHGlobal($buffer)}
|
||||
$before=Get-WelaWecAuthorizationDefinition $base;$after=Get-WelaWecAuthorizationDefinition ($base.Replace($authA,$authAB))
|
||||
Assert ($before.SourceSids.Count -eq 1 -and $after.SourceSids.Count -eq 2 -and $before.PreservedKey -ceq $after.PreservedKey -and $before.WholeKey -cne $after.WholeKey) 'Only the explicit allow list is excluded from preserved XML'
|
||||
foreach($bad in @($base.Replace('SourceInitiated','CollectorInitiated'),$base.Replace('>false</Enabled>','>true</Enabled>'),$base.Replace($authA,'D:(A;;GA;;;WD)'),$base.Replace($authA,''),$base.Replace($authA,$authAB.Replace($sidB,$sidA)),$base.Replace('Path="Security"','Path="Microsoft-Windows-Sysmon/Operational"'),$base.Replace('</Subscription>','<AllowedSourceDomainComputers>bad</AllowedSourceDomainComputers></Subscription>'))){Reject {Get-WelaWecAuthorizationDefinition $bad}}
|
||||
$reader=[pscustomobject][ordered]@{ProcessId=10;TokenId='1';ModifiedId='2';UserSid=$sidA;AuthenticationId='3';ElevatedAdministrator=$true;GroupSids=@('S-1-5-32-544')}
|
||||
$context=[pscustomobject][ordered]@{Host='TEST';Reader=$reader;Services='Running';Destination='unchanged'}
|
||||
$copy=Copy-Auth $context;$copy.Reader.ProcessId=11;$copy.Reader.TokenId='4';$copy.Reader.ModifiedId='5';Assert ((Get-WelaWecAuthorizationReviewKey $copy) -ceq (Get-WelaWecAuthorizationReviewKey $context)) 'Separate same-logon CLI processes can use a reviewed plan'
|
||||
$copy.Reader.AuthenticationId='6';Assert ((Get-WelaWecAuthorizationReviewKey $copy) -cne (Get-WelaWecAuthorizationReviewKey $context)) 'Different logon is not accepted'
|
||||
$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-auth-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
|
||||
$script:xml=$base;$script:mode='ok';$script:reads=0;$script:contextReads=0;$script:saves=0;$script:pending=''
|
||||
function Get-WelaWecAuthorizationContext {$script:contextReads++;$v=Copy-Auth $context;if($script:mode -eq 'token-drift' -and $script:contextReads -gt 1){$v.Reader.ModifiedId='drift'};$v}
|
||||
function Read-WelaWecAuthorizationDefinition {param($Id);$script:reads++;if($script:mode -eq 'drift' -and $script:reads -eq 2){$script:xml=$script:xml.Replace('MinLatency','Normal')};if($script:mode -eq 'denied'){throw 'Native access denied'};Get-WelaWecAuthorizationDefinition $script:xml}
|
||||
function Read-WelaWecSubscriptionXml {param($Id);$script:xml}
|
||||
function New-WelaWecAuthorizationEdit {
|
||||
param($Before)
|
||||
$edit=[pscustomobject]@{SaveAttempted=$false}
|
||||
$edit|Add-Member ScriptMethod Save {param($Authorization)
|
||||
Assert (Test-Path $script:pending) 'Pending artifact exists before native call'
|
||||
$pending=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($script:pending));Assert ($pending.Status -ceq 'Pending' -and $pending.DesiredAuthorization -ceq $Authorization) 'Durable intent states exact allow list'
|
||||
if($script:mode -eq 'native-refusal'){throw 'Native current view differs'}
|
||||
$this.SaveAttempted=$true;$script:saves++
|
||||
if($script:mode -eq 'native-error'){throw 'Native save failed'}
|
||||
if($script:mode -eq 'false-success'){return}
|
||||
$doc=Read-WelaWefXml $script:xml;$doc.Subscription.AllowedSourceDomainComputers=$Authorization
|
||||
if($script:mode -eq 'preservation'){$doc.Subscription.ReadExistingEvents='true'}
|
||||
if($script:mode -eq 'unexpected-enabled'){$doc.Subscription.Enabled='true'}
|
||||
$script:xml=$doc.OuterXml
|
||||
if($script:mode -eq 'artifact-drift'){[IO.File]::AppendAllText($script:pending,' ')}
|
||||
}
|
||||
$edit|Add-Member ScriptMethod Dispose {if($script:mode -eq 'cleanup-error'){throw 'Handle cleanup failed'}}
|
||||
$edit
|
||||
}
|
||||
try {
|
||||
Reject {Invoke-WelaWecAuthorization -Id $id -SourceSids @($sidA) -PlanHash ('a'*64) -OutputPath (Join-Path $root 'bad')} 'Plan requires'
|
||||
Reject {Invoke-WelaWecAuthorization Apply -PlanPath missing -PlanHash ('a'*64) -Id '' -OutputPath (Join-Path $root 'bad')} 'only'
|
||||
Reject {Invoke-WelaWecAuthorization -Id $id -SourceSids @($sidA) -WhatIf -OutputPath (Join-Path $root 'bad')} 'Unknown'
|
||||
foreach($scenario in @('ok','no-op','hash','schema','kind','duplicate-json','context','source','stale','enabled','denied','drift','token-drift','native-refusal','native-error','false-success','preservation','unexpected-enabled','artifact-drift','cleanup-error')){
|
||||
$script:mode='ok';$script:xml=$base;$script:reads=0;$script:contextReads=0;$script:saves=0
|
||||
$desired=if($scenario -eq 'no-op'){@($sidA)}else{@($sidA,$sidB)}
|
||||
$planned=Invoke-WelaWecAuthorization -Id $id -SourceSids $desired -OutputPath (Join-Path $root ($scenario+'-plan'))
|
||||
Assert ($planned.Status -ceq 'ReviewRequired' -and $planned.ExitCode -eq 0 -and -not $planned.NativeSaveAttempted -and $script:saves -eq 0) "Plan: $($planned.Diagnostic)"
|
||||
$path=Join-Path $planned.OutputPath 'plan.json';$hash=$planned.PlanHash
|
||||
if($scenario -eq 'hash'){$hash='f'*64}
|
||||
if($scenario -in @('schema','kind','duplicate-json','context','source')){
|
||||
$text=[IO.File]::ReadAllText($path)
|
||||
switch($scenario){schema{$text=$text -replace '"SchemaVersion"\s*:\s*1','"SchemaVersion":true'}kind{$text=$text -replace '"Kind"\s*:\s*"WelaWecAuthorizationPlan"','"Kind":true'}duplicate-json{$text=$text.Replace('"SchemaVersion":','"SchemaVersion":1,"SchemaVersion":')}context{$text=$text.Replace('TEST','OTHER')}source{$text=$text.Replace('scripts/WecAuthorization.ps1','scripts/other.ps1')}}
|
||||
[IO.File]::WriteAllText($path,$text);$hash=(Get-FileHash $path).Hash.ToLowerInvariant()
|
||||
}
|
||||
if($scenario -eq 'stale'){$script:xml=$base.Replace('MinLatency','Normal')};if($scenario -eq 'enabled'){$script:xml=$base.Replace('>false</Enabled>','>true</Enabled>')}
|
||||
$script:mode=$scenario;$script:reads=0;$script:contextReads=0;$out=Join-Path $root ($scenario+'-apply');$script:pending=Join-Path $out 'before-save.json'
|
||||
$applied=Invoke-WelaWecAuthorization Apply -PlanPath $path -PlanHash $hash -OutputPath $out
|
||||
Assert (($applied.ExitCode -eq 0) -eq ($scenario -in @('ok','no-op'))) "Scenario $scenario : $($applied.Diagnostic)"
|
||||
Assert ($applied.ReadyRuleCredit -eq 0 -and (Test-Path (Join-Path $out 'manifest.json'))) 'No readiness credit and final result retained'
|
||||
if($scenario -in @('native-error','false-success','preservation','unexpected-enabled','artifact-drift','cleanup-error')){Assert ($applied.NativeSaveAttempted -and $applied.Status -ceq 'SaveAttemptedUnverified') 'Possible persistent change remains unverified'}elseif($scenario -notin @('ok','no-op')){Assert (-not $applied.NativeSaveAttempted -and $script:saves -eq 0 -and $applied.Status -ceq 'Refused') 'Rejected before native save'}
|
||||
if($scenario -eq 'no-op'){Assert ($applied.Status -ceq 'AlreadyMatches' -and -not $applied.NativeSaveAttempted -and $script:saves -eq 0) 'No-op never saves'}
|
||||
if($scenario -eq 'ok'){
|
||||
Assert ($applied.Status -ceq 'AuthorizationChangedAndVerified' -and $applied.NativeSaveAttempted -and $applied.After.PreservedKey -ceq $before.PreservedKey) 'Successful update changes only explicit authorization'
|
||||
$again=Invoke-WelaWecAuthorization Apply -PlanPath $path -PlanHash $hash -OutputPath (Join-Path $root 'replay');Assert ($again.Status -ceq 'Refused' -and -not $again.NativeSaveAttempted -and $script:saves -eq 1) 'Changed pre-state refuses stale plan'
|
||||
}
|
||||
if($scenario -ne 'artifact-drift'){foreach($artifact in $applied.Artifacts){Assert ((Get-FileHash (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'Retained artifact hash matches bytes'}}
|
||||
}
|
||||
}finally{Remove-Item -LiteralPath $root -Recurse -Force}
|
||||
Write-Host "PASS: $count focused WEC authorization assertions; no native delivery proof."
|
||||
@@ -0,0 +1,107 @@
|
||||
param([switch]$AllowDisposableSubscription)
|
||||
$ErrorActionPreference='Stop'
|
||||
if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not $AllowDisposableSubscription -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable GitHub-hosted Windows subscription opt-in required.'}
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$script:ScriptRoot=$repo
|
||||
Import-Module "$repo/modules/WefSubscriptions.psm1" -Force
|
||||
Import-Module "$repo/modules/AuditProfiles.psm1" -Force
|
||||
. "$repo/scripts/Configuration.ps1"
|
||||
. "$repo/scripts/WefArrival.ps1"
|
||||
. "$repo/scripts/WecUpdate.ps1"
|
||||
. "$repo/scripts/ChannelRead.ps1"
|
||||
. "$repo/scripts/WecAuthorization.ps1"
|
||||
$count=0;$engine=(Get-Process -Id $PID).Path
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Key($Value){Get-WelaWecAuthorizationKey $Value}
|
||||
function Services {@(Get-CimInstance Win32_Service -Filter "Name='Wecsvc' OR Name='Winmgmt' OR Name='EventLog' OR Name='WinRM'"|Sort-Object Name|Select-Object Name,State,StartMode)}
|
||||
function Channel {$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{[pscustomobject]@{Name=$c.LogName;Enabled=$c.IsEnabled;Mode=[string]$c.LogMode;MaximumBytes=$c.MaximumSizeInBytes;Path=$c.LogFilePath;SecurityDescriptor=$c.SecurityDescriptor}}finally{$c.Dispose()}}
|
||||
function EnableChannel([bool]$Value){$c=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('ForwardedEvents');try{$c.IsEnabled=$Value;$c.SaveChanges()}finally{$c.Dispose()}}
|
||||
Add-Type -Path (Join-Path $PSScriptRoot 'WecAuthorizationFixtureNative.cs')
|
||||
function Ids {[Wela.WecAuthorizationFixture.Inventory]::Read()|Sort-Object}
|
||||
function Inventory {$ids=@(Ids);Save 'last-observed-ids.json' $ids;@($ids|ForEach-Object {[pscustomobject]@{Id=$_;Xml=Read-WelaWecSubscriptionXml $_}})}
|
||||
$nonce=[guid]::NewGuid().ToString('N');$id='WELA-Authorization-'+$nonce;$description='Owned authorization '+$nonce+' '+[char]0x65e5+[char]0x672c
|
||||
$sidA='S-1-5-21-111111111-222222222-333333333-1234';$sidB='S-1-5-21-111111111-222222222-333333333-1235'
|
||||
$root=Join-Path $env:RUNNER_TEMP ('wela-wec-authorization-'+$nonce);$null=New-Item -ItemType Directory $root
|
||||
function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
|
||||
# Native -File argument binding cannot portably carry a string[] on both engines.
|
||||
# This fixture wrapper supplies the selected array to the actual public script.
|
||||
$wrapper=Join-Path $root 'invoke-public.ps1'
|
||||
@'
|
||||
param([string]$WelaPath,[string]$Action,[string]$Id,[string]$Sids,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath)
|
||||
$ErrorActionPreference='Stop'
|
||||
$p=@{Cmd='wec-authorization';WecAuthorizationAction=$Action;WecAuthorizationOutputPath=$OutputPath}
|
||||
if($PSBoundParameters.ContainsKey('Id')){$p.WecAuthorizationId=$Id}
|
||||
if($PSBoundParameters.ContainsKey('Sids')){$p.WecAuthorizationSourceSid=@($Sids.Split(';'))}
|
||||
if($PSBoundParameters.ContainsKey('PlanPath')){$p.WecAuthorizationPlanPath=$PlanPath}
|
||||
if($PSBoundParameters.ContainsKey('PlanHash')){$p.WecAuthorizationPlanHash=$PlanHash}
|
||||
$global:LASTEXITCODE=0
|
||||
& $WelaPath @p
|
||||
exit $LASTEXITCODE
|
||||
'@|Set-Content -LiteralPath $wrapper -Encoding UTF8
|
||||
function Public([string[]]$Arguments,[string]$Output,[bool]$Success=$true){
|
||||
$prior=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$text=@(&$engine -NoLogo -NoProfile -NonInteractive -File $wrapper -WelaPath "$repo/WELA.ps1" @Arguments -OutputPath $Output 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$prior}
|
||||
if(($code -eq 0) -ne $Success){Write-Host ($text -join "`n");Get-ChildItem $root -Filter manifest.json -Recurse|ForEach-Object {Write-Host (Get-Content $_.FullName -Raw)};throw "Public command returned $code"};$script:count++
|
||||
$result=Get-Content -LiteralPath (Join-Path $Output 'manifest.json') -Raw|ConvertFrom-Json
|
||||
foreach($a in $result.Artifacts){Assert ((Get-FileHash (Join-Path $Output $a.Name)).Hash.ToLowerInvariant() -ceq $a.Sha256) 'Public evidence hash matches actual bytes'}
|
||||
$result
|
||||
}
|
||||
$beforeServices=Services;$beforeChannel=Channel;$serviceKey='HKLM:\SYSTEM\CurrentControlSet\Services\Wecsvc';$beforeDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart
|
||||
$original=$null;$created=$false;$failure=$null;$cleanupErrors=@();$inventoryOk=$false;$servicesOk=$false;$channelOk=$false;$endServices=$null;$endChannel=$null;$endDelayed=$null
|
||||
Save 'before-fixture.json' @{Services=$beforeServices;Channel=$beforeChannel;DelayedAutoStart=$beforeDelayed}
|
||||
try {
|
||||
$wec=@($beforeServices|Where-Object Name -eq Wecsvc);Assert ($wec.Count -eq 1 -and $wec[0].State -in @('Running','Stopped') -and $wec[0].StartMode -in @('Auto','Manual','Disabled')) 'Stable original service state required'
|
||||
if($wec[0].StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Manual};if($wec[0].State -eq 'Stopped'){Start-Service Wecsvc}
|
||||
if(-not $beforeChannel.Enabled){EnableChannel $true}
|
||||
Save 'original-console-enumeration.json' (Invoke-WelaNative 'wecutil.exe' @('es'))
|
||||
$original=@(Inventory);Save 'original-inventory.json' $original;Assert (@(Ids) -notcontains $id) 'Unique owned subscription is initially absent'
|
||||
$query='<QueryList><Query Id="0" Path="Application"><Select Path="Application">*[System[(EventID=1)]]</Select></Query></QueryList>'
|
||||
$xml=@"
|
||||
<Subscription xmlns="http://schemas.microsoft.com/2006/03/windows/events/subscription"><SubscriptionId>$id</SubscriptionId><SubscriptionType>SourceInitiated</SubscriptionType><Description>$description</Description><Enabled>false</Enabled><Uri>http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog</Uri><ConfigurationMode>Normal</ConfigurationMode><Query><![CDATA[$query]]></Query><ReadExistingEvents>false</ReadExistingEvents><TransportName>HTTP</TransportName><ContentFormat>Events</ContentFormat><Locale Language="en-US"/><LogFile>ForwardedEvents</LogFile><AllowedSourceDomainComputers>$(Get-WelaWefAuthorization @($sidA))</AllowedSourceDomainComputers></Subscription>
|
||||
"@
|
||||
$path=Join-Path $root 'owned.xml';[IO.File]::WriteAllText($path,$xml,[Text.UTF8Encoding]::new($false));$created=$true;$null=Invoke-WelaNative 'wecutil.exe' @('cs',$path)
|
||||
$before=Read-WelaWecAuthorizationDefinition $id;[IO.File]::WriteAllText((Join-Path $root 'original.xml'),$before.Xml,[Text.UTF8Encoding]::new($false));$duringServices=Services;$duringChannel=Channel
|
||||
Initialize-WelaWecAuthorizationNative
|
||||
$missing=$false;try{$e=[Wela.WecAuthorization.Edit]::new($id+'-missing');$e.Dispose()}catch{$missing=$true};Assert ($missing -and @(Ids) -notcontains ($id+'-missing')) 'Native existing-only handle never creates a missing ID'
|
||||
$index=0
|
||||
foreach($desired in @(@($sidA),@($sidA,$sidB),@($sidA,$sidB),@($sidB),@($sidA))){
|
||||
$index++;$prior=Read-WelaWecAuthorizationDefinition $id;$changed=$prior.Authorization -cne (Get-WelaWefAuthorization $desired)
|
||||
$plan=Public @('-Action','Plan','-Id',$id,'-Sids',($desired -join ';')) (Join-Path $root "plan-$index")
|
||||
Assert ($plan.Status -ceq 'ReviewRequired' -and -not $plan.NativeSaveAttempted -and (Read-WelaWecAuthorizationDefinition $id).WholeKey -ceq $prior.WholeKey) 'Actual public Plan preserved original native definition'
|
||||
$planPath=Join-Path $plan.OutputPath 'plan.json'
|
||||
if($index -eq 2){$bad=Public @('-Action','Apply','-PlanPath',$planPath,'-PlanHash',('f'*64)) (Join-Path $root 'bad-hash') $false;Assert ($bad.Status -ceq 'Refused' -and -not $bad.NativeSaveAttempted) 'Wrong hash refuses before native save'}
|
||||
$apply=Public @('-Action','Apply','-PlanPath',$planPath,'-PlanHash',$plan.PlanHash) (Join-Path $root "apply-$index")
|
||||
Assert ($apply.NativeSaveAttempted -eq $changed -and $apply.Status -ceq $(if($changed){'AuthorizationChangedAndVerified'}else{'AlreadyMatches'})) 'Only a changed allow list saves'
|
||||
$after=Read-WelaWecAuthorizationDefinition $id
|
||||
Assert ($after.Authorization -ceq (Get-WelaWefAuthorization $desired) -and $after.PreservedKey -ceq $before.PreservedKey -and $apply.ReadyRuleCredit -eq 0) 'Actual disabled definition changes only selected authorization; no readiness credit'
|
||||
if($index -eq 2){$stale=Public @('-Action','Apply','-PlanPath',$planPath,'-PlanHash',$plan.PlanHash) (Join-Path $root 'stale') $false;Assert ($stale.Status -ceq 'Refused' -and -not $stale.NativeSaveAttempted) 'Stale pre-state plan refuses replay'}
|
||||
}
|
||||
Assert ((Read-WelaWecAuthorizationDefinition $id).WholeKey -ceq $before.WholeKey) 'Fresh public plan restored complete original subscription'
|
||||
# Direct native fresh-handle guard checks a concurrently changed description.
|
||||
$edit=New-WelaWecAuthorizationEdit $before
|
||||
try{$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+($description+' drift')));$refused=$false;try{$edit.Save((Get-WelaWefAuthorization @($sidB)))}catch{$refused=$true};Assert ($refused -and -not $edit.SaveAttempted) 'Native guard refuses a changed current definition before save'}finally{$edit.Dispose();$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,('/d:'+$description))}
|
||||
try{
|
||||
$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,'/e:true')
|
||||
$enabled=Public @('-Action','Plan','-Id',$id,'-Sids',$sidB) (Join-Path $root 'enabled-refusal') $false
|
||||
Assert ($enabled.Status -ceq 'Refused' -and -not $enabled.NativeSaveAttempted) 'Public command refuses actual enabled subscription'
|
||||
}finally{$null=Invoke-WelaNative 'wecutil.exe' @('ss',$id,'/e:false')}
|
||||
Assert ((Read-WelaWecAuthorizationDefinition $id).WholeKey -ceq $before.WholeKey) 'Fixture drift and enabled-state probes restored full original XML'
|
||||
Assert ((Key (Services)) -ceq (Key $duringServices) -and (Key (Channel)) -ceq (Key $duringChannel)) 'Product preserves services and complete channel configuration'
|
||||
[IO.File]::WriteAllText((Join-Path $root 'restored-owned.xml'),(Read-WelaWecSubscriptionXml $id),[Text.UTF8Encoding]::new($false))
|
||||
Write-Host "PASS: $count actual WEC authorization assertions on $($PSVersionTable.PSVersion). No real source or forwarding proof."
|
||||
}catch{$failure=$_.ToString();Write-Host $failure}finally{
|
||||
try{
|
||||
if($created -and @(Ids) -contains $id){$raw=Read-WelaWecSubscriptionXml $id;$doc=Read-WelaWefXml $raw;if($doc.Subscription.Description -cne $description -and $doc.Subscription.Description -cne ($description+' drift')){throw 'Fixture ownership differs; do not delete subscription.'};$null=Invoke-WelaNative 'wecutil.exe' @('ds',$id)}
|
||||
$restored=@(Inventory);Save 'restored-inventory.json' $restored;$inventoryOk=$null -ne $original -and (Key $restored) -ceq (Key $original)
|
||||
}catch{$cleanupErrors+=$_.ToString()}
|
||||
try{if((Channel).Enabled -ne $beforeChannel.Enabled){EnableChannel $beforeChannel.Enabled};$endChannel=Channel;$channelOk=(Key $endChannel) -ceq (Key $beforeChannel)}catch{$cleanupErrors+=$_.ToString()}
|
||||
try{
|
||||
$wec=@($beforeServices|Where-Object Name -eq Wecsvc)[0]
|
||||
if($wec.State -eq 'Stopped' -and (Get-Service Wecsvc).Status -ne 'Stopped'){Stop-Service Wecsvc}
|
||||
if($wec.StartMode -eq 'Disabled'){Set-Service Wecsvc -StartupType Disabled}
|
||||
if((Key (Get-WelaRegistryState $serviceKey DelayedAutoStart)) -cne (Key $beforeDelayed)){if($beforeDelayed.ValueExists){$null=New-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -Value $beforeDelayed.Value -PropertyType $beforeDelayed.Type -Force}else{Remove-ItemProperty -LiteralPath $serviceKey -Name DelayedAutoStart -ErrorAction Stop}}
|
||||
$endServices=Services;$endDelayed=Get-WelaRegistryState $serviceKey DelayedAutoStart;$servicesOk=(Key $endServices) -ceq (Key $beforeServices) -and (Key $endDelayed) -ceq (Key $beforeDelayed)
|
||||
}catch{$cleanupErrors+=$_.ToString()}
|
||||
Save 'after-fixture.json' @{Services=$endServices;Channel=$endChannel;DelayedAutoStart=$endDelayed}
|
||||
Save 'cleanup.json' @{Failure=$failure;CleanupErrors=$cleanupErrors;SubscriptionsRestored=$inventoryOk;ServicesRestored=$servicesOk;ChannelRestored=$channelOk;Complete=($inventoryOk -and $servicesOk -and $channelOk -and -not $cleanupErrors.Count);Assertions=$count;Computer=[Environment]::MachineName;Engine=$PSVersionTable.PSVersion.ToString();Scope='Owned disabled subscription authorization only; inert SIDs are not resolved or authenticated.'}
|
||||
}
|
||||
if($failure -or -not $inventoryOk -or -not $servicesOk -or -not $channelOk -or $cleanupErrors.Count){throw "Native authorization or fixture cleanup failed; inspect $root"}
|
||||
exit 0
|
||||
@@ -0,0 +1,25 @@
|
||||
// Read-only disposable-fixture inventory; bypasses console/native text decoding.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
namespace Wela.WecAuthorizationFixture {
|
||||
public static class Inventory {
|
||||
[DllImport("wecapi.dll",SetLastError=true)] static extern IntPtr EcOpenSubscriptionEnum(uint flags);
|
||||
[DllImport("wecapi.dll",CharSet=CharSet.Unicode,SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcEnumNextSubscription(IntPtr enumeration,uint size,StringBuilder name,out uint used);
|
||||
[DllImport("wecapi.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] static extern bool EcClose(IntPtr handle);
|
||||
public static string[] Read() {
|
||||
IntPtr handle=EcOpenSubscriptionEnum(0);if(handle==IntPtr.Zero)throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
try {
|
||||
var names=new List<string>();
|
||||
while(true) {
|
||||
var name=new StringBuilder(4096);uint used;
|
||||
if(!EcEnumNextSubscription(handle,4096,name,out used)) {int error=Marshal.GetLastWin32Error();if(error==259)return names.ToArray();throw new Win32Exception(error);}
|
||||
if(used<2||used>4096||name.Length==0||name.Length+1!=used||names.Count>=64||names.Contains(name.ToString()))throw new InvalidOperationException("Disposable native subscription inventory is invalid, duplicated or exceeds its bound.");
|
||||
names.Add(name.ToString());
|
||||
}
|
||||
}finally {EcClose(handle);}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- 既存の無効なネイティブサブスクリプション1件の明示的なソースSID一覧を変更する、オプトインの `wec-authorization` Plan/Apply を追加しました。レビュー済みハッシュ、ホスト・トークン・実装・定義全体の照合、永続化した変更前証跡、認可プロパティのみのネイティブ更新と読み戻しにより他の設定を保持し、変更不要と部分失敗を区別します。使い捨てWindowsテストで追加・削除・復元・拒否・後処理を検証しますが、SID解決、ドメイン認証、転送、Sigmaの有効性は主張しません。(@Shirofune-Security)
|
||||
|
||||
- カスタム監査プロファイルの公開 Plan、DryRun、Configure、任意項目、再実行、Audit を Server 2022/2025 と Windows PowerShell 5.1/PowerShell 7 の破棄可能な環境で検証します。実際の優先設定、厳密値・最小値・維持の動作、変更前ジャーナル、全59監査マスクと復元を確認します。 (@Shirofune-Security)
|
||||
- `wec-listener` の Plan/Apply を追加し、割り当て済みIPv4に限定した新規HTTP5985リスナーを作成できるようにしました。ホスト・実行ユーザー・ソース・WinRMとファイアウォールの状態、計画ハッシュ、実行前記録とネイティブ再読取で変更を検証します。両PowerShellホストから固定のWindows PowerShell 5.1ワーカーを使用し、既存リスナーや状態変化を検出した場合は拒否します。転送到着やSigma対応は別途検証が必要です。 (@Shirofune-Security)
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added opt-in `wec-authorization` Plan/Apply for the explicit source SID list of one existing disabled native subscription. Reviewed hashes, host/token/source and full-definition guards, durable pending evidence, one native authorization setter and readback preserve other settings; no-op and partial-save outcomes stay explicit. Native disposable tests cover add/remove/restore, refusals and cleanup without SID-resolution, domain authentication, forwarding or Sigma claims. (@Shirofune-Security)
|
||||
|
||||
- Add disposable native acceptance for public custom-profile Plan, DryRun, Configure, optional controls, idempotence and Audit on Server 2022/2025 under Windows PowerShell 5.1/PowerShell 7. Verify exact/minimum/preserve semantics, real precedence, original journals and all 59 masks, with exact fixture restoration. (@Shirofune-Security)
|
||||
- Added opt-in `wec-listener` Plan/Apply for one new assigned-IPv4 HTTP5985 listener. Reviewed host/operator/source and WinRM/firewall snapshots, explicit plan hashes, pending evidence and native readback guard creation and preserve existing settings. A fixed native Windows PowerShell 5.1 worker provides the creation adapter under both PowerShell host versions. Existing listeners and drift require review; forwarding arrival and Sigma readiness are not inferred. (@Shirofune-Security)
|
||||
|
||||
|
||||
Reference in new issue
Block a user