diff --git a/.gitattributes b/.gitattributes index 190bc45a..b55085e0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -68,3 +68,7 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf /scripts/WecState* text eol=lf /scripts/WecRuntime* text eol=lf /tests/WecState* text eol=lf + +# Existing-file read receipts bind identical native/worker source bytes. +/scripts/FileAccessProbe* text eol=lf +/tests/FileAccessProbe* text eol=lf diff --git a/.github/workflows/file-access-probe.yml b/.github/workflows/file-access-probe.yml new file mode 100644 index 00000000..f671f8f9 --- /dev/null +++ b/.github/workflows/file-access-probe.yml @@ -0,0 +1,45 @@ +name: Native one-byte file access probe +on: + push: + branches: ['**'] + paths: + - 'WELA.ps1' + - 'scripts/FileAccessProbe*' + - 'tests/FileAccessProbe*' + - '.github/workflows/file-access-probe.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + file-access-probe: + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + engine: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Public CLI and actual file read in Windows PowerShell 5.1 + if: matrix.engine == 'powershell' + shell: powershell + run: | + ./tests/FileAccessProbe.Tests.ps1 + ./tests/FileAccessProbe.Cli.Tests.ps1 + ./tests/FileAccessProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Public CLI and actual file read in PowerShell 7 + if: matrix.engine == 'pwsh' + shell: pwsh + run: | + ./tests/FileAccessProbe.Tests.ps1 + ./tests/FileAccessProbe.Cli.Tests.ps1 + ./tests/FileAccessProbe.Windows.Tests.ps1 -AllowDisposablePolicyWrite + - name: Retain genuine XML, receipts and exact cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: file-access-${{ matrix.os }}-${{ matrix.engine }} + path: ${{ runner.temp }}/wela-file-access-*/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c8e1c7e..b71c8ed6 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/applocker-script-probe.md, ./docs/applocker-probe.md, ./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 -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/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index f0c026a7..12188cf3 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -4,6 +4,8 @@ **改善:** +- 明示的な`file-access-probe` Plan/Runを追加し、既存のReadData成功監査SACLが適用される通常のローカルファイルから1バイトだけ読み取ります。実装・実行中エンジンの選択をハッシュ処理前に拒否し、同じハンドルのDOS/NTパスと実体、読み取りと実体再確認の実測区間、実際のワーカー・トークン・ハンドル、ポリシー・セキュリティ・実装の一致を確認し、ローカルSecurity4663と永続化した専用の証拠を必要とします。内容は保持せず、ポリシー・ACL・ファイルデータを変更しません。失敗監査・転送・Sigma利用可能性は未検証です。Server 2022/2025と両PowerShellの使い捨てテストで実イベントと正確な復元を検証します。 (関連 #373) (@Shirofune-Security) + - 既存の Script AuditOnly ポリシーに対し、固定の Windows PowerShell5.1 スクリプトを実行する `applocker-script-probe` を追加しました。実行者と子プロセスのログオン、保持したファイル、高精度 UTC とイベント記録境界を確認し、Script8005 の許可と8006 の監査専用ブロック判定を区別します。拒否・上限・重複・状態変化は未検証とし、設定変更や Sigma の評価加算は行いません。使い捨て Windows の4構成で両イベントとポリシー・チャネル・タスクの復元を検証し、保護された AppIDSvc を停止できない場合は明記します。 (関連 #381) (@Shirofune-Security) - 従来の設定コマンドでも、未対応の `-WhatIf` や入力ミスなどの未認識引数を実行前に拒否するようにしました。`-ErrorAction` や `-Verbose` などの PowerShell 共通パラメーターも拒否するため、自動化ラッパーへの影響をヘルプと診断に明記しました。正しい位置指定引数と文書化された `-DryRun` の動作は維持し、Windows PowerShell 5.1 と PowerShell 7 で公開CLIを検証します。 (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc49f295..3f5cee2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ **Improvements:** +- Added explicit `file-access-probe` Plan/Run for one byte read from one existing ordinary local leaf with a matching pre-existing ReadData success SACL. Source/engine targets are refused before hashing. Same-handle DOS/NT identity, exact worker/token/handle attribution over the measured read and identity-readback phase, full policy/security/source guards and durable private evidence require an actual local Security4663. No content is retained and no policy, ACL or file-data changes are made; failure, forwarding and Sigma readiness remain unverified. Disposable Server 2022/2025 tests cover both PowerShell engines and exact cleanup. (Related #373) (@Shirofune-Security) + - Added opt-in `applocker-script-probe` for a fixed native Windows PowerShell5.1 script under an existing Script AuditOnly policy. Actual caller/child logon context, held file bytes, precise UTC and native record boundaries distinguish exact Script8005 allowed and8006 would-block events; denied, capped, ambiguous or drifted runs remain unverified. The disposable four-way Windows suite requires both native decisions and policy/channel/task cleanup, with the protected-AppIDSvc stopping boundary recorded. No configuration changes or Sigma credit. (Related #381) (@Shirofune-Security) - Reject unbound command-line arguments before dispatch, including unsupported `-WhatIf` and misspelled options on legacy configuration commands. PowerShell common parameters such as `-ErrorAction` and `-Verbose` are also rejected; help and diagnostics explain the automation-wrapper compatibility change. Valid positional arguments and documented `-DryRun` behavior are preserved. Public CLI regressions cover both Windows PowerShell 5.1 and PowerShell 7. (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 73b3e570..437fbb7e 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -61,6 +61,10 @@ [string]$WmiProbeNamespace, [string]$WmiProbeOutputPath, [ValidateRange(1,30)][int]$WmiProbeTimeoutSeconds = 15, + [ValidateSet('Plan','Run')][string]$FileProbeAction = 'Plan', + [string]$FileProbePath, + [string]$FileProbeOutputPath, + [ValidateRange(1,30)][int]$FileProbeTimeoutSeconds = 15, [string[]]$WmiNamespace, [switch]$WmiIncludeChildren, [string]$RuleEvidencePath, @@ -217,6 +221,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json" . (Join-Path $ScriptRoot "scripts/AppLockerScriptProbe.ps1") . (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1") . (Join-Path $ScriptRoot "scripts/WmiProbe.ps1") +. (Join-Path $ScriptRoot "scripts/FileAccessProbe.ps1") . (Join-Path $ScriptRoot "scripts/Capi2Probe.ps1") . (Join-Path $ScriptRoot "scripts/FailedLogonProbe.ps1") . (Join-Path $ScriptRoot "scripts/PowerShellTranscription.ps1") @@ -1995,6 +2000,7 @@ Usage: ./WELA.ps1 event-measurement -MeasurementChannel Security -MeasurementAction Run -MeasurementOutputPath C:\Evidence\new-sample -MeasurementExportEvtx ./WELA.ps1 rule-eligibility -RuleEvidencePath reviewed-lab-evidence.json -ResultsPath evidence-review.json ./WELA.ps1 smb-auditing -SmbAction Configure -DryRun + ./WELA.ps1 file-access-probe -Help ./WELA.ps1 transcription-recovery -Help ./WELA.ps1 powershell-transcription -TranscriptionAction Plan -TranscriptDirectory C:\Transcripts -ResultsPath transcription-plan.json ./WELA.ps1 applocker-readiness -ResultsPath applocker.json @@ -2102,6 +2108,8 @@ if ($Cmd -eq 'intune-export' -and @($PSBoundParameters.Keys | Where-Object { $_ throw 'intune-export accepts only Intune target/export options, IncludeOptional and Help. No command was run.' } +if ($Cmd -ne 'file-access-probe' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'FileProbe*'}).Count) {throw 'FileProbe options require file-access-probe.'} +if ($Cmd -eq 'file-access-probe' -and ($args.Count -gt 0 -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','FileProbeAction','FileProbePath','FileProbeOutputPath','FileProbeTimeoutSeconds','Help')}).Count)) {throw 'file-access-probe accepts only its dedicated options.'} if ($Cmd -ne 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Evtx*'}).Count) {throw 'EVTX options require evtx-recovery. No command was run.'} if ($Cmd -eq 'evtx-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','EvtxAction','EvtxProbePath','EvtxArchivePath','EvtxOutputPath','Help')}).Count) {throw 'evtx-recovery accepts only its dedicated options. No command was run.'} if ($Cmd -ne 'transcription-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'TranscriptRecovery*'}).Count) {throw 'TranscriptRecovery options require transcription-recovery.'} @@ -2332,6 +2340,12 @@ switch ($Cmd.ToLower()) { $report if ($report.ExitCode) {exit $report.ExitCode} } + 'file-access-probe' { + if ($Help) {Write-Host 'Usage: file-access-probe [-FileProbeAction Plan] -FileProbePath C:\Audit\existing-file.txt; Run additionally requires -FileProbeOutputPath C:\Evidence\new-probe [-FileProbeTimeoutSeconds 15]. Reads one byte and discards it; event matching uses the measured read plus held-handle identity/security readback phase, with the ReadFile return recorded separately. Source-tree/active-engine targets and aliases are refused before hashing. Existing File System success policy, precedence and matching ReadData SACL are required; no policy, ACL or file-data writes. Local4663 success only, no failure/forwarding/Sigma credit. See docs/file-access-probe.md.';return} + $report=Invoke-WelaFileAccessProbe -Action $FileProbeAction -FilePath $FileProbePath -OutputPath $FileProbeOutputPath -TimeoutSeconds $FileProbeTimeoutSeconds + $report|ConvertTo-Json -Depth 28|Write-Output + exit ([int]$report.ExitCode) + } 'transcription-recovery' { if ($Help) {Write-Host 'Usage: transcription-recovery -TranscriptRecoveryAction Plan -TranscriptRecoveryJournalPath before.jsonl -TranscriptRecoveryOriginalResultsPath results.json -TranscriptRecoveryOutputPath new-directory; Restore uses -TranscriptRecoveryPlanPath, -TranscriptRecoveryPlanHash and new -TranscriptRecoveryOutputPath [-Auto] [-TranscriptRecoveryAllowTemporarySuspension]. DryRun omits output. Temporary suspension can leave machine transcription disabled after an error, drift refusal or process termination; there is no automatic rollback or re-enable. Inspect receipts, current policy and the destination before manual recovery. See docs/transcription-recovery.md.';return} $report=Invoke-WelaTranscriptRecovery -Action $TranscriptRecoveryAction -JournalPath $TranscriptRecoveryJournalPath -OriginalResultsPath $TranscriptRecoveryOriginalResultsPath -PlanPath $TranscriptRecoveryPlanPath -PlanHash $TranscriptRecoveryPlanHash -OutputPath $TranscriptRecoveryOutputPath -AllowTemporarySuspension:$TranscriptRecoveryAllowTemporarySuspension -Auto:$Auto -DryRun:$DryRun diff --git a/docs/file-access-probe.md b/docs/file-access-probe.md new file mode 100644 index 00000000..0398055d --- /dev/null +++ b/docs/file-access-probe.md @@ -0,0 +1,31 @@ +# One-byte local file access probe + +`file-access-probe` checks whether one explicit read of one existing file produces an attributable local Security 4663 event. Plan observes prerequisites without reading file data. Run opens the same selected leaf in a fixed worker, reads exactly one byte once, clears that buffer and retains no file contents. It changes no audit policy, ACL, service, channel setting or file data. A native read can update access metadata and can trigger existing monitoring. + +Run elevated in native 64-bit Windows PowerShell 5.1 or PowerShell 7. Select an ordinary, nonempty file on a fixed local drive using its exact absolute DOS path (at most 240 characters). UNC/device input paths, alternate streams, wildcards, reparse components, multiple hard links, EFS, offline/recall files and directories are refused. Targets inside the canonical WELA source tree, the active PowerShell executable and aliases are refused by an initial metadata-only check before implementation/engine hashing. The token must already hold the security privilege needed to inspect the SACL; observation enables that existing privilege only around handle acquisition and restores its prior state before the data read. No privilege is granted and backup semantics are not used. + +The File System subcategory must already include Success, `SCENoApplyLegacyAuditPolicy` must be typed DWORD 1, and the enabled Security channel must be readable. One existing ordinary success ReadData audit ACE must apply directly to the user SID or an enabled, non-deny-only group. Inherit-only and conditional/callback ACEs cannot establish this prerequisite. EventLog, Winmgmt and RpcSs must already be running. This command does not install a SACL or repair prerequisites. + +```powershell +.\WELA.ps1 file-access-probe -FileProbePath C:\Audit\existing-file.txt +.\WELA.ps1 file-access-probe -FileProbeAction Run ` + -FileProbePath C:\Audit\existing-file.txt ` + -FileProbeOutputPath C:\Evidence\new-file-probe ` + -FileProbeTimeoutSeconds 15 +``` + +Run requires a fresh private evidence directory outside the code tree. Only dedicated options are accepted; no `-Auto`, `-DryRun`, generic `-WhatIf` or extra positional arguments. `FileProbeTimeoutSeconds` accepts 1–30 seconds for polling after the worker; worker execution has a separate 20-second limit. Native query work and cleanup add elapsed time. + +The request binds actual host/build/MachineGuid, engine and implementation hashes, token groups and privileges, all effective audit masks, precedence, Security configuration and full selected-file metadata/security. A held existing-file handle prevents concurrent write/delete opens. Volume/file ID, creation and last-write times, size, attributes, link count and full current SDK security descriptor must agree before and after the operation. Both DOS and NT volume names are observed from that same handle and bound to this identity. Path comparison is case-insensitive; other volume names or paths are not inferred or accepted. Some volumes can emit Removable Storage Task 12812 even when `DriveInfo` reports Fixed, as observed on a hosted runner data volume. This probe accepts only File System Task 12800; those other events remain unverified. + +Before launch, the parent writes and flushes `before.json` and `intent.json`. The worker inherits the existing execution policy without an override; a blocked worker remains unverified with its prior intent retained. The fixed worker independently rebuilds the request state, verifies its primary token, performs one native `ReadFile` call requesting one byte and returns a receipt with exact PID, handle and precise `StartedUtc`, `ReadReturnedUtc` and `CompletedUtc` timestamps. Its fixed `OneByteReadAndHeldIdentityReadback` phase spans the one read and the existing same-handle identity/security readback; the immediate `ReadFile` return remains separately visible. Times must satisfy start <= read return <= phase completion <= parent observation. No sleep or timestamp padding is added. The parent retains `operation.json`, the original matching `event.xml`, `after.json` and their SHA-256 hashes in `manifest.json`. These artifacts contain file paths, SIDs, security descriptors and audit context, but no target contents or target-content hashes. The manifest is not self-hashed. + +Success requires exactly one fresh version-1 Security 4663 from the expected provider, computer, user SID/logon ID, worker PID/executable, native handle, selected DOS or NT path and ReadData mask/access token. Event time must fall within that actual measured read/readback phase, with no padding; it need not fall inside the `ReadFile` call itself. The query stops at 256 events and bounds individual XML size; hitting a cap, missing/duplicate evidence, drift, changed reader context or failed persistence prevents verified success. The final Security record boundary must not move backwards. + +`PrerequisitesObserved` (Plan) and `FileReadObserved` (Run) exit 0. `Unverified` exits 1 and explains the observed gap. A stopped or failed worker may already have attempted the read; durable intent alone does not prove completion. An interrupted process may leave only partial evidence, and a manifest-write failure fails outward while earlier receipts remain. Inspect retained artifacts before deciding whether to run another probe in a different fresh directory. + +This is evidence for that one current-token local ReadData success. It does not prove Failure auditing, other rights/users/files, child inheritance, forwarded delivery, backend parsing, Sigma readiness or general detection coverage. Security 4663 has no Failure variant. No Sigma/EVTX coverage points are added. + +The disposable Windows fixture owns its files in a fresh private system-volume directory and its separate evidence directory, explicitly establishes the test SACL/policy, exercises the public Plan/Run path twice, verifies all artifact hashes and unchanged file content/security, tests missing-SACL/policy and file-replacement refusals, then restores all effective audit masks, typed precedence and token state. It removes only its owned target directory and retains cleanup evidence. Server 2022/2025 and PowerShell 5.1/7 run independently; these fixture changes are not product behavior. + +Microsoft references: [4663 event semantics and fields](https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4663), [ReadFile](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-readfile), and [same-handle DOS/NT path observation](https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew). diff --git a/scripts/FileAccessProbe.ps1 b/scripts/FileAccessProbe.ps1 new file mode 100644 index 00000000..e6bc1ff3 --- /dev/null +++ b/scripts/FileAccessProbe.ps1 @@ -0,0 +1,265 @@ +# Explicit one-byte existing-file read and exact local Security4663 evidence. +# Resolve target scope before reading any external native-helper source or hashing dependencies. +# This small literal helper opens metadata only and never reads target bytes. +function Initialize-WelaFileProbeScopeNative { + if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'The file probe requires native 64-bit Windows.'} + $definition=@' +using System;using System.ComponentModel;using System.Runtime.InteropServices;using System.Text; +namespace Wela.FileAccessScope { + public sealed class Observation {public string Path;public uint Links,Attributes;} + public static class Native { + public const string SourceSha256="__WELA_FILE_SCOPE_SOURCE_SHA256__"; + [StructLayout(LayoutKind.Sequential,Pack=4)] struct Info {public uint Attributes;public long Created,Accessed,Written;public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;} + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern IntPtr CreateFileW(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle,out Info info); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern uint GetFinalPathNameByHandleW(IntPtr handle,StringBuilder path,uint length,uint flags); + public static Observation Observe(string path) { + IntPtr handle=CreateFileW(path,0x80,7,IntPtr.Zero,3,0x00200000,IntPtr.Zero); + if(handle==new IntPtr(-1))throw new Win32Exception(Marshal.GetLastWin32Error(),"Metadata-only target-scope observation failed."); + try{Info info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error()); + StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandleW(handle,final,(uint)final.Capacity,0); + if(length==0||length>=final.Capacity||!final.ToString().StartsWith(@"\\?\",StringComparison.Ordinal))throw new InvalidOperationException("Canonical local target scope is unknown."); + return new Observation{Path=final.ToString().Substring(4),Links=info.Links,Attributes=info.Attributes}; + }finally{CloseHandle(handle);} + } + } +} +'@ + $hash=Get-WelaArrivalHash ([Text.UTF8Encoding]::new($false).GetBytes($definition)) + if(-not ('Wela.FileAccessScope.Native' -as [type])){Add-Type -TypeDefinition $definition.Replace('__WELA_FILE_SCOPE_SOURCE_SHA256__',$hash) -ErrorAction Stop} + if([Wela.FileAccessScope.Native]::SourceSha256 -cne $hash){throw 'Loaded file scope helper differs; start a fresh session.'} +} +function Assert-WelaFileProbeScopeObservation { + param([string]$SelectedPath,$Selected,$SourceFile,$Engine) + foreach($item in @($Selected,$SourceFile,$Engine)){if($item.Path -isnot [string] -or -not $item.Path){throw 'Incomplete canonical target scope.'};Assert-WelaFileProbePath $item.Path} + $sourceRoot=$SourceFile.Path.Substring(0,$SourceFile.Path.LastIndexOf('\')+1) + if($Selected.Path.StartsWith($sourceRoot,[StringComparison]::OrdinalIgnoreCase)){throw 'Select a file outside the WELA source tree; implementation targets are unsupported.'} + if($Selected.Path.Equals($Engine.Path,[StringComparison]::OrdinalIgnoreCase)){throw 'The active PowerShell engine cannot be the selected file target.'} + if($Selected.Path -ine $SelectedPath -or -not(Test-WelaFileProbeInteger $Selected.Links) -or $Selected.Links -ne 1 -or -not(Test-WelaFileProbeInteger $Selected.Attributes) -or ($Selected.Attributes -band 1040)){throw 'Only ordinary canonical single-link file targets are supported; aliases and reparse targets are refused.'} +} +function Assert-WelaFileProbeTargetScope { + param([string]$Path) + Initialize-WelaFileProbeScopeNative + $selected=[Wela.FileAccessScope.Native]::Observe($Path) + $source=[Wela.FileAccessScope.Native]::Observe((Join-Path $script:ScriptRoot 'WELA.ps1')) + $engine=[Wela.FileAccessScope.Native]::Observe((Get-Process -Id $PID -ErrorAction Stop).Path) + Assert-WelaFileProbeScopeObservation $Path $selected $source $engine +} +function Initialize-WelaFileProbeNative { + Initialize-WelaWmiProbeNative + $source=Join-Path $PSScriptRoot 'FileAccessProbeNative.cs';$bytes=[IO.File]::ReadAllBytes($source);$hash=Get-WelaArrivalHash $bytes + if(-not ('Wela.FileAccessProbe.FileHandle' -as [type])){ + $definition=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).Replace('__WELA_FILE_PROBE_SOURCE_SHA256__',$hash) + Add-Type -TypeDefinition $definition -ErrorAction Stop + } + if([Wela.FileAccessProbe.FileHandle]::SourceSha256 -cne $hash){throw 'Loaded file probe helper differs from its source; start a fresh session.'} +} +function Test-WelaFileProbeInteger {param($Value) ($Value -is [int] -or $Value -is [long] -or $Value -is [uint32] -or $Value -is [uint64])} +function Assert-WelaFileProbePath { + param([string]$Path) + if(-not $Path -or $Path.Length -gt 240 -or $Path -cnotmatch '^[A-Za-z]:\\' -or $Path.Substring(2).Contains(':') -or $Path -match '["*?<>|/\x00-\x1f]|(^|\\)\.\.?($|\\)|[ .](\\|$)|\\$|\\\\'){throw 'Select one exact ordinary absolute local leaf file, at most 240 characters; links, streams, wildcards and remote paths are unsupported.'} +} +function Get-WelaFileProbeSources { + $sources=[ordered]@{} + foreach($name in @('WELA.ps1','scripts/FileAccessProbe.ps1','scripts/FileAccessProbeWorker.ps1','scripts/FileAccessProbeNative.cs','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/ChannelRead.ps1','scripts/ChannelReadNative.cs','scripts/WefArrival.ps1','scripts/Configuration.ps1','scripts/CustomAuditProfiles.ps1','scripts/IpsecPrerequisites.ps1','modules/AuditProfiles.psm1','config/audit_profiles.json')) { + $sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot ('../'+$name)) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + [pscustomobject]$sources +} +function Get-WelaFileProbeKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 24 -Compress} +function Get-WelaFileProbeTokenKey { + param($Token) + foreach($name in @('Sid','Name','AuthenticationId','AuthenticationType','ImpersonationLevel','TokenSource')){if($Token.$name -isnot [string]){throw 'Incomplete typed file-reader token.'}} + if($Token.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or $Token.AuthenticationId -cnotmatch '^0x[0-9a-f]+$' -or $Token.TokenSource -cne 'Process' -or $Token.Groups -isnot [array] -or -not $Token.Groups.Count -or $Token.Privileges -isnot [array]){throw 'An ordinary native primary-token file reader is required.'} + foreach($group in $Token.Groups){if($group.Sid -isnot [string] -or $group.Sid -cnotmatch '^S-1-\d+(-\d+)+$' -or -not(Test-WelaFileProbeInteger $group.Attributes)){throw 'Incomplete typed file-reader group.'}} + foreach($privilege in $Token.Privileges){if($privilege.Luid -isnot [string] -or $privilege.Luid -cnotmatch '^0x[0-9a-f]+$' -or -not(Test-WelaFileProbeInteger $privilege.Attributes)){throw 'Incomplete typed file-reader privilege.'}} + Get-WelaFileProbeKey $Token +} +function Get-WelaFileProbeReaderKey { + param($Reader,[switch]$AuthorizationOnly) + foreach($name in @('UserSid','UserName','AuthenticationId','TokenId','ModifiedId','TokenType','Impersonation')){if($Reader.$name -isnot [string]){throw 'Incomplete typed reader observation.'}} + if($Reader.TokenType -cne 'Primary' -or $Reader.Impersonation -cne 'Absent' -or $Reader.ElevatedAdministrator -isnot [bool] -or -not $Reader.ElevatedAdministrator){throw 'An elevated primary-token reader with no impersonation is required.'} + if($AuthorizationOnly){Get-WelaFileProbeKey ($Reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation)} + else{Get-WelaFileProbeKey $Reader} +} +function Assert-WelaFileProbeSnapshot { + param($Snapshot) + foreach($name in @('Path','NativePath','Identity','DescriptorBase64','StateKey')){if($Snapshot.$name -isnot [string] -or -not $Snapshot.$name){throw 'Incomplete typed file observation.'}} + Assert-WelaFileProbePath $Snapshot.Path + if($Snapshot.NativePath -cnotmatch '^\\Device\\[^\\]+\\'){throw 'Incomplete native NT file path observation.'} + if($Snapshot.StateKey -cnotmatch '^[a-f0-9]{64}$' -or -not(Test-WelaFileProbeInteger $Snapshot.Size) -or $Snapshot.Size -le 0 -or -not(Test-WelaFileProbeInteger $Snapshot.SecurityInformation) -or $Snapshot.SecurityInformation -ne 511 -or -not(Test-WelaFileProbeInteger $Snapshot.Links) -or $Snapshot.Links -ne 1 -or -not(Test-WelaFileProbeInteger $Snapshot.Attributes) -or ($Snapshot.Attributes -band (16+1024+4096+16384+262144+4194304))){throw 'Only a complete nonempty ordinary single-link leaf-file observation is supported.'} + $Snapshot.LastWriteUtc=(ConvertTo-WelaArrivalUtc $Snapshot.LastWriteUtc).UtcDateTime.ToString('o') + if($Snapshot.Aces -isnot [array] -or $Snapshot.Aces.Count -gt 128){throw 'Missing or oversized file audit ACE inventory.'} + foreach($ace in $Snapshot.Aces){ + if($ace.Ordinary -isnot [bool] -or -not(Test-WelaFileProbeInteger $ace.Type) -or -not(Test-WelaFileProbeInteger $ace.Flags) -or -not(Test-WelaFileProbeInteger $ace.Mask) -or $ace.Binary -isnot [string]){throw 'Incomplete typed file audit ACE.'} + if($ace.Ordinary -and ($ace.Type -ne 2 -or $ace.Sid -isnot [string] -or $ace.Sid -cnotmatch '^S-1-\d+(-\d+)+$')){throw 'Incomplete ordinary file audit ACE.'} + } +} +function Get-WelaFileProbeSnapshot { + param([string]$Path) + Assert-WelaFileProbePath $Path;Initialize-WelaFileProbeNative + $handle=[Wela.FileAccessProbe.FileHandle]::new($Path,$false) + try{$handle.Observe()}finally{$handle.Dispose()} +} +function Get-WelaFileProbeState { + param([string]$Path) + Assert-WelaFileProbePath $Path;Assert-WelaFileProbeTargetScope $Path;Initialize-WelaFileProbeNative + $services=@(Get-Service -Name EventLog,Winmgmt,RpcSs -ErrorAction Stop|Sort-Object Name|ForEach-Object {[pscustomobject]@{Name=$_.Name;Status=[string]$_.Status}}) + if($services.Count -ne 3 -or @($services|Where-Object Status -ne 'Running').Count){throw 'EventLog, Winmgmt and RpcSs must already be running.'} + $null=Get-WelaFileProbeReaderKey (Get-WelaChannelReader) + $tokenBefore=[Wela.WmiProbe.Native]::Snapshot();$snapshot=Get-WelaFileProbeSnapshot $Path + $hostState=Get-WelaChannelReadHost + $channel=[Diagnostics.Eventing.Reader.EventLogConfiguration]::new('Security') + try{$log=[pscustomobject]@{Name=$channel.LogName;Enabled=$channel.IsEnabled;SecurityDescriptor=$channel.SecurityDescriptor;MaximumSize=$channel.MaximumSizeInBytes;Mode=[string]$channel.LogMode;Type=[string]$channel.LogType;Provider=$channel.OwningProviderName}}finally{$channel.Dispose()} + $policy=Get-WelaEffectiveAuditPolicy;$masks=[ordered]@{};foreach($guid in @($policy.Keys|Sort-Object)){$masks[$guid]=$policy[$guid]} + $engine=(Get-Process -Id $PID -ErrorAction Stop).Path + $reader=Get-WelaChannelReader;$token=[Wela.WmiProbe.Native]::Snapshot() + if((Get-WelaFileProbeTokenKey $tokenBefore) -cne (Get-WelaFileProbeTokenKey $token)){throw 'File prerequisite observation changed token groups or privileges.'} + [pscustomobject][ordered]@{Computer=[Environment]::MachineName;Host=$hostState;MachineGuid=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid;Services=$services;Reader=($reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation);Token=$token;File=$snapshot;AuditPolicies=[pscustomobject]$masks;Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Channel=$log;Engine=$engine;EngineHash=(Get-FileHash -LiteralPath $engine -Algorithm SHA256).Hash.ToLowerInvariant();Sources=(Get-WelaFileProbeSources)} +} +function Get-WelaFileProbeStateKey { + param($State) + Assert-WelaFileProbeSnapshot $State.File;$null=Get-WelaFileProbeTokenKey $State.Token + if($State.Computer -isnot [string] -or -not $State.Computer -or $State.MachineGuid -isnot [string] -or $State.MachineGuid -cnotmatch '^[a-fA-F0-9]{8}(-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$' -or -not(Test-WelaFileProbeInteger $State.Host.ProductType) -or $State.Host.ProductType -notin @(1,2,3) -or -not(Test-WelaFileProbeInteger $State.Host.Build) -or $State.Host.Build -notin @(22000,22621,22631,20348,26100,26200) -or $State.Host.DomainJoined -isnot [bool]){throw 'Complete actual supported Windows host identity is required.'} + if($State.Services -isnot [array] -or $State.Services.Count -ne 3 -or (@($State.Services.Name)-join ',') -cne 'EventLog,RpcSs,Winmgmt'){throw 'Complete native service observations are required.'} + foreach($service in $State.Services){if($service.Status -isnot [string] -or $service.Status -cne 'Running'){throw 'Required services must already be running.'}} + $mask=$State.AuditPolicies.'0CCE921D-69AE-11D9-BED3-505054503030' + if(-not(Test-WelaFileProbeInteger $mask) -or $mask -notin @(1,3) -or $State.Precedence.ValueExists -isnot [bool] -or -not $State.Precedence.ValueExists -or $State.Precedence.Type -isnot [string] -or $State.Precedence.Type -cne 'DWord' -or -not(Test-WelaFileProbeInteger $State.Precedence.Value) -or $State.Precedence.Value -ne 1){throw 'File System success auditing and typed audit precedence DWORD1 must already be configured.'} + if($State.Channel.Name -isnot [string] -or $State.Channel.Name -cne 'Security' -or $State.Channel.Enabled -isnot [bool] -or -not $State.Channel.Enabled -or $State.Channel.SecurityDescriptor -isnot [string] -or -not $State.Channel.SecurityDescriptor){throw 'The Security channel must already be enabled with readable configuration.'} + if($State.Reader.TokenType -isnot [string] -or $State.Reader.TokenType -cne 'Primary' -or $State.Reader.Impersonation -isnot [string] -or $State.Reader.Impersonation -cne 'Absent' -or $State.Reader.ElevatedAdministrator -isnot [bool] -or -not $State.Reader.ElevatedAdministrator -or $State.Reader.UserSid -isnot [string] -or $State.Reader.UserSid -cne $State.Token.Sid){throw 'Complete elevated primary-token reader identity is required.'} + $sids=@($State.Token.Sid)+@($State.Token.Groups|Where-Object {($_.Attributes -band 4) -and -not($_.Attributes -band 16)}|ForEach-Object Sid) + $matches=@($State.File.Aces|Where-Object {$_.Ordinary -and $_.Type -eq 2 -and ($_.Flags -band 64) -and -not($_.Flags -band 8) -and ($_.Mask -band 1) -and $_.Sid -in $sids}) + if(-not $matches.Count){throw 'No existing ordinary success ReadData audit ACE matches this token on the selected file; no SACL is added.'} + foreach($name in @('Engine','EngineHash')){if($State.$name -isnot [string] -or -not $State.$name){throw 'Missing native engine identity.'}} + if($State.EngineHash -cnotmatch '^[a-f0-9]{64}$' -or -not @($State.Sources.PSObject.Properties).Count){throw 'Missing implementation fingerprints.'} + foreach($source in $State.Sources.PSObject.Properties){if($source.Value -isnot [string] -or $source.Value -cnotmatch '^[a-f0-9]{64}$'){throw 'Malformed implementation fingerprint.'}} + # Windows paths may change spelling/case while referring to this same native identity. + $State|ConvertTo-Json -Depth 24 -Compress +} +function Get-WelaFileProbeWatermark { + $result=Read-WelaChannelLatest Security + if($result.Status -isnot [string] -or $result.Status -cne 'EventObserved' -or -not(Test-WelaFileProbeInteger $result.Event.RecordId) -or $result.Event.RecordId -lt 1){throw 'A successful native Security query and positive record boundary are required.'} + [long]$result.Event.RecordId +} +function Get-WelaFileProbeOutputKey { + param([string]$Path) + $full=Resolve-WelaArrivalPath $Path;$item=Get-Item -LiteralPath $full -Force -ErrorAction Stop + if(-not $item.PSIsContainer){throw 'Probe output is not a directory.'} + $acl=Get-Acl -LiteralPath $full -ErrorAction Stop + Get-WelaFileProbeKey ([pscustomobject]@{Path=$item.FullName;CreatedUtc=$item.CreationTimeUtc.ToString('o');Attributes=[int]$item.Attributes;Security=$acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access -bor [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Group)}) +} +function Write-WelaFileProbeArtifact { + param([string]$Root,[string]$OutputKey,[string]$Name,[string]$Text) + if((Get-WelaFileProbeOutputKey $Root) -cne $OutputKey){throw 'Private probe output directory changed.'} + $bytes=[Text.UTF8Encoding]::new($false).GetBytes($Text);$path=Join-Path $Root $Name + $stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)}finally{$stream.Dispose()} + $hash=Get-WelaArrivalHash $bytes + if((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $hash){throw 'Saved probe evidence hash differs.'} + [pscustomobject]@{Name=$Name;Sha256=$hash;Bytes=$bytes.Length} +} +function Assert-WelaFileProbeOperation { + param($Operation,$State,[string]$Nonce,[int]$ProcessId,[DateTimeOffset]$LaunchedUtc,[DateTimeOffset]$ObservedUtc) + foreach($name in @('Kind','Nonce','Executable','FilePath')){if($Operation.$name -isnot [string]){throw 'Untyped fixed file worker authority.'}} + if($Operation.Kind -cne 'WelaOneByteFileRead' -or $Operation.Nonce -cne $Nonce -or -not(Test-WelaFileProbeInteger $Operation.ProcessId) -or $Operation.ProcessId -ne $ProcessId -or $Operation.Executable -ine $State.Engine -or $Operation.FilePath -ine $State.File.Path){throw 'Unexpected fixed file worker identity.'} + $read=$Operation.Read + foreach($name in @('Clock','Phase','HandleId','BeforeKey','AfterKey')){if($read.$name -isnot [string]){throw 'Untyped native read receipt.'}} + if($read.Phase -cne 'OneByteReadAndHeldIdentityReadback' -or $read.Clock -cne 'GetSystemTimePreciseAsFileTime' -or $read.Succeeded -isnot [bool] -or -not $read.Succeeded -or -not(Test-WelaFileProbeInteger $read.ReadCalls) -or $read.ReadCalls -ne 1 -or -not(Test-WelaFileProbeInteger $read.BytesRead) -or $read.BytesRead -ne 1 -or $read.HandleId -cnotmatch '^0x[0-9a-f]+$' -or [Convert]::ToUInt64($read.HandleId.Substring(2),16) -eq 0 -or $read.BeforeKey -cne $State.File.StateKey -or $read.AfterKey -cne $State.File.StateKey){throw 'Expected exactly one successful byte read from the unchanged held file.'} + $start=ConvertTo-WelaArrivalUtc $read.StartedUtc;$returned=ConvertTo-WelaArrivalUtc $read.ReadReturnedUtc;$end=ConvertTo-WelaArrivalUtc $read.CompletedUtc + if($LaunchedUtc -gt $ObservedUtc -or $start -lt $LaunchedUtc -or $returned -lt $start -or $end -lt $returned -or $end -gt $ObservedUtc -or ($end-$start).TotalSeconds -gt 20){throw 'Invalid precise one-byte/readback operation interval.'} + if((Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $Operation.AfterToken) -or (Get-WelaFileProbeTokenKey $Operation.BeforeToken) -cne (Get-WelaFileProbeTokenKey $State.Token) -or + (Get-WelaFileProbeReaderKey $Operation.BeforeReader) -cne (Get-WelaFileProbeReaderKey $Operation.AfterReader) -or (Get-WelaFileProbeReaderKey $Operation.BeforeReader -AuthorizationOnly) -cne (Get-WelaFileProbeKey $State.Reader)){throw 'Worker primary token differs from the caller or changed during the native read.'} + $read.StartedUtc=$start.UtcDateTime.ToString('o');$read.ReadReturnedUtc=$returned.UtcDateTime.ToString('o');$read.CompletedUtc=$end.UtcDateTime.ToString('o') +} +function Start-WelaFileProbeRead { + param($State,[string]$RequestPath,[string]$Nonce) + $fresh=Get-WelaFileProbeState $State.File.Path + if((Get-WelaFileProbeStateKey $fresh) -cne (Get-WelaFileProbeStateKey $State)){throw 'File probe prerequisites drifted before worker launch.'} + $watermark=Get-WelaFileProbeWatermark;$worker=Join-Path $PSScriptRoot 'FileAccessProbeWorker.ps1' + $info=[Diagnostics.ProcessStartInfo]::new();$info.FileName=$State.Engine;$info.Arguments='-NoLogo -NoProfile -NonInteractive -File "'+$worker+'" -RequestPath "'+$RequestPath+'" -Nonce '+$Nonce + $info.UseShellExecute=$false;$info.CreateNoWindow=$true;$info.RedirectStandardOutput=$true;$info.RedirectStandardError=$true + $info.StandardOutputEncoding=[Text.UTF8Encoding]::new($false,$true);$info.StandardErrorEncoding=$info.StandardOutputEncoding;$process=$null + try { + $launch=[DateTimeOffset][Wela.FileAccessProbe.FileHandle]::UtcNow();$process=[Diagnostics.Process]::Start($info) + $stdout=$process.StandardOutput.ReadToEndAsync();$stderr=$process.StandardError.ReadToEndAsync() + if(-not $process.WaitForExit(20000)){$process.Kill();$null=$process.WaitForExit(1000);throw 'File worker exceeded twenty seconds; the read may have been attempted.'} + if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout,$stderr),1000)){throw 'File worker output did not complete.'} + if($stdout.Result.Length -gt 1048576 -or $stderr.Result.Length -gt 65536){throw 'File worker output exceeded its evidence bound.'} + if($process.ExitCode -ne 0 -or $stderr.Result){throw ('Fixed file worker failed: '+$stderr.Result)} + $operation=ConvertFrom-WelaArrivalJson $stdout.Result + Assert-WelaFileProbeOperation $operation $State $Nonce $process.Id $launch ([DateTimeOffset][Wela.FileAccessProbe.FileHandle]::UtcNow()) + $operation|Add-Member NoteProperty RecordIdBefore $watermark + $operation + }finally{if($process){try{if(-not $process.HasExited){$process.Kill();$null=$process.WaitForExit(1000)}}finally{$process.Dispose()}}} +} +function Read-WelaFileProbeEvents { + param($Operation) + # Keep out-of-interval candidates for diagnosis; the matcher never credits them. + $query="*[System[Provider[@Name='Microsoft-Windows-Security-Auditing'] and EventID=4663 and EventRecordID>$($Operation.RecordIdBefore)]]" + $reader=$null;$records=New-Object 'System.Collections.Generic.List[string]' + try { + $q=[Diagnostics.Eventing.Reader.EventLogQuery]::new('Security',[Diagnostics.Eventing.Reader.PathType]::LogName,$query);$q.TolerateQueryErrors=$false + $reader=[Diagnostics.Eventing.Reader.EventLogReader]::new($q);$reader.BatchSize=16 + while($records.Count -lt 256){$event=$reader.ReadEvent([TimeSpan]::FromSeconds(1));if($null -eq $event){break};try{$xml=$event.ToXml();if($xml.Length -gt 131072){throw 'Security event exceeds the XML bound.'};$records.Add($xml)}finally{$event.Dispose()}} + $status=@($reader.LogStatus|ForEach-Object {[pscustomobject]@{LogName=$_.LogName;StatusCode=$_.StatusCode}});Assert-WelaChannelQueryStatus Security $status + [pscustomobject]@{Xml=@($records.ToArray());Capped=($records.Count -ge 256);Query=$query;MaximumEvents=256;LogStatus=$status} + }finally{if($reader){$reader.Dispose()}} +} +function Test-WelaFileProbeEvent { + param([string]$Xml,$Operation,$State) + $reader=$null + try { + if($Xml.Length -gt 131072){return $false} + $settings=[Xml.XmlReaderSettings]::new();$settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=131072 + $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Xml),$settings);$doc=[Xml.XmlDocument]::new();$doc.XmlResolver=$null;$doc.Load($reader) + $ns=[Xml.XmlNamespaceManager]::new($doc.NameTable);$ns.AddNamespace('e','http://schemas.microsoft.com/win/2004/08/events/event') + if($doc.DocumentElement.LocalName -cne 'Event' -or $doc.DocumentElement.NamespaceURI -cne $ns.LookupNamespace('e') -or $doc.SelectNodes('/e:Event/e:System',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:EventData',$ns).Count -ne 1 -or $doc.SelectNodes('/e:Event/e:UserData',$ns).Count){return $false} + $system=@{};foreach($name in @('Provider','EventID','Version','Keywords','EventRecordID','Channel','Computer','TimeCreated','Level','Task','Opcode')){$nodes=$doc.SelectNodes("/e:Event/e:System/e:$name",$ns);if($nodes.Count -ne 1){return $false};$system[$name]=$nodes[0]} + if($system.Provider.GetAttribute('Name') -cne 'Microsoft-Windows-Security-Auditing' -or $system.Provider.GetAttribute('Guid').Trim('{}') -ine '54849625-5478-4994-a5ba-3e3b0328c30d' -or $system.EventID.InnerText -cne '4663' -or $system.Version.InnerText -cne '1' -or $system.Keywords.InnerText -ine '0x8020000000000000' -or $system.Channel.InnerText -cne 'Security' -or $system.Level.InnerText -cne '0' -or $system.Task.InnerText -cne '12800' -or $system.Opcode.InnerText -cne '0' -or $system.EventRecordID.InnerText -cnotmatch '^[1-9][0-9]*$' -or [long]$system.EventRecordID.InnerText -le $Operation.RecordIdBefore){return $false} + $computers=@($State.Computer);if($State.Host.DomainJoined){$computers+=$State.Computer+'.'+$State.Host.Domain};if($system.Computer.InnerText -notin $computers){return $false} + $time=ConvertTo-WelaArrivalUtc $system.TimeCreated.GetAttribute('SystemTime');if($time -lt (ConvertTo-WelaArrivalUtc $Operation.Read.StartedUtc) -or $time -gt (ConvertTo-WelaArrivalUtc $Operation.Read.CompletedUtc)){return $false} + $data=@{};foreach($node in $doc.SelectSingleNode('/e:Event/e:EventData',$ns).ChildNodes){if($node.NodeType -eq 'Whitespace'){continue};if($node.NodeType -ne 'Element' -or $node.LocalName -cne 'Data' -or $node.NamespaceURI -cne $ns.LookupNamespace('e')){return $false};$name=$node.GetAttribute('Name');if(-not $name -or $data.ContainsKey($name) -or @($node.ChildNodes|Where-Object NodeType -eq Element).Count){return $false};$data[$name]=$node.InnerText} + foreach($name in @('SubjectUserSid','SubjectUserName','SubjectDomainName','SubjectLogonId','ObjectServer','ObjectType','ObjectName','HandleId','AccessList','AccessMask','ProcessId','ProcessName','ResourceAttributes')){if(-not $data.ContainsKey($name)){return $false}} + if($data.Count -ne 13 -or $data.ObjectServer -cne 'Security' -or $data.ObjectType -cne 'File' -or ($data.ObjectName -ine $State.File.Path -and $data.ObjectName -ine $State.File.NativePath) -or $data.ProcessName -ine $State.Engine -or $data.SubjectUserSid -cne $Operation.BeforeToken.Sid -or $data.AccessList.Trim() -cne '%%4416'){return $false} + foreach($name in @('SubjectLogonId','AccessMask','ProcessId','HandleId')){if($data[$name] -cnotmatch '^0x[0-9a-fA-F]+$'){return $false}} + if([Convert]::ToUInt64($data.SubjectLogonId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.BeforeToken.AuthenticationId.Substring(2),16) -or [Convert]::ToUInt64($data.AccessMask.Substring(2),16) -ne 1 -or [Convert]::ToUInt64($data.ProcessId.Substring(2),16) -ne $Operation.ProcessId -or [Convert]::ToUInt64($data.HandleId.Substring(2),16) -ne [Convert]::ToUInt64($Operation.Read.HandleId.Substring(2),16)){return $false} + $true + }catch{$false}finally{if($reader){$reader.Dispose()}} +} +function Invoke-WelaFileAccessProbe { + param([ValidateSet('Plan','Run')][string]$Action='Plan',[string]$FilePath,[string]$OutputPath,[ValidateRange(1,30)][int]$TimeoutSeconds=15) + Assert-WelaFileProbePath $FilePath + if(($Action -eq 'Run') -ne (-not [string]::IsNullOrWhiteSpace($OutputPath))){throw 'Run requires a new FileProbeOutputPath; Plan creates no output.'} + $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileAccessProbe';Action=$Action;Status='Unverified';ExitCode=1;RecordedUtc=[datetime]::UtcNow.ToString('o');Before=$null;After=$null;Operation=$null;Candidates=0;Matches=0;Query=$null;Artifacts=@();Diagnostic='';OutputPath=$null;ConfigurationChanges=0;FileDataWrites=0;RetainedContentBytes=0;SigmaEvtxCredit=0;Scope='One current-token local file ReadData success only; failure access, other files/rights/users, inheritance, forwarding and Sigma are unverified. Reads may update native access metadata.'} + $outputKey=$null;$beforeKey=$null + try { + if($Action -eq 'Run'){$report.OutputPath=New-WelaArrivalOutput $OutputPath $script:ScriptRoot;$outputKey=Get-WelaFileProbeOutputKey $report.OutputPath} + $before=Get-WelaFileProbeState $FilePath;$beforeKey=Get-WelaFileProbeStateKey $before;$report.Before=$before + if($Action -eq 'Plan'){$report.After=$before;$report.Status='PrerequisitesObserved';$report.ExitCode=0;return $report} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'before.json' ($before|ConvertTo-Json -Depth 24) + $nonce=[guid]::NewGuid().ToString('N');$intent=[pscustomobject]@{Kind='WelaOneByteFileReadIntent';Nonce=$nonce;Path=$before.File.Path;StateKey=$before.File.StateKey;ExpectedBytes=1;Outcome='Pending; interruption may leave an attempted read without a completion receipt.'} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'intent.json' ($intent|ConvertTo-Json -Depth 8) + $operation=Start-WelaFileProbeRead $before (Join-Path $report.OutputPath 'before.json') $nonce;$report.Operation=$operation + if((Get-FileHash -LiteralPath (Join-Path $report.OutputPath 'before.json')).Hash.ToLowerInvariant() -cne $report.Artifacts[0].Sha256){throw 'Worker request evidence changed.'} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'operation.json' ($operation|ConvertTo-Json -Depth 16) + $timer=[Diagnostics.Stopwatch]::StartNew();$matches=@() + do{$batch=Read-WelaFileProbeEvents $operation;$report.Candidates=@($batch.Xml).Count;$report.Query=$batch.Query + if($batch.Capped -isnot [bool] -or $batch.Capped){throw 'Security query reached its 256-event cap or completeness is unknown.'} + $matches=@($batch.Xml|Where-Object {Test-WelaFileProbeEvent $_ $operation $before});if($matches.Count){break};Start-Sleep -Milliseconds 250 + }while($timer.Elapsed.TotalSeconds -lt $TimeoutSeconds) + $report.Matches=$matches.Count + if($matches.Count -ne 1){$i=0;foreach($xml in @($batch.Xml|Select-Object -First 4)){$i++;$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey ('candidate-'+$i+'.xml') $xml};throw 'Exactly one attributable native4663 was not observed in the measured one-byte/readback phase.'} + $report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'event.xml' $matches[0] + if((Get-WelaFileProbeWatermark) -lt $operation.RecordIdBefore){throw 'Security record boundary moved backwards.'} + $after=Get-WelaFileProbeState $before.File.Path;$report.After=$after + if((Get-WelaFileProbeStateKey $after) -cne $beforeKey){throw 'File identity/security, policy, channel, host, token or implementation changed during the probe.'} + $report.Status='FileReadObserved';$report.ExitCode=0 + }catch{$report.Diagnostic=$_.Exception.Message} + finally{if($report.Before -and -not $report.After){try{$report.After=Get-WelaFileProbeState $report.Before.File.Path}catch{$report.Diagnostic+=' Final observation failed: '+$_.Exception.Message}}} + if($report.OutputPath -and $outputKey){ + if($report.After){$report.Artifacts+=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'after.json' ($report.After|ConvertTo-Json -Depth 24)} + $null=Write-WelaFileProbeArtifact $report.OutputPath $outputKey 'manifest.json' ($report|ConvertTo-Json -Depth 28) + } + $report +} diff --git a/scripts/FileAccessProbeNative.cs b/scripts/FileAccessProbeNative.cs new file mode 100644 index 00000000..c156f0cd --- /dev/null +++ b/scripts/FileAccessProbeNative.cs @@ -0,0 +1,112 @@ +// A held existing local file handle: observe security and read exactly one byte. +// No file creation, data/security writes, backup semantics, or retained contents. +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Text; +namespace Wela.FileAccessProbe { + public sealed class Ace { public int Type,Flags,Mask; public string Sid,Binary; public bool Ordinary; } + public sealed class Observation { + public string Path,NativePath,Identity,LastWriteUtc,DescriptorBase64,StateKey; + public long Size; public uint Attributes,Links; public int SecurityInformation; public Ace[] Aces; + } + public sealed class ReadReceipt { + public string StartedUtc,ReadReturnedUtc,CompletedUtc,Clock,Phase,HandleId,BeforeKey,AfterKey; + public int ReadCalls,BytesRead; public bool Succeeded; + } + sealed class SecurityPrivilege : IDisposable { + [StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;} + [StructLayout(LayoutKind.Sequential)] struct Privileges {public uint Count;public Luid Id;public uint Attributes;} + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [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",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string system,string name,out Luid luid); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token,bool all,ref Privileges requested,uint size,out Privileges previous,out uint required); + IntPtr token;Privileges previous; + public SecurityPrivilege() { + IntPtr thread;if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated file readers are unsupported.");} + int error=Marshal.GetLastWin32Error();if(error!=1008)throw new Win32Exception(error); + if(!OpenProcessToken(GetCurrentProcess(),0x28,out token))throw new Win32Exception(Marshal.GetLastWin32Error()); + try {Luid id;if(!LookupPrivilegeValue(null,"SeSecurityPrivilege",out id))throw new Win32Exception(Marshal.GetLastWin32Error()); + Privileges requested=new Privileges{Count=1,Id=id,Attributes=2};uint needed; + bool ok=AdjustTokenPrivileges(token,false,ref requested,(uint)Marshal.SizeOf(typeof(Privileges)),out previous,out needed); + error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"Existing SeSecurityPrivilege is required to inspect the SACL."); + }catch{CloseHandle(token);token=IntPtr.Zero;throw;} + } + public void Dispose(){if(token==IntPtr.Zero)return;try{Privileges ignored;uint needed;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(Privileges)),out ignored,out needed);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SACL observation privilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}} + } + public sealed class FileHandle : IDisposable { + [StructLayout(LayoutKind.Sequential,Pack=4)] struct FileInfo {public uint Attributes;public long Created,Accessed,Written;public uint Volume,SizeHigh,SizeLow,Links,IndexHigh,IndexLow;} + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern IntPtr CreateFileW(string path,uint access,uint share,IntPtr security,uint disposition,uint flags,IntPtr template); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] static extern uint GetFileType(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetFileInformationByHandle(IntPtr handle,out FileInfo info); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true,ExactSpelling=true)] static extern uint GetFinalPathNameByHandleW(IntPtr handle,StringBuilder path,uint length,uint flags); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool ReadFile(IntPtr handle,[Out]byte[] buffer,uint count,out uint read,IntPtr overlapped); + [DllImport("kernel32.dll",ExactSpelling=true)] static extern void GetSystemTimePreciseAsFileTime(out long value); + [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr memory); + [DllImport("advapi32.dll")] static extern uint GetSecurityInfo(IntPtr handle,uint kind,uint flags,out IntPtr owner,out IntPtr group,out IntPtr dacl,out IntPtr sacl,out IntPtr descriptor); + [DllImport("advapi32.dll")] static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + public const string SourceSha256="__WELA_FILE_PROBE_SOURCE_SHA256__"; + IntPtr handle;readonly bool canRead;bool readAttempted;readonly string selected; + public static DateTime UtcNow(){long value;GetSystemTimePreciseAsFileTime(out value);return DateTime.FromFileTimeUtc(value);} + public FileHandle(string path,bool readData) { + if(String.IsNullOrEmpty(path)||path.Length>240||!System.Text.RegularExpressions.Regex.IsMatch(path,@"^[A-Za-z]:\\"))throw new InvalidOperationException("Select an ordinary absolute local file path, at most 240 characters."); + if(path.Substring(2).IndexOf(':')>=0||path.IndexOfAny(new char[]{'"','*','?','<','>','|','/','\r','\n','\0'})>=0||!String.Equals(Path.GetFullPath(path),path,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Ambiguous file path refused."); + string root=Path.GetPathRoot(path);if(new DriveInfo(root).DriveType!=DriveType.Fixed)throw new InvalidOperationException("Only fixed local drives are supported."); + string part=root;foreach(string name in path.Substring(root.Length).Split('\\')) { + if(name.Length==0||name=="."||name==".."||name.EndsWith(".")||name.EndsWith(" "))throw new InvalidOperationException("Ambiguous file component refused."); + part=Path.Combine(part,name);if((File.GetAttributes(part)&FileAttributes.ReparsePoint)!=0)throw new InvalidOperationException("Reparse components are unsupported."); + } + selected=path;canRead=readData; + try { + // READ_CONTROL + ACCESS_SYSTEM_SECURITY + READ_ATTRIBUTES, optionally READ_DATA. + // Share read only: reject concurrent write/delete handles while this handle is held. + using(new SecurityPrivilege()){handle=CreateFileW(path,0x01020080U|(readData?1U:0U),1,IntPtr.Zero,3,0x00200000,IntPtr.Zero);if(handle==new IntPtr(-1)){handle=IntPtr.Zero;throw new Win32Exception(Marshal.GetLastWin32Error());}} + if(GetFileType(handle)!=1)throw new InvalidOperationException("The selected handle is not a disk file."); + Observe(); + }catch{Dispose();throw;} + } + public Observation Observe() { + if(handle==IntPtr.Zero)throw new ObjectDisposedException("FileHandle"); + FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error()); + // No directories, links, EFS, offline/cloud recall, or empty data streams. + if((info.Attributes&(16U|1024U|4096U|16384U|0x40000U|0x400000U))!=0||info.Links!=1)throw new InvalidOperationException("Only ordinary local leaf files with one link are supported."); + long size=((long)info.SizeHigh<<32)|info.SizeLow;if(size<=0)throw new InvalidOperationException("The selected file must be nonempty."); + StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandleW(handle,final,(uint)final.Capacity,0); + if(length==0||length>=final.Capacity||!String.Equals(final.ToString(),@"\\?\"+selected,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Native final file path differs from the selected local path."); + string actual=final.ToString().Substring(4); + // Bind the NT volume name from this same held handle: Security4663 may use it. + StringBuilder native=new StringBuilder(32768);uint nativeLength=GetFinalPathNameByHandleW(handle,native,(uint)native.Capacity,2); + if(nativeLength==0||nativeLength>=native.Capacity||!System.Text.RegularExpressions.Regex.IsMatch(native.ToString(),@"^\\Device\\[^\\]+\\"))throw new InvalidOperationException("Native NT file path observation failed."); + string nativePath=native.ToString();IntPtr owner,group,dacl,sacl,descriptor; + uint error=GetSecurityInfo(handle,1,0x1ff,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full current SDK descriptor observation (0x1ff) failed."); + byte[] bytes;try{uint count=GetSecurityDescriptorLength(descriptor);if(count<20||count>131072)throw new InvalidOperationException("File descriptor exceeds its observation bound.");bytes=new byte[count];Marshal.Copy(descriptor,bytes,0,(int)count);}finally{LocalFree(descriptor);} + RawSecurityDescriptor sd=new RawSecurityDescriptor(bytes,0);List entries=new List(); + if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){if(entries.Count>=128)throw new InvalidOperationException("File SACL exceeds 128 entries.");CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;byte[] binary=new byte[ace.BinaryLength];ace.GetBinaryForm(binary,0);entries.Add(new Ace{Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Binary=Convert.ToBase64String(binary),Ordinary=ordinary});} + string identity=info.Volume+":"+info.IndexHigh+":"+info.IndexLow+":"+info.Created,encoded=Convert.ToBase64String(bytes),written=DateTime.FromFileTimeUtc(info.Written).ToString("o"); + string value=actual.ToUpperInvariant()+"|"+nativePath.ToUpperInvariant()+"|"+identity+"|"+size+"|"+written+"|"+info.Attributes+"|"+info.Links+"|"+encoded,key; + using(SHA256 sha=SHA256.Create()){key=BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(value))).Replace("-","").ToLowerInvariant();} + return new Observation{Path=actual,NativePath=nativePath,Identity=identity,Size=size,LastWriteUtc=written,Attributes=info.Attributes,Links=info.Links,DescriptorBase64=encoded,SecurityInformation=511,Aces=entries.ToArray(),StateKey=key}; + } + public ReadReceipt ReadOne(string expectedKey) { + if(!canRead||readAttempted)throw new InvalidOperationException("Exactly one explicitly requested data read is permitted."); + Observation before=Observe();if(!String.Equals(before.StateKey,expectedKey,StringComparison.Ordinal))throw new InvalidOperationException("Selected file changed before its one-byte read."); + byte[] buffer=new byte[1];readAttempted=true;uint count=0;DateTime started=UtcNow(),returned;bool success;int error; + try{success=ReadFile(handle,buffer,1,out count,IntPtr.Zero);error=Marshal.GetLastWin32Error();returned=UtcNow();}finally{Array.Clear(buffer,0,buffer.Length);} + if(!success)throw new Win32Exception(error,"The one-byte read failed.");if(count!=1)throw new InvalidOperationException("The fixed read did not return exactly one byte."); + Observation after=Observe();if(after.StateKey!=before.StateKey)throw new InvalidOperationException("Held file identity, data metadata or descriptor changed during the read."); + // Measure the real completed phase, including the existing held-handle identity/security readback. + // Retain the immediate ReadFile return separately; add no delay or timestamp padding. + DateTime completed=UtcNow(); + return new ReadReceipt{StartedUtc=started.ToString("o"),ReadReturnedUtc=returned.ToString("o"),CompletedUtc=completed.ToString("o"),Phase="OneByteReadAndHeldIdentityReadback",Clock="GetSystemTimePreciseAsFileTime",ReadCalls=1,BytesRead=1,Succeeded=true,HandleId="0x"+unchecked((ulong)handle.ToInt64()).ToString("x"),BeforeKey=before.StateKey,AfterKey=after.StateKey}; + } + public void Dispose(){if(handle!=IntPtr.Zero){CloseHandle(handle);handle=IntPtr.Zero;}} + } +} diff --git a/scripts/FileAccessProbeWorker.ps1 b/scripts/FileAccessProbeWorker.ps1 new file mode 100644 index 00000000..fdaeafce --- /dev/null +++ b/scripts/FileAccessProbeWorker.ps1 @@ -0,0 +1,22 @@ +param([Parameter(Mandatory)][string]$RequestPath,[Parameter(Mandatory)][ValidatePattern('^[a-f0-9]{32}$')][string]$Nonce) +$ErrorActionPreference='Stop';[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) +if($args.Count){throw 'Unexpected file worker arguments.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop +foreach($name in @('Configuration','WefArrival','ChannelRead','WmiProbe','FileAccessProbe')){. (Join-Path $PSScriptRoot ($name+'.ps1'))} +Initialize-WelaFileProbeNative +$requestFile=Get-Item -LiteralPath (Resolve-WelaArrivalPath $RequestPath) -ErrorAction Stop +if($requestFile.Length -gt 1048576){throw 'File worker request exceeds one MiB.'} +$state=ConvertFrom-WelaArrivalJson ([IO.File]::ReadAllText($requestFile.FullName,[Text.UTF8Encoding]::new($false,$true))) +$null=Get-WelaFileProbeStateKey $state +$fresh=Get-WelaFileProbeState $state.File.Path +if((Get-WelaFileProbeStateKey $fresh) -cne (Get-WelaFileProbeStateKey $state)){throw 'Host, caller, source, file or audit prerequisites changed before worker access.'} +$handle=[Wela.FileAccessProbe.FileHandle]::new($state.File.Path,$true) +try { + if($handle.Observe().StateKey -cne $state.File.StateKey){throw 'Selected file changed before worker read.'} + $beforeReader=Get-WelaChannelReader;$beforeToken=[Wela.WmiProbe.Native]::Snapshot() + if((Get-WelaFileProbeTokenKey $beforeToken) -cne (Get-WelaFileProbeTokenKey $state.Token) -or (Get-WelaFileProbeReaderKey $beforeReader -AuthorizationOnly) -cne (Get-WelaFileProbeKey $state.Reader)){throw 'Worker does not preserve the expected caller token.'} + $read=$handle.ReadOne($state.File.StateKey) + $afterToken=[Wela.WmiProbe.Native]::Snapshot();$afterReader=Get-WelaChannelReader + [pscustomobject]@{Kind='WelaOneByteFileRead';Nonce=$Nonce;ProcessId=$PID;Executable=(Get-Process -Id $PID).Path;FilePath=$state.File.Path;BeforeReader=$beforeReader;AfterReader=$afterReader;BeforeToken=$beforeToken;AfterToken=$afterToken;Read=$read}|ConvertTo-Json -Depth 16 -Compress +}finally{$handle.Dispose()} diff --git a/tests/AppLockerScriptProbe.Windows.Tests.ps1 b/tests/AppLockerScriptProbe.Windows.Tests.ps1 index 8f0eaef4..46759f35 100644 --- a/tests/AppLockerScriptProbe.Windows.Tests.ps1 +++ b/tests/AppLockerScriptProbe.Windows.Tests.ps1 @@ -130,5 +130,5 @@ if($cleanup.Count){throw ('Native cleanup failed: '+($cleanup -join '; '))} if($primary){throw $primary} $cleanupReceipt=[pscustomobject]@{Head=$env:GITHUB_SHA;Engine=[string]$PSVersionTable.PSVersion;PolicyRestored=$true;ChannelRestored=$true;TaskRestored=$true;ServiceBefore=$before.Service;ServiceAfter=(Get-WelaAppLockerService);ServiceStateRestored=($before.Service.State -ceq (Get-WelaAppLockerService).State);ServiceStartupPreserved=($before.Service.StartMode -ceq (Get-WelaAppLockerService).StartMode);ProtectedServiceBoundary='If AppIDSvc refuses Stop, its running state is left for disposable VM teardown; no full service-state rollback claim.'} [IO.File]::WriteAllText((Join-Path $root 'cleanup.json'),($cleanupReceipt|ConvertTo-Json -Depth 8)) -Write-Host 'Original local/effective GP policy, channel enablement and converter task restored; service startup mode preserved.' +Write-Host 'Original local/effective GP policy, channel enablement and converter task restored; service startup mode preserved.' $global:LASTEXITCODE=0 diff --git a/tests/FileAccessProbe.Cli.Tests.ps1 b/tests/FileAccessProbe.Cli.Tests.ps1 new file mode 100644 index 00000000..38b72896 --- /dev/null +++ b/tests/FileAccessProbe.Cli.Tests.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path +$cases=@( + @{Args=@('file-access-probe','-Help');Code=0;Pattern='Reads one byte and discards it'}, + @{Args=@('file-access-probe','-FileProbeAction','Run','-WhatIf');Code=1;Pattern='dedicated options'}, + @{Args=@('file-access-probe','extra','-Help');Code=1;Pattern='dedicated options'}, + @{Args=@('file-access-probe','-Auto','-Help');Code=1;Pattern='dedicated options'}, + @{Args=@('file-access-probe','-DryRun','-Help');Code=1;Pattern='dedicated options'}, + @{Args=@('help','-FileProbeAction','Run');Code=1;Pattern='require file-access-probe'}, + @{Args=@('file-access-probe','-FileProbePath','\\host\share\file');Code=1;Pattern='exact ordinary'}, + @{Args=@('file-access-probe','-FileProbePath','C:\file.txt','-FileProbeAction','Run');Code=1;Pattern='Run requires'}, + @{Args=@('file-access-probe','-FileProbePath','C:\file.txt','-FileProbeOutputPath','never-created');Code=1;Pattern='Plan creates no output'} +) +foreach($case in $cases){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old};if($code -ne $case.Code -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI refusal failure: $($case.Args -join ' ') => $code / $($output -join ' ')"}} +Write-Host "Passed $($cases.Count) public file-access CLI assertions.";$global:LASTEXITCODE=0 diff --git a/tests/FileAccessProbe.Tests.ps1 b/tests/FileAccessProbe.Tests.ps1 new file mode 100644 index 00000000..7b9257e6 --- /dev/null +++ b/tests/FileAccessProbe.Tests.ps1 @@ -0,0 +1,98 @@ +$ErrorActionPreference='Stop';$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop +foreach($name in @('WefArrival','FileAccessProbe')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +$script:checks=0 +function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++} +function Reject([scriptblock]$Action,[string]$Pattern){$message='';try{& $Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'"} +function Copy-Value($Value){(ConvertFrom-WelaArrivalJson (Get-WelaFileProbeKey ([pscustomobject]@{Data=$Value}))).Data} +function New-Fixture { + $script:token=[pscustomobject]@{Sid='S-1-5-21-1-2-3-1001';Name='FIXTURE\Reader';AuthenticationId='0x1234';AuthenticationType='Negotiate';ImpersonationLevel='None';TokenSource='Process';Groups=@([pscustomobject]@{Sid='S-1-1-0';Attributes=7});Privileges=@([pscustomobject]@{Luid='0x8';Attributes=0})} + $script:reader=[pscustomobject]@{Computer='FIXTURE';ProcessId=1234;UserSid=$script:token.Sid;UserName=$script:token.Name;TokenId='1';AuthenticationId='4660';ModifiedId='2';GroupSids=@('S-1-1-0');GroupCount=1;PrivilegeCount=1;ElevatedAdministrator=$true;TokenType='Primary';Impersonation='Absent'} + $script:state=[pscustomobject]@{Computer='FIXTURE';Host=[pscustomobject]@{ProductType=3;Build=20348;DomainJoined=$false;Domain='WORKGROUP'};MachineGuid='01234567-89ab-cdef-0123-456789abcdef';Services=@([pscustomobject]@{Name='EventLog';Status='Running'},[pscustomobject]@{Name='RpcSs';Status='Running'},[pscustomobject]@{Name='Winmgmt';Status='Running'});Reader=($script:reader|Select-Object UserSid,UserName,AuthenticationId,GroupSids,GroupCount,PrivilegeCount,ElevatedAdministrator,TokenType,Impersonation);Token=(Copy-Value $script:token);File=[pscustomobject]@{Path='C:\Fixture\ReadCase.TxT';NativePath='\Device\HarddiskVolume5\Fixture\ReadCase.TxT';Identity='1:2:3:1339999';Size=32;LastWriteUtc='2026-09-20T00:00:00.0000000Z';DescriptorBase64='AA==';StateKey=('a'*64);Attributes=32;Links=1;SecurityInformation=511;Aces=@([pscustomobject]@{Type=2;Flags=64;Mask=1;Sid='S-1-1-0';Ordinary=$true;Binary='AA=='})};AuditPolicies=[pscustomobject]@{'0CCE921D-69AE-11D9-BED3-505054503030'=1};Precedence=[pscustomobject]@{KeyExists=$true;ValueExists=$true;Value=1;Type='DWord'};Channel=[pscustomobject]@{Name='Security';Enabled=$true;SecurityDescriptor='O:SYG:SYD:'};Engine='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe';EngineHash=('b'*64);Sources=[pscustomobject]@{Source=('c'*64)}} + $script:nonce='d'*32 + $script:operation=[pscustomobject]@{Kind='WelaOneByteFileRead';Nonce=$script:nonce;ProcessId=1234;Executable=$script:state.Engine;FilePath=$script:state.File.Path;BeforeReader=(Copy-Value $script:reader);AfterReader=(Copy-Value $script:reader);BeforeToken=(Copy-Value $script:token);AfterToken=(Copy-Value $script:token);Read=[pscustomobject]@{Clock='GetSystemTimePreciseAsFileTime';Phase='OneByteReadAndHeldIdentityReadback';Succeeded=$true;ReadCalls=1;BytesRead=1;HandleId='0x888';BeforeKey=('a'*64);AfterKey=('a'*64);StartedUtc='2026-09-21T00:00:00.0001000Z';ReadReturnedUtc='2026-09-21T00:00:00.0001600Z';CompletedUtc='2026-09-21T00:00:00.0002000Z'};RecordIdBefore=10} + $script:reads=0;$script:batchMode='match';$script:afterDrift=$false;$script:workerFailure=$false;$script:failArtifact=$null +} +function Native-Xml { + @" +4663101280000x802000000000000011SecurityFIXTURES-1-5-21-1-2-3-1001ReaderFIXTURE0x1234SecurityFileC:\Fixture\ReadCase.TxT0x888%%44160x10x4d2C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe- +"@ +} +New-Fixture +$null=Get-WelaFileProbeStateKey $script:state;Assert $true 'complete native prerequisites are accepted' +Assert-WelaFileProbeOperation $script:operation $script:state $script:nonce 1234 ([datetimeoffset]'2026-09-21T00:00:00Z') ([datetimeoffset]'2026-09-21T00:00:01Z');Assert $true 'one precise same-token byte read is accepted' +foreach($path in @('','relative.txt','\\host\share\file','C:\a:stream','C:\a\..\file','C:\a\file.','C:\a\file ','C:\a\file*','C:\a\','C:\a\\file','C:/file',('C:\'+('a'*240)))){Reject {Assert-WelaFileProbePath $path} 'exact ordinary'} +$scopeSource=[pscustomobject]@{Path='C:\WELA\WELA.ps1'};$scopeEngine=[pscustomobject]@{Path='C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'} +foreach($path in @('C:\Data\ordinary.txt','C:\WELA-other\ordinary.txt')){$selected=[pscustomobject]@{Path=$path;Links=1;Attributes=32};Assert-WelaFileProbeScopeObservation $path $selected $scopeSource $scopeEngine;Assert $true 'ordinary targets outside the canonical implementation tree are allowed'} +foreach($path in @('C:\WELA\WELA.ps1','c:\wela\scripts\FileAccessProbeNative.cs',$scopeEngine.Path)){$selected=[pscustomobject]@{Path=$path;Links=1;Attributes=32};Reject {Assert-WelaFileProbeScopeObservation $path $selected $scopeSource $scopeEngine} 'source tree|active PowerShell engine'} +foreach($mode in @('alias','links','reparse','typed-links')){$selected=[pscustomobject]@{Path='C:\Data\ordinary.txt';Links=1;Attributes=32};$inputPath=$selected.Path;switch($mode){'alias' {$inputPath='C:\Alias\ordinary.txt'};'links' {$selected.Links=2};'reparse' {$selected.Attributes=1024};'typed-links' {$selected.Links=$true}};Reject {Assert-WelaFileProbeScopeObservation $inputPath $selected $scopeSource $scopeEngine} 'ordinary canonical single-link'} +foreach($name in @('computer','machine','host-build','host-product','joined','service','token-source','token-sid','token-group','token-privilege','file-path','native-path','file-key','descriptor','size','links','sections','ace-ordinary','ace-type','ace-mask','ace-sid','channel-name','channel-enabled','precedence-type','precedence-value','mask','reader-type','reader-impersonation','engine','source')){ + New-Fixture + switch($name){ + 'computer' {$script:state.Computer=$true};'machine' {$script:state.MachineGuid=$true};'host-build' {$script:state.Host.Build=$true};'host-product' {$script:state.Host.ProductType=$true};'joined' {$script:state.Host.DomainJoined='false'} + 'service' {$script:state.Services[0].Status=$true};'token-source' {$script:state.Token.TokenSource=$true};'token-sid' {$script:state.Token.Sid=$true};'token-group' {$script:state.Token.Groups[0].Attributes=$true};'token-privilege' {$script:state.Token.Privileges[0].Attributes=$true} + 'file-path' {$script:state.File.Path=$true};'native-path' {$script:state.File.NativePath=$true};'file-key' {$script:state.File.StateKey=$true};'descriptor' {$script:state.File.DescriptorBase64=$true};'size' {$script:state.File.Size=$true};'links' {$script:state.File.Links=$true};'sections' {$script:state.File.SecurityInformation=$true} + 'ace-ordinary' {$script:state.File.Aces[0].Ordinary='true'};'ace-type' {$script:state.File.Aces[0].Type=$true};'ace-mask' {$script:state.File.Aces[0].Mask=$true};'ace-sid' {$script:state.File.Aces[0].Sid=$true} + 'channel-name' {$script:state.Channel.Name=$true};'channel-enabled' {$script:state.Channel.Enabled='true'};'precedence-type' {$script:state.Precedence.Type=$true};'precedence-value' {$script:state.Precedence.Value=$true};'mask' {$script:state.AuditPolicies.'0CCE921D-69AE-11D9-BED3-505054503030'=$true} + 'reader-type' {$script:state.Reader.TokenType=$true};'reader-impersonation' {$script:state.Reader.Impersonation=$true};'engine' {$script:state.Engine=$true};'source' {$script:state.Sources.Source=$true} + } + Reject {Get-WelaFileProbeStateKey $script:state} 'required|Incomplete|complete|ordinary|Unknown|Missing|Malformed|must already' +} +foreach($name in @('deny-only','disabled-group','inherit-only','failure-ace','callback','wrong-right','wrong-sid')){ + New-Fixture + switch($name){'deny-only' {$script:state.Token.Groups[0].Attributes=16};'disabled-group' {$script:state.Token.Groups[0].Attributes=0};'inherit-only' {$script:state.File.Aces[0].Flags=72};'failure-ace' {$script:state.File.Aces[0].Flags=128};'callback' {$script:state.File.Aces[0].Ordinary=$false};'wrong-right' {$script:state.File.Aces[0].Mask=2};'wrong-sid' {$script:state.File.Aces[0].Sid='S-1-5-18'}} + Reject {Get-WelaFileProbeStateKey $script:state} 'No existing ordinary success ReadData' +} +foreach($name in @('kind','nonce','pid','executable','path','clock','phase-type','phase-name','return-before','return-after','success','calls','bytes','handle','before','after','token-drift','reader-drift','pre-launch','post-observed','reverse')){ + New-Fixture + switch($name){'kind' {$script:operation.Kind=$true};'nonce' {$script:operation.Nonce=$true};'pid' {$script:operation.ProcessId=$true};'executable' {$script:operation.Executable=$true};'path' {$script:operation.FilePath=$true};'clock' {$script:operation.Read.Clock=$true};'phase-type' {$script:operation.Read.Phase=$true};'phase-name' {$script:operation.Read.Phase='Other'};'return-before' {$script:operation.Read.ReadReturnedUtc='2026-09-21T00:00:00.0000999Z'};'return-after' {$script:operation.Read.ReadReturnedUtc='2026-09-21T00:00:00.0002001Z'};'success' {$script:operation.Read.Succeeded='true'};'calls' {$script:operation.Read.ReadCalls=$true};'bytes' {$script:operation.Read.BytesRead=$true};'handle' {$script:operation.Read.HandleId='0x0'};'before' {$script:operation.Read.BeforeKey=$true};'after' {$script:operation.Read.AfterKey='e'*64};'token-drift' {$script:operation.AfterToken.Privileges[0].Attributes=2};'reader-drift' {$script:operation.AfterReader.ModifiedId='999'};'pre-launch' {$script:operation.Read.StartedUtc='2026-09-20T23:59:59Z'};'post-observed' {$script:operation.Read.CompletedUtc='2026-09-21T00:00:02Z'};'reverse' {$script:operation.Read.CompletedUtc='2026-09-21T00:00:00Z'}} + Reject {Assert-WelaFileProbeOperation $script:operation $script:state $script:nonce 1234 ([datetimeoffset]'2026-09-21T00:00:00Z') ([datetimeoffset]'2026-09-21T00:00:01Z')} 'authority|identity|receipt|Expected|token|interval' +} +New-Fixture;$xml=Native-Xml +Assert (Test-WelaFileProbeEvent $xml $script:operation $script:state) 'actual-schema source fixture matches all attribution fields' +foreach($change in @(@('4663','4662'),@('1','0'),@('0x8020000000000000','0x8010000000000000'),@('12800','1'),@('>FIXTURE','>OTHER'),@('>0x1','>0x2'),@('>0x888','>0x889'),@('>0x4d2','>0x4d3'),@('>0x1234','>0x1235'),@('>File','>Key'),@('ReadCase.TxT','Other.txt'),@('>%%4416','>%%4417'),@('0001500Z','0000999Z'),@('0001500Z','0002001Z'),@('11','10'),@('1001','1002'),@('v1.0\powershell.exe','v1.0\other.exe'))){Assert (-not(Test-WelaFileProbeEvent $xml.Replace($change[0],$change[1]) $script:operation $script:state)) "mismatched event $($change[0]) is refused"} +Assert (Test-WelaFileProbeEvent $xml.Replace('ReadCase.TxT','readcase.txt').Replace('System32','SYSTEM32').Replace('%%4416',' %%4416 ') $script:operation $script:state) 'Windows path casing and native access-list whitespace do not change identity/right' +Assert (Test-WelaFileProbeEvent $xml.Replace($script:state.File.Path,$script:state.File.NativePath.ToLowerInvariant()) $script:operation $script:state) 'the exact same-handle observed NT path is accepted case-insensitively' +foreach($wrong in @('\Device\HarddiskVolume6\Fixture\ReadCase.TxT','\Device\HarddiskVolume5\Elsewhere\ReadCase.TxT','\Device\HarddiskVolume5\Fixture\Other.TxT')){Assert (-not(Test-WelaFileProbeEvent $xml.Replace($script:state.File.Path,$wrong) $script:operation $script:state)) 'other NT volumes and paths remain rejected'} +Assert (Test-WelaFileProbeEvent $xml.Replace('0001500Z','0001800Z') $script:operation $script:state) 'an event after ReadFile returns but inside actual held-identity readback phase remains attributable' +foreach($time in @('0001000Z','0002000Z')){Assert (Test-WelaFileProbeEvent $xml.Replace('0001500Z',$time) $script:operation $script:state) 'exact measured phase boundaries are inclusive'} +Assert (-not(Test-WelaFileProbeEvent $xml.Replace('','0x1') $script:operation $script:state)) 'duplicate XML authority is refused' +Assert (-not(Test-WelaFileProbeEvent (']>'+$xml) $script:operation $script:state)) 'DTD evidence is refused' +Assert (-not(Test-WelaFileProbeEvent (''+$xml+'') $script:operation $script:state)) 'wrapped event is refused' +$script:state.File.LastWriteUtc=[datetime]::SpecifyKind([datetime]'2026-09-20T00:00:00',[DateTimeKind]::Utc);$null=Get-WelaFileProbeStateKey $script:state +$script:operation.Read.StartedUtc=[datetime]::SpecifyKind([datetime]'2026-09-21T00:00:00.0001000',[DateTimeKind]::Utc) +Assert-WelaFileProbeOperation $script:operation $script:state $script:nonce 1234 ([datetimeoffset]'2026-09-21T00:00:00Z') ([datetimeoffset]'2026-09-21T00:00:01Z');Assert $true 'older PowerShell UTC DateTime observations remain valid' +# Compile the exact retained bytes even on portable hosts; invoke no native API. +function Initialize-WelaWmiProbeNative {} +Initialize-WelaFileProbeNative +Assert ([Wela.FileAccessProbe.FileHandle]::SourceSha256 -ceq (Get-FileHash (Join-Path $script:ScriptRoot 'scripts/FileAccessProbeNative.cs')).Hash.ToLowerInvariant()) 'compiled helper carries the SHA256 of the exact decoded source bytes' +Initialize-WelaFileProbeNative;Assert $true 'identical compiled helper binding is reusable' +Remove-Item Function:Initialize-WelaWmiProbeNative +$sources=Get-WelaFileProbeSources +foreach($name in @('scripts/CustomAuditProfiles.ps1','scripts/ChannelReadNative.cs','scripts/Configuration.ps1','scripts/IpsecPrerequisites.ps1','modules/AuditProfiles.psm1','config/audit_profiles.json')){Assert ($sources.$name -ceq (Get-FileHash (Join-Path $script:ScriptRoot $name)).Hash.ToLowerInvariant()) 'actual transitive dependency fingerprint is included'} + +$script:writer=(Get-Command Write-WelaFileProbeArtifact).ScriptBlock +function Get-WelaFileProbeOutputKey {param($Path) 'fixture-private-output'} +function Write-WelaFileProbeArtifact {param($Root,$OutputKey,$Name,$Text) if($Name -eq $script:failArtifact){throw 'injected durable artifact failure'};& $script:writer $Root $OutputKey $Name $Text} +function Get-WelaFileProbeState {param($Path) Copy-Value $script:state} +function Start-WelaFileProbeRead {param($State,$RequestPath,$Nonce) $script:reads++;Assert (Test-Path (Join-Path (Split-Path $RequestPath) 'intent.json')) 'durable intent precedes each worker attempt';if($script:workerFailure){throw 'worker failed after a possible attempt'};$operation=Copy-Value $script:operation;$operation.Nonce=$Nonce;if($script:afterDrift){$script:state.Sources.Source='f'*64};$operation} +function Read-WelaFileProbeEvents {param($Operation) $xml=Native-Xml;$items=if($script:batchMode -eq 'empty'){@()}elseif($script:batchMode -eq 'duplicate'){@($xml,$xml)}else{@($xml)};[pscustomobject]@{Xml=$items;Capped=($script:batchMode -eq 'capped');Query='fixture'}} +function Get-WelaFileProbeWatermark {11} +$root=Join-Path ([IO.Path]::GetTempPath()) ('wela-file-probe-tests-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $root +try { + New-Fixture;$report=Invoke-WelaFileAccessProbe -FilePath $script:state.File.Path + Assert ($report.Status -ceq 'PrerequisitesObserved' -and $script:reads -eq 0 -and -not $report.OutputPath) 'Plan observes prerequisites without files or byte reads' + New-Fixture;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'success') + Assert ($report.Status -ceq 'FileReadObserved' -and $script:reads -eq 1 -and $report.Matches -eq 1 -and $report.Artifacts.Count -eq 5 -and $report.RetainedContentBytes -eq 0 -and $report.SigmaEvtxCredit -eq 0) 'successful report retains five hashed metadata/XML artifacts and no byte content' + foreach($mode in @('capped','duplicate','empty')){New-Fixture;$script:batchMode=$mode;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root $mode) -TimeoutSeconds 1;Assert ($report.Status -ceq 'Unverified' -and $report.ExitCode -eq 1) 'capped, duplicated or absent source evidence remains unverified'} + New-Fixture;$script:afterDrift=$true;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'drift') + Assert ($report.Status -ceq 'Unverified' -and $report.Diagnostic -match 'changed during the probe') 'late implementation drift prevents event readiness even after a matched record' + New-Fixture;$script:workerFailure=$true;$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'worker-failure') + Assert ($report.Status -ceq 'Unverified' -and (Test-Path (Join-Path $root 'worker-failure/intent.json')) -and -not(Test-Path (Join-Path $root 'worker-failure/operation.json'))) 'uncertain worker attempt retains intent without fabricating completion' + New-Fixture;$script:failArtifact='intent.json';$report=Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'intent-failure') + Assert ($report.ExitCode -eq 1 -and $script:reads -eq 0) 'failed durable intent prevents worker launch' + New-Fixture;$script:failArtifact='manifest.json' + Reject {Invoke-WelaFileAccessProbe -Action Run -FilePath $script:state.File.Path -OutputPath (Join-Path $root 'manifest-failure')} 'durable artifact failure' + Assert ((Test-Path (Join-Path $root 'manifest-failure/operation.json')) -and (Test-Path (Join-Path $root 'manifest-failure/event.xml'))) 'manifest persistence failure fails outward while completed evidence remains' +}finally{Remove-Item -LiteralPath $root -Recurse -Force} +Write-Host "Passed $script:checks file-access probe assertions; no Windows settings or file data changed." diff --git a/tests/FileAccessProbe.Windows.Tests.ps1 b/tests/FileAccessProbe.Windows.Tests.ps1 new file mode 100644 index 00000000..e6486031 --- /dev/null +++ b/tests/FileAccessProbe.Windows.Tests.ps1 @@ -0,0 +1,71 @@ +param([switch]$AllowDisposablePolicyWrite) +$ErrorActionPreference='Stop' +if(-not $AllowDisposablePolicyWrite -or $env:GITHUB_ACTIONS -ne 'true' -or [Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess){throw 'Only an explicitly permitted disposable GitHub-hosted native Windows runner is supported.'} +$script:ScriptRoot=Split-Path $PSScriptRoot -Parent +Import-Module (Join-Path $script:ScriptRoot 'modules/AuditProfiles.psm1') -ErrorAction Stop +foreach($name in @('Configuration','WefArrival','ChannelRead','WmiProbe','FileAccessProbe','SelectedSaclConfiguration')){. (Join-Path $script:ScriptRoot ('scripts/'+$name+'.ps1'))} +Initialize-WelaFileProbeNative;Initialize-WelaSelectedSaclNative +$engine=(Get-Process -Id $PID).Path;$script:checks=0 +function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++} +function Policy-Key($Policy){$ordered=[ordered]@{};foreach($key in @($Policy.Keys|Sort-Object)){$ordered[$key]=$Policy[$key]};Get-WelaFileProbeKey $ordered} +function Invoke-PublicFileProbe([string[]]$Arguments,[string]$Log,[bool]$Success=$true){$old=$ErrorActionPreference;try{$ErrorActionPreference='Continue';$lines=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $script:ScriptRoot 'WELA.ps1') @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference=$old;$global:LASTEXITCODE=0};$text=$lines -join "`n";[IO.File]::WriteAllText($Log,$text,[Text.UTF8Encoding]::new($false));if(($Success -and $code -ne 0) -or (-not $Success -and $code -eq 0)){throw "Unexpected public CLI result $code : $text"};$start=$text.IndexOf('{');if($start -lt 0){throw 'Public CLI returned no JSON report.'};ConvertFrom-WelaArrivalJson $text.Substring($start)} +function Add-OwnedReadSacl([string]$Path){$privilege=[Wela.SelectedSacl.Privilege]::new();$target=$null;try{$target=[Wela.SelectedSacl.Target]::new('FileSystem',$Path);$before=$target.Read();$null=$target.Add($before.Identity,$before.DescriptorBase64,'S-1-1-0',1,64)}finally{if($target){$target.Dispose()};$privilege.Dispose()}} +$root=New-WelaArrivalOutput (Join-Path $env:RUNNER_TEMP ('wela-file-access-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot +# The hosted runner's data volume can classify4663 as Removable Storage (Task12812). +# Own an ordinary private system-volume directory for the strict File System Task12800 fixture. +$targetRoot=New-WelaArrivalOutput (Join-Path (Join-Path $env:SystemRoot 'Temp') ('wela-file-access-targets-'+[guid]::NewGuid().ToString('N'))) $script:ScriptRoot +$file=Join-Path $targetRoot 'ReadCase.TxT';$plain=Join-Path $targetRoot 'WithoutAudit.txt' +[IO.File]::WriteAllText($file,'WELA owned harmless file probe fixture.',[Text.UTF8Encoding]::new($false));[IO.File]::WriteAllText($plain,'WELA owned file without a matching audit ACE.',[Text.UTF8Encoding]::new($false)) +$fileHash=(Get-FileHash $file).Hash;$beforePolicies=Get-WelaEffectiveAuditPolicy;$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$beforePrecedence=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy +$originalToken=Get-WelaFileProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot());$changed=$false;$cleanupErrors=@() +[IO.File]::WriteAllText((Join-Path $root 'original-audit-policy.json'),(Policy-Key $beforePolicies),[Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllText((Join-Path $root 'original-precedence.json'),(Get-WelaFileProbeKey $beforePrecedence),[Text.UTF8Encoding]::new($false)) +try { + $changed=$true;Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type DWord -Value 1 + Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 1 -Mode exact + Add-OwnedReadSacl $file + $before=Get-WelaFileProbeSnapshot $file;$beforeKey=$before.StateKey + $plan=Invoke-PublicFileProbe @('file-access-probe','-FileProbePath',$file.ToLowerInvariant()) (Join-Path $root 'plan.log') + Assert ($plan.Status -ceq 'PrerequisitesObserved' -and $plan.Before.File.Path -ieq $file -and $plan.Before.File.StateKey -ceq $beforeKey) 'public Plan accepts Windows path casing and verifies the exact existing file SACL/policy' + foreach($index in 1..2){ + $output=Join-Path $root ('run-'+$index) + $report=Invoke-PublicFileProbe @('file-access-probe','-FileProbeAction','Run','-FileProbePath',$file.ToLowerInvariant(),'-FileProbeOutputPath',$output) (Join-Path $root ('run-'+$index+'.log')) + Assert ($report.Status -ceq 'FileReadObserved' -and $report.Matches -eq 1) 'public one-byte read produced exactly one attributable actual4663' + Assert ($report.Operation.Read.Phase -ceq 'OneByteReadAndHeldIdentityReadback' -and (ConvertTo-WelaArrivalUtc $report.Operation.Read.StartedUtc) -le (ConvertTo-WelaArrivalUtc $report.Operation.Read.ReadReturnedUtc) -and (ConvertTo-WelaArrivalUtc $report.Operation.Read.ReadReturnedUtc) -le (ConvertTo-WelaArrivalUtc $report.Operation.Read.CompletedUtc)) 'actual phase retains separate ordered precise read-start, ReadFile-return and held-identity-readback completion timestamps' + Assert ($report.Operation.Read.ReadCalls -eq 1 -and $report.Operation.Read.BytesRead -eq 1 -and $report.RetainedContentBytes -eq 0 -and $report.SigmaEvtxCredit -eq 0) 'one byte is read without retaining contents or granting Sigma credit' + Assert ($report.Before.File.StateKey -ceq $beforeKey -and $report.After.File.StateKey -ceq $beforeKey -and (Get-FileHash $file).Hash -ceq $fileHash) 'existing file data, native identity and full descriptor remain unchanged' + Assert (Test-WelaFileProbeEvent ([IO.File]::ReadAllText((Join-Path $output 'event.xml'))) $report.Operation $report.Before) 'retained native XML matches operation PID, handle, SID, logon, path, right and measured read/readback phase' + foreach($artifact in $report.Artifacts){Assert ((Get-FileHash (Join-Path $output $artifact.Name)).Hash.ToLowerInvariant() -ceq $artifact.Sha256) 'retained artifact hash matches its public manifest'} + } + $missing=Invoke-PublicFileProbe @('file-access-probe','-FileProbePath',$plain) (Join-Path $root 'missing-sacl.log') $false + Assert ($missing.Status -ceq 'Unverified' -and $missing.Diagnostic -match 'No existing ordinary success ReadData' -and $null -eq $missing.Operation) 'real missing SACL refuses access without adding an ACE' + Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 0 -Mode exact + $disabled=Invoke-PublicFileProbe @('file-access-probe','-FileProbeAction','Run','-FileProbePath',$file,'-FileProbeOutputPath',(Join-Path $root 'disabled-policy')) (Join-Path $root 'disabled-policy.log') $false + Assert ($disabled.Status -ceq 'Unverified' -and $disabled.Diagnostic -match 'File System success auditing' -and $null -eq $disabled.Operation) 'real disabled auditing refuses the byte read' + Assert (-not(Test-Path (Join-Path $root 'disabled-policy/intent.json'))) 'failed prerequisites produce no pending read intent' + Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 1 -Mode exact + foreach($target in @((Join-Path $script:ScriptRoot 'WELA.ps1'),$engine)){ + $refused=Invoke-PublicFileProbe @('file-access-probe','-FileProbePath',$target) (Join-Path $root ('scope-refusal-'+[guid]::NewGuid().ToString('N')+'.log')) $false + Assert ($refused.Status -ceq 'Unverified' -and $null -eq $refused.Before -and $refused.Diagnostic -match 'source tree|active PowerShell engine') 'actual implementation/engine targets are refused before prerequisite hashes' + } + $oldState=Get-WelaFileProbeState $file + $replacement=Join-Path $targetRoot 'Replacement.txt';[IO.File]::WriteAllText($replacement,'WELA owned replacement.',[Text.UTF8Encoding]::new($false));Add-OwnedReadSacl $replacement + Remove-Item -LiteralPath $file -Force;Move-Item -LiteralPath $replacement -Destination $file + $message='';try{Start-WelaFileProbeRead $oldState (Join-Path $root 'not-used.json') ([guid]::NewGuid().ToString('N'))|Out-Null}catch{$message=$_.Exception.Message} + Assert ($message -match 'drifted before worker launch') 'real replaced native file identity refuses a stale preflight before launching a worker' + # Metadata-only desired access does not enforce data/delete sharing restrictions. + # Acquire READ_DATA for this fixture lock check without issuing a ReadFile call. + $held=[Wela.FileAccessProbe.FileHandle]::new($file,$true) + try{$denied=$false;try{Remove-Item -LiteralPath $file -Force -ErrorAction Stop}catch{$denied=$true};Assert $denied 'held native data-capable handle prevents deletion of the selected file'}finally{$held.Dispose()} +} finally { + if($changed){try{Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask $beforePolicies['0CCE921D-69AE-11D9-BED3-505054503030'] -Mode exact;if($beforePrecedence.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type $beforePrecedence.Type -Value $beforePrecedence.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction Stop}}catch{$cleanupErrors+=$_.Exception.Message}} + $auditRestored=(Policy-Key (Get-WelaEffectiveAuditPolicy)) -ceq (Policy-Key $beforePolicies) + $precedenceRestored=(Get-WelaFileProbeKey (Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy)) -ceq (Get-WelaFileProbeKey $beforePrecedence) + $tokenRestored=(Get-WelaFileProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot())) -ceq $originalToken + try{Remove-Item -LiteralPath $targetRoot -Recurse -Force -ErrorAction Stop}catch{$cleanupErrors+=$_.Exception.Message} + $cleanup=[pscustomobject]@{Complete=($auditRestored -and $precedenceRestored -and $tokenRestored -and -not(Test-Path $targetRoot) -and $cleanupErrors.Count -eq 0);AuditPoliciesRestored=$auditRestored;PrecedenceRestored=$precedenceRestored;TokenRestored=$tokenRestored;OwnedTargetsRemoved=(-not(Test-Path $targetRoot));Errors=$cleanupErrors;Checks=$script:checks;Engine=$PSVersionTable.PSVersion.ToString();AfterAuditPolicies=(Get-WelaEffectiveAuditPolicy);AfterPrecedence=(Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy)} + [IO.File]::WriteAllText((Join-Path $root 'cleanup.json'),($cleanup|ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false)) + if(-not $cleanup.Complete){throw "Native file-probe cleanup incomplete: $($cleanup|ConvertTo-Json -Compress -Depth 10)"} +} +Write-Host "Passed $script:checks actual native file-access assertions; all59 audit masks, typed precedence, privileges and owned-target cleanup verified. Evidence: $root" +$global:LASTEXITCODE=0 diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index c1e90136..fcd1e482 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -7,6 +7,8 @@ **改善:** +- 明示的な`file-access-probe` Plan/Runを追加し、既存のReadData成功監査SACLが適用される通常のローカルファイルから1バイトだけ読み取ります。実装・実行中エンジンの選択をハッシュ処理前に拒否し、同じハンドルのDOS/NTパスと実体、読み取りと実体再確認の実測区間、実際のワーカー・トークン・ハンドル、ポリシー・セキュリティ・実装の一致を確認し、ローカルSecurity4663と永続化した専用の証拠を必要とします。内容は保持せず、ポリシー・ACL・ファイルデータを変更しません。失敗監査・転送・Sigma利用可能性は未検証です。Server 2022/2025と両PowerShellの使い捨てテストで実イベントと正確な復元を検証します。 (関連 #373) (@Shirofune-Security) + - 既存の Script AuditOnly ポリシーに対し、固定の Windows PowerShell5.1 スクリプトを実行する `applocker-script-probe` を追加しました。実行者と子プロセスのログオン、保持したファイル、高精度 UTC とイベント記録境界を確認し、Script8005 の許可と8006 の監査専用ブロック判定を区別します。拒否・上限・重複・状態変化は未検証とし、設定変更や Sigma の評価加算は行いません。使い捨て Windows の4構成で両イベントとポリシー・チャネル・タスクの復元を検証し、保護された AppIDSvc を停止できない場合は明記します。 (関連 #381) (@Shirofune-Security) - 従来の設定コマンドでも、未対応の `-WhatIf` や入力ミスなどの未認識引数を実行前に拒否するようにしました。`-ErrorAction` や `-Verbose` などの PowerShell 共通パラメーターも拒否するため、自動化ラッパーへの影響をヘルプと診断に明記しました。正しい位置指定引数と文書化された `-DryRun` の動作は維持し、Windows PowerShell 5.1 と PowerShell 7 で公開CLIを検証します。 (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index f01ccea5..9e7329d7 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -7,6 +7,8 @@ **Improvements:** +- Added explicit `file-access-probe` Plan/Run for one byte read from one existing ordinary local leaf with a matching pre-existing ReadData success SACL. Source/engine targets are refused before hashing. Same-handle DOS/NT identity, exact worker/token/handle attribution over the measured read and identity-readback phase, full policy/security/source guards and durable private evidence require an actual local Security4663. No content is retained and no policy, ACL or file-data changes are made; failure, forwarding and Sigma readiness remain unverified. Disposable Server 2022/2025 tests cover both PowerShell engines and exact cleanup. (Related #373) (@Shirofune-Security) + - Added opt-in `applocker-script-probe` for a fixed native Windows PowerShell5.1 script under an existing Script AuditOnly policy. Actual caller/child logon context, held file bytes, precise UTC and native record boundaries distinguish exact Script8005 allowed and8006 would-block events; denied, capped, ambiguous or drifted runs remain unverified. The disposable four-way Windows suite requires both native decisions and policy/channel/task cleanup, with the protected-AppIDSvc stopping boundary recorded. No configuration changes or Sigma credit. (Related #381) (@Shirofune-Security) - Reject unbound command-line arguments before dispatch, including unsupported `-WhatIf` and misspelled options on legacy configuration commands. PowerShell common parameters such as `-ErrorAction` and `-Verbose` are also rejected; help and diagnostics explain the automation-wrapper compatibility change. Valid positional arguments and documented `-DryRun` behavior are preserved. Public CLI regressions cover both Windows PowerShell 5.1 and PowerShell 7. (@Shirofune-Security)