diff --git a/.gitattributes b/.gitattributes
index 7f4524b7..f7d7aa75 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -96,3 +96,6 @@ tests/NativeProviderConfigure.Windows.Tests.ps1 text eol=lf
/modules/WecSubscriptionInventory.cs text eol=lf
/tests/WecCollectorObservation* text eol=lf
/tests/WecSubscriptionInventory* text eol=lf
+
+/tests/TokenRightAttribution*.ps1 text eol=lf
+/tests/TokenRightAttribution*.cs text eol=lf
diff --git a/.github/workflows/native-token-attribution.yml b/.github/workflows/native-token-attribution.yml
new file mode 100644
index 00000000..b9c1dd98
--- /dev/null
+++ b/.github/workflows/native-token-attribution.yml
@@ -0,0 +1,47 @@
+name: Native Security4703 audit attribution
+on:
+ push:
+ branches: ['**']
+ paths:
+ - 'tests/TokenRightAttribution*'
+ - 'docs/native-token-right-attribution.md'
+ - 'config/eid_subcategory_mapping.csv'
+ - 'config/audit_profiles.json'
+ - '.github/workflows/native-token-attribution.yml'
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ token-right-probe:
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-2022, windows-2025]
+ engine: [powershell, pwsh]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
+ - name: Native audit attribution in Windows PowerShell
+ if: matrix.engine == 'powershell'
+ shell: powershell
+ run: |
+ ./tests/TokenRightAttribution.Tests.ps1
+ ./tests/AuditCatalogMappings.Tests.ps1
+ ./tests/TokenRightAttribution.Windows.Tests.ps1 -AllowDisposableAuditWrite
+ - name: Native audit attribution in PowerShell7
+ if: matrix.engine == 'pwsh'
+ shell: pwsh
+ run: |
+ ./tests/TokenRightAttribution.Tests.ps1
+ ./tests/AuditCatalogMappings.Tests.ps1
+ ./tests/TokenRightAttribution.Windows.Tests.ps1 -AllowDisposableAuditWrite
+ - name: Retain native events and cleanup
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: token-right-probe-${{ matrix.os }}-${{ matrix.engine }}
+ path: ${{ runner.temp }}/wela-token-right-attribution-*/
+ if-no-files-found: error
+ retention-days: 7
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 52989c99..717931cb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -41,7 +41,7 @@ jobs:
Copy-Item -Recurse -Path ./scripts -Destination release-binaries/
Copy-Item -Recurse -Path ./modules -Destination release-binaries/
New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null
- Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md -Destination release-binaries/docs/
+ Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md -Destination release-binaries/docs/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
diff --git a/.github/workflows/token-right-probe.yml b/.github/workflows/token-right-probe.yml
deleted file mode 100644
index aa281902..00000000
--- a/.github/workflows/token-right-probe.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-name: Native token right adjustment probe
-on:
- push:
- branches: ['**']
- paths:
- - 'scripts/TokenRightProbe*'
- - 'tests/TokenRightProbe*'
- - '.github/workflows/token-right-probe.yml'
- pull_request:
- workflow_dispatch:
-permissions:
- contents: read
-jobs:
- token-right-probe:
- timeout-minutes: 15
- strategy:
- fail-fast: false
- matrix:
- os: [windows-2022, windows-2025]
- engine: [powershell, pwsh]
- runs-on: ${{ matrix.os }}
- steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- - name: Native adjustment feasibility in Windows PowerShell
- if: matrix.engine == 'powershell'
- shell: powershell
- run: ./tests/TokenRightProbe.Feasibility.ps1 -AllowDisposableAuditWrite
- - name: Native adjustment feasibility in PowerShell7
- if: matrix.engine == 'pwsh'
- shell: pwsh
- run: ./tests/TokenRightProbe.Feasibility.ps1 -AllowDisposableAuditWrite
- - name: Retain native events and cleanup
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
- with:
- name: token-right-probe-${{ matrix.os }}-${{ matrix.engine }}
- path: ${{ runner.temp }}/wela-token-right-feasibility-*/
- if-no-files-found: error
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index 3b6874eb..c6ab9b15 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,6 +4,8 @@
**改善:**
+- Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security)
+
- 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security)
- Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7129758..e98e2194 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
**Improvements:**
+- Added disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security)
+
- Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security)
- Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@Shirofune-Security)
diff --git a/docs/audit-catalog-mappings.md b/docs/audit-catalog-mappings.md
index 1ee7d66d..8dbd19be 100644
--- a/docs/audit-catalog-mappings.md
+++ b/docs/audit-catalog-mappings.md
@@ -15,3 +15,5 @@ The export fingerprints the mapping file, lists candidates and reasons per Event
The bundled CSV remains a historical candidate map, not a universally valid event-generation contract. For example, 4703 appears against both Token Right Adjusted and Authorization Policy Change. Microsoft's [Token Right Adjusted page](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/audit-token-right-adjusted) lists it, while the [4703 event page](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-4703) names Authorization Policy Change. WELA retains the conflict rather than inventing a build-independent resolution. Microsoft also states that Token Right Adjusted has no Failure events; setting a Failure mask is not proof of Failure records.
Fixtures check malformed/duplicate identifiers, unknown events, ambiguous 4703/object mappings, and independent RPC/token state through all four legacy baseline renderers. Windows CI runs [`auditpol /list /subcategory:* /v`](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/auditpol-list) and native policy queries under Windows PowerShell 5.1/PowerShell 7. These checks validate identifiers/readback, not generated event XML. Build-specific client, member-server, DC and CA event/outcome validation remains separate acceptance work for issue #380. Native Windows functionality only; Sysmon is out of scope.
+
+A separate [native4703 attribution fixture](native-token-right-attribution.md) compares the two selected masks on disposable standalone Server2022/2025 hosts with actual fixed privilege-adjustment XML. It retains all other audit masks and records the build/UBR and provider schema. This bounded generation evidence leaves historical candidates conditional and does not grant detection readiness.
diff --git a/docs/native-token-right-attribution.md b/docs/native-token-right-attribution.md
new file mode 100644
index 00000000..63b5f5b9
--- /dev/null
+++ b/docs/native-token-right-attribution.md
@@ -0,0 +1,25 @@
+# Native Security 4703 audit attribution
+
+The disposable `Native Security 4703 audit attribution` workflow tests the two historical audit-subcategory candidates for event 4703 on standalone Windows Server 2022/2025 under Windows PowerShell 5.1 and PowerShell 7. It does not add a production probe, change WELA policy recommendations, remove historical mapping candidates or grant Sigma readiness.
+
+Microsoft's [Token Right Adjusted guidance](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/audit-token-right-adjusted) and [advanced audit-policy reference](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/advanced-audit-policy-configuration) associate 4703 with token adjustment. The older [4703 event reference](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/auditing/event-4703) names Authorization Policy Change. [WELA's mapping review](audit-catalog-mappings.md) retains both historical candidates as conditional. Native evidence below is specific to the recorded Windows build/UBR, provider manifest, engine and fixed operation.
+
+The opted-in test changes exactly two audit masks and the advanced-audit precedence DWORD on an isolated GitHub-hosted runner. First it sets Token Right Adjusted Events (`0CCE924A-69AE-11D9-BED3-505054503030`) to Success and Authorization Policy Change (`0CCE9231-69AE-11D9-BED3-505054503030`) to None. Then it reverses those two masks. The other 57 audit masks remain at their observed original values. This comparison establishes the selected two-mask behavior under that retained context; it is not an experiment with all other audit sources disabled.
+
+Each phase starts a fresh owned child. A test-only native helper requires an already-enabled `SeDebugPrivilege` in that child's primary token, disables it, reads the complete privilege inventory, restores its original attributes and verifies the complete inventory again. It refuses impersonation, missing or disabled privileges. It never grants a new right, removes a privilege, opens another process, performs a debug operation or changes an account's assigned rights. The executable path is read through `QueryFullProcessImageName` before the operation so `Get-Process` cannot introduce an extra privilege adjustment inside the measured operation.
+
+Acceptance requires two distinct actual Security 4703 records in the TokenRight-only phase: exactly the fixed disable and restoration. The inverse phase must have no matching records during its bounded observation. A match requires the installed provider GUID/name, eventID/version/task, Security channel, success keyword, observed computer identity, fresh record boundary, owned PID/executable, subject and target SID/logon ID, and exact privilege direction/sentinel. The precise UTC envelope starts before the native adjustment and ends after native final inventory equality. Individual syscall-return times are also retained. Security logging can timestamp a record just after the adjustment call returns; the measured verification interval is part of the operation, with no artificial delay or padded interval accepted as evidence. A wider query only collects diagnostic candidates; the strict matcher determines attribution.
+
+The child has a 90-second limit, bounded asynchronous output, a bounded drain and confirmed termination before fixture cleanup. Native event reads have a timeout and require one successful Security-channel status. Query errors, schema differences, extra attributable records, caps, missing events, policy drift or failed cleanup fail the fixture; they are never reported as an empty successful observation. Native events and diagnostic XML remain in the short-lived CI artifacts.
+
+Retained evidence includes actual host/build/UBR, native audit name/GUID listing, all 59 original/prepared/restored masks, typed precedence state, full Security-channel configuration, service states, parent/child tokens and complete privilege arrays, precise timestamps, raw event XML, mapping review, source fingerprints and artifact hashes. Cleanup independently restores both selected masks and the original precedence value or absence, then checks all masks, full channel configuration, service states and parent token. It does not erase generated events or recreate a historical event-log contents snapshot; dispose of the runner.
+
+Run only on the explicitly supported disposable hosted fixture:
+
+```powershell
+./tests/TokenRightAttribution.Tests.ps1
+./tests/AuditCatalogMappings.Tests.ps1
+./tests/TokenRightAttribution.Windows.Tests.ps1 -AllowDisposableAuditWrite
+```
+
+This is a build-specific regression for issue #380, not universal proof about Windows 11, domain controllers, AD CS, every privilege, failure auditing, remote forwarding, policy persistence or Sigma Boolean/field requirements. All functionality is built into Windows; Sysmon is excluded.
diff --git a/tests/TokenRightAttribution.Tests.ps1 b/tests/TokenRightAttribution.Tests.ps1
new file mode 100644
index 00000000..b7289eb0
--- /dev/null
+++ b/tests/TokenRightAttribution.Tests.ps1
@@ -0,0 +1,45 @@
+$ErrorActionPreference='Stop'
+. "$PSScriptRoot/TokenRightAttributionEvidence.ps1"
+$count=0
+function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
+$start=[DateTime]::Parse('2026-09-22T00:00:00Z').ToFileTimeUtc()
+$context=[pscustomobject]@{Task=13570;Computers=@('HOST','HOST.example.test');Watermark=100;ProcessId=1234;ProcessName='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';Sid='S-1-5-21-1-2-3-500';AuthenticationId='0x123';DisableStartedFileTime=$start;DisableReturnedFileTime=$start+100000;RestoreStartedFileTime=$start+200000;RestoreReturnedFileTime=$start+300000;OperationCompletedFileTime=$start+300000}
+$xml=@'
+4703001357000x8020000000000000101SecurityHOST.example.testS-1-5-21-1-2-3-5000x123S-1-5-21-1-2-3-5000x123C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe0x4d2-SeDebugPrivilege
+'@
+$result=Get-WelaTokenAttributionMatch $xml $context
+Assert ($result.Direction -ceq 'Disable' -and $result.RecordId -eq 101) 'Exact fixed disable is attributable.'
+$restore=$xml.Replace('>101<','>102<').Replace('00.0050000Z','00.0250000Z').Replace('Name="EnabledPrivilegeList">-','Name="EnabledPrivilegeList">SeDebugPrivilege').Replace('Name="DisabledPrivilegeList">SeDebugPrivilege','Name="DisabledPrivilegeList">-')
+Assert ((Get-WelaTokenAttributionMatch $restore $context).Direction -ceq 'Restore') 'Exact fixed restoration is independently attributable.'
+foreach($case in @(
+ @('Microsoft-Windows-Security-Auditing','Other-Provider'),@('54849625','54849626'),@('4703','4704'),@('0','1'),@('0','1'),@('13570','13571'),@('0','1'),@('0x8020000000000000','0x8010000000000000'),@('Security','ForwardedEvents'),@('HOST.example.test','HOST.attacker.test'),@('>101<','>100<'),@('>101<','>0<'),@('>101<','>true<'),@('>0x4d2<','>0x4d3<'),@('>0x4d2<','>1234<'),@('powershell.exe','pwsh.exe'),@('S-1-5-21-1-2-3-500','S-1-5-21-1-2-3-501'),@('>0x123<','>0x124<'),@('>SeDebugPrivilege<','>SeDebugPrivilege SeBackupPrivilege<'),@('>SeDebugPrivilege<','>SeChangeNotifyPrivilege<'),@('>SeDebugPrivilege<','>sedebugprivilege<'),@('00.0050000Z','00.0350000Z'),@('00.0050000Z','00.0050000+00:00'),@('http://schemas.microsoft.com/win/2004/08/events/event','urn:wrong')
+)){Assert ($null -eq (Get-WelaTokenAttributionMatch ($xml.Replace($case[0],$case[1])) $context)) ('Mismatched event rejected: '+$case[0])}
+foreach($field in @('SubjectUserSid','SubjectLogonId','TargetUserSid','TargetLogonId')){
+ $doc=[xml]$xml;$node=@($doc.Event.EventData.Data|Where-Object{$_.Name -ceq $field})[0];$node.InnerText+='9'
+ Assert ($null -eq (Get-WelaTokenAttributionMatch $doc.OuterXml $context)) ('Independent mismatched identity rejected: '+$field)
+}
+$doc=[xml]$xml;$node=$doc.Event.EventData.Data[0];$null=$doc.Event.EventData.AppendChild($node.CloneNode($true));Assert ($null -eq (Get-WelaTokenAttributionMatch $doc.OuterXml $context)) 'Duplicate data field refused.'
+$doc=[xml]$xml;$node=$doc.Event.System.EventID;$null=$doc.Event.System.AppendChild($doc.Event.System.SelectSingleNode('*[local-name()="EventID"]').CloneNode($true));Assert ($null -eq (Get-WelaTokenAttributionMatch $doc.OuterXml $context)) 'Duplicate header field refused.'
+$doc=[xml]$xml;$null=$doc.Event.System.RemoveChild($doc.Event.System.SelectSingleNode('*[local-name()="Task"]'));Assert ($null -eq (Get-WelaTokenAttributionMatch $doc.OuterXml $context)) 'Missing native task refused.'
+foreach($text in @((' '+$xml).PadRight(65537),(']>'+$xml))){$rejected=$false;try{$null=Get-WelaTokenAttributionMatch $text $context}catch{$rejected=$true};Assert $rejected 'Oversized XML and DTD refuse explicitly.'}
+# Regex operations must not corrupt the event accumulator (PowerShell owns $Matches).
+$attributedEvents=@();foreach($text in @($xml,$restore)){$m=Get-WelaTokenAttributionMatch $text $context;if($m){$attributedEvents+=@($m)}}
+Assert ($attributedEvents.Count -eq 2 -and @($attributedEvents|Select-Object -ExpandProperty RecordId -Unique).Count -eq 2) 'Two directions survive regex correlation as distinct records.'
+Import-Module "$PSScriptRoot/../modules/AuditProfiles.psm1" -Force
+Import-Module "$PSScriptRoot/../modules/AuditCatalog.psm1" -Force
+$catalog=(Import-WelaAuditProfiles).catalog
+$token=@($catalog|Where-Object id -CEQ 'Token Right Adjusted Events')
+Assert ($token.Count -eq 1 -and $token[0].guid -ceq '0CCE924A-69AE-11D9-BED3-505054503030') 'Native attribution is bound to the canonical token GUID, never RPC.'
+$review=Get-WelaEventMappingReview @(Import-Csv "$PSScriptRoot/../config/eid_subcategory_mapping.csv") $catalog 4703
+Assert ($review.State -ceq 'Conditional' -and $review.Candidates.Count -eq 2 -and -not $review.DetectionReady) 'Build-specific generation never erases historical candidates or grants Sigma credit.'
+Assert-WelaTokenAttributionTimes $context ($start-100) ($start+400000)
+Assert $true 'Typed monotonic native timing accepted.'
+foreach($field in @('DisableStartedFileTime','DisableReturnedFileTime','RestoreStartedFileTime','RestoreReturnedFileTime','OperationCompletedFileTime')){
+ foreach($bad in @($true,'134345000000000000',0L,($start-200),($start+500000))){
+ $copy=$context|ConvertTo-Json -Depth 8|ConvertFrom-Json;$copy.$field=$bad;$rejected=$false
+ try{Assert-WelaTokenAttributionTimes $copy ($start-100) ($start+400000)}catch{$rejected=$true}
+ Assert $rejected ('Malformed or out-of-envelope native timestamp refused: '+$field)
+ }
+}
+$copy=$context|ConvertTo-Json -Depth 8|ConvertFrom-Json;$copy.RestoreStartedFileTime=$start+50000;$rejected=$false;try{Assert-WelaTokenAttributionTimes $copy ($start-100) ($start+400000)}catch{$rejected=$true};Assert $rejected 'Nonmonotonic in-envelope timestamps refused.'
+Write-Host "PASS: $count strict token attribution and catalog checks."
diff --git a/tests/TokenRightProbe.Feasibility.ps1 b/tests/TokenRightAttribution.Windows.Tests.ps1
similarity index 52%
rename from tests/TokenRightProbe.Feasibility.ps1
rename to tests/TokenRightAttribution.Windows.Tests.ps1
index 574d1158..0ce72511 100644
--- a/tests/TokenRightProbe.Feasibility.ps1
+++ b/tests/TokenRightAttribution.Windows.Tests.ps1
@@ -1,19 +1,47 @@
param([switch]$AllowDisposableAuditWrite)
$ErrorActionPreference='Stop'
-if(-not $AllowDisposableAuditWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable Windows fixture only.'}
+if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess -or -not $AllowDisposableAuditWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit disposable Windows fixture only.'}
$repo=Split-Path $PSScriptRoot -Parent
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
+Import-Module (Join-Path $repo 'modules/AuditCatalog.psm1') -Force
. (Join-Path $repo 'scripts/Configuration.ps1')
+. (Join-Path $repo 'scripts/WefArrival.ps1')
. (Join-Path $repo 'scripts/WmiProbe.ps1')
+. (Join-Path $repo 'scripts/ControlApplicability.ps1')
+. (Join-Path $PSScriptRoot 'TokenRightAttributionEvidence.ps1')
Initialize-WelaWmiProbeNative
-$root=Join-Path $env:RUNNER_TEMP ('wela-token-right-feasibility-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root
+$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-token-right-attribution-'+[guid]::NewGuid().ToString('N'))) $PSScriptRoot
function Save($Name,$Value){ConvertTo-Json -InputObject $Value -Depth 24|Set-Content -LiteralPath (Join-Path $root $Name) -Encoding UTF8}
function Key($Value){ConvertTo-Json -InputObject $Value -Depth 24 -Compress}
function Masks{$m=Get-WelaEffectiveAuditPolicy;@($m.Keys|Sort-Object|ForEach-Object{"$_=$($m[$_])"}) -join ';'}
$guid='0CCE924A-69AE-11D9-BED3-505054503030';$auth='0CCE9231-69AE-11D9-BED3-505054503030'
$path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$name='SCENoApplyLegacyAuditPolicy'
+foreach($service in @('Winmgmt','EventLog')){if((Get-Service $service).Status -ne 'Running'){throw 'Observation services must already be running.'}}
+$hostContext=Get-WelaDefaultContext
+if(-not(Test-WelaDefaultContextComplete $hostContext) -or $hostContext.ProductType -ne 3 -or $hostContext.DomainRole -ne 2 -or $hostContext.Build -notin @(20348,26100)){throw 'Only reviewed disposable standalone Server2022/2025 hosts are accepted.'}
+$provider=Get-WinEvent -ListProvider 'Microsoft-Windows-Security-Auditing'
+$schema=@($provider.Events|Where-Object{$_.Id -eq 4703 -and $_.Version -eq 0})
+if($schema.Count -ne 1 -or $provider.Id -ne [guid]'54849625-5478-4994-a5ba-3e3b0328c30d'){throw 'Exactly one installed version0 Security4703 schema is required.'}
+$eventTask=[int]$schema[0].Task.Value
+$computerProperties=[Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties();$computers=@([Environment]::MachineName,$computerProperties.HostName);if($computerProperties.DomainName){$computers+=$computerProperties.HostName+'.'+$computerProperties.DomainName};$computers=@($computers|Sort-Object -Unique)
+function Channel{(Invoke-WelaNative wevtutil.exe @('gl','Security','/f:xml')).Output -join "`n"}
+function Services{@(Get-Service Winmgmt,EventLog|Sort-Object Name|ForEach-Object{[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status;StartType=[string]$_.StartType}})}
+$originalChannel=Channel;$originalServices=Services
+$nativeListing=Invoke-WelaNative auditpol.exe @('/list','/subcategory:*','/v')
+$listing=$nativeListing.Output -join "`n"
+foreach($row in @(@{Name='Token Right Adjusted Events';Guid=$guid},@{Name='Authorization Policy Change';Guid=$auth})){
+ if($listing -notmatch [regex]::Escape($row.Guid)){throw 'Native audit listing omits a selected exact GUID.'}
+ if([Globalization.CultureInfo]::InstalledUICulture.TwoLetterISOLanguageName -eq 'en' -and -not @($nativeListing.Output|Where-Object{$_ -match [regex]::Escape($row.Guid) -and $_ -match [regex]::Escape($row.Name)}).Count){throw 'Native selected audit name and GUID disagree.'}
+}
+Save 'native-audit-catalog.json' @{Listing=$nativeListing.Output;Selected=@($guid,$auth)}
+$canonical=(Import-WelaAuditProfiles).catalog
+$mapping=Get-WelaEventMappingReview @(Import-Csv "$repo/config/eid_subcategory_mapping.csv") $canonical 4703
+if($mapping.State -cne 'Conditional' -or $mapping.Candidates.Count -ne 2 -or $mapping.DetectionReady){throw 'Historical4703 candidates must remain conditional with no readiness credit.'}
+Save 'mapping-review.json' $mapping
+$sources=[ordered]@{}
+foreach($file in @('tests/TokenRightAttribution.Windows.Tests.ps1','tests/TokenRightAttributionNative.cs','tests/TokenRightAttributionEvidence.ps1','tests/TokenRightAttribution.Tests.ps1','modules/AuditProfiles.psm1','modules/AuditCatalog.psm1','scripts/Configuration.ps1','scripts/WefArrival.ps1','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ControlApplicability.ps1','config/audit_profiles.json','config/baselines.json','config/eid_subcategory_mapping.csv')){$sources[$file]=(Get-FileHash -LiteralPath "$repo/$file" -Algorithm SHA256).Hash.ToLowerInvariant()}
$beforeMasks=Get-WelaEffectiveAuditPolicy;$masks=Masks;$beforePrecedence=Get-WelaRegistryState $path $name;$beforeToken=[Wela.WmiProbe.Native]::Snapshot();$failure=$null;$errors=@()
-Save 'original.json' @{Masks=$beforeMasks;Precedence=$beforePrecedence;Token=$beforeToken;Head=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString()}
+Save 'original.json' @{Masks=$beforeMasks;Precedence=$beforePrecedence;Token=$beforeToken;Head=$env:GITHUB_SHA;Engine=$PSVersionTable.PSVersion.ToString();Host=$hostContext;Channel=$originalChannel;Services=$originalServices;Computers=$computers;Provider=[string]$provider.Id;Schema=@{Id=4703;Version=0;Task=$eventTask;Template=$schema[0].Template}}
Add-Type -TypeDefinition @'
using System;using System.IO;using System.Text;using System.Threading.Tasks;
public static class WelaTokenFixturePipe {
@@ -34,6 +62,7 @@ function Worker([string]$Phase,[string]$Receipt){
if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),5000)){throw 'Owned worker drain exceeded5seconds.'}
[IO.File]::WriteAllText((Join-Path $root ($Phase+'-worker.txt')),$stdout.Result+"`n"+$stderr.Result)
if($process.ExitCode -ne 0){throw 'Owned native worker failed; see retained output.'}
+ [pscustomobject]@{ProcessId=$process.Id;Executable=$info.FileName}
}finally{
if($started){$exited=$false;try{$exited=$process.HasExited}catch{$script:errors+=$_.ToString()};if(-not $exited){try{$process.Kill()}catch{$script:errors+=$_.ToString()};try{$exited=$process.WaitForExit(5000)}catch{$script:errors+=$_.ToString()}};if(-not $exited){$script:errors+='Owned worker termination unconfirmed.'}}
try{$process.Dispose()}catch{$script:errors+=$_.ToString()}
@@ -44,11 +73,12 @@ $worker=Join-Path $root 'worker.ps1';$receipt=Join-Path $root 'worker.json'
param($Repo,$Result)
$ErrorActionPreference='Stop'
Add-Type -Path (Join-Path $Repo 'scripts/WmiProbeNative.cs')
-Add-Type -Path (Join-Path $Repo 'scripts/TokenRightProbeNative.cs')
+Add-Type -Path (Join-Path $Repo 'tests/TokenRightAttributionNative.cs')
+$executable=[Wela.TokenRightProbe.Native]::Executable()
$before=[Wela.WmiProbe.Native]::Snapshot()
$outcome=[Wela.TokenRightProbe.Native]::Run()
$after=[Wela.WmiProbe.Native]::Snapshot()
-[pscustomobject]@{ProcessId=$PID;ProcessName=(Get-Process -Id $PID).Path;Before=$before;After=$after;Outcome=$outcome}|ConvertTo-Json -Depth 24|Set-Content -LiteralPath $Result -Encoding UTF8
+[pscustomobject]@{ProcessId=$PID;ProcessName=$executable;Before=$before;After=$after;Outcome=$outcome}|ConvertTo-Json -Depth 24|Set-Content -LiteralPath $Result -Encoding UTF8
if($outcome.Status -ne 'Adjusted' -or -not $outcome.Restored -or (($before|ConvertTo-Json -Depth 24 -Compress) -cne ($after|ConvertTo-Json -Depth 24 -Compress))){exit 1}
exit 0
'@|Set-Content -LiteralPath $worker -Encoding UTF8
@@ -64,9 +94,16 @@ try{
foreach($entry in $beforeMasks.Keys){$expected=$beforeMasks[$entry];if($entry -eq $guid){$expected=$phase.Token};if($entry -eq $auth){$expected=$phase.Authorization};if($prepared[$entry] -ne $expected){throw 'Prepared native policy differs from the exact selected two-mask change.'}}
Save ($phase.Name+'-prepared.json') @{Phase=$phase;Masks=$prepared;Precedence=Get-WelaRegistryState $path $name}
$receipt=Join-Path $root ($phase.Name+'-worker.json')
- Worker $phase.Name $receipt
+ $record=Get-WinEvent -LogName Security -MaxEvents 1 -ErrorAction Stop;try{$watermark=[long]$record.RecordId}finally{$record.Dispose()}
+ $launched=[Wela.WmiProbe.Native]::UtcNow().ToFileTimeUtc()
+ $child=Worker $phase.Name $receipt
+ $observed=[Wela.WmiProbe.Native]::UtcNow().ToFileTimeUtc()
$result=Get-Content -Raw $receipt|ConvertFrom-Json
- $exactStart=[DateTime]::FromFileTimeUtc($result.Outcome.DisableStartedFileTime);$exactEnd=[DateTime]::FromFileTimeUtc($result.Outcome.RestoreReturnedFileTime)
+ if($result.ProcessId -ne $child.ProcessId -or $result.ProcessName -ine $child.Executable -or $result.Outcome.Status -isnot [string] -or $result.Outcome.Status -cne 'Adjusted' -or $result.Outcome.Restored -isnot [bool] -or -not $result.Outcome.Restored -or $result.Outcome.DisableStartedFileTime -lt $launched -or $result.Outcome.OperationCompletedFileTime -gt $observed){throw 'Owned worker receipt identity, status or measured operation interval is invalid.'}
+ Assert-WelaTokenAttributionTimes $result.Outcome $launched $observed
+ $context=[pscustomobject]@{ProcessId=$child.ProcessId;ProcessName=$child.Executable;Sid=$result.Before.Sid;AuthenticationId=$result.Before.AuthenticationId;Computers=$computers;Task=$eventTask;Watermark=$watermark;DisableStartedFileTime=$result.Outcome.DisableStartedFileTime;OperationCompletedFileTime=$result.Outcome.OperationCompletedFileTime}
+ Save ($phase.Name+'-context.json') @{Context=$context;LaunchedFileTime=$launched;ObservedFileTime=$observed;Child=$child}
+ $exactStart=[DateTime]::FromFileTimeUtc($result.Outcome.DisableStartedFileTime);$exactEnd=[DateTime]::FromFileTimeUtc($result.Outcome.OperationCompletedFileTime)
$start=$exactStart.AddSeconds(-2);$end=$exactEnd.AddSeconds(2)
$query="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4703 and TimeCreated[@SystemTime>='$($start.ToString('o'))' and @SystemTime<='$($end.ToString('o'))']]]"
$candidates=@{};$queryErrors=@();$attributedEvents=@();$deadline=[DateTime]::UtcNow.AddSeconds(15)
@@ -82,11 +119,8 @@ try{
$count++;if($count -gt 512){throw 'Diagnostic candidate count exceeded512.'}
$raw=$event.ToXml();if($raw.Length -gt 65536){throw 'Candidate XML exceeded64Ki characters.'}
[xml]$xml=$raw;$data=@{};foreach($field in $xml.Event.EventData.Data){$data[[string]$field.Name]=[string]$field.'#text'}
- $time=[DateTime]::Parse([string]$xml.Event.System.TimeCreated.SystemTime,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime()
- $identity=$data.ProcessId -and [Convert]::ToInt64($data.ProcessId,16) -eq $result.ProcessId -and $data.ProcessName -ieq $result.ProcessName -and $data.SubjectUserSid -ceq $result.Before.Sid -and $data.TargetUserSid -ceq $result.Before.Sid -and $data.SubjectLogonId -ieq $result.Before.AuthenticationId -and $data.TargetLogonId -ieq $result.Before.AuthenticationId
- $privilege=$data.EnabledPrivilegeList -ceq 'SeDebugPrivilege' -or $data.DisabledPrivilegeList -ceq 'SeDebugPrivilege'
- $exact=$identity -and $privilege -and $time -ge $exactStart -and $time -le $exactEnd
- $candidates[[string]$event.RecordId]=[pscustomobject]@{RecordId=$event.RecordId;Xml=$raw;Data=$data;ExactIdentity=[bool]$identity;ExactInterval=($time -ge $exactStart -and $time -le $exactEnd);Attributed=[bool]$exact}
+ $match=Get-WelaTokenAttributionMatch $raw $context
+ $candidates[[string]$event.RecordId]=[pscustomobject]@{RecordId=$event.RecordId;Xml=$raw;Data=$data;Attributed=($null -ne $match);Direction=if($match){$match.Direction}else{$null}}
}finally{$event.Dispose()}
}
}catch{$queryErrors+=@($_.ToString());break}finally{if($reader){$reader.Dispose()}}
@@ -97,6 +131,8 @@ try{
Save ($phase.Name+'-events.json') $attributedEvents
Save ($phase.Name+'-query.json') @{XPath=$query;ExactStart=$exactStart;ExactEnd=$exactEnd;QueryErrors=$queryErrors;CandidateCount=$candidates.Count;AttributedCount=$attributedEvents.Count;NoMatchingEvents=($candidates.Count -eq 0);DiagnosticOnly=$true}
if($queryErrors.Count){throw 'Native4703 observation failed; see retained query errors.'}
+ if($phase.Name -ceq 'TokenRightOnly' -and ($attributedEvents.Count -ne 2 -or @($attributedEvents|Where-Object Direction -CEQ Disable).Count -ne 1 -or @($attributedEvents|Where-Object Direction -CEQ Restore).Count -ne 1)){throw 'TokenRight-only requires exactly one actual disable and one restore4703.'}
+ if($phase.Name -ceq 'AuthorizationOnly' -and $attributedEvents.Count -ne 0){throw 'Inverse phase produced an unexpected attributable event; do not generalize the mapping.'}
if((Key (Get-WelaEffectiveAuditPolicy)) -cne (Key $prepared)){throw 'Prepared audit policy drifted during observation.'}
$summaries+=@([pscustomobject]@{Phase=$phase.Name;CandidateCount=$candidates.Count;AttributedCount=$attributedEvents.Count;ReadComplete=$true;AdjustedAndRestored=($result.Outcome.Status -eq 'Adjusted' -and $result.Outcome.Restored)})
Save 'summary.json' $summaries
@@ -106,12 +142,17 @@ try{
}catch{$failure=$_.ToString();throw}finally{
foreach($restoreGuid in @($guid,$auth)){try{Set-WelaEffectiveAuditPolicy -Guid $restoreGuid -Mask $beforeMasks[$restoreGuid] -Mode exact}catch{$errors+=$_.ToString()}}
try{if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $path -Name $name -Value $beforePrecedence.Value -Type $beforePrecedence.Type}else{Remove-ItemProperty -LiteralPath $path -Name $name -ErrorAction Stop}}catch{$errors+=$_.ToString()}
- $afterToken=$null;$afterPrecedence=$null;$afterMasks=$null;$afterMaskKey=$null
+ $afterToken=$null;$afterPrecedence=$null;$afterMasks=$null;$afterMaskKey=$null;$afterChannel=$null;$afterServices=$null
try{$afterToken=[Wela.WmiProbe.Native]::Snapshot()}catch{$errors+=$_.ToString()}
try{$afterPrecedence=Get-WelaRegistryState $path $name}catch{$errors+=$_.ToString()}
try{$afterMasks=Get-WelaEffectiveAuditPolicy;$afterMaskKey=@($afterMasks.Keys|Sort-Object|ForEach-Object{"$_=$($afterMasks[$_])"}) -join ';'}catch{$errors+=$_.ToString()}
- $complete=$errors.Count -eq 0 -and $afterMaskKey -ceq $masks -and (Key $afterPrecedence) -ceq (Key $beforePrecedence) -and ((Key $beforeToken) -ceq (Key $afterToken))
- Save 'cleanup.json' @{Complete=$complete;Errors=$errors;Failure=$failure;AfterToken=$afterToken;AfterMasks=$afterMasks;AfterPrecedence=$afterPrecedence}
- if(-not $complete){throw 'Native feasibility fixture cleanup failed.'}
+ try{$afterChannel=Channel}catch{$errors+=$_.ToString()}
+ try{$afterServices=Services}catch{$errors+=$_.ToString()}
+ $complete=$afterChannel -ceq $originalChannel -and (Key $afterServices) -ceq (Key $originalServices) -and $errors.Count -eq 0 -and $afterMaskKey -ceq $masks -and (Key $afterPrecedence) -ceq (Key $beforePrecedence) -and ((Key $beforeToken) -ceq (Key $afterToken))
+ foreach($file in $sources.Keys){try{if((Get-FileHash -LiteralPath "$repo/$file" -Algorithm SHA256).Hash.ToLowerInvariant() -cne $sources[$file]){$errors+='Fixture source drift: '+$file;$complete=$false}}catch{$errors+=$_.ToString();$complete=$false}}
+ Save 'cleanup.json' @{Complete=$complete;Errors=$errors;Failure=$failure;AfterToken=$afterToken;AfterMasks=$afterMasks;AfterPrecedence=$afterPrecedence;AfterChannel=$afterChannel;AfterServices=$afterServices}
+ $artifactHashes=@(Get-ChildItem -LiteralPath $root -File -Recurse|Sort-Object FullName|ForEach-Object{[pscustomobject]@{Path=$_.FullName.Substring($root.Length+1).Replace('\','/');Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}})
+ Save 'manifest.json' @{Head=$env:GITHUB_SHA;Status=if($complete -and -not $failure){'Passed'}else{'Failed'};Sources=$sources;Artifacts=$artifactHashes;Fixture='NativeSecurity4703Attribution';EventIds=@(4703);RuntimePolicyPhases=@('TokenRightOnly','AuthorizationOnly');OtherAuditMasksPreserved=57;NoSigmaCredit=$true;NoForwardingCredit=$true;HistoricalCandidatesRemainConditional=$true}
+ if(-not $complete){throw 'Native attribution fixture cleanup failed.'}
}
$global:LASTEXITCODE=0
diff --git a/tests/TokenRightAttributionEvidence.ps1 b/tests/TokenRightAttributionEvidence.ps1
new file mode 100644
index 00000000..443b7dfb
--- /dev/null
+++ b/tests/TokenRightAttributionEvidence.ps1
@@ -0,0 +1,40 @@
+# Test-only strict correlation. This helper never changes WELA scoring or policy.
+function Get-WelaTokenAttributionMatch {
+ param([string]$Text,$Context)
+ if($Text.Length -gt 65536){throw 'Event XML exceeds the fixture bound.'}
+ $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=65536
+ $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Text),$settings);$xml=[Xml.XmlDocument]::new();$xml.XmlResolver=$null
+ try{$xml.Load($reader)}finally{$reader.Dispose()}
+ $ns=[Xml.XmlNamespaceManager]::new($xml.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event')
+ if($xml.DocumentElement.LocalName -cne 'Event' -or $xml.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $xml.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $xml.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1){return $null}
+ $system=$xml.SelectSingleNode('/e:Event/e:System',$ns)
+ foreach($field in @('Provider','EventID','Version','Level','Task','Opcode','Keywords','TimeCreated','EventRecordID','Channel','Computer')){if($system.SelectNodes('e:'+$field,$ns).Count -ne 1){return $null}}
+ $p=$system.SelectSingleNode('e:Provider',$ns)
+ if($p.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $p.GetAttribute('Guid') -ine '{54849625-5478-4994-a5ba-3e3b0328c30d}'){return $null}
+ foreach($pair in @(@('EventID','4703'),@('Version','0'),@('Level','0'),@('Opcode','0'),@('Task',[string]$Context.Task),@('Keywords','0x8020000000000000'),@('Channel','Security'))){if($system.SelectSingleNode('e:'+$pair[0],$ns).InnerText -cne $pair[1]){return $null}}
+ if(@($Context.Computers|Where-Object{$_ -ieq $system.SelectSingleNode('e:Computer',$ns).InnerText}).Count -ne 1){return $null}
+ $record=0L;if(-not[long]::TryParse($system.SelectSingleNode('e:EventRecordID',$ns).InnerText,[ref]$record) -or $record -le $Context.Watermark){return $null}
+ $data=@{};$fields=$xml.SelectNodes('/e:Event/e:EventData/e:Data',$ns)
+ foreach($node in $fields){$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name)){return $null};$data[$name]=$node.InnerText}
+ foreach($field in @('SubjectUserSid','SubjectLogonId','TargetUserSid','TargetLogonId','ProcessName','ProcessId','EnabledPrivilegeList','DisabledPrivilegeList')){if(-not $data.ContainsKey($field)){return $null}}
+ if($data.SubjectUserSid -cne $Context.Sid -or $data.TargetUserSid -cne $Context.Sid -or $data.SubjectLogonId -ine $Context.AuthenticationId -or $data.TargetLogonId -ine $Context.AuthenticationId -or $data.ProcessName -ine $Context.ProcessName){return $null}
+ if($data.ProcessId -cnotmatch '^0x[0-9a-fA-F]+$' -or [Convert]::ToUInt64($data.ProcessId.Substring(2),16) -ne [uint64]$Context.ProcessId){return $null}
+ $direction=$null
+ if($data.DisabledPrivilegeList -ceq 'SeDebugPrivilege' -and $data.EnabledPrivilegeList -ceq '-'){$direction='Disable'}
+ if($data.EnabledPrivilegeList -ceq 'SeDebugPrivilege' -and $data.DisabledPrivilegeList -ceq '-'){$direction='Restore'}
+ if(-not $direction){return $null}
+ $textTime=$system.SelectSingleNode('e:TimeCreated',$ns).GetAttribute('SystemTime');if($textTime -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,7})?Z$'){return $null}
+ $time=[DateTime]::Parse($textTime,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind).ToFileTimeUtc()
+ if($time -lt $Context.DisableStartedFileTime -or $time -gt $Context.OperationCompletedFileTime){return $null}
+ [pscustomobject]@{RecordId=$record;Direction=$direction;Utc=$textTime;Data=$data}
+}
+function Assert-WelaTokenAttributionTimes {
+ param($Operation,[long]$Launched,[long]$Observed)
+ $previous=$Launched
+ foreach($name in @('DisableStartedFileTime','DisableReturnedFileTime','RestoreStartedFileTime','RestoreReturnedFileTime','OperationCompletedFileTime')){
+ $value=$Operation.$name
+ if(($value -isnot [long] -and $value -isnot [int]) -or $value -le 0 -or $value -lt $previous -or $value -gt $Observed){throw 'Native operation timestamps must be typed, monotonic and within parent observations.'}
+ $previous=$value
+ }
+ if($Launched -gt $Observed -or ($Observed-$Launched) -gt 950000000){throw 'Parent operation envelope exceeds its bounded worker lifetime.'}
+}
diff --git a/scripts/TokenRightProbeNative.cs b/tests/TokenRightAttributionNative.cs
similarity index 90%
rename from scripts/TokenRightProbeNative.cs
rename to tests/TokenRightAttributionNative.cs
index 3807d522..c69a25de 100644
--- a/scripts/TokenRightProbeNative.cs
+++ b/tests/TokenRightAttributionNative.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
+using System.Text;
namespace Wela.TokenRightProbe {
public sealed class Privilege { public string Luid; public uint Attributes; }
@@ -9,7 +10,7 @@ namespace Wela.TokenRightProbe {
public string Status, Diagnostic, Luid;
public bool AdjustmentAttempted, Restored;
public uint OriginalAttributes;
- public long DisableStartedFileTime, DisableReturnedFileTime, RestoreStartedFileTime, RestoreReturnedFileTime;
+ public long DisableStartedFileTime, DisableReturnedFileTime, RestoreStartedFileTime, RestoreReturnedFileTime, OperationCompletedFileTime;
public Privilege[] Before, Disabled, After;
}
public static class Native {
@@ -21,6 +22,7 @@ namespace Wela.TokenRightProbe {
[DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll")] static extern void GetSystemTimePreciseAsFileTime(out long value);
[DllImport("kernel32.dll")] static extern void SetLastError(uint error);
+ [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool QueryFullProcessImageName(IntPtr process,uint flags,StringBuilder path,ref uint length);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr process,uint access,out IntPtr token);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint access,bool self,out IntPtr token);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr token,int kind,IntPtr buffer,int length,out int needed);
@@ -28,6 +30,11 @@ namespace Wela.TokenRightProbe {
[DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool all,ref One value,uint length,IntPtr previous,IntPtr returned);
static string Hex(Luid id) { return "0x"+(((ulong)(uint)id.High<<32)|id.Low).ToString("x"); }
static long Now() { long value; GetSystemTimePreciseAsFileTime(out value); return value; }
+ public static string Executable() {
+ var path=new StringBuilder(32768);uint length=32768;
+ if(!QueryFullProcessImageName(GetCurrentProcess(),0,path,ref length)||length<1||length>=32768)throw new Win32Exception(Marshal.GetLastWin32Error());
+ return path.ToString();
+ }
static void PrimaryOnly() {
IntPtr thread;
if(OpenThreadToken(GetCurrentThread(),8,true,out thread)) { CloseHandle(thread); throw new InvalidOperationException("An impersonation token is not accepted."); }
@@ -79,7 +86,7 @@ namespace Wela.TokenRightProbe {
} finally {
if(result.AdjustmentAttempted) {
result.RestoreStartedFileTime=Now();Change(token,target,result.OriginalAttributes);result.RestoreReturnedFileTime=Now();
- result.After=Read(token);Equal(result.Before,result.After,result.Luid,true);result.Restored=true;
+ result.After=Read(token);Equal(result.Before,result.After,result.Luid,true);result.Restored=true;result.OperationCompletedFileTime=Now();
}
}
} catch(Exception error) {result.Status=result.AdjustmentAttempted?"Unverified":"Refused";result.Diagnostic=error.ToString();}
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 854679ee..8af7cac9 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,6 +7,8 @@
**改善:**
+- Server2022/2025 と PowerShell5.1/7 で、Token Right Adjusted の正規GUIDに対する Security4703 の実機検証を追加しました。所有する子プロセスの既存権限を固定手順で無効化・復元し、候補となる2つの監査マスクを比較して、実イベント・実行条件・ハッシュとポリシー/トークンの復元を記録します。過去の候補は条件付きのまま維持し、製品用プローブ・全OS共通の対応関係・Sigma加点は追加しません。(関連 #380) (@Shirofune-Security)
+
- 明示的な `registry-sacl-recovery` を追加しました。整合する4つの元記録、レビュー済み計画のハッシュ、過去・現在ともに空の子キー観測、監査縮小と継承への個別同意を必須とし、追加が証明されたレジストリルートの明示的な監査ACEを1つだけ削除します。SACLのみのネイティブ書き込みで他の記述子フィールドとACEの順序を保持し、部分的な書き込みの証跡と元の記述子バイトとの一致を別々に報告します。過去のキー・操作者の同一性認証、ツリー全体の原子性、イベント生成、Sigmaの準備完了は保証しません。 (Related #373) (@Shirofune-Security)
- Server 2022/2025とPowerShell 5.1/7でSMBポリシー設定の公開CLIを検証します。対応ホストで6項目の適用・再読取・再実行、非対応ホストのスキップ、元の型付き記録、無関係な設定の維持と完全な復元を確認します。イベント生成と実行時の有効化は別途検証します。(関連 #377) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index 6b533916..d930c924 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,6 +7,8 @@
**Improvements:**
+- Added disposable native Security4703 attribution for the canonical Token Right Adjusted GUID on Server2022/2025 and PowerShell5.1/7. A fixed owned-child privilege disable/restore compares the two historical audit-mask candidates, retains exact event/context/hash evidence and verifies policy/token cleanup. Historical candidates remain conditional; no production probe, universal mapping or Sigma credit. (Related #380) (@Shirofune-Security)
+
- Added opt-in `registry-sacl-recovery` for one proven explicit registry-root audit ACE, requiring four matching original records, a reviewed plan hash, empty historical/current descendants and separate audit-reduction/inheritance consent. Native SACL-only removal preserves unrelated descriptor fields and ACE order, retains partial-write evidence and reports original-byte equality separately; no authenticated historical key/operator identity, atomic-tree, event or Sigma claim. (Related #373) (@Shirofune-Security)
- Add native public SMB policy configuration acceptance on Server 2022/2025 and PowerShell 5.1/7: six-policy apply/readback and idempotence on supported hosts, unsupported-host skips, typed original journals, unrelated-state preservation and exact cleanup. Event generation and runtime activation remain separate. (Related #377) (@Shirofune-Security)