mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 15:25:24 +02:00
Merge pull request #437 from Shirofune-Security/feat/373-file-sacl-recovery
Add guarded recovery of one selected leaf-file audit ACE
This commit is contained in:
15 files changed
+710
-2
No files matched your search
@@ -58,6 +58,9 @@ tests/SelectedSaclFixtureProtection.cs text eol=lf
|
||||
/scripts/WmiNamespaceAuditing.ps1 text eol=lf
|
||||
/scripts/WefArrival.ps1 text eol=lf
|
||||
/tests/WmiProbe*.ps1 text eol=lf
|
||||
# Leaf-file recovery review binds these exact helper bytes.
|
||||
/scripts/FileSaclRecovery* text eol=lf
|
||||
/tests/FileSaclRecovery* text eol=lf
|
||||
# Actual archive-reader evidence binds implementation and native-token source bytes.
|
||||
/scripts/EvtxRecovery.ps1 text eol=lf
|
||||
/scripts/NativeValidation.ps1 text eol=lf
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Native leaf-file SACL recovery
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
file-sacl-recovery:
|
||||
timeout-minutes: 35
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-2022, windows-2025]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Strict inputs and CLI on Windows PowerShell 5.1
|
||||
shell: powershell
|
||||
run: |
|
||||
./tests/FileSaclRecovery.Tests.ps1
|
||||
./tests/FileSaclRecovery.Cli.Tests.ps1
|
||||
- name: Native descriptor guards on Windows PowerShell 5.1
|
||||
shell: powershell
|
||||
run: ./tests/FileSaclRecovery.Descriptor.Tests.ps1
|
||||
- name: Public owned-file addition and recovery on Windows PowerShell 5.1
|
||||
shell: powershell
|
||||
run: ./tests/FileSaclRecovery.Windows.Tests.ps1 -AllowDisposableSaclWrite
|
||||
- name: Strict inputs and CLI on PowerShell 7
|
||||
shell: pwsh
|
||||
run: |
|
||||
./tests/FileSaclRecovery.Tests.ps1
|
||||
./tests/FileSaclRecovery.Cli.Tests.ps1
|
||||
- name: Native descriptor guards on PowerShell 7
|
||||
shell: pwsh
|
||||
run: ./tests/FileSaclRecovery.Descriptor.Tests.ps1
|
||||
- name: Public owned-file addition and recovery on PowerShell 7
|
||||
shell: pwsh
|
||||
run: ./tests/FileSaclRecovery.Windows.Tests.ps1 -AllowDisposableSaclWrite
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
Copy-Item -Recurse -Path ./scripts -Destination release-binaries/
|
||||
Copy-Item -Recurse -Path ./modules -Destination release-binaries/
|
||||
New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null
|
||||
Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/dns-client-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/native-filesystem-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.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/file-sacl-recovery.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/native-filesystem-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/dns-client-probe.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md -Destination release-binaries/docs/
|
||||
|
||||
- name: Set Artifact Name
|
||||
if: contains(matrix.info.os, 'windows') == true
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空または null の SACL が存在フラグ付きで残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
|
||||
|
||||
- 明示した IPv4 リゾルバーに固定の無害な `wela-<nonce>.wela.test.` A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
|
||||
|
||||
- `evtx-recovery` を強化し、実際のプライマリトークンと通常のホスト情報を使って、標準ユーザーでも既存のネイティブアーカイブを検証できるようにしました。バージョン2のレポートはファイル読取拒否と厳密なイベント復元を区別し、トークン・ログオン・変更IDと実装の指紋を照合します。元のイベント生成者と読取者を分離し、既存の権限変更やSigma評価への加算は行いません。使い捨てWindowsテストでは独立した標準ユーザーログオンで実際の拒否と4688 EVTX復元を確認し、テスト用アカウント・ファイルACL・監査設定の復元を検証します。 (#433) (@Shirofune-Security)
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty or null present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)
|
||||
|
||||
- Added opt-in `dns-client-probe` for one fixed benign `wela-<nonce>.wela.test.` A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)
|
||||
|
||||
- Strengthened `evtx-recovery` with actual primary-token and ordinary host observations so intended standard users can verify existing native archives. Version 2 reports distinguish file-open denial from exact native event recovery, pin token/logon/modification and implementation identities, and retain independent producer/reader evidence without changing existing permissions or granting Sigma credit. Disposable Windows tests use fresh owned standard-user sessions for real denial and exact 4688 EVTX recovery, with verified account, file-ACL and policy cleanup. (#433) (@Shirofune-Security)
|
||||
|
||||
@@ -144,6 +144,14 @@
|
||||
[string]$EvtxProbePath,
|
||||
[string]$EvtxArchivePath,
|
||||
[string]$EvtxOutputPath,
|
||||
[ValidateSet('Plan','Restore')][string]$FileSaclRecoveryAction = 'Plan',
|
||||
[string]$FileSaclRecoveryOriginalPlanPath,
|
||||
[string]$FileSaclRecoveryPendingPath,
|
||||
[string]$FileSaclRecoveryConfirmedPath,
|
||||
[string]$FileSaclRecoveryResultsPath,
|
||||
[string]$FileSaclRecoveryPlanPath,
|
||||
[string]$FileSaclRecoveryPlanHash,
|
||||
[string]$FileSaclRecoveryOutputPath,
|
||||
[ValidateSet('Plan','Restore')][string]$TranscriptRecoveryAction = 'Plan',
|
||||
[string]$TranscriptRecoveryJournalPath,
|
||||
[string]$TranscriptRecoveryOriginalResultsPath,
|
||||
@@ -304,6 +312,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi
|
||||
. (Join-Path $ScriptRoot "scripts/EventMeasurement.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/GpoCreation.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/AuditRecovery.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/FileSaclRecovery.ps1")
|
||||
. (Join-Path $ScriptRoot "scripts/TranscriptionRecovery.ps1")
|
||||
|
||||
# 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。
|
||||
@@ -2177,6 +2186,8 @@ if ($Cmd -ne 'file-access-probe' -and @($PSBoundParameters.Keys | Where-Object {
|
||||
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 'file-sacl-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'FileSaclRecovery*'}).Count) {throw 'FileSaclRecovery options require file-sacl-recovery. No command was run.'}
|
||||
if ($Cmd -eq 'file-sacl-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','FileSaclRecoveryAction','FileSaclRecoveryOriginalPlanPath','FileSaclRecoveryPendingPath','FileSaclRecoveryConfirmedPath','FileSaclRecoveryResultsPath','FileSaclRecoveryPlanPath','FileSaclRecoveryPlanHash','FileSaclRecoveryOutputPath','Auto','DryRun','Help')}).Count) {throw 'file-sacl-recovery accepts only dedicated recovery options, Auto and DryRun. No command was run.'}
|
||||
if ($Cmd -ne 'transcription-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'TranscriptRecovery*'}).Count) {throw 'TranscriptRecovery options require transcription-recovery.'}
|
||||
if ($Cmd -eq 'transcription-recovery' -and ($args.Count -gt 0 -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','TranscriptRecoveryAction','TranscriptRecoveryJournalPath','TranscriptRecoveryOriginalResultsPath','TranscriptRecoveryPlanPath','TranscriptRecoveryPlanHash','TranscriptRecoveryOutputPath','TranscriptRecoveryAllowTemporarySuspension','Auto','DryRun','Help')}).Count)) {throw 'transcription-recovery accepts only its dedicated options, Auto and DryRun.'}
|
||||
if ($Cmd -ne 'audit-recovery' -and @($PSBoundParameters.Keys | Where-Object {$_ -like 'Recovery*'}).Count) {throw 'Recovery options require audit-recovery. No command was run.'}
|
||||
@@ -2302,7 +2313,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object {
|
||||
}).Count) {
|
||||
throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.'
|
||||
}
|
||||
if ($DryRun -and -not ($Cmd -eq 'ntlm-auditing' -and $NtlmAuditAction -eq 'Configure') -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
|
||||
if ($DryRun -and -not ($Cmd -eq 'file-sacl-recovery' -and $FileSaclRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'ntlm-auditing' -and $NtlmAuditAction -eq 'Configure') -and -not ($Cmd -eq 'outgoing-ntlm' -and $NtlmAction -eq 'Configure') -and -not ($Cmd -eq 'transcription-recovery' -and $TranscriptRecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'adcs-auditing' -and $AdcsAction -eq 'Configure') -and -not ($Cmd -eq 'adcs-resume' -and $AdcsResumeAction -eq 'Resume') -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
|
||||
-not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and
|
||||
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
|
||||
-not ($Cmd -eq 'firewall-recovery' -and $FirewallRecoveryAction -eq 'Restore') -and
|
||||
@@ -2434,6 +2445,12 @@ switch ($Cmd.ToLower()) {
|
||||
$report
|
||||
if ($report.ExitCode) {exit $report.ExitCode}
|
||||
}
|
||||
'file-sacl-recovery' {
|
||||
if ($Help) {Write-Host 'Usage: file-sacl-recovery [-FileSaclRecoveryAction Plan] -FileSaclRecoveryOriginalPlanPath original-plan.json -FileSaclRecoveryPendingPath target.pending.json -FileSaclRecoveryConfirmedPath target.confirmed.json -FileSaclRecoveryResultsPath original-results.json -FileSaclRecoveryOutputPath new-directory; then -FileSaclRecoveryAction Restore -FileSaclRecoveryPlanPath reviewed-plan.json -FileSaclRecoveryPlanHash SHA256 with -DryRun, or -Auto -FileSaclRecoveryOutputPath new-directory. Removes only one proven explicit leaf-file audit ACE. See docs/file-sacl-recovery.md.';return}
|
||||
$report=Invoke-WelaFileSaclRecovery -Action $FileSaclRecoveryAction -OriginalPlanPath $FileSaclRecoveryOriginalPlanPath -PendingPath $FileSaclRecoveryPendingPath -ConfirmedPath $FileSaclRecoveryConfirmedPath -ResultsPath $FileSaclRecoveryResultsPath -PlanPath $FileSaclRecoveryPlanPath -PlanHash $FileSaclRecoveryPlanHash -OutputPath $FileSaclRecoveryOutputPath -Auto:$Auto -DryRun:$DryRun
|
||||
$report | ConvertTo-Json -Depth 30 | Write-Output
|
||||
if ($report.ExitCode) {exit $report.ExitCode};return
|
||||
}
|
||||
'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
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Recover one selected leaf-file audit ACE
|
||||
|
||||
`file-sacl-recovery` removes one explicit ordinary audit ACE proven to have been added by a completed `targeted-sacl` operation. It supports one existing local leaf file, selected from the installed catalog with `Inheritance=None` and without child consent. Use elevated native 64-bit PowerShell on the original host. Registry keys, directories, descendants, inherited/object/callback ACE additions, arbitrary supplied ACEs and older or source-mismatched receipts require manual review.
|
||||
|
||||
This command changes only that file's SACL. It does not restore audit policy, rewrite its DACL, stop services, alter inheritance settings, or make an event-generation/Sigma readiness claim. Sysmon is out of scope. Preserve trusted original evidence; hashes detect changes and bind the reviewed selection but do not authenticate an untrusted receipt author.
|
||||
|
||||
## Required evidence and review
|
||||
|
||||
Retain all four files from the original public selected-target operation:
|
||||
|
||||
- Its original one-target `Plan` JSON, recorded while the row was `ChangeRequired`.
|
||||
- The matching `<id>.pending.json` and `<id>.confirmed.json` under the original backup directory.
|
||||
- The successful `Configure` results JSON, with its one row marked `Applied`.
|
||||
|
||||
The original before/after snapshots must prove exactly one new explicit ordinary audit ACE for the selected principal, rights and outcomes. Every previous ACE's bytes and count must remain; owner/group, DACL bytes, control flags, resource-manager control byte and SACL revision must agree, except that the original addition may have introduced the SACL-present flag. Neither an already-covered ACE nor any additional unexplained delta grants removal authority. Original snapshots are reconstructed from their binary descriptors and checked against their reported metadata.
|
||||
|
||||
The host/context, installed catalog and original selected-operation source hashes must still match. Recovery additionally records current helper/source hashes, actual elevated operator SID/groups and machine GUID, original input hashes, full current descriptor bytes and volume/file-index/creation identity. Current state must exactly match the confirmed addition. Input JSON is strict UTF-8, rejects duplicate properties and is limited to four MiB per file.
|
||||
|
||||
```powershell
|
||||
.\WELA.ps1 file-sacl-recovery `
|
||||
-FileSaclRecoveryOriginalPlanPath C:\Evidence\selected-plan.json `
|
||||
-FileSaclRecoveryPendingPath C:\Evidence\receipts\sacl-<id>.pending.json `
|
||||
-FileSaclRecoveryConfirmedPath C:\Evidence\receipts\sacl-<id>.confirmed.json `
|
||||
-FileSaclRecoveryResultsPath C:\Evidence\selected-results.json `
|
||||
-FileSaclRecoveryOutputPath C:\Evidence\recovery-review
|
||||
```
|
||||
|
||||
Review the new `plan.json`, especially `OriginalFiles`, `Operator`, `Expected`, `AddedAce` and `BeforeAddition`. Record its SHA-256 from the command result or `Get-FileHash`. The review directory must be new, outside the WELA installation, with an existing parent.
|
||||
|
||||
```powershell
|
||||
$plan = 'C:\Evidence\recovery-review\plan.json'
|
||||
$hash = (Get-FileHash $plan -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
.\WELA.ps1 file-sacl-recovery -FileSaclRecoveryAction Restore `
|
||||
-FileSaclRecoveryPlanPath $plan -FileSaclRecoveryPlanHash $hash -DryRun
|
||||
|
||||
.\WELA.ps1 file-sacl-recovery -FileSaclRecoveryAction Restore `
|
||||
-FileSaclRecoveryPlanPath $plan -FileSaclRecoveryPlanHash $hash `
|
||||
-Auto -FileSaclRecoveryOutputPath C:\Evidence\recovery-result
|
||||
```
|
||||
|
||||
`DryRun` rebuilds and compares the review from the original evidence and current host/file, then reports `WouldRemoveAddedAce`; it writes nothing. Actual restore requires `Auto` and a new private output directory outside the review directory. Both actions reject a modified or stale plan; rerunning an already completed plan is refused.
|
||||
|
||||
## Mutation and outcomes
|
||||
|
||||
Before mutation, `reviewed-plan.json` and `pending.json` are created exclusively, flushed to disk, reopened and hashed. Implementation, operator, host, original input files and reviewed plan are rechecked. The native helper holds a file handle without delete sharing, rejects directories and reparse files, verifies its final path and actual identity, and rereads the exact descriptor. It submits only `SACL_SECURITY_INFORMATION` to remove the unique proven ACE. Temporary `SeSecurityPrivilege` state is restored.
|
||||
|
||||
Afterwards WELA reads the held file and reopens the path, checks identity, unrelated ACE bytes/counts, SACL presence, revision when an ACL remains, owner/group, DACL, control flags and resource-manager control, then rechecks sources/evidence and reopens once more. Descriptor observations cover WinSDK-defined sections `0x1ff`; future sections are unobserved. Windows security-descriptor operations are not an atomic compare-and-swap against another administrator. Quiesce concurrent ACL writers; the guards detect observed drift, not an arbitrarily timed competing write.
|
||||
|
||||
`result.json` reports:
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `AddedAceRemoved` | One proven addition was removed and the bounded readback/preservation checks passed. |
|
||||
| `Refused` | The operation failed before any native write attempt. |
|
||||
| `WriteAttemptedUnverified` | A native write was attempted but complete final verification failed. Retain evidence and inspect manually. |
|
||||
|
||||
Removing the final audit ACE may leave an **empty or null present SACL** even if the historical descriptor had no SACL. Windows may retain `SACL_PRESENT` while returning no ACL pointer (`PresentNull`); this is accepted only when the removed ACE was the sole original ACE and all outside control/header fields still match. `SaclBefore` and `SaclAfter` record the observed representation and available ACL revision. This is an ACE-removal result, not a byte-for-byte restoration of the historical descriptor. `OriginalDescriptorBytesMatch` is only an observation; exact historical descriptor equality and original ACE ordering are not promised. Unrelated ACE bytes and counts are preserved. WELA does not automatically re-add the ACE after partial failure. No outcome grants rule-readiness credit.
|
||||
|
||||
## Validation and limits
|
||||
|
||||
`tests/FileSaclRecovery.Tests.ps1` covers strict input, source binding, durable exclusive output and action guards; separate CLI tests run real public process dispatch. Native descriptor tests exercise exact deltas and unsafe ACE/header/control changes. The explicitly gated Windows fixture runs on disposable Server 2022/2025 with Windows PowerShell 5.1 and PowerShell 7: it installs an owned one-file catalog only in a disposable checkout copy, obtains genuine public `Plan`/`Configure` receipts, then exercises public review/dry-run/removal/replay refusal, altered evidence/source and replacement file identity. Empty and unrelated-ACE cases retain their observed outside descriptor components. The fixture restores all 59 audit-policy masks and the exact typed precedence value/absence and deletes only its owned files.
|
||||
|
||||
This is not Windows 11, DC, ADCS, inherited directory recovery, distributed policy refresh or event/backend acceptance evidence. The original selected-target implementation files remain unchanged so the recovery feature itself does not invalidate their existing source hashes.
|
||||
|
||||
API contracts: [GetSecurityInfo](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-getsecurityinfo), [SetSecurityInfo](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo), [RawSecurityDescriptor](https://learn.microsoft.com/en-us/dotnet/api/system.security.accesscontrol.rawsecuritydescriptor).
|
||||
@@ -0,0 +1,183 @@
|
||||
# Recovery is limited to a single proven explicit addition on an existing leaf file.
|
||||
function Get-WelaFileSaclRecoveryKey {param($Value) ConvertTo-Json -InputObject $Value -Depth 30 -Compress}
|
||||
function Initialize-WelaFileSaclRecoveryNative {
|
||||
$path=Join-Path $PSScriptRoot 'FileSaclRecoveryNative.cs';$bytes=[IO.File]::ReadAllBytes($path);$hash=Get-WelaArrivalHash $bytes
|
||||
if (-not ('Wela.FileSaclRecovery.Descriptor' -as [type])) {
|
||||
$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
|
||||
$marker='__WELA_FILE_SACL_RECOVERY_SOURCE_SHA256__'
|
||||
if (($source.Split(@($marker),[StringSplitOptions]::None)).Count -ne 2) {throw 'Unexpected native recovery source binding.'}
|
||||
Add-Type -TypeDefinition $source.Replace($marker,$hash) -ErrorAction Stop
|
||||
}
|
||||
if ([Wela.FileSaclRecovery.Descriptor]::SourceSha256 -cne $hash) {throw 'Loaded file recovery helper differs from current source; start a fresh PowerShell process.'}
|
||||
}
|
||||
function Get-WelaFileSaclRecoverySources {
|
||||
$sources=[ordered]@{}
|
||||
foreach ($path in @('WELA.ps1','scripts/FileSaclRecovery.ps1','scripts/FileSaclRecoveryNative.cs','scripts/SelectedSaclConfiguration.ps1','scripts/SelectedSaclNative.cs','scripts/SelectedSaclDescendants.ps1','scripts/TargetedSaclPlanning.ps1','scripts/ControlApplicability.ps1','scripts/Configuration.ps1','config/control_applicability.json','modules/NativeProviders.psm1','scripts/EvtxRecovery.ps1','scripts/WefArrival.ps1','modules/AuditProfiles.psm1','modules/AuditCatalog.psm1','config/audit_profiles.json','config/audit_sacl_targets.json')) {
|
||||
$sources[$path]=(Get-FileHash -LiteralPath (Join-Path (Split-Path $PSScriptRoot -Parent) $path) -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
}
|
||||
[pscustomobject]$sources
|
||||
}
|
||||
function Get-WelaFileSaclRecoveryOperator {
|
||||
if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) {throw 'File SACL recovery requires native 64-bit Windows.'}
|
||||
$thread=[Security.Principal.WindowsIdentity]::GetCurrent($true)
|
||||
if ($thread) {$thread.Dispose();throw 'Impersonated recovery is unsupported.'}
|
||||
$identity=[Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
try {
|
||||
if (-not ([Security.Principal.WindowsPrincipal]::new($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {throw 'File SACL recovery requires the actual elevated operator.'}
|
||||
$key=[Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SOFTWARE\Microsoft\Cryptography',$false)
|
||||
if (-not $key) {throw 'Machine identity is unavailable.'}
|
||||
try {$machine=$key.GetValue('MachineGuid');if ($key.GetValueKind('MachineGuid') -ne 'String' -or $machine -isnot [string]) {throw 'Machine identity is mistyped.'}} finally {$key.Dispose()}
|
||||
[guid]$parsed=[guid]::Empty;if (-not [guid]::TryParse($machine,[ref]$parsed) -or $parsed -eq [guid]::Empty) {throw 'Machine identity is invalid.'}
|
||||
[pscustomobject][ordered]@{Computer=[Environment]::MachineName;MachineGuid=$parsed.ToString();UserSid=$identity.User.Value;Groups=@($identity.Groups|ForEach-Object Value|Sort-Object);ElevatedAdministrator=$true;Impersonation='Absent'}
|
||||
} finally {$identity.Dispose()}
|
||||
}
|
||||
function Read-WelaFileSaclRecoveryInput {
|
||||
param([string]$Path)
|
||||
$full=Resolve-WelaArrivalPath $Path
|
||||
$stream=[IO.File]::Open($full,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
|
||||
try {
|
||||
if ($stream.Length -lt 1 -or $stream.Length -gt 4194304) {throw 'Recovery JSON must contain 1 byte through four MiB.'}
|
||||
$bytes=New-Object byte[] ([int]$stream.Length);$offset=0
|
||||
while ($offset -lt $bytes.Length) {$count=$stream.Read($bytes,$offset,$bytes.Length-$offset);if ($count -eq 0) {throw 'Recovery input changed during reading.'};$offset+=$count}
|
||||
if ($stream.Length -ne $bytes.Length) {throw 'Recovery input length changed.'}
|
||||
} finally {$stream.Dispose()}
|
||||
$text=[Text.UTF8Encoding]::new($false,$true).GetString($bytes).TrimStart([char]0xfeff)
|
||||
[pscustomobject]@{Path=$full;Sha256=(Get-WelaArrivalHash $bytes);Bytes=$bytes.Length;Data=(ConvertFrom-WelaEvtxJson $text)}
|
||||
}
|
||||
function Assert-WelaFileSaclRecoverySnapshot {
|
||||
param($Snapshot,$Definition)
|
||||
Assert-WelaEvtxObject $Snapshot @('Path','Kind','Identity','IsDirectory','DescriptorBase64','Owner','Group','DaclBase64','ControlFlags','SecurityInformation','DescriptorScope','Aces')
|
||||
if ($Snapshot.Kind -cne 'FileSystem' -or $Snapshot.IsDirectory -isnot [bool] -or $Snapshot.IsDirectory -or $Snapshot.Path -cne $Definition.Path -or $Snapshot.Identity -cnotmatch '^[0-9]+:[0-9]+:[0-9]+:[0-9]+$' -or $Snapshot.Aces -isnot [array]) {throw 'Only exact historical leaf-file snapshots are supported.'}
|
||||
$null=Get-WelaSelectedSaclSnapshotKey $Snapshot
|
||||
foreach ($ace in $Snapshot.Aces) {
|
||||
Assert-WelaEvtxObject $ace @('Binary','Type','Flags','Mask','Sid','Ordinary')
|
||||
if ($ace.Ordinary -isnot [bool]) {throw 'Mistyped ACE metadata.'}
|
||||
foreach ($name in @('Type','Flags','Mask')) {if ($ace.$name -isnot [int] -and $ace.$name -isnot [long]) {throw 'Mistyped ACE metadata.'}}
|
||||
}
|
||||
Initialize-WelaFileSaclRecoveryNative
|
||||
$parsed=[Wela.FileSaclRecovery.Descriptor]::Observe($Snapshot.Path,$Snapshot.Identity,[Convert]::FromBase64String($Snapshot.DescriptorBase64))
|
||||
if ((Get-WelaSelectedSaclSnapshotKey $parsed) -cne (Get-WelaSelectedSaclSnapshotKey $Snapshot)) {throw 'Historical snapshot metadata differs from its actual descriptor bytes.'}
|
||||
}
|
||||
function Get-WelaFileSaclRecoverySnapshot {
|
||||
param($Definition)
|
||||
if ($Definition.Kind -cne 'FileSystem') {throw 'Only leaf FileSystem targets are supported.'}
|
||||
$path=Resolve-WelaSelectedSaclNativePath $Definition;Initialize-WelaFileSaclRecoveryNative
|
||||
$target=[Wela.FileSaclRecovery.Target]::new($path)
|
||||
try {$target.Read()} finally {$target.Dispose()}
|
||||
}
|
||||
function Get-WelaFileSaclRecoveryAddition {
|
||||
param($Before,$After,$Ace)
|
||||
Initialize-WelaFileSaclRecoveryNative
|
||||
[Wela.FileSaclRecovery.Descriptor]::AddedAce($Before.DescriptorBase64,$After.DescriptorBase64,$Ace.Sid,$Ace.Mask,$Ace.Flags)
|
||||
}
|
||||
function New-WelaFileSaclRecoveryPlan {
|
||||
param([string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$ResultsPath)
|
||||
$operator=Get-WelaFileSaclRecoveryOperator;$context=Get-WelaSelectedSaclContext;$sources=Get-WelaFileSaclRecoverySources
|
||||
$files=[ordered]@{};foreach ($entry in @(@('OriginalPlan',$OriginalPlanPath),@('Pending',$PendingPath),@('Confirmed',$ConfirmedPath),@('Results',$ResultsPath))) {$files[$entry[0]]=Read-WelaFileSaclRecoveryInput $entry[1]}
|
||||
if (@($files.Values.Path|Sort-Object -Unique).Count -ne 4) {throw 'Four distinct original evidence files are required.'}
|
||||
$plan=$files.OriginalPlan.Data;$pending=$files.Pending.Data;$confirmed=$files.Confirmed.Data;$result=$files.Results.Data
|
||||
Assert-WelaEvtxObject $plan @('SchemaVersion','Kind','CapturedUtc','Profile','IncludeOptional','IncludeChildren','Context','Sources','Rows','GenerationReadiness','UsableRuleCredit','Catalog','UserInventory')
|
||||
foreach ($value in @($plan,$pending,$confirmed,$result)) {if (($value.SchemaVersion -isnot [int] -and $value.SchemaVersion -isnot [long]) -or $value.SchemaVersion -ne 1) {throw 'Unsupported original evidence schema.'}}
|
||||
if ($plan.Kind -cne 'WelaSelectedSaclPlan' -or $plan.IncludeChildren -isnot [bool] -or $plan.IncludeChildren -or $plan.IncludeOptional -isnot [bool] -or $plan.Rows -isnot [array] -or $plan.Rows.Count -ne 1) {throw 'Require one original selected target, without child consent.'}
|
||||
$row=$plan.Rows[0]
|
||||
if ($row.Status -cne 'ChangeRequired' -or $row.After -or $row.DescendantsBefore -or $row.DescendantsAfter -or $row.DescendantVerification -or $row.Id -cnotmatch '^sacl-[a-f0-9]{24}$' -or $row.Definition.Kind -cne 'FileSystem' -or $row.Definition.Inheritance -cne 'None' -or $row.Definition.Propagation -cne 'None') {throw 'Original plan must describe one explicit leaf-file addition without inheritance.'}
|
||||
Assert-WelaSelectedSaclSources $plan.Sources
|
||||
if ($plan.Context.Key -cne $context.Key -or $plan.Context.Computer -cne $operator.Computer) {throw 'Original host context differs from the actual recovery host.'}
|
||||
$catalog=Get-WelaSelectedSaclCatalog -Profile $plan.Profile -IncludeOptional:$plan.IncludeOptional -Context $context
|
||||
$selected=@($catalog.Rows|Where-Object Id -CEQ $row.Id)
|
||||
if ($selected.Count -ne 1 -or $selected[0].DefinitionKey -cne $row.DefinitionKey -or (Get-WelaSelectedSaclDefinitionKey $row.Definition) -cne $row.DefinitionKey -or (Get-WelaFileSaclRecoveryKey $selected[0].Definition) -cne (Get-WelaFileSaclRecoveryKey $row.Definition)) {throw 'Original target is not the exact currently source-bound catalog selection.'}
|
||||
Assert-WelaFileSaclRecoverySnapshot $row.Before $row.Definition
|
||||
$ace=Get-WelaSelectedSaclAce $row.Definition $row.Before
|
||||
if ((Get-WelaFileSaclRecoveryKey $ace) -cne (Get-WelaFileSaclRecoveryKey $row.Ace) -or $ace.Flags -notin @(64,128,192) -or (Test-WelaSelectedSaclAce $row.Before $ace)) {throw 'Original selected audit ACE is mistyped, inherited or already covered.'}
|
||||
$receiptFields=@('SchemaVersion','Kind','State','RecordedUtc','Computer','ContextKey','Id','Sources','Definition','Before','Ace','After','DescendantsBefore','DescendantsAfter','DescendantVerification','Ownership')
|
||||
foreach ($receipt in @($pending,$confirmed)) {
|
||||
Assert-WelaEvtxObject $receipt $receiptFields
|
||||
if ($receipt.Kind -cne 'WelaSelectedSaclReceipt' -or $receipt.Computer -cne $operator.Computer -or $receipt.ContextKey -cne $context.Key -or $receipt.Id -cne $row.Id -or $receipt.DescendantsBefore -or $receipt.DescendantsAfter -or $receipt.DescendantVerification -or $receipt.Ownership -cne 'Only the verified explicit selected-root addition; never descendant ACE ownership or bulk rollback authority.') {throw 'Original receipt scope or ownership is unsupported.'}
|
||||
Assert-WelaSelectedSaclSources $receipt.Sources
|
||||
foreach ($name in @('Definition','Ace')) {if ((Get-WelaFileSaclRecoveryKey $receipt.$name) -cne (Get-WelaFileSaclRecoveryKey $row.$name)) {throw 'Original receipt differs from the selected plan.'}}
|
||||
Assert-WelaFileSaclRecoverySnapshot $receipt.Before $row.Definition
|
||||
if ((Get-WelaSelectedSaclSnapshotKey $receipt.Before) -cne (Get-WelaSelectedSaclSnapshotKey $row.Before)) {throw 'Original before-state differs across records.'}
|
||||
}
|
||||
if ($pending.State -cne 'Pending' -or $pending.After -or $confirmed.State -cne 'Confirmed' -or -not $confirmed.After -or $pending.RecordedUtc -cne $confirmed.RecordedUtc) {throw 'A matching pending and confirmed receipt pair is required.'}
|
||||
Assert-WelaFileSaclRecoverySnapshot $confirmed.After $row.Definition
|
||||
if ($confirmed.After.Identity -cne $row.Before.Identity) {throw 'The original operation changed file identity.'}
|
||||
$added=Get-WelaFileSaclRecoveryAddition $row.Before $confirmed.After $ace
|
||||
Assert-WelaEvtxObject $result @('SchemaVersion','Kind','ExitCode','DryRun','BackupPath','Plan','Results','GenerationReadiness','UsableRuleCredit')
|
||||
if ($result.Kind -cne 'WelaSelectedSaclResult' -or ($result.ExitCode -isnot [int] -and $result.ExitCode -isnot [long]) -or $result.ExitCode -ne 0 -or $result.DryRun -isnot [bool] -or $result.DryRun -or $result.Results -isnot [array] -or $result.Results.Count -ne 1 -or $result.Results[0].Status -cne 'Applied') {throw 'Require a completed successful, non-dry-run selected operation.'}
|
||||
$applied=$result.Results[0]
|
||||
if ($result.Plan.Kind -cne 'WelaSelectedSaclPlan' -or $result.Plan.Rows -isnot [array] -or $result.Plan.Rows.Count -ne 1 -or (Get-WelaFileSaclRecoveryKey $applied) -cne (Get-WelaFileSaclRecoveryKey $result.Plan.Rows[0]) -or $applied.Id -cne $row.Id -or $applied.DefinitionKey -cne $row.DefinitionKey -or $applied.DescendantsBefore -or $applied.DescendantsAfter -or $applied.DescendantVerification) {throw 'Completed result rows or scope disagree.'}
|
||||
foreach ($name in @('Definition','Ace')) {if ((Get-WelaFileSaclRecoveryKey $applied.$name) -cne (Get-WelaFileSaclRecoveryKey $row.$name)) {throw 'Completed selection differs from original plan.'}}
|
||||
if ((Get-WelaSelectedSaclSnapshotKey $applied.Before) -cne (Get-WelaSelectedSaclSnapshotKey $row.Before) -or (Get-WelaSelectedSaclSnapshotKey $applied.After) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.After)) {throw 'Completed descriptor evidence disagrees.'}
|
||||
foreach ($name in @('Profile','IncludeOptional','IncludeChildren','Context','Sources')) {if ((Get-WelaFileSaclRecoveryKey $result.Plan.$name) -cne (Get-WelaFileSaclRecoveryKey $plan.$name)) {throw 'Completed plan context differs from the original selection.'}}
|
||||
$backup=Resolve-WelaArrivalPath $result.BackupPath
|
||||
if ($files.Pending.Path -ine (Join-Path $backup ($row.Id+'.pending.json')) -or $files.Confirmed.Path -ine (Join-Path $backup ($row.Id+'.confirmed.json'))) {throw 'Receipt paths do not match the original recorded backup directory.'}
|
||||
$originalTime=ConvertTo-WelaEvtxUtc $plan.CapturedUtc;$configuredTime=ConvertTo-WelaEvtxUtc $result.Plan.CapturedUtc;$receiptTime=ConvertTo-WelaEvtxUtc $pending.RecordedUtc
|
||||
if ($originalTime -gt $configuredTime -or $configuredTime -gt $receiptTime -or $receiptTime -gt [DateTimeOffset]::UtcNow) {throw 'Original evidence timestamps are out of order or in the future.'}
|
||||
$current=Get-WelaFileSaclRecoverySnapshot $row.Definition
|
||||
if ((Get-WelaSelectedSaclSnapshotKey $current) -cne (Get-WelaSelectedSaclSnapshotKey $confirmed.After)) {throw 'Current file identity or descriptor differs from the completed operation; manual review required.'}
|
||||
if ((Get-WelaSelectedSaclSnapshotKey (Get-WelaFileSaclRecoverySnapshot $row.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $current)) {throw 'File changed during recovery planning.'}
|
||||
$inputFiles=[ordered]@{};foreach ($name in $files.Keys) {$file=$files[$name];$inputFiles[$name]=[pscustomobject]@{Path=$file.Path;Sha256=$file.Sha256;Bytes=$file.Bytes}}
|
||||
$recovery=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileSaclRecoveryPlan';Id=$row.Id;Profile=$plan.Profile;Operator=$operator;ContextKey=$context.Key;Sources=$sources;OriginalFiles=[pscustomobject]$inputFiles;Definition=$row.Definition;BeforeAddition=$row.Before;Expected=$current;AddedAce=$added;Outcome='Remove one proven explicit ordinary audit ACE; an empty or null present SACL can remain.';ReadyRuleCredit=0}
|
||||
Assert-WelaFileSaclRecoveryFresh $recovery
|
||||
$recovery
|
||||
}
|
||||
function Assert-WelaFileSaclRecoveryFresh {
|
||||
param($Plan)
|
||||
if ((Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoverySources)) -cne (Get-WelaFileSaclRecoveryKey $Plan.Sources) -or (Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoveryOperator)) -cne (Get-WelaFileSaclRecoveryKey $Plan.Operator) -or (Get-WelaSelectedSaclContext).Key -cne $Plan.ContextKey) {throw 'Recovery implementation, operator or host context changed.'}
|
||||
foreach ($entry in $Plan.OriginalFiles.PSObject.Properties) {$file=Read-WelaFileSaclRecoveryInput $entry.Value.Path;if ($file.Sha256 -cne $entry.Value.Sha256 -or $file.Bytes -ne $entry.Value.Bytes) {throw 'Original recovery evidence changed.'}}
|
||||
if ((Get-WelaSelectedSaclSnapshotKey (Get-WelaFileSaclRecoverySnapshot $Plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $Plan.Expected)) {throw 'Reviewed file changed before removal.'}
|
||||
}
|
||||
function Invoke-WelaFileSaclRecovery {
|
||||
param([ValidateSet('Plan','Restore')][string]$Action='Plan',[string]$OriginalPlanPath,[string]$PendingPath,[string]$ConfirmedPath,[string]$ResultsPath,[string]$PlanPath,[string]$PlanHash,[string]$OutputPath,[switch]$Auto,[switch]$DryRun)
|
||||
if ($Action -eq 'Plan') {
|
||||
if ($PlanPath -or $PlanHash -or $Auto -or $DryRun -or -not $OriginalPlanPath -or -not $PendingPath -or -not $ConfirmedPath -or -not $ResultsPath -or -not $OutputPath) {throw 'Plan requires four original evidence paths and a new output directory only.'}
|
||||
$plan=New-WelaFileSaclRecoveryPlan $OriginalPlanPath $PendingPath $ConfirmedPath $ResultsPath
|
||||
$output=New-WelaArrivalOutput -Path $OutputPath -SourcePath (Split-Path $PSScriptRoot -Parent)
|
||||
$artifact=Write-WelaFileSaclRecoveryArtifact $output 'plan.json' (Get-WelaFileSaclRecoveryKey $plan)
|
||||
return [pscustomobject]@{Status='Planned';ExitCode=0;PlanPath=(Join-Path $output 'plan.json');PlanHash=$artifact.Sha256;ReadyRuleCredit=0}
|
||||
}
|
||||
if ($OriginalPlanPath -or $PendingPath -or $ConfirmedPath -or $ResultsPath -or -not $PlanPath -or $PlanHash -cnotmatch '^[a-f0-9]{64}$' -or ($DryRun -and ($OutputPath -or $Auto)) -or (-not $DryRun -and (-not $Auto -or -not $OutputPath))) {throw 'Restore requires PlanPath/PlanHash and either DryRun or Auto with a new output directory.'}
|
||||
$reviewed=Read-WelaFileSaclRecoveryInput $PlanPath
|
||||
if ($reviewed.Sha256 -cne $PlanHash -or $reviewed.Data.Kind -cne 'WelaFileSaclRecoveryPlan') {throw 'Reviewed recovery plan hash or kind differs.'}
|
||||
$plan=$reviewed.Data;$inputs=$plan.OriginalFiles
|
||||
$rebuilt=New-WelaFileSaclRecoveryPlan $inputs.OriginalPlan.Path $inputs.Pending.Path $inputs.Confirmed.Path $inputs.Results.Path
|
||||
if ((Get-WelaFileSaclRecoveryKey $plan) -cne (Get-WelaFileSaclRecoveryKey $rebuilt)) {throw 'Reviewed recovery plan is stale or modified.'}
|
||||
if ($DryRun) {return [pscustomobject]@{Status='WouldRemoveAddedAce';ExitCode=0;Target=$plan.Definition.Path;ReadyRuleCredit=0}}
|
||||
$output=New-WelaArrivalOutput -Path $OutputPath -SourcePath (Split-Path $reviewed.Path -Parent)
|
||||
$report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaFileSaclRecoveryResult';Status='Refused';ExitCode=1;StartedUtc=[DateTime]::UtcNow.ToString('o');CompletedUtc=$null;PlanHash=$PlanHash;Before=$plan.Expected;After=$null;SaclBefore=[Wela.FileSaclRecovery.Descriptor]::SaclRepresentation($plan.Expected.DescriptorBase64);SaclAfter=$null;WriteAttempted=$false;Artifacts=@();OriginalDescriptorBytesMatch=$false;Diagnostic='';OutputPath=$output;ReadyRuleCredit=0;PolicyChanges=0;Scope='Remove only one proven explicit leaf-file audit ACE; preserve other ACE bytes/counts and observed descriptor components. No descendant, exact historical descriptor, event or Sigma claim.'}
|
||||
$target=$null
|
||||
try {
|
||||
$report.Artifacts+=Write-WelaFileSaclRecoveryArtifact $output 'reviewed-plan.json' (Get-WelaFileSaclRecoveryKey $plan)
|
||||
$report.Artifacts+=Write-WelaFileSaclRecoveryArtifact $output 'pending.json' (Get-WelaFileSaclRecoveryKey ([pscustomobject]@{Kind='WelaFileSaclRecoveryIntent';PlanHash=$PlanHash;Before=$plan.Expected;RemoveAce=$plan.AddedAce;RecordedUtc=[DateTime]::UtcNow.ToString('o')}))
|
||||
Assert-WelaFileSaclRecoveryFresh $plan
|
||||
if ((Read-WelaFileSaclRecoveryInput $reviewed.Path).Sha256 -cne $PlanHash) {throw 'Reviewed recovery plan changed before write.'}
|
||||
Initialize-WelaFileSaclRecoveryNative
|
||||
$target=[Wela.FileSaclRecovery.Target]::new((Resolve-WelaSelectedSaclNativePath $plan.Definition))
|
||||
try {$report.After=$target.Remove($plan.Expected.Identity,$plan.Expected.DescriptorBase64,$plan.AddedAce)} finally {$report.WriteAttempted=$target.WriteAttempted;if ($target.AfterObservation) {$report.After=$target.AfterObservation;$report.SaclAfter=[Wela.FileSaclRecovery.Descriptor]::SaclRepresentation($report.After.DescriptorBase64)}}
|
||||
$target.Dispose();$target=$null
|
||||
$fresh=Get-WelaFileSaclRecoverySnapshot $plan.Definition
|
||||
if ((Get-WelaSelectedSaclSnapshotKey $fresh) -cne (Get-WelaSelectedSaclSnapshotKey $report.After)) {throw 'File identity or descriptor changed after removal.'}
|
||||
if ((Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoverySources)) -cne (Get-WelaFileSaclRecoveryKey $plan.Sources) -or (Get-WelaFileSaclRecoveryKey (Get-WelaFileSaclRecoveryOperator)) -cne (Get-WelaFileSaclRecoveryKey $plan.Operator) -or (Get-WelaSelectedSaclContext).Key -cne $plan.ContextKey) {throw 'Recovery context changed after removal.'}
|
||||
foreach ($entry in $plan.OriginalFiles.PSObject.Properties) {if ((Read-WelaFileSaclRecoveryInput $entry.Value.Path).Sha256 -cne $entry.Value.Sha256) {throw 'Original recovery evidence changed after removal.'}}
|
||||
if ((Read-WelaFileSaclRecoveryInput $reviewed.Path).Sha256 -cne $PlanHash) {throw 'Reviewed plan changed after removal.'}
|
||||
foreach ($artifact in $report.Artifacts) {if ((Get-FileHash -LiteralPath (Join-Path $output $artifact.Name) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $artifact.Sha256) {throw 'Recovery artifact changed after writing.'}}
|
||||
if ((Get-WelaSelectedSaclSnapshotKey (Get-WelaFileSaclRecoverySnapshot $plan.Definition)) -cne (Get-WelaSelectedSaclSnapshotKey $report.After)) {throw 'Final reopened file differs after recovery.'}
|
||||
$report.OriginalDescriptorBytesMatch=$report.After.DescriptorBase64 -ceq $plan.BeforeAddition.DescriptorBase64
|
||||
$report.Status='AddedAceRemoved';$report.ExitCode=0
|
||||
} catch {$report.Diagnostic=$_.Exception.Message;if ($report.WriteAttempted) {$report.Status='WriteAttemptedUnverified'}}
|
||||
finally {if ($target) {try {$target.Dispose()} catch {$report.Status='WriteAttemptedUnverified';$report.ExitCode=1;$report.Diagnostic+=' Native cleanup failed: '+$_.Exception.Message}}}
|
||||
$report.CompletedUtc=[DateTime]::UtcNow.ToString('o')
|
||||
$null=Write-WelaFileSaclRecoveryArtifact $output 'result.json' (Get-WelaFileSaclRecoveryKey $report)
|
||||
$report
|
||||
}
|
||||
|
||||
function Write-WelaFileSaclRecoveryArtifact {
|
||||
param([string]$Root,[string]$Name,[string]$Text)
|
||||
$null=Resolve-WelaArrivalPath $Root
|
||||
$bytes=[Text.UTF8Encoding]::new($false).GetBytes($Text);$path=Join-Path $Root $Name
|
||||
$stream=[IO.File]::Open($path,[IO.FileMode]::CreateNew,[IO.FileAccess]::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 -ErrorAction Stop).Hash.ToLowerInvariant() -cne $hash) {throw 'Recovery artifact readback differs.'}
|
||||
[pscustomobject]@{Name=$Name;Sha256=$hash;Bytes=$bytes.Length}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Narrow leaf-file recovery: remove one proven explicit ordinary audit ACE.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
namespace Wela.FileSaclRecovery {
|
||||
public sealed class Ace { public string Binary; public int Type,Flags,Mask; public string Sid; public bool Ordinary; }
|
||||
public sealed class Snapshot {
|
||||
public string Path,Kind,Identity; public bool IsDirectory;
|
||||
public string DescriptorBase64,Owner,Group,DaclBase64; public int ControlFlags,SecurityInformation;
|
||||
public string DescriptorScope; public Ace[] Aces;
|
||||
}
|
||||
public static class Descriptor {
|
||||
public const string SourceSha256="__WELA_FILE_SACL_RECOVERY_SOURCE_SHA256__";
|
||||
public static string Bytes(GenericAcl value) { if(value==null)return null;byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); }
|
||||
public static string Bytes(GenericAce value) { byte[] b=new byte[value.BinaryLength];value.GetBinaryForm(b,0);return Convert.ToBase64String(b); }
|
||||
static string Sid(SecurityIdentifier value) {return value==null?null:value.Value;}
|
||||
public static RawSecurityDescriptor Parse(string value) {
|
||||
byte[] b=Convert.FromBase64String(value);
|
||||
if(b.Length<20||b.Length>1048576||Convert.ToBase64String(b)!=value)throw new InvalidOperationException("Invalid or noncanonical descriptor bytes.");
|
||||
RawSecurityDescriptor sd=new RawSecurityDescriptor(b,0);
|
||||
return sd;
|
||||
}
|
||||
static Dictionary<string,int> Counts(RawAcl acl) {
|
||||
Dictionary<string,int> counts=new Dictionary<string,int>(StringComparer.Ordinal);
|
||||
if(acl!=null)foreach(GenericAce ace in acl){string b=Bytes(ace);if(!counts.ContainsKey(b))counts[b]=0;counts[b]++;}
|
||||
return counts;
|
||||
}
|
||||
static void Outside(RawSecurityDescriptor before,RawSecurityDescriptor after,bool allowPresence) {
|
||||
int mask=allowPresence?~16:~0;
|
||||
if(Sid(before.Owner)!=Sid(after.Owner)||Sid(before.Group)!=Sid(after.Group)||Bytes(before.DiscretionaryAcl)!=Bytes(after.DiscretionaryAcl)||before.ResourceManagerControl!=after.ResourceManagerControl||(((int)before.ControlFlags)&mask)!=(((int)after.ControlFlags)&mask))throw new InvalidOperationException("Owner, group, DACL or preserved control/header fields differ.");
|
||||
}
|
||||
public static string AddedAce(string beforeBytes,string afterBytes,string sid,int mask,int flags) {
|
||||
if((sid!="S-1-1-0"&&sid!="S-1-5-11")||mask<=0||(flags!=64&&flags!=128&&flags!=192))throw new InvalidOperationException("Only an explicit ordinary non-inherited selected audit ACE is supported.");
|
||||
RawSecurityDescriptor before=Parse(beforeBytes),after=Parse(afterBytes);Outside(before,after,true);
|
||||
if(after.SystemAcl==null||after.SystemAcl.Revision!=(before.SystemAcl==null?2:before.SystemAcl.Revision))throw new InvalidOperationException("SACL revision changed during the claimed addition.");
|
||||
if(before.SystemAcl!=null)foreach(GenericAce entry in before.SystemAcl){CommonAce common=entry as CommonAce;if(common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit&&common.SecurityIdentifier.Value==sid&&(int)common.AceFlags==flags&&(common.AccessMask&mask)==mask)throw new InvalidOperationException("Original descriptor already covered the requested audit ACE.");}
|
||||
string added=Bytes(new CommonAce((AceFlags)flags,AceQualifier.SystemAudit,mask,new SecurityIdentifier(sid),false,null));
|
||||
Dictionary<string,int> remaining=Counts(after.SystemAcl);
|
||||
if(!remaining.ContainsKey(added)||remaining[added]!=1)throw new InvalidOperationException("Expected exactly one new matching audit ACE.");
|
||||
remaining[added]--;
|
||||
if(before.SystemAcl!=null)foreach(GenericAce entry in before.SystemAcl){string b=Bytes(entry);if(!remaining.ContainsKey(b)||remaining[b]<1)throw new InvalidOperationException("An original ACE was changed or removed.");remaining[b]--;}
|
||||
foreach(int count in remaining.Values)if(count!=0)throw new InvalidOperationException("The completed operation changed more than one audit ACE.");
|
||||
return added;
|
||||
}
|
||||
public static void Removed(string beforeBytes,string afterBytes,string added) {
|
||||
RawSecurityDescriptor before=Parse(beforeBytes),after=Parse(afterBytes);Outside(before,after,false);
|
||||
if(before.SystemAcl==null)throw new InvalidOperationException("Original SACL is absent.");
|
||||
if(after.SystemAcl==null){if(before.SystemAcl.Count!=1)throw new InvalidOperationException("A null SACL would lose unrelated audit ACEs.");}
|
||||
else if(before.SystemAcl.Revision!=after.SystemAcl.Revision)throw new InvalidOperationException("SACL revision changed during removal: "+before.SystemAcl.Revision+" to "+after.SystemAcl.Revision+" (after count "+after.SystemAcl.Count+").");
|
||||
Dictionary<string,int> expected=Counts(before.SystemAcl),actual=Counts(after.SystemAcl);
|
||||
if(!expected.ContainsKey(added)||expected[added]!=1)throw new InvalidOperationException("The selected audit ACE is no longer unique.");
|
||||
expected[added]--;
|
||||
foreach(KeyValuePair<string,int> entry in expected){int count=actual.ContainsKey(entry.Key)?actual[entry.Key]:0;if(count!=entry.Value)throw new InvalidOperationException("Unrelated audit ACEs changed during removal.");actual.Remove(entry.Key);}
|
||||
if(actual.Count!=0)throw new InvalidOperationException("Unexpected ACE appeared during removal.");
|
||||
}
|
||||
public static string SaclRepresentation(string value) {
|
||||
RawSecurityDescriptor sd=Parse(value);bool present=(sd.ControlFlags&ControlFlags.SystemAclPresent)!=0;
|
||||
if(!present)return "Absent";
|
||||
if(sd.SystemAcl==null)return "PresentNull";
|
||||
return (sd.SystemAcl.Count==0?"PresentEmpty":"PresentWithAces")+";Revision="+sd.SystemAcl.Revision;
|
||||
}
|
||||
public static Snapshot Observe(string path,string identity,byte[] bytes) {
|
||||
string encoded=Convert.ToBase64String(bytes);RawSecurityDescriptor sd=Parse(encoded);List<Ace> entries=new List<Ace>();
|
||||
if(sd.SystemAcl!=null)foreach(GenericAce ace in sd.SystemAcl){CommonAce common=ace as CommonAce;bool ordinary=common!=null&&!common.IsCallback&&common.AceType==AceType.SystemAudit;entries.Add(new Ace {Binary=Bytes(ace),Type=(int)ace.AceType,Flags=(int)ace.AceFlags,Mask=ordinary?common.AccessMask:0,Sid=ordinary?common.SecurityIdentifier.Value:null,Ordinary=ordinary});}
|
||||
return new Snapshot {Path=path,Kind="FileSystem",Identity=identity,IsDirectory=false,DescriptorBase64=encoded,Owner=Sid(sd.Owner),Group=Sid(sd.Group),DaclBase64=Bytes(sd.DiscretionaryAcl),ControlFlags=(int)sd.ControlFlags,SecurityInformation=511,DescriptorScope="WinSDK-defined sections 0x1ff; future sections unobserved",Aces=entries.ToArray()};
|
||||
}
|
||||
}
|
||||
sealed class Privilege : IDisposable {
|
||||
[StructLayout(LayoutKind.Sequential)] struct Luid {public uint Low;public int High;}
|
||||
[StructLayout(LayoutKind.Sequential)] struct TokenPrivileges {public uint Count;public Luid Luid;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 value);
|
||||
[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 disable,ref TokenPrivileges value,uint size,out TokenPrivileges previous,out uint required);
|
||||
IntPtr token;TokenPrivileges previous;
|
||||
public Privilege(){IntPtr thread;
|
||||
if(OpenThreadToken(GetCurrentThread(),8,true,out thread)){CloseHandle(thread);throw new InvalidOperationException("Impersonated recovery is 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 luid;if(!LookupPrivilegeValue(null,"SeSecurityPrivilege",out luid))throw new Win32Exception(Marshal.GetLastWin32Error());TokenPrivileges request=new TokenPrivileges {Count=1,Luid=luid,Attributes=2};uint required;bool ok=AdjustTokenPrivileges(token,false,ref request,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out previous,out required);error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege is unavailable.");}
|
||||
catch{CloseHandle(token);token=IntPtr.Zero;throw;}
|
||||
}
|
||||
public void Dispose(){if(token==IntPtr.Zero)return;try{TokenPrivileges ignored;uint required;bool ok=AdjustTokenPrivileges(token,false,ref previous,(uint)Marshal.SizeOf(typeof(TokenPrivileges)),out ignored,out required);int error=Marshal.GetLastWin32Error();if(!ok||error!=0)throw new Win32Exception(error,"SeSecurityPrivilege restoration failed.");}finally{CloseHandle(token);token=IntPtr.Zero;}}
|
||||
}
|
||||
public sealed class Target : 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)] static extern IntPtr CreateFile(string name,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 FileInfo info);
|
||||
[DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern uint GetFinalPathNameByHandle(IntPtr handle,StringBuilder path,uint size,uint flags);
|
||||
[DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value);
|
||||
[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);
|
||||
[DllImport("advapi32.dll")] static extern uint SetSecurityInfo(IntPtr handle,uint kind,uint flags,IntPtr owner,IntPtr group,IntPtr dacl,IntPtr sacl);
|
||||
readonly string path;IntPtr handle;Privilege privilege;public bool WriteAttempted {get;private set;}public Snapshot AfterObservation {get;private set;}
|
||||
public Target(string path){this.path=path;try{privilege=new Privilege();handle=CreateFile(path,0x01020000,3,IntPtr.Zero,3,0x02200000,IntPtr.Zero);if(handle==new IntPtr(-1)){int error=Marshal.GetLastWin32Error();handle=IntPtr.Zero;throw new Win32Exception(error);}Check();}catch{Dispose();throw;}}
|
||||
string Check(){if(handle==IntPtr.Zero)throw new ObjectDisposedException("Target");FileInfo info;if(!GetFileInformationByHandle(handle,out info))throw new Win32Exception(Marshal.GetLastWin32Error());if((info.Attributes&0x410)!=0)throw new InvalidOperationException("Directories and reparse files are unsupported.");StringBuilder final=new StringBuilder(32768);uint length=GetFinalPathNameByHandle(handle,final,(uint)final.Capacity,0);if(length==0||length>=final.Capacity||!String.Equals(final.ToString(),"\\\\?\\"+path,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("Final held file path differs from the reviewed path.");return info.Volume+":"+info.IndexHigh+":"+info.IndexLow+":"+info.Created;}
|
||||
public Snapshot Read(){string identity=Check();IntPtr owner,group,dacl,sacl,descriptor;uint error=GetSecurityInfo(handle,1,511,out owner,out group,out dacl,out sacl,out descriptor);if(error!=0)throw new Win32Exception((int)error,"Full SDK-defined file descriptor read failed.");byte[] bytes;try{uint size=GetSecurityDescriptorLength(descriptor);if(size<20||size>1048576)throw new InvalidOperationException("Invalid descriptor size.");bytes=new byte[size];Marshal.Copy(descriptor,bytes,0,(int)size);}finally{LocalFree(descriptor);}if(Check()!=identity)throw new InvalidOperationException("Held file identity changed.");return Descriptor.Observe(path,identity,bytes);}
|
||||
public Snapshot Remove(string expectedIdentity,string expectedDescriptor,string added){
|
||||
Snapshot before=Read();if(before.Identity!=expectedIdentity||before.DescriptorBase64!=expectedDescriptor)throw new InvalidOperationException("Reviewed file identity or descriptor changed before removal.");
|
||||
RawSecurityDescriptor sd=Descriptor.Parse(before.DescriptorBase64);int index=-1;
|
||||
if(sd.SystemAcl!=null)for(int i=0;i<sd.SystemAcl.Count;i++)if(Descriptor.Bytes(sd.SystemAcl[i])==added){if(index!=-1)throw new InvalidOperationException("Audit ACE is not unique.");index=i;}
|
||||
if(index<0)throw new InvalidOperationException("Audit ACE is absent.");CommonAce ace=sd.SystemAcl[index] as CommonAce;
|
||||
if(ace==null||ace.IsCallback||ace.AceType!=AceType.SystemAudit||((int)ace.AceFlags!=64&&(int)ace.AceFlags!=128&&(int)ace.AceFlags!=192))throw new InvalidOperationException("Only an explicit ordinary audit ACE can be removed.");
|
||||
sd.SystemAcl.RemoveAce(index);byte[] bytes=new byte[sd.SystemAcl.BinaryLength];sd.SystemAcl.GetBinaryForm(bytes,0);IntPtr buffer=Marshal.AllocHGlobal(bytes.Length);
|
||||
try{Marshal.Copy(bytes,0,buffer,bytes.Length);WriteAttempted=true;uint error=SetSecurityInfo(handle,1,8,IntPtr.Zero,IntPtr.Zero,IntPtr.Zero,buffer);if(error!=0)throw new Win32Exception((int)error,"SACL-only removal failed.");}finally{Marshal.FreeHGlobal(buffer);}
|
||||
Snapshot after=Read();AfterObservation=after;if(after.Identity!=before.Identity)throw new InvalidOperationException("File identity changed during removal.");Descriptor.Removed(before.DescriptorBase64,after.DescriptorBase64,added);return after;
|
||||
}
|
||||
public void Dispose(){try{if(handle!=IntPtr.Zero){CloseHandle(handle);handle=IntPtr.Zero;}}finally{if(privilege!=null){privilege.Dispose();privilege=null;}}}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path
|
||||
$cases=@(
|
||||
@{Args=@('file-sacl-recovery','-Help');Exit=0;Pattern='Usage: file-sacl-recovery'},
|
||||
@{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-DryRun','-Help');Exit=0;Pattern='Usage:'},
|
||||
@{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-Auto','-Help');Exit=0;Pattern='Usage:'},
|
||||
@{Args=@('file-sacl-recovery','-DryRun','-Help');Exit=1;Pattern='DryRun'},
|
||||
@{Args=@('configure','-FileSaclRecoveryAction','Restore','-Help');Exit=1;Pattern='require file-sacl-recovery'},
|
||||
@{Args=@('targeted-sacl','-FileSaclRecoveryPlanPath','unread.json','-Help');Exit=1;Pattern='require file-sacl-recovery|targeted-sacl accepts only'},
|
||||
@{Args=@('file-sacl-recovery','-TargetSaclIncludeChildren','-Help');Exit=1;Pattern='require targeted-sacl|dedicated'},
|
||||
@{Args=@('file-sacl-recovery','-Role','Client','-Help');Exit=1;Pattern='dedicated'},
|
||||
@{Args=@('file-sacl-recovery','-ResultsPath','unwritten.json','-Help');Exit=1;Pattern='dedicated'},
|
||||
@{Args=@('file-sacl-recovery','-RecoveryPlanPath','unread.json','-Help');Exit=1;Pattern='dedicated'},
|
||||
@{Args=@('file-sacl-recovery','-Auto');Exit=1;Pattern='Plan requires four original'},
|
||||
@{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-Auto');Exit=1;Pattern='Restore requires PlanPath'},
|
||||
@{Args=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-DryRun','-FileSaclRecoveryOutputPath','unwritten-directory');Exit=1;Pattern='Restore requires PlanPath'},
|
||||
@{Args=@('audit-recovery','-RecoveryAction','Restore','-DryRun','-Help');Exit=0;Pattern='Usage: audit-recovery'}
|
||||
)
|
||||
foreach($case in $cases){
|
||||
$ErrorActionPreference='Continue';try{$text=@(& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'}
|
||||
if($code -ne $case.Exit -or ($text -join "`n") -notmatch $case.Pattern){throw "Unexpected CLI result for $($case.Args -join ' '): $code / $text"}
|
||||
}
|
||||
Write-Host "File SACL recovery public CLI: $($cases.Count) checks passed."
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,106 @@
|
||||
# Actual Windows security-descriptor parsing, without file or policy mutation.
|
||||
$ErrorActionPreference='Stop'
|
||||
if ($env:OS -ne 'Windows_NT') {Write-Host 'Skipped: Windows security descriptor runtime required.';exit 0}
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
. (Join-Path $repo 'scripts/WefArrival.ps1')
|
||||
. (Join-Path $repo 'scripts/FileSaclRecovery.ps1')
|
||||
Initialize-WelaFileSaclRecoveryNative
|
||||
$script:n=0
|
||||
function Assert($Value,$Message) {if (-not $Value) {throw $Message};$script:n++}
|
||||
function Throws($Action,$Pattern) {$message='';try {& $Action | Out-Null} catch {$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, received $message"}
|
||||
function Encode($Descriptor) {$bytes=New-Object byte[] $Descriptor.BinaryLength;$Descriptor.GetBinaryForm($bytes,0);[Convert]::ToBase64String($bytes)}
|
||||
function Clone($Descriptor) {[Security.AccessControl.RawSecurityDescriptor]::new([Convert]::FromBase64String((Encode $Descriptor)),0)}
|
||||
function New-AuditAce([int]$Mask=1,[int]$Flags=64,[string]$Sid='S-1-1-0') {
|
||||
[Security.AccessControl.CommonAce]::new([Security.AccessControl.AceFlags]$Flags,[Security.AccessControl.AceQualifier]::SystemAudit,$Mask,[Security.Principal.SecurityIdentifier]::new($Sid),$false,$null)
|
||||
}
|
||||
function Add-AuditAce($Descriptor,$Ace) {
|
||||
$copy=Clone $Descriptor
|
||||
if ($null -eq $copy.SystemAcl) {$copy.SystemAcl=[Security.AccessControl.RawAcl]::new(2,1);$copy.SetFlags($copy.ControlFlags -bor [Security.AccessControl.ControlFlags]::SystemAclPresent)}
|
||||
$copy.SystemAcl.InsertAce($copy.SystemAcl.Count,$Ace)
|
||||
$copy
|
||||
}
|
||||
$base=[Security.AccessControl.RawSecurityDescriptor]::new('O:SYG:SYD:(A;;FA;;;SY)')
|
||||
$before=Encode $base
|
||||
foreach ($flags in @(64,128,192)) {
|
||||
foreach ($sid in @('S-1-1-0','S-1-5-11')) {
|
||||
$after=Add-AuditAce $base (New-AuditAce 1 $flags $sid)
|
||||
$added=[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),$sid,1,$flags)
|
||||
Assert ($added -ceq [Wela.FileSaclRecovery.Descriptor]::Bytes($after.SystemAcl[0])) 'Exactly the ordinary selected ACE is identified.'
|
||||
$empty=Clone $after;$empty.SystemAcl.RemoveAce(0)
|
||||
[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $empty),$added)
|
||||
Assert ($empty.SystemAcl.Count -eq 0 -and ($empty.ControlFlags -band 16) -ne 0 -and (Encode $empty) -cne $before) 'ACE removal preserves an empty present SACL without claiming historical representation equality.'
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),$before,$added)} 'control'
|
||||
$duplicate=Add-AuditAce $after (New-AuditAce 1 $flags $sid)
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $duplicate),$sid,1,$flags)} 'exactly one'
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $duplicate),(Encode $after),$added)} 'no longer unique'
|
||||
$unrelated=Add-AuditAce $after (New-AuditAce 2 128 'S-1-5-11')
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $unrelated),$sid,1,$flags)} 'more than one|exactly one'
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $unrelated),$added)} 'Unrelated|Unexpected'
|
||||
}
|
||||
}
|
||||
$old=Add-AuditAce $base (New-AuditAce 2 128 'S-1-5-11')
|
||||
$after=Add-AuditAce $old (New-AuditAce)
|
||||
$added=[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $after),'S-1-1-0',1,64)
|
||||
[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $old),$added)
|
||||
Assert ($old.SystemAcl.Count -eq 1) 'The original unrelated audit ACE remains after a valid removal.'
|
||||
$lost=Add-AuditAce $base (New-AuditAce)
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $lost),'S-1-1-0',1,64)} 'original ACE'
|
||||
$missing=Clone $after;$missing.SystemAcl.RemoveAce(0);$missing.SystemAcl.RemoveAce(0)
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $missing),$added)} 'Unrelated audit ACEs'
|
||||
$covering=Add-AuditAce $base (New-AuditAce 3 64)
|
||||
$redundant=Add-AuditAce $covering (New-AuditAce)
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $covering),(Encode $redundant),'S-1-1-0',1,64)} 'already covered'
|
||||
foreach ($flags in @(0,16,65,80,129,208)) {Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),'S-1-1-0',1,$flags)} 'explicit ordinary'}
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),'S-1-5-18',1,64)} 'explicit ordinary'
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $after),'S-1-1-0',0,64)} 'explicit ordinary'
|
||||
# Both historical addition and removal must preserve non-audit descriptor fields.
|
||||
foreach ($mutation in @('Owner','Group','Dacl','ControlFlags')) {
|
||||
$changed=Clone $old
|
||||
switch ($mutation) {
|
||||
Owner {$changed.Owner=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')}
|
||||
Group {$changed.Group=[Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')}
|
||||
Dacl {$changed.DiscretionaryAcl.RemoveAce(0)}
|
||||
ControlFlags {$changed.SetFlags($changed.ControlFlags -bor [Security.AccessControl.ControlFlags]::DiscretionaryAclProtected)}
|
||||
}
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $changed),$added)} 'Owner|control|header|manager'
|
||||
$withAddition=Add-AuditAce $changed (New-AuditAce)
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $withAddition),'S-1-1-0',1,64)} 'Owner|control|header|manager'
|
||||
}
|
||||
# Resource-manager control is serialized only when its valid flag is present.
|
||||
$rmBefore=Clone $old;$rmBefore.SetFlags($rmBefore.ControlFlags -bor [Security.AccessControl.ControlFlags]::RMControlValid);$rmBefore.ResourceManagerControl=1
|
||||
$rmAfter=Add-AuditAce $rmBefore (New-AuditAce)
|
||||
$rmChanged=Clone $rmBefore;$rmChanged.ResourceManagerControl=2
|
||||
Assert ((Encode $rmChanged) -cne (Encode $rmBefore)) 'RMControl fixture changes actual serialized bytes with flags unchanged.'
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $rmAfter),(Encode $rmChanged),$added)} 'control|header'
|
||||
$rmChangedAddition=Add-AuditAce $rmChanged (New-AuditAce)
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $rmBefore),(Encode $rmChangedAddition),'S-1-1-0',1,64)} 'control|header'
|
||||
# ACL revision changes cannot hide behind unchanged ACE bytes.
|
||||
$revised=Clone $old;$acl4=[Security.AccessControl.RawAcl]::new(4,$revised.SystemAcl.Count)
|
||||
foreach ($entry in $revised.SystemAcl) {$acl4.InsertAce($acl4.Count,$entry)}
|
||||
$revised.SystemAcl=$acl4;$revisedAddition=Add-AuditAce $revised (New-AuditAce)
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $old),(Encode $revisedAddition),'S-1-1-0',1,64)} 'revision'
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $revised),$added)} 'revision'
|
||||
# Duplicate unrelated entries retain their exact counts.
|
||||
$duplicateOld=Add-AuditAce $old (New-AuditAce 2 128 'S-1-5-11')
|
||||
$duplicateAfter=Add-AuditAce $duplicateOld (New-AuditAce)
|
||||
$duplicateAdded=[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $duplicateOld),(Encode $duplicateAfter),'S-1-1-0',1,64)
|
||||
[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $duplicateAfter),(Encode $duplicateOld),$duplicateAdded)
|
||||
Assert ($duplicateOld.SystemAcl.Count -eq 2) 'Duplicate unrelated ACEs are preserved.'
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $duplicateAfter),(Encode $old),$duplicateAdded)} 'Unrelated audit ACEs'
|
||||
# Windows can retain SACL_PRESENT with a null ACL after removing the sole ACE.
|
||||
$sole=Add-AuditAce $base (New-AuditAce)
|
||||
$soleAdded=[Wela.FileSaclRecovery.Descriptor]::AddedAce($before,(Encode $sole),'S-1-1-0',1,64)
|
||||
$presentNull=Clone $sole;$presentNull.SystemAcl=$null
|
||||
[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $sole),(Encode $presentNull),$soleAdded)
|
||||
Assert ([Wela.FileSaclRecovery.Descriptor]::SaclRepresentation((Encode $presentNull)) -ceq 'PresentNull') 'Sole-ACE removal can retain present-null SACL with exact control fields.'
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $after),(Encode $presentNull),$added)} 'lose unrelated'
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::Removed((Encode $sole),(Encode $presentNull),'different-ACE')} 'no longer unique'
|
||||
# Native object audit ACEs never qualify as the ordinary selected addition.
|
||||
$objectBase=Clone $base;$objectBase.SystemAcl=[Security.AccessControl.RawAcl]::new(4,0);$objectBase.SetFlags($objectBase.ControlFlags -bor [Security.AccessControl.ControlFlags]::SystemAclPresent)
|
||||
$objectAfter=Clone $objectBase
|
||||
$objectAce=[Security.AccessControl.ObjectAce]::new([Security.AccessControl.AceFlags]64,[Security.AccessControl.AceQualifier]::SystemAudit,1,[Security.Principal.SecurityIdentifier]::new('S-1-1-0'),[Security.AccessControl.ObjectAceFlags]::ObjectAceTypePresent,[guid]::NewGuid(),[guid]::Empty,$false,$null)
|
||||
$objectAfter.SystemAcl.InsertAce(0,$objectAce)
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::AddedAce((Encode $objectBase),(Encode $objectAfter),'S-1-1-0',1,64)} 'exactly one'
|
||||
Throws {[Wela.FileSaclRecovery.Descriptor]::Parse('not base64')} '.'
|
||||
$global:LASTEXITCODE=0
|
||||
Write-Host "File SACL recovery native descriptor guards: $script:n assertions passed."
|
||||
@@ -0,0 +1,37 @@
|
||||
$ErrorActionPreference='Stop'
|
||||
$root=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force
|
||||
. (Join-Path $root 'scripts/WefArrival.ps1')
|
||||
. (Join-Path $root 'scripts/EvtxRecovery.ps1')
|
||||
. (Join-Path $root 'scripts/FileSaclRecovery.ps1')
|
||||
$script:count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Throws($Action,$Pattern){$message='';try{& $Action|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"}
|
||||
Initialize-WelaFileSaclRecoveryNative
|
||||
Assert ([Wela.FileSaclRecovery.Descriptor]::SourceSha256 -ceq (Get-FileHash (Join-Path $root 'scripts/FileSaclRecoveryNative.cs')).Hash.ToLowerInvariant()) 'Compiled helper is bound to actual source bytes.'
|
||||
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-file-recovery-json-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
|
||||
try {
|
||||
$path=Join-Path $temp 'input.json'
|
||||
foreach($value in @('{"ExitCode":0}','{"text":"東京","SchemaVersion":1}')){
|
||||
[IO.File]::WriteAllText($path,$value,[Text.UTF8Encoding]::new($false))
|
||||
$input=Read-WelaFileSaclRecoveryInput $path
|
||||
Assert ($input.Sha256 -ceq (Get-FileHash $path).Hash.ToLowerInvariant() -and $input.Bytes -eq ([IO.File]::ReadAllBytes($path)).Length) 'Strict evidence reader hashes actual UTF-8 bytes.'
|
||||
}
|
||||
$zero=ConvertFrom-WelaEvtxJson '{"ExitCode":0}'
|
||||
Assert (($zero.ExitCode -is [int] -or $zero.ExitCode -is [long]) -and $zero.ExitCode -eq 0) 'Real JSON integer zero is accepted across engines.'
|
||||
foreach($invalid in @('{"a":1,"a":2}','{"x":NaN}','{"x":1,}','{"x":true} trailing','')){
|
||||
[IO.File]::WriteAllText($path,$invalid)
|
||||
Throws {Read-WelaFileSaclRecoveryInput $path} 'JSON|json|byte|Unexpected|Invalid|Duplicate|custom-profile'
|
||||
}
|
||||
[IO.File]::WriteAllBytes($path,[byte[]]@(0xc3,0x28));Throws {Read-WelaFileSaclRecoveryInput $path} 'translate|valid|Unable'
|
||||
$oversize=New-Object byte[] 4194305;[IO.File]::WriteAllBytes($path,$oversize);Throws {Read-WelaFileSaclRecoveryInput $path} 'four MiB'
|
||||
$artifact=Write-WelaFileSaclRecoveryArtifact $temp 'pending.json' '{"state":"Pending"}'
|
||||
Assert ($artifact.Bytes -gt 0 -and $artifact.Sha256 -ceq (Get-FileHash (Join-Path $temp 'pending.json')).Hash.ToLowerInvariant()) 'Durably flushed pending artifact is reopened and hashed.'
|
||||
Throws {Write-WelaFileSaclRecoveryArtifact $temp 'pending.json' '{}'} 'exists'
|
||||
foreach($arguments in @(@{},@{Action='Plan';PlanPath='x'},@{Action='Plan';Auto=$true},@{Action='Plan';DryRun=$true},@{Action='Restore'},@{Action='Restore';PlanPath='x';PlanHash=('a'*64);DryRun=$true;Auto=$true},@{Action='Restore';PlanPath='x';PlanHash=('a'*64);DryRun=$true;OutputPath='out'},@{Action='Restore';PlanPath='x';PlanHash=('a'*64);OutputPath='out'})) {
|
||||
Throws {Invoke-WelaFileSaclRecovery @arguments} 'requires'
|
||||
}
|
||||
if($env:OS -ne 'Windows_NT'){Throws {Get-WelaFileSaclRecoveryOperator} 'Windows'}
|
||||
Write-Host "PASS: $script:count file recovery source, strict input, durable output and argument assertions. Native descriptor semantics run separately on Windows."
|
||||
} finally {Remove-Item -LiteralPath $temp -Recurse -Force}
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -0,0 +1,107 @@
|
||||
param([switch]$AllowDisposableSaclWrite)
|
||||
$ErrorActionPreference='Stop'
|
||||
if(-not $AllowDisposableSaclWrite -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted'){throw 'Explicit opt-in on a disposable GitHub-hosted Windows runner is required.'}
|
||||
$root=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force
|
||||
. (Join-Path $root 'scripts/Configuration.ps1')
|
||||
$script:count=0
|
||||
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
||||
function Fingerprint($Map){(@($Map.Keys|Sort-Object|ForEach-Object{"$_=$($Map[$_])"}) -join ';')}
|
||||
function Json($Path){Get-Content -LiteralPath $Path -Raw|ConvertFrom-Json}
|
||||
function Save($Path,$Value){[IO.File]::WriteAllText($Path,($Value|ConvertTo-Json -Depth 30),[Text.UTF8Encoding]::new($false))}
|
||||
$policyBefore=Get-WelaEffectiveAuditPolicy
|
||||
$precedencePath='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa';$precedenceBefore=Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy
|
||||
$nonce=[guid]::NewGuid().ToString('N');$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-file-recovery-'+$nonce);$copy=Join-Path $temp 'checkout'
|
||||
$engine=(Get-Process -Id $PID).Path
|
||||
$script:call=0
|
||||
function Run-Wela {
|
||||
param([string[]]$Arguments,[int]$Expected=0,[string]$Pattern='')
|
||||
$script:call++;$log=Join-Path $temp ('call-'+$script:call+'.log')
|
||||
$start=[Diagnostics.ProcessStartInfo]::new();$start.FileName=$engine;$start.UseShellExecute=$false;$start.RedirectStandardOutput=$true;$start.RedirectStandardError=$true
|
||||
$all=@('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',(Join-Path $copy 'WELA.ps1'))+$Arguments
|
||||
$start.Arguments=(@($all|ForEach-Object {'"'+$_.Replace('"','\"')+'"'}) -join ' ')
|
||||
$process=[Diagnostics.Process]::new();$process.StartInfo=$start
|
||||
try {$null=$process.Start();$out=$process.StandardOutput.ReadToEndAsync();$err=$process.StandardError.ReadToEndAsync();if(-not $process.WaitForExit(180000)){$process.Kill();throw 'Public recovery fixture command timed out.'};if(-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($out,$err),10000)){throw 'Public fixture output capture timed out.'};$text=$out.Result+$err.Result;[IO.File]::WriteAllText($log,$text);Assert ($process.ExitCode -eq $Expected) "Public command failed with $($process.ExitCode), expected $Expected. $text";if($Pattern){Assert ($text -match $Pattern) "Expected diagnostic $Pattern. $text"}}finally{if($process.Id -and -not $process.HasExited){$process.Kill();$null=$process.WaitForExit(10000)};$process.Dispose()}
|
||||
}
|
||||
$completed=$false
|
||||
try {
|
||||
$null=New-Item -ItemType Directory $copy -Force
|
||||
foreach($name in @('WELA.ps1','config','scripts','modules')){Copy-Item -LiteralPath (Join-Path $root $name) -Destination $copy -Recurse}
|
||||
# Only the owned disposable checkout gets this installed one-file catalog.
|
||||
# Production command and receipt validation expose no arbitrary-target override.
|
||||
$file=Join-Path $temp 'owned.txt';[IO.File]::WriteAllText($file,'owned recovery fixture')
|
||||
$catalog=[pscustomobject]@{description='Owned disposable installed catalog';registry=@();files=@([pscustomobject]@{path=$file;inherit=$false;rights=@('ReadData');note='Owned leaf'});user_registry=@();user_files=@()}
|
||||
Save (Join-Path $copy 'config/audit_sacl_targets.json') $catalog
|
||||
Import-Module (Join-Path $copy 'modules/AuditProfiles.psm1') -Force
|
||||
. (Join-Path $copy 'scripts/ControlApplicability.ps1')
|
||||
. (Join-Path $copy 'scripts/TargetedSaclPlanning.ps1')
|
||||
. (Join-Path $copy 'scripts/SelectedSaclConfiguration.ps1')
|
||||
. (Join-Path $copy 'scripts/WefArrival.ps1')
|
||||
. (Join-Path $copy 'scripts/EvtxRecovery.ps1')
|
||||
. (Join-Path $copy 'scripts/FileSaclRecovery.ps1')
|
||||
Initialize-WelaFileSaclRecoveryNative
|
||||
Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Value 1 -Type DWord
|
||||
Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask 3 -Mode minimum
|
||||
# ASD has the same explicit opt-in file prerequisite without WEF screenshot
|
||||
# companion registry rows, so this isolated catalog can contain one file only.
|
||||
$context=Get-WelaSelectedSaclContext
|
||||
$target=@((Get-WelaSelectedSaclCatalog -Profile asd-native-2021-10 -IncludeOptional -Context $context).Rows)
|
||||
Assert ($target.Count -eq 1 -and $target[0].Definition.Path -ceq $file) 'Installed fixture catalog selects only the owned leaf.'
|
||||
$id=$target[0].Id;$definition=$target[0].Definition
|
||||
foreach($case in @('empty','unrelated')){
|
||||
$caseDir=Join-Path $temp $case;$null=New-Item -ItemType Directory $caseDir
|
||||
if($case -eq 'unrelated'){
|
||||
$beforeUnrelated=Get-WelaSelectedSaclSnapshot $definition
|
||||
$other=[pscustomobject]@{Sid='S-1-5-11';Mask=2;Flags=64;RequiredPolicyMask=1}
|
||||
$null=Write-WelaSelectedSaclNative $definition $beforeUnrelated $other
|
||||
}
|
||||
$before=Get-WelaSelectedSaclSnapshot $definition
|
||||
$original=Join-Path $caseDir 'original.json';$backup=Join-Path $caseDir 'receipts';$configured=Join-Path $caseDir 'configured.json'
|
||||
Run-Wela @('targeted-sacl','-TargetSaclAction','Plan','-TargetSaclProfile','asd-native-2021-10','-TargetSaclId',$id,'-IncludeOptional','-ResultsPath',$original)
|
||||
Run-Wela @('targeted-sacl','-TargetSaclAction','Configure','-TargetSaclPlanPath',$original,'-TargetSaclId',$id,'-IncludeOptional','-Auto','-BackupPath',$backup,'-ResultsPath',$configured)
|
||||
$completedAddition=Json $configured
|
||||
Assert ($completedAddition.Results[0].Status -ceq 'Applied' -and $completedAddition.ExitCode -eq 0) 'Original public Configure supplied genuine Applied result and receipt pair.'
|
||||
$pending=Join-Path $backup ($id+'.pending.json');$confirmed=Join-Path $backup ($id+'.confirmed.json')
|
||||
$afterAddition=Get-WelaSelectedSaclSnapshot $definition
|
||||
$planDir=Join-Path $caseDir 'recovery-plan'
|
||||
$planArgs=@('file-sacl-recovery','-FileSaclRecoveryOriginalPlanPath',$original,'-FileSaclRecoveryPendingPath',$pending,'-FileSaclRecoveryConfirmedPath',$confirmed,'-FileSaclRecoveryResultsPath',$configured)
|
||||
Run-Wela ($planArgs+@('-FileSaclRecoveryOutputPath',$planDir))
|
||||
$planPath=Join-Path $planDir 'plan.json';$hash=(Get-FileHash $planPath).Hash.ToLowerInvariant();$plan=Json $planPath
|
||||
Assert ($plan.Kind -ceq 'WelaFileSaclRecoveryPlan' -and $plan.Expected.Identity -ceq $before.Identity -and $plan.ReadyRuleCredit -eq 0) 'Recovery plan binds the original actual file identity without telemetry credit.'
|
||||
$restore=@('file-sacl-recovery','-FileSaclRecoveryAction','Restore','-FileSaclRecoveryPlanPath',$planPath,'-FileSaclRecoveryPlanHash',$hash)
|
||||
Run-Wela ($restore+@('-DryRun'))
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $afterAddition)) 'Public dry run preserves the exact current full descriptor.'
|
||||
if($case -eq 'empty'){
|
||||
$saved=[IO.File]::ReadAllBytes($confirmed);$broken=Json $confirmed;$broken.State='Pending';Save $confirmed $broken
|
||||
Run-Wela ($restore+@('-DryRun')) 1 'pending and confirmed'
|
||||
[IO.File]::WriteAllBytes($confirmed,$saved)
|
||||
$nativePath=Join-Path $copy 'scripts/FileSaclRecoveryNative.cs';$nativeBytes=[IO.File]::ReadAllBytes($nativePath);[IO.File]::AppendAllText($nativePath,"`n// owned source mismatch fixture`n")
|
||||
Run-Wela ($restore+@('-DryRun')) 1 'stale or modified'
|
||||
[IO.File]::WriteAllBytes($nativePath,$nativeBytes)
|
||||
# A different file at the identical path must not inherit recovery authority.
|
||||
$held=Join-Path $caseDir 'original-held.txt';Move-Item -LiteralPath $file -Destination $held;[IO.File]::WriteAllText($file,'replacement')
|
||||
Run-Wela ($restore+@('-DryRun')) 1 'identity or descriptor differs'
|
||||
Remove-Item -LiteralPath $file;Move-Item -LiteralPath $held -Destination $file
|
||||
Assert ((Get-WelaSelectedSaclSnapshotKey (Get-WelaSelectedSaclSnapshot $definition)) -ceq (Get-WelaSelectedSaclSnapshotKey $afterAddition)) 'Refused receipt/source/replacement cases did not alter the original descriptor.'
|
||||
}
|
||||
$out=Join-Path $caseDir 'restored'
|
||||
Run-Wela ($restore+@('-Auto','-FileSaclRecoveryOutputPath',$out))
|
||||
$result=Json (Join-Path $out 'result.json');$after=Get-WelaSelectedSaclSnapshot $definition
|
||||
Assert ($result.SaclAfter -ceq [Wela.FileSaclRecovery.Descriptor]::SaclRepresentation($after.DescriptorBase64)) 'Reported final SACL representation matches actual reopened native bytes.'
|
||||
Write-Host ("Native SACL representation: "+$result.SaclBefore+' -> '+$result.SaclAfter)
|
||||
Assert ($result.Status -ceq 'AddedAceRemoved' -and $result.WriteAttempted -and $result.ExitCode -eq 0 -and $result.PolicyChanges -eq 0) 'Public recovery performs and verifies only the proven added ACE removal.'
|
||||
[Wela.FileSaclRecovery.Descriptor]::Removed($afterAddition.DescriptorBase64,$after.DescriptorBase64,$plan.AddedAce)
|
||||
Assert ($before.Identity -ceq $after.Identity -and $before.Owner -ceq $after.Owner -and $before.Group -ceq $after.Group -and $before.DaclBase64 -ceq $after.DaclBase64 -and $before.Aces.Count -eq $after.Aces.Count) 'Actual reopened leaf preserves identity, owner/group/DACL and unrelated ACE counts.'
|
||||
foreach($artifact in $result.Artifacts){Assert ($artifact.Sha256 -ceq (Get-FileHash (Join-Path $out $artifact.Name)).Hash.ToLowerInvariant()) 'Durable review and pre-write intent artifacts retain their recorded hashes.'}
|
||||
Assert ((Json (Join-Path $out 'pending.json')).Before.DescriptorBase64 -ceq $afterAddition.DescriptorBase64) 'Pending receipt records the exact descriptor reviewed before removal.'
|
||||
Run-Wela ($restore+@('-DryRun')) 1 'identity or descriptor differs'
|
||||
Write-Host "PASS: actual public leaf recovery $case, original identity $($before.Identity), $($before.Aces.Count) unrelated ACEs preserved."
|
||||
}
|
||||
$completed=$true
|
||||
} finally {
|
||||
Set-WelaEffectiveAuditPolicy -Guid '0CCE921D-69AE-11D9-BED3-505054503030' -Mask $policyBefore['0CCE921D-69AE-11D9-BED3-505054503030'] -Mode exact
|
||||
if($precedenceBefore.ValueExists){Set-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -Type $precedenceBefore.Type -Value $precedenceBefore.Value}else{Remove-ItemProperty -LiteralPath $precedencePath -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue}
|
||||
Assert ((Fingerprint (Get-WelaEffectiveAuditPolicy)) -ceq (Fingerprint $policyBefore) -and ((Get-WelaRegistryState $precedencePath SCENoApplyLegacyAuditPolicy)|ConvertTo-Json -Compress) -ceq ($precedenceBefore|ConvertTo-Json -Compress)) 'All 59 original policy masks and typed precedence restored.'
|
||||
if($completed){Remove-Item -LiteralPath $temp -Recurse -Force;Write-Host "PASS: $script:count actual public file recovery assertions; only owned files and checkout removed."}else{Write-Host "Failed fixture evidence retained at $temp"}
|
||||
}
|
||||
$global:LASTEXITCODE=0
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
**改善:**
|
||||
|
||||
- `file-sacl-recovery` を追加しました。元の計画、変更前後のレシート、成功結果、ソースとホストのハッシュ、実際のファイル識別情報を照合し、変更されていない選択済み単一ファイルに追加した監査 ACE だけを明示的に削除できます。書き込み前の永続記録と SACL 限定の変更・再読込により、無関係な ACE のバイト列と個数、観測した他の記述子要素を保持します。空または null の SACL が存在フラグ付きで残る場合を区別し、元の記述子との完全一致、ポリシー・ディレクトリ・レジストリの復旧、Sigma の利用可能性は保証しません。(#437) (@Shirofune-Security)
|
||||
|
||||
- 明示した IPv4 リゾルバーに固定の無害な `wela-<nonce>.wela.test.` A クエリを送信し、Windows 標準イベント 3008 と照合する任意実行の `dns-client-probe` を追加しました。既定では前提条件の計画だけを行い、実行時は時間・件数を制限した証拠をハッシュ付きで保護します。Windows 設定は変更せず、元の DNS ルールのチャネル不一致と相関の限界を保持し、Sigma の評価には加算しません。Server 2022/2025 と PowerShell 5.1/7 向けに使い捨ての権威 DNS・ループバック検証を追加しました。 (#434) (@Shirofune-Security)
|
||||
|
||||
- `evtx-recovery` を強化し、実際のプライマリトークンと通常のホスト情報を使って、標準ユーザーでも既存のネイティブアーカイブを検証できるようにしました。バージョン2のレポートはファイル読取拒否と厳密なイベント復元を区別し、トークン・ログオン・変更IDと実装の指紋を照合します。元のイベント生成者と読取者を分離し、既存の権限変更やSigma評価への加算は行いません。使い捨てWindowsテストでは独立した標準ユーザーログオンで実際の拒否と4688 EVTX復元を確認し、テスト用アカウント・ファイルACL・監査設定の復元を検証します。 (#433) (@Shirofune-Security)
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Added opt-in `file-sacl-recovery` to review and remove one proven explicit audit ACE from an unchanged selected leaf file. Original plans, paired receipts, successful results, source/host hashes and held file identity bind the operation; durable pre-write evidence and SACL-only readback preserve unrelated ACE bytes/counts and observed descriptor components. Empty or null present SACLs are reported without claiming exact historical descriptor restoration; policy, directory/registry recovery and Sigma readiness remain outside this scope. (#437) (@Shirofune-Security)
|
||||
|
||||
- Added opt-in `dns-client-probe` for one fixed benign `wela-<nonce>.wela.test.` A lookup to an explicitly selected IPv4 resolver, with default prerequisite planning, bounded native DNS execution and exact local event 3008 correlation. Private hashed evidence preserves native status, context and correlation limits without changing Windows settings. The original DNS rule/channel mismatch remains explicit and Sigma credit stays zero. Added disposable authoritative-loopback DNS acceptance tests for Server 2022/2025 and PowerShell 5.1/7. (#434) (@Shirofune-Security)
|
||||
|
||||
- Strengthened `evtx-recovery` with actual primary-token and ordinary host observations so intended standard users can verify existing native archives. Version 2 reports distinguish file-open denial from exact native event recovery, pin token/logon/modification and implementation identities, and retain independent producer/reader evidence without changing existing permissions or granting Sigma credit. Disposable Windows tests use fresh owned standard-user sessions for real denial and exact 4688 EVTX recovery, with verified account, file-ACL and policy cleanup. (#433) (@Shirofune-Security)
|
||||
|
||||
Reference in new issue
Block a user